import { EventBus } from "../core/event_bus";
import { h, patch, VNode } from "../vdom/index";
import { Context } from "./context";

/**
 * Owl QWeb Engine
 *
 * In this file, you will find a QWeb engine/template compiler.  It is the core
 * of how Owl component works.
 *
 * Briefly, Owl QWeb compiles XML templates into functions that output a virtual
 * DOM representation.
 *
 * We have here:
 * - a CompilationContext class, which is an internal object that contains all
 *   compilation specific information, while a template is being compiled.
 * - a QWeb class: this is the code of the QWeb compiler.
 *
 * Note that this file does not contain the implementation of the QWeb
 * directives (see qweb_directives.ts and qweb_extensions.ts).
 */

//------------------------------------------------------------------------------
// Types
//------------------------------------------------------------------------------
export type EvalContext = { [key: string]: any };
export type CompiledTemplate = (context: EvalContext, extra: any) => VNode;

interface Template {
  elem: Element;
  fn: CompiledTemplate;
}

interface CompilationInfo {
  node: Element;
  qweb: QWeb;
  ctx: Context;
  fullName: string;
  value: string;
}

interface NodeCreationCompilationInfo extends CompilationInfo {
  nodeID: number;
  addNodeHook: Function;
}

export interface Directive {
  name: string;
  extraNames?: string[];
  priority: number;
  // if return true, then directive is fully applied and there is no need to
  // keep processing node. Otherwise, we keep going.
  atNodeEncounter?(info: CompilationInfo): boolean | void;
  atNodeCreation?(info: NodeCreationCompilationInfo): void;
  finalize?(info: CompilationInfo): void;
}

//------------------------------------------------------------------------------
// Const/global stuff/helpers
//------------------------------------------------------------------------------

const DISABLED_TAGS = ["input", "textarea", "button", "select", "option", "optgroup"];

const lineBreakRE = /[\r\n]/;
const whitespaceRE = /\s+/g;

const NODE_HOOKS_PARAMS = {
  create: "(_, n)",
  insert: "vn",
  remove: "(vn, rm)"
};

interface Utils {
  toObj(expr: any): Object;
  shallowEqual(p1: Object, p2: Object): boolean;
  [key: string]: any;
}

const UTILS: Utils = {
  toObj(expr) {
    if (typeof expr === "string") {
      expr = expr.trim();
      if (!expr) {
        return {};
      }
      let words = expr.split(/\s+/);
      let result = {};
      for (let i = 0; i < words.length; i++) {
        result[words[i]] = true;
      }
      return result;
    }
    return expr;
  },
  shallowEqual(p1, p2) {
    for (let k in p1) {
      if (p1[k] !== p2[k]) {
        return false;
      }
    }
    return true;
  }
};

function parseXML(xml: string): Document {
  const parser = new DOMParser();
  const doc = parser.parseFromString(xml, "text/xml");
  if (doc.getElementsByTagName("parsererror").length) {
    let msg = "Invalid XML in template.";
    const parsererrorText = doc.getElementsByTagName("parsererror")[0].textContent;
    if (parsererrorText) {
      msg += "\nThe parser has produced the following error message:\n" + parsererrorText;
      const re = /\d+/g;
      const firstMatch = re.exec(parsererrorText);
      if (firstMatch) {
        const lineNumber = Number(firstMatch[0]);
        const line = xml.split("\n")[lineNumber - 1];
        const secondMatch = re.exec(parsererrorText);
        if (line && secondMatch) {
          const columnIndex = Number(secondMatch[0]) - 1;
          if (line[columnIndex]) {
            msg +=
              `\nThe error might be located at xml line ${lineNumber} column ${columnIndex}\n` +
              `${line}\n${"-".repeat(columnIndex - 1)}^`;
          }
        }
      }
    }
    throw new Error(msg);
  }
  return doc;
}

let nextID = 1;

//------------------------------------------------------------------------------
// QWeb rendering engine
//------------------------------------------------------------------------------
export class QWeb extends EventBus {
  templates: { [name: string]: Template } = {};
  static utils = UTILS;
  static components = Object.create(null);

  static DIRECTIVE_NAMES: { [key: string]: 1 } = {
    name: 1,
    att: 1,
    attf: 1,
    key: 1
  };
  static DIRECTIVES: Directive[] = [];

  h = h;
  // dev mode enables better error messages or more costly validations
  static dev: boolean = false;

