// Lazy React header chrome — shell.ts import()s this by URL lazily, in
// parallel with the iframe mount, so React arrives as a separate chunk
// that never blocks first content.
// This file is the rollup entry ("frame-shell-chrome" in vite.config.ts);
// the implementation lives in ./chrome/.
//
// The share popover (audience select, people list, per-version share pinning,
// copy link), the title caret menu (delete), the account menu, and the
// first-open coachmark. People are added via an org-member typeahead
// (members_v2 search) because the prod ACL stores account UUIDs, never
// emails. The history view pins which version is shared, and — via
// ctx.preview() → the CP's owner-only ?ver= param — lets the owner
// re-mount a past version in their own iframe.

import { Button, CdsRoot, type CdsFont, type CdsPlatform } from "@ant/cds";
import {
  StrictMode,
  useCallback,
  useEffect,
  useState,
  type ReactNode,
} from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";

import { AccountMenu } from "./chrome/AccountMenu";
import { useAccount } from "./chrome/api";
import { Byline, UGC_DISCLAIMER } from "./chrome/Byline";
import {
  ConnectorReauthHost,
  showReauthDialogViaRegistry,
} from "./chrome/ConnectorReauthDialog";
import {
  ConsentDialogHost,
  showConsentDialogViaRegistry,
} from "./chrome/ConsentDialog";
import { installDomReconcileGuard } from "./chrome/dom-reconcile-guard";
import { Edit } from "./chrome/Edit";
import { Ico } from "./chrome/icons";
import { Report } from "./chrome/Report";
import { Share } from "./chrome/Share";
import { ShowHeader } from "./chrome/ShowHeader";
import { ThemeToggle } from "./chrome/ThemeToggle";
import { TitleMenu } from "./chrome/TitleMenu";
import {
  type ChromeCtx,
  type ChromeHandle,
  type ChromeMod,
} from "./chrome/types";
import { KNOWN_MODES, useMobile } from "./chrome/util";
import { parseFrameChromeCmd } from "./protocol.chrome";

import chromeCss from "./chrome.css?inline";
import chromeTwCss from "./chrome.tw.css?inline";

export type {
  ChromeCtx,
  ChromeHandle,
  FrameEventName,
  FrameTrackOpts,
} from "./chrome/types";

// Translator tolerance for the shell document's React tree: browser
// translators (Chrome/Google Translate, Edge) reparent text nodes under
// the header, and React's commit-phase removeChild/insertBefore then throw
// NotFoundError (facebook/react#11538), crashing the chrome into the
// degraded static header (Sentry issue 7585726464). Module eval runs
// before the first createRoot commit — the same timing as the SPA's
// install in apps/claude-ai/src/main.tsx — and the guard is lazy inside:
// Node.prototype is only patched once a translator signal appears, so
// untranslated sessions run native DOM.
installDomReconcileGuard();

const ds = document.documentElement.dataset;

export const oneOf = <T extends string>(
  v: string | undefined,
  ok: readonly T[],
): T | undefined => (ok.includes(v as T) ? (v as T) : undefined);

