/**
 * Various dom manipulation utils that have followed me around.
 */
const DIRECT_ATTRIBUTE_MAP: {[name: string]: string} = {
  cellpadding: 'cellPadding',
  cellspacing: 'cellSpacing',
  colspan: 'colSpan',
  frameborder: 'frameBorder',
  height: 'height',
  maxlength: 'maxLength',
  nonce: 'nonce',
  role: 'role',
  rowspan: 'rowSpan',
  type: 'type',
  usemap: 'useMap',
  valign: 'vAlign',
  width: 'width',
};

const RGX_NUMERIC_STYLE_UNIT = 'px';
const RGX_NUMERIC_STYLE = /^((max|min)?(width|height)|margin|padding|(margin|padding)?(left|top|bottom|right)|fontsize|borderwidth)$/i;
const RGX_DEFAULT_VALUE_PROP = /input|textarea|select/i;


function localAssertNotFalsy<T>(input?: T|null, errorMsg = `Input is not of type.`) : T {
  if (input == null) {
    throw new Error(errorMsg);
  }
  return input;
}


const RGX_STRING_VALID = '[a-z0-9_-]';
const RGX_TAG = new RegExp(`^([a-z]${RGX_STRING_VALID}*)(\\.|\\[|\\#|$)`, 'i');
const RGX_ATTR_ID = new RegExp(`#(${RGX_STRING_VALID}+)`, 'gi');
const RGX_ATTR_CLASS = new RegExp(`(^|\\S)\\.([a-z0-9_\\-\\.]+)`, 'gi');
const RGX_STRING_CONTENT_TO_SQUARES = '(.*?)(\\[|\\])';
const RGX_ATTRS_MAYBE_OPEN = new RegExp(`\\[${RGX_STRING_CONTENT_TO_SQUARES}`, 'gi');
const RGX_ATTRS_FOLLOW_OPEN = new RegExp(`^${RGX_STRING_CONTENT_TO_SQUARES}`, 'gi');

export function query<K extends keyof HTMLElementTagNameMap>(selectors: K, parent?: HTMLElement|Document): Array<HTMLElementTagNameMap[K]>;
export function query<K extends keyof SVGElementTagNameMap>(selectors: K, parent?: HTMLElement|Document): Array<SVGElementTagNameMap[K]>;
export function query<K extends keyof MathMLElementTagNameMap>(selectors: K, parent?: HTMLElement|Document): Array<MathMLElementTagNameMap[K]>;
export function query<T extends HTMLElement>(selectors: string, parent?: HTMLElement|Document): Array<T>;
export function query(selectors: string, parent: HTMLElement|Document = document) {
  return Array.from(parent.querySelectorAll(selectors)).filter(n => !!n);
}

export function queryOne<K extends keyof HTMLElementTagNameMap>(selectors: K, parent?: HTMLElement|Document): HTMLElementTagNameMap[K] | null;
export function queryOne<K extends keyof SVGElementTagNameMap>(selectors: K, parent?: HTMLElement|Document): SVGElementTagNameMap[K] | null;
export function queryOne<K extends keyof MathMLElementTagNameMap>(selectors: K, parent?: HTMLElement|Document): MathMLElementTagNameMap[K] | null;
export function queryOne<T extends HTMLElement>(selectors: string, parent?: HTMLElement|Document): T | null;
export function queryOne(selectors: string, parent: HTMLElement|Document = document)  {
  return parent.querySelector(selectors) ?? null;
}

export function createText(text: string) {
  return document.createTextNode(text);
}

export function getClosestOrSelf(element: EventTarget|HTMLElement|null, query: string) : HTMLElement|null {
  const el = (element as HTMLElement);
  return (el?.closest && (el.matches(query) && el || el.closest(query)) as HTMLElement) || null;
}

export function containsOrSelf(parent: EventTarget|HTMLElement|null, contained: EventTarget|HTMLElement|null) : boolean {
  return parent === contained || (parent as HTMLElement)?.contains?.(contained as HTMLElement) || false;
}

type Attrs = {
  [name: string]: any;
};

