// ── Symframe registry ─────────────────────────────────────────────────
// Phase 2 of the symframe arc. Provides `window.symframe.add/update/remove/list`
// for dynamic cards in the right column. The 3 existing panels (SUBAGENTS,
// REASONING, SCHEDULING) auto-register at boot as sticky cards — they keep
// their own DOM and JS hookups, the registry just tracks metadata.
//
// Card stack order: ephemeral (non-sticky) cards stack at the TOP of the
// container, newest at the very top; sticky cards anchor below them in
// fixed insertion order so the always-visible system panels never drift.
//
// Phase 3+ will add type-specific renderers + a server-side push RPC. For
// now the registry is JS-only and exercisable from the browser console:
//   symframe.add({ title: "Test", body: "hello", dismissable: true });
//   symframe.update("<id>", { body: "updated" });
//   symframe.remove("<id>");

(function () {
  const container = document.getElementById("symframe-cards");
  const countEl = document.getElementById("symframe-count");
  if (!container) {
    return;
  }

  /** @type {Map<string, Card>} */
  const cards = new Map();

  /**
   * @typedef {Object} Card
   * @property {string} id
   * @property {string} [type]            renderer key — see `renderers` map below
   * @property {string} [intent]          mental-state slot (Stage A): holding | weighing |
   *                                       producing | awaiting | drafted | reference. Renderers
   *                                       ignore intent for now; the registry stores it on the
   *                                       entry and writes it as a data-intent attribute on the
   *                                       card root so Stage B+ CSS can decorate per intent
   *                                       without further JS changes.
   * @property {string} title             header text (rendered uppercase via existing .panel-label)
   * @property {string} [body]            plain-text body (escaped)
   * @property {string} [bodyHtml]        trusted-HTML body (no escape) — only set internally
   * @property {Array<Action>} [actions]  footer buttons
   * @property {boolean} [sticky]         true → anchored at bottom of stack, no dismiss control
   * @property {boolean} [dismissable]    true → header gains a × button that calls remove(id)
   * @property {Element} [element]        DOM element for register()-tracked cards (existing panels)
   * @property {number} createdAt         set by the registry
   */

  /**
   * @typedef {Object} Action
   * @property {string} label
   * @property {"primary"|"secondary"} [kind]
   * @property {() => void} onClick
   */

  function genId() {
    return `sf-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
  }

  function updateCount() {
    if (!countEl) {
      return;
    }
    const total = cards.size;
    // Stage C.1: blank when no cards (the static "2 cards" default
    // was confusing post-Stage-B/C when the panel often has 0 cards).
    if (total === 0) {
      countEl.textContent = "";
    } else {
      countEl.textContent = total === 1 ? "1 card" : `${total} cards`;
    }
  }

  // ── Per-type body renderers ──
  // Each renderer takes a card and returns an HTMLElement (or
  // DocumentFragment) that gets appended to the body slot. New types
  // plug in by adding to the renderers map below — no other code path
  // changes required.

  function renderTextBody(card) {
    const div = document.createElement("div");
    div.className = "symframe-body-text";
    if (card.bodyHtml) {
      div.innerHTML = card.bodyHtml;
    } else {
      div.textContent = card.body || "";
    }
    return div;
  }

  function renderMarkdownBody(card) {
    const div = document.createElement("div");
    div.className = "symframe-body-markdown";
    if (typeof window.marked?.parse === "function") {
      div.innerHTML = window.marked.parse(card.body || "");
    } else {
      div.textContent = card.body || "";
    }
    return div;
  }

  function renderCodeBody(card) {
    const pre = document.createElement("pre");
    pre.className = "symframe-body-code";
    const code = document.createElement("code");
    if (card.language) {
      code.classList.add(`language-${card.language}`);
    }
    code.textContent = card.body || "";
    pre.appendChild(code);
    // hljs is bundled as window.hljs via vendor/highlight.min.js. Best-effort
    // highlight; if hljs throws (unknown language, etc.) the plain pre/code
    // already renders fine.
    if (typeof window.hljs?.highlightElement === "function") {
      try {
        window.hljs.highlightElement(code);
      } catch (err) {
        console.warn("[symframe] hljs.highlightElement failed:", err);
      }
    }
    return pre;
  }

  function renderEmailDraftBody(card) {
    const wrap = document.createElement("div");
    wrap.className = "symframe-body-email";
    const email = card.email || {};
    const headerRows = [
      ["From", email.from],
      ["To", email.to],
      ["Cc", email.cc],
      ["Bcc", email.bcc],
      ["Subject", email.subject],
    ];
    for (const [label, value] of headerRows) {
      if (!value) {
        continue;
      }
      const row = document.createElement("div");
      row.className = "symframe-email-row";
      const lbl = document.createElement("span");
      lbl.className = "symframe-email-label";
      lbl.textContent = `${label}:`;
      const val = document.createElement("span");
      val.className = "symframe-email-value";
      val.textContent = String(value);
      row.appendChild(lbl);
      row.appendChild(val);
      wrap.appendChild(row);
    }
    if (email.body) {
      const body = document.createElement("div");
      body.className = "symframe-email-body";
      body.textContent = email.body;
      wrap.appendChild(body);
    }
    return wrap;
  }

  function renderEmptyMedia(reason) {
    const div = document.createElement("div");
    div.className = "symframe-body-empty";
    div.textContent = reason;
    return div;
  }

  function renderPdfBody(card) {
    if (!card.url) {
      return renderEmptyMedia("(no url provided)");
    }
    const wrap = document.createElement("div");
    wrap.className = "symframe-body-pdf";
    const frame = document.createElement("iframe");
    frame.src = card.url;
    frame.className = "symframe-pdf-iframe";
    frame.setAttribute("title", card.title || "PDF document");
    wrap.appendChild(frame);
    wrap.appendChild(buildDownloadLink(card.url, card.downloadName, "Download PDF"));
    return wrap;
  }

  function renderAudioBody(card) {
    if (!card.url) {
      return renderEmptyMedia("(no url provided)");
    }
    const wrap = document.createElement("div");
    wrap.className = "symframe-body-audio";
    const audio = document.createElement("audio");
    audio.controls = true;
    audio.preload = "metadata";
    audio.className = "symframe-audio";
    if (card.mimeType) {
      const source = document.createElement("source");
      source.src = card.url;
      source.type = card.mimeType;
      audio.appendChild(source);
    } else {
      audio.src = card.url;
    }
    wrap.appendChild(audio);
    wrap.appendChild(buildDownloadLink(card.url, card.downloadName, "Download"));
    return wrap;
  }

  function renderVideoBody(card) {
    if (!card.url) {
      return renderEmptyMedia("(no url provided)");
    }
    const wrap = document.createElement("div");
    wrap.className = "symframe-body-video";
    const video = document.createElement("video");
    video.controls = true;
    video.preload = "metadata";
    video.className = "symframe-video";
    video.playsInline = true;
    if (card.poster) {
      video.poster = card.poster;
    }
    if (card.mimeType) {
      const source = document.createElement("source");
      source.src = card.url;
      source.type = card.mimeType;
      video.appendChild(source);
    } else {
      video.src = card.url;
    }
    wrap.appendChild(video);
    wrap.appendChild(buildDownloadLink(card.url, card.downloadName, "Download"));
    return wrap;
  }

  function buildDownloadLink(url, downloadName, label) {
    const a = document.createElement("a");
    a.href = url;
    if (downloadName) {
      a.download = downloadName;
    } else {
      a.setAttribute("download", "");
    }
    a.target = "_blank";
    a.rel = "noopener noreferrer";
    a.className = "symframe-download";
    a.textContent = `⬇ ${label}`;
    return a;
  }

  const renderers = {
    text: renderTextBody,
    markdown: renderMarkdownBody,
    code: renderCodeBody,
    "email-draft": renderEmailDraftBody,
    pdf: renderPdfBody,
    audio: renderAudioBody,
    video: renderVideoBody,
  };

  function renderBodyForType(card) {
    const type = card.type || "text";
    const renderer = renderers[type] || renderers.text;
    return renderer(card);
  }

  // True iff the card has any body-shaped data — body, bodyHtml, or
  // type-specific structured fields. Used to decide whether to render
  // the body slot at all.
  function hasBodyContent(card) {
    if (card.body || card.bodyHtml) {
      return true;
    }
    if (card.url) {
      return true;
    }
    if (card.email && typeof card.email === "object") {
      return true;
    }
    return false;
  }

  // Choose what `Copy` would copy for this card. Honors body/bodyHtml first,
  // then type-specific shapes (email-draft renders a plain-text email;
  // url-based media types copy the URL).
  function resolveCopyTarget(card) {
    if (card.body) {
      return card.body;
    }
    if (card.bodyHtml) {
      return card.bodyHtml;
    }
    if (card.email && typeof card.email === "object") {
      const lines = [];
      if (card.email.from) {
        lines.push(`From: ${card.email.from}`);
      }
      if (card.email.to) {
        lines.push(`To: ${card.email.to}`);
      }
      if (card.email.cc) {
        lines.push(`Cc: ${card.email.cc}`);
      }
      if (card.email.bcc) {
        lines.push(`Bcc: ${card.email.bcc}`);
      }
      if (card.email.subject) {
        lines.push(`Subject: ${card.email.subject}`);
      }
      if (card.email.body) {
        lines.push("");
        lines.push(card.email.body);
      }
      return lines.join("\n");
    }
    if (card.url) {
      return card.url;
    }
    return "";
  }

  // Default Copy action when the card has copyable content and no explicit
  // actions. Custom actions take precedence — server-pushed cards or
  // tool-pushed cards with their own buttons get those instead.
  function buildDefaultActions(card) {
    if (Array.isArray(card.actions) && card.actions.length > 0) {
      return card.actions;
    }
    const copyTarget = resolveCopyTarget(card);
    if (!copyTarget) {
      return [];
    }
    return [
      {
        label: "Copy",
        kind: "secondary",
        onClick: async () => {
          try {
            if (navigator.clipboard && typeof navigator.clipboard.writeText === "function") {
              await navigator.clipboard.writeText(copyTarget);
            }
          } catch (err) {
            console.warn("[symframe] copy failed:", err);
          }
        },
      },
    ];
  }

  function renderCardElement(card) {
    const el = document.createElement("div");
    el.className = "glass-panel symframe-card symframe-card-dynamic";
    if (card.sticky) {
      el.classList.add("symframe-card-sticky");
    }
    el.dataset.cardId = card.id;
    if (card.type) {
      el.dataset.cardType = card.type;
    }
    // Stage A: surface intent as a CSS-targetable attribute so Stage B+
    // can decorate per-intent without a JS change. No styling reads
    // [data-intent] in Stage A — this is purely the hook.
    if (card.intent) {
      el.dataset.intent = card.intent;
    }

    // Header
    const header = document.createElement("div");
    header.className = "panel-label symframe-card-header";
    const titleSpan = document.createElement("span");
    titleSpan.textContent = card.title || "";
    header.appendChild(titleSpan);
    // Stage C: pin glyph for pinned cards. Click toggles unpin.
    if (card.pinned) {
      const pinGlyph = document.createElement("button");
      pinGlyph.className = "symframe-card-pin";
      pinGlyph.type = "button";
      pinGlyph.setAttribute("aria-label", "Unpin");
      pinGlyph.title = "Pinned — click to unpin";
      pinGlyph.textContent = "📌";
      pinGlyph.addEventListener("click", (e) => {
        e.preventDefault();
        e.stopPropagation();
        unpin(card.id);
      });
      header.appendChild(pinGlyph);
    } else if (card.dismissable && !card.sticky) {
      const dismiss = document.createElement("button");
      dismiss.className = "symframe-card-dismiss";
      dismiss.type = "button";
      dismiss.setAttribute("aria-label", "Dismiss");
      dismiss.textContent = "×";
      dismiss.addEventListener("click", (e) => {
        e.preventDefault();
        e.stopPropagation();
        remove(card.id);
      });
      header.appendChild(dismiss);
    }
    el.appendChild(header);

    // Body via type-specific renderer (returns an Element)
    if (hasBodyContent(card)) {
      const body = document.createElement("div");
      body.className = "symframe-card-body";
      const rendered = renderBodyForType(card);
      if (rendered) {
        body.appendChild(rendered);
      }
      el.appendChild(body);
    }

    // Actions (custom or default Copy)
    const actions = buildDefaultActions(card);
    if (actions.length > 0) {
      const actionsRow = document.createElement("div");
      actionsRow.className = "symframe-card-actions";
      for (const action of actions) {
        const btn = document.createElement("button");
        btn.className = "symframe-card-action";
        if (action.kind === "primary") {
          btn.classList.add("symframe-card-action-primary");
        }
        btn.type = "button";
        btn.textContent = action.label || "";
        if (typeof action.onClick === "function") {
          btn.addEventListener("click", (e) => {
            e.preventDefault();
            try {
              action.onClick();
            } catch (err) {
              console.warn("[symframe] action threw:", err);
            }
          });
        } else if (action.rpc) {
          // Server-pushed cards declare actions as { rpc: "method", params: {...} }
          // since closures don't survive the WebSocket. Resolve via the
          // gateway client at click time.
          btn.addEventListener("click", async (e) => {
            e.preventDefault();
            try {
              if (window.gateway?.rpc) {
                await window.gateway.rpc(action.rpc, action.params || {});
              }
            } catch (err) {
              console.warn("[symframe] rpc action failed:", action.rpc, err);
            }
          });
        }
        actionsRow.appendChild(btn);
      }
      el.appendChild(actionsRow);
    }

    return el;
  }

  function insertElement(card, el) {
    if (card.sticky) {
      // Sticky cards anchor below all ephemeral cards. Append to end of
      // the sticky group (which is the end of the container, since
      // ephemeral cards live above).
      container.appendChild(el);
    } else {
      // Ephemeral cards stack at the top, newest first → prepend.
      container.prepend(el);
    }
  }

  /**
   * Track an existing DOM-managed card (e.g. SUBAGENTS panel). The
   * element is already in the container and renders/updates itself; the
   * registry just records metadata so list/count/sort logic can see it.
   */
  function register(card) {
    if (!card || !card.id || !card.element) {
      console.warn("[symframe] register: missing id or element");
      return;
    }
    const entry = {
      ...card,
      sticky: card.sticky !== false,
      dismissable: false,
      createdAt: Date.now(),
    };
    cards.set(entry.id, entry);
    entry.element.classList.add("symframe-card");
    if (entry.sticky) {
      entry.element.classList.add("symframe-card-sticky");
    }
    updateCount();
    return entry;
  }

  /**
   * Add a new dynamic card. Renders into the container, prepended for
   * ephemeral cards or appended for sticky cards.
   */
  function add(card) {
    const id = card?.id || genId();
    if (cards.has(id)) {
      // Treat as update if id collides.
      return update(id, card);
    }
    const entry = {
      ...card,
      id,
      sticky: card?.sticky === true,
      dismissable: card?.dismissable !== false,
      createdAt: Date.now(),
    };
    const el = renderCardElement(entry);
    entry.element = el;
    cards.set(id, entry);
    insertElement(entry, el);
    updateCount();
    // Stage D: if focus mode is active and this is the focused card,
    // apply the halo class immediately so the card renders focused
    // on first paint (otherwise the user briefly sees a dim card).
    if (modeState.current === "focus" && modeState.focusedId === id) {
      el.classList.add("symframe-card-focused");
    }
    return entry;
  }

  /**
   * Patch an existing card. Re-renders the card's DOM element from the
   * merged data; preserves stack position.
   *
   * Stage A: sticky cards refuse update without `force=true`. Native panels
   * (subagents, scheduling, reasoning) register with sticky=true and have
   * a real DOM `element` already in the container; running renderCardElement
   * on them would key off `card.type` (e.g. "panel-subagents") which is not
   * in the `renderers` map, falling back to renderTextBody and nuking the
   * panel's hand-built DOM. Mirrors the existing remove() sticky guard.
   *
   * @param {string} id
   * @param {Partial<Card>} patch
   * @param {boolean} [force=false] required for sticky cards
   */
  function update(id, patch, force = false) {
    const existing = cards.get(id);
    if (!existing) {
      console.warn(`[symframe] update: unknown id ${id}`);
      return;
    }
    if (existing.sticky && !force) {
      console.warn(`[symframe] update: refusing to patch sticky card '${id}' without force=true`);
      return existing;
    }
    if (!existing.element) {
      console.warn(`[symframe] update: card ${id} has no element (registered, not added)`);
      // Allow metadata patch without re-render
      const merged = { ...existing, ...patch, id };
      cards.set(id, merged);
      return merged;
    }
    const merged = { ...existing, ...patch, id };
    const newEl = renderCardElement(merged);
    merged.element = newEl;
    existing.element.replaceWith(newEl);
    cards.set(id, merged);
    // Stage D: re-apply the focus class if this is the focused card
    // (replaceWith drops classes on the old element).
    if (modeState.current === "focus" && modeState.focusedId === id) {
      newEl.classList.add("symframe-card-focused");
    }
    return merged;
  }

  /**
   * Remove a card from the registry + DOM. Sticky cards refuse removal
   * unless force=true is passed (defensive — the system panels really
   * shouldn't disappear via stray API calls).
   *
   * Stage C: pinned cards behave like sticky for the user-side dismiss
   * button (× hidden in the header) but `remove` still drops them when
   * called — the pin survives in localStorage and the card re-appears
   * on the next reload. To actually drop a pinned card permanently,
   * the agent / user must unpin first.
   */
  function remove(id, force = false) {
    const entry = cards.get(id);
    if (!entry) {
      return false;
    }
    if (entry.sticky && !force) {
      console.warn(`[symframe] remove: refusing to drop sticky card '${id}' without force=true`);
      return false;
    }
    if (entry.element && entry.element.parentElement === container) {
      entry.element.remove();
    }
    cards.delete(id);
    updateCount();
    return true;
  }

  // ── Stage D: modes ─────────────────────────────────────────────────
  // The agent picks a panel posture per task via display_artifact
  // action="setMode". Mode is applied as a data-mode attribute on the
  // #symframe root; per-mode CSS rules in style.css handle the layout
  // shift (substrate/foreground/awaiting proportions, focused-card
  // highlight). No JS layout math — all visual changes are CSS-driven.
  //
  // User override: clicking a mode chip in the header sets userOverride
  // = true. While true, agent setMode calls are stored as
  // pendingAgentMode but NOT applied to the DOM — the user's choice
  // wins. An AUTO chip becomes visible; clicking it clears the
  // override and applies whatever the agent last asked for.
  const MODES = ["ambient", "focus", "awaiting", "quiet"];
  const DEFAULT_MODE = "ambient";
  /** @type {{ current: string, userOverride: boolean, pendingAgent: string | null, focusedId: string | null }} */
  const modeState = {
    current: DEFAULT_MODE,
    userOverride: false,
    pendingAgent: null,
    focusedId: null,
  };
  /** @type {Set<(state: typeof modeState) => void>} */
  const modeListeners = new Set();
  const symframeRoot = document.getElementById("symframe");

  function applyMode() {
    if (!symframeRoot) {
      return;
    }
    symframeRoot.setAttribute("data-mode", modeState.current);
    if (modeState.focusedId) {
      symframeRoot.setAttribute("data-focus-id", modeState.focusedId);
    } else {
      symframeRoot.removeAttribute("data-focus-id");
    }
    if (modeState.userOverride) {
      symframeRoot.setAttribute("data-user-override", "true");
    } else {
      symframeRoot.removeAttribute("data-user-override");
    }
    for (const fn of modeListeners) {
      try {
        fn({ ...modeState });
      } catch (err) {
        console.warn("[symframe] mode listener threw:", err);
      }
    }
  }

  function setMode(nextMode, opts = {}) {
    if (!MODES.includes(nextMode)) {
      console.warn(`[symframe] setMode: unknown mode '${nextMode}'`);
      return false;
    }
    const fromUser = opts.user === true;
    if (fromUser) {
      modeState.userOverride = true;
      modeState.current = nextMode;
      if (typeof opts.focusedId === "string") {
        modeState.focusedId = opts.focusedId;
      } else if (opts.focusedId === null) {
        modeState.focusedId = null;
      }
    } else {
      // Agent setMode: respect user override
      modeState.pendingAgent = nextMode;
      if (modeState.userOverride) {
        return true; // ack but don't apply
      }
      modeState.current = nextMode;
      if (typeof opts.focusedId === "string") {
        modeState.focusedId = opts.focusedId;
      } else if (opts.focusedId === null) {
        modeState.focusedId = null;
      }
    }
    applyMode();
    return true;
  }

  /** Clear the user override; reapply the agent's last mode (or default). */
  function clearUserOverride() {
    modeState.userOverride = false;
    if (modeState.pendingAgent) {
      modeState.current = modeState.pendingAgent;
    }
    applyMode();
  }

  function getMode() {
    return { ...modeState };
  }

  function onModeChange(fn) {
    modeListeners.add(fn);
    return () => modeListeners.delete(fn);
  }

  // ── Stage C: dwell / release / recall / pin / unpin ────────────────
  // The CRUD vocabulary (add/update/remove) treats the panel as a
  // notification list. These verbs treat it as a mind-window: cards
  // can be foregrounded ("dwell"), softly let go of ("release"),
  // brought back ("recall"), or persisted across sessions ("pin").

  const RECENTLY_RELEASED_MAX = 10;
  /** @type {Array<{id:string, card:object, releasedAt:number}>} */
  const recentlyReleased = [];

  /**
   * Pull a card to the top of the stack with a brief cyan border pulse.
   * Implementation: prepend the element (newest-first stack order
   * normally has it there if it was just added, but for older cards
   * this lifts them up), then add a `dwelling` class for a 600ms
   * CSS pulse animation. Sticky cards aren't reordered (they live at
   * the anchored end of the stack by design).
   */
  function dwell(id) {
    const entry = cards.get(id);
    if (!entry) {
      console.warn(`[symframe] dwell: unknown id ${id}`);
      return false;
    }
    if (!entry.element) {
      console.warn(`[symframe] dwell: card ${id} has no element`);
      return false;
    }
    if (!entry.sticky) {
      // Re-prepend to bring to top of ephemeral stack.
      container.prepend(entry.element);
    }
    entry.element.classList.remove("symframe-dwelling");
    // Re-flow so the animation restarts even on repeated dwell calls.
    void entry.element.offsetWidth;
    entry.element.classList.add("symframe-dwelling");
    setTimeout(() => entry.element?.classList.remove("symframe-dwelling"), 700);
    return true;
  }

  /**
   * Soft-dismiss a card. Animated fade + scale; on animation end the
   * element is removed from the registry and DOM. Unlike `remove`,
   * `release` records the card in a session ring buffer so `recall(id)`
   * can bring it back. Sticky cards refuse release without force=true.
   */
  function release(id, force = false) {
    const entry = cards.get(id);
    if (!entry) {
      return false;
    }
    if (entry.sticky && !force) {
      console.warn(`[symframe] release: refusing sticky card '${id}' without force=true`);
      return false;
    }
    // Snapshot for recall (strip the live DOM element)
    const snapshot = { ...entry };
    delete snapshot.element;
    recentlyReleased.unshift({
      id,
      card: snapshot,
      releasedAt: Date.now(),
    });
    if (recentlyReleased.length > RECENTLY_RELEASED_MAX) {
      recentlyReleased.length = RECENTLY_RELEASED_MAX;
    }
    // Animate then drop
    const el = entry.element;
    if (el) {
      el.classList.add("symframe-releasing");
      const cleanup = () => {
        cards.delete(id);
        if (el.parentElement === container) {
          el.remove();
        }
        updateCount();
      };
      // Fail-safe: drop after the CSS transition window even if the
      // transitionend event doesn't fire (browser bug, reduced motion).
      const handle = setTimeout(cleanup, 900);
      el.addEventListener(
        "transitionend",
        () => {
          clearTimeout(handle);
          cleanup();
        },
        { once: true },
      );
    } else {
      cards.delete(id);
      updateCount();
    }
    return true;
  }

  /**
   * Re-summon a recently-released card from the session ring buffer.
   * Calling `recall(id)` without an id recalls the most-recent release.
   * If the id isn't in the buffer or the card already exists, no-op.
   */
  function recall(id) {
    const recordIdx = id
      ? recentlyReleased.findIndex((r) => r.id === id)
      : recentlyReleased.length > 0
        ? 0
        : -1;
    if (recordIdx < 0) {
      console.warn(`[symframe] recall: nothing to recall${id ? ` for id ${id}` : ""}`);
      return false;
    }
    const [record] = recentlyReleased.splice(recordIdx, 1);
    if (cards.has(record.id)) {
      // Already restored elsewhere — no-op.
      return false;
    }
    add(record.card);
    return true;
  }

  /** Snapshot of recently-released cards (most-recent first). */
  function listRecentlyReleased() {
    return recentlyReleased.slice();
  }

  /**
   * Pin a card to localStorage so it survives reload + /new. Idempotent
   * on id. The card is marked `pinned: true` so the dismiss button is
   * hidden. Calling pin on an unknown id is a no-op.
   */
  function pin(id) {
    const entry = cards.get(id);
    if (!entry) {
      console.warn(`[symframe] pin: unknown id ${id}`);
      return false;
    }
    if (typeof window.symframePinStore?.pin !== "function") {
      console.warn("[symframe] pin: pin-store unavailable");
      return false;
    }
    const snapshot = { ...entry };
    delete snapshot.element;
    snapshot.pinned = true;
    snapshot.dismissable = false;
    window.symframePinStore.pin(snapshot);
    // Update live entry so the chrome reflects the pin (no dismiss btn,
    // pin glyph in header).
    entry.pinned = true;
    entry.dismissable = false;
    if (entry.element) {
      const newEl = renderCardElement(entry);
      entry.element.replaceWith(newEl);
      entry.element = newEl;
    }
    return true;
  }

  /** Inverse of pin. Removes from localStorage; does NOT drop the card. */
  function unpin(id) {
    const entry = cards.get(id);
    if (typeof window.symframePinStore?.unpin === "function") {
      window.symframePinStore.unpin(id);
    }
    if (entry) {
      entry.pinned = false;
      entry.dismissable = true;
      if (entry.element) {
        const newEl = renderCardElement(entry);
        entry.element.replaceWith(newEl);
        entry.element = newEl;
      }
    }
    return true;
  }

  /** Snapshot of all registered cards. */
  function list() {
    return Array.from(cards.values());
  }

  // Sticky-panel auto-registration was removed in Stage C (3.5.21):
  // SUBAGENTS and SCHEDULING moved to the AWAITING footer, and REASONING
  // became the substrate in Stage B (3.5.20). The `register()` API
  // remains exposed via window.symframe.register for any future caller
  // that wants to track a non-registry-managed DOM element as a card.

  window.symframe = {
    add,
    update,
    remove,
    list,
    register,
    // Stage C — attention verbs
    dwell,
    release,
    recall,
    pin,
    unpin,
    listRecentlyReleased,
    // Stage D — modes
    setMode,
    getMode,
    clearUserOverride,
    onModeChange,
    MODES,
  };

  // Phase 3: server-pushed cards. The gateway's `symframe` broadcast is
  // re-emitted as a window-level CustomEvent by gateway.js — subscribe
  // here and route to the registry. Payload shape matches the
  // chat.symframe.push RPC: { action, id?, card?, force? }.
  // Stage A: `force` flows through to update/remove so the agent (via
  // display_artifact) and the CLI can override the sticky-card guard.
  // Stage C: new verbs (dwell / release / recall / pin / unpin) route
  // alongside the existing CRUD trio.
  window.addEventListener("symi:symframe", (e) => {
    const payload = e.detail;
    if (!payload || typeof payload !== "object") {
      return;
    }
    const force = payload.force === true;
    if (payload.action === "add" && payload.card) {
      add(payload.card);
    } else if (payload.action === "update" && payload.id && payload.card) {
      update(payload.id, payload.card, force);
    } else if (payload.action === "remove" && payload.id) {
      remove(payload.id, force);
    } else if (payload.action === "dwell" && payload.id) {
      dwell(payload.id);
    } else if (payload.action === "release" && payload.id) {
      release(payload.id, force);
    } else if (payload.action === "recall") {
      recall(payload.id);
    } else if (payload.action === "pin" && payload.id) {
      pin(payload.id);
    } else if (payload.action === "unpin" && payload.id) {
      unpin(payload.id);
    } else if (payload.action === "setMode" && typeof payload.mode === "string") {
      setMode(payload.mode, {
        focusedId: typeof payload.focusedId === "string" ? payload.focusedId : null,
      });
    }
  });

  // Stage D: apply the default mode on boot so the data-mode attribute
  // always exists for CSS to target. No-op if userOverride is set
  // (won't happen at boot, but defensive).
  applyMode();

  // Stage D: when mode changes, toggle the .symframe-card-focused class
  // on the card whose id matches modeState.focusedId. CSS can't select
  // "the card whose data-card-id matches the symframe's data-focus-id"
  // directly, so JS maintains the class. Re-applies after card add too.
  function syncFocusClass() {
    for (const [id, entry] of cards.entries()) {
      if (!entry.element) {
        continue;
      }
      const shouldFocus = modeState.current === "focus" && modeState.focusedId === id;
      entry.element.classList.toggle("symframe-card-focused", shouldFocus);
    }
  }
  onModeChange(syncFocusClass);

  // Stage C: restore pinned cards from localStorage on boot. Pinned
  // cards are user-curated state and survive reload, /new, and gateway
  // reconnect. Each pin is a snapshot; the registry re-adds it as a
  // dynamic card with pinned=true (no dismiss button).
  if (typeof window.symframePinStore?.list === "function") {
    try {
      const pinned = window.symframePinStore.list();
      for (const record of pinned) {
        if (record?.card && !cards.has(record.id)) {
          add({ ...record.card, pinned: true, dismissable: false });
        }
      }
    } catch (err) {
      console.warn("[symframe] failed to restore pinned cards:", err);
    }
  }
})();