function Chrome({ ctx, gen }: { ctx: ChromeCtx; gen: number }): ReactNode {
  // Read per render, not at module init: frame-shell.html stamps the
  // attributes pre-paint, and the dynamic read keeps the flags testable
  // without a module reset.
  const embedded = "embedded" in ds;
  // Host-chrome embed: header hidden (--bar:0); the host owns Share +
  // attribution and opens the sheet via the postMessage bridge.
  const hostChrome = embedded && ds.chrome === "none";
  const org = ctx.org;
  // shell.ts prefetches /api/account at module eval (~500ms before this
  // chunk mounts), so account is typically resolved by first render. The
  // createRoot on #hdr replaces the static skeleton in one commit — no
  // per-slot swap dance.
  const account = useAccount(ctx.accountPrefetch, org);
  const mobile = useMobile();
  // User-toggled "Hide header" (AccountMenu): collapses --bar to 0 via
  // html[data-header-hidden] so the artifact fills the viewport. Ephemeral
  // by design — plain component state, never persisted. No keyboard
  // shortcut: the corner ShowHeader button is the one exit path, which
  // keeps the PR frame-shell-only and avoids the "whose Escape is it"
  // question for artifacts with their own modals.
  const [headerHidden, setHeaderHidden] = useState(false);
  const hideHeader = useCallback(() => setHeaderHidden(true), []);
  const showHeader = useCallback(() => setHeaderHidden(false), []);
  // Anon public view. perm is absent on these boots, so the owner/share/
  // report affordances are already off; the trust-surface consequences
  // (disclaimer, sign-in/growth action, no hide-header) are documented on
  // ChromeCtx.boot.publicView (chrome/types.ts).
  const publicView = ctx.boot.publicView === true;
  // A silent downgrade into publicView re-renders this same Chrome
  // instance, so a header hidden while authenticated would stay collapsed
  // — hiding the disclaimer, the one element publicView exists to show.
  useEffect(() => {
    if (publicView) setHeaderHidden(false);
  }, [publicView]);
  useEffect(() => {
    if (!headerHidden) return undefined;
    ds.headerHidden = "";
    return () => {
      delete ds.headerHidden;
      // Return focus to the trigger that opened it. Runs after the
      // ds.headerHidden delete so #hdr is no longer display:none. Under
      // publicView the AccountMenu (and its trigger) is replaced by the
      // sign-in action, so fall back to it — a silent downgrade unhides
      // the header via the publicView effect above, and focus must land
      // on a still-mounted element instead of stranding on <body>.
      (
        document.querySelector<HTMLButtonElement>(
          "button[data-account-trigger]",
        ) ?? document.querySelector<HTMLElement>("[data-public-action]")
      )?.focus();
    };
  }, [headerHidden]);
  // Host-command bridge: when embedded the header is hidden, so the host
  // opens the share sheet by posting here. Only the SAME-ORIGIN embedding
  // window may drive chrome: the artifact content iframe is cross-origin
  // (fails the origin check) and any other window fails the source pin.
  // e.data is untrusted input — strict shape check, no other fields read,
  // no reply posted. Commands arriving before this chunk mounts (or for a
  // non-owner, who has no Share) are dropped — the command only opens UI;
  // it grants nothing the viewer's own session couldn't already do.
  const [hostShareGen, setHostShareGen] = useState(0);
  const [hostDismissGen, setHostDismissGen] = useState(0);
  useEffect(() => {
    if (!hostChrome) return;
    const onMsg = (e: MessageEvent): void => {
      if (e.origin !== location.origin || e.source !== window.parent) return;
      const d = parseFrameChromeCmd(e.data);
      if (!d) return;
      if (d.cmd === "share") setHostShareGen((g) => g + 1);
      else if (d.cmd === "dismiss") setHostDismissGen((g) => g + 1);
    };
    window.addEventListener("message", onMsg);
    return () => window.removeEventListener("message", onMsg);
  }, [hostChrome]);
  const role = ctx.boot.perm?.role;
  const isOwner = role === "owner";
  // sync: render.ts paintError's 401 action — same returnTo, same ?sk=
  // strip (the share key stays inside this document's containment).
  const publicActionHref = (): string => {
    const ret = new URLSearchParams(location.search);
    ret.delete("sk");
    const retQ = ret.toString();
    return `/login?returnTo=${encodeURIComponent(
      location.pathname + (retQ ? `?${retQ}` : ""),
    )}`;
  };
  const disclaimer = <span className="byline shrink-0">{UGC_DISCLAIMER}</span>;
  // Report affordance: every viewer, owners included — matching the chat
  // panel (ArtifactFrame.tsx), whose Report action has no owner gate, and
  // the CP route, which accepts owner-filed reports (handleReport gates on
  // can-view, not non-ownership). Behind the CP-resolved
  // `frame_report_button` GB flag. Client gate is visibility-only; the
  // route 404s flag-off and answers oracle-safe 202.
  const showReport = ctx.boot.reportEnabled === true;
  // Share needs org context (the CP rejects org-less writes anyway) and
  // a role that may PATCH perm — owner always, writer when the CP says so
  // (writersEnabled only arrives on writer/owner boots; role==="writer"
  // without it is a contract bug we fail closed on). Delete stays
  // owner-only. Unknown read modes render no share UI — see KNOWN_MODES.
  const mode = ctx.boot.perm?.mode ?? "";
  const writeMode = ctx.boot.perm?.write?.mode ?? "owner";
  const canShare =
    (isOwner ||
      (role === "writer" && ctx.boot.perm?.writersEnabled === true)) &&
    org !== undefined &&
    KNOWN_MODES.has(mode) &&
    // Fail-closed on the write axis too: an unknown write tier this build
    // can't represent must not be editable (a single role gesture would
    // clobber it). Absent write → "owner" → known. Gate-on only: a
    // gate-off session never asserts the write axis (no role dropdown,
    // PATCH bodies omit `write`), so an unknown tier there is
    // unclobberable and must not cost the owner the whole sheet.
    (ctx.boot.perm?.writersEnabled !== true || KNOWN_MODES.has(writeMode)) &&
    // writersEnabled===true is itself the post-upgrade-CP signal — the
    // owner-boot-only `live`/`users` disjunct below doesn't hold for a
    // writer whose read allowlist is empty, and gating them on it would
    // hide the sheet from exactly the users this PR exists to serve.
    (ctx.boot.perm?.writersEnabled === true ||
      // The companion-CP protocol signal is `live` (an owner-only field
      // that's never omitempty'd). `perm.users` is `omitempty` on the wire,
      // so an owner with an empty allowlist arrives without the key — that's
      // [] (useShareSaves seeds it as such), not "CP doesn't support it". A
      // boot with NEITHER is a pre-upgrade CP → degraded mode (every PATCH
      // replaces the stored list wholesale, so editing from a boot that
      // never carried it would silently wipe whatever the server has).
      Array.isArray(ctx.boot.perm?.users) ||
      typeof ctx.boot.live === "string");
  // mayWiden is the CP's Phase-1 prosumer hint (perm.mayWiden): false means
  // this caller's widen would 403 at setReadMode. The share sheet has
  // nothing useful for an owner-only-forever caller, so render the trigger
  // disabled with an upsell tooltip instead. No stored-mode guard: Pro/Max
  // never had Artifacts access, and orgs don't cross the consumer/non-
  // consumer boundary (see the CP's mayWidenReadMode), so a consumer-tier
  // owner's frame is always already private — there are no grandfathered
  // shares to narrow. The one transient exception is a Team/Enterprise
  // owner whose OrgType degrades to "" during a userauth brownout: their
  // shared frame reads mayWiden=false until refresh; the CP's requireAuth
  // retry makes this rare, and a refresh recovers. `=== false`
  // specifically — undefined is a CP that predates the field, and the
  // sheet stays interactive (server still enforces). The locked button
  // mirrors Share.tsx's Popover.Trigger (secondary; iconOnly on mobile).
  const shareLocked = canShare && ctx.boot.perm?.mayWiden === false;
  const lockedShareBtn = (
    <Button
      data-share-btn=""
      variant="secondary"
      disabled
      disabledReason="Sharing artifacts is available on Team or Enterprise plans"
      disabledReasonSide="bottom"
      {...(mobile
        ? {
            iconOnly: true as const,
            icon: Ico.shareApple,
            "aria-label": "Share",
          }
        : { children: "Share" })}
    />
  );
  // Embedded slim header: byline left, Share right. Host-chrome embed:
  // header is CSS-hidden; render only Share so the host's postMessage
  // command can open the (portaled) sheet.
  if (embedded) {
    return (
      <>
        {!hostChrome && (
          <div className="l">
            {publicView ? disclaimer : <Byline ctx={ctx} account={account} />}
          </div>
        )}
        <div
          className="r"
          data-chrome-root=""
          data-size={mobile ? undefined : "xs"}
        >
          {shareLocked
            ? lockedShareBtn
            : canShare && (
                <Share
                  gen={gen}
                  ctx={ctx}
                  org={org}
                  account={account}
                  hostChrome={hostChrome}
                  hostOpenGen={hostShareGen}
                  hostDismissGen={hostDismissGen}
                />
              )}
        </div>
      </>
    );
  }
  // The CdsRoot is comfortable (base = 32px) so popovers/menus get touch-
  // sized rows everywhere. Pin the in-header controls back to step 2
  // (24px) on desktop via data-size="xs" so the bar matches the static
  // shell.css geometry; mobile takes the 32px base.
  return (
    <>
      <div className="l">
        {/* A public viewer has no gallery; the logo goes home instead. */}
        <a
          className="logo"
          href={publicView ? "/" : "/code/artifacts"}
          aria-label={publicView ? "Claude" : "Your artifacts"}
        >
          <span className="ico" aria-hidden>
            {"\u{E017}"}
          </span>
        </a>
        {publicView ? (
          // Plain title: a PublicFields boot carries no author, dates,
          // history, or versions, so the caret menu would open onto an
          // empty popup (and the version list stays hidden to public
          // viewers regardless — they see one pinned version). Width is
          // capped like the title button (shell.css max-width: 24rem) and
          // the disclaimer is shrink-0: the title is author-controlled
          // input and must never crowd the trust copy out of view.
          <span
            data-public-title=""
            className="ml-[2px] max-w-96 min-w-0 truncate pl-[6px] text-body font-medium text-primary"
          >
            {ctx.boot.title}
          </span>
        ) : (
          <TitleMenu
            ctx={ctx}
            org={org}
            account={account}
            interactive={isOwner && org !== undefined}
            mobile={mobile}
          />
        )}
        {publicView ? disclaimer : <Byline ctx={ctx} account={account} />}
      </div>
      {/* Edit reopens the owner's last-editing session with this frame in
          its drawer. last_edit is owner-boot-only, so the isOwner guard is
          defense-in-depth. */}
      {isOwner && <Edit ctx={ctx} mobile={mobile} />}
      <div
        className="r"
        data-chrome-root=""
        data-size={mobile ? undefined : "xs"}
      >
        {/* Standalone header only — embedded panes inherit the embedder's
            theme axis, so the embedded branch above renders no toggle. */}
        {ctx.boot.themeToggle && <ThemeToggle />}
        {showReport && <Report ctx={ctx} />}
        {/* `gen` is a PROP (not a React key): a sigOf-changing reboot
            re-seeds useShareSaves' optimistic baselines in-chain (serialized
            after any pending write settles) instead of remounting. An open
            sheet stays open and in-flight writes' success handlers apply to
            this same instance. */}
        {shareLocked
          ? lockedShareBtn
          : canShare && (
              <Share
                gen={gen}
                ctx={ctx}
                org={org}
                account={account}
                hostChrome={false}
                hostOpenGen={hostShareGen}
                hostDismissGen={hostDismissGen}
              />
            )}
        {/* account non-null under a PublicFields boot is the silent-
            downgrade signal: /api/account answered with a live session,
            but the boot's credential didn't reach the CP (expired between
            fetches, cookie dropped). Re-prompt sign-in instead of the
            growth CTA — this viewer may have real access the anon
            response can't show. */}
        {publicView ? (
          <Button
            data-public-action=""
            variant="secondary"
            href={publicActionHref()}
          >
            {account ? "Sign in" : "Try Claude"}
          </Button>
        ) : (
          <AccountMenu
            account={account}
            mobile={mobile}
            onHideHeader={hideHeader}
          />
        )}
      </div>
      {headerHidden && <ShowHeader onExit={showHeader} mobile={mobile} />}
    </>
  );
}

