import t from '@babel/types';
import babelGenerate from '@babel/generator';
import _ from 'lodash';
import findTarget from './finders.js';
import typeAnnotation from '../type_annotation.js';

const generate = babelGenerate.default || babelGenerate;

/**
 * Infers param tags by reading function parameter names
 *
 * @param {Object} comment parsed comment
 * @returns {Object} comment with parameters
 */
export default function inferParams(comment) {
  let path = findTarget(comment.context.ast);
  if (!path) {
    return comment;
  }

  // In case of `/** */ var x = function () {}` findTarget returns
  // the declarator.
  if (t.isVariableDeclarator(path)) {
    path = path.get('init');
  }

  // If this is an ES6 class with a constructor method, infer
  // parameters from that constructor method.
  if (
    t.isClassDeclaration(path) &&
    !(comment.constructorComment && comment.constructorComment.hideconstructor)
  ) {
    const constructor = path.node.body.body.find(item => {
      // https://github.com/babel/babylon/blob/master/ast/spec.md#classbody
      return t.isClassMethod(item) && item.kind === 'constructor';
    });
    if (constructor) {
      return inferAndCombineParams(constructor.params, comment);
    }
  }

  if (
    !t.isFunction(path) &&
    !t.isTSDeclareFunction(path) &&
    !t.isTSDeclareMethod(path) &&
    !t.isFunctionTypeAnnotation(path) &&
    !t.isTSMethodSignature(path)
  ) {
    return comment;
  }

  if (comment.kind === 'class' && comment.hideconstructor) {
    return comment;
  }

  let params = t.isTSMethodSignature(path)
    ? path.node.parameters
    : path.node.params;

  // Flow function annotations separate rest parameters into a different list
  if (t.isFunctionTypeAnnotation(path) && path.node.rest) {
    params = params.concat(path.node.rest);
  }

  const result = inferAndCombineParams(params, comment);

  // Wrap flow rest parameter with a RestType
  if (t.isFunctionTypeAnnotation(path) && path.node.rest) {
    const rest = result.params[result.params.length - 1];
    rest.type = {
      type: 'RestType',
      expression: rest.type
    };
  }

  return result;
}

function inferAndCombineParams(params, comment) {
  const inferredParams = params.map((param, i) => {
    const doc = paramToDoc(param, '', i);
    if (param.optional) {
      doc.type = {
        type: 'OptionalType',
        expression: doc.type
      };
    }

    return doc;
  });
  const paramsToMerge = comment.params;
  if (comment.constructorComment) {
    paramsToMerge.push.apply(paramsToMerge, comment.constructorComment.params);
  }
  const mergedParamsAndErrors = mergeTrees(inferredParams, paramsToMerge);

  // Then merge the trees. This is the hard part.
  return Object.assign(comment, {
    params: mergedParamsAndErrors.mergedParams,
    errors: comment.errors.concat(mergedParamsAndErrors.errors)
  });
}

// Utility methods ============================================================
//
const PATH_SPLIT_CAPTURING = /(\[])?(\.)/g;
const PATH_SPLIT = /(?:\[])?\./g;
function tagDepth(tag) {
  return (tag.name || '').split(PATH_SPLIT).length;
}

/**
 * Index tags by their `name` property into an ES6 map.
 */
function mapTags(tags) {
  return new Map(
    tags.map(tag => {
      return [tag.name, tag];
    })
  );
}

/**
 * Babel parses JavaScript source code and produces an abstract syntax
 * tree that includes methods and their arguments. This function takes
 * that AST and uses it to infer details that would otherwise need
 * explicit documentation, like the names of comments and their
 * default values.
 *
 * It is especially careful to allow the user and the machine to collaborate:
 * documentation.js should not overwrite any details that the user
 * explicitly sets.
 *
 * @private
 * @param {Object} param the abstract syntax tree of the parameter in JavaScript
 * @param {number} i the number of this parameter, in argument order
 * @param {string} prefix of the comment, if it is nested, like in the case of destructuring
 * @returns {Object} parameter with inference.
 */
