import { now, inline_chunk, EMPTY, pad_lines, pad_chunk } from "efreet/utils";

import { RawValue, EventBinder } from "./catalyst";

export enum PropType {
  Property,
  Attribute,
  Event,
  Lifecycle
}

export interface FElement
  extends FElement.Basic,
    FElement.Content,
    FElement.Hooks,
    FElement.Lifecycle {
  [attr: string]: any;
}
export interface FSVGElement
  extends FElement.Basic,
    FElement.SVGPresentation,
    FElement.Hooks,
    FElement.Lifecycle {
  svg: true;
  [attr: string]: any;
}
export namespace FElement {
  export type EventHandler = ReturnType<EventBinder["compile"]>;
  export interface Hooks {
    // Mouse Hooks
    click?: EventBinder | EventHandler;
    dblclick?: EventBinder | EventHandler;
    contextmenu?: EventBinder | EventHandler;
    mousedown?: EventBinder | EventHandler;
    mouseup?: EventBinder | EventHandler;
    mousemove?: EventBinder | EventHandler;
    mouseover?: EventBinder | EventHandler;
    mouseout?: EventBinder | EventHandler;
    mouseenter?: EventBinder | EventHandler;
    mouseleave?: EventBinder | EventHandler;
    wheel?: EventBinder | EventHandler;

    // Touch Hooks
    touchstart?: EventBinder | EventHandler;
    touchend?: EventBinder | EventHandler;
    touchcancel?: EventBinder | EventHandler;
    touchmove?: EventBinder | EventHandler;

    // Drag-n-Drop Hooks
    dragstart?: EventBinder | EventHandler;
    dragover?: EventBinder | EventHandler;
    drag?: EventBinder | EventHandler;
    dragend?: EventBinder | EventHandler;
    drop?: EventBinder | EventHandler;

    // Keyboard Hooks
    keyup?: EventBinder | EventHandler;
    keydown?: EventBinder | EventHandler;

    // Misc. Hooks
    focus?: EventBinder | EventHandler;
    blur?: EventBinder | EventHandler;
    focusin?: EventBinder | EventHandler;
    focusout?: EventBinder | EventHandler;
    scroll?: EventBinder | EventHandler;
    change?: EventBinder | EventHandler;
    input?: EventBinder | EventHandler;
    cut?: EventBinder | EventHandler;
    copy?: EventBinder | EventHandler;
    paste?: EventBinder | EventHandler;

    // Animation Hooks
    transitionstart?: EventBinder | EventHandler;
    transitionrun?: EventBinder | EventHandler;
    transitioncancel?: EventBinder | EventHandler;
    transitionend?: EventBinder | EventHandler;

    animationstart?: EventBinder | EventHandler;
    animationiteration?: EventBinder | EventHandler;
    animationcancel?: EventBinder | EventHandler;
    animationend?: EventBinder | EventHandler;
  }

  export interface Lifecycle {
    postrender?: (elem: FElement, node: Element) => void;
  }

  export interface Basic {
    class?: string;
    id?: string;
    tabindex?: number;
    kind?: string;
    svg?: boolean;
    key?: string;
    ix?: number;
    dirty?: boolean;
    focused?: boolean;
    style?: string;
  }

  export interface Content {
    selected?: boolean;
    contentEditable?: boolean;
    checked?: boolean;
    disabled?: boolean;
    draggable?: boolean;
    spellcheck?: boolean;
    allowfullscreen?: boolean;

    title?: string;
    placeholder?: string;
    href?: string;
    src?: string;
    download?: string;
    type?: string;
    min?: number;
    max?: number;
    step?: number;
    value?: any;
    for?: string;
    name?: string;
    target?: any;
  }

  type CursorValues =
    | "auto"
    | "crosshair"
    | "default"
    | "pointer"
    | "move"
    | "e-resize"
    | "ne-resize"
    | "nw-resize"
    | "n-resize"
    | "se-resize"
    | "sw-resize"
    | "s-resize"
    | "w-resize"
    | "text"
    | "wait"
    | "help"
    | "inherit"
    | string;
  type DisplayValues =
    | "inline"
    | "block"
    | "list-item"
    | "run-in"
    | "compact"
    | "marker"
    | "table"
    | "inline-table"
    | "table-row-group"
    | "table-header-group"
    | "table-footer-group"
    | "table-row"
    | "table-column-group"
    | "table-column"
    | "table-cell"
    | "table-caption"
    | "none"
    | "inherit"
    | "flex"
    | "grid";

  type SVGDrawRule = "nonzero" | "evenodd";
  type SVGPointerEvents =
    | "bounding-box"
    | "visiblePainted"
    | "visibleFill"
    | "visibleStroke"
    | "visible"
    | "painted"
    | "fill"
    | "stroke"
    | "all"
    | "none";

  export interface SVGPresentation {
    "clip-path"?: string;
    "clip-rule"?: SVGDrawRule | "inherit";
    color?: string;
    "color-interpolation"?: "auto" | "sRGB" | "linearRGB" | "inherit";
    "color-rendering"?:
      | "auto"
      | "optimizeSpeed"
      | "optimizeQuality"
      | "inherit";
    cursor?: CursorValues;
    display?: DisplayValues;
    fill?: string;
    "fill-opacity"?: number | string;
    "fill-rule"?: SVGDrawRule;
    filter?: string;
    mask?: string;
    opacity?: number | string;
    "pointer-events"?: SVGPointerEvents;
    "shape-rendering"?:
      | "auto"
      | "optimizeSpeed"
      | "crispEdges"
      | "geometricPrecision"
      | "inherit";
    stroke?: string;
    "stroke-dasharray"?: string;
    "stroke-dashoffset"?: number | string;
    "stroke-linecap"?: "butt" | "round" | "square";
    "stroke-linejoin"?: "arcs" | "bevel" | "miter" | "miter-clip" | "round";
    "stroke-miterlimit"?: number | string;
    "stroke-opacity"?: number | string;
    "stroke-width"?: number | string;
    transform?: string;
    "vector-effect"?: "default" | "non-scaling-stroke" | "inherit" | string;
    visibility?: "visible" | "hidden" | "collapse" | "inherit";
  }
}

export type FChild = FElem | string | undefined;

export interface FElem extends FElement {
  _id: string;
  kind: string;
  tagname?: string;
  parent?: string;
  ix?: number;
  children?: (FChild | FChild[])[];

  [attr: string]: any;
}

export interface FNode extends Element, HTMLOrSVGElement {
  _id: string;
}

export const EMPTY_ELEM = {} as Readonly<FElem>;

const namespace_urls = {
  xlink: "http://www.w3.org/1999/xlink"
};
function set_attribute(node: Element, property: string, value: any) {
  let [ns, prop] = property.split(":");
  if (prop === undefined) [prop, ns] = [ns, prop];
  if (ns) console.log(ns, prop, value);
  if (value === false || value === undefined)
    ns
      ? node.removeAttributeNS(namespace_urls[ns], prop)
      : node.removeAttribute(property);
  else if (value === true)
    ns
      ? node.setAttributeNS(namespace_urls[ns], prop, "")
      : node.setAttribute(property, "");
  else
    ns
      ? node.setAttributeNS(namespace_urls[ns], prop, value)
      : node.setAttribute(property, value);
}

export class Renderer {
  prop_map: { [prop: string]: PropType } = {
    // DEBUG
    _id: PropType.Attribute,
    _ix: PropType.Attribute,

    // Content
    class: PropType.Attribute,
    type: PropType.Attribute,
    disabled: PropType.Attribute,
    spellcheck: PropType.Attribute,
    placeholder: PropType.Attribute,
    tabindex: PropType.Attribute,
    title: PropType.Attribute,
    href: PropType.Attribute,
    src: PropType.Attribute,
    target: PropType.Attribute,
    download: PropType.Attribute,
    allowfullscreen: PropType.Attribute,
    for: PropType.Attribute,
    name: PropType.Attribute,
    id: PropType.Attribute,
    selected: PropType.Attribute,
    checked: PropType.Attribute,
    // style: PropType.Property,

    // Mouse Hooks
    click: PropType.Event,
    dblclick: PropType.Event,
    contextmenu: PropType.Event,
    mousedown: PropType.Event,
    mouseup: PropType.Event,
    mousemove: PropType.Event,
    mouseover: PropType.Event,
    mouseout: PropType.Event,
    mouseenter: PropType.Event,
    mouseleave: PropType.Event,
    wheel: PropType.Event,

    // Touch Hooks
    touchstart: PropType.Event,
    touchend: PropType.Event,
    touchcancel: PropType.Event,
    touchmove: PropType.Event,

    // Drag-n-Drop Hooks
    dragstart: PropType.Event,
    dragover: PropType.Event,
    drag: PropType.Event,
    dragend: PropType.Event,
    drop: PropType.Event,

    // Keyboard Hooks
    keyup: PropType.Event,
    keydown: PropType.Event,

    // Misc. Hooks
    focus: PropType.Event,
    blur: PropType.Event,
    focusin: PropType.Event,
    focusout: PropType.Event,
    scroll: PropType.Event,
    change: PropType.Event,
    input: PropType.Event,
    cut: PropType.Event,
    copy: PropType.Event,
    paste: PropType.Event,

    // Animation Hooks
    transitionstart: PropType.Event,
    transitionrun: PropType.Event,
    transitioncancel: PropType.Event,
    transitionend: PropType.Event,

    animationstart: PropType.Event,
    animationiteration: PropType.Event,
    animationcancel: PropType.Event,
    animationend: PropType.Event,

    // Lifecycle
    postrender: PropType.Lifecycle
  };

