// TODO: docs
/** @module @jsdoc/parse.astNode */
const _ = require('lodash');
const { cast } = require('@jsdoc/util');
const { SCOPE } = require('@jsdoc/core').name;
const { Syntax } = require('./syntax');

// Counter for generating unique node IDs.
let uid = 100000000;

/**
 * Check whether an AST node represents a function.
 *
 * @alias module:@jsdoc/parse.astNode.isFunction
 * @param {(Object|string)} node - The AST node to check, or the `type` property of a node.
 * @return {boolean} Set to `true` if the node is a function or `false` in all other cases.
 */
const isFunction = (exports.isFunction = (node) => {
  let type;

  if (!node) {
    return false;
  }

  if (typeof node === 'string') {
    type = node;
  } else {
    type = node.type;
  }

  return (
    type === Syntax.FunctionDeclaration ||
    type === Syntax.FunctionExpression ||
    type === Syntax.MethodDefinition ||
    type === Syntax.ArrowFunctionExpression
  );
});

/**
 * Check whether an AST node creates a new scope.
 *
 * @alias module:@jsdoc/parse.astNode.isScope
 * @param {Object} node - The AST node to check.
 * @return {Boolean} Set to `true` if the node creates a new scope, or `false` in all other cases.
 */
exports.isScope = (
  node // TODO: handle blocks with "let" declarations
) =>
  Boolean(node) &&
  typeof node === 'object' &&
  (node.type === Syntax.CatchClause ||
    node.type === Syntax.ClassDeclaration ||
    node.type === Syntax.ClassExpression ||
    isFunction(node));

// TODO: docs
exports.addNodeProperties = (node) => {
  const newProperties = {};

  if (!node || typeof node !== 'object') {
    return null;
  }

  if (!node.nodeId) {
    newProperties.nodeId = {
      value: `astnode${uid++}`,
      enumerable: true,
    };
  }

  if (_.isUndefined(node.parent)) {
    newProperties.parent = {
      // `null` means 'no parent', so use `undefined` for now
      value: undefined,
      writable: true,
    };
  }

  if (_.isUndefined(node.enclosingScope)) {
    newProperties.enclosingScope = {
      // `null` means 'no enclosing scope', so use `undefined` for now
      value: undefined,
      writable: true,
    };
  }

  if (_.isUndefined(node.parentId)) {
    newProperties.parentId = {
      enumerable: true,
      get() {
        return this.parent ? this.parent.nodeId : null;
      },
    };
  }

  if (_.isUndefined(node.enclosingScopeId)) {
    newProperties.enclosingScopeId = {
      enumerable: true,
      get() {
        return this.enclosingScope ? this.enclosingScope.nodeId : null;
      },
    };
  }

  Object.defineProperties(node, newProperties);

  return node;
};

// TODO: docs
const nodeToValue = (exports.nodeToValue = (node) => {
  let key;
  let parent;
  let str;
  let tempObject;

  switch (node.type) {
    case Syntax.ArrayExpression:
      tempObject = [];
      node.elements.forEach((el, i) => {
        // handle sparse arrays. use `null` to represent missing values, consistent with
        // JSON.stringify([,]).
        if (!el) {
          tempObject[i] = null;
        } else {
          tempObject[i] = nodeToValue(el);
        }
      });

      str = JSON.stringify(tempObject);
      break;

    case Syntax.AssignmentExpression:
    // falls through

    case Syntax.AssignmentPattern:
      str = nodeToValue(node.left);
      break;

    case Syntax.BigIntLiteral:
      str = node.value;
      break;

    case Syntax.ClassDeclaration:
      str = nodeToValue(node.id);
      break;

    case Syntax.ClassPrivateProperty:
      // TODO: Strictly speaking, the name should be '#' plus node.key, but because we
      // already use '#' as scope punctuation, that causes JSDoc to get extremely confused.
      // The solution probably involves quoting part or all of the name, but JSDoc doesn't
      // deal with quoted names very nicely right now, and most people probably won't want to
      // document class private properties anyhow. So for now, we'll just cheat and omit the
      // leading '#'.
      str = nodeToValue(node.key.id);
      break;

    case Syntax.ClassProperty:
      str = nodeToValue(node.key);
      break;

    case Syntax.ExportAllDeclaration:
    // falls through

    case Syntax.ExportDefaultDeclaration:
      str = 'module.exports';
      break;

    case Syntax.ExportNamedDeclaration:
      if (node.declaration) {
        // like `var` in: export var foo = 'bar';
        // we need a single value, so we use the first variable name
        if (node.declaration.declarations) {
          str = `exports.${nodeToValue(node.declaration.declarations[0])}`;
        } else {
          str = `exports.${nodeToValue(node.declaration)}`;
        }
      }

      // otherwise we'll use the ExportSpecifier nodes
      break;

    case Syntax.ExportSpecifier:
      str = `exports.${nodeToValue(node.exported)}`;
      break;

    case Syntax.ArrowFunctionExpression:
    // falls through

    case Syntax.FunctionDeclaration:
    // falls through

    case Syntax.FunctionExpression:
      if (node.id && node.id.name) {
        str = node.id.name;
      }
      break;

    case Syntax.Identifier:
      str = node.name;
      break;

    case Syntax.Literal:
      str = node.value;
      break;

    case Syntax.MemberExpression:
      // could be computed (like foo['bar']) or not (like foo.bar)
      str = nodeToValue(node.object);
      if (node.computed) {
        str += `[${node.property.raw}]`;
      } else {
        str += `.${nodeToValue(node.property)}`;
      }
      break;

    case Syntax.MethodDefinition:
      parent = node.parent.parent;
      // for class expressions, we want the name of the variable the class is assigned to
      // (but there won't be a name if the class is returned by an arrow function expression)
      // TODO: we should use `LONGNAMES.ANONYMOUS` instead of an empty string, but that
      // causes problems downstream if the parent class has an `@alias` tag
      if (parent.type === Syntax.ClassExpression) {
        str = nodeToValue(parent.parent) || '';
      }
      // for the constructor of a module's default export, use a special name
      else if (
        node.kind === 'constructor' &&
        parent.parent &&
        parent.parent.type === Syntax.ExportDefaultDeclaration
      ) {
        str = 'module.exports';
      }
      // for the constructor of a module's named export, use the name of the export
      // declaration
      else if (
        node.kind === 'constructor' &&
        parent.parent &&
        parent.parent.type === Syntax.ExportNamedDeclaration
      ) {
        str = nodeToValue(parent.parent);
      }
      // for other constructors, use the name of the parent class
      else if (node.kind === 'constructor') {
        str = nodeToValue(parent);
      }
      // if the method is a member of a module's default export, ignore the name, because it's
      // irrelevant
      else if (parent.parent && parent.parent.type === Syntax.ExportDefaultDeclaration) {
        str = '';
      }
      // otherwise, use the class's name
      else {
        str = parent.id ? nodeToValue(parent.id) : '';
      }

      if (node.kind !== 'constructor') {
        if (str) {
          str += node.static ? SCOPE.PUNC.STATIC : SCOPE.PUNC.INSTANCE;
        }
        str += nodeToValue(node.key);
      }
      break;

    case Syntax.ObjectExpression:
      tempObject = {};
      node.properties.forEach((prop) => {
        // ExperimentalSpreadProperty have no key
        // like var hello = {...hi};
        if (!prop.key) {
          return;
        }

        key = prop.key.name;

        // preserve literal values so that the JSON form shows the correct type
        if (prop.value.type === Syntax.Literal) {
          tempObject[key] = prop.value.value;
        } else {
          tempObject[key] = nodeToValue(prop);
        }
      });

      str = JSON.stringify(tempObject);
      break;

    case Syntax.RestElement:
      str = nodeToValue(node.argument);
      break;

    case Syntax.ThisExpression:
      str = 'this';
      break;

    case Syntax.UnaryExpression:
      // like -1. in theory, operator can be prefix or postfix. in practice, any value with a
      // valid postfix operator (such as -- or ++) is not a UnaryExpression.
      str = nodeToValue(node.argument);

      if (node.prefix === true) {
        str = cast(node.operator + str);
      } else {
        // this shouldn't happen
        throw new Error(`Found a UnaryExpression with a postfix operator: ${node}`);
      }
      break;

    case Syntax.VariableDeclarator:
      str = nodeToValue(node.id);
      break;

    default:
      str = '';
  }

  return str;
});

// backwards compatibility
exports.nodeToString = nodeToValue;

// TODO: docs
const getParamNames = (exports.getParamNames = (node) => {
  let params;

  if (!node || !node.params) {
    return [];
  }

  params = node.params.slice(0);

  return params.map((param) => nodeToValue(param));
});

// TODO: docs
const isAccessor = (exports.isAccessor = (node) =>
  Boolean(node) &&
  typeof node === 'object' &&
  (node.type === Syntax.Property || node.type === Syntax.MethodDefinition) &&
  (node.kind === 'get' || node.kind === 'set'));

// TODO: docs
exports.isAssignment = (node) =>
  Boolean(node) &&
  typeof node === 'object' &&
  (node.type === Syntax.AssignmentExpression || node.type === Syntax.VariableDeclarator);

// TODO: docs
/**
 * Retrieve information about the node, including its name and type.
 */