function paramToDoc(param, prefix, i) {
  const autoName = '$' + String(i);
  const prefixedName = prefix + '.' + param.name;

  switch (param.type) {
    case 'AssignmentPattern': {
      // (a = b)
      const newAssignmentParam = paramToDoc(param.left, '', i);

      if (Array.isArray(newAssignmentParam)) {
        throw new Error('Encountered an unexpected parameter type');
      }

      return Object.assign(newAssignmentParam, {
        default: generate(param.right, {
          compact: true
        }).code,
        type: newAssignmentParam.type
      });
    }
    // ObjectPattern <AssignmentProperty | RestElement>
    case 'ObjectPattern': {
      // { a }
      if (prefix === '') {
        // If this is a root-level param, like f({ x }), then we need to name
        // it, like $0 or $1, depending on its position.
        return {
          title: 'param',
          name: autoName,
          anonymous: true,
          type: (param.typeAnnotation &&
            typeAnnotation(param.typeAnnotation)) || {
            type: 'NameExpression',
            name: 'Object'
          },
          properties: _.flatMap(param.properties, prop => {
            return paramToDoc(prop, prefix + autoName);
          })
        };
      } else if (param.indexed) {
        // Likewise, if this object pattern sits inside of an ArrayPattern,
        // like [{ foo }], it shouldn't just look like $0.foo, but like $0.0.foo,
        // so make sure it isn't indexed first.
        return {
          title: 'param',
          name: prefixedName,
          anonymous: true,
          type: (param.typeAnnotation &&
            typeAnnotation(param.typeAnnotation)) || {
            type: 'NameExpression',
            name: 'Object'
          },
          properties: _.flatMap(param.properties, prop => {
            return paramToDoc(prop, prefixedName);
          })
        };
      }
      // If, otherwise, this is nested, we don't really represent it as
      // a parameter in and of itself - we just want its children, and
      // it will be the . in obj.prop
      return _.flatMap(param.properties, prop => {
        return paramToDoc(prop, prefix);
      });
    }
    // ArrayPattern<Pattern | null>
    case 'ArrayPattern': {
      // ([a, b, { c }])
      if (prefix === '') {
        return {
          title: 'param',
          name: autoName,
          anonymous: true,
          type: (param.typeAnnotation &&
            typeAnnotation(param.typeAnnotation)) || {
            type: 'NameExpression',
            name: 'Array'
          },
          // Array destructuring lets you name the elements in the array,
          // but those names don't really make sense within the JSDoc
          // indexing tradition, or have any external meaning. So
          // instead we're going to (immutably) rename the parameters to their
          // indices
          properties: _.flatMap(param.elements, (element, idx) => {
            const indexedElement = Object.assign({}, element, {
              name: String(idx),
              indexed: true
            });
            return paramToDoc(indexedElement, autoName);
          })
        };
      }
      return _.flatMap(param.elements, (element, idx) => {
        const indexedElement = Object.assign({}, element, {
          name: String(idx)
        });
        return paramToDoc(indexedElement, prefix);
      });
    }
    case 'ObjectProperty': {
      return Object.assign(
        paramToDoc(
          param.value,
          prefix + '.' + param.key.name || param.key.value
        ),
        {
          name: prefix + '.' + param.key.name || param.key.value
        }
      );
    }
    case 'RestProperty': // (a, ...b)
    case 'RestElement': {
      const type = {
        type: 'RestType'
      };
      if (param.typeAnnotation) {
        type.expression = typeAnnotation(param.typeAnnotation);
      }
      return {
        title: 'param',
        name: prefix ? `${prefix}.${param.argument.name}` : param.argument.name,
        lineNumber: param.loc.start.line,
        type
      };
    }
    case 'FunctionTypeParam': // flow interface method signature
      return {
        title: 'param',
        name: prefix ? prefix + '.' + param.name.name : param.name.name,
        lineNumber: param.loc.start.line,
        type: param.typeAnnotation
          ? typeAnnotation(param.typeAnnotation)
          : undefined
      };
    default: {
      // (a)
      const newParam = {
        title: 'param',
        name: prefix ? prefixedName : param.name,
        // A skipped array argument like ([, a]);
        // looks like { name: '0', indexed: true }, and thus has no location,
        // so we allow location to be undefined here.
        lineNumber: param.loc ? param.loc.start.line : undefined
      };

      // Flow/TS annotations
      if (param.typeAnnotation && param.typeAnnotation.typeAnnotation) {
        newParam.type = typeAnnotation(param.typeAnnotation.typeAnnotation);
      }

      return newParam;
    }
  }
}