  differs: {
    [kind: string]: (cur: FElem, old: FElem | undefined) => boolean;
  } = {
    static: this.static_diff,
    text: this.text_diff,
    dynamic: this.dynamic_diff
  };
  updaters: {
    [kind: string]: (
      cur: FElem,
      old: FElem | undefined,
      node: Element,
      lib: Renderer
    ) => void;
  } = {
    static: this.static_update,
    text: this.text_update,
    dynamic: this.dynamic_update
  };

  container: FNode = document.createElement("div") as any;

  node_cache: { [id: string]: FNode } = {
    __root: this.container,
    head: document.head as any
  };
  prev_tree: { [id: string]: FElem };
  tree: { [id: string]: FElem } = {};
  postrenders: string[];
  to_focus?: string;

  render_roots: () => FElement[] = () => [];
  debug = { tree: false, perf: false, events: false };
  queued: boolean;

  constructor() {
    this.container.className = "__root";
    this.container._id = "__root";
  }

  handle_event = (event: Event) => {
    let id = ((event.currentTarget || event.target) as FNode)._id;
    let elem = this.tree[id];
    if (!elem) return;
    let handler = elem[event.type] as FElement.EventHandler | EventBinder;
    if (handler) {
      if (typeof handler === "function") handler(event, elem);
      else handler.compile()(event, elem);
    }
  };

  reset() {
    this.prev_tree = this.tree;
    this.tree = {};
    this.postrenders = [];
  }

  prepare(root: FElement) {
    let start = now();
    let elem_count = 1;
    let { tree } = this;
    let elements = [root];

    for (let elem_ix = 0; elem_ix < elem_count; elem_ix += 1) {
      let cur: FElement = elements[elem_ix];
      if (cur.parent === undefined) cur.parent = "__root";
      if (cur._id === undefined) cur._id = "__root__" + elem_ix;
      tree[cur._id] = cur as FElem;

      let { children = EMPTY } = cur;
      let child_count = 0;
      for (let maybe_child of children) {
        if (maybe_child === undefined) continue;
        if (
          typeof maybe_child === "string" ||
          typeof maybe_child === "number"
        ) {
          elements.push(
            this.prepare_child(
              cur._id,
              { kind: "text", textContent: "" + maybe_child },
              child_count++
            )
          );
        } else if (!(maybe_child instanceof Array)) {
          elements.push(
            this.prepare_child(cur._id, maybe_child, child_count++)
          );
        } else {
          for (let sub_child of maybe_child) {
            if (sub_child === undefined) continue;
            if (
              typeof sub_child === "string" ||
              typeof sub_child === "number"
            ) {
              elements.push(
                this.prepare_child(
                  cur._id,
                  { kind: "text", textContent: "" + sub_child },
                  child_count++
                )
              );
            } else if (!(sub_child instanceof Array)) {
              elements.push(
                this.prepare_child(cur._id, sub_child, child_count++)
              );
            } else {
              throw new Error(
                "Fluorine allows one one layer of array-based child nesting (make sure to flatten any nested maps)."
              );
            }
          }
        }
      }
      elem_count += child_count;
    }
    if (this.debug.perf)
      console.log(`Prepared (${elem_count}) in ${now() - start}`);
    return tree;
  }

  prepare_child(parent_id: string, child: FElement, child_ix: number) {
    if (child._id === undefined) child._id = `${parent_id}__${child_ix}`;
    if (child.ix === undefined) child.ix = child_ix;
    if (child.parent === undefined) child.parent = parent_id;
    return child as FElem;
  }