exports.getInfo = (node) => {
  const info = {};

  switch (node.type) {
    // like the function in: "var foo = () => {}"
    case Syntax.ArrowFunctionExpression:
      info.node = node;
      info.name = '';
      info.type = info.node.type;
      info.paramnames = getParamNames(node);
      break;

    // like: "foo = 'bar'" (after declaring foo)
    // like: "MyClass.prototype.myMethod = function() {}" (after declaring MyClass)
    case Syntax.AssignmentExpression:
      info.node = node.right;
      info.name = nodeToValue(node.left);
      info.type = info.node.type;
      info.value = nodeToValue(info.node);
      // if the assigned value is a function, we need to capture the parameter names here
      info.paramnames = getParamNames(node.right);
      break;

    // like "bar='baz'" in: function foo(bar='baz') {}
    case Syntax.AssignmentPattern:
      info.node = node;
      info.name = nodeToValue(node.left);
      info.type = info.node.type;
      info.value = nodeToValue(info.node);

      break;

    // like:          "class Foo {}"
    // or "class" in: "export default class {}"
    case Syntax.ClassDeclaration:
      info.node = node;
      // if this class is the default export, we need to use a special name
      if (node.parent && node.parent.type === Syntax.ExportDefaultDeclaration) {
        info.name = 'module.exports';
      } else {
        info.name = node.id ? nodeToValue(node.id) : '';
      }
      info.type = info.node.type;
      info.paramnames = [];

      node.body.body.some(({ kind, value }) => {
        if (kind === 'constructor') {
          info.paramnames = getParamNames(value);

          return true;
        }

        return false;
      });

      break;

    // like "#b = 1;" in: "class A { #b = 1; }"
    case Syntax.ClassPrivateProperty:
      info.node = node;
      info.name = nodeToValue(info.node);
      info.type = info.node.type;
      break;

    // like "b = 1;" in: "class A { b = 1; }"
    case Syntax.ClassProperty:
      info.node = node;
      info.name = nodeToValue(info.node);
      info.type = info.node.type;
      break;

    // like: "export * from 'foo'"
    case Syntax.ExportAllDeclaration:
      info.node = node;
      info.name = nodeToValue(info.node);
      info.type = info.node.type;
      break;

    // like: "export default 'foo'"
    case Syntax.ExportDefaultDeclaration:
      info.node = node.declaration;
      info.name = nodeToValue(node);
      info.type = info.node.type;

      if (isFunction(info.node)) {
        info.paramnames = getParamNames(info.node);
      }

      break;

    // like: "export var foo;" (has declaration)
    // or:   "export {foo}" (no declaration)
    case Syntax.ExportNamedDeclaration:
      info.node = node;
      info.name = nodeToValue(info.node);
      info.type = info.node.declaration ? info.node.declaration.type : Syntax.ObjectExpression;

      if (info.node.declaration) {
        if (isFunction(info.node.declaration)) {
          info.paramnames = getParamNames(info.node.declaration);
        }

        // TODO: This duplicates logic for another node type in `jsdoc/src/visitor` in
        // `makeSymbolFoundEvent()`. Is there a way to combine the logic for both node types
        // into a single module?
        if (info.node.declaration.kind === 'const') {
          info.kind = 'constant';
        }
      }

      break;

    // like "foo as bar" in: "export {foo as bar}"
    case Syntax.ExportSpecifier:
      info.node = node;
      info.name = nodeToValue(info.node);
      info.type = info.node.local.type;

      if (isFunction(info.node.local)) {
        info.paramnames = getParamNames(info.node.local);
      }

      break;

    // like: "function foo() {}"
    // or the function in: "export default function() {}"
    case Syntax.FunctionDeclaration:
      info.node = node;
      info.name = node.id ? nodeToValue(node.id) : '';
      info.type = info.node.type;
      info.paramnames = getParamNames(node);
      break;

    // like the function in: "var foo = function() {}"
    case Syntax.FunctionExpression:
      info.node = node;
      // TODO: should we add a name for, e.g., "var foo = function bar() {}"?
      info.name = '';
      info.type = info.node.type;
      info.paramnames = getParamNames(node);
      break;

    // like the param "bar" in: "function foo(bar) {}"
    case Syntax.Identifier:
      info.node = node;
      info.name = nodeToValue(info.node);
      info.type = info.node.type;
      break;

    // like "a.b.c"
    case Syntax.MemberExpression:
      info.node = node;
      info.name = nodeToValue(info.node);
      info.type = info.node.type;
      break;

    // like: "foo() {}"
    case Syntax.MethodDefinition:
      info.node = node;
      info.name = nodeToValue(info.node);
      info.type = info.node.type;
      info.paramnames = getParamNames(node.value);
      break;

    // like "a: 0" in "var foo = {a: 0}"
    case Syntax.Property:
      info.node = node.value;
      info.name = nodeToValue(node.key);
      info.value = nodeToValue(info.node);

      // property names with unsafe characters must be quoted
      if (!/^[$_a-zA-Z0-9]*$/.test(info.name)) {
        info.name = `"${String(info.name).replace(/"/g, '\\"')}"`;
      }

      if (isAccessor(node)) {
        info.type = nodeToValue(info.node);
        info.paramnames = getParamNames(info.node);
      } else {
        info.type = info.node.type;
      }

      break;

    // like "...bar" in: function foo(...bar) {}
    case Syntax.RestElement:
      info.node = node;
      info.name = nodeToValue(info.node.argument);
      info.type = info.node.type;

      break;

    // like: "var i = 0" (has init property)
    // like: "var i" (no init property)
    case Syntax.VariableDeclarator:
      info.node = node.init || node.id;
      info.name = node.id.name;

      if (node.init) {
        info.type = info.node.type;
        info.value = nodeToValue(info.node);
      }

      break;

    default:
      info.node = node;
      info.type = info.node.type;
  }

  return info;
};
