import doctrine from 'doctrine-temporary-fork';
import parseMarkdown from './remark-parse.js';

/**
 * Flatteners: these methods simplify the structure of JSDoc comments
 * into a flat object structure, parsing markdown and extracting
 * information where appropriate.
 * @private
 */
const flatteners = {
  abstract: flattenBoolean,
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @param {Object} tag the tag
   * @returns {undefined} has side-effects
   */
  access(result, tag) {
    // doctrine ensures that tag.access is valid
    result.access = tag.access;
  },
  alias: flattenName,
  arg: synonym('param'),
  argument: synonym('param'),
  async: flattenBoolean,
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @param {Object} tag the tag
   * @returns {undefined} has side-effects
   */
  augments(result, tag) {
    // Google variation of augments/extends tag:
    // uses type with brackets instead of name.
    // https://github.com/google/closure-library/issues/746
    if (!tag.name && tag.type && tag.type.name) {
      tag.name = tag.type.name;
    }
    if (!tag.name) {
      console.error('@extends from complex types is not supported yet'); // eslint-disable-line no-console
      return;
    }
    result.augments.push(tag);
  },
  author: flattenDescription,
  borrows: todo,
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @param {Object} tag the tag
   * @returns {undefined} has side-effects
   */
  callback(result, tag) {
    result.kind = 'typedef';

    if (tag.description) {
      result.name = tag.description;
    }

    result.type = {
      type: 'NameExpression',
      name: 'Function'
    };
  },
  class: flattenKindShorthand,
  classdesc: flattenMarkdownDescription,
  const: synonym('constant'),
  constant: flattenKindShorthand,
  constructor: synonym('class'),
  constructs: todo,
  copyright: flattenMarkdownDescription,
  default: todo,
  defaultvalue: synonym('default'),
  deprecated(result, tag) {
    const description = tag.description || 'This is deprecated.';
    result.deprecated = parseMarkdown(description);
  },
  flattenMarkdownDescription,
  desc: synonym('description'),
  description: flattenMarkdownDescription,
  emits: synonym('fires'),
  enum(result, tag) {
    result.kind = 'enum';
    result.type = tag.type;
  },
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @param {Object} tag the tag
   * @returns {undefined} has side-effects
   */
  event(result, tag) {
    result.kind = 'event';

    if (tag.description) {
      result.name = tag.description;
    }
  },
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @param {Object} tag the tag
   * @returns {undefined} has side-effects
   */
  example(result, tag) {
    if (!tag.description) {
      result.errors.push({
        message: '@example without code',
        commentLineNumber: tag.lineNumber
      });
      return;
    }

    const example = {
      description: tag.description
    };

    if (tag.caption) {
      example.caption = parseMarkdown(tag.caption);
    }

    result.examples.push(example);
  },
  exception: synonym('throws'),
  exports: todo,
  extends: synonym('augments'),
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @param {Object} tag the tag
   * @returns {undefined} has side-effects
   */
  external(result, tag) {
    result.kind = 'external';

    if (tag.description) {
      result.name = tag.description;
    }
  },
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @param {Object} tag the tag
   * @returns {undefined} has side-effects
   */
  file(result, tag) {
    result.kind = 'file';

    if (tag.description) {
      result.description = parseMarkdown(tag.description);
    }
  },
  fileoverview: synonym('file'),
  fires: todo,
  func: synonym('function'),
  function: flattenKindShorthand,
  generator: flattenBoolean,
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @returns {undefined} has side-effects
   */
  global(result) {
    result.scope = 'global';
  },
  hideconstructor: flattenBoolean,
  host: synonym('external'),
  ignore: flattenBoolean,
  implements(result, tag) {
    // Match @extends/@augments above.
    if (!tag.name && tag.type && tag.type.name) {
      tag.name = tag.type.name;
    }

    result.implements.push(tag);
  },
  inheritdoc: todo,
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @returns {undefined} has side-effects
   */
  inner(result) {
    result.scope = 'inner';
  },
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @returns {undefined} has side-effects
   */
  instance(result) {
    result.scope = 'instance';
  },
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @param {Object} tag the tag
   * @returns {undefined} has side-effects
   */
  interface(result, tag) {
    result.kind = 'interface';
    if (tag.description) {
      result.name = tag.description;
    }
  },
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @param {Object} tag the tag
   * @returns {undefined} has side-effects
   */
  kind(result, tag) {
    // doctrine ensures that tag.kind is valid
    result.kind = tag.kind;
  },
  lends: flattenDescription,
  license: flattenDescription,
  listens: todo,
  member: flattenKindShorthand,
  memberof: flattenDescription,
  method: synonym('function'),
  mixes: todo,
  mixin: flattenKindShorthand,
  module: flattenKindShorthand,
  name: flattenName,
  namespace: flattenKindShorthand,
  override: flattenBoolean,
  overview: synonym('file'),
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @param {Object} tag the tag
   * @returns {undefined} has side-effects
   */
  param(result, tag) {
    const param = {
      title: 'param',
      name: tag.name,
      lineNumber: tag.lineNumber // TODO: remove
    };

    if (tag.description) {
      param.description = parseMarkdown(tag.description);
    }

    if (tag.type) {
      param.type = tag.type;
    }

    if (tag.default) {
      param.default = tag.default;
      if (param.type && param.type.type === 'OptionalType') {
        param.type = param.type.expression;
      }
    }

    result.params.push(param);
  },
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @returns {undefined} has side-effects
   */
  private(result) {
    result.access = 'private';
  },
  prop: synonym('property'),
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @param {Object} tag the tag
   * @returns {undefined} has side-effects
   */
  property(result, tag) {
    const property = {
      title: 'property',
      name: tag.name,
      lineNumber: tag.lineNumber // TODO: remove
    };

    if (tag.description) {
      property.description = parseMarkdown(tag.description);
    }

    if (tag.type) {
      property.type = tag.type;
    }

    result.properties.push(property);
  },
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @returns {undefined} has side-effects
   */
  protected(result) {
    result.access = 'protected';
  },
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @returns {undefined} has side-effects
   */
  public(result) {
    result.access = 'public';
  },
  readonly: flattenBoolean,
  requires: todo,
  return: synonym('returns'),
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @param {Object} tag the tag
   * @returns {undefined} has side-effects
   */
  returns(result, tag) {
    const returns = {
      description: parseMarkdown(tag.description),
      title: 'returns'
    };

    if (tag.type) {
      returns.type = tag.type;
    }

    result.returns.push(returns);
  },
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @param {Object} tag the tag
   * @returns {undefined} has side-effects
   */
  see(result, tag) {
    const sees = {
      description: parseMarkdown(tag.description),
      title: 'sees'
    };

    if (tag.type) {
      sees.type = tag.type;
    }

    result.sees.push(sees);
  },
  since: flattenDescription,
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @returns {undefined} has side-effects
   */
  static(result) {
    result.scope = 'static';
  },
  summary: flattenMarkdownDescription,
  this: todo,
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @param {Object} tag the tag
   * @returns {undefined} has side-effects
   */
  throws(result, tag) {
    const throws = {};

    if (tag.description) {
      throws.description = parseMarkdown(tag.description);
    }

    if (tag.type) {
      throws.type = tag.type;
    }

    result.throws.push(throws);
  },
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @param {Object} tag the tag
   * @returns {undefined} has side-effects
   */
  todo(result, tag) {
    result.todos.push(parseMarkdown(tag.description));
  },
  tutorial: todo,
  type(result, tag) {
    result.type = tag.type;
  },
  typedef: flattenKindShorthand,
  var: synonym('member'),
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @param {Object} tag the tag
   * @returns {undefined} has side-effects
   */
  variation(result, tag) {
    result.variation = tag.variation;
  },
  version: flattenDescription,
  virtual: synonym('abstract'),
  yield: synonym('yields'),
  /**
   * Parse tag
   * @private
   * @param {Object} result target comment
   * @param {Object} tag the tag
   * @returns {undefined} has side-effects
   */
  yields(result, tag) {
    const yields = {
      description: parseMarkdown(tag.description),
      title: 'yields'
    };

    if (tag.type) {
      yields.type = tag.type;
    }

    result.yields.push(yields);
  }
};

/**
 * A no-op function for unsupported tags
 * @returns {undefined} does nothing
 */
function todo() {}

/**
 * Generate a function that curries a destination key for a flattener
 * @private
 * @param {string} key the eventual destination key
 * @returns {Function} a flattener that remembers that key
 */
function synonym(key) {
  return function (result, tag) {
    const fun = flatteners[key];
    fun.apply(null, [result, tag, key].slice(0, fun.length));
  };
}

/**
 * Treat the existence of a tag as a sign to mark `key` as true in the result
 * @private
 * @param {Object} result the documentation object
 * @param {Object} tag the tag object, with a name property
 * @param {string} key destination on the result
 * @returns {undefined} operates with side-effects
 */
function flattenBoolean(result, tag, key) {
  result[key] = true;
}

/**
 * Flatten a usable-once name tag into a key
 * @private
 * @param {Object} result the documentation object
 * @param {Object} tag the tag object, with a name property
 * @param {string} key destination on the result
 * @returns {undefined} operates with side-effects
 */
function flattenName(result, tag, key) {
  result[key] = tag.name;
}

/**
 * Flatten a usable-once description tag into a key
 * @private
 * @param {Object} result the documentation object
 * @param {Object} tag the tag object, with a description property
 * @param {string} key destination on the result
 * @returns {undefined} operates with side-effects
 */
function flattenDescription(result, tag, key) {
  result[key] = tag.description;
}

/**
 * Flatten a usable-once description tag into a key and parse it as Markdown
 * @private
 * @param {Object} result the documentation object
 * @param {Object} tag the tag object, with a description property
 * @param {string} key destination on the result
 * @returns {undefined} operates with side-effects
 */
function flattenMarkdownDescription(result, tag, key) {
  result[key] = parseMarkdown(tag.description);
}

