/**
 * Lazy deferred chunk — everything off the shell's boot critical path.
 * Modulepreloaded from <head> so it's in cache by the time any buffered
 * message drains or the brokered path awaits it.
 *
 * Owns: nav/engaged message drain, broker load+wire (applyBoot via
 * mountBrokered), error-pane painting + orgSwitch, live channel, web-vitals,
 * versions fetch, share-key replaceState, preview, track. All cross-bundle
 * mutable state is read/written through bootHost so the shell entry's
 * security-relevant state (closeNavConfirm, contentShown, closeConsent via
 * consentButtonArming, previewVer) has exactly one owner.
 */

import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";

// eslint-disable-next-line ant/inline-type-imports -- guaranteed-erased; see the matching comment on the ./shell import below
import type { BrokerHost, mountBrokered } from "./broker";
import { startLive } from "./live";
import {
  canRecordApprovals,
  isApprovedNavOrigin,
  isRememberableOrigin,
  navApprovalsKey,
  recordApprovedNavOrigin,
} from "./navApprovals";
import { offerOrgSwitch } from "./orgSwitch";
import {
  buildNavConfirm,
  isBlockedNavUrl,
  NAV_CONFIRM_ARM_MS,
  paintError,
  paintRequestAccess as _paintRequestAccess,
} from "./render";
// eslint-disable-next-line ant/inline-type-imports -- guaranteed-erased form; inline-type would emit `import {} from "./shell"` under verbatimModuleSyntax and pull the shell entry into this chunk
import type { BootHost, BufferedMsg } from "./shell";

// ?inline so vite bundles the CSS into this JS chunk instead of emitting a
// separate CSS file (which frameShellEntry's emitted-CSS check rejects for
// lazy chunks — same convention as chrome.tsx).
import orgSwitchCss from "./orgSwitch.css?inline";
import errorCss from "./shell-error.css?inline";

/** sync: @ant/frame-runtime isFrameNavMessage — inlined so this chunk and
 *  the shell entry doesn't share a frame-runtime import (Rollup would split
 *  it into an anonymous chunk the self-contained check rejects). The shell
 *  re-validates url downstream regardless; this is shape-only. */
const isFrameNavMessage = (d: unknown): d is { url: string } =>
  d !== null &&
  typeof d === "object" &&
  (d as { __frame_nav?: unknown }).__frame_nav === true &&
  typeof (d as { url?: unknown }).url === "string";

const ENGAGED_KINDS: ReadonlySet<string> = new Set([
  "scroll",
  "pointer",
  "click",
]);
/** sync: @ant/frame-runtime isFrameEngagedMessage — inlined, same reason. */
const isFrameEngagedMessage = (d: unknown): d is { kind: string } =>
  d !== null &&
  typeof d === "object" &&
  (d as { __frame_engaged?: unknown }).__frame_engaged === true &&
  ENGAGED_KINDS.has((d as { kind?: unknown }).kind as string);

let host: BootHost;
let navApprovalsStoreKey: string | null = null;
let liveStarted = false;
let unwireBroker: (() => void) | null = null;
let currentBootOrg: string | undefined;
const engaged = new Set<string>();
const STALE_MS = 50 * 60_000;
const BROKER_LOAD_MS = 20_000;

interface BrokerMod {
  mountBrokered: typeof mountBrokered;
}
let brokerMod: Promise<BrokerMod | null> | undefined;

