var babylon = require('babylon');
var MagicString = require('magic-string');

/**
 * Given a string JavaScript source which contains Flow types, return a string
 * which has removed those types.
 *
 * Options:
 *
 *   - checkPragma: (default: true) looks for an @flow pragma before parsing.
 */
module.exports = function flowRemoveTypes(source, options) {
  options = options || {};

  var s = new MagicString(source);

  // If there's no @flow or @noflow flag, then expect no annotation.
  var pragmaStart = source.indexOf('@flow');
  var pragmaEnd = pragmaStart + 5;
  if (pragmaStart === -1) {
    pragmaStart = source.indexOf('@noflow');
    pragmaEnd = pragmaStart + 7;
    if (pragmaStart === -1 && options.checkPragma !== false) {
      return { code: source, map: s.generateMap({ hires: true }) };
    }
  }

  var removedNodes = pragmaStart === -1 ?
    [] :
    [ { start: pragmaStart, end: pragmaEnd } ];

  // Babylon is one of the sources of truth for Flow syntax. This parse
  // configuration is intended to be as permissive as possible.
  var ast = babylon.parse(source, {
    allowImportExportEverywhere: true,
    allowReturnOutsideFunction: true,
    allowSuperOutsideMethod: true,
    sourceType: 'module',
    plugins: [ '*', 'jsx', 'flow' ],
  });

  visit(ast, removedNodes, removeFlowVisitor);

  if (removedNodes.length === 0) {
    return { code: source, map: s.generateMap({ hires: true }) };
  }

  // Step through the removed nodes, building up the resulting string.
  for (var i = 0; i < removedNodes.length; i++) {
    var node = removedNodes[i];
    s.overwrite(node.start, node.end, '');
  }

  var result = { code: s.toString() };
  if (options.sourceMap !== false) {
    result.map = s.generateMap({ hires: true });
  }

  return result;
}

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: removeNode,
  TypeParameterDeclaration: removeNode,
  TypeParameterInstantiation: removeNode,

  ClassDeclaration: removeImplementedInterfaces,
  ClassExpression: removeImplementedInterfaces,

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

  ClassProperty: function (removedNodes, node) {
    if (!node.value) {
      return removeNode(removedNodes, node)
    }
  },

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

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

// If this class declaration or expression implements interfaces, remove
// the associated tokens.
function removeImplementedInterfaces(removedNodes, 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, first.start);
    do {
      idx--;
    } while (ast.tokens[idx].value !== 'implements');

    var lastIdx = findTokenIndex(ast.tokens, last.start);
    do {
      if (ast.tokens[idx].type !== 'CommentBlock' &&
          ast.tokens[idx].type !== 'CommentLine') {
        removeNode(removedNodes, 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(removedNodes, node) {
  var length = removedNodes.length;
  var index = length;
  while (index > 0 && removedNodes[index - 1].end > node.start) {
    index--;
  }
  if (index === length) {
    removedNodes.push(node);
  } else {
    removedNodes.splice(index, 0, node);
  }
  return false;
}

// Given the AST output of babylon parse, 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]] : ast.program;
      if (node && typeof node === 'object' && (node.type || node.length && node[0].type)) {
        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 (token.end <= offset) {
      min = ptr + 1;
    } else if (token.start > offset) {
      max = ptr - 1;
    } else {
      return ptr;
    }
  }

  return ptr;
}