export function createElement<T extends HTMLElement>(selectorOrMarkup: string, attrs?: Attrs) {
  const frag = getHtmlFragment(selectorOrMarkup);
  let element = frag?.firstElementChild as HTMLElement;
  let selector = "";
  if (!element) {
    selector = selectorOrMarkup.replace(/[\r\n]\s*/g, "");
    const tag = getSelectorTag(selector) || "div";
    element = document.createElement(tag);
    selector = selector.replace(RGX_TAG, "$2");
    // Turn id and classname into [attr]s that can be nested
    selector = selector.replace(RGX_ATTR_ID, '[id="$1"]');
    selector = selector.replace(
      RGX_ATTR_CLASS,
      (match, p1, p2) => `${p1}[class="${p2.replace(/\./g, " ")}"]`,
    );
  }

  const selectorAttrs = getSelectorAttributes(selector);
  if (selectorAttrs) {
    for (const attr of selectorAttrs) {
      let matches = attr.substring(1, attr.length - 1).split("=");
      let key = localAssertNotFalsy(matches.shift());
      let value: string = matches.join("=");
      if (value === undefined) {
        setAttribute(element, key, true);
      } else {
        value = value.replace(/^['"](.*)['"]$/, "$1");
        setAttribute(element, key, value);
      }
    }
  }
  if (attrs) {
    setAttributes(element, attrs);
  }
  return element as T;
}
export const $el = createElement;

function getSelectorTag(str: string) {
  return tryMatch(str, RGX_TAG);
}

function getSelectorAttributes(selector: string) {
  RGX_ATTRS_MAYBE_OPEN.lastIndex = 0;
  let attrs: string[] = [];
  let result;
  while (result = RGX_ATTRS_MAYBE_OPEN.exec(selector)) {
    let attr = result[0];
    if (attr.endsWith(']')) {
      attrs.push(attr);
    } else {
      attr = result[0]
          + getOpenAttributesRecursive(selector.substr(RGX_ATTRS_MAYBE_OPEN.lastIndex), 2);
      RGX_ATTRS_MAYBE_OPEN.lastIndex += (attr.length - result[0].length);
      attrs.push(attr);
    }
  }
  return attrs;
}


function getOpenAttributesRecursive(selectorSubstring: string, openCount: number) {
  let matches = selectorSubstring.match(RGX_ATTRS_FOLLOW_OPEN);
  let result = '';
  if (matches && matches.length) {
    result = matches[0];
    openCount += result.endsWith(']') ? -1 : 1;
    if (openCount > 0) {
      result += getOpenAttributesRecursive(selectorSubstring.substr(result.length), openCount);
    }
  }
  return result;
}

function tryMatch(str: string, rgx: RegExp, index = 1) {
  let found = '';
  try {
    found = str.match(rgx)?.[index] || '';
  } catch (e) {
    found = '';
  }
  return found;
}

export function setAttributes(element: HTMLElement, data: {[name: string]: any}) {
  let attr;
  for (attr in data) {
    if (data.hasOwnProperty(attr)) {
      setAttribute(element, attr, data[attr]);
    }
  }
}

function getHtmlFragment(value: string) {
  if (value.match(/^\s*<.*?>[\s\S]*<\/[a-z0-9]+>\s*$/)) {
    return document.createRange().createContextualFragment(value.trim());
  }
  return null;
}

function getChild(value: any) : HTMLElement|DocumentFragment|Text|null {
  if (value instanceof Node) {
    return value as HTMLElement;
  }
  if (typeof value === 'string') {
    let child = getHtmlFragment(value);
    if (child) {
      return child;
    }
    if (getSelectorTag(value)) {
      return createElement(value);
    }
    return createText(value);
  }
  if (value && typeof value.toElement === 'function') {
    return value.toElement() as HTMLElement;
  }
  return null;
}


export function setAttribute(element: HTMLElement, attribute: string, value: any) {
  let isRemoving = value == null;

  if (attribute === 'default') {
    attribute = RGX_DEFAULT_VALUE_PROP.test(element.nodeName) ? 'value' : 'text';
  }

  if (attribute === 'text') {
    empty(element).appendChild(createText(value != null ? String(value) : ''));

  } else if (attribute === 'html') {
    empty(element).innerHTML += value != null ? String(value) : '';

  } else if (attribute == 'style') {
    if (typeof value === 'string') {
      element.style.cssText = isRemoving ? '' : (value != null ? String(value) : '');
    } else {
      for (const [styleKey, styleValue] of Object.entries(value as {[key: string]: any})) {
        element.style[styleKey as 'display'] = styleValue;
      }
    }

  } else if (attribute == 'events') {
    for (const [key, fn] of Object.entries(value as {[key: string]: (e: Event) => void})) {
      addEvent(element, key, fn);
    }

  } else if (attribute === 'parent') {
    value.appendChild(element);

  } else if (attribute === 'child' || attribute === 'children') {
    // Try to handle an array, like [li,li,li]. Not nested brackets, though
    if (typeof value === 'string' && /^\[[^\[\]]+\]$/.test(value)) {
      const parseable = value.replace(/^\[([^\[\]]+)\]$/, '["$1"]').replace(/,/g, '","');
      try {
        const parsed = JSON.parse(parseable);
        value = parsed;
      } catch(e) {
        console.error(e);
      }
    }

    // "children" is a replace of the children, while "child" appends a new child if others exist.
    if (attribute === 'children') {
      empty(element);
    }

    let children = value instanceof Array ? value : [value];
    for (let child of children) {
      child = getChild(child);
      if (child instanceof Node) {
        if (element instanceof HTMLTemplateElement) {
          element.content.appendChild(child);
        } else {
          element.appendChild(child);
        }
      }
    }

  } else if (attribute == 'for') {
    (element as HTMLLabelElement).htmlFor = value != null ? String(value) : '';
    if (isRemoving) {
      // delete (element as HTMLLabelElement).htmlFor;
      element.removeAttribute('for');
    }

  } else if (attribute === 'class' || attribute === 'className' || attribute === 'classes') {
    element.className = isRemoving ? '' : Array.isArray(value) ? value.join(' ') : String(value);

  } else if (attribute === 'dataset') {
    if (typeof value !== 'object') {
      console.error('Expecting an object for dataset');
      return;
    }
    for (const [key, val] of Object.entries(value)) {
      element.dataset[key] = String(val);
    }

  } else if (attribute.startsWith('on') && typeof value === 'function') {
    element.addEventListener(attribute.substring(2), value);

  } else if (['checked', 'disabled', 'readonly', 'required', 'selected'].includes(attribute)) {
    // Could be input, button, etc. We are not discriminate.
    (element as HTMLInputElement)[attribute as 'checked'] = !!value;
    if (!value) {
      (element as HTMLInputElement).removeAttribute(attribute);
    } else {
      (element as HTMLInputElement).setAttribute(attribute, attribute);
    }

  } else if (DIRECT_ATTRIBUTE_MAP.hasOwnProperty(attribute)) {
    if (isRemoving) {
      element.removeAttribute(DIRECT_ATTRIBUTE_MAP[attribute]!);
    } else {
      element.setAttribute(DIRECT_ATTRIBUTE_MAP[attribute]!, String(value));
    }

  } else if (isRemoving) {
    element.removeAttribute(attribute);

  } else {
    let oldVal = element.getAttribute(attribute);
    if (oldVal !== value) {
      element.setAttribute(attribute, String(value));
    }
  }
}

function addEvent(element: HTMLElement, key: string, fn: (e:Event) => void) {
  element.addEventListener(key, fn);
}

function setStyles(element: HTMLElement, styles: {[name: string]: string|number}|null = null) {
  if (styles) {
    for (let name in styles) {
      setStyle(element, name, styles[name]!);
    }
  }
  return element;
}

function setStyle(element: HTMLElement, name: string, value: string|number|null) {
  // Note: Old IE uses 'styleFloat'
  name = (name.indexOf('float') > -1 ? 'cssFloat' : name);
  // Camelcase
  if (name.indexOf('-') != -1) {
    name = name.replace(/-\D/g, (match) => {
      return match.charAt(1).toUpperCase();
    });
  }
  if (value == String(Number(value)) && RGX_NUMERIC_STYLE.test(name)) {
    value = value + RGX_NUMERIC_STYLE_UNIT;
  }
  if (name === 'display' && typeof value !== 'string') {
    value = !!value ? null : 'none';
  }
  (element.style as any)[name] = value === null ? null : String(value);
  return element;
};

export function empty(element: HTMLElement) {
  while (element.firstChild) {
    element.removeChild(element.firstChild);
  }
  return element;
}

type ChildType = HTMLElement|DocumentFragment|Text|string|null;
export function appendChildren(el: HTMLElement, children: ChildType|ChildType[]) {
  children = !Array.isArray(children) ? [children] : children;
  for (let child of children) {
    child = getChild(child);
    if (child instanceof Node) {
      if (el instanceof HTMLTemplateElement) {
        el.content.appendChild(child);
      } else {
        el.appendChild(child);
      }
    }
  }
}