function loadBroker(): Promise<BrokerMod | null> {
  if (brokerMod) return brokerMod;
  let url = "/__FRAME_BROKER_URL__";
  if (import.meta.env.DEV && import.meta.env.MODE !== "test") {
    // Concatenated so vite's new-URL() asset analysis doesn't emit
    // broker.ts as a duplicate asset alongside the rollup-input chunk.
    url = new URL("./broker" + ".ts", import.meta.url).href;
  }
  let done = false;
  let to = 0;
  const onFail = (kind: string, e?: unknown): null => {
    if (done) return null;
    done = true;
    window.clearTimeout(to);
    brokerMod = undefined;
    if (import.meta.env.DEV)
      // eslint-disable-next-line ant/no-console -- dev-only diagnostic; shell has no @ant/utils/logger
      console.error("[frame-shell] broker chunk", kind, e ?? "");
    else host.report("broker-load", kind);
    return null;
  };
  brokerMod = Promise.race([
    import(/* @vite-ignore */ url).then(
      (m) => {
        done = true;
        window.clearTimeout(to);
        return m as BrokerMod;
      },
      (e) => onFail("reject", e),
    ),
    new Promise<null>((r) => {
      to = window.setTimeout(() => r(onFail("timeout")), BROKER_LOAD_MS);
    }),
  ]);
  return brokerMod;
}

/**
 * Clickjacking arming for the broker consent dialog's Continue button:
 * Continue is disabled until a NAV_CONFIRM_ARM_MS quiet window passes
 * with no held pointer and no iframe focus; any input before that
 * restarts the window. Called synchronously with button creation
 * (broker.ts showConsentDialog), so the button is never appended in an
 * armed state.
 */
function consentButtonArming(
  btn: HTMLButtonElement,
  cancel: HTMLButtonElement,
  box: HTMLElement,
): () => void {
  btn.disabled = true;
  const heldPointers = new Set<number>();
  const tryArm = (): void => {
    if (!box.isConnected) {
      cleanup();
      return;
    }
    if (
      heldPointers.size > 0 ||
      document.activeElement instanceof HTMLIFrameElement
    ) {
      armTimer = window.setTimeout(tryArm, NAV_CONFIRM_ARM_MS);
      return;
    }
    btn.disabled = false;
  };
  let armTimer = window.setTimeout(tryArm, NAV_CONFIRM_ARM_MS);
  const resetScope = new AbortController();
  const cleanup = (): void => {
    window.clearTimeout(armTimer);
    resetScope.abort();
    host.setCloseConsent(null);
  };
  const disarm = (): void => {
    if (document.activeElement === btn) {
      cancel.focus();
    }
    btn.disabled = true;
    window.clearTimeout(armTimer);
    armTimer = window.setTimeout(tryArm, NAV_CONFIRM_ARM_MS);
  };
  const resetIfDisarmed = (e: Event): void => {
    if (!box.isConnected) {
      cleanup();
      return;
    }
    if (e.type === "pointerdown") {
      heldPointers.add((e as PointerEvent).pointerId);
    } else if (e.type === "pointerup" || e.type === "pointercancel") {
      if (heldPointers.delete((e as PointerEvent).pointerId)) {
        if (btn.disabled) {
          disarm();
        }
      } else {
        disarm();
      }
      return;
    }
    if (e.type === "pointermove" && (e as PointerEvent).buttons === 0) {
      heldPointers.delete((e as PointerEvent).pointerId);
      return;
    }
    if (!btn.disabled) {
      return;
    }
    disarm();
  };
  for (const t of [
    "keydown",
    "pointerdown",
    "pointerup",
    "pointercancel",
    "wheel",
    "pointermove",
  ]) {
    document.addEventListener(t, resetIfDisarmed, {
      capture: true,
      passive: t === "wheel" || t === "pointermove",
      signal: resetScope.signal,
    });
  }
  document.addEventListener(
    "visibilitychange",
    () => {
      if (!document.hidden) {
        disarm();
      }
    },
    { signal: resetScope.signal },
  );
  window.addEventListener("focus", disarm, { signal: resetScope.signal });
  host.setCloseConsent(() => {
    cleanup();
    box.remove();
  });
  return cleanup;
}

/** Tear down any wired MCP postMessage bridge. Exported so shell.ts can
 *  unwire on the paths applyBoot never sees: a re-boot that DROPS the
 *  mcp capability (non-broker remount branch) and the terminal
 *  403/404 denial. Without this the old listener — pinned to the old
 *  iframe's contentWindow with the old token in its closure — stays live
 *  for the swap window. Backend enforces manifest ∩ bootstrap per call
 *  regardless; this is the shell-side revocation. */
export function teardownBroker(): void {
  unwireBroker?.();
  unwireBroker = null;
}

let brokerHost: BrokerHost | undefined;
function getBrokerHost(): BrokerHost {
  brokerHost ??= {
    frameUuid: host.frameUuid,
    frameOrigin: new URL(host.frameOrigin()).origin,
    cpHeaders: host.cpHeaders,
    org: currentBootOrg,
    slot: host.slot,
    mount: (d) => host.mount(d as Parameters<typeof host.mount>[0]),
    setUnwire: (fn) => {
      unwireBroker?.();
      unwireBroker = fn;
    },
    markShown: () => host.setContentShown(true),
    report: host.report,
    staleMs: STALE_MS,
    consentId: host.CONSENT_ID,
    confirmOpenSelector: host.CONFIRM_OPEN,
    closeNavConfirm: () => host.callCloseNavConfirm(),
    inertContent: host.inertContent,
    restoreContentInteractivity: host.restoreContentInteractivity,
    consentButtonArming,
    unmount: host.unmountIframe,
    onStale: () => {
      if (liveStarted && host.getCurrent()?.wsToken) {
        void host.boot().then((res) => {
          if (
            !res.ok &&
            res.kind === "network" &&
            !host.byId(host.CONTENT_ID)
          ) {
            host.fail("upstream");
          }
        });
      } else {
        location.reload();
      }
    },
  };
  brokerHost.org = currentBootOrg;
  return brokerHost;
}

/** sync: shell.ts hasBrokerCap — duplicated for chunk-isolation. */
function hasBrokerCap(d: {
  capabilities?: Readonly<Record<string, unknown>>;
}): boolean {
  return !!d.capabilities && Object.keys(d.capabilities).length > 0;
}

/**
 * Brokered (re)mount — same semantics as the original shell.ts applyBoot.
 * The shell entry calls this (awaited) instead of mountIframe when the boot
 * declares a broker capability; clearForRemount runs HERE (after the async
 * broker fetch) so prior content stays visible during the chunk load.
 */
export async function applyBoot(
  d: Parameters<typeof host.mount>[0] & {
    capabilities?: Readonly<Record<string, unknown>>;
  },
  bootAt: number,
  bootOrg: string | undefined,
): Promise<void> {
  currentBootOrg = bootOrg;
  // Pre-await alarm + handshake disarm happens synchronously in shell.ts's
  // bootOnce remount block (the mountSlow/mountFallback timers live there).
  const m = await loadBroker();
  const isInitial = !host.getContentShown();
  host.clearForRemount();
  if (!m) {
    unwireBroker?.();
    unwireBroker = null;
    host.mount(d);
    return;
  }
  try {
    m.mountBrokered(getBrokerHost(), d, isInitial, bootAt);
  } catch (err) {
    brokerMod = Promise.resolve(null);
    if (import.meta.env.DEV)
      // eslint-disable-next-line ant/no-console -- dev-only diagnostic; shell has no @ant/utils/logger
      console.error("[frame-shell] mountBrokered threw", err);
    else host.report("broker-load", "mount");
    unwireBroker?.();
    unwireBroker = null;
    host.mount(d);
  }
}

/** Called once from boot.ts; injects lazy CSS, wires web-vitals + drain. */
export function install(
  h: BootHost,
  setDrain: (fn: (m: BufferedMsg) => void) => void,
): void {
  host = h;
  // Inject error-pane + orgSwitch + consent/nav-confirm CSS as a single
  // <style> so the first paintError/buildNavConfirm render is styled.
  const style = document.createElement("style");
  style.dataset.src = "frame-shell-deferred";
  style.textContent = errorCss + orgSwitchCss;
  document.head.appendChild(style);
  for (const on of [onLCP, onFCP, onINP, onTTFB, onCLS]) {
    on((m) =>
      host.reportVital(
        m.name.toLowerCase() as Parameters<typeof host.reportVital>[0],
        m.value,
        m.rating,
      ),
    );
  }
  setDrain(onFrameMessage);
}

