/* jshint evil: true */
var fs = require('fs');
var path = require('path');
var _template = require('lodash.template');
var esprima = require('esprima');

// disable ES6 delimiters
var _templateSettings = {interpolate: /<%=([\s\S]+?)%>/g};

// Functions used in templates.
var tools = {};

var read     = tools.read     = filename => fs.readFileSync(filename, 'utf8').replace(/\r\n/g, '\n');
var readJSON = tools.readJSON = filename => JSON.parse(read(filename));
tools.readBase64              = filename => fs.readFileSync(filename).toString('base64');

tools.readHTML = function(filename) {
  var text = read(filename).replace(/^ +/gm, '').replace(/\r?\n/g, '');
  text = _template(text, _templateSettings)(pkg); // package.json data only; no recursive imports
  return tools.html(text);
};

tools.multiline = function(text) {
  return text.replace(/\n+/g, '\n').split(/^/m).map(JSON.stringify).join('+').replace(/"\+"/g, '\\\n');
};

// Convert JSONify-able object to Javascript expression.
var constExpression = data => JSON.stringify(data).replace(/`/g, '\\`');

function TextStream(text) {
  this.text = text;
}

TextStream.prototype.eat = function(regexp) {
  var match = regexp.exec(this.text);
  if (match && match.index === 0) {
    this.text = this.text.slice(match[0].length);
  }
  return match;
};

function parseHTMLTemplate(stream, context) {
  var template = stream.text; // text from beginning, for error messages
  var expression = new HTMLExpression(context);
  var match;

  try {

    while (stream.text) {
      // Literal HTML
      if ((match = stream.eat(
        // characters not indicating start or end of placeholder, using backslash as escape
        /^(?:[^\\{}]|\\.)+(?!{)/
      ))) {
        var unescaped = match[0].replace(/\\(.)/g, '$1');
        expression.addLiteral(unescaped);

      // Placeholder
      } else if ((match = stream.eat(
        // symbol identifying placeholder type and first argument (enclosed by {})
        // backtick not allowed in arguments as it can end embedded JS in Coffeescript
        /^([^}]){([^}`]*)}/
      ))) {
        var type = match[1];
        var args = [match[2]];
        if (type === '?') {
          // conditional expression can take up to two subtemplate arguments
          for (var i = 0; i < 2 && stream.eat(/^{/); i++) {
            var subtemplate = parseHTMLTemplate(stream, context);
            args.push(subtemplate);
            if (!stream.eat(/^}/)) {
              throw new Error(`Unexpected characters in subtemplate (${stream.text})`);
            }
          }
        }
        expression.addPlaceholder(new Placeholder(type, args));

      // No match: end of subtemplate (} next) or error
      } else {
        break;
      }
    }

    return expression.build();

  } catch(err) {
    throw new Error(`${err.message}: ${template}`);
  }
}

function HTMLExpression(context) {
  this.parts = [];
  this.startContext = this.endContext = (context || '');
}

HTMLExpression.prototype.addLiteral = function(text) {
  this.parts.push(constExpression(text));
  this.endContext = (
    this.endContext
      .replace(/(=['"])[^'"<>]*/g, '$1')                    // remove values from quoted attributes (no '"<> allowed)
      .replace(/(<\w+)( [\w-]+((?=[ >])|=''|=""))*/g, '$1') // remove attributes from tags
      .replace(/^([^'"<>]+|<\/?\w+>)*/, '')                 // remove text (no '"<> allowed) and tags
  );
};

HTMLExpression.prototype.addPlaceholder = function(placeholder) {
  if (!placeholder.allowed(this.endContext)) {
    throw new Error(`Illegal insertion of placeholder (type ${placeholder.type}) into HTML template (at ${this.endContext})`);
  }
  this.parts.push(placeholder.build());
};

HTMLExpression.prototype.build = function() {
  if (this.startContext !== this.endContext) {
    throw new Error(`HTML template is ill-formed (at ${this.endContext})`);
  }
  return (this.parts.length === 0 ? '""' : this.parts.join(' + '));
};

function Placeholder(type, args) {
  this.type = type;
  this.args = args;
}

Placeholder.prototype.allowed = function(context) {
  switch(this.type) {
    case '$':
      // escaped text allowed outside tags or in quoted attributes
      return (context === '' || /\=['"]$/.test(context));
    case '&':
    case '@':
      // contents of one/many HTML element or template allowed outside tags only
      return (context === '');
    case '?':
      // conditionals allowed anywhere so long as their contents don't change context (checked by HTMLExpression.prototype.build)
      return true;
  }
  throw new Error(`Unrecognized placeholder type (${this.type})`);
};

Placeholder.prototype.build = function() {
  // first argument is always JS expression; validate it so we don't accidentally break out of placeholders
  var expr = this.args[0];
  var ast;
  try {
    ast = esprima.parse(expr);
  } catch (err) {
    throw new Error(`Invalid JavaScript in template (${expr})`);
  }
  if (!(ast.type === 'Program' && ast.body.length == 1 && ast.body[0].type === 'ExpressionStatement')) {
    throw new Error(`JavaScript in template is not an expression (${expr})`);
  }
  switch(this.type) {
    case '$': return `E(${expr})`;        // $ : escaped text
    case '&': return `(${expr}).innerHTML`; // & : contents of HTML element or template (of form {innerHTML: "safeHTML"})
    case '@': return `E.cat(${expr})`;    // @ : contents of array of HTML elements or templates (see src/General/Globals.coffee for E.cat)
    case '?':
      return `((${expr}) ? ${this.args[1] || '""'} : ${this.args[2] || '""'})`; // ? : conditional expression
  }
  throw new Error(`Unrecognized placeholder type (${this.type})`);
};

// HTML template generator with placeholders of forms ${}, &{}, @{}, and ?{}{}{} (see Placeholder.prototype.build)
// that checks safety of generated expressions at compile time.
tools.html = function(template) {
  var stream = new TextStream(template);
  var output = parseHTMLTemplate(stream);
  if (stream.text) {
    throw new Error(`Unexpected characters in template (${stream.text}): ${template}`);
  }
  return `{innerHTML: ${output}}`;
};

function includesDir(templateName) {
  var dir = path.dirname(templateName);
  var subdir = path.basename(templateName).replace(/\.[^.]+$/, '');
  if (fs.readdirSync(dir).indexOf(subdir) >= 0) {
    return path.join(dir, subdir);
  } else {
    return dir;
  }
}

function resolvePath(includeName, templateName) {
  var dir;
  if (includeName[0] === '/') {
    dir = process.cwd();
  } else {
    dir = includesDir(templateName);
  }
  return path.join(dir, includeName);
}

function wrapTool(tool, templateName) {
  return function(includeName) {
    return tool(resolvePath(includeName, templateName));
  };
}

function loadModules(templateName) {
  var dir = includesDir(templateName);
  var moduleNames = fs.readdirSync(dir).filter(f => /\.inc$/.test(f));
  var modules = {};
  for (var name of moduleNames) {
    var code = read(path.join(dir, name));
    modules[name.replace(/\.inc$/, '')] = new Function(code)();
  }
  return modules;
}

// Import variables from package.json.
var pkg = readJSON('package.json');

function interpolate(text, data, filename) {
  var context = {}, key;
  for (key in tools) {
    context[key] = /^read/.test(key) ? wrapTool(tools[key], filename) : tools[key];
  }
  for (key in pkg) {
    context[key] = pkg[key];
  }
  if (data) {
    for (key in data) {
      context[key] = data[key];
    }
  }
  context.files = fs.readdirSync(includesDir(filename));
  context.require = loadModules(filename);
  return _template(text, _templateSettings)(context);
}

module.exports = interpolate;

if (require.main === module) {
  (function() {
    // Take variables from command line.
    var data = {};
    for (var i = 4; i < process.argv.length; i++) {
      var m = process.argv[i].match(/(.*?)=(.*)/);
      data[m[1]] = m[2];
    }

    var text = read(process.argv[2]);
    text = interpolate(text, data, process.argv[2]);
    fs.writeFileSync(process.argv[3], text);
  })();
}
