/**
 * Copyright (c) 2015-present, Facebook, Inc.
 * All rights reserved.
 *
 * This source code is licensed under the BSD-style license found in the
 * LICENSE file in the root directory of this source tree. An additional grant
 * of patent rights can be found in the PATENTS file in the same directory.
 */

/*jslint node: true */
'use strict';

var esprima = require('esprima-fb');
var fs = require('fs');
var Syntax = esprima.Syntax;

var findExportDefinition = require('./findExportDefinition');
var genericTransform = require('./generic-function-visitor');
var genericVisitor = genericTransform.visitorList[0];
var traverseFlat = require('./traverseFlat');
var parseTypehint = require('./TypeExpressionParser').parse;

// Don't save object properties source code that is longer than this
var MAX_PROPERTY_SOURCE_LENGTH = 1000;

function invariant(condition, message) {
  if (!condition) {
    throw new Error(message);
  }
}

/**
 * If the expression is an identifier, it is resolved in the scope chain.
 * If it is an assignment expression, it resolves to the right hand side.
 *
 * In all other cases the expression itself is returned.
 *
 * Since the scope chain constructed by the traverse function is very simple
 * (it doesn't take into account *changes* to the variable through assignment
 * statements), this function doesn't return the correct value in every
 * situation. But it's good enough for how it is used in the parser.
 *
 * @param {object} expr
 * @param {array} scopeChain
 *
 * @return {object}
 */
function resolveToValue(expr, scopeChain) {
  switch (expr.type) {
    case Syntax.AssignmentExpression:
      if (expr.operator === '=') {
        return resolveToValue(expr.right, scopeChain);
      }
      break;
    case Syntax.Identifier:
      var value;
      scopeChain.some(function(scope, i) {
        if (hasOwnProperty.call(scope, expr.name) && scope[expr.name]) {
          value = resolveToValue(scope[expr.name], scopeChain.slice(i));
          return true;
        }
      });
      return value;
  }
  return expr;
}

/**
 * Strips the "static upstream" warning from the docblock.
 *
 * @param {?string} docblock
 * @return {?string}
 */
function stripStaticUpstreamWarning(docblock) {
  if (!docblock) {
    return docblock;
  }
  // Esprima strips out the starting and ending tokens, so add them back
  docblock = '/*' + docblock + '*/\n';
  return docblock;
}

/**
 * Parse a typehint into the 'nice' form, if possible.
 */
function safeParseTypehint(typehint) {
  if (!typehint) {
    return null;
  }
  try {
    return JSON.stringify(parseTypehint(typehint));
  } catch (e) {
    return typehint;
  }
}

/**
 * Gets the docblock for the file
 *
 * @param {array<object>} commentsForFile
 * @return {?string}
 */
function getFileDocBlock(commentsForFile) {
  var docblock;
  commentsForFile.some(function(comment, i) {
    if (comment.loc.start.line === 1) {
      var lines = comment.value.split('\n');
      var inCopyrightBlock = false;
      var filteredLines = lines.filter(function(line) {
        if (!!line.match(/^\s*\*\s+Copyright \(c\)/)) {
          inCopyrightBlock = true;
        }

        var hasProvides = !!line.match(/^\s*\*\s+@provides/);
        var hasFlow = !!line.match(/^\s*\*\s+@flow/);

        if (hasFlow || hasProvides) {
          inCopyrightBlock = false;
        }

        return !inCopyrightBlock && !hasFlow && !hasProvides;
      });
      docblock = filteredLines.join('\n');
      return true;
    }
  });
  return stripStaticUpstreamWarning(docblock);
}

/**
 * Gets the docblock for a given node.
 *
 * @param {object} Node to get docblock for
 * @param {array<object>} commentsForFile
 * @param {array<string>} linesForFile
 * @return {?string}
 */
function getDocBlock(node, commentsForFile, linesForFile) {
  if (node.isSynthesized === true) {
    return '';
  }
  var docblock;
  var prevLine = node.loc.start.line - 1;
  // skip blank lines
  while (linesForFile[prevLine - 1].trim() === '') {
    prevLine--;
  }

  commentsForFile.some(function(comment, i) {
    if (comment.loc.end.line === prevLine) {
      if (comment.type === 'Line') {
        // Don't accept line comments that are separated
        if (prevLine !== node.loc.start.line - 1) {
          return true;
        }
        var line = prevLine;
        docblock = '';
        for (var ii = i; ii >= 0; ii--) {
          var lineComment = commentsForFile[ii];
          if (lineComment.loc.end.line === line) {
            docblock = '//' + lineComment.value +
              (docblock ? '\n' + docblock : '');
            line--;
          } else {
            break;
          }
        }
      } else {
        docblock = stripStaticUpstreamWarning(comment.value);
      }
      return true;
    }
  });
  return docblock;
}

/**
 * Given the comments for a file, return the module name (by looking for
 * @providesModule).
 *
 * @param {array<object>}
 * @return {?string}
 */
