import A11yDialog from "a11y-dialog";
import { isTabbable } from "tabbable";

interface ModalConfig {
  /** Class which indicates that modal is open */
  classModalIsOpen: string;
  /** Class on body which indicates that modal is open */
  classModalIsOpenBody: string;
  /** Root of page content which should be hidden when modal is open */
  root: string;
  /** Move modal into this element selector (must be unique in DOM) */
  modalsRoot: string;
  /** Disable moving modal to #root-modals (for React controlled mode) */
  disablePortal?: boolean;
}

const defaultConfig = (): ModalConfig => ({
  /** Class which indicates that modal is open */
  classModalIsOpen: "is-active",
  /** Class on body which indicates that modal is open */
  classModalIsOpenBody: "has-modal",
  /** Root of page content which should be hidden when modal is open */
  root: "#root",
  /** Move modal into this element selector (must be unique in DOM) */
  modalsRoot: "#root-modals",
});

const MODAL_SCROLLBAR_WIDTH_VAR = "--ods-modal-scrollbar-width";
const BODY_LOCK_SCROLL_TOP_ATTR = "data-lock-scrolltop";

interface HTMLElementWithModal extends HTMLElement {
  ODS_Modal?: Modal;
}

export default class Modal {
  element: HTMLElementWithModal;
  config: ModalConfig;
  instance!: A11yDialog; // Using definite assignment assertion as it's initialized in init()

  constructor(element: HTMLElement, config?: Partial<ModalConfig>) {
    this.element = element as HTMLElementWithModal;
    this.config = { ...defaultConfig(), ...config };

    this.handleShow = this.handleShow.bind(this);
    this.handleHide = this.handleHide.bind(this);
    this.show = this.show.bind(this);
    this.hide = this.hide.bind(this);
    this.headerFirstItemSpacing = this.headerFirstItemSpacing.bind(this);

    Modal.moveToModalRoot = Modal.moveToModalRoot.bind(this);
    Modal.lockBody = Modal.lockBody.bind(this);
    Modal.unlockBody = Modal.unlockBody.bind(this);

    this.element.ODS_Modal = this;

    this.init();

    return this;
  }

  static getInstance(el: HTMLElement | null): Modal | null {
    if (!el) return null;
    const modalEl = el as HTMLElementWithModal;
    return modalEl.ODS_Modal || null;
  }

  handleShow(el: HTMLElement): void {
    Modal.lockBody();
    if (el) el.classList.add(this.config.classModalIsOpen);

    // Long modals can have first focusabble element under the fold, so static content above the fold should be focused instead to prevent undesirable scrolling
    const initialFocusEl = el.querySelector("[data-a11y-dialog-initial-focus]");

    if (!initialFocusEl) {
      return;
    }

    // set tabindex to -1 so element can't be focused via keyboard
    if (!isTabbable(initialFocusEl as HTMLElement)) {
      initialFocusEl.setAttribute("tabindex", "-1");
    }

    (initialFocusEl as HTMLElement).focus();
  }

  handleHide(el: HTMLElement): void {
    Modal.unlockBody();
    if (el) el.classList.remove(this.config.classModalIsOpen);
  }

  show(): void {
    this.instance.show();
  }

  hide(): void {
    this.instance.hide();
  }

  headerFirstItemSpacing(): void {
    /**
     * Target the first element which is not .btn inside .modal__header and add margin-right 40px.
     * css has its limitations and it is not possible with pseudo selectors
     */
    const modalHeader = this.element.querySelector(".modal__header");
    if (modalHeader) {
      const hasNoSpacingClass = modalHeader.classList.contains(
        "modal__header--no-spacing",
      );
      const firstNonBtnElement = Array.from(modalHeader.children).find(
        (child) => !child.classList.contains("btn"),
      );
      if (firstNonBtnElement && !hasNoSpacingClass) {
        (firstNonBtnElement as HTMLElement).style.marginRight = "40px";
      }
    }
  }

  init(): void {
    if (this.config.modalsRoot && !this.config.disablePortal) {
      Modal.moveToModalRoot(
        this.element,
        document.querySelector(this.config.modalsRoot),
      );
    }

    this.instance = new A11yDialog(this.element);

    this.instance.on("show", (event: Event) => {
      const dialogEl = event.currentTarget as HTMLElement;
      this.handleShow(dialogEl);
    });

    this.instance.on("hide", (event: Event) => {
      const dialogEl = event.currentTarget as HTMLElement;
      this.handleHide(dialogEl);
    });

    this.headerFirstItemSpacing();
  }

  destroy(): void {
    if (this.instance) {
      // Don't call this.instance.destroy() because a11y-dialog's destroy
      // does replaceWith(cloneNode(true)) which breaks React!
      // Instead, just hide and let the instance be garbage collected
      this.instance.hide();
      // Cast to any to access private methods for cleanup
      const dialog = this.instance as any;
      document.removeEventListener("click", dialog.handleTriggerClicks, true);
      document.body.removeEventListener("focus", dialog.maintainFocus, true);
      this.element.removeEventListener("keydown", dialog.bindKeypress, true);

      // Make sure scroll is unlocked when destroying
      Modal.unlockBody();
    }

    this.element.ODS_Modal = this;
  }

  update(): void {
    this.destroy();
    this.init();
  }

  static moveToModalRoot(el: HTMLElement, container: Element | null): void {
    if (container) {
      container.appendChild(el);
    } else {
      console.warn(
        `\`modalsRoot: ${(this as unknown as Modal).config?.modalsRoot}\` element is not present in DOM. Modal will be placed inside content which can affect it's styling. Please provide \`modalsRoot\` selector (should be placed outside of main contant, usualy in end of <body /> tag)`,
      );
    }
  }

  // Track if body is locked
  private static isLocked = false;

  private static resolveBodyLockClassName(
    className?: string,
    context?: unknown,
  ): string {
    const modalContext = context as Modal | undefined;

    return (
      className ||
      modalContext?.config?.classModalIsOpenBody ||
      defaultConfig().classModalIsOpenBody
    );
  }

  static lockBody(className?: string, root?: string): void {
    if (Modal.isLocked) return;

    const actualClassName = Modal.resolveBodyLockClassName(className, this);

    // Store scroll position
    const scrollY = window.scrollY;
    document.body.setAttribute(BODY_LOCK_SCROLL_TOP_ATTR, scrollY.toString());

    const scrollbarWidth =
      window.innerWidth - document.documentElement.clientWidth;

    // Lock body with position fixed and offset
    document.body.style.position = "fixed";
    document.body.style.top = `-${scrollY}px`;
    document.body.style.left = "0";
    document.body.style.right = "0";
    document.body.style.paddingRight = `${scrollbarWidth}px`;
    document.body.style.setProperty(
      MODAL_SCROLLBAR_WIDTH_VAR,
      `${scrollbarWidth}px`,
    );

    // add modal class
    document.body.classList.add(actualClassName);
    Modal.isLocked = true;
  }

  static unlockBody(className?: string, root?: string): void {
    if (!Modal.isLocked) return;

    const actualClassName = Modal.resolveBodyLockClassName(className, this);
    const scrollTop = Number.parseInt(
      document.body.getAttribute(BODY_LOCK_SCROLL_TOP_ATTR) || "0",
      10,
    );

    // Remove lock styles
    document.body.style.position = "";
    document.body.style.top = "";
    document.body.style.left = "";
    document.body.style.right = "";
    document.body.style.paddingRight = "";
    document.body.style.removeProperty(MODAL_SCROLLBAR_WIDTH_VAR);

    // remove modal class
    document.body.classList.remove(actualClassName);

    // Restore scroll position instantly (ignore scroll-behavior: smooth)
    window.scrollTo({
      top: Number.isFinite(scrollTop) ? scrollTop : 0,
      left: 0,
      behavior: "instant",
    });
    Modal.isLocked = false;
  }
}
