import {
  CHECKABLE_INPUTS,
  DEBOUNCE_PREV_KEY,
  DEBOUNCE_TRIGGER,
  FOCUSABLE_INPUTS,
  PHX_COMPONENT,
  PHX_VIEW_REF,
  PHX_TELEPORTED_REF,
  PHX_HAS_FOCUSED,
  PHX_HAS_SUBMITTED,
  PHX_MAIN,
  PHX_PARENT_ID,
  PHX_PRIVATE,
  PHX_REF_SRC,
  PHX_REF_LOCK,
  PHX_PENDING_ATTRS,
  PHX_ROOT_ID,
  PHX_SESSION,
  PHX_STATIC,
  PHX_UPLOAD_REF,
  PHX_VIEW_SELECTOR,
  PHX_STICKY,
  PHX_EVENT_CLASSES,
  THROTTLED,
  PHX_PORTAL,
  PHX_STREAM,
} from "./constants";

import { logError } from "./utils";

const DOM = {
  byId(id) {
    return document.getElementById(id) || logError(`no id found for ${id}`);
  },

  removeClass(el, className) {
    el.classList.remove(className);
    if (el.classList.length === 0) {
      el.removeAttribute("class");
    }
  },

  all(node, query, callback) {
    if (!node) {
      return [];
    }
    const array = Array.from(node.querySelectorAll(query));
    if (callback) {
      array.forEach(callback);
    }
    return array;
  },

  childNodeLength(html) {
    const template = document.createElement("template");
    template.innerHTML = html;
    return template.content.childElementCount;
  },

  isUploadInput(el) {
    return el.type === "file" && el.getAttribute(PHX_UPLOAD_REF) !== null;
  },

  isAutoUpload(inputEl) {
    return inputEl.hasAttribute("data-phx-auto-upload");
  },

  findUploadInputs(node) {
    const formId = node.id;
    const inputsOutsideForm = this.all(
      document,
      `input[type="file"][${PHX_UPLOAD_REF}][form="${formId}"]`,
    );
    return this.all(node, `input[type="file"][${PHX_UPLOAD_REF}]`).concat(
      inputsOutsideForm,
    );
  },

  findComponentNodeList(viewId, cid, doc = document) {
    return this.all(
      doc,
      `[${PHX_VIEW_REF}="${viewId}"][${PHX_COMPONENT}="${cid}"]`,
    );
  },

  isPhxDestroyed(node) {
    return node.id && DOM.private(node, "destroyed") ? true : false;
  },

  wantsNewTab(e) {
    const wantsNewTab =
      e.ctrlKey || e.shiftKey || e.metaKey || (e.button && e.button === 1);
    const isDownload =
      e.target instanceof HTMLAnchorElement &&
      e.target.hasAttribute("download");
    const isTargetBlank =
      e.target.hasAttribute("target") &&
      e.target.getAttribute("target").toLowerCase() === "_blank";
    const isTargetNamedTab =
      e.target.hasAttribute("target") &&
      !e.target.getAttribute("target").startsWith("_");
    return wantsNewTab || isTargetBlank || isDownload || isTargetNamedTab;
  },

  isUnloadableFormSubmit(e) {
    // Ignore form submissions intended to close a native <dialog> element
    // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog#usage_notes
    const isDialogSubmit =
      (e.target && e.target.getAttribute("method") === "dialog") ||
      (e.submitter && e.submitter.getAttribute("formmethod") === "dialog");

    if (isDialogSubmit) {
      return false;
    } else {
      return !e.defaultPrevented && !this.wantsNewTab(e);
    }
  },

  isNewPageClick(e, currentLocation) {
    const href =
      e.target instanceof HTMLAnchorElement
        ? e.target.getAttribute("href")
        : null;
    let url;

    if (e.defaultPrevented || href === null || this.wantsNewTab(e)) {
      return false;
    }
    if (href.startsWith("mailto:") || href.startsWith("tel:")) {
      return false;
    }
    if (e.target.isContentEditable) {
      return false;
    }

    try {
      url = new URL(href);
    } catch {
      try {
        url = new URL(href, currentLocation);
      } catch {
        // bad URL, fallback to let browser try it as external
        return true;
      }
    }

    if (
      url.host === currentLocation.host &&
      url.protocol === currentLocation.protocol
    ) {
      if (
        url.pathname === currentLocation.pathname &&
        url.search === currentLocation.search
      ) {
        return url.hash === "" && !url.href.endsWith("#");
      }
    }
    return url.protocol.startsWith("http");
  },

  markPhxChildDestroyed(el) {
    if (this.isPhxChild(el)) {
      el.setAttribute(PHX_SESSION, "");
    }
    this.putPrivate(el, "destroyed", true);
  },

  findPhxChildrenInFragment(html, parentId) {
    const template = document.createElement("template");
    template.innerHTML = html;
    return this.findPhxChildren(template.content, parentId);
  },

  isIgnored(el, phxUpdate) {
    return (
      (el.getAttribute(phxUpdate) || el.getAttribute("data-phx-update")) ===
      "ignore"
    );
  },

  isPhxUpdate(el, phxUpdate, updateTypes) {
    return (
      el.getAttribute && updateTypes.indexOf(el.getAttribute(phxUpdate)) >= 0
    );
  },

  findPhxSticky(el) {
    return this.all(el, `[${PHX_STICKY}]`);
  },

  findPhxChildren(el, parentId) {
    return this.all(el, `${PHX_VIEW_SELECTOR}[${PHX_PARENT_ID}="${parentId}"]`);
  },

  findExistingParentCIDs(viewId, cids) {
    // we only want to find parents that exist on the page
    // if a cid is not on the page, the only way it can be added back to the page
    // is if a parent adds it back, therefore if a cid does not exist on the page,
    // we should not try to render it by itself (because it would be rendered twice,
    // one by the parent, and a second time by itself)
    const parentCids = new Set();
    const childrenCids = new Set();

    cids.forEach((cid) => {
      this.all(
        document,
        `[${PHX_VIEW_REF}="${viewId}"][${PHX_COMPONENT}="${cid}"]`,
      ).forEach((parent) => {
        parentCids.add(cid);
        this.all(parent, `[${PHX_VIEW_REF}="${viewId}"][${PHX_COMPONENT}]`)
          .map((el) => parseInt(el.getAttribute(PHX_COMPONENT)))
          .forEach((childCID) => childrenCids.add(childCID));
      });
    });

    childrenCids.forEach((childCid) => parentCids.delete(childCid));

    return parentCids;
  },

  private(el, key) {
    return el[PHX_PRIVATE] && el[PHX_PRIVATE][key];
  },

  deletePrivate(el, key) {
    el[PHX_PRIVATE] && delete el[PHX_PRIVATE][key];
  },

  putPrivate(el, key, value) {
    if (!el[PHX_PRIVATE]) {
      el[PHX_PRIVATE] = {};
    }
    el[PHX_PRIVATE][key] = value;
  },

  updatePrivate(el, key, defaultVal, updateFunc) {
    const existing = this.private(el, key);
    if (existing === undefined) {
      this.putPrivate(el, key, updateFunc(defaultVal));
    } else {
      this.putPrivate(el, key, updateFunc(existing));
    }
  },

  syncPendingAttrs(fromEl, toEl) {
    if (!fromEl.hasAttribute(PHX_REF_SRC)) {
      return;
    }
    PHX_EVENT_CLASSES.forEach((className) => {
      fromEl.classList.contains(className) && toEl.classList.add(className);
    });
    PHX_PENDING_ATTRS.filter((attr) => fromEl.hasAttribute(attr)).forEach(
      (attr) => {
        toEl.setAttribute(attr, fromEl.getAttribute(attr));
      },
    );
  },

  copyPrivates(target, source) {
    if (source[PHX_PRIVATE]) {
      target[PHX_PRIVATE] = source[PHX_PRIVATE];
    }
  },

  putTitle(str) {
    const titleEl = document.querySelector("title");
    if (titleEl) {
      const { prefix, suffix, default: defaultTitle } = titleEl.dataset;
      const isEmpty = typeof str !== "string" || str.trim() === "";
      if (isEmpty && typeof defaultTitle !== "string") {
        return;
      }

      const inner = isEmpty ? defaultTitle : str;
      document.title = `${prefix || ""}${inner || ""}${suffix || ""}`;
    } else {
      document.title = str;
    }
  },

  debounce(
    el,
    event,
    phxDebounce,
    defaultDebounce,
    phxThrottle,
    defaultThrottle,
    asyncFilter,
    callback,
  ) {
    let debounce = el.getAttribute(phxDebounce);
    let throttle = el.getAttribute(phxThrottle);

    if (debounce === "") {
      debounce = defaultDebounce;
    }
    if (throttle === "") {
      throttle = defaultThrottle;
    }
    const value = debounce || throttle;
    switch (value) {
      case null:
        return callback();

      case "blur":
        this.incCycle(el, "debounce-blur-cycle", () => {
          if (asyncFilter()) {
            callback();
          }
        });
        if (this.once(el, "debounce-blur")) {
          el.addEventListener("blur", () =>
            this.triggerCycle(el, "debounce-blur-cycle"),
          );
        }
        return;

      default:
        const timeout = parseInt(value);
        const trigger = () =>
          throttle ? this.deletePrivate(el, THROTTLED) : callback();
        const currentCycle = this.incCycle(el, DEBOUNCE_TRIGGER, trigger);
        if (isNaN(timeout)) {
          return logError(`invalid throttle/debounce value: ${value}`);
        }
        if (throttle) {
          let newKeyDown = false;
          if (event.type === "keydown") {
            const prevKey = this.private(el, DEBOUNCE_PREV_KEY);
            this.putPrivate(el, DEBOUNCE_PREV_KEY, event.key);
            newKeyDown = prevKey !== event.key;
          }

          if (!newKeyDown && this.private(el, THROTTLED)) {
            return false;
          } else {
            callback();
            const t = setTimeout(() => {
              if (asyncFilter()) {
                this.triggerCycle(el, DEBOUNCE_TRIGGER);
              }
            }, timeout);
            this.putPrivate(el, THROTTLED, t);
          }
        } else {
          setTimeout(() => {
            if (asyncFilter()) {
              this.triggerCycle(el, DEBOUNCE_TRIGGER, currentCycle);
            }
          }, timeout);
        }

        const form = el.form;
        if (form && this.once(form, "bind-debounce")) {
          form.addEventListener("submit", () => {
            Array.from(new FormData(form).entries(), ([name]) => {
              const input = form.querySelector(`[name="${name}"]`);
              this.incCycle(input, DEBOUNCE_TRIGGER);
              this.deletePrivate(input, THROTTLED);
            });
          });
        }
        if (this.once(el, "bind-debounce")) {
          el.addEventListener("blur", () => {
            // because we trigger the callback here,
            // we also clear the throttle timeout to prevent the callback
            // from being called again after the timeout fires
            clearTimeout(this.private(el, THROTTLED));
            this.triggerCycle(el, DEBOUNCE_TRIGGER);
          });
        }
    }
  },

  triggerCycle(el, key, currentCycle) {
    const [cycle, trigger] = this.private(el, key);
    if (!currentCycle) {
      currentCycle = cycle;
    }
    if (currentCycle === cycle) {
      this.incCycle(el, key);
      trigger();
    }
  },

  once(el, key) {
    if (this.private(el, key) === true) {
      return false;
    }
    this.putPrivate(el, key, true);
    return true;
  },

  incCycle(el, key, trigger = function () {}) {
    let [currentCycle] = this.private(el, key) || [0, trigger];
    currentCycle++;
    this.putPrivate(el, key, [currentCycle, trigger]);
    return currentCycle;
  },

  // maintains or adds privately used hook information
  // fromEl and toEl can be the same element in the case of a newly added node
  // fromEl and toEl can be any HTML node type, so we need to check if it's an element node
  maintainPrivateHooks(fromEl, toEl, phxViewportTop, phxViewportBottom) {
    // maintain the hooks created with createHook
    if (
      fromEl.hasAttribute &&
      fromEl.hasAttribute("data-phx-hook") &&
      !toEl.hasAttribute("data-phx-hook")
    ) {
      toEl.setAttribute("data-phx-hook", fromEl.getAttribute("data-phx-hook"));
    }
    // add hooks to elements with viewport attributes
    if (
      toEl.hasAttribute &&
      (toEl.hasAttribute(phxViewportTop) ||
        toEl.hasAttribute(phxViewportBottom))
    ) {
      toEl.setAttribute("data-phx-hook", "Phoenix.InfiniteScroll");
    }
  },

  putCustomElHook(el, hook) {
    if (el.isConnected) {
      el.setAttribute("data-phx-hook", "");
    } else {
      console.error(`
        hook attached to non-connected DOM element
        ensure you are calling createHook within your connectedCallback. ${el.outerHTML}
      `);
    }
    this.putPrivate(el, "custom-el-hook", hook);
  },

  getCustomElHook(el) {
    return this.private(el, "custom-el-hook");
  },

  isUsedInput(el) {
    return (
      el.nodeType === Node.ELEMENT_NODE &&
      (this.private(el, PHX_HAS_FOCUSED) || this.private(el, PHX_HAS_SUBMITTED))
    );
  },

  resetForm(form) {
    Array.from(form.elements).forEach((input) => {
      this.deletePrivate(input, PHX_HAS_FOCUSED);
      this.deletePrivate(input, PHX_HAS_SUBMITTED);
    });
  },

  isPhxChild(node) {
    return node.getAttribute && node.getAttribute(PHX_PARENT_ID);
  },

  isPhxSticky(node) {
    return node.getAttribute && node.getAttribute(PHX_STICKY) !== null;
  },

  isChildOfAny(el, parents) {
    return !!parents.find((parent) => parent.contains(el));
  },

  firstPhxChild(el) {
    return this.isPhxChild(el) ? el : this.all(el, `[${PHX_PARENT_ID}]`)[0];
  },

  isPortalTemplate(el) {
    return el.tagName === "TEMPLATE" && el.hasAttribute(PHX_PORTAL);
  },

  closestViewEl(el) {
    // find the closest portal or view element, whichever comes first
    const portalOrViewEl = el.closest(
      `[${PHX_TELEPORTED_REF}],${PHX_VIEW_SELECTOR}`,
    );
    if (!portalOrViewEl) {
      return null;
    }
    if (portalOrViewEl.hasAttribute(PHX_TELEPORTED_REF)) {
      // PHX_TELEPORTED_REF is set to the id of the view that owns the portal element
      return this.byId(portalOrViewEl.getAttribute(PHX_TELEPORTED_REF));
    } else if (portalOrViewEl.hasAttribute(PHX_SESSION)) {
      return portalOrViewEl;
    }
    return null;
  },

  dispatchEvent(target, name, opts = {}) {
    let defaultBubble = true;
    const isUploadTarget =
      target.nodeName === "INPUT" && target.type === "file";
    if (isUploadTarget && name === "click") {
      defaultBubble = false;
    }
    const bubbles = opts.bubbles === undefined ? defaultBubble : !!opts.bubbles;
    const eventOpts = {
      bubbles: bubbles,
      cancelable: true,
      detail: opts.detail || {},
    };
    const event =
      name === "click"
        ? new MouseEvent("click", eventOpts)
        : new CustomEvent(name, eventOpts);
    target.dispatchEvent(event);
  },

  cloneNode(node, html) {
    if (typeof html === "undefined") {
      return node.cloneNode(true);
    } else {
      const cloned = node.cloneNode(false);
      cloned.innerHTML = html;
      return cloned;
    }
  },

  // merge attributes from source to target
  // if an element is ignored, we only merge data attributes
  // including removing data attributes that are no longer in the source
  mergeAttrs(target, source, opts = {}) {
    const exclude = new Set(opts.exclude || []);
    const isIgnored = opts.isIgnored;
    const sourceAttrs = source.attributes;
    for (let i = sourceAttrs.length - 1; i >= 0; i--) {
      const name = sourceAttrs[i].name;
      if (!exclude.has(name)) {
        const sourceValue = source.getAttribute(name);
        if (
          target.getAttribute(name) !== sourceValue &&
          (!isIgnored || (isIgnored && name.startsWith("data-")))
        ) {
          target.setAttribute(name, sourceValue);
        }
      } else {
        // We exclude the value from being merged on focused inputs, because the
        // user's input should always win.
        // We can still assign it as long as the value property is the same, though.
        // This prevents a situation where the updated hook is not being triggered
        // when an input is back in its "original state", because the attribute
        // was never changed, see:
        // https://github.com/phoenixframework/phoenix_live_view/issues/2163
        if (name === "value" && target.value === source.value) {
          // actually set the value attribute to sync it with the value property
          target.setAttribute("value", source.getAttribute(name));
        }
      }
    }

    const targetAttrs = target.attributes;
    for (let i = targetAttrs.length - 1; i >= 0; i--) {
      const name = targetAttrs[i].name;
      if (isIgnored) {
        if (
          name.startsWith("data-") &&
          !source.hasAttribute(name) &&
          !PHX_PENDING_ATTRS.includes(name)
        ) {
          target.removeAttribute(name);
        }
      } else {
        if (!source.hasAttribute(name)) {
          target.removeAttribute(name);
        }
      }
    }
  },

  mergeFocusedInput(target, source) {
    // skip selects because FF will reset highlighted index for any setAttribute
    if (!(target instanceof HTMLSelectElement)) {
      DOM.mergeAttrs(target, source, { exclude: ["value"] });
    }

    if (source.readOnly) {
      target.setAttribute("readonly", true);
    } else {
      target.removeAttribute("readonly");
    }
  },

  hasSelectionRange(el) {
    return (
      el.setSelectionRange && (el.type === "text" || el.type === "textarea")
    );
  },

  restoreFocus(focused, selectionStart, selectionEnd) {
    if (focused instanceof HTMLSelectElement) {
      focused.focus();
    }
    if (!DOM.isTextualInput(focused)) {
      return;
    }

    const wasFocused = focused.matches(":focus");
    if (!wasFocused) {
      focused.focus();
    }
    if (this.hasSelectionRange(focused)) {
      focused.setSelectionRange(selectionStart, selectionEnd);
    }
  },

  isFormInput(el) {
    if (el.localName && customElements.get(el.localName)) {
      // Custom Elements may be form associated. This allows them
      // to participate within a form's lifecycle, including form
      // validity and form submissions.
      // The spec for Form Associated custom elements requires the
      // custom element's class to contain a static boolean value of `formAssociated`
      // which identifies this class as allowed to associate to a form.
      // See https://html.spec.whatwg.org/dev/custom-elements.html#custom-elements-face-example
      // for details.
      return customElements.get(el.localName)[`formAssociated`];
    }

    return (
      /^(?:input|select|textarea)$/i.test(el.tagName) && el.type !== "button"
    );
  },

  syncAttrsToProps(el) {
    if (
      el instanceof HTMLInputElement &&
      CHECKABLE_INPUTS.indexOf(el.type.toLocaleLowerCase()) >= 0
    ) {
      el.checked = el.getAttribute("checked") !== null;
    }
  },

  isTextualInput(el) {
    return FOCUSABLE_INPUTS.indexOf(el.type) >= 0;
  },

  isNowTriggerFormExternal(el, phxTriggerExternal) {
    return (
      el.getAttribute &&
      el.getAttribute(phxTriggerExternal) !== null &&
      document.body.contains(el)
    );
  },

  cleanChildNodes(container, phxUpdate) {
    if (
      DOM.isPhxUpdate(container, phxUpdate, ["append", "prepend", PHX_STREAM])
    ) {
      const toRemove = [];
      container.childNodes.forEach((childNode) => {
        if (!childNode.id) {
          // Skip warning if it's an empty text node (e.g. a new-line)
          const isEmptyTextNode =
            childNode.nodeType === Node.TEXT_NODE &&
            childNode.nodeValue.trim() === "";
          if (!isEmptyTextNode && childNode.nodeType !== Node.COMMENT_NODE) {
            logError(
              "only HTML element tags with an id are allowed inside containers with phx-update.\n\n" +
                `removing illegal node: "${(childNode.outerHTML || childNode.nodeValue).trim()}"\n\n`,
            );
          }
          toRemove.push(childNode);
        }
      });
      toRemove.forEach((childNode) => childNode.remove());
    }
  },

  replaceRootContainer(container, tagName, attrs) {
    const retainedAttrs = new Set([
      "id",
      PHX_SESSION,
      PHX_STATIC,
      PHX_MAIN,
      PHX_ROOT_ID,
    ]);
    if (container.tagName.toLowerCase() === tagName.toLowerCase()) {
      Array.from(container.attributes)
        .filter((attr) => !retainedAttrs.has(attr.name.toLowerCase()))
        .forEach((attr) => container.removeAttribute(attr.name));

      Object.keys(attrs)
        .filter((name) => !retainedAttrs.has(name.toLowerCase()))
        .forEach((attr) => container.setAttribute(attr, attrs[attr]));

      return container;
    } else {
      const newContainer = document.createElement(tagName);
      Object.keys(attrs).forEach((attr) =>
        newContainer.setAttribute(attr, attrs[attr]),
      );
      retainedAttrs.forEach((attr) =>
        newContainer.setAttribute(attr, container.getAttribute(attr)),
      );
      newContainer.innerHTML = container.innerHTML;
      container.replaceWith(newContainer);
      return newContainer;
    }
  },

  getSticky(el, name, defaultVal) {
    const op = (DOM.private(el, "sticky") || []).find(
      ([existingName]) => name === existingName,
    );
    if (op) {
      const [_name, _op, stashedResult] = op;
      return stashedResult;
    } else {
      return typeof defaultVal === "function" ? defaultVal() : defaultVal;
    }
  },

  deleteSticky(el, name) {
    this.updatePrivate(el, "sticky", [], (ops) => {
      return ops.filter(([existingName, _]) => existingName !== name);
    });
  },

  putSticky(el, name, op) {
    const stashedResult = op(el);
    this.updatePrivate(el, "sticky", [], (ops) => {
      const existingIndex = ops.findIndex(
        ([existingName]) => name === existingName,
      );
      if (existingIndex >= 0) {
        ops[existingIndex] = [name, op, stashedResult];
      } else {
        ops.push([name, op, stashedResult]);
      }
      return ops;
    });
  },

  applyStickyOperations(el) {
    const ops = DOM.private(el, "sticky");
    if (!ops) {
      return;
    }

    ops.forEach(([name, op, _stashed]) => this.putSticky(el, name, op));
  },

  isLocked(el) {
    return el.hasAttribute && el.hasAttribute(PHX_REF_LOCK);
  },
};

export default DOM;
