/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @noformat
 */

var parse = require('flow-parser').parse;
var vlq = require('vlq');

/**
 * Given a string JavaScript source which contains Flow types, return a string
 * which has removed those types.
 *
 * Options:
 *
 *   - all: (default: false)
 *     If true, bypasses looking for an @flow pragma comment before parsing.
 *
 *   - pretty: (default: false)
 *     If true, removes types completely rather than replacing with spaces.
 *     This may require using source maps.
 *
 *   - ignoreUninitializedFields: (default: false)
 *     If true, removes uninitialized class fields (`foo;`, `foo: string;`)
 *     completely rather than only removing the type. THIS IS NOT SPEC
 *     COMPLIANT! Instead, use `declare foo: string;` for type-only fields.
 *
 * Returns an object with two methods:
 *
 *   - .toString()
 *     Returns the transformed source code.
 *
 *   - .generateMap()
 *     Returns a v3 source map.
 */
module.exports = function flowRemoveTypes(source, options) {
  // Options
  var all = Boolean(options && options.all);
  if (options && options.checkPragma) {
    throw new Error(
      'flow-remove-types: the "checkPragma" option has been replaced by "all".',
    );
  }

  // If there's no @flow or @noflow flag, then expect no annotation.
  var pragmaStart = source.indexOf('@' + 'flow');
  var pragmaSize = 5;
  if (pragmaStart === -1) {
    pragmaStart = source.indexOf('@' + 'noflow');
    pragmaSize = 7;
    if (pragmaStart === -1 && !all) {
      return resultPrinter(options, source);
    }
  }

  // This parse configuration is intended to be as permissive as possible.
  var ast = parse(source, {
    esproposal_decorators: true,
    esproposal_class_instance_fields: true,
    esproposal_class_static_fields: true,
    esproposal_export_star_as: true,
    esproposal_optional_chaining: true,
    esproposal_nullish_coalescing: true,
    types: true,
    tokens: true,
  });

  var removedNodes = [];

  var context = {
    ast: ast,
    source: source,
    removedNodes: removedNodes,
    pretty: Boolean(options && options.pretty),
    ignoreUninitializedFields: Boolean(
      options && options.ignoreUninitializedFields,
    ),
  };

  // Remove the flow pragma.
  if (pragmaStart !== -1) {
    var comments = getComments(ast);
    var pragmaIdx = findTokenIndex(comments, pragmaStart);
    if (pragmaIdx >= 0 && pragmaIdx < comments.length) {
      var pragmaType = comments[pragmaIdx].type;
      if (pragmaType === 'Line' || pragmaType === 'Block') {
        removedNodes.push(getPragmaNode(context, pragmaStart, pragmaSize));
      }
    }
  }

  // Remove all flow type definitions.
  visit(ast, context, removeFlowVisitor);

  return resultPrinter(options, source, removedNodes);
};

function resultPrinter(options, source, removedNodes) {
  // Options
  var pretty = Boolean(options && options.pretty);

  return {
    toString: function() {
      if (!removedNodes || removedNodes.length === 0) {
        return source;
      }

      var result = '';
      var lastPos = 0;

      // Step through the removed nodes, building up the resulting string.
      for (var i = 0; i < removedNodes.length; i++) {
        var node = removedNodes[i];
        result += source.slice(lastPos, startOf(node));
        lastPos = endOf(node);
        if (typeof node.__spliceValue === 'string') {
          result += node.__spliceValue;
        }
        if (!pretty) {
          var toReplace = source.slice(startOf(node), endOf(node));
          if (!node.loc || node.loc.start.line === node.loc.end.line) {
            result += space(toReplace.length);
          } else {
            var toReplaceLines = toReplace.split(LINE_RX);
            result += space(toReplaceLines[0].length);
            for (var j = 1; j < toReplaceLines.length; j += 2) {
              result += toReplaceLines[j] + space(toReplaceLines[j + 1].length);
            }
          }
        }
      }

      return (result += source.slice(lastPos));
    },
    generateMap: function() {
      return {
        version: 3,
        sources: ['source.js'],
        names: [],
        mappings: pretty ? generateSourceMappings(removedNodes) : '',
      };
    },
  };
}

var LINE_RX = /(\r\n?|\n|\u2028|\u2029)/;

