/**
 * 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 —
 * registered by the broker's decision dialogs — 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,
  invalidateBrokerGeneration,
  mountBrokered,
  refreshBrokerToken as brokerRefreshToken,
} from "./broker";
import { startLive } from "./live";
import {
  canRecordApprovals,
  isApprovedNavOrigin,
  isRememberableOrigin,
  isSessionApprovedNavOrigin,
  navApprovalsKey,
  noteNavSessionIdentity,
  recordApprovedNavOrigin,
  recordSessionApprovedNavOrigin,
  type RememberScope,
} from "./navApprovals";
import { offerOrgSwitch } from "./orgSwitch";
import {
  buildNavConfirm,
  isBlockedNavUrl,
  paintError,
  paintRequestAccess as _paintRequestAccess,
  safeTimeout,
} 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;
/** True while the current boot is tokenless (anon public or cross-org
 *  reader). Gates every client-analytics POST, not just session_start —
 *  the anon path sends no analytics beacons, a constraint the consent
 *  posture depends on, and an engaged event is as much a beacon as a
 *  session start. */
let tokenlessSession = false;
/** True only for the ANONYMOUS boot shape — an explicit allowlist on
 *  kind:"public" (the re-picked PublicFields response, the one shape the
 *  CP guarantees carries no account). The session nav-approval store is
 *  gated on this, NOT on tokenlessSession: the cross-org reader skeleton
 *  is also tokenless but is an authenticated, account-specific viewer
 *  the boot carries no client-side discriminator for — two different
 *  readers would be indistinguishable, so their approvals must never
 *  ride a shared in-memory store; they re-confirm every link. Allowlist,
 *  not "no-perm" inference: the skeleton's fields are all optional
 *  (CLAUDE.md tokenless key-sets are ⊆-bounds), so any shape that isn't
 *  provably anonymous fails CLOSED to always-ask. */
let anonSession = false;
let liveStarted = false;
/** External stop for the running live channel — held so a tokened→
 *  tokenless downgrade (a 200, which live.ts's own denied-reboot teardown
 *  never sees) can close the authenticated socket. Re-set on restart. */
let liveStop: (() => void) | null = null;
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;
  invalidateBrokerGeneration: typeof invalidateBrokerGeneration;
  refreshBrokerToken: typeof brokerRefreshToken;
}
let brokerMod: Promise<BrokerMod | null> | undefined;
// Sync handle to the loaded broker module — teardownBroker is synchronous
// and must retire the broker generation without awaiting the chunk.
let brokerModSync: BrokerMod | null = null;

/** True once mountBrokered threw and brokerMod was pinned to null — the
 *  bridge can never wire again this session, so the posture correction
 *  must NOT run (clearing it would make every later cap-carrying boot
 *  remount-and-fail in a loop, reloading the iframe each time). */
let brokerDead = false;

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);
        brokerModSync = m as BrokerMod;
        return m as BrokerMod;
      },
      (e) => onFail("reject", e),
    ),
    new Promise<null>((r) => {
      to = window.setTimeout(() => r(onFail("timeout")), BROKER_LOAD_MS);
    }),
  ]);
  return brokerMod;
}

/** 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;
  // Retire the broker generation: non-broker boot shapes (mcp-less
  // re-boot, broker-load failure) route through here and must invalidate
  // the prior generation's pending async continuations. Never loaded means
  // no generation ever existed.
  brokerModSync?.invalidateBrokerGeneration();
}

/** Same-version token-refresh reboot: thread the fresh boot's mcp token
 *  into the live bridge (broker.ts refreshBrokerToken — org-guarded,
 *  no-op when unwired). Deliberately synchronous via the sync module
 *  handle: the caller may be racing a stale-token replay that reads the
 *  slot in the next microtask, and a broker that was never loaded has no
 *  bridge to refresh — loading the chunk for a no-op would be waste. */
export function refreshBrokerToken(
  d: Parameters<BrokerMod["refreshBrokerToken"]>[0],
  bootOrg: string | undefined,
): void {
  brokerModSync?.refreshBrokerToken(d, bootOrg);
}

/** Stop the live channel from the shell's denial path: a 403/404 the
 *  SHELL observes (e.g. a bfcache re-boot) never reaches live.ts's own
 *  denied-reboot self-stop — without this the running-latch stays stuck
 *  and a later re-granted boot cannot restart the channel. Idempotent:
 *  onStop nulls liveStop, and a never-started channel is a no-op. */
export function stopLive(): void {
  liveStop?.();
}

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]),
    setBrokered: (v) => host.setBrokered?.(v),
    setUnwire: (fn) => {
      unwireBroker?.();
      unwireBroker = fn;
      // Mirror POSITIVE wiring truth only: a real bridge always means
      // brokered=true (this is the one place wiring happens, so the
      // consent path's pre-wire setUnwire(null) can never strand a wired
      // session false). Nulls deliberately do NOT clear here — they are
      // ambiguous: pending or declined consent must keep the posture
      // (clearing would make every later same-ver cap boot a phantom
      // "gain" → remount → iframe reload → consent re-prompt, a nag loop
      // for a declined manifest), while the structural no-wire exits
      // clear explicitly via brokerHost.setBrokered from broker.ts, which
      // knows which exit it took.
      if (fn !== null) host.setBrokered?.(true);
    },
    markShown: () => host.setContentShown(true),
    // Single-flight shell boot; resolves with the fresh boot's mcp
    // token + org for the broker's stale-token replay (see BrokerHost).
    requestReboot: () =>
      host.boot().then((res) => (res.ok ? host.getLastBootMcp() : null)),
    report: host.report,
    staleMs: STALE_MS,
    chrome: () => host.getChrome(),
    chromeReady: () => host.chromeReady(),
    setCloseConsent: (fn) => host.setCloseConsent(fn),
    callCloseConsent: () => host.callCloseConsent(),
    setDecisionSurfaceOpen: (v) => host.setDecisionSurfaceOpen(v),
    confirmOpenSelector: host.CONFIRM_OPEN,
    closeNavConfirm: () => host.callCloseNavConfirm(),
    inertContent: host.inertContent,
    restoreContentInteractivity: host.restoreContentInteractivity,
    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();
  // Tokenless boots are never brokered: the consent record is keyed by the
  // assetToken's account segment, so a capability with no token cannot be
  // consent-gated — and the CP never pairs them (PublicFields carries no
  // capabilities; cross-org skeletons omit both). Plain-mount on that
  // contradiction rather than wiring a broker no viewer consented to.
  const token = d.assetToken;
  if (!m || token === undefined) {
    unwireBroker?.();
    unwireBroker = null;
    // A RETRYABLE plain-mount fallback wires no bridge — correct the
    // shell's optimistic posture so the next cap-carrying boot re-attempts
    // the brokered path (loadBroker reset brokerMod, so the re-attempt can
    // succeed). When the broker is permanently dead this session, the
    // posture is deliberately left alone: clearing it would turn every
    // later cap boot into a remount-and-fail loop that reloads the iframe
    // for nothing.
    if (!brokerDead) host.setBrokered?.(false);
    host.mount(d);
    return;
  }
  try {
    m.mountBrokered(
      getBrokerHost(),
      { ...d, assetToken: token },
      isInitial,
      bootAt,
    );
  } catch (err) {
    brokerMod = Promise.resolve(null);
    brokerDead = true;
    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;
    // Posture deliberately NOT cleared — see the brokerDead note above.
    host.mount(d);
  }
}

// sync: chrome.tsx cspNonce() — duplicated rather than shared because the
// chrome and deferred chunks must each stay self-contained (a shared module
// would become a cross-entry chunk, which frameShellEntry's import guards
// fail the build on).
function cspNonce(): string {
  // ion-edge (frame-shell.ts) serves the shell with a per-request CSP nonce
  // stamped on every <script>/<style>; runtime-created styles must carry it
  // or style-src blocks them. Browsers hide the nonce content attribute
  // after parsing but keep the IDL property readable, so any nonced element
  // yields the value. Empty when the document has no nonce (tests, stories,
  // an older worker) — harmless, since no style-src is enforced there.
  const el = document.querySelector<HTMLElement>("style[nonce],script[nonce]");
  return el?.nonce ?? "";
}

/** 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.nonce = cspNonce();
  style.textContent = errorCss + orgSwitchCss;
  document.head.appendChild(style);
  // web-vitals' CLS and INP managers call Array.prototype.at (Chrome 92+
  // / Safari 15.4+ / FF 90+) inside their observer callbacks and crash
  // without it (lib/LayoutShiftManager.js, lib/InteractionManager.js) —
  // each metric registers its own independent observer, so only those two
  // are gated. LCP/FCP/TTFB don't use .at and keep reporting on older
  // browsers; the `mount` vital is web-vitals-free and unaffected.
  const vitalsToWire =
    typeof Array.prototype.at === "function"
      ? [onLCP, onFCP, onINP, onTTFB, onCLS]
      : [onLCP, onFCP, onTTFB];
  for (const on of vitalsToWire) {
    on((m) =>
      host.reportVital(
        m.name.toLowerCase() as Parameters<typeof host.reportVital>[0],
        m.value,
        m.rating,
      ),
    );
  }
  setDrain(onFrameMessage);
  wireShellEngagement();
}

/**
 * Chrome engagement: pointermove/click on the shell document — interaction
 * with the UI around the frame, reported with via:"chrome" so analytics can
 * rank it below the in-frame signals. Scroll is omitted: the shell page
 * does not scroll — the frame owns the viewport. Events inside the iframe's
 * browsing context never reach this document, so the two sources are
 * disjoint; the "chrome:"-prefixed dedup keys keep a chrome event from ever
 * suppressing the first in-frame event of the same kind. Latch-and-remove:
 * each kind posts at most once per page load, then drops its listener.
 */
function wireShellEngagement(): void {
  for (const [type, kind] of [
    ["pointermove", "pointer"],
    ["click", "click"],
  ] as const) {
    const dedupKey = "chrome:" + kind;
    const scope = new AbortController();
    const report = (): void => {
      // An instance whose slot has left the document no longer owns the
      // page (module re-imports in tests; any future teardown) — unwire
      // instead of reporting against a page that isn't ours. Same
      // per-instance isolation the message drain gets from source-pinning.
      if (!host.slot.isConnected) {
        scope.abort();
        return;
      }
      const f = host.byId(host.CONTENT_ID);
      // Same reveal gate as the in-frame drain, plus inert: while a consent
      // or nav-confirm dialog holds the claim, pointer traffic is dialog
      // interaction — friction, not engagement. Neither case burns the
      // latch; a real post-reveal interaction still counts.
      if (
        !(f instanceof HTMLIFrameElement) ||
        !f.classList.contains("ready") ||
        f.inert
      ) {
        return;
      }
      // Tokenless viewers send no analytics — same dual gate as the
      // in-frame drain (onFrameMessage), same reasoning: mount posture
      // covers the buffered/early window, the session flag covers the
      // kept-iframe-after-failed-downgrade window. Doesn't burn the
      // latch: a sign-in upgrade remounts tokened and a later chrome
      // interaction still counts.
      if (host.getCurrent()?.tokened !== true || tokenlessSession) {
        return;
      }
      // Record-only: the abort() is the latch; nothing reads "chrome:" keys.
      engaged.add(dedupKey);
      scope.abort();
      trackFrameEvent("frame_engaged", {
        slug: host.frameUuid,
        via: "chrome",
        mode: kind,
      });
    };
    document.addEventListener(type, report, {
      capture: true,
      passive: true,
      signal: scope.signal,
    });
  }
}

/**
 * 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) &&
      // Tokenless viewers send no analytics — BOTH signals must agree,
      // because each covers the other's blind spot. current.tokened is
      // set synchronously in bootOnce, so it is correct in the window
      // where a buffered message drains before afterBoot runs (where
      // tokenlessSession would still read its pre-boot default) — but it
      // is MOUNT posture, and giveUp restores it to a kept tokened
      // iframe after a failed downgrade swap, where the SESSION is
      // tokenless. The conjunction fails closed in every interleaving.
      host.getCurrent()?.tokened === true &&
      !tokenlessSession
    ) {
      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;

/**
 * Which remember scope an Open on this nav-confirm will record (the
 * standard's closed set; drives the dialog's disclosure line). Signed-in
 * viewers get the persistent per-account store; signed-out viewers get
 * the session store — shared-browser carryover is exactly the quiet trust
 * transfer the dialog exists to prevent, so nothing anonymous ever
 * persists; everywhere else every click re-confirms.
 */