  domify() {
    let { differs, prev_tree, tree } = this;

    for (let id of Object.keys(prev_tree)) {
      if (!tree[id]) {
        // Remove
        let node = this.node_cache[id];
        if (node.parentNode) node.parentNode.removeChild(node);
      }
    }

    for (let id of Object.keys(tree)) {
      let cur = tree[id];
      let prev = prev_tree[id];

      if (!prev) {
        // Add
        let node: FNode;
        if (cur.svg)
          node = document.createElementNS(
            "http://www.w3.org/2000/svg",
            cur.tagname || "div"
          ) as any;
        else if (cur.kind === "text")
          node = document.createTextNode(cur.textContent) as any;
        else node = document.createElement(cur.tagname || "div") as any;
        this.node_cache[id] = node;
        node._id = id;
        prev = EMPTY_ELEM;
      } else if (cur.tagname !== prev.tagname) {
        // Replace
        let old_node = this.node_cache[id];
        if (old_node.parentNode) old_node.parentNode.removeChild(old_node);

        let new_node: FNode;
        if (cur.svg)
          new_node = document.createElementNS(
            "http://www.w3.org/2000/svg",
            cur.tagname || "div"
          ) as any;
        else if (cur.kind === "text")
          new_node = document.createTextNode(cur.textContent) as any;
        else new_node = document.createElement(cur.tagname || "div") as any;

        this.node_cache[id] = new_node;
        new_node._id = id;
        // @FIXME: If it isn't correct to always omit old children, we need to topo sort the inputs and go top down.
        // while (old_node.firstChild) new_node.appendChild(old_node.firstChild);
        let old_kids = prev.children;
        prev = EMPTY_ELEM;
        prev.children = old_kids;
        let child_ix = 0;
        let child_count = old_kids ? old_kids.length : 0;
        while (old_node.firstChild) {
          let child = old_node.firstChild as FNode | (Text & { _id?: string });
          let cur_kid = (old_kids || EMPTY)[child_ix];
          if (child_ix >= child_count || !cur_kid) {
            old_node.removeChild(child);
          } else if (
            (child instanceof Text && child.textContent === cur_kid) ||
            child._id === (cur_kid as FElem)._id
          ) {
            new_node.appendChild(child);
          }
          child_ix += 1;
        }
      }

      if (cur.ix !== prev.ix || cur.parent !== prev.parent) {
        // Move
        let node = this.node_cache[id];
        this._move_elem(id, cur, node);
        if (this.debug.tree && node.nodeName !== "#text") {
          node.setAttribute("_id", cur._id);
          node.setAttribute("ix", "" + cur.ix);
        }
      }

      if (differs[cur.kind](cur, prev)) {
        // Update
        let node = this.node_cache[id];
        this.updaters[cur.kind](cur, prev, node, this);
        if (cur.postrender) this.postrenders.push(id);
      }

      if (cur.focused && cur.focused !== prev.focused) {
        // Focus
        this.to_focus = id;
      }
    }
  }

  _move_elem(id: string, cur: FElem, node: Element) {
    let parent_node = this.node_cache[cur.parent!];
    if (parent_node) {
      if (cur.ix! >= parent_node.children.length) {
        parent_node.appendChild(node);
      } else {
        parent_node.insertBefore(node, parent_node.children[cur.ix!]);
      }
    }
    if (cur.postrender) this.postrenders.push(id);
  }

  postdomify() {
    for (let id of this.postrenders) {
      let node = this.node_cache[id];
      let cur = this.tree[id];
      cur.postrender!(cur, node);
    }

    if (this.to_focus) {
      let id = this.to_focus;
      this.to_focus = undefined;
      let node = this.node_cache[id];
      if (node.focus) {
        setTimeout(() => {
          node.focus();
          if ("value" in node) {
            let input: HTMLInputElement = node as any;
            input.selectionStart = input.selectionEnd = (
              input.value || ""
            ).length;
          }

          // if (typeof window.getSelection != "undefined"
          //     && typeof document.createRange != "undefined") {
          //   let range = document.createRange();
          //   range.selectNodeContents(node);
          //   range.collapse(false);
          //   let sel = window.getSelection();
          //   sel.removeAllRanges();
          //   sel.addRange(range);
          // }
        }, 0);
      }
    }
  }

  redraw() {
    if (!this.queued) {
      this.queued = true;
      requestAnimationFrame(this.force_draw);
    }
  }

  force_draw = () => {
    let start = now();
    let roots = this.render_roots();
    let root_time = now();
    this.render(roots);
    if (this.debug.perf) {
      console.info(
        "Render time",
        now() - root_time,
        "Gen roots time",
        root_time - start
      );
    }
    this.queued = false;
  };