// A collection of methods for each AST type names which contain Flow types to
// be removed.
var removeFlowVisitor = {
  DeclareClass: removeNode,
  DeclareFunction: removeNode,
  DeclareInterface: removeNode,
  DeclareModule: removeNode,
  DeclareTypeAlias: removeNode,
  DeclareVariable: removeNode,
  InterfaceDeclaration: removeNode,
  TypeAlias: removeNode,
  TypeAnnotation: removeNodeIfNotCommentType,
  TypeParameterDeclaration: removeNode,
  TypeParameterInstantiation: removeNode,
  InferredPredicate: removeNode,
  OpaqueType: removeNode,
  DeclareOpaqueType: removeNode,
  DeclareExportDeclaration: removeNode,

  ClassDeclaration: removeImplementedInterfaces,
  ClassExpression: removeImplementedInterfaces,

  Identifier: function(context, node, ast) {
    if (node.optional) {
      // Find the optional token.
      var idx = findTokenIndex(ast.tokens, startOf(node));
      do {
        idx++;
      } while (getLabel(ast.tokens[idx]) !== '?');
      removeNode(context, ast.tokens[idx]);
    }
  },

  FunctionDeclaration: function(context, node) {
    if (node.params && node.params.length) {
      if (node.params[0].name === 'this') {
        return removeNode(context, node.params[0], undefined, node.params[1]);
      }
    }
  },

  FunctionExpression: function(context, node) {
    if (node.params && node.params.length) {
      if (node.params[0].name === 'this') {
        return removeNode(context, node.params[0], undefined, node.params[1]);
      }
    }
  },

  PropertyDefinition: function(context, node) {
    if (node.declare || (context.ignoreUninitializedFields && !node.value)) {
      return removeNode(context, node);
    }
    if (node.variance != null) {
      removeNode(context, node.variance);
    }
  },

  ExportNamedDeclaration: function(context, node) {
    if (node.exportKind === 'type' || node.exportKind === 'typeof') {
      return removeNode(context, node);
    }
  },

  ImportDeclaration: function(context, node) {
    if (node.importKind === 'type' || node.importKind === 'typeof') {
      return removeNode(context, node);
    }
  },

  ImportSpecifier: function(context, node) {
    if (node.importKind === 'type' || node.importKind === 'typeof') {
      var ast = context.ast;

      // Flow quirk: Remove importKind which is outside the node
      var idxStart = findTokenIndex(ast.tokens, startOf(node));
      var maybeImportKind = ast.tokens[idxStart - 1];
      var maybeImportKindLabel = getLabel(maybeImportKind);
      if (
        maybeImportKindLabel === 'type' ||
        maybeImportKindLabel === 'typeof'
      ) {
        removeNode(context, maybeImportKind);
      }

      // Remove the node itself
      removeNode(context, node);

      // Remove trailing comma
      var idx = findTokenIndex(ast.tokens, endOf(node));

      while (isComment(ast.tokens[idx])) {
        // NOTE: ast.tokens has no comments in Flow
        idx++;
      }
      if (getLabel(ast.tokens[idx]) === ',') {
        removeNode(context, ast.tokens[idx]);
      }
      return false;
    }
  },

  ArrowFunctionExpression: function(context, node) {
    // Naively erasing a multi-line return type from an arrow function will
    // leave a newline between the parameter list and the arrow, which is not
    // valid JS. Detect this here and move the arrow up to the correct line.

    if (context.pretty) {
      // Pretty-printing solves this naturally. Good, because our arrow-fudging
      // below doesn't play nice with source maps... Which are only created when
      // using --pretty.
      return;
    }
    var returnType = node.returnType;
    if (returnType) {
      var ast = context.ast;
      var paramEndIdx = findTokenIndex(ast.tokens, startOf(returnType));
      do {
        paramEndIdx--;
      } while (isComment(ast.tokens[paramEndIdx]));

      var arrowIdx = findTokenIndex(ast.tokens, endOf(returnType));
      while (getLabel(ast.tokens[arrowIdx]) !== '=>') {
        arrowIdx++;
      }

      if (
        ast.tokens[paramEndIdx].loc.end.line <
        ast.tokens[arrowIdx].loc.start.line
      ) {
        // Insert an arrow immediately after the parameter list.
        removeNode(
          context,
          getSpliceNodeAtPos(
            context,
            endOf(ast.tokens[paramEndIdx]),
            ast.tokens[paramEndIdx].loc.end,
            ' =>',
          ),
        );

        // Delete the original arrow token.
        removeNode(context, ast.tokens[arrowIdx]);
      }
    }
  },
};