function navRememberScope(
  active: boolean,
  key: string | null,
  origin: string,
): RememberScope {
  if (!active || !isRememberableOrigin(origin)) return "always-ask";
  if (key !== null) {
    return canRecordApprovals(key) ? "account-persistent" : "always-ask";
  }
  // Session scope is for the ANONYMOUS viewer only — a tokenless
  // cross-org reader is a signed-in account the client cannot
  // distinguish from the next one, so every click re-confirms.
  return anonSession ? "session" : "always-ask";
}

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) {
    if (key && isApprovedNavOrigin(key, url.origin)) {
      open(url.href);
      return;
    }
    // ANONYMOUS viewers (kind:"public"): approvals live in the in-memory
    // session store (navApprovals.ts), never localStorage. Both gates
    // must hold — key===null alone also describes the pre-boot default
    // AND the cross-org reader skeleton, where nothing may be remembered.
    if (key === null && anonSession && isSessionApprovedNavOrigin(url.origin)) {
      open(url.href);
      return;
    }
  }
  const remember = navRememberScope(ua?.isActive === true, key, url.origin);
  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
      // (and noteNavSessionIdentity clears the session store); an approval
      // written under the stale identity would attribute to a session that
      // no longer owns the dialog. Downgrade to one-shot in that case.
      if (
        remember === "account-persistent" &&
        key !== null &&
        key === navApprovalsStoreKey
      ) {
        recordApprovedNavOrigin(key, url.origin);
      } else if (
        remember === "session" &&
        navApprovalsStoreKey === null &&
        anonSession
      ) {
        recordSessionApprovedNavOrigin(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()).
    host.restoreContentInteractivity();
    // While a chrome decision dialog is open, leave focus with its own
    // Base UI trap; otherwise hand focus to the (non-inert) iframe — the
    // iOS touch-scroll workaround (see shell.ts reveal()).
    if (!host.decisionSurfaceOpen()) {
      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;
    /** "public" | "authed" on post-public CPs (shell.ts re-picks both
     *  tokenless shapes down to their key-sets before this runs, so the
     *  value is trustworthy); absent on pre-public CPs, whose boots are
     *  always tokened. */
    kind?: string;
    /** Absent on tokenless boots (anon public / cross-org readers). */
    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;
  // Two scopes: tokenlessSession gates analytics beacons and the live
  // channel; anonSession (⊂ tokenless, kind-discriminated) alone unlocks
  // the session nav-approval store — see the declarations up top.
  tokenlessSession = d.assetToken === undefined;
  anonSession = tokenlessSession && d.kind === "public";
  // Tokenless boots get no approvals key: the localStorage record is
  // scoped by the assetToken's account segment, and an anonymous viewer
  // has none — the ANON shape's approvals live in the in-memory SESSION
  // store instead (navApprovals.ts; cross-org readers get neither store
  // and re-confirm every link). An anon approval must never land in the
  // shared localStorage record.
  navApprovalsStoreKey = d.assetToken
    ? navApprovalsKey(bootOrg, d.assetToken)
    : null;
  // Any viewer-identity change — sign-in, sign-out downgrade, account or
  // org switch, and any anon ↔ cross-org transition (the two tokenless
  // shapes get distinct identity strings) — clears the session store: a
  // handed-over browser must not inherit the previous person's session
  // approvals. Cross-org reader → cross-org reader account switches
  // collapse to one identity string, which is safe because the store is
  // never read or written outside the anon shape.
  noteNavSessionIdentity(
    navApprovalsStoreKey ?? (anonSession ? "anon" : "tokenless-reader"),
  );
  // 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}`,
      );
    }
  }
  // A downgrade to tokenless stops the live channel outright: the boot is
  // a 200 (not the denied reboot live.ts self-terminates on), so without
  // this the authenticated wsToken socket would survive behind the public
  // header — and a bfcache pageshow would even reconnect it with the
  // stale token before the re-boot resolved. The latch itself resets via
  // onStop (below), which ALSO fires on the denied self-stop — so a
  // denial → re-grant → reboot sequence restarts the channel instead of
  // leaving it dead behind a stuck latch.
  if (tokenlessSession && liveStarted) liveStop?.();
  if (!liveStarted && d.wsToken) {
    liveStarted = true;
    liveStop = startLive({
      onStop: () => {
        liveStarted = false;
        liveStop = null;
      },
      uuid: host.slot.dataset.frameUuid ?? "",
      getVer: () => host.getCurrent()?.ver ?? "",
      getToken: () => host.getCurrent()?.wsToken,
      reboot: host.boot,
      alive: () => host.slot.isConnected,
      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,
    });
  }
  // Tokenless viewers (anon public, cross-org readers) send no client
  // analytics: the CP skips recordView/emitFrameViewed for them by
  // design, and an anon POST to /track would only 401. Gating on the
  // token keeps the anon path free of analytics beacons — a constraint
  // the consent posture depends on. (The bounded error-telemetry channel
  // is separate and carries no analytics or identity.)
  if (
    !prev &&
    d.assetToken &&
    !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 {
  let res: Promise<Response>;
  try {
    res = fetch(
      `/api/frame/versions/${host.frameUuid}${bootOrg ? `?org=${bootOrg}` : ""}`,
      {
        credentials: "same-origin",
        headers: host.cpHeaders,
        signal: safeTimeout(20_000),
      },
    );
  } catch (err) {
    // Native fetch never throws synchronously here — but non-native fetch
    // machinery (an extension or injected instrumentation wrapping
    // window.fetch, or a tampered AbortSignal.timeout — safeTimeout only
    // papers over the ABSENT static, not a throwing one) can, and the
    // escaping throw surfaces as an uncaught TypeError (Sentry
    // FRAME-SHELL-H). Route it into the rejection path instead: the
    // terminal .catch reports it under versions-fetch and the chrome keeps
    // the cached fallback, same as an async network failure.
    // sync: shell.ts boot-fetch guard (FRAME-SHELL-C).
    res = Promise.reject(err);
  }
  void res
    .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.markHandled(err);
      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((err: unknown) => host.markHandled(err));
}
