const nunjucks = require('nunjucks');
const { default: generate } = require('@babel/generator');
const t = require('@babel/types');
const { default: traverse } = require('@babel/traverse');
const { OG_INTERNAL_VAR_NAME, FOR_LOOP_HELPER_NAME } = require('./constants');

const n = nunjucks.nodes;

const mapValues = (obj, fn) =>
  Object.entries(obj).reduce((a, [key, val]) => {
    a[key] = fn(val);
    return a;
  }, {});

const LOGICAL_EXPRESSIONS = {
  Or: '||',
  And: '&&'
};

const BINARY_EXPRESSIONS = {
  In: 'in',
  Is: '===',
  Add: '+',
  Concat: '+',
  Sub: '-',
  Mul: '*',
  Div: '/',
  FloorDiv: '/',
  Mod: '%',
  Pow: '**'
};

const isTemplateData = child => child instanceof n.Output && child.children.every(it => it instanceof n.TemplateData);

function parseNunjucks(source) {
  return nunjucks.parser.parse(source);
}

function compile(source, opts) {
  return new Parser(opts).compile(source);
}

function parse(source, opts) {
  return new Parser(opts).parse(source);
}

class Parser {
  constructor(opts) {
    this.opts = opts;
  }

  compile(source) {
    const ast = this.parse(source);
    return generate(ast).code;
  }

  parse(source) {
    this.parsedPartials = this.opts && this.opts.partials ? mapValues(this.opts.partials, parseNunjucks) : {};

    const root = parseNunjucks(source);
    return this.transform(root);
  }

  transform(node) {
    this.statements = [];

    const templateStatements = this.wrapTemplate(node);

    const block = t.blockStatement([...this.statements, t.returnStatement(templateStatements)]);

    const variablesToDeclare = [];
    let variablesInScope = [];

    traverse(t.file(t.program([block])), {
      AssignmentExpression(path) {
        if (path.find(path => path.isMemberExpression())) {
          return;
        }
        if (!t.isIdentifier(path.node.left)) {
          return;
        }
        const firstPart = path.node.left.name.split('.')[0];
        if (
          !(path.scope.hasBinding(firstPart) || path.scope.parentHasBinding(firstPart)) &&
          !variablesToDeclare.includes(firstPart)
        ) {
          variablesToDeclare.push(firstPart);
        }
      },
      Identifier(path) {
        // skip if is not a top level identifier
        if (t.isMemberExpression(path.parent) && t.isIdentifier(path.parent.object)) {
          const name = path.parent.object.name;
          if (
            !(path.scope.hasBinding(name) || path.scope.parentHasBinding(name)) &&
            !variablesInScope.includes(name) &&
            name !== OG_INTERNAL_VAR_NAME
          )
            variablesInScope.push(name);
          return;
        }
        if (path.find(path => path.isMemberExpression())) {
          return;
        }
        const firstPart = path.node.name.split('.')[0];
        if (
          !(path.scope.hasBinding(firstPart) || path.scope.parentHasBinding(firstPart)) &&
          !variablesInScope.includes(firstPart) &&
          !['html', 'repeat', '_F', OG_INTERNAL_VAR_NAME].includes(firstPart)
        ) {
          variablesInScope.push(firstPart);
        }
      }
    });
    variablesInScope = variablesInScope.filter(v => !variablesToDeclare.includes(v));

    return t.functionDeclaration(
      t.identifier('template'),
      [
        t.objectPattern(
          variablesInScope.map(val => t.objectProperty(t.identifier(val), t.identifier(val), false, true))
        ),
        t.identifier('_F')
      ],
      t.blockStatement([
        ...(variablesToDeclare.length
          ? [
              t.variableDeclaration(
                'var',
                variablesToDeclare.map(target => t.variableDeclarator(t.identifier(target)))
              )
            ]
          : []),
        ...this.statements,
        t.returnStatement(templateStatements)
      ])
    );
  }

  wrapTemplate(node) {
    if (node instanceof n.NodeList) {
      if (node.children.length === 1) {
        if (node.children.length === 1) return this.wrap(node.children[0]);
        return node.children.map(it => this.wrap(it));
      }
      let prevRawData = '';
      const elements = [];
      let expressions = [];

      node.children.forEach(child => {
        if (isTemplateData(child)) {
          prevRawData += child.children.map(it => it.value).join('');
        } else {
          elements.push(t.templateElement({ raw: prevRawData }));
          prevRawData = '';
          if (child instanceof n.Output) {
            const r = this.wrap(child.children[0]);
            expressions = [...expressions, r];
          } else {
            expressions = expressions.concat(this.wrap(child));
          }
        }
      });

      elements.push(t.templateElement({ raw: prevRawData }));

      return t.taggedTemplateExpression(
        t.identifier('html'),
        t.templateLiteral(
          elements,
          expressions.filter(it => it)
        )
      );
    }
    return this.wrap(node);
  }

  get handlers() {
    // lazy prop
    if (!this._handlers) {
      // the order of this array metters in order to match the subclass
      this._handlers = [
        [this.handleOutput, n.Output],
        [this.handleArray, n.Array],
        [this.handleDict, n.Dict],
        [this.handleGroup, n.Group],
        [this.handleNodeList, n.NodeList],
        [this.handleSet, n.Set],
        [this.handleInclude, n.Include],
        [this.handleLookupVal, n.LookupVal],
        [this.handleSymbol, n.Symbol],
        [this.handleFunCall, n.FunCall],
        [this.handleLiteral, n.Literal],
        [this.handleValue, n.Value],
        [this.handleCompare, n.Compare],
        [this.handleNot, n.Not],
        [this.handleBinOp, n.BinOp],
        [this.handleIf, n.InlineIf],
        [this.handleIf, n.If],
        [this.handleFor, n.For]
      ];
    }
    return this._handlers;
  }
  wrap(node) {
    const [handler] = this.handlers.find(([, klass]) => node instanceof klass) || [];
    if (handler) {
      return handler.call(this, node);
    }
    throw [node.typename, node];
  }