function getModuleName(commentsForFile) {
  var moduleName;
  commentsForFile.forEach(function(comment) {
    if (comment.type === 'Block') {
      var matches = comment.value.match(/@providesModule\s+(\S*)/);
      if (matches && matches[1]) {
        moduleName = matches[1];
      }
    }
  });
  return moduleName;
}

/**
 * Esprima includes the leading colon (and possibly spaces) as part of the
 * typehint, so we have to strip those out.
 */
function sanitizeTypehint(string) {
  for (var i = 0; i < string.length; i++) {
    if (string[i] != ' ' && string[i] != ':') {
      return string.substring(i);
    }
  }
  return null;
}

/**
 * @param {object} node
 * @param {object} docNode  Node used for location/docblock purposes
 * @param {object} state
 * @param {string} source
 * @param {array<object>} commentsForFile
 * @param {array<string>} linesForFile
 * @return {object}
 */
function getFunctionData(
  node,
  docNode,
  state,
  source,
  commentsForFile,
  linesForFile
) {
  var params = [];
  var typechecks = commentsForFile.typechecks;
  var typehintsFromBlock = null;
  if (typechecks) {
    // esprima has trouble with some params so ignore them (e.g. $__0)
    if (!node.params.some(function(param) { return !param.name; })) {
      try {
        typehintsFromBlock = genericTransform.getTypeHintsFromDocBlock(
          node,
          state.docBlocksByLine
        );
      } catch (e) {
      }
    }
  }
  node.params.forEach(function(param) {
    // TODO: Handle other things like Syntax.ObjectPattern
    if (param.type === Syntax.Identifier) {
      var typehint;
      if (param.typeAnnotation) {
        typehint = sanitizeTypehint(source.substring(
          param.typeAnnotation.range[0],
          param.typeAnnotation.range[1]
        ));
      } else if (typehintsFromBlock && typehintsFromBlock.params) {
        typehintsFromBlock.params.some(function(paramTypehint) {
          if (paramTypehint[0] === param.name) {
            typehint = paramTypehint[1];
            return true;
          }
        });
      }
      if (!typehint && typechecks) {
        try {
          typehint = genericTransform.getTypeHintFromInline(
            param,
            state.commentsByLine
          );
        } catch (e) {
        }
      }
      params.push({
        typehint: safeParseTypehint(typehint),
        name: param.name + (param.optional ? '?' : ''),
      });
    } else if (param.type === Syntax.TypeAnnotatedIdentifier) {
      params.push({
        typehint: sanitizeTypehint(source.substring(
          param.annotation.range[0],
          param.annotation.range[1]
        )),
        name: param.id.name
      });
    }
  });
  var returnTypehint = null;
  if (node.returnType) {
    returnTypehint = sanitizeTypehint(source.substring(
      node.returnType.range[0],
      node.returnType.range[1]
    ));
  } else if (typehintsFromBlock) {
    returnTypehint = typehintsFromBlock.returns;
  }
  var tparams = null;
  if (node.typeParameters) {
    tparams = node.typeParameters.params.map(function(x) {
      return x.name;
    });
  }
  return {
    line: docNode.loc.start.line,
    source: source.substring.apply(source, node.range),
    docblock: getDocBlock(docNode, commentsForFile, linesForFile),
    modifiers: [],
    params: params,
    tparams: tparams,
    returntypehint: safeParseTypehint(returnTypehint)
  };
}

/**
 * @param {object} node
 * @param {object} state
 * @param {string} source
 * @param {array<object>} commentsForFile
 * @param {array<string>} linesForFile
 * @return {object}
 */
function getObjectData(node, state, source, scopeChain,
    commentsForFile, linesForFile) {
  var methods = [];
  var properties = [];
  var superClass = null;
  node.properties.forEach(function(property) {
    if (property.type === Syntax.SpreadProperty) {
      if (property.argument.type === Syntax.Identifier) {
        superClass = property.argument.name;
      }
      return;
    }

    switch (property.value.type) {
    case Syntax.FunctionExpression:
      var methodData = getFunctionData(property.value, property, state, source,
        commentsForFile, linesForFile);
      methodData.name = property.key.name || property.key.value;
      methodData.source = source.substring.apply(source, property.range);
      methodData.modifiers.push('static');
      methods.push(methodData);
      break;
    case Syntax.Identifier:
      var expr = resolveToValue(
        property.value,
        scopeChain
      );
      if (expr) {
        if (expr.type === Syntax.FunctionDeclaration) {
          var functionData =
            getFunctionData(expr, property, state, source, commentsForFile,
              linesForFile);
          functionData.name = property.key.name || property.key.value;
          functionData.modifiers.push('static');
          methods.push(functionData);
          break;
        } else {
          property.value = expr;
        }
      }
      /* falls through */
    default:
      var propertySource = '';
      var valueRange = property.value.range;
      if (valueRange[1] - valueRange[0] < MAX_PROPERTY_SOURCE_LENGTH) {
        propertySource = source.substring.apply(source, valueRange);
      }
      var docBlock = getDocBlock(property, commentsForFile, linesForFile);
      /* CodexVarDef: modifiers, type, name, default, docblock */
      var propertyData = [
        ['static'],
        '',
        // Cast to String because this can be a Number
        // Could also be a String literal (e.g. "key") hence the value
        String(property.key.name || property.key.value),
        propertySource,
        docBlock || '',
        property.loc.start.line
      ];
      properties.push(propertyData);
      break;
    }
  });
  return {
    methods: methods,
    properties: properties,
    superClass: superClass
  };
}

