/**
 * @module jsdoc/src/handlers
 */
const { Doclet } = require('jsdoc/doclet');
const escape = require('escape-string-regexp');
const { log } = require('@jsdoc/util');
const { SCOPE } = require('@jsdoc/core').name;
const { Syntax } = require('@jsdoc/parse');

let currentModule = null;

class CurrentModule {
  constructor(doclet) {
    this.doclet = doclet;
    this.longname = doclet.longname;
    this.originalName = doclet.meta.code.name || '';
  }
}
function filterByLongname({ longname }) {
  // you can't document prototypes
  if (/#$/.test(longname)) {
    return true;
  }

  return false;
}

function createDoclet(comment, e, deps) {
  let doclet;
  let flatComment;
  let msg;

  try {
    doclet = new Doclet(comment, e, deps);
  } catch (error) {
    flatComment = comment.replace(/[\r\n]/g, '');
    msg = `cannot create a doclet for the comment "${flatComment}": ${error.message}`;
    log.error(msg);
    doclet = new Doclet('', e, deps);
  }

  return doclet;
}

/**
 * Create a doclet for a `symbolFound` event. The doclet represents an actual symbol that is defined
 * in the code.
 *
 * Here's why this function is useful. A JSDoc comment can define a symbol name by including:
 *
 * + A `@name` tag
 * + Another tag that accepts a name, such as `@function`
 *
 * When the JSDoc comment defines a symbol name, we treat it as a "virtual comment" for a symbol
 * that isn't actually present in the code. And if a virtual comment is attached to a symbol, it's
 * possible that the comment and symbol have nothing to do with one another.
 *
 * To handle this case, this function checks the new doclet to see if we've already added a name
 * property by parsing the JSDoc comment. If so, this method creates a replacement doclet that
 * ignores the attached JSDoc comment and only looks at the code.
 *
 * @private
 */
function createSymbolDoclet(comment, e, deps) {
  let doclet = createDoclet(comment, e, deps);

  if (doclet.name) {
    // try again, without the comment
    e.comment = '@undocumented';
    doclet = createDoclet(e.comment, e, deps);
  }

  return doclet;
}

function setCurrentModule(doclet) {
  if (doclet.kind === 'module') {
    currentModule = new CurrentModule(doclet);
  }
}

function setModuleScopeMemberOf(parser, doclet) {
  let parentDoclet;
  let skipMemberof;

  // handle module symbols that are _not_ assigned to module.exports
  if (currentModule && currentModule.longname !== doclet.name) {
    if (!doclet.scope) {
      // is this a method definition? if so, we usually get the scope from the node directly
      if (
        doclet.meta &&
        doclet.meta.code &&
        doclet.meta.code.node &&
        doclet.meta.code.node.type === Syntax.MethodDefinition
      ) {
        // special case for constructors of classes that have @alias tags
        if (doclet.meta.code.node.kind === 'constructor') {
          parentDoclet = parser._getDocletById(doclet.meta.code.node.parent.parent.nodeId);

          if (parentDoclet && parentDoclet.alias) {
            // the constructor should use the same name as the class
            doclet.addTag('alias', parentDoclet.alias);
            doclet.addTag('name', parentDoclet.alias);

            // and we shouldn't try to set a memberof value
            skipMemberof = true;
          }
        } else if (doclet.meta.code.node.static) {
          doclet.addTag('static');
        } else {
          doclet.addTag('instance');
        }
      }
      // is this something that the module exports? if so, it's a static member
      else if (
        doclet.meta &&
        doclet.meta.code &&
        doclet.meta.code.node &&
        doclet.meta.code.node.parent &&
        doclet.meta.code.node.parent.type === Syntax.ExportNamedDeclaration
      ) {
        doclet.addTag('static');
      }
      // otherwise, it must be an inner member
      else {
        doclet.addTag('inner');
      }
    }

    // if the doclet isn't a memberof anything yet, and it's not a global, it must be a memberof
    // the current module (unless we were told to skip adding memberof)
    if (!doclet.memberof && doclet.scope !== SCOPE.NAMES.GLOBAL && !skipMemberof) {
      doclet.addTag('memberof', currentModule.longname);
    }
  }
}

function setDefaultScope(doclet) {
  // module doclets don't get a default scope
  if (!doclet.scope && doclet.kind !== 'module') {
    doclet.setScope(SCOPE.NAMES.GLOBAL);
  }
}

function addDoclet(parser, newDoclet) {
  let e;

  if (newDoclet) {
    setCurrentModule(newDoclet);
    e = { doclet: newDoclet };
    parser.emit('newDoclet', e);

    if (!e.defaultPrevented && !filterByLongname(e.doclet)) {
      parser.addResult(e.doclet);
    }
  }
}