/**
 * Drain for the page-level message buffer — nav/engaged only. connect/ready
 * are handled inline by boot.ts's per-mount onHandshake (so the kernel never
 * observes an un-initialised window.claude); this drain no-ops on them so a
 * buffered connect is never double-answered.
 *
 * userActivation transience: a nav message buffered longer than the browser's
 * transient-activation window (~5s per spec) will fail the isActive check
 * when drained and be dropped. With deferred modulepreloaded this is rare;
 * the viewer clicks again and the next nav is handled live. More fail-closed
 * than the alternative (acting on a stale gesture).
 */
function onFrameMessage({ e, f }: BufferedMsg): void {
  const d = e.data;
  if (isFrameEngagedMessage(d)) {
    // `ready` gate: on first mount the iframe gets CONTENT_ID at mount time
    // (before reveal), so the stub's source check passes pre-reveal — but a
    // real engagement can't happen until the user can interact. Drop
    // programmatic pre-reveal messages.
    if (f.classList.contains("ready") && !engaged.has(d.kind)) {
      engaged.add(d.kind);
      trackFrameEvent("frame_engaged", { slug: host.frameUuid, mode: d.kind });
    }
    return;
  }
  if (isFrameNavMessage(d)) handleNav(d.url, f, e.origin);
}

/** The one open nav-confirm dialog, or null. Module-level so Gate A below
 *  enforces first-wins across rapid messages. */
let navBox: HTMLElement | null = null;

function handleNav(rawUrl: string, f: HTMLIFrameElement, origin: string): void {
  const ua = navigator.userActivation;
  if (ua && !ua.isActive) return;
  let url: URL;
  try {
    url = new URL(rawUrl);
  } catch {
    return;
  }
  if (url.protocol !== "http:" && url.protocol !== "https:") return;
  if (isBlockedNavUrl(url, new URL(origin))) return;
  // Gate A — one dialog at a time, first-wins: frame JS can fire nav
  // messages at will, and a mid-decision swap could switch the URL under a
  // click already in flight — so an open dialog is never replaced. The
  // newer request is dropped. Runs BEFORE Gate B: when navBox is open the
  // nav-confirm itself set inert, and Gate B would mis-attribute that to
  // another holder.
  if (navBox) return;
  // Gate B — if the iframe is inert here (navBox is null, so the nav-confirm
  // isn't the holder), another holder owns the claim — pre-reveal, an open
  // consent dialog, or the chrome's delete confirm. Under any of these a
  // real click inside the frame was impossible, so the message is
  // necessarily spontaneous — drop it.
  if (f.inert) return;
  url.username = "";
  url.password = "";
  const key = navApprovalsStoreKey;
  const open = (href: string): void => {
    const a = document.createElement("a");
    a.href = href;
    a.target = "_blank";
    a.rel = "noopener noreferrer";
    a.click();
  };
  if (ua?.isActive === true && key && isApprovedNavOrigin(key, url.origin)) {
    open(url.href);
    return;
  }
  const remember =
    ua?.isActive === true &&
    key !== null &&
    isRememberableOrigin(url.origin) &&
    canRecordApprovals(key);
  const box = buildNavConfirm(
    url,
    () => {
      // Recheck at click time: a viewer change (re-boot under a different
      // org/account) while this dialog was open rotates navApprovalsStoreKey;
      // an approval written under the stale key would attribute to a session
      // that no longer owns the dialog. Downgrade to one-shot in that case.
      if (remember && key !== null && key === navApprovalsStoreKey) {
        recordApprovedNavOrigin(key, url.origin);
      }
      open(url.href);
      close();
    },
    () => close(),
    remember,
  );
  f.inert = true;
  navBox = box;
  host.slot.append(box);
  box.focus();
  const close = (): void => {
    box.remove();
    navBox = null;
    host.setCloseNavConfirm(null);
    // Re-anchor focus: handing focus into frame content while a consent
    // decision is pending would sidestep its disarmed Tab trap; focusing
    // the iframe is also the iOS touch-scroll workaround (see shell.ts
    // reveal()).
    const consent = host.byId(host.CONSENT_ID);
    host.restoreContentInteractivity();
    if (consent) {
      (consent as HTMLElement).focus();
    } else {
      const cf = host.byId(host.CONTENT_ID);
      if (cf instanceof HTMLIFrameElement && !cf.inert) cf.focus();
    }
  };
  host.setCloseNavConfirm(close);
}

/** Post-boot wiring: live, nav-approvals key, share-key replaceState,
 *  versions fetch, track, broker kick. */
export function afterBoot(
  d: {
    ver: string;
    assetToken: string;
    wsToken?: string;
    share_key?: string;
    capabilities?: Readonly<Record<string, unknown>>;
    perm?: { role: string };
    contract?: string;
    changes?: readonly string[];
    versions?: unknown;
  },
  prev: { ver: string } | null,
  bootOrg: string | undefined,
  epoch: number,
): void {
  currentBootOrg = bootOrg;
  navApprovalsStoreKey = navApprovalsKey(bootOrg, d.assetToken);
  // sync: chrome/util.ts setShareKeyParam — skipped when embedded (the
  // iframe's history entry isn't the address bar; the host owns its URL).
  if (d.share_key && !document.documentElement.hasAttribute("data-embedded")) {
    const p = new URLSearchParams(location.search);
    if (p.get("sk") !== d.share_key) {
      p.set("sk", d.share_key);
      history.replaceState(
        history.state,
        "",
        `${location.pathname}?${p}${location.hash}`,
      );
    }
  }
  if (!liveStarted && d.wsToken) {
    liveStarted = true;
    startLive({
      uuid: host.slot.dataset.frameUuid ?? "",
      getVer: () => host.getCurrent()?.ver ?? "",
      getToken: () => host.getCurrent()?.wsToken,
      reboot: host.boot,
      isPinned: () => host.getPreviewVer() !== null,
      // Access-request nudge (owner sockets only). Bump the count
      // optimistically so Share's dot badge appears now; Share's
      // fetch effect (role-gated, count in deps) refetches the
      // owner-only list on open to get the real list + requesters.
      // pending_request_kick is a nonce for the toast — not
      // pending_request_count — so a re-boot echoing the same count
      // never toasts.
      onKick: () => {
        const ctx = host.getChromeCtx();
        if (!ctx) return;
        const b = ctx.boot;
        host.updateChrome({
          ...ctx,
          boot: {
            ...b,
            pending_request_count: (b.pending_request_count ?? 0) + 1,
            pending_request_kick: (b.pending_request_kick ?? 0) + 1,
          },
        });
      },
      report: host.report,
    });
  }
  if (!prev && !document.documentElement.hasAttribute("data-embedded")) {
    trackFrameEvent("session_start");
  }
  // Kick the broker chunk fetch early (overlaps chrome import) so the
  // applyBoot await above joins an in-flight promise.
  if (hasBrokerCap(d)) {
    void loadBroker();
  }
  // Owner versions lazy-fetch — the synchronous seed/clear already ran in
  // boot.ts's bootOnce (so chromeCtx carried the cache); this handles only
  // the lean-owner FETCH case.
  if (d.perm?.role === "owner" && d.versions === undefined) {
    fetchOwnerVersions(bootOrg, epoch);
  }
}

export function paintFail(
  e: HTMLElement,
  code: string,
  ctx: {
    frameUuid: string | undefined;
    bootOrg: string | undefined;
    bootSucceeded: boolean;
  },
): void {
  if (paintError(e, code)) {
    document.title = "Page not found – Claude";
    if (
      (code === "403" || code === "404") &&
      ctx.frameUuid &&
      !ctx.bootSucceeded
    ) {
      void offerOrgSwitch(e, ctx.bootOrg);
    }
  }
}