// sync: deferred.ts 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 ?? "";
}

function injectCss(): void {
  if (!document.getElementById("frame-chrome-css")) {
    const s = document.createElement("style");
    s.id = "frame-chrome-css";
    s.nonce = cspNonce();
    // The Tailwind sheet first — its preflight reset must come before
    // chrome.css's selectors.
    s.textContent = chromeTwCss + chromeCss;
    document.head.appendChild(s);
  }
}

// CdsRoot owns .cds-root + data-mode stamping now; scope() wraps each
// tree so CDS-portaled content (CdsPortal) lands inside it. mode is
// omitted so CdsRoot's [data-mode] DOM fallback reads <html> (stamped
// pre-stylesheet from the embedder) and live-tracks it.
//
// platform defaults to "web", never CdsRoot's UA sniff: the chrome (share
// sheet, menus) is a web-page surface, and inside an Electron-hosted
// embed the sniff would silently flip --cds-cursor-interactive to the
// native arrow convention — the same sheet at the standalone URL shows a
// pointer. An embedder that wants the native convention opts in
// explicitly via the platform axis (?p= / the desktop pane preload).
function scope(children: ReactNode): ReactNode {
  return (
    <CdsRoot
      // display: contents so .l/.r are #hdr's direct flex children.
      className="contents"
      // Comfortable everywhere so portaled surfaces (share sheet, menus,
      // confirm dialog) get the 32px base step at every viewport. The
      // in-header controls are pinned back to step 2 on desktop via
      // data-size="xs" (Chrome's [data-chrome-root] + TitleMenu's trigger)
      // so they align with the static shell.css geometry, which is fixed-px
      // and ships no CDS runtime.
      density="comfortable"
      platform={oneOf<CdsPlatform>(ds.platform, ["web", "desktop"]) ?? "web"}
      font={oneOf<CdsFont>(ds.font, ["anthropic", "system"])}
    >
      {children}
    </CdsRoot>
  );
}

