/**
 * Lazy postMessage broker + consent flow for the frame shell.
 *
 * A separate rollup entry (`frame-shell-broker`) loaded by shell.ts via a
 * `@vite-ignore`'d import() — same placeholder-stamping mechanism as the
 * lazy React chrome. The shell entry chunk therefore carries none of the
 * broker's wire strings (the /api/frame/mcp/* routes, the __frame_cap
 * postMessage discriminators, the consent-store key); the chunk is only
 * fetched when the boot response declares a broker capability.
 *
 * Chunk-isolation rule (CLAUDE.md): this module may not share a static
 * import with shell.ts or frameShellEntry() sees a shared chunk and
 * fails the build. `el()` is duplicated (kept in lock-step with
 * render.ts); `report()` and the shell's mount/iframe state arrive via
 * the BrokerHost callbacks.
 */

// Wire shapes — protocol.ts is the canonical source of truth. This is the
// ONE frame-shell entry that runtime-imports it (see protocol.ts header);
// the module bundles into this chunk alone, so the zero-imports gate in
// frameShellEntry (vite.config.ts) still holds.
import { frameCapReply, parseFrameCap, type McpErr } from "./protocol";

export interface ManifestServer {
  server: string;
  tools: string[];
}

/** Boot fields the broker reads. shell.ts passes the raw boot object. */
interface BrokerBoot {
  assetToken: string;
  /** Capabilities envelope — the only boot shape the broker reads. */
  capabilities?: {
    mcp?: { token?: string; config?: { servers?: ManifestServer[] } };
  };
}

/** Shell-owned state + callbacks the broker needs. */
export interface BrokerHost {
  frameUuid: string;
  /** Normalized origin of the user-content iframe (frameScheme()//frameUchost). */
  frameOrigin: string;
  cpHeaders: Record<string, string>;
  org: string | undefined;
  slot: HTMLElement;
  /** Mount the iframe for `d` and return it (shell.ts's mountIframe). */
  mount: (d: object) => HTMLIFrameElement;
  /** Replace the shell's current broker tear-down (null = unwired). */
  setUnwire: (fn: (() => void) | null) => void;
  /** Mark content as shown (flips shell's initial-gate semantics). */
  markShown: () => void;
  /** Telemetry counter — telemetry.ts report(). */
  report: (
    code: "broker-consent-error" | "transform-error" | "cap-load-error",
    detail?: string,
  ) => void;
  /** Called when the initial-consent assetToken aged out while the dialog sat open. */
  onStale: () => void;
  /** assetToken freshness window (ms). */
  staleMs: number;
  /** The consent dialog's element id — shell's contentInertClaim reads it. */
  consentId: string;
  /** Selector for a LIVE chrome delete-confirm (Base UI). */
  confirmOpenSelector: string;
  /** Close any open nav-confirm dialog (its close() re-derives inert). */
  closeNavConfirm: () => void;
  /** Force the mounted content iframe inert (dialog open — see contentInertClaim). */
  inertContent: () => void;
  /** Re-derive content-iframe interactivity after this dialog goes away. */
  restoreContentInteractivity: () => void;
  /**
   * Clickjacking input-protection arming for Continue. Disarms btn,
   * installs the heldPointers/quiet-window reset listeners, registers
   * the out-of-band closeConsent handle, and returns the cleanup every
   * dialog-death path must call. Called synchronously with btn creation
   * so the button is never appended in an armed state.
   */
  consentButtonArming: (
    btn: HTMLButtonElement,
    cancel: HTMLButtonElement,
    box: HTMLElement,
  ) => () => void;
  /** Tear down any mounted iframe + its alarms (strict prompt-before-mount). */
  unmount: () => void;
}

function mcpErr(code: string, message: string, server?: string): McpErr {
  const out: McpErr = { code, message };
  if (server !== undefined) out.server = server;
  return out;
}

// Cap a connector/tool name for display in the consent dialog. 128 matches
// policy.go's tool-name length bound, so policy-valid names render in full
// (the viewer is granting the full scope and must be able to inspect it);
// shell.css wraps/clamps at that length. The cap only bites on policy skew.
const capName = (s: unknown): string => {
  // parseManifest validates Array.isArray(tools) but not element types;
  // coerce so a non-string entry renders as its string form rather than
  // throwing mid-dialog (which applyBoot would catch and pin MCP off).
  const str = String(s);
  const cp = [...str];
  return cp.length > 128 ? `${cp.slice(0, 127).join("")}…` : str;
};

// Chunk-isolated duplicate of render.ts's el() — see file header.
const el = (
  tag: string,
  cls?: string,
  ...kids: (Node | string)[]
): HTMLElement => {
  const n = document.createElement(tag);
  if (cls) n.className = cls;
  n.append(...kids);
  return n;
};

const byId = (id: string): HTMLElement | null => document.getElementById(id);

/**
 * Wire the iframe → shell → /api/frame/mcp/{call,servers} postMessage broker.
 *
 * Origin + window-identity is the boundary: only messages from the iframe's
 * exact origin AND contentWindow are accepted; replies go back to that origin
 * only. mcpToken (CSRF double-submit) stays closure-local. The backend
 * enforces manifest ∩ viewer-bootstrap per call — this is the transport.
 *
 * Every rejection is projected to `{code, server?, message}` so frames can
 * branch on .code regardless of whether the failure came from the CP body,
 * fetch/timeout, or shell-side validation.
 *
 * Returns a tear-down that removes the window listener — called on the next
 * swap so brokers don't accumulate across re-boots.
 */
function wireMcp(
  h: BrokerHost,
  iframe: HTMLIFrameElement,
  mcpToken: string,
  consented: readonly ManifestServer[],
): () => void {
  const { frameOrigin, frameUuid, cpHeaders, org } = h;
  // Bind calls to the manifest the viewer actually consented to. The backend
  // enforces the CURRENT published manifest (so a republish can narrow scope),
  // but this check ensures a republish can't BROADEN scope for an already-open
  // session that wasn't re-wired — the intersection of consented ∩ backend.
  const consentedScope = new Map<string, Set<string>>(
    consented.map((s) => [s.server, new Set(s.tools)]),
  );
  const q = org ? `?org=${encodeURIComponent(org)}` : "";
  // Project a Frame CP error body to the documented fields only — future
  // backend fields (or accidental request-echo) must not reach the iframe.
  const projectErr = (status: number, text: string, sid?: string): McpErr => {
    try {
      const e = (JSON.parse(text) as { error?: Partial<McpErr> }).error;
      if (e && typeof e.code === "string") {
        const srv = typeof e.server === "string" ? e.server : sid;
        return mcpErr(e.code, String(e.message ?? ""), srv);
      }
    } catch {
      /* non-JSON body */
    }
    // No 401 → needs_reauth mapping: a bare 401 here is most likely the
    // viewer's session expiring (antOnly), not a connector token issue.
    const code =
      status === 404
        ? "server_not_found"
        : status === 429 || status >= 500
          ? "server_unavailable"
          : "upstream_error";
    return mcpErr(code, `request failed (${status})`, sid);
  };
  const netErr = (err: unknown, sid?: string): McpErr =>
    mcpErr(
      err instanceof DOMException && err.name === "TimeoutError"
        ? "server_unavailable"
        : "upstream_error",
      "request failed",
      sid,
    );

  // One shared handler per RPC kind; `send` closes over the caller's
  // correlation id so replies go back as __frame_cap_r.
  type Send = (body: { result: unknown } | { error: McpErr }) => void;

  const handleList = (send: Send): void => {
    void fetch(`/api/frame/mcp/servers${q}`, {
      method: "POST",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json", ...cpHeaders },
      body: JSON.stringify({ slug: frameUuid, mcpToken }),
      signal: AbortSignal.timeout(65_000),
    })
      .then(async (r) => {
        const text = await r.text();
        if (!r.ok) return send({ error: projectErr(r.status, text) });
        // Project to the documented shape only.
        const body = JSON.parse(text) as {
          servers?: {
            server?: unknown;
            authStatus?: unknown;
            tools?: { name?: unknown; description?: unknown }[];
          }[];
        };
        // Filter to the consented scope — same gate as the callTool path
        // below. The backend result may include connectors the viewer
        // never agreed to (a republish, or a server the viewer declined);
        // their presence and authStatus are not this frame's to learn.
        send({
          result: {
            servers: (body.servers ?? []).flatMap((s) => {
              const server = String(s.server ?? "");
              const allowed = consentedScope.get(server);
              if (!allowed) return [];
              return [
                {
                  server,
                  authStatus: String(s.authStatus ?? ""),
                  tools: (s.tools ?? []).flatMap((t) => {
                    const name = String(t.name ?? "");
                    if (!allowed.has(name)) return [];
                    return [{ name, description: String(t.description ?? "") }];
                  }),
                },
              ];
            }),
          },
        });
      })
      .catch((err: unknown) => send({ error: netErr(err) }));
  };

  const handleCall = (
    send: Send,
    server: unknown,
    tool: unknown,
    input: unknown,
  ): void => {
    if (typeof server !== "string" || typeof tool !== "string") {
      return send({
        error: mcpErr("bad_request", "server and tool must be strings"),
      });
    }
    if (!consentedScope.get(server)?.has(tool)) {
      return send({
        error: mcpErr(
          "not_in_manifest",
          "outside the scope you consented to. Reload to review.",
          server,
        ),
      });
    }
    // input came via structured clone and may contain BigInt/cycles that
    // JSON.stringify throws on — catch so the iframe's Promise settles.
    let body: string;
    try {
      body = JSON.stringify({
        slug: frameUuid,
        server,
        tool,
        input: input ?? {},
        mcpToken,
      });
    } catch {
      return send({
        error: mcpErr("bad_request", "input is not JSON-serializable", server),
      });
    }
    void fetch(`/api/frame/mcp/call${q}`, {
      method: "POST",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json", ...cpHeaders },
      body,
      signal: AbortSignal.timeout(120_000),
    })
      .then(async (r) => {
        const text = await r.text();
        if (!r.ok) return send({ error: projectErr(r.status, text, server) });
        // CP parses the toolbox SSE/JSON-RPC envelope server-side and returns
        // {result: <mcp-result>} or {error: mcpErrOut}; unwrap here so
        // callTool() resolves with the bare MCP result object. A non-JSON
        // body falls back to raw text.
        let j: unknown;
        try {
          j = JSON.parse(text);
        } catch {
          return send({ result: text });
        }
        if (!j || typeof j !== "object" || Array.isArray(j)) {
          return send({ result: j });
        }
        const o = j as { result?: unknown; error?: Partial<McpErr> };
        if (o.error) {
          const code =
            typeof o.error.code === "string" ? o.error.code : "upstream_error";
          const srv =
            typeof o.error.server === "string" ? o.error.server : server;
          return send({
            error: mcpErr(code, String(o.error.message ?? ""), srv),
          });
        }
        if (!("result" in o)) {
          return send({
            error: mcpErr("upstream_error", "malformed response", server),
          });
        }
        send({ result: o.result });
      })
      .catch((err: unknown) => send({ error: netErr(err, server) }));
  };

  const onMessage = (e: MessageEvent): void => {
    // iframe.isConnected is false after mountIframe's IFRAME_TIMEOUT_MS fallback removed it
    // (or after a swap) — refuse to spend viewer credentials on a connection
    // the viewer has already seen replaced.
    if (!iframe.isConnected) return;
    if (e.origin !== frameOrigin || e.source !== iframe.contentWindow) return;

    // Generic envelope — what kernel-mounted cap modules emit.
    const cap = parseFrameCap(e.data);
    if (cap && cap.cap === "mcp") {
      const send: Send = (b) =>
        iframe.contentWindow?.postMessage(
          frameCapReply(cap.id, b),
          frameOrigin,
        );
      if (cap.method === "listTools") return handleList(send);
      if (cap.method === "callTool") {
        const [s, t, i] = cap.args;
        return handleCall(send, s, t, i);
      }
      return send({
        error: mcpErr("bad_request", `unknown method ${cap.method}`),
      });
    }

    // Kernel telemetry relay (kernel → shell → /api/frame/telemetry).
    // Everything on this path is author-forgeable postMessage, so every
    // detail must be re-bounded shell-side before it becomes a metric label.
    const tm = (e.data as { __frame_cap_telemetry?: unknown } | null)
      ?.__frame_cap_telemetry;
    if (tm && typeof tm === "object") {
      // Granted-cap chunk-load / install() failure — the no-fallback
      // failure mode. Cap name re-bounded to the known released set;
      // anything else (including forged names) buckets as "other".
      if ((tm as { kind?: unknown }).kind === "cap-load-error") {
        const cap = (tm as { cap?: unknown }).cap;
        h.report("cap-load-error", cap === "mcp" ? "mcp" : "other");
        return;
      }
      // Transform fail-open telemetry — only the leg enum crosses; the
      // change-id is author-influenceable via init.changes[] and would blow
      // up label cardinality.
      const leg = (tm as { leg?: unknown }).leg;
      h.report(
        "transform-error",
        leg === "returns" || leg === "throws" ? leg : "unknown",
      );
      return;
    }
  };

  window.addEventListener("message", onMessage);
  // The teardown does NOT post __frame_cap_settle: setUnwire() invokes it on
  // every re-wire (e.g. consent-Continue expanding scope on a live iframe),
  // and a settle there would permanently poison the kernel's latched state.
  // The kernel's own post-init settle covers never-granted; a withdrawn
  // iframe is removed at remount anyway.
  return () => window.removeEventListener("message", onMessage);
}

// ── Consent state ────────────────────────────────────────────────────────
// Consent is a SET of (server, tool) capabilities the viewer has accepted,
// cached in localStorage keyed on viewer identity + slug. We re-prompt only
// when a manifest requests a capability NOT already in that set (so a republish
// that keeps or drops capabilities is silent, and a republish can never
// auto-grant a new one). Any unreadable/corrupt store is treated as
// indeterminate → "nothing consented" → the prompt is the only path to wire.

function consentStoreKey(h: BrokerHost, assetToken: string): string {
  // Key includes viewer identity so an account or org switch on a shared
  // browser re-prompts (localStorage is per-profile, not per-session). The
  // account UUID is the 3rd segment of assetToken ({mac}.{org}.{account}.{exp}).
  const acct = assetToken.split(".")[2] ?? "-";
  return `frame-mcp-consent:${h.org ?? "-"}:${acct}:${h.frameUuid}`;
}

/**
 * Read the consented capability set. Returns null on an INDETERMINATE store
 * (storage blocked or corrupt JSON) — distinct from an empty Map (genuinely
 * no prior consent). The caller treats null as deny-by-default.
 */
function readConsented(key: string): Map<string, Set<string>> | null {
  let raw: string | null;
  try {
    raw = localStorage.getItem(key);
  } catch {
    return null; // storage blocked — indeterminate
  }
  if (raw === null) return new Map();
  try {
    const arr = JSON.parse(raw) as { server?: unknown; tools?: unknown }[];
    const m = new Map<string, Set<string>>();
    for (const s of Array.isArray(arr) ? arr : []) {
      if (!s || typeof s.server !== "string" || !Array.isArray(s.tools)) {
        continue;
      }
      m.set(
        s.server,
        new Set(s.tools.filter((t): t is string => typeof t === "string")),
      );
    }
    return m;
  } catch {
    return null; // corrupt — indeterminate
  }
}

/** True iff the manifest asks for a capability not in `consented`. */
function hasNewCaps(
  servers: readonly ManifestServer[],
  consented: Map<string, Set<string>>,
): boolean {
  for (const s of servers) {
    const ok = consented.get(s.server);
    for (const t of s.tools) if (!ok?.has(t)) return true;
  }
  return false;
}

/** Persist consented ∪ manifest so a later re-add of a dropped cap is silent. */
function persistConsent(key: string, servers: readonly ManifestServer[]): void {
  const merged = readConsented(key);
  if (merged === null) {
    // Indeterminate store (blocked or corrupt): writing `manifest ∪ nothing`
    // would lossily overwrite whatever is actually in there. Skip persisting —
    // this acceptance stays session-only and the next load re-prompts.
    return;
  }
  for (const s of servers) {
    let set = merged.get(s.server);
    if (!set) {
      set = new Set();
      merged.set(s.server, set);
    }
    for (const t of s.tools) set.add(t);
  }
  const arr = [...merged].map(([server, tools]) => ({
    server,
    tools: [...tools],
  }));
  try {
    localStorage.setItem(key, JSON.stringify(arr));
  } catch {
    /* storage blocked — acceptance is session-only */
  }
}

/**
 * Blocking manifest-consent prompt. Shows the frame's declared scope
 * ("This app can access: ...") and calls onContinue/onCancel. The backend
 * enforces manifest ∩ bootstrap regardless — this is the UX consent moment,
 * not the security boundary.
 */
function showConsentDialog(
  h: BrokerHost,
  servers: readonly ManifestServer[],
  cb: { onContinue: () => void; onCancel: () => void },
): void {
  const loading = byId("loading");
  if (loading) loading.hidden = true;
  // ONE decision surface at a time: the nav-confirm is a fixed
  // full-viewport overlay (.consent.nav) while this consent dialog is
  // slot-scoped — left open, the nav-confirm would contest or occlude this
  // dialog while its own arming guard kept running. Close it — the
  // viewer can re-request the link after deciding here. ORDER MATTERS:
  // this runs BEFORE the inert set below. close()'s re-derive sees neither
  // dialog in this window (our box isn't appended yet, the nav marker just
  // nulled) and un-inerts + focuses the iframe; the inert set re-asserts
  // the claim within the same synchronous task, so no user input can land
  // in the gap, and box.focus() at the end re-anchors focus.
  h.closeNavConfirm();
  // Same for the chrome's delete-confirm (a Base-UI modal with its own
  // focus trap and document-level Escape handler): left open, it would
  // contest this dialog's box.focus()/Tab trap and make Escape/Enter
  // ambiguous. A synthetic Escape on the live node lets Base UI run its
  // own close path (TitleMenu's release rAF-loop then waits for full
  // unmount before touching inert — covered by shell's contentInertClaim).
  document
    .querySelector<HTMLElement>(h.confirmOpenSelector)
    ?.dispatchEvent(
      new KeyboardEvent("keydown", { key: "Escape", bubbles: true }),
    );
  // The content iframe is INERT for the dialog's lifetime: the arming
  // guard's quiet-clock reasoning is only sound when there is NO input
  // channel invisible to the document-level reset listeners, and non-inert
  // cross-origin content is exactly such a channel. inert blocks USER
  // input only: frame JS keeps running and already-wired covered brokers
  // still serve its postMessage calls, so granted capabilities continue
  // working while the viewer decides. This also makes the dialog's
  // aria-modal declaration true for assistive tech.
  h.inertContent();
  const box = el("div", "consent");
  // shell's contentInertClaim checks byId(this) — a drift would silently
  // un-inert the iframe under an open consent dialog (the clickjacking
  // vector), so the id comes from shell.ts rather than a local literal.
  box.id = h.consentId;
  box.setAttribute("role", "dialog");
  box.setAttribute("aria-modal", "true");
  const hdr = el("b", undefined, "This app wants to access your connectors");
  hdr.id = "consent-hdr";
  box.setAttribute("aria-labelledby", "consent-hdr");
  box.append(hdr);
  const list = el("ul");
  // list-style:none drops list semantics in Safari/VoiceOver without this.
  list.setAttribute("role", "list");
  // The list is the scroll container (shell.css clamps it). A focusable
  // scroll region is the only keyboard path to the overflow for sighted
  // non-pointer users — the Tab trap below includes it in the cycle.
  list.tabIndex = 0;
  for (const s of servers) {
    // Boot path filters out empty/non-array tools before this runs, so tools
    // is always a non-empty array here — no "0 tools" label case. Every
    // tool is listed — the viewer is granting the full scope, so every name
    // must be inspectable; shell.css clamps the <ul> height and wraps long
    // names so the header and buttons stay on screen regardless of manifest
    // size. Each individual name is display-capped (capName).
    const tools = s.tools;
    const label = `${tools.length} tool${tools.length === 1 ? "" : "s"}: ${tools.map(capName).join(", ")}`;
    // server IS the connector's display name (the manifest key) — no
    // separate spoofable label, so no fallback needed.
    list.append(
      el("li", undefined, el("b", undefined, capName(s.server)), label),
    );
  }
  box.append(list);
  box.append(
    el(
      "span",
      undefined,
      "Tools run with your claude.ai credentials. Only continue if you trust this app.",
    ),
  );
  const row = el("div", "row");
  const btn = el("button", undefined, "Continue") as HTMLButtonElement;
  const cancel = el("button", "secondary", "Cancel") as HTMLButtonElement;
  // Continue is DISARMED for an input-protection window after paint;
  // any input while disarmed restarts the clock. Cancel stays live
  // (safe action). Arming is called synchronously with btn creation so
  // the button is never appended armed.
  const cleanup = h.consentButtonArming(btn, cancel, box);
  btn.onclick = () => {
    // Defense in depth against synthetic events — dispatchEvent() invokes
    // listeners even on a disabled button (native suppression only covers
    // user-initiated activation). Same re-check as buildNavConfirm's Open.
    if (btn.disabled) return;
    cleanup();
    box.remove();
    h.restoreContentInteractivity();
    if (loading) loading.hidden = false;
    // Move focus to the slot before onContinue() so AT doesn't land on <body>
    // after the focused button disappears. The iframe takes focus on
    // reveal(); until then the slot is a sensible anchor.
    h.slot.focus();
    cb.onContinue();
  };
  cancel.onclick = () => {
    cleanup();
    box.remove();
    h.restoreContentInteractivity();
    // Re-anchor focus (the focused Cancel just left the DOM — without this
    // it drops to <body> and keyboard/AT users lose their place).
    h.slot.focus();
    cb.onCancel();
  };
  row.append(cancel, btn);
  box.append(row);
  box.tabIndex = -1;
  h.slot.tabIndex = -1;
  h.slot.append(box);
  // Focus the dialog container — a stray Enter on a just-popped dialog
  // shouldn't sticky-grant (Continue) or dead-end the page (Cancel). Both
  // buttons are one Tab away. Trap Tab within the dialog while open.
  box.addEventListener("keydown", (e) => {
    // Escape = decline, same as Cancel — an aria-modal dialog with a dead
    // Escape key strands keyboard users.
    if (e.key === "Escape") {
      e.preventDefault();
      cancel.click();
      return;
    }
    if (e.key !== "Tab") return;
    // The cycle is list → Cancel → Continue (DOM order). While Continue is
    // disarmed, native tab order skips it (disabled); trap within [list,
    // Cancel] so focus can't walk OUT of this aria-modal dialog into the
    // revealed untrusted iframe on the mount-first swap path, during
    // exactly the window the arming guard exists to protect. The list stop
    // is the keyboard path to scroll the manifest overflow.
    const stops = btn.disabled ? [list, cancel] : [list, cancel, btn];
    const first = stops[0]!;
    const last = stops[stops.length - 1]!;
    // Focus starts on the container itself (box.focus() below) — wrap
    // from there too, or Shift+Tab escapes the aria-modal trap into the
    // header chrome behind the dialog.
    if (
      e.shiftKey &&
      (document.activeElement === first || document.activeElement === box)
    ) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  });
  box.focus();
  // Base UI's ConfirmationDialog (closed via the synthetic Escape above)
  // restores focus to its trigger AFTER React commits — which is after this
  // synchronous focus(). Re-assert once after paint so the aria-modal trap
  // actually engages.
  requestAnimationFrame(() => {
    if (box.isConnected && !box.contains(document.activeElement)) box.focus();
  });
}

/** Resolve the mcp token + manifest from the capabilities envelope.
 * Returns null when the envelope lacks what the broker needs to activate.
 * No apps-side feature-flag check: the CP's four-gate (flag ∩ entitlement
 * ∩ ACL ∩ intent) runs server-side and simply omits `capabilities.mcp`
 * when any gate fails. */
function resolveMcp(
  d: BrokerBoot,
): { token: string; servers: ManifestServer[] } | null {
  const env = d.capabilities?.mcp;
  const token = env?.token;
  const rawServers = env?.config?.servers;
  if (typeof token !== "string" || token === "") return null;
  const raw: unknown[] = Array.isArray(rawServers) ? rawServers : [];
  // Null-safe: Go marshals nil slices as null, and a manifest shape we
  // can't read must not brick the whole frame — filter once so the
  // activation gate, consent dialog, and wireMcp's consented-scope check
  // all agree on what "the manifest" is. Empty ⇒ broker inactive ⇒
  // deny-by-default.
  const servers = raw.filter(
    (s): s is ManifestServer =>
      !!s &&
      typeof s === "object" &&
      typeof (s as ManifestServer).server === "string" &&
      (s as ManifestServer).server !== "" &&
      Array.isArray((s as ManifestServer).tools) &&
      (s as ManifestServer).tools.length > 0,
  );
  return servers.length > 0 ? { token, servers } : null;
}

/**
 * (Re)mount the iframe for a booted version and (re)wire the broker against
 * it. This is the reboot wrapper: a live swap re-mounts the iframe, re-runs
 * the consent check, and re-wires the broker with the fresh token — or leaves
 * it unwired when consent is missing/indeterminate.
 *
 * Runs only when the boot carries a broker capability (shell.ts gates the
 * chunk load). If the manifest then parses empty, falls through to a plain
 * mount with the broker torn down.
 *
 * - consent already has all caps (or a keeps/drops republish) → mount + wire.
 * - new caps, INITIAL boot (nothing ever shown/consented) → prompt BEFORE
 *   mounting: no content renders and NOTHING wires until the viewer accepts —
 *   the pre-live UX. The staleness guard applies because the token can age
 *   while the dialog sits open. Cancel paints the error pane.
 * - new caps, LIVE swap → mount the (already fresh-token) new version now;
 *   servers whose full capability set is already consented re-wire
 *   IMMEDIATELY (an app doesn't lose granted permissions while asking for
 *   more) and only the NEW capabilities wait on the dialog. Continue persists
 *   + wires the full set; Cancel declines only the new capabilities — the
 *   previously-consented subset stays wired. Never auto-grant.
 */