/**
 * Recurse through a potentially nested parameter tag,
 * replacing the auto-generated name, like $0, with an explicit
 * name provided from a JSDoc comment. For instance, if you have a code
 * block like
 *
 * function f({ x });
 *
 * It would by default be documented with a first param $0, with a member $0.x
 *
 * If you specify the name of the param, then it could be documented with, say,
 * options and options.x. So we need to recursively rename not just $0 but
 * also $0.x and maybe $0.x.y.z all to options.x and options.x.y.z
 */
function renameTree(node, explicitName) {
  const parts = node.name.split(PATH_SPLIT_CAPTURING);
  parts[0] = explicitName;
  node.name = parts.join('');
  if (node.properties) {
    node.properties.forEach(property => renameTree(property, explicitName));
  }
}

export function mergeTrees(inferred, explicit) {
  // The first order of business is ensuring that the root types are specified
  // in the right order. For the order of arguments, the inferred reality
  // is the ground-truth: a function like
  // function addThem(a, b, c) {}
  // Should always see (a, b, c) in that order

  // First, if all parameters are specified, allow explicit names to apply
  // to destructuring parameters, which do not have inferred names. This is
  // _only_ enabled in the case in which all parameters are specified explicitly
  if (inferred.length === explicit.length) {
    for (let i = 0; i < inferred.length; i++) {
      if (inferred[i].anonymous === true) {
        renameTree(inferred[i], explicit[i].name);
      }
    }
  }

  return mergeTopNodes(inferred, explicit);
}

function mergeTopNodes(inferred, explicit) {
  const mapExplicit = mapTags(explicit);
  const inferredNames = new Set(inferred.map(tag => tag.name));
  const explicitTagsWithoutInference = explicit.filter(tag => {
    return tagDepth(tag) === 1 && !inferredNames.has(tag.name);
  });

  const errors = explicitTagsWithoutInference.map(tag => {
    return {
      message:
        `An explicit parameter named ${
          tag.name || ''
        } was specified but didn't match ` +
        `inferred information ${Array.from(inferredNames).join(', ')}`,
      commentLineNumber: tag.lineNumber
    };
  });

  return {
    errors,
    mergedParams: inferred
      .map(inferredTag => {
        const explicitTag = mapExplicit.get(inferredTag.name);
        return explicitTag
          ? combineTags(inferredTag, explicitTag)
          : inferredTag;
      })
      .concat(explicitTagsWithoutInference)
  };
}

// This method is used for _non-root_ properties only - we use mergeTopNodes
// for root properties, which strictly requires inferred only. In this case,
// we combine all tags:
// - inferred & explicit
// - explicit only
// - inferred only
function mergeNodes(inferred, explicit) {
  const intersection = _.intersectionBy(inferred, explicit, tag => tag.name);
  const explicitOnly = _.differenceBy(explicit, inferred, tag => tag.name);
  const inferredOnly = _.differenceBy(inferred, explicit, tag => tag.name);
  const mapExplicit = mapTags(explicit);

  return intersection
    .map(inferredTag => {
      const explicitTag = mapExplicit.get(inferredTag.name);
      return explicitTag ? combineTags(inferredTag, explicitTag) : inferredTag;
    })
    .concat(explicitOnly)
    .concat(inferredOnly);
}

function combineTags(inferredTag, explicitTag) {
  let type = explicitTag.type;
  let defaultValue;
  if (!explicitTag.type) {
    type = inferredTag.type;
  }

  if (!explicitTag.default && inferredTag.default) {
    defaultValue = inferredTag.default;
  }

  const hasProperties =
    (inferredTag.properties && inferredTag.properties.length) ||
    (explicitTag.properties && explicitTag.properties.length);

  return Object.assign(
    explicitTag,
    hasProperties
      ? {
          properties: mergeNodes(
            inferredTag.properties || [],
            explicitTag.properties || []
          )
        }
      : {},
    { type },
    defaultValue ? { default: defaultValue } : {}
  );
}