// If this class declaration or expression implements interfaces, remove
// the associated tokens.
function removeImplementedInterfaces(context, node, ast) {
  if (node.implements && node.implements.length > 0) {
    var first = node.implements[0];
    var last = node.implements[node.implements.length - 1];
    var idx = findTokenIndex(ast.tokens, startOf(first));
    do {
      idx--;
    } while (ast.tokens[idx].value !== 'implements');

    var lastIdx = findTokenIndex(ast.tokens, startOf(last));
    do {
      if (!isComment(ast.tokens[idx])) {
        // NOTE: ast.tokens has no comments in Flow
        removeNode(context, ast.tokens[idx]);
      }
    } while (idx++ !== lastIdx);
  }
}

// Append node to the list of removed nodes, ensuring the order of the nodes
// in the list.
function removeNode(context, node, _ast, nextInList) {
  var removedNodes = context.removedNodes;
  var length = removedNodes.length;
  var index = length;

  // Check for line's leading and trailing space to be removed.
  var spaceNode = context.pretty ? getLeadingSpaceNode(context, node) : null;
  var lineNode = context.pretty ? getTrailingLineNode(context, node) : null;
  var commaNode = nextInList
    ? createNode({
        start: endOf(node),
        end: startOf(nextInList),
        loc: {
          start: endOf(node),
          end: startOf(nextInList),
        },
      })
    : null;

  while (index > 0 && endOf(removedNodes[index - 1]) > startOf(node)) {
    index--;
  }

  if (index === length) {
    if (spaceNode) {
      removedNodes.push(spaceNode);
    }
    removedNodes.push(node);
    if (lineNode) {
      removedNodes.push(lineNode);
    }
    if (commaNode) {
      removedNodes.push(commaNode);
    }
  } else {
    if (lineNode) {
      if (spaceNode) {
        removedNodes.splice(index, 0, spaceNode, node, lineNode);
      } else {
        removedNodes.splice(index, 0, node, lineNode);
      }
    } else if (spaceNode) {
      removedNodes.splice(index, 0, spaceNode, node);
    } else {
      removedNodes.splice(index, 0, node);
    }
  }

  return false;
}

function removeNodeIfNotCommentType(context, node) {
  var source = context.source;
  var start = startOf(node);
  if (source[start] === '/') {
    return false;
  }
  return removeNode(context, node);
}

function getPragmaNode(context, start, size) {
  var source = context.source;
  var line = 1;
  var column = 0;
  for (var position = 0; position < start; position++) {
    var char = source[position];
    if (char === '\n') {
      line++;
      column = 0;
    } else if (char === '\r') {
      if (source[position + 1] === '\n') {
        position++;
      }
      line++;
      column = 0;
    } else {
      column++;
    }
  }
  return createNode({
    start: start,
    end: start + size,
    loc: {
      start: {line: line, column: column},
      end: {line: line, column: column + size},
    },
  });
}

function getLeadingSpaceNode(context, node) {
  var source = context.source;
  var end = startOf(node);
  var start = end;
  while (source[start - 1] === ' ' || source[start - 1] === '\t') {
    start--;
  }
  if (start !== end) {
    return createNode({
      start: start,
      end: end,
      loc: {start: node.loc.start, end: node.loc.start},
    });
  }
}

function getTrailingLineNode(context, node) {
  var source = context.source;
  var start = endOf(node);
  var end = start;
  while (source[end] === ' ' || source[end] === '\t') {
    end++;
  }

  // Remove all space including the line break if this token was alone on the line.
  if (source[end] === '\n' || source[end] === '\r') {
    if (source[end] === '\r' && source[end + 1] === '\n') {
      end++;
    }
    end++;

    if (isLastNodeRemovedFromLine(context, node)) {
      return createNode({
        start: start,
        end: end,
        loc: {start: node.loc.end, end: node.loc.end},
      });
    }
  }
}

// Creates a zero-width "node" with a value to splice at that position.
// WARNING: This is only safe to use when source maps are off!
function getSpliceNodeAtPos(context, pos, loc, value) {
  return createNode({
    start: pos,
    end: pos,
    loc: {start: loc, end: loc},
    __spliceValue: value,
  });
}

// Returns true if node is the last to be removed from a line.
function isLastNodeRemovedFromLine(context, node) {
  var tokens = context.ast.tokens;
  var priorTokenIdx = findTokenIndex(tokens, startOf(node)) - 1;
  var token = tokens[priorTokenIdx];
  var line = node.loc.end.line;

  // Find previous token that was not removed on the same line.
  while (
    priorTokenIdx >= 0 &&
    token.loc.end.line === line &&
    isRemovedToken(context, token)
  ) {
    token = tokens[--priorTokenIdx];
  }

  // If there's no prior token (start of file), or the prior token is on another
  // line, this line must be fully removed.
  return !token || token.loc.end.line !== line;
}

