/* eslint-env node */

'use strict';

// --------------------------------------------------------------------
// Imports
// --------------------------------------------------------------------

const fs = require('fs');
const path = require('path');

const ohm = require('ohm-js');

// --------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------

// Take an Array of nodes, and whenever an _iter node is encountered, splice in its
// recursively-flattened children instead.
function flattenIterNodes(nodes) {
  const result = [];
  for (let i = 0; i < nodes.length; ++i) {
    if (nodes[i]._node.ctorName === '_iter') {
      result.push(...flattenIterNodes(nodes[i].children));
    } else {
      result.push(nodes[i]);
    }
  }
  return result;
}

// Comparison function for sorting nodes based on their interval's start index.
function compareByInterval(node, otherNode) {
  return node.source.startIdx - otherNode.source.startIdx;
}

function nodeToES5(node, children) {
  const flatChildren = flattenIterNodes(children).sort(compareByInterval);

  // Keeps track of where the previous sibling ended, so that we can re-insert discarded
  // whitespace into the final output.
  let prevEndIdx = node.source.startIdx;

  let code = '';
  for (let i = 0; i < flatChildren.length; ++i) {
    const child = flatChildren[i];

    // Restore any discarded whitespace between this node and the previous one.
    if (child.source.startIdx > prevEndIdx) {
      code += node.source.sourceString.slice(prevEndIdx, child.source.startIdx);
    }
    code += child.toES5();
    prevEndIdx = child.source.endIdx;
  }
  return code;
}

// Instantiate the ES5 grammar.
const contents = fs.readFileSync(path.join(__dirname, 'es5.ohm'));
const g = ohm.grammars(contents).ES5;
const semantics = g.createSemantics();

semantics.addOperation('toES5()', {
  Program(_, sourceElements) {
    // Top-level leading and trailing whitespace is not handled by nodeToES5(), so do it here.
    const {sourceString} = this.source;
    return (
      sourceString.slice(0, this.source.startIdx) +
      nodeToES5(this, [sourceElements]) +
      sourceString.slice(this.source.endIdx)
    );
  },
  _nonterminal(...children) {
    return nodeToES5(this, children);
  },
  _terminal() {
    return this.sourceString;
  },
});

// Implements hoisting of variable and function declarations.
// See https://developer.mozilla.org/en-US/docs/Glossary/Hoisting
// Note that on its own, this operation doesn't create nested lexical environments,
// but it should be possible to use it as a helper for another operation that would.
semantics.addOperation('hoistDeclarations()', {
  FunctionDeclaration(_, ident, _1, _2, _3, _4, _5, _6) {
    // Don't hoist from the function body, only return this function's identifier.
    return new Map([[ident.sourceString, [ident.source]]]);
  },
  FunctionExpression(_) {
    return new Map();
  },
  VariableDeclaration(ident, _) {
    return new Map([[ident.sourceString, [ident.source]]]);
  },
  _iter: mergeBindings,
  _nonterminal: mergeBindings,
  _terminal() {
    return new Map();
  },
});

// Merge the bindings from the given `nodes` into a single map, where the value
// is an array of source locations that name is bound.
function mergeBindings(...nodes) {
  const bindings = new Map();
  for (const child of nodes.filter(c => !c.isLexical())) {
    child.hoistDeclarations().forEach((sources, ident) => {
      if (bindings.has(ident)) {
        bindings.get(ident).push(...sources); // Shadowed binding.
      } else {
        bindings.set(ident, sources); // Not shadowed at this level.
      }
    });
  }
  return bindings;
}

module.exports = {
  grammar: g,
  semantics,
};