import {
  Options,
  BindableElement,
  ClassOfBinder,
  ClassOfComponent,
  DataElement,
} from "./types/index.js";
import { parseNode } from "./parse-node.js";
import { parseDeclaration } from "./parse-declaration.js";
import { BasicComponent, Component } from "./component/index.js";
import { isCustomElement } from "@ribajs/utils";
import { Binder } from "./binder.js";

/**
 * Sets the attribute on the element. If no binder is matched it will fall
 * back to using this binder.
 */
import { AttributeBinder } from "./binders/attribute.binder.js";

/**
 * A collection of bindings built from a set of parent nodes.
 */
export class View {
  /** Global root scope */
  public static $rootScope: any = {};

  public static bindingComparator = (a: Binder, b: Binder) => {
    const aPriority = a.priority || 0;
    const bPriority = b.priority || 0;
    return bPriority - aPriority;
  };

  /**
   * Helper function to create a new view inside of a binding
   * @param binding
   * @param models
   * @param anchorEl
   */
  public static create(
    binder: Binder,
    models: any,
    anchorEl: HTMLElement | Node | null,
  ) {
    const template = binder.el.cloneNode(true);
    const view = new View(template, models, binder.view.options);
    view.bind();
    if (!binder?.marker?.parentNode) {
      console.warn("[View]: No parent node for binding!");
    } else {
      binder.marker.parentNode.insertBefore(template, anchorEl);
    }
    return view;
  }

  public els: HTMLCollection | HTMLElement[] | Node[];
  public models: any;
  public options: Options;
  public bindings: Array<Binder> = [];
  public webComponents: Array<Component | BasicComponent> = [];
  // public componentView: View | null = null;

  /**
   * The DOM elements and the model objects for binding are passed into the
   * constructor along with any local options that should be used throughout the
   * context of the view and it's bindings.
   * @param els
   * @param models
   * @param options
   */
  constructor(
    els:
      | HTMLCollection
      | HTMLElement
      | Node
      | NodeListOf<ChildNode>
      | HTMLUnknownElement[],
    models: any,
    options: Options,
  ) {
    if (Array.isArray(els)) {
      this.els = els;
    } else {
      this.els = [els] as HTMLElement[] | Node[];
    }

    this.models = models;

    // If $root property exists in model merge this with the global root scope / model
    if (this.models.$root) {
      for (const key of Object.keys(this.models.$root)) {
        View.$rootScope[key] = this.models.$root[key];
      }
    }

    this.models.$root = View.$rootScope;

    this.options = options;

    this.build();
  }

  /**
   * Binds all of the current bindings for this view.
   */
  public bind() {
    this.bindings.forEach((binding) => {
      binding._bind();
    });
  }

  /**
   * Unbinds all of the current bindings for this view.
   */
  public unbind() {
    if (Array.isArray(this.bindings)) {
      this.bindings.forEach((binding) => {
        binding._unbind();
        if (typeof binding.el && this.options.removeBinderAttributes) {
          // TODO reset attribute ?
          // binding.el.setAttribute(attribute.name);
        }
      });
    }

    // TODO fallback to unbind web components
    if (Array.isArray(this.webComponents)) {
      this.webComponents.forEach((webComponent) => {
        if (typeof (webComponent as Component).unbind === "function") {
          (webComponent as Component).unbind();
        }
      });
    }
  }

  /**
   * Syncs up the view with the model by running the routines on all bindings.
   */
  public sync() {
    this.bindings.forEach((binding) => {
      if (binding.sync) {
        binding.sync();
      }
    });
  }

  /**
   * Publishes the input values from the view back to the model (reverse sync).
   */
  public publish() {
    this.bindings.forEach((binder) => {
      if (binder.publishes && binder.publish) {
        binder.publish();
      }
    });
  }

  /**
   * Updates the view's models along with any affected bindings.
   * @param models
   */
  public update(models: any = {}) {
    Object.keys(models).forEach((key) => {
      this.models[key] = models[key];
    });

    for (const binding of this.bindings) {
      if (binding._update) {
        binding._update(models);
      }
    }
  }

  /**
   * Parses the DOM tree and builds `Binding` instances for every matched
   * binding declaration.
   */
  public build() {
    this.bindings = []; // this.bindings || [];

    if (!this.options.templateDelimiters) {
      throw new Error("templateDelimiters required");
    }

    const elements = this.els;
    for (let i = 0; i < elements.length; i++) {
      const element = elements[i];
      if (element) {
        parseNode(
          this,
          element as DataElement,
          this.options.templateDelimiters,
        );
      }
    }

    this.bindings.sort(View.bindingComparator);
  }

  protected startsWithPrefix(name: string) {
    const bindingFullPrefixes = this.options.fullPrefix;
    if (!bindingFullPrefixes) {
      throw new Error("prefix is required");
    }

    for (const fullPrefix of bindingFullPrefixes) {
      if (name.startsWith(fullPrefix)) {
        return fullPrefix;
      }
    }

    return undefined;
  }

  public binderRegex(identifier: string) {
    const wildcards = identifier.match(/\*/g)?.length || 0;
    let regexp: RegExp;
    if (wildcards > 1) {
      regexp = new RegExp(
        `^${identifier.replace("*", "([^-]*)").replaceAll("-*", "-(.+)")}$`,
      );
    } else {
      regexp = new RegExp(`^${identifier.replaceAll("*", "(.+)")}$`);
    }
    return regexp;
  }