export function paintRequestAccess(
  e: HTMLElement,
  frameUuid: string,
  bootOrg: string | undefined,
  cannotRequest: boolean,
): void {
  _paintRequestAccess(
    e,
    frameUuid,
    bootOrg,
    cannotRequest,
    host.cpHeaders,
    host.report,
  );
}

export function preview(ver: string | null): void {
  const next = ver !== null && /^[A-Za-z0-9_-]{1,64}$/.test(ver) ? ver : null;
  if (next === host.getPreviewVer()) return;
  const prevPin = host.getPreviewVer();
  host.setPreviewVer(next);
  // boot()'s single-flight means a click during an in-flight boot joins
  // THAT fetch (which captured whatever ?ver= it saw, not ours). If we
  // joined, the follow-up boot after it settles is what carries our ?ver=.
  const joined = host.isBootInFlight();
  // Restore the PRIOR pin when the boot that carried ours fails, so
  // isPinned() stays truthful to what's on screen and a re-click of the
  // failed row passes the no-op guard. Gated on previewVer===next so a
  // later click (which owns the pin now) isn't rolled back.
  const rollback = (res: { ok: boolean }): void => {
    if (host.getPreviewVer() === next && !res.ok) host.setPreviewVer(prevPin);
  };
  void host.boot().then((res) => {
    if (host.getPreviewVer() !== next) return;
    if (joined) return void host.boot().then(rollback);
    rollback(res);
  });
}

/** Lazy-fetch /api/frame/versions/{uuid} and push the result to the mounted
 *  chrome. Caller has already checked owner + lean (the synchronous
 *  seed/clear lives in boot.ts's bootOnce). Epoch-guarded so a fetch
 *  resolving after a later reboot can't clobber newer state; any non-200 or
 *  malformed body keeps the cached fallback. */
function fetchOwnerVersions(bootOrg: string | undefined, epoch: number): void {
  void fetch(
    `/api/frame/versions/${host.frameUuid}${bootOrg ? `?org=${bootOrg}` : ""}`,
    {
      credentials: "same-origin",
      headers: host.cpHeaders,
      signal: AbortSignal.timeout(20_000),
    },
  )
    .then((r) => {
      if (r.ok) return r.json() as Promise<unknown>;
      host.report("versions-fetch", String(r.status));
      return null;
    })
    .then((body) => {
      if (epoch !== host.getBootEpoch()) return;
      const v = (body as { versions?: unknown } | null)?.versions;
      if (!v || typeof v !== "object" || Array.isArray(v)) return;
      if (JSON.stringify(v) === JSON.stringify(host.getOwnerVersions())) return;
      host.setOwnerVersions(v as Parameters<BootHost["setOwnerVersions"]>[0]);
      const ctx = host.getChromeCtx();
      if (ctx) {
        host.updateChrome({
          ...ctx,
          boot: {
            ...ctx.boot,
            versions: v as Parameters<BootHost["setOwnerVersions"]>[0],
          },
        });
      }
    })
    // sync: chrome/api.ts fetchErrDetail — same status/"timeout"/"error"
    // detail mapping, duplicated because the chunks can't share a module.
    .catch((err: unknown) => {
      host.report(
        "versions-fetch",
        (err as { name?: unknown } | null)?.name === "TimeoutError"
          ? "timeout"
          : "error",
      );
    });
}

function trackFrameEvent(
  name: string,
  opts: Record<string, unknown> = {},
): void {
  // sync: chrome/api.ts trackFrameEvent — ?org= scoping so the CP doesn't
  // fall back to the lastActiveOrg cookie (cross-tab drift).
  const org = currentBootOrg;
  void fetch(
    `/api/frame/track${org ? `?org=${encodeURIComponent(org)}` : ""}`,
    {
      method: "POST",
      credentials: "same-origin",
      keepalive: true,
      headers: { ...host.cpHeaders, "Content-Type": "application/json" },
      body: JSON.stringify({ event_name: name, ...opts }),
    },
  ).catch(() => undefined);
}