/**
 * @param {object} node
 * @param {object} state
 * @param {string} source
 * @param {array<object>} commentsForFile
 * @param {array<string>} linesForFile
 * @return {object}
 */
function getClassData(node, state, source, commentsForFile, linesForFile) {
  var methods = [];
  invariant(node.body.type === Syntax.ClassBody, 'Expected ClassBody');
  node.body.body.forEach(function(bodyItem) {
    if (bodyItem.type === Syntax.MethodDefinition) {
      if (bodyItem.value.type === Syntax.FunctionExpression) {
        var methodData =
          getFunctionData(bodyItem.value, bodyItem, state, source,
            commentsForFile, linesForFile);
        methodData.name = bodyItem.key.name;
        methodData.source = source.substring.apply(source, bodyItem.range);
        if (bodyItem.static) {
          methodData.modifiers.push('static');
        }
        methods.push(methodData);
      }
    }
  });
  var data = {
    methods: methods
  };
  if (node.superClass && node.superClass.type === Syntax.Identifier) {
    data.superClass = node.superClass.name;
  }
  if (node.typeParameters) {
    data.tparams = node.typeParameters.params.map(function(x) {
      return x.name;
    });
  }
  return data;
}


/**
 * Finds all the requires
 *
 * @param {object} ast
 * @return {array<object>}
 */
function findRequires(ast) {
  var requires = [];
  traverseFlat(ast, function(node, scopeChain) {
    var requireData = getRequireData(node);
    if (requireData) {
      requires.push(requireData);
    }
    return !requireData;
  });
  return requires;
}

/**
 * If the given node is a 'require' statement, returns a list of following data
 *  {
 *    name: string
 *  }
 *
 * @return ?object
 */
function getRequireData(node) {
  if (!node || node.type !== Syntax.CallExpression) {
    return null;
  }

  var callee = node.callee;
  if (callee.type !== Syntax.Identifier
    || (callee.name !== 'require')) {
    return null;
  }
  var args = node['arguments'];
  if (args.length === 0) {
    return null;
  }
  var firstArgument = args[0];
  if (firstArgument.type !== Syntax.Literal) {
    return null;
  }

  return {
    name: firstArgument.value
  };
}

/**
 * Given the source of a file, this returns the data about the module's exported
 * value.
 *
 * @param {string} source
 * @return {?object} data
 */
function parseSource(source) {
  var lines = source.split('\n');
  var ast = esprima.parse(source, {
    loc: true,
    comment: true,
    range: true,
    sourceType: 'nonStrictModule',
  });

  /**
   * This sets up genericTransform so that it can be queried above.
   */
  var _state = {
    g: {
      source: source
    }
  };
  if (genericVisitor.test(ast, [], _state)) {
    // HACK: Mark that this file has typechecks on the comments object.
    ast.comments.typechecks = true;
    // This fills out the data for genericTransform.
    genericVisitor(function() {}, ast, [], _state);
  }
  var result = findExportDefinition(ast.body);
  if (result) {
    var definition = result.definition;
    var scopeChain = result.scopeChain;
    var data;
    var moduleName = getModuleName(ast.comments);
    if (!moduleName) {
      return null;
    }
    if (definition.type === Syntax.NewExpression &&
        definition.callee.type === Syntax.Identifier) {
      var name = definition.callee.name;
      // If the class is defined in the scopeChain, export that instead.
      scopeChain.some(function(scope) {
        if (hasOwnProperty.call(scope, name) &&
            scope[name].type === Syntax.ClassDeclaration) {
          definition = scope[name];
          return true;
        }
      });
    }

    switch (definition.type) {
      case Syntax.ClassDeclaration:
        data = getClassData(definition, _state, source, ast.comments, lines);
        data.type = 'class';
        break;
      case Syntax.ObjectExpression:
        data = getObjectData(definition, _state, source, scopeChain,
          ast.comments, lines);
        data.type = 'object';
        break;
      case Syntax.FunctionDeclaration:
      case Syntax.FunctionExpression:
        data = getFunctionData(definition, definition, _state, source,
          ast.comments, lines);
        data.type = 'function';
        break;
      default:
        data = {type: 'module'};
        break;
    }
    if (data) {
      data.line = definition.loc.start.line;
      data.name = moduleName;
      data.docblock =
        getDocBlock(definition, ast.comments, lines) ||
        getFileDocBlock(ast.comments);
      data.requires = findRequires(ast.body);
      return data;
    }
  }
  return null;
}


module.exports = parseSource;