// Unified modal primitive — t-paliad-217.
//
// Native <dialog>-backed. The browser handles top-layer stacking, ESC,
// ARIA, and focus trap. We layer back-button integration and focus
// restoration on top so the modal behaves consistently on desktop and on
// the iPhone PWA (m's checking surface).
//
// API:
//   const result = await openModal<MyResult>({
//     title: "…",
//     body: htmlStringOrElement,
//     primary: { label: "Speichern", handler: (close) => { close(result); } },
//     secondary: { label: "Abbrechen" },   // optional, defaults to "Abbrechen"
//     size: "sm" | "md" | "lg" | "full",   // optional, defaults to "md"
//     onClose: () => { /* … */ },
//     classNames: "extra css classes on the <dialog>",
//   });
//   // result is the value passed to close(), or null if the user
//   // dismissed via ESC / backdrop / secondary / browser back-button.
//
// All dismiss paths are unified: ESC, backdrop click, secondary button,
// the always-rendered close (×) button, and the browser back-button all
// resolve the promise with null. Programmatic close from the primary
// handler resolves with whatever was passed.
//
// Migration target: call sites that currently roll their own
// modal-overlay + ESC handler + focus management replace all of it with
// one openModal() call. broadcast.ts and approval-edit-modal.ts are the
// first two call sites (t-paliad-217 Slices C + D); the other ~5 legacy
// modals migrate in follow-up PRs.

import { t } from "../i18n";

export interface ModalConfig<T> {
  title: string;
  // body can be either a pre-built HTMLElement (the caller assembled the
  // DOM and may have local references for read-back) or an HTML string
  // (caller is responsible for escaping). Element is preferred when the
  // caller needs to read form state on submit.
  body: HTMLElement | string;
  primary: {
    label: string;
    handler: (close: (result: T) => void) => void | Promise<void>;
  };
  // secondary defaults to a Cancel button that just dismisses. Pass null
  // explicitly to suppress (rare — primary-only modals like a confirmation
  // toast).
  secondary?: { label: string } | null;
  size?: "sm" | "md" | "lg" | "full";
  // onClose fires on EVERY dismiss path (including primary handler
  // resolution). Use for analytics / dirty-state warnings.
  onClose?: () => void;
  classNames?: string;
}

// openModal returns a promise that resolves with the value passed to
// close() inside the primary handler, or null if the user dismissed via
// any other path. Always non-throwing — the primary handler decides
// whether to surface errors via its own UI (e.g. inline form errors)
// rather than rejecting the promise.
export function openModal<T = void>(config: ModalConfig<T>): Promise<T | null> {
  return new Promise((resolve) => {
    // Record + restore focus to whatever was focused before the modal
    // opened. Native <dialog> does NOT do this automatically.
    const previouslyFocused = document.activeElement as HTMLElement | null;

    const dialog = document.createElement("dialog");
    dialog.className = ["modal", config.classNames].filter(Boolean).join(" ");
    dialog.dataset.size = config.size ?? "md";

    const header = document.createElement("header");
    header.className = "modal__header";
    const titleEl = document.createElement("h2");
    titleEl.className = "modal__title";
    titleEl.textContent = config.title;
    header.appendChild(titleEl);
    const closeBtn = document.createElement("button");
    closeBtn.type = "button";
    closeBtn.className = "modal__close";
    closeBtn.setAttribute("aria-label", t("modal.close.label"));
    closeBtn.textContent = "×"; // ×
    header.appendChild(closeBtn);
    dialog.appendChild(header);

    const body = document.createElement("div");
    body.className = "modal__body";
    if (typeof config.body === "string") {
      body.innerHTML = config.body;
    } else {
      body.appendChild(config.body);
    }
    dialog.appendChild(body);

    const footer = document.createElement("footer");
    footer.className = "modal__footer";
    const secondaryCfg = config.secondary === null
      ? null
      : config.secondary ?? { label: t("common.cancel") };
    let secondaryBtn: HTMLButtonElement | null = null;
    if (secondaryCfg) {
      secondaryBtn = document.createElement("button");
      secondaryBtn.type = "button";
      secondaryBtn.className = "btn btn-ghost modal__secondary";
      secondaryBtn.textContent = secondaryCfg.label;
      footer.appendChild(secondaryBtn);
    }
    const primaryBtn = document.createElement("button");
    primaryBtn.type = "button";
    primaryBtn.className = "btn btn-primary modal__primary";
    primaryBtn.textContent = config.primary.label;
    footer.appendChild(primaryBtn);
    dialog.appendChild(footer);

    document.body.appendChild(dialog);

    // History integration (Q5): push a synthetic history state so the
    // browser back-button closes the modal instead of leaving the page.
    // We pop the state in finish() unless popstate already fired it.
    let historyEntryActive = false;
    try {
      history.pushState({ paliadModalOpen: true }, "");
      historyEntryActive = true;
    } catch (_e) {
      // pushState may throw in obscure embedded contexts; degrade gracefully.
    }

    // resolved guards against double-resolution (e.g. ESC fires + then a
    // microtask-deferred primary handler also calls close).
    let resolved = false;

    const finish = (value: T | null) => {
      if (resolved) return;
      resolved = true;

      window.removeEventListener("popstate", onPopState);

      // Pop our history entry if it's still on the stack. Skip when the
      // popstate listener already fired (otherwise we'd go back twice).
      if (historyEntryActive) {
        historyEntryActive = false;
        try { history.back(); } catch (_e) { /* same fallback as pushState */ }
      }

      // Native dialog close. Use the close event's default rather than
      // the cancel event so we don't fight the browser's own dismissal.
      if (dialog.open) dialog.close();
      dialog.remove();

      // Restore focus to whatever the user was on before. The dialog
      // teardown happens synchronously so the focus call lands on a
      // live element.
      if (previouslyFocused && document.body.contains(previouslyFocused)) {
        previouslyFocused.focus();
      }

      config.onClose?.();
      resolve(value);
    };

    const close = (result: T) => finish(result);

    // Dismiss paths.
    closeBtn.addEventListener("click", () => finish(null));
    secondaryBtn?.addEventListener("click", () => finish(null));
    dialog.addEventListener("click", (e) => {
      // Backdrop click — only when the click landed on the dialog element
      // itself (not on a child). Browsers report dialog.click events
      // through the backdrop too because the backdrop is conceptually
      // part of the dialog's box.
      if (e.target === dialog) finish(null);
    });
    // <dialog>'s cancel event fires on ESC. preventDefault stops the
    // browser's default close so we can run our finish() (history pop,
    // focus restore, onClose, resolve).
    dialog.addEventListener("cancel", (e) => {
      e.preventDefault();
      finish(null);
    });
    const onPopState = () => {
      // Browser back-button. Our history entry is gone by the time this
      // fires, so skip the history.back() in finish().
      historyEntryActive = false;
      finish(null);
    };
    window.addEventListener("popstate", onPopState);

    // Primary action.
    primaryBtn.addEventListener("click", () => {
      const result = config.primary.handler(close);
      // Allow async primary handlers (handler returns a promise) — we
      // don't wait for it explicitly; the handler is responsible for
      // calling close() when ready.
      void result;
    });

    // Open the dialog in the top layer. showModal activates ARIA
    // role="dialog" + aria-modal=true + focus trap + backdrop.
    dialog.showModal();
  });
}