export function mountBrokered(
  h: BrokerHost,
  d: BrokerBoot,
  isInitial: boolean,
  bootAt: number,
): void {
  const mcp = resolveMcp(d);
  // The broker is active iff the CP sent BOTH a manifest AND a token (the CP
  // only mints the token when the frame has a manifest AND the gate is on).
  if (!mcp) {
    // CP declined the capability (flag/entitlement/ACL). The kernel's own
    // post-install settle fires `not_granted` once init arrives without
    // `capabilities.mcp`; no shell-side settle post needed.
    h.setUnwire(null);
    h.mount(d);
    return;
  }
  const { token: mcpToken, servers } = mcp;

  // Wire the broker against `iframe` with exactly `scope` as the consented
  // capability set, replacing any prior broker. A wiring exception leaves the
  // broker UNWIRED (deny) and surfaces a counter — never a half-wired bridge.
  const wire = (
    iframe: HTMLIFrameElement,
    scope: readonly ManifestServer[],
  ): void => {
    try {
      h.setUnwire(wireMcp(h, iframe, mcpToken, scope));
    } catch {
      h.setUnwire(null);
      h.report("broker-consent-error");
    }
  };

  const key = consentStoreKey(h, d.assetToken);
  const stored = readConsented(key);
  if (stored === null) {
    // Indeterminate consent state: deny (prompt below is the only wire path)
    // and surface the failure rather than silently granting.
    h.report("broker-consent-error");
  }
  const consented = stored ?? new Map<string, Set<string>>();

  if (!hasNewCaps(servers, consented)) {
    // Already consented, or a republish that only keeps/drops capabilities.
    wire(h.mount(d), servers);
    return;
  }

  // New capabilities → prompt. Two variants, keyed on isInitial:
  //  - swap (!isInitial): mount the (already fresh-token) new version NOW,
  //    keep fully-covered previously-consented servers wired through the
  //    open dialog, and gate only the NEW capabilities; a decline leaves the
  //    new content running with exactly that consented subset wired.
  //  - initial: prompt BEFORE mounting — no content renders and nothing
  //    wires until the viewer accepts, and a decline paints the error pane.
  //    The assetToken can age past staleMs while the dialog sits open, so
  //    Continue re-boots for a fresh token when stale.
  if (isInitial) {
    // Strict prompt-before-mount: "no content renders and NOTHING wires
    // until the viewer accepts". A previous version's first load may be in
    // flight (contentShown false until reveal()) — tear it down so its
    // alarms/reveal/broker can't interfere with the open dialog.
    h.setUnwire(null);
    h.unmount();
  }
  const f = isInitial ? null : h.mount(d);
  if (f) {
    // The viewer declined nothing yet: servers whose full capability set is
    // already consented stay live while the dialog asks about the rest.
    // Coverage is per-server — a server with even one new tool waits whole.
    const covered = servers.filter((s) => {
      const ok = consented.get(s.server);
      return s.tools.every((t) => ok?.has(t) === true);
    });
    if (covered.length > 0) {
      wire(f, covered);
    } else {
      h.setUnwire(null);
    }
  }
  showConsentDialog(h, servers, {
    onContinue: () => {
      persistConsent(key, servers);
      // Two recovery paths route through onStale, which re-boots with a
      // fresh token and re-sets shell's current.ver; the persist above means
      // the re-boot sees the new caps as consented and mounts without
      // re-prompting. (a) An initial dialog that outlived the asset token.
      // (b) A swap candidate that shell's giveUp() tore down mid-dialog
      // (load error/timeout) — f is detached-but-non-null and current.ver
      // was rolled back, so a direct h.mount(d) would desync it.
      if (
        (isInitial && Date.now() - bootAt > h.staleMs) ||
        (f && !f.isConnected)
      ) {
        h.onStale();
        return;
      }
      h.markShown(); // after the early-returns — only when content actually wires
      wire(f ?? h.mount(d), servers);
    },
    onCancel: () => {
      // swap: the new caps are declined, the swapped-in content stays,
      // and the previously-consented subset wired above stays wired. An
      // uncovered (server,tool) call gets `not_in_manifest` from the broker.
      // initial: the iframe was never mounted (prompt-before-mount), so
      // there's no kernel to settle — the error pane paints below.
      if (!isInitial) return;
      // Paint the decline in-chunk (not via shell's paintError/ERROR_COPY —
      // that map is in the shell entry) so the copy stays lazy.
      byId("loading")?.remove();
      const err = byId("err");
      if (err) {
        err.hidden = false;
        err.replaceChildren(
          el("b", undefined, "Connector access declined"),
          el("span", undefined, "Reload to review the request again."),
        );
        err.tabIndex = -1;
        err.focus();
      }
    },
  });
}