function processAlias(parser, doclet, astNode) {
  let memberofName;

  if (doclet.alias === '{@thisClass}') {
    memberofName = parser.resolveThis(astNode);

    // "class" refers to the owner of the prototype, not the prototype itself
    if (/^(.+?)(\.prototype|#)$/.test(memberofName)) {
      memberofName = RegExp.$1;
    }
    doclet.alias = memberofName;
  }

  doclet.addTag('name', doclet.alias);
  doclet.postProcess();
}

// TODO: separate code that resolves `this` from code that resolves the module object
function findSymbolMemberof(parser, doclet, astNode, nameStartsWith, trailingPunc) {
  let memberof = '';
  let nameAndPunc;
  let scopePunc = '';

  // handle computed properties like foo['bar']
  if (trailingPunc === '[') {
    // we don't know yet whether the symbol is a static or instance member
    trailingPunc = null;
  }

  nameAndPunc = nameStartsWith + (trailingPunc || '');

  // remove stuff that indicates module membership (but don't touch the name `module.exports`,
  // which identifies the module object itself)
  if (doclet.name !== 'module.exports') {
    doclet.name = doclet.name.replace(nameAndPunc, '');
  }

  // like `bar` in:
  //   exports.bar = 1;
  //   module.exports.bar = 1;
  //   module.exports = MyModuleObject; MyModuleObject.bar = 1;
  if (nameStartsWith !== 'this' && currentModule && doclet.name !== 'module.exports') {
    memberof = currentModule.longname;
    scopePunc = SCOPE.PUNC.STATIC;
  }
  // like: module.exports = 1;
  else if (doclet.name === 'module.exports' && currentModule) {
    doclet.addTag('name', currentModule.longname);
    doclet.postProcess();
  } else {
    memberof = parser.resolveThis(astNode);

    // like the following at the top level of a module:
    //   this.foo = 1;
    if (nameStartsWith === 'this' && currentModule && !memberof) {
      memberof = currentModule.longname;
      scopePunc = SCOPE.PUNC.STATIC;
    } else {
      scopePunc = SCOPE.PUNC.INSTANCE;
    }
  }

  return {
    memberof: memberof,
    scopePunc: scopePunc,
  };
}

function addSymbolMemberof(parser, doclet, astNode) {
  let basename;
  let memberof;
  let memberofInfo;
  let moduleOriginalName = '';
  let resolveTargetRegExp;
  let scopePunc;
  let unresolved;

  if (!astNode) {
    return;
  }

  // check to see if the doclet name is an unresolved reference to the module object, or to `this`
  // TODO: handle cases where the module object is shadowed in the current scope
  if (currentModule) {
    moduleOriginalName = `|${currentModule.originalName}`;
  }
  resolveTargetRegExp = new RegExp(`^((?:module.)?exports|this${moduleOriginalName})(\\.|\\[|$)`);
  unresolved = resolveTargetRegExp.exec(doclet.name);

  if (unresolved) {
    memberofInfo = findSymbolMemberof(parser, doclet, astNode, unresolved[1], unresolved[2]);
    memberof = memberofInfo.memberof;
    scopePunc = memberofInfo.scopePunc;

    if (memberof) {
      doclet.name = doclet.name ? memberof + scopePunc + doclet.name : memberof;
    }
  } else {
    memberofInfo = parser.astnodeToMemberof(astNode);
    basename = memberofInfo.basename;
    memberof = memberofInfo.memberof;
  }

  // if we found a memberof name, apply it to the doclet
  if (memberof) {
    doclet.addTag('memberof', memberof);
    if (basename) {
      doclet.name = (doclet.name || '').replace(new RegExp(`^${escape(basename)}.`), '');
    }
  }
  // otherwise, add the defaults for a module (if we're currently in a module)
  else {
    setModuleScopeMemberOf(parser, doclet);
  }
}

function newSymbolDoclet(parser, docletSrc, e) {
  const newDoclet = createSymbolDoclet(docletSrc, e, parser.dependencies);

  // if there's an alias, use that as the symbol name
  if (newDoclet.alias) {
    processAlias(parser, newDoclet, e.astnode);
  }
  // otherwise, get the symbol name from the code
  else if (e.code && typeof e.code.name !== 'undefined' && e.code.name !== '') {
    newDoclet.addTag('name', e.code.name);
    if (!newDoclet.memberof) {
      addSymbolMemberof(parser, newDoclet, e.astnode);
    }

    newDoclet.postProcess();
  } else {
    return false;
  }

  // set the scope to global unless any of the following are true:
  // a) the doclet is a memberof something
  // b) the doclet represents a module
  // c) we're in a module that exports only this symbol
  if (
    !newDoclet.memberof &&
    newDoclet.kind !== 'module' &&
    (!currentModule || currentModule.longname !== newDoclet.name)
  ) {
    newDoclet.scope = SCOPE.NAMES.GLOBAL;
  }

  // handle cases where the doclet kind is auto-detected from the node type
  if (e.code.kind && newDoclet.kind === 'member') {
    newDoclet.kind = e.code.kind;
  }

  addDoclet(parser, newDoclet);
  e.doclet = newDoclet;

  return true;
}

/**
 * Attach these event handlers to a particular instance of a parser.
 * @param parser
 */
exports.attachTo = (parser) => {
  // Handle JSDoc "virtual comments" that include one of the following:
  // + A `@name` tag
  // + Another tag that accepts a name, such as `@function`
  parser.on('jsdocCommentFound', (e) => {
    const comments = e.comment.split(/@also\b/g);
    let newDoclet;

    for (let i = 0, l = comments.length; i < l; i++) {
      newDoclet = createDoclet(comments[i], e, parser.dependencies);

      // we're only interested in virtual comments here
      if (!newDoclet.name) {
        continue;
      }

      // add the default scope/memberof for a module (if we're in a module)
      setModuleScopeMemberOf(parser, newDoclet);
      newDoclet.postProcess();

      // if we _still_ don't have a scope, use the default
      setDefaultScope(newDoclet);

      addDoclet(parser, newDoclet);

      e.doclet = newDoclet;
    }
  });

  // Handle named symbols in the code. May or may not have a JSDoc comment attached.
  parser.on('symbolFound', (e) => {
    const comments = e.comment.split(/@also\b/g);

    for (let i = 0, l = comments.length; i < l; i++) {
      newSymbolDoclet(parser, comments[i], e);
    }
  });

  parser.on('fileComplete', () => {
    currentModule = null;
  });
};