// Returns true if the provided token was previously marked as removed.
function isRemovedToken(context, token) {
  var removedNodes = context.removedNodes;
  var nodeIdx = removedNodes.length - 1;

  // Find the last removed node which could possibly contain this token.
  while (nodeIdx >= 0 && startOf(removedNodes[nodeIdx]) > startOf(token)) {
    nodeIdx--;
  }

  var node = removedNodes[nodeIdx];

  // This token couldn't be removed if not contained within the removed node.
  if (nodeIdx === -1 || endOf(node) < endOf(token)) {
    return false;
  }

  // Iterate through the tokens contained by the removed node to find a match.
  var tokens = context.ast.tokens;
  var tokenIdx = findTokenIndex(tokens, startOf(node));
  while (endOf(tokens[tokenIdx]) <= endOf(node)) {
    if (token === tokens[tokenIdx]) {
      return true;
    }
    tokenIdx++;
  }

  return false;
}

// Given the AST output from the parser, walk through in a depth-first order,
// calling methods on the given visitor, providing context as the first argument.
function visit(ast, context, visitor) {
  var stack;
  var parent;
  var keys = [];
  var index = -1;

  do {
    index++;
    if (stack && index === keys.length) {
      parent = stack.parent;
      keys = stack.keys;
      index = stack.index;
      stack = stack.prev;
    } else {
      var node = parent ? parent[keys[index]] : getProgram(ast);
      if (node && typeof node === 'object' && (node.type || node.length)) {
        if (node.type) {
          var visitFn = visitor[node.type];
          if (visitFn && visitFn(context, node, ast) === false) {
            continue;
          }
        }
        stack = {parent: parent, keys: keys, index: index, prev: stack};
        parent = node;
        keys = Object.keys(node);
        index = -1;
      }
    }
  } while (stack);
}

// Given an array of sorted tokens, find the index of the token which contains
// the given offset. Uses binary search for O(log N) performance.
function findTokenIndex(tokens, offset) {
  var min = 0;
  var max = tokens.length - 1;

  while (min <= max) {
    var ptr = ((min + max) / 2) | 0;
    var token = tokens[ptr];
    if (endOf(token) <= offset) {
      min = ptr + 1;
    } else if (startOf(token) > offset) {
      max = ptr - 1;
    } else {
      return ptr;
    }
  }

  return ptr;
}

// True if the provided token is a comment.
function isComment(token) {
  return token.type === 'Block' || token.type === 'Line';
}

// Produce a string full of space characters of a given size.
function space(size) {
  var sp = ' ';
  var result = '';

  for (;;) {
    if ((size & 1) === 1) {
      result += sp;
    }
    size >>>= 1;
    if (size === 0) {
      break;
    }
    sp += sp;
  }
  return result;
}

// Generate a source map when *removing* nodes rather than replacing them
// with spaces.
function generateSourceMappings(removedNodes) {
  var mappings = '';
  if (!removedNodes || removedNodes.length === '') {
    return mappings;
  }

  var end = {line: 1, column: 0};

  for (var i = 0; i < removedNodes.length; i++) {
    var start = removedNodes[i].loc.start;
    var lineDiff = start.line - end.line;
    var columnDiff = start.column - end.column;
    if (lineDiff) {
      for (var l = 0; l !== lineDiff; l++) {
        mappings += ';';
      }
      mappings += vlq.encode([start.column, 0, lineDiff, columnDiff]);
    } else if (columnDiff) {
      if (i) {
        mappings += ',';
      }
      mappings += vlq.encode([columnDiff, 0, lineDiff, columnDiff]);
    }

    end = removedNodes[i].loc.end;
    mappings += ',';
    mappings += vlq.encode([
      0,
      0,
      end.line - start.line,
      end.column - start.column,
    ]);
  }

  return mappings;
}

/**
 * A lightweight layer to abstract over the slightly different ASTs returned by
 * Flow vs Babylon.
 */

function startOf(token) {
  return token.range[0];
}

function endOf(token) {
  return token.range[1];
}

function getComments(ast) {
  return ast.comments;
}

function createNode(data) {
  return {
    range: [data.start, data.end],
    loc: data.loc,
    __spliceValue: data.__spliceValue,
  };
}

function getLabel(token) {
  return token.value;
}

function getProgram(ast) {
  return ast;
}