/** Mounts the chrome; returns an unmount handle (the shell never calls it —
 * the page lives as long as the chrome — but tests must, or document-level
 * listeners and timers leak across cases). */
export function mount(
  el: HTMLElement,
  ctx: ChromeCtx,
  onError?: (e: unknown) => void,
): ChromeHandle {
  // Chrome styles are ?inline-bundled into this JS chunk — the
  // @vite-ignore import in the entries has no mechanism to auto-load a
  // separate CSS asset.
  injectCss();
  // onUncaughtError surfaces throws from the chrome during concurrent
  // render — those never propagate out of createRoot().render() and so
  // are invisible to the caller's try/catch.
  const root = createRoot(el, { onUncaughtError: onError });
  // `gen` bumps per share-relevant boot delta; useShareSaves enqueues an
  // in-chain re-seed on each bump so the sheet's optimistic baselines
  // adopt server truth without a remount (the sheet stays open). Only
  // bump when the share-relevant fields actually changed; a no-change
  // reboot (e.g. token refresh) re-renders
  // in place. `versions` is excluded: Share reads it via useMemo (not
  // useState-seeded), so shell.ts's lazy /versions fetch resolving is an
  // in-place label re-render with no reseed needed. perm.version IS part
  // of the signature: it's the catch-all for any perm write (rotate, or a
  // change whose mode/users delta isn't observable from this client's
  // last snapshot).
  const sigOf = (b: ChromeCtx["boot"]): string =>
    // Null-safe (a malformed boot must throw inside React's render where
    // onUncaughtError catches it, not here) and key-order-stable (perm is
    // destructured into a fixed-order array so two boots with identical
    // content but differently-ordered JSON can't trigger a gratuitous
    // reseed).
    b
      ? JSON.stringify([
          b.perm?.role,
          b.perm?.mode,
          b.perm?.users,
          b.perm?.write?.mode,
          b.perm?.write?.users,
          b.perm?.writersEnabled,
          b.perm?.version,
          b.shared,
          b.live,
          b.history,
        ])
      : "";
  let gen = 0;
  let lastSig = sigOf(ctx.boot);
  const render = (c: ChromeCtx): void =>
    root.render(
      <StrictMode>
        {scope(
          <>
            <Chrome ctx={c} gen={gen} />
            <ConnectorReauthHost />
            <ConsentDialogHost />
          </>,
        )}
      </StrictMode>,
    );
  // Synchronous first commit: ConnectorReauthHost registers its dialog impl
  // in a layout effect, so flushing here makes `mount() returned` imply
  // `showReauthDialog is live` — without it, a broker poll tick landing in
  // the mount→commit window would get null and forfeit the prompt. Updates
  // stay async (only the initial handle-availability contract needs this).
  flushSync(() => render(ctx));
  return {
    update(next) {
      const sig = sigOf(next.boot);
      if (sig !== lastSig) {
        gen++;
        lastSig = sig;
      }
      render(next);
    },
    unmount() {
      root.unmount();
    },
    // Delegates to the host's registered impl — null only before the host
    // commits (first paint) or after unmount, both of which callers treat
    // as "not yet"; the broker's reveal-poll retries.
    showReauthDialog: (rows, cbs) => showReauthDialogViaRegistry(rows, cbs),
    showConsentDialog: (rows, opts, cbs) =>
      showConsentDialogViaRegistry(rows, opts, cbs),
  };
}
mount satisfies ChromeMod["mount"];