  // the id field is useful to be able to hash qweb instances.  The current
  // use case is that component's templates are qweb dependant, and need to be
  // able to map a qweb instance to a template name.
  id = nextID++;

  // slots contains sub templates defined with t-set inside t-component nodes, and
  // are meant to be used by the t-slot directive.
  slots = {};
  nextSlotId = 1;

  isUpdating: boolean = false;

  constructor(data?: string) {
    super();
    if (data) {
      this.addTemplates(data);
    }
  }

  static addDirective(directive: Directive) {
    if (directive.name in QWeb.DIRECTIVE_NAMES) {
      throw new Error(`Directive "${directive.name} already registered`);
    }
    QWeb.DIRECTIVES.push(directive);
    QWeb.DIRECTIVE_NAMES[directive.name] = 1;
    QWeb.DIRECTIVES.sort((d1, d2) => d1.priority - d2.priority);
    if (directive.extraNames) {
      directive.extraNames.forEach(n => (QWeb.DIRECTIVE_NAMES[n] = 1));
    }
  }

  static register(name: string, Component: any) {
    if (QWeb.components[name]) {
      throw new Error(`Component '${name}' has already been registered`);
    }
    QWeb.components[name] = Component;
  }

  /**
   * Add a template to the internal template map.  Note that it is not
   * immediately compiled.
   */
  addTemplate(name: string, xmlString: string, allowDuplicate?: boolean) {
    if (allowDuplicate && name in this.templates) {
      return;
    }
    const doc = parseXML(xmlString);
    if (!doc.firstChild) {
      throw new Error("Invalid template (should not be empty)");
    }
    this._addTemplate(name, <Element>doc.firstChild);
  }

  /**
   * Load templates from a xml (as a string or xml document).  This will look up
   * for the first <templates> tag, and will consider each child of this as a
   * template, with the name given by the t-name attribute.
   */
  addTemplates(xmlstr: string | Document) {
    const doc = typeof xmlstr === 'string' ? parseXML(xmlstr) : xmlstr;
    const templates = doc.getElementsByTagName("templates")[0];
    if (!templates) {
      return;
    }
    for (let elem of <any>templates.children) {
      const name = elem.getAttribute("t-name");
      this._addTemplate(name, elem);
    }
  }

  _addTemplate(name: string, elem: Element) {
    if (name in this.templates) {
      throw new Error(`Template ${name} already defined`);
    }
    this._processTemplate(elem);
    const template = {
      elem,
      fn: (context, extra) => {
        const compiledFunction = this._compile(name, elem);
        template.fn = compiledFunction;
        return compiledFunction.call(this, context, extra);
      }
    };
    this.templates[name] = template;
  }

  _processTemplate(elem: Element) {
    let tbranch = elem.querySelectorAll("[t-elif], [t-else]");
    for (let i = 0, ilen = tbranch.length; i < ilen; i++) {
      let node = tbranch[i];
      let prevElem = node.previousElementSibling!;
      let pattr = function(name) {
        return prevElem.getAttribute(name);
      };
      let nattr = function(name) {
        return +!!node.getAttribute(name);
      };
      if (prevElem && (pattr("t-if") || pattr("t-elif"))) {
        if (pattr("t-foreach")) {
          throw new Error(
            "t-if cannot stay at the same level as t-foreach when using t-elif or t-else"
          );
        }
        if (
          ["t-if", "t-elif", "t-else"].map(nattr).reduce(function(a, b) {
            return a + b;
          }) > 1
        ) {
          throw new Error("Only one conditional branching directive is allowed per node");
        }
        // All text nodes between branch nodes are removed
        let textNode;
        while ((textNode = node.previousSibling) !== prevElem) {
          if (textNode.nodeValue.trim().length) {
            throw new Error("text is not allowed between branching directives");
          }
          textNode.remove();
        }
      } else {
        throw new Error(
          "t-elif and t-else directives must be preceded by a t-if or t-elif directive"
        );
      }
    }
  }
  /**
   * Render a template
   *
   * @param {string} name the template should already have been added
   */
  render(name: string, context: EvalContext = {}, extra: any = null): VNode {
    const template = this.templates[name];
    if (!template) {
      throw new Error(`Template ${name} does not exist`);
    }
    return template.fn.call(this, context, extra);
  }