/**
 * Parse [kind shorthand](http://usejsdoc.org/tags-kind.html) into
 * both name and type tags, like `@class [<type> <name>]`
 *
 * @param {Object} result comment
 * @param {Object} tag parsed tag
 * @param {string} key tag
 * @returns {undefined} operates through side effects
 * @private
 */
function flattenKindShorthand(result, tag, key) {
  result.kind = key;

  if (tag.name) {
    result.name = tag.name;
  }

  if (tag.type) {
    result.type = tag.type;
  }
}

/**
 * Parse a comment with doctrine, decorate the result with file position and code
 * context, handle parsing errors, and fix up various infelicities in the structure
 * outputted by doctrine.
 *
 * The following tags are treated as synonyms for a canonical tag:
 *
 *  * `@virtual` ⇢ `@abstract`
 *  * `@extends` ⇢ `@augments`
 *  * `@constructor` ⇢ `@class`
 *  * `@const` ⇢ `@constant`
 *  * `@defaultvalue` ⇢ `@default`
 *  * `@desc` ⇢ `@description`
 *  * `@host` ⇢ `@external`
 *  * `@fileoverview`, `@overview` ⇢ `@file`
 *  * `@emits` ⇢ `@fires`
 *  * `@func`, `@method` ⇢ `@function`
 *  * `@var` ⇢ `@member`
 *  * `@arg`, `@argument` ⇢ `@param`
 *  * `@prop` ⇢ `@property`
 *  * `@return` ⇢ `@returns`
 *  * `@exception` ⇢ `@throws`
 *  * `@linkcode`, `@linkplain` ⇢ `@link`
 *
 * The following tags are assumed to be singletons, and are flattened
 * to a top-level property on the result whose value is extracted from
 * the tag:
 *
 *  * `@name`
 *  * `@memberof`
 *  * `@classdesc`
 *  * `@kind`
 *  * `@class`
 *  * `@constant`
 *  * `@event`
 *  * `@external`
 *  * `@file`
 *  * `@function`
 *  * `@member`
 *  * `@mixin`
 *  * `@module`
 *  * `@namespace`
 *  * `@typedef`
 *  * `@access`
 *  * `@lends`
 *  * `@description`
 *  * `@summary`
 *  * `@copyright`
 *  * `@deprecated`
 *
 * The following tags are flattened to a top-level array-valued property:
 *
 *  * `@param` (to `params` property)
 *  * `@property` (to `properties` property)
 *  * `@returns` (to `returns` property)
 *  * `@augments` (to `augments` property)
 *  * `@example` (to `examples` property)
 *  * `@throws` (to `throws` property)
 *  * `@see` (to `sees` property)
 *  * `@todo` (to `todos` property)
 *
 * The `@global`, `@static`, `@instance`, and `@inner` tags are flattened
 * to a `scope` property whose value is `"global"`, `"static"`, `"instance"`,
 * or `"inner"`.
 *
 * The `@access`, `@public`, `@protected`, and `@private` tags are flattened
 * to an `access` property whose value is `"protected"` or `"private"`.
 * The assumed default value is `"public"`, so `@access public` or `@public`
 * tags result in no `access` property.
 *
 * @param {string} comment input to be parsed
 * @param {Object} loc location of the input
 * @param {Object} context code context of the input
 * @returns {Comment} an object conforming to the
 * [documentation schema](https://github.com/documentationjs/api-json)
 */
export default function parseJSDoc(comment, loc, context) {
  const result = doctrine.parse(comment, {
    // have doctrine itself remove the comment asterisks from content
    unwrap: true,
    // enable parsing of optional parameters in brackets, JSDoc3 style
    sloppy: true,
    // `recoverable: true` is the only way to get error information out
    recoverable: true,
    // include line numbers
    lineNumbers: true
  });

  result.loc = loc;
  result.context = context;

  result.augments = [];
  result.errors = [];
  result.examples = [];
  result.implements = [];
  result.params = [];
  result.properties = [];
  result.returns = [];
  result.sees = [];
  result.throws = [];
  result.todos = [];
  result.yields = [];

  if (result.description) {
    result.description = parseMarkdown(result.description);
  }

  // Reject parameter tags without a parameter name
  result.tags.filter(function (tag) {
    if (tag.title === 'param' && tag.name === undefined) {
      result.errors.push({
        message: 'A @param tag without a parameter name was rejected'
      });
      return false;
    }
    return true;
  });

  result.tags.forEach(function (tag) {
    if (tag.errors) {
      for (let j = 0; j < tag.errors.length; j++) {
        result.errors.push({ message: tag.errors[j] });
      }
    } else if (flatteners[tag.title]) {
      flatteners[tag.title](result, tag, tag.title);
    } else {
      result.errors.push({
        message: 'unknown tag @' + tag.title,
        commentLineNumber: tag.lineNumber
      });
    }
  });

  // Using the @name tag, or any other tag that sets the name of a comment,
  // disconnects the comment from its surrounding code.
  if (context && result.name) {
    delete context.ast;
  }

  return result;
}