  /**
   *
   */
  private bindBinders(
    attributes: NamedNodeMap,
    node: BindableElement,
    attributeBinders = this.options.attributeBinders,
  ) {
    let block = false;
    if (!this.options.binders) {
      return block;
    }
    const bindInfos = [];
    for (let i = 0, len = attributes.length; i < len; i++) {
      let nodeName = "";
      let Binder: ClassOfBinder | null = null;
      const attribute = attributes[i];
      // if attribute starts with the binding prefix. E.g. rv-
      const startingPrefix = this.startsWithPrefix(attribute.name);
      if (startingPrefix) {
        let identifier = "";
        nodeName = attribute.name.slice(startingPrefix.length);

        // if binder is not a attributeBinder binder should be set
        if (this.options.binders[nodeName]) {
          Binder = this.options.binders[nodeName];
        }

        if (Binder === null) {
          // seems to be a star binder
          // Check if any attributeBinder match's
          for (let k = 0; k < attributeBinders.length; k++) {
            identifier = attributeBinders[k];
            const regexp = this.binderRegex(identifier);
            if (regexp.test(nodeName)) {
              Binder = this.options.binders[identifier];
              break;
            }
          }
        }

        if (Binder === null) {
          if (this.options.binders["*"]) {
            Binder = this.options.binders["*"];
            identifier = "*";
          } else {
            Binder = AttributeBinder;
          }
        }
        // if block is set, do not bind its child's (this means the binder bound it by itself)
        // and build binding directly (do not push it to bindInfos array)
        if (Binder.block) {
          this.buildBinding(
            node,
            nodeName,
            attribute.value,
            Binder,
            identifier,
          );
          if (node.removeAttribute && this.options.removeBinderAttributes) {
            node.removeAttribute(attribute.name);
          }
          block = true;
          return block;
        }

        bindInfos.push({ attr: attribute, Binder, nodeName, identifier });
      }
    }

    for (let i = 0; i < bindInfos.length; i++) {
      const bindInfo = bindInfos[i];
      this.buildBinding(
        node,
        bindInfo.nodeName,
        bindInfo.attr.value,
        bindInfo.Binder,
        bindInfo.identifier,
      );
      if (node.removeAttribute && this.options.removeBinderAttributes) {
        node.removeAttribute(bindInfo.attr.name);
      }
    }
    return block;
  }

  private bindComponent(node: BindableElement) {
    let block = false;
    if (!this.options.components) {
      return block;
    }

    const nodeName = node.nodeName.toLowerCase();
    const COMPONENT = this.options.components[nodeName];
    if (COMPONENT) {
      // this.registComponentWithFallback(node, COMPONENT, nodeName);
      this.registerComponent(COMPONENT, nodeName);
      block = true;
    }
    // Also block unknown custom elements except page components
    else if (
      this.options.blockUnknownCustomElements &&
      isCustomElement(node) &&
      !nodeName.endsWith("-page")
    ) {
      block = true;
    }
    return block;
  }

  public traverse(node: BindableElement): boolean {
    /** If true stop / block the parseNode recursion */
    let block = this.options.blockNodeNames.includes(node.nodeName);
    if (block) {
      return block;
    }
    const attributes = node.attributes;

    // bind attribute binders if available
    if (attributes && this.options.binders) {
      block = this.bindBinders(attributes, node);
      if (block) {
        return block;
      }
    }

    // bind components
    if (!block && !node._bound && this.options.components) {
      block = this.bindComponent(node);
    }
    return block;
  }

  public buildBinding(
    node: HTMLUnknownElement | Text,
    type: string | null,
    declaration: string,
    Binder: ClassOfBinder,
    identifier: string | null,
  ) {
    const parsedDeclaration = parseDeclaration(declaration);
    const keypath = parsedDeclaration.keypath;
    const pipes = parsedDeclaration.pipes;
    this.bindings.push(
      new Binder(this, node, type, Binder.key, keypath, pipes, identifier),
    );
  }

  /**
   * Regist all components
   * This can sometimes be useful so that the browser automatically recognizes whether a component is inserted into the dom.
   * However, the components are already registered when they are found by riba in the DOM.
   *
   * Please note, this method does not support the browser fallback for browsers that cannot use custom elements.
   */
  public registerComponents() {
    for (const nodeName in this.options.components) {
      if (this.options.components[nodeName]) {
        // Not already registered?
        if (!customElements.get(nodeName)) {
          const COMPONENT = this.options.components[nodeName];
          this.registerComponent(COMPONENT, nodeName);
        }
      }
    }
  }

  /**
   * Register a custom element using the native customElements feature.
   * @param COMPONENT
   * @param nodeName
   */
  public registerComponent(COMPONENT: ClassOfComponent, nodeName?: string) {
    if (!customElements) {
      console.error("customElements not supported by your browser!");
      throw new Error("customElements not supported by your browser!");
    }

    const resolveNodeName = nodeName || COMPONENT.tagName;
    if (!customElements.get(resolveNodeName)) {
      customElements.define(COMPONENT.tagName, COMPONENT);
    }
  }
}