  render(elems: FElement[]) {
    this.reset();
    // We sort elements by depth to allow them to be self referential.
    elems.sort(
      (a, b) =>
        (a.parent ? a.parent.split("__").length : 0) -
        (b.parent ? b.parent.split("__").length : 0)
    );
    let start = now();
    for (let elem of elems) this.prepare(elem);
    let prepare = now();
    this.domify();
    let domify = now();
    this.postdomify();
    let postDomify = now();
    let time = now() - start;
    if (time > 15) {
      console.info("slow render (> 15ms): ", time, {
        prepare: prepare - start,
        domify: domify - prepare,
        postDomify: postDomify - domify
      });
    }
  }

  static_diff(cur: FElem, old: FElem) {
    return !!cur.dirty;
  }

  static_update(this: never, cur: FElem, old: FElem, node: Element) {}

  text_diff(cur: FElem, old: FElem) {
    return cur.textContent !== old.textContent;
  }

  text_update(this: never, cur: FElem, old: FElem, node: Element) {
    if (cur.textContent !== old.textContent) node.textContent = cur.textContent;
  }

  dynamic_diff(cur: FElem, old: FElem) {
    for (let prop in cur) {
      if (cur[prop] !== old[prop]) return true;
    }
    for (let prop in old) {
      if (cur[prop] !== old[prop]) return true;
    }
    return false;
  }

  dynamic_update(
    this: never,
    cur: FElem,
    old: FElem,
    node: Element,
    lib: this
  ) {
    for (let prop in cur) {
      if (
        prop === "children" ||
        prop === "ix" ||
        prop === "parent" ||
        prop === "tagname"
      )
        continue;
      let type = lib.prop_map[prop];

      if (cur[prop] !== old[prop]) {
        if (type === PropType.Attribute || (cur.svg && type === undefined)) {
          set_attribute(node, prop, cur[prop]);
        } else if (type === PropType.Event) {
          // node["on" + prop] = lib.handle_event;
          node.addEventListener(prop, lib.handle_event);
        } else {
          node[prop] = cur[prop];
        }
      }
    }
    for (let prop in old) {
      if (cur[prop] !== undefined) continue;

      let type = lib.prop_map[prop];
      if (type === PropType.Attribute) {
        node.removeAttribute(prop);
      } else if (type === PropType.Event) {
        // node["on" + prop] = undefined;
        node.removeEventListener(prop, old[prop]);
      } else if (prop !== "children") {
        node[prop] = undefined;
      }
    }
  }

  specialize(kind: string, properties: string[]) {
    if (this.differs[kind]) return;

    let start = now();

    if (properties.length === 0) {
      this.differs[kind] = this.static_diff;
      this.updaters[kind] = this.static_update;
      return;
    }

    let is_svg = !!properties.filter(prop => prop === "svg").length;
    let prop_checks = properties.map(
      prop => `cur["${prop}"] !== old["${prop}"]`
    );
    let diff = new Function(
      "cur",
      "old",
      pad_chunk(2)`
      if(cur.dirty ||
         ${prop_checks.join(" ||\n")}) {
        return true;
      }

      return false;
    `
    ) as (cur: FElem, old: FElem | undefined) => boolean;

    let prop_updaters = properties.map(prop => {
      let update: string = `node["${prop}"] = cur["${prop}"];`;
      let type = this.prop_map[prop];
      if (type === PropType.Attribute || (type === undefined && is_svg)) {
        update = inline_chunk`
          if(cur["${prop}"] === undefined || cur["${prop}"] === false) node.removeAttribute("${prop}");
          else node.setAttribute("${prop}", cur["${prop}"] === true ? "" : cur["${prop}"]);
        `;
      } else if (type === PropType.Event) {
        update = inline_chunk`
          node["${"on" +
            prop}"] = cur["${prop}"] ? lib.handle_event : undefined;
        `;
      } else if (type === PropType.Lifecycle) {
        // This is a no-op, lifecycle events are managed using the element, not the node.
      }

      return inline_chunk`
        if(cur["${prop}"] !== old["${prop}"]) {
          ${update}
        }
      `;
    });

    let update = new Function(
      "cur",
      "old",
      "node",
      "lib",
      pad_chunk(2)`

      ${prop_updaters.join("\n")}
    `
    ) as (
      cur: FElem,
      old: FElem | undefined,
      node: Element | undefined
    ) => void;

    this.differs[kind] = diff;
    this.updaters[kind] = update;
  }
}