  handleOutput(node) {
    if (node.children.length === 1 && node.children[0] instanceof n.TemplateData) {
      return t.taggedTemplateExpression(
        t.identifier('html'),
        t.templateLiteral(
          node.children.map(c => t.templateElement({ raw: c.value })),
          []
        )
      );
    }
    return this.wrap(node.children[0]);
  }

  handleArray(node) {
    return t.arrayExpression(node.children.map(c => this.wrap(c)));
  }

  handleDict(node) {
    return t.objectExpression(
      node.children.map(pair => t.objectProperty(t.stringLiteral(pair.key.value), this.wrap(pair.value)))
    );
  }

  handleGroup(node) {
    return t.sequenceExpression(node.children.map(it => this.wrap(it)));
  }

  handleNodeList(node) {
    if (node.children.length === 1) return this.wrap(node.children[0]);
    return node.children.map(it => this.wrap(it));
  }

  handleSet(node) {
    return t.callExpression(
      t.arrowFunctionExpression(
        [],
        t.blockStatement(
          node.targets.map(target =>
            t.expressionStatement(t.assignmentExpression('=', this.wrap(target), this.wrap(node.value)))
          )
        )
      ),
      []
    );
  }

  handleInclude(node) {
    const { parsedPartials } = this;
    if (node instanceof n.Include) {
      if (node.template instanceof n.Literal && node.template.value in parsedPartials) {
        return this.wrapTemplate(parsedPartials[node.template.value]);
      }
      if (!(node.template.value in parsedPartials) && node.ignoreMissing) {
        return t.nullLiteral();
      }
      if (!(node.template.value in parsedPartials)) {
        throw new Error(`Template include not found ${node.template.value}`);
      }
    }
  }

  handleLookupVal(node) {
    const member = t.memberExpression(
      this.wrap(node.target),
      node.val instanceof n.Literal
        ? Number.isInteger(node.val.value)
          ? t.numericLiteral(node.val.value)
          : t.identifier(node.val.value)
        : this.wrap(node.val),
      !(node.val instanceof n.Literal) || Number.isInteger(node.val.value)
    );
    return member;
  }

  handleSymbol(node) {
    return t.identifier(node.value);
  }

  handleFunCall(node) {
    if (node.name instanceof n.Symbol) {
      return t.callExpression(t.identifier(`_F.${node.name.value}`), [].concat(this.wrap(node.args, false)));
    }
  }

  handleLiteral(node) {
    if (node.value === null) {
      return t.nullLiteral();
    }
    if (typeof node.value === 'string') {
      return t.stringLiteral(node.value);
    }
    if (typeof node.value === 'boolean') {
      return t.booleanLiteral(node.value);
    }
    if (typeof node.value === 'number') {
      return t.numericLiteral(node.value);
    }
  }

  handleValue(node) {
    return t.identifier(node.value);
  }

  handleCompare(node) {
    if (node.ops.length > 1) {
      throw new Error('Multiple Operand not supported');
    }
    return t.binaryExpression(node.ops[0].type, this.wrap(node.expr), this.wrap(node.ops[0].expr));
  }

  handleNot(node) {
    return t.unaryExpression('!', this.wrap(node.target));
  }
  handleBinOp(node) {
    if (LOGICAL_EXPRESSIONS[node.typename]) {
      return t.logicalExpression(LOGICAL_EXPRESSIONS[node.typename], this.wrap(node.left), this.wrap(node.right));
    }
    if (BINARY_EXPRESSIONS[node.typename]) {
      return t.binaryExpression(BINARY_EXPRESSIONS[node.typename], this.wrap(node.left), this.wrap(node.right));
    }
  }

  handleIf(node) {
    return this.handleInlineIf(node);
  }

  handleInlineIf(node) {
    return t.conditionalExpression(
      this.wrap(node.cond),
      this.wrapTemplate(node.body),
      node.else_ ? this.wrapTemplate(node.else_) : t.stringLiteral('')
    );
  }

  handleFor(node) {
    const array = this.wrap(node.arr);
    const renderArrayItem = t.arrowFunctionExpression(
      [
        node.name instanceof n.Array
          ? t.objectPattern(
              node.name.children.map(it =>
                t.objectProperty(t.identifier(it.value), t.identifier(it.value), false, true)
              )
            )
          : this.wrap(node.name),
        t.identifier('index')
      ],
      this.wrapTemplate(node.body)
    );

    const isArray = t.callExpression(t.identifier('Array.isArray'), [array]);

    const conditionalMapOrRepeat = t.callExpression(
      t.memberExpression(t.identifier(OG_INTERNAL_VAR_NAME), t.identifier(FOR_LOOP_HELPER_NAME)),
      [array, renderArrayItem]
    );

    return t.conditionalExpression(
      node.else_
        ? t.logicalExpression('&&', isArray, t.memberExpression(this.wrap(node.arr), t.identifier('length')))
        : isArray,

      conditionalMapOrRepeat,
      node.else_ ? this.wrapTemplate(node.else_) : t.stringLiteral('')
    );
  }
}

module.exports = { compile, parse, Parser };