  /**
   * Render a template to a html string.
   *
   * Note that this is more limited than the `render` method: it is not suitable
   * to render a full component tree, since this is an asynchronous operation.
   * This method can only render templates without components.
   */
  renderToString(name: string, context: EvalContext = {}): string {
    const vnode = this.render(name, context);
    if (vnode.sel === undefined) {
      return vnode.text!;
    }
    const node = document.createElement(vnode.sel);
    const result = patch(node, vnode);
    return (<HTMLElement>result.elm).outerHTML;
  }

  /**
   * Force all widgets connected to this QWeb instance to rerender themselves.
   *
   * This method is mostly useful for external code that want to modify the
   * application in some cases.  For example, a router plugin.
   */
  forceUpdate() {
    this.isUpdating = true;
    Promise.resolve().then(() => {
      if (this.isUpdating) {
        this.isUpdating = false;
        this.trigger("update");
      }
    });
  }

  _compile(name: string, elem: Element, parentContext?: Context): CompiledTemplate {
    const isDebug = elem.attributes.hasOwnProperty("t-debug");
    const ctx = new Context(name);
    if (parentContext) {
      ctx.variables = Object.create(parentContext.variables);
      ctx.nextID = parentContext.parentNode! + 1;
      ctx.parentNode = parentContext.parentNode!;
      ctx.allowMultipleRoots = true;
      ctx.hasParentWidget = true;
      ctx.addLine(`let c${ctx.parentNode} = extra.parentNode;`);

      for (let v in parentContext.variables) {
        let variable = <any>parentContext.variables[v];
        if (variable.id) {
          ctx.addLine(`let ${variable.id} = extra.vars.${variable.id}`);
        }
      }
    }
    if (parentContext) {
      ctx.addLine("    Object.assign(context, extra.scope);");
    }
    this._compileNode(elem, ctx);

    if (!parentContext) {
      if (ctx.shouldDefineResult) {
        ctx.addLine(`return result;`);
      } else {
        if (!ctx.rootNode) {
          throw new Error(`A template should have one root node (${ctx.templateName})`);
        }
        ctx.addLine(`return vn${ctx.rootNode};`);
      }
    }

    let code = ctx.generateCode();

    let template;
    try {
      template = new Function("context", "extra", code.join("\n")) as CompiledTemplate;
    } catch (e) {
      const templateName = ctx.templateName.replace(/`/g, "'");
      console.groupCollapsed(`Invalid Code generated by ${templateName}`);
      console.warn(code.join("\n"));
      console.groupEnd();
      throw new Error(
        `Invalid generated code while compiling template '${templateName}': ${e.message}`
      );
    }
    if (isDebug) {
      const tpl = this.templates[name];
      if (tpl) {
        const msg = `Template: ${tpl.elem.outerHTML}\nCompiled code:\n${template.toString()}`;
        console.log(msg);
      }
    }
    return template;
  }

  /**
   * Generate code from an xml node
   *
   */
  _compileNode(node: ChildNode, ctx: Context) {
    if (!(node instanceof Element)) {
      // this is a text node, there are no directive to apply
      let text = node.textContent!;
      if (!ctx.inPreTag) {
        if (lineBreakRE.test(text) && !text.trim()) {
          return;
        }
        text = text.replace(whitespaceRE, " ");
      }
      if (ctx.parentNode) {
        ctx.addLine(`c${ctx.parentNode}.push({text: \`${text}\`});`);
      } else if (ctx.parentTextNode) {
        ctx.addLine(`vn${ctx.parentTextNode}.text += \`${text}\`;`);
      } else {
        // this is an unusual situation: this text node is the result of the
        // template rendering.
        let nodeID = ctx.generateID();
        ctx.addLine(`var vn${nodeID} = {text: \`${text}\`};`);
        ctx.rootContext.rootNode = nodeID;
        ctx.rootContext.parentTextNode = nodeID;
      }
      return;
    }

    const firstLetter = node.tagName[0];
    if (firstLetter === firstLetter.toUpperCase()) {
      // this is a component, we modify in place the xml document to change
      // <SomeComponent ... /> to <t t-component="SomeComponent" ... />
      node.setAttribute("t-component", node.tagName);
    }
    const attributes = (<Element>node).attributes;

    const validDirectives: {
      directive: Directive;
      value: string;
      fullName: string;
    }[] = [];

    let withHandlers = false;

    // maybe this is not optimal: we iterate on all attributes here, and again
    // just after for each directive.
    for (let i = 0; i < attributes.length; i++) {
      let attrName = attributes[i].name;
      if (attrName.startsWith("t-")) {
        let dName = attrName.slice(2).split(/-|\./)[0];
        if (!(dName in QWeb.DIRECTIVE_NAMES)) {
          throw new Error(`Unknown QWeb directive: '${attrName}'`);
        }
      }
    }

    const DIR_N = QWeb.DIRECTIVES.length;
    const ATTR_N = attributes.length;
    for (let i = 0; i < DIR_N; i++) {
      let directive = QWeb.DIRECTIVES[i];
      let fullName;
      let value;
      for (let j = 0; j < ATTR_N; j++) {
        const name = attributes[j].name;
        if (
          name === "t-" + directive.name ||
          name.startsWith("t-" + directive.name + "-") ||
          name.startsWith("t-" + directive.name + ".")
        ) {
          fullName = name;
          value = attributes[j].textContent;
          validDirectives.push({ directive, value, fullName });
          if (directive.name === "on" || directive.name === "model") {
            withHandlers = true;
          }
        }
      }
    }
    for (let { directive, value, fullName } of validDirectives) {
      if (directive.atNodeEncounter) {
        const isDone = directive.atNodeEncounter({
          node,
          qweb: this,
          ctx,
          fullName,
          value
        });
        if (isDone) {
          return;
        }
      }
    }

    if (node.nodeName !== "t") {
      let nodeID = this._compileGenericNode(node, ctx, withHandlers);
      ctx = ctx.withParent(nodeID);
      let nodeHooks = {};
      let addNodeHook = function(hook, handler) {
        nodeHooks[hook] = nodeHooks[hook] || [];
        nodeHooks[hook].push(handler);
      };

      for (let { directive, value, fullName } of validDirectives) {
        if (directive.atNodeCreation) {
          directive.atNodeCreation({
            node,
            qweb: this,
            ctx,
            fullName,
            value,
            nodeID,
            addNodeHook
          });
        }
      }

      if (Object.keys(nodeHooks).length) {
        ctx.addLine(`p${nodeID}.hook = {`);
        for (let hook in nodeHooks) {
          ctx.addLine(`  ${hook}: ${NODE_HOOKS_PARAMS[hook]} => {`);
          for (let handler of nodeHooks[hook]) {
            ctx.addLine(`    ${handler}`);
          }
          ctx.addLine(`  },`);
        }
        ctx.addLine(`};`);
      }
    }
    if (node.nodeName === "pre") {
      ctx = ctx.subContext("inPreTag", true);
    }

    this._compileChildren(node, ctx);

    for (let { directive, value, fullName } of validDirectives) {
      if (directive.finalize) {
        directive.finalize({ node, qweb: this, ctx, fullName, value });
      }
    }
  }

  _compileGenericNode(node: ChildNode, ctx: Context, withHandlers: boolean = true): number {
    // nodeType 1 is generic tag
    if (node.nodeType !== 1) {
      throw new Error("unsupported node type");
    }
    const attributes = (<Element>node).attributes;
    const attrs: string[] = [];
    const props: string[] = [];
    const tattrs: number[] = [];

    function handleBooleanProps(key, val) {
      let isProp = false;
      if (node.nodeName === "input" && key === "checked") {
        let type = (<Element>node).getAttribute("type");
        if (type === "checkbox" || type === "radio") {
          isProp = true;
        }
      }
      if (node.nodeName === "option" && key === "selected") {
        isProp = true;
      }
      if (key === "disabled" && DISABLED_TAGS.indexOf(node.nodeName) > -1) {
        isProp = true;
      }
      if ((key === "readonly" && node.nodeName === "input") || node.nodeName === "textarea") {
        isProp = true;
      }
      if (isProp) {
        props.push(`${key}: _${val}`);
      }
    }
    let classObj = "";

    for (let i = 0; i < attributes.length; i++) {
      let name = attributes[i].name;
      const value = attributes[i].textContent!;

      // regular attributes
      if (!name.startsWith("t-") && !(<Element>node).getAttribute("t-attf-" + name)) {
        const attID = ctx.generateID();
        if (name === "class") {
          let classDef = value
            .trim()
            .split(/\s+/)
            .map(a => `'${a}':true`)
            .join(",");
          classObj = `_${ctx.generateID()}`;
          ctx.addLine(`let ${classObj} = {${classDef}};`);
        } else {
          ctx.addLine(`var _${attID} = '${value}';`);
          if (!name.match(/^[a-zA-Z]+$/)) {
            // attribute contains 'non letters' => we want to quote it
            name = '"' + name + '"';
          }
          attrs.push(`${name}: _${attID}`);
          handleBooleanProps(name, attID);
        }
      }

      // dynamic attributes
      if (name.startsWith("t-att-")) {
        let attName = name.slice(6);
        const v = ctx.getValue(value);
        let formattedValue = v.id || ctx.formatExpression(v);

        if (attName === "class") {
          ctx.rootContext.shouldDefineUtils = true;
          formattedValue = `utils.toObj(${formattedValue})`;
          if (classObj) {
            ctx.addLine(`Object.assign(${classObj}, ${formattedValue})`);
          } else {
            classObj = `_${ctx.generateID()}`;
            ctx.addLine(`let ${classObj} = ${formattedValue};`);
          }
        } else {
          const attID = ctx.generateID();
          if (!attName.match(/^[a-zA-Z]+$/)) {
            // attribute contains 'non letters' => we want to quote it
            attName = '"' + attName + '"';
          }
          // we need to combine dynamic with non dynamic attributes:
          // class="a" t-att-class="'yop'" should be rendered as class="a yop"
          const attValue = (<Element>node).getAttribute(attName);
          if (attValue) {
            const attValueID = ctx.generateID();
            ctx.addLine(`var _${attValueID} = ${formattedValue};`);
            formattedValue = `'${attValue}' + (_${attValueID} ? ' ' + _${attValueID} : '')`;
            const attrIndex = attrs.findIndex(att => att.startsWith(attName + ":"));
            attrs.splice(attrIndex, 1);
          }
          ctx.addLine(`var _${attID} = ${formattedValue};`);
          attrs.push(`${attName}: _${attID}`);
          handleBooleanProps(attName, attID);
        }
      }

      if (name.startsWith("t-attf-")) {
        let attName = name.slice(7);
        if (!attName.match(/^[a-zA-Z]+$/)) {
          // attribute contains 'non letters' => we want to quote it
          attName = '"' + attName + '"';
        }
        const formattedExpr = ctx.interpolate(value);
        const attID = ctx.generateID();
        let staticVal = (<Element>node).getAttribute(attName);
        if (staticVal) {
          ctx.addLine(`var _${attID} = '${staticVal} ' + ${formattedExpr};`);
        } else {
          ctx.addLine(`var _${attID} = ${formattedExpr};`);
        }
        attrs.push(`${attName}: _${attID}`);
      }

      // t-att= attributes
      if (name === "t-att") {
        let id = ctx.generateID();
        ctx.addLine(`var _${id} = ${ctx.formatExpression(value!)};`);
        tattrs.push(id);
      }
    }
    let nodeID = ctx.generateID();
    let nodeKey: any = (<Element>node).getAttribute("t-key");
    if (nodeKey) {
      nodeKey = ctx.formatExpression(nodeKey);
    } else {
      nodeKey = nodeID;
    }
    const parts = [`key:${nodeKey}`];
    if (attrs.length + tattrs.length > 0) {
      parts.push(`attrs:{${attrs.join(",")}}`);
    }
    if (props.length > 0) {
      parts.push(`props:{${props.join(",")}}`);
    }
    if (classObj) {
      parts.push(`class:${classObj}`);
    }
    if (withHandlers) {
      parts.push(`on:{}`);
    }

    ctx.addLine(`let c${nodeID} = [], p${nodeID} = {${parts.join(",")}};`);
    for (let id of tattrs) {
      ctx.addIf(`_${id} instanceof Array`);
      ctx.addLine(`p${nodeID}.attrs[_${id}[0]] = _${id}[1];`);
      ctx.addElse();
      ctx.addLine(`for (let key in _${id}) {`);
      ctx.indent();
      ctx.addLine(`p${nodeID}.attrs[key] = _${id}[key];`);
      ctx.dedent();
      ctx.addLine(`}`);
      ctx.closeIf();
    }
    ctx.addLine(`var vn${nodeID} = h('${node.nodeName}', p${nodeID}, c${nodeID});`);
    if (ctx.parentNode) {
      ctx.addLine(`c${ctx.parentNode}.push(vn${nodeID});`);
    }

    return nodeID;
  }

  _compileChildren(node: ChildNode, ctx: Context) {
    if (node.childNodes.length > 0) {
      for (let child of Array.from(node.childNodes)) {
        this._compileNode(child, ctx);
      }
    }
  }
}
