// Spellstash — The Bracketizer (BETA)
//
// Pick a deck, name its commander, and run the deck against the EDHREC
// average deck at every bracket. Verdict first (which bracket does this deck
// really read as), then per-bracket detail: what the average runs that you
// don't (owned elsewhere vs priced at Card Kingdom), and your spice (what you
// run that the average doesn't). Backed by GET /api/bracketizer/check.
//
// Mirrors synergy.jsx: same shell (AppShell), same auth pattern, the same
// cm-radar-* / syn-* component styles from radar.css, the same paperclip buy
// list, and the same plain-mode card popup. LAW: nothing fires until the
// Bracketize button is clicked. Picking a deck or commander never runs the
// check; bracket chips switch detail client-side from the returned payload.

const { useState, useEffect, useCallback, useRef } = React;

// Brand token bridge — used by inline styles only (same pattern as synergy.jsx).
const T = {
  accent: "var(--cm-accent)",
  accentFg: "var(--cm-accent-fg)",
  accentSoft: "var(--cm-accent-soft)",
  bg: "var(--bg)", bg2: "var(--bg-2)", bg3: "var(--bg-3)",
  line: "var(--line)",
  ink: "var(--ink)", ink2: "var(--ink-2)", ink3: "var(--ink-3)", ink4: "var(--ink-4)",
  good: "var(--good)",
  serif: "var(--cm-serif)", sans: "var(--cm-sans)", mono: "var(--cm-mono)",
};

// Plausible custom events. Analytics never breaks the page.

// Body-scroll lock shared by every modal on this page. Stacked modals close
// in one React commit in sibling order; naive capture-and-restore can leave
// the page frozen. The counter makes the LAST unlock restore scrolling.
let overflowLocks = 0;
function lockScroll() {
  overflowLocks += 1;
  document.body.style.overflow = "hidden";
}
function unlockScroll() {
  overflowLocks = Math.max(0, overflowLocks - 1);
  if (overflowLocks === 0) document.body.style.overflow = "";
}

function track(name, props) {
  try { window.plausible && window.plausible(name, { props: props || {} }); } catch (e) { /* best effort */ }
}

// Deck Tech's per-container commander pick (an oracle_id) lives under these
// keys. When a deck is picked here, a stored pick prefills the commander
// input. Prefill ONLY. It never runs the check.
function readDeckTechCommander(containerId) {
  if (!containerId || typeof localStorage === "undefined") return null;
  try {
    return localStorage.getItem("spellstash.deck-tech.commander." + containerId);
  } catch (e) { return null; }
}

function readDeckTechPartner(containerId) {
  if (!containerId || typeof localStorage === "undefined") return null;
  try {
    return localStorage.getItem("spellstash.deck-tech.commander2." + containerId);
  } catch (e) { return null; }
}

// ── Paperclip buy list ────────────────────────────────────────────────
// A page-local shopping list, same as Synergy Check's. Clip any gap card you
// don't own, then buy the whole list at Card Kingdom in one handoff.
// Survives reloads via localStorage.
const PICKS_KEY = "ss-bracketizer-picks";
// The last successful run (deck + commander), for the bare-open form prefill.
const LAST_RUN_KEY = "ss-bracketizer-last-run";

function loadPicks() {
  try {
    const raw = JSON.parse(localStorage.getItem(PICKS_KEY) || "[]");
    return Array.isArray(raw) ? raw.filter((p) => p && p.name) : [];
  } catch (e) { return []; }
}

function ClipIcon({ filled }) {
  return (
    <svg width="13" height="13" viewBox="0 0 24 24" fill="none" aria-hidden="true"
      style={{ verticalAlign: "-2px" }}
      stroke="currentColor" strokeWidth={filled ? 2.7 : 2} strokeLinecap="round" strokeLinejoin="round">
      <path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48" />
    </svg>
  );
}

function picksToPlainList(picks) {
  return picks.map((p) => "1 " + p.name).join("\n");
}

// Card Kingdom deck-builder handoff: clipboard first (CK's bot gate can eat
// the POST), then the form submit in a new tab. Same pattern as Synergy
// Check's, tagged to this surface.
function ckBulkBuy(picks) {
  const plain = picksToPlainList(picks);
  if (!plain) return;
  try { if (navigator.clipboard) navigator.clipboard.writeText(plain); } catch (e) { /* best effort */ }
  const form = document.createElement("form");
  form.method = "POST";
  form.action = "https://www.cardkingdom.com/builder?partner=spellstash&utm_source=spellstash&utm_medium=affiliate&utm_campaign=bracketizer";
  form.target = "_blank";
  const input = document.createElement("input");
  input.type = "hidden";
  input.name = "c";
  input.value = plain;
  form.appendChild(input);
  document.body.appendChild(form);
  form.submit();
  form.remove();
}

// ── Wishlist modal ────────────────────────────────────────────────────
// Move gap cards you don't own onto a wishlist. Mirrors the collection
// page's deck-completer flow: pick an existing wishlist (is_wishlist
// containers) or create one inline, then one Add button does the work.
// LAW: selecting a radio never fires anything; only Add acts. Wishlist rows
// are name-only wants and never count toward the free cap (server-side).
function WishlistModal({ cards, onAdded, onClose }) {
  const [wishlists, setWishlists] = useState(null); // null = loading
  const [target, setTarget] = useState(null);       // container id string, or "new"
  const [newName, setNewName] = useState("");
  const [adding, setAdding] = useState(false);
  const [added, setAdded] = useState(null);         // { count, name } after success
  const [error, setError] = useState(null);

  // Fetch the user's wishlist containers when the modal opens.
  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const r = await window.ssAuth.authedFetch("/api/containers");
        const rows = r.ok ? await r.json() : [];
        if (cancelled) return;
        const lists = (Array.isArray(rows) ? rows : []).filter((c) => c && c.is_wishlist && !c.archived_at);
        setWishlists(lists);
        setTarget(lists.length ? String(lists[0].id) : "new");
      } catch (e) {
        if (!cancelled) { setWishlists([]); setTarget("new"); }
      }
    })();
    return () => { cancelled = true; };
  }, []);

  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    lockScroll();
    return () => {
      window.removeEventListener("keydown", onKey);
      unlockScroll();
    };
  }, [onClose]);

  const add = async () => {
    if (adding || added || !cards.length || target == null) return;
    setAdding(true);
    setError(null);
    try {
      let targetId;
      let targetName;
      if (target === "new") {
        const name = newName.trim() || "Wishlist";
        const r = await window.ssAuth.authedFetch("/api/containers", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ name, kind: "other", is_wishlist: true }),
        });
        const body = await r.json().catch(() => ({}));
        if (!r.ok) throw new Error(body.error || "HTTP " + r.status);
        targetId = body.id;
        targetName = body.name || name;
      } else {
        targetId = parseInt(target, 10);
        const found = (wishlists || []).find((w) => w.id === targetId);
        targetName = found ? found.name : "your wishlist";
      }
      // The bulk endpoint caps at 200 items per request; chunk larger adds.
      for (let i = 0; i < cards.length; i += 200) {
        const items = cards.slice(i, i + 200).map((c) => ({
          oracle_id: c.oracle_id,
          container_id: targetId,
          quantity: 1,
        }));
        const r = await window.ssAuth.authedFetch("/api/instances/bulk", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ items }),
        });
        if (!r.ok) {
          const body = await r.json().catch(() => ({}));
          throw new Error(body.error || "HTTP " + r.status);
        }
      }
      setAdded({ count: cards.length, name: targetName });
      // Once per successful add batch.
      track("BracketizerWishlistAdd", { count: cards.length });
      onAdded(cards.map((c) => c.oracle_id));
    } catch (e) {
      setError(e.message || "Couldn't add to the wishlist. Try again.");
    }
    setAdding(false);
  };

  const radioStyle = (active) => ({
    display: "flex", alignItems: "center", gap: "0.55rem",
    minHeight: 44, padding: "0 0.6rem", boxSizing: "border-box",
    border: "1px solid " + (active ? T.accent : T.line),
    background: active ? T.accentSoft : "transparent",
    borderRadius: 8, cursor: "pointer",
    color: T.ink, fontSize: "0.92rem",
  });

  return (
    <div className="cm-modal-backdrop" onClick={onClose}>
      <div onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Add to a wishlist"
        style={{
          width: "min(92vw, 420px)", background: T.bg2, border: "1px solid " + T.line,
          borderRadius: 12, padding: "1.1rem", boxSizing: "border-box",
          maxHeight: "calc(100dvh - 40px)", overflowY: "auto", overscrollBehavior: "contain",
        }}>
        <div style={{ fontFamily: T.serif, fontWeight: 700, fontSize: "1.1rem", color: T.ink }}>Add to a wishlist</div>
        <div style={{ font: "600 12px " + T.mono, color: T.ink3, marginTop: "0.25rem" }}>
          {cards.length === 1 ? cards[0].name : cards.length.toLocaleString() + " cards"}
        </div>
        {added ? (
          <div style={{ marginTop: "0.9rem" }}>
            <div style={{ color: T.ink, fontSize: "0.95rem" }}>
              Added {added.count.toLocaleString()} to {added.name}.
            </div>
            <a href="/collection?view=wishlist"
              style={{ display: "inline-block", marginTop: "0.5rem", color: T.accent, font: "600 13px " + T.sans }}>
              See your wishlist
            </a>
            <div style={{ marginTop: "0.9rem" }}>
              <button type="button" className="cm-btn cm-btn--primary" style={{ minHeight: 44 }} onClick={onClose}>Done</button>
            </div>
          </div>
        ) : (
          <>
            {wishlists == null && (
              <p style={{ color: T.ink3, fontSize: "0.9rem", margin: "0.75rem 0 0" }}>Loading your wishlists…</p>
            )}
            {wishlists != null && (
              <div role="radiogroup" aria-label="Pick a wishlist"
                style={{ marginTop: "0.75rem", display: "flex", flexDirection: "column", gap: "0.35rem" }}>
                {/* New wishlist leads the list. At the bottom of a long list
                    on a phone, the create option and its name box sat under
                    the keyboard, which read as "can't create a container". */}
                <label style={radioStyle(target === "new")}>
                  <input type="radio" name="ss-wishlist-target"
                    checked={target === "new"}
                    onChange={() => setTarget("new")} />
                  + New wishlist
                </label>
                {target === "new" && (
                  <input value={newName} onChange={(e) => setNewName(e.target.value)}
                    placeholder="Wishlist" aria-label="New wishlist name" autoComplete="off"
                    style={{
                      minHeight: 44, boxSizing: "border-box", width: "100%",
                      border: "2px solid " + T.ink, borderRadius: 8, background: T.bg,
                      padding: "0 12px", font: "600 15px " + T.sans, color: T.ink,
                    }} />
                )}
                {wishlists.map((w) => (
                  <label key={w.id} style={radioStyle(target === String(w.id))}>
                    <input type="radio" name="ss-wishlist-target"
                      checked={target === String(w.id)}
                      onChange={() => setTarget(String(w.id))} />
                    {w.name}
                  </label>
                ))}
              </div>
            )}
            {error && (
              <p className="cm-radar-status-msg is-err" style={{ margin: "0.6rem 0 0" }}>{error}</p>
            )}
            <div style={{
              display: "flex", gap: "0.5rem", marginTop: "0.9rem",
              position: "sticky", bottom: "-1.1rem", background: T.bg2,
              padding: "0.6rem 0 1.1rem", marginBottom: "-1.1rem",
            }}>
              <button type="button" className="cm-btn cm-btn--primary" style={{ minHeight: 44 }}
                onClick={add} disabled={adding || wishlists == null || !cards.length}>
                {adding
                  ? "Adding…"
                  : (target === "new" ? "Create + add " : "Add ") + cards.length + (cards.length === 1 ? " card" : " cards")}
              </button>
              <button type="button" className="cm-btn cm-btn--ghost" style={{ minHeight: 44 }}
                onClick={onClose} disabled={adding}>
                Cancel
              </button>
            </div>
          </>
        )}
      </div>
    </div>
  );
}

// ── Start this deck (PRO): the average becomes a real brew container ──────
// Opens from the export bar. On open it runs a brew PREVIEW (POST /api/brew,
// Pro-gated) against the bracket's full_list, then shows an explicit pull
// plan: cards with a copy in a box/binder default to MOVE from that pile;
// copies living only in other decks default to "leave it where it is" so
// starting a deck never silently disassembles another one. Missing cards ride
// along in the saved decklist so the brew's status tracker keeps hunting
// them. Confirm POSTs /api/brew/start with MOVE items only — never the
// oracle-only COPY path (phantom rows).
function StartDeckModal({ sel, cardsMap, commander, onClose }) {
  const primaryName = commander
    ? (commander.partner ? commander.name.replace(" + " + commander.partner.name, "") : commander.name)
    : "";
  const defaultName = primaryName
    ? primaryName + (sel && sel.bracket != null ? " Bracket " + sel.bracket + " average" : " average")
    : "Average deck";
  const [name, setName] = useState(defaultName);
  const [preview, setPreview] = useState(null);   // null=loading | { rows, missing } | { upgrade } | { error }
  const [checked, setChecked] = useState(() => new Set());
  const [starting, setStarting] = useState(false);
  const [started, setStarted] = useState(null);   // { moved } after success
  const [error, setError] = useState(null);

  // The average as brew items: full_list names resolved to oracle_ids through
  // the result's cards map (basics and unhydrated names have no entry there
  // and are skipped — they stay in the saved decklist below). Falls back to
  // the on-screen list if a stale cache has no full_list. Brew caps at 200.
  // Frozen at mount (useState initializer) so the plan the user confirms is
  // exactly the plan that was previewed, whatever re-renders around it.
  const [plan] = useState(() => {
    const lines = (sel && Array.isArray(sel.full_list) && sel.full_list.length > 0)
      ? sel.full_list
      : (sel ? [...(sel.have || []), ...(sel.gap || [])].map((nm) => ({ name: nm, count: 1 })) : []);
    const items = [];
    for (const l of lines) {
      const c = cardsMap[l.name];
      if (!c || !c.oracle_id) continue;
      items.push({ oracle_id: c.oracle_id, qty: l.count || 1, name: l.name });
      if (items.length >= 200) break;
    }
    const out = [];
    if (commander) {
      out.push("1 " + primaryName);
      if (commander.partner) out.push("1 " + commander.partner.name);
    }
    for (const l of lines) out.push((l.count || 1) + " " + l.name);
    return { items, decklistText: out.join("\n") };
  });
  const items = plan.items;
  const decklistText = plan.decklistText;

  // Close is blocked while the start POST is in flight: the server is
  // creating a container and moving physical cards, and an invisible success
  // invites a retry that starts a duplicate deck.
  const startingRef = useRef(false);
  const safeClose = () => { if (!startingRef.current) onClose(); };
  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") safeClose(); };
    window.addEventListener("keydown", onKey);
    lockScroll();
    return () => {
      window.removeEventListener("keydown", onKey);
      unlockScroll();
    };
  }, []); // eslint-disable-line

  // Preview on open. 403 = free tier → the upgrade view, not an error.
  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        if (items.length === 0) {
          setPreview({ error: "Nothing in this average resolved to real cards. Run the check again." });
          return;
        }
        const r = await window.ssAuth.authedFetch("/api/brew", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ items: items.map((i) => ({ oracle_id: i.oracle_id, qty: i.qty })) }),
        });
        const body = await r.json().catch(() => ({}));
        if (cancelled) return;
        if (r.status === 403) {
          setPreview({ upgrade: body.upgrade_url || "/upgrade" });
          return;
        }
        if (!r.ok) throw new Error(body.error || "HTTP " + r.status);
        const rows = [];
        const missing = [];
        const defaults = new Set();
        for (const d of body.card_diffs || []) {
          const locs = Array.isArray(d.locations) ? d.locations : [];
          if (locs.length === 0) { missing.push(d); continue; }
          const nonDeck = locs.find((l) => l.kind !== "deck");
          const source = nonDeck || locs[0];
          const row = {
            oracle_id: d.oracle_id,
            name: d.name,
            qty: Math.max(1, Math.min(d.qty_in_deck || 1, source.qty || 1)),
            source,
            fromDeck: !nonDeck,
          };
          rows.push(row);
          // Box/binder copies pull in by default; deck-only copies stay put
          // unless the user opts in.
          if (nonDeck) defaults.add(d.oracle_id);
        }
        setPreview({ rows, missing });
        setChecked(defaults);
      } catch (e) {
        if (!cancelled) setPreview({ error: e.message || "Couldn't price out the pull plan. Try again." });
      }
    })();
    return () => { cancelled = true; };
  }, []); // eslint-disable-line

  const toggle = (oid) => setChecked((prev) => {
    const next = new Set(prev);
    if (next.has(oid)) next.delete(oid); else next.add(oid);
    return next;
  });

  const start = async () => {
    if (starting || started || !preview || !preview.rows) return;
    setStarting(true);
    startingRef.current = true;
    setError(null);
    try {
      const moveItems = preview.rows
        .filter((r) => checked.has(r.oracle_id))
        .map((r) => ({ oracle_id: r.oracle_id, qty: r.qty, instance_id: r.source.instance_id }));
      const r = await window.ssAuth.authedFetch("/api/brew/start", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ items: moveItems, name: name.trim() || defaultName, decklist_text: decklistText }),
      });
      const body = await r.json().catch(() => ({}));
      if (r.status === 403) { setPreview({ upgrade: body.upgrade_url || "/upgrade" }); setStarting(false); return; }
      if (!r.ok && r.status !== 207) throw new Error(body.error || "HTTP " + r.status);
      setStarted({ moved: body.moved_count != null ? body.moved_count : moveItems.length });
      track("BracketizerStartDeck", { bracket: sel && sel.bracket != null ? sel.bracket : "all" });
    } catch (e) {
      setError(e.message || "Couldn't start the deck. Try again.");
    }
    setStarting(false);
    startingRef.current = false;
  };

  // Copies, not rows, so the button agrees with the server's moved_count.
  const pullCount = preview && preview.rows
    ? preview.rows.filter((r) => checked.has(r.oracle_id)).reduce((sum, r) => sum + r.qty, 0)
    : 0;
  const rowStyle = {
    display: "flex", alignItems: "center", gap: "0.5rem", minHeight: 44,
    padding: "0.1rem 0.2rem", color: T.ink, fontSize: "0.9rem",
  };
  const groupHead = (label) => (
    <div style={{ font: "700 11.5px " + T.mono, color: T.ink3, textTransform: "uppercase", letterSpacing: "0.06em", margin: "0.85rem 0 0.25rem" }}>
      {label}
    </div>
  );

  return (
    <div className="cm-modal-backdrop" onClick={safeClose}>
      <div onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Start this deck"
        style={{
          width: "min(92vw, 460px)", background: T.bg2, border: "1px solid " + T.line,
          borderRadius: 12, padding: "1.1rem", boxSizing: "border-box",
          maxHeight: "calc(100dvh - 40px)", overflowY: "auto", overscrollBehavior: "contain",
        }}>
        <div style={{ fontFamily: T.serif, fontWeight: 700, fontSize: "1.1rem", color: T.ink }}>Start this deck</div>
        <div style={{ font: "600 12px " + T.mono, color: T.ink3, marginTop: "0.25rem" }}>
          {sel && sel.bracket != null ? "The bracket " + sel.bracket + " average" : "The average deck"} becomes a real deck in your stash.
        </div>

        {started ? (
          <div style={{ marginTop: "0.9rem" }}>
            <div style={{ color: T.ink, fontSize: "0.95rem" }}>
              Deck started. {started.moved.toLocaleString()} card{started.moved === 1 ? "" : "s"} pulled in.
              The rest stay on the deck's build tracker until you find or buy them.
            </div>
            <a href="/collection"
              style={{ display: "inline-block", marginTop: "0.5rem", color: T.accent, font: "600 13px " + T.sans }}>
              Open your collection
            </a>
            <div style={{ marginTop: "0.9rem" }}>
              <button type="button" className="cm-btn cm-btn--primary" style={{ minHeight: 44 }} onClick={onClose}>Done</button>
            </div>
          </div>
        ) : preview == null ? (
          <p style={{ color: T.ink3, fontSize: "0.9rem", margin: "0.75rem 0 0" }}>Checking your stash for every card…</p>
        ) : preview.upgrade ? (
          <div style={{ marginTop: "0.9rem" }}>
            <div style={{ color: T.ink, fontSize: "0.95rem" }}>
              Starting a deck from the average is a Pro feature. Pro pulls the cards you own out of
              your boxes into a new deck and tracks the rest until it's built.
            </div>
            <a href={preview.upgrade} className="cm-btn cm-btn--primary"
              style={{ display: "inline-flex", alignItems: "center", minHeight: 44, marginTop: "0.75rem", textDecoration: "none" }}>
              See Pro
            </a>
          </div>
        ) : preview.error ? (
          <p className="cm-radar-status-msg is-err" style={{ margin: "0.75rem 0 0" }}>{preview.error}</p>
        ) : (
          <>
            <label style={{ display: "block", marginTop: "0.85rem", font: "700 11.5px " + T.mono, color: T.ink3, textTransform: "uppercase", letterSpacing: "0.06em" }}>
              Deck name
              <input value={name} onChange={(e) => setName(e.target.value)} aria-label="Deck name" autoComplete="off"
                style={{
                  display: "block", marginTop: "0.35rem", minHeight: 44, boxSizing: "border-box", width: "100%",
                  border: "2px solid " + T.ink, borderRadius: 8, background: T.bg,
                  padding: "0 12px", font: "600 15px " + T.sans, color: T.ink,
                }} />
            </label>

            {preview.rows.some((r) => !r.fromDeck) && groupHead("Pulling in from boxes and binders")}
            {preview.rows.filter((r) => !r.fromDeck).map((r) => (
              <label key={r.oracle_id} style={rowStyle}>
                <input type="checkbox" checked={checked.has(r.oracle_id)} onChange={() => toggle(r.oracle_id)} />
                <span style={{ minWidth: 0, overflowWrap: "anywhere" }}>{r.qty > 1 ? r.qty + "× " : ""}{r.name}</span>
                <span style={{ marginLeft: "auto", font: "600 11px " + T.mono, color: T.ink3, whiteSpace: "nowrap", maxWidth: "40%", overflow: "hidden", textOverflow: "ellipsis" }}>from {r.source.container_name}</span>
              </label>
            ))}
            {preview.rows.some((r) => r.fromDeck) && groupHead("In your other decks (left alone unless you check them)")}
            {preview.rows.filter((r) => r.fromDeck).map((r) => (
              <label key={r.oracle_id} style={rowStyle}>
                <input type="checkbox" checked={checked.has(r.oracle_id)} onChange={() => toggle(r.oracle_id)} />
                <span style={{ minWidth: 0, overflowWrap: "anywhere" }}>{r.qty > 1 ? r.qty + "× " : ""}{r.name}</span>
                <span style={{ marginLeft: "auto", font: "600 11px " + T.mono, color: T.ink3, whiteSpace: "nowrap", maxWidth: "40%", overflow: "hidden", textOverflow: "ellipsis" }}>in {r.source.container_name}</span>
              </label>
            ))}
            {preview.missing.length > 0 && groupHead("Missing · stays on the build tracker")}
            {preview.missing.length > 0 && (
              <div style={{ color: T.ink3, fontSize: "0.85rem", lineHeight: 1.5 }}>
                {preview.missing.map((d) => d.name).join(" · ")}
              </div>
            )}

            {error && <p className="cm-radar-status-msg is-err" style={{ margin: "0.6rem 0 0" }}>{error}</p>}
            <div style={{ display: "flex", gap: "0.5rem", marginTop: "1rem" }}>
              <button type="button" className="cm-btn cm-btn--primary" style={{ minHeight: 44 }}
                onClick={start} disabled={starting}>
                {starting
                  ? "Starting…"
                  : pullCount > 0
                    ? "Start deck · pull " + pullCount + " in"
                    : "Start deck · track the whole list"}
              </button>
              <button type="button" className="cm-btn cm-btn--ghost" style={{ minHeight: 44 }}
                onClick={onClose} disabled={starting}>
                Cancel
              </button>
            </div>
          </>
        )}
      </div>
    </div>
  );
}

// ── Commander input with commander-scoped typeahead (/api/radar/search) ──
// Copied from synergy.jsx (showButton false throughout this page). Picking a
// suggestion only fills the input.
function CommanderInput({ value, onChange, onPick, disabled, showButton = true, placeholder = "e.g. Atraxa, Praetors' Voice", ariaLabel = "Commander name" }) {
  const [suggestions, setSuggestions] = useState([]);
  const [open, setOpen] = useState(false);
  const [searching, setSearching] = useState(false);

  useEffect(() => {
    const q = value.trim();
    if (q.length < 2) { setSuggestions([]); setSearching(false); return; }
    let cancelled = false;
    setSearching(true);
    const t = setTimeout(async () => {
      try {
        // Commander-scoped search (same endpoint Radar and Synergy use):
        // only real commanders with art, so a pick can never dead-end at
        // "EDHREC has no page for that commander."
        const r = await window.ssAuth.authedFetch("/api/radar/search?q=" + encodeURIComponent(q));
        if (cancelled) return;
        const data = r.ok ? await r.json() : {};
        const cards = Array.isArray(data.results) ? data.results : [];
        setSuggestions(cards.slice(0, 8));
      } catch (e) {
        if (!cancelled) setSuggestions([]);
      } finally {
        if (!cancelled) setSearching(false);
      }
    }, 200);
    return () => { cancelled = true; clearTimeout(t); };
  }, [value]);

  return (
    <div className="cm-radar-addrow">
      <input
        value={value}
        disabled={disabled}
        onChange={(e) => { onChange(e.target.value); setOpen(true); }}
        onFocus={() => { if (value.trim().length >= 2) setOpen(true); }}
        onBlur={() => setTimeout(() => setOpen(false), 150)}
        placeholder={placeholder}
        autoComplete="off"
        aria-label={ariaLabel}
      />
      {showButton && (
        <button type="submit" className="cm-btn cm-btn--primary" disabled={disabled || !value.trim()}>
          {disabled ? "Checking…" : "Check"}
        </button>
      )}
      {open && value.trim().length >= 2 && (
        <div className="cm-radar-ac">
          {searching && suggestions.length === 0 && (
            <div className="cm-radar-ac-empty">Searching cards…</div>
          )}
          {!searching && suggestions.length === 0 && (
            <div className="cm-radar-ac-empty">No cards match. Try the full name.</div>
          )}
          {suggestions.map((s) => (
            <div key={s.oracle_id || s.name} className="cm-radar-ac-item"
              onMouseDown={(e) => { e.preventDefault(); setOpen(false); onPick(s.name); }}>
              {s.image_small
                ? <img src={s.image_small} alt="" loading="lazy" />
                : <div className="cm-radar-ac-ph" />}
              <span className="cm-radar-ac-name">{s.name}</span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ── Own this ──────────────────────────────────────────────────────────
// The untracked-bulk fix: the user KNOWS they own a "missing" card, so file
// it into a real container right here (pick one or create a box inline) and
// the check flips it to owned on the spot. Mirrors WishlistModal's shape;
// targets real containers, never wishlists.
function OwnThisModal({ card, onOwned, onClose }) {
  const [containers, setContainers] = useState(null); // null = loading
  const [target, setTarget] = useState(null);
  const [newName, setNewName] = useState("");
  const [adding, setAdding] = useState(false);
  const [added, setAdded] = useState(null); // { name } after success
  const [error, setError] = useState(null);

  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const r = await window.ssAuth.authedFetch("/api/containers");
        const rows = r.ok ? await r.json() : [];
        if (cancelled) return;
        const list = (Array.isArray(rows) ? rows : []).filter((c) => c && !c.is_wishlist && !c.archived_at);
        setContainers(list);
        setTarget(list.length ? String(list[0].id) : "new");
      } catch (e) {
        if (!cancelled) { setContainers([]); setTarget("new"); }
      }
    })();
    return () => { cancelled = true; };
  }, []);

  useEffect(() => {
    const onKey = (e) => { if (e.key === "Escape") onClose(); };
    window.addEventListener("keydown", onKey);
    lockScroll();
    return () => {
      window.removeEventListener("keydown", onKey);
      unlockScroll();
    };
  }, [onClose]);

  const add = async () => {
    if (adding || added || !card || !card.oracle_id || target == null) return;
    setAdding(true);
    setError(null);
    try {
      let targetId; let targetName; let targetKind = "box";
      if (target === "new") {
        const name = newName.trim() || "Storage Box";
        const r = await window.ssAuth.authedFetch("/api/containers", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ name, kind: "box" }),
        });
        const body = await r.json().catch(() => ({}));
        if (!r.ok) throw new Error(body.error || "HTTP " + r.status);
        targetId = body.id;
        targetName = body.name || name;
      } else {
        targetId = parseInt(target, 10);
        const found = (containers || []).find((c) => c.id === targetId);
        targetName = found ? found.name : "your container";
        targetKind = found ? found.kind : "box";
      }
      const r = await window.ssAuth.authedFetch("/api/instances", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ oracle_id: card.oracle_id, container_id: targetId, quantity: 1 }),
      });
      const body = await r.json().catch(() => ({}));
      if (!r.ok) throw new Error(body.error || "HTTP " + r.status);
      setAdded({ name: targetName });
      track("BracketizerOwnThis", { card: card.name });
      onOwned(card, targetId, targetName, targetKind);
    } catch (e) {
      setError(e.message || "Couldn't add the card. Try again.");
    }
    setAdding(false);
  };

  const radioStyle = (active) => ({
    display: "flex", alignItems: "center", gap: "0.55rem",
    minHeight: 44, padding: "0 0.6rem", boxSizing: "border-box",
    border: "1px solid " + (active ? T.accent : T.line),
    background: active ? T.accentSoft : "transparent",
    borderRadius: 8, cursor: "pointer",
    color: T.ink, fontSize: "0.92rem",
  });

  return (
    <div className="cm-modal-backdrop" onClick={onClose}>
      <div onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="I own this card"
        style={{
          width: "min(92vw, 420px)", background: T.bg2, border: "1px solid " + T.line,
          borderRadius: 12, padding: "1.1rem", boxSizing: "border-box",
          maxHeight: "calc(100dvh - 40px)", overflowY: "auto", overscrollBehavior: "contain",
        }}>
        <div style={{ fontFamily: T.serif, fontWeight: 700, fontSize: "1.1rem", color: T.ink }}>Where does it live?</div>
        <div style={{ font: "600 12px " + T.mono, color: T.ink3, marginTop: "0.25rem" }}>{card.name}</div>
        {added ? (
          <div style={{ marginTop: "0.9rem" }}>
            <div style={{ color: T.ink, fontSize: "0.95rem" }}>
              Filed into {added.name}. It counts as owned now.
            </div>
            <div style={{ marginTop: "0.9rem" }}>
              <button type="button" className="cm-btn cm-btn--primary" style={{ minHeight: 44 }} onClick={onClose}>Done</button>
            </div>
          </div>
        ) : (
          <>
            {containers == null && (
              <p style={{ color: T.ink3, fontSize: "0.9rem", margin: "0.75rem 0 0" }}>Loading your containers…</p>
            )}
            {containers != null && (
              <div role="radiogroup" aria-label="Pick a container"
                style={{ marginTop: "0.75rem", display: "flex", flexDirection: "column", gap: "0.35rem" }}>
                {/* New box leads for the same phone-keyboard reason as the
                    wishlist modal. */}
                <label style={radioStyle(target === "new")}>
                  <input type="radio" name="ss-ownthis-target"
                    checked={target === "new"}
                    onChange={() => setTarget("new")} />
                  + New box
                </label>
                {target === "new" && (
                  <input value={newName} onChange={(e) => setNewName(e.target.value)}
                    placeholder="Storage Box" aria-label="New box name" autoComplete="off"
                    style={{
                      minHeight: 44, boxSizing: "border-box", width: "100%",
                      border: "2px solid " + T.ink, borderRadius: 8, background: T.bg,
                      padding: "0 12px", font: "600 15px " + T.sans, color: T.ink,
                    }} />
                )}
                {containers.map((c) => (
                  <label key={c.id} style={radioStyle(target === String(c.id))}>
                    <input type="radio" name="ss-ownthis-target"
                      checked={target === String(c.id)}
                      onChange={() => setTarget(String(c.id))} />
                    {c.name} <span style={{ color: T.ink3, font: "600 11px " + T.mono }}>({c.kind})</span>
                  </label>
                ))}
              </div>
            )}
            {error && (
              <p className="cm-radar-status-msg is-err" style={{ margin: "0.6rem 0 0" }}>{error}</p>
            )}
            <div style={{
              display: "flex", gap: "0.5rem", marginTop: "0.9rem",
              position: "sticky", bottom: "-1.1rem", background: T.bg2,
              padding: "0.6rem 0 1.1rem", marginBottom: "-1.1rem",
            }}>
              <button type="button" className="cm-btn cm-btn--primary" style={{ minHeight: 44 }}
                onClick={add} disabled={adding || containers == null}>
                {adding ? "Adding…" : (target === "new" ? "Create the box + file it" : "I own this, file it")}
              </button>
              <button type="button" className="cm-btn cm-btn--ghost" style={{ minHeight: 44 }} onClick={onClose}>Cancel</button>
            </div>
          </>
        )}
      </div>
    </div>
  );
}


// ── Owned-copy actions: live locations + quick-move ──────────────────────
// Owning a card shouldn't dead-end the popup (Dwayne 8-03). Pulls the live
// locations (with instance ids) from /api/find and offers the same
// POST /api/instances/:id/move quick-move the Radar popup has. Containers
// load once per open; wishlists are excluded as move targets (a real copy
// moving into a want-list would stop counting as owned).
function StashActions({ card }) {
  const [find, setFind] = useState(null);
  const [containers, setContainers] = useState([]);
  const [moveFor, setMoveFor] = useState(null); // instance_id with the picker open
  const [busy, setBusy] = useState(false);

  const reload = useCallback(async () => {
    try {
      const r = await window.ssAuth.authedFetch(
        "/api/find?oracle_id=" + encodeURIComponent(card.oracle_id || "") +
        "&q=" + encodeURIComponent(card.name || "") + "&strict=1");
      if (r.ok) setFind(await r.json());
    } catch (e) { /* the static line below covers the failure */ }
  }, [card.oracle_id, card.name]);

  useEffect(() => {
    let aborted = false;
    setFind(null); setMoveFor(null);
    (async () => {
      await reload();
      try {
        const c = await window.ssAuth.authedFetch("/api/containers");
        if (!aborted && c.ok) {
          const arr = await c.json();
          setContainers((Array.isArray(arr) ? arr : []).filter((x) => x && !x.archived_at && !x.is_wishlist));
        }
      } catch (e) { /* no targets, Move buttons just hide */ }
    })();
    return () => { aborted = true; };
  }, [reload]);

  const doMove = async (instanceId, toId) => {
    if (!toId || busy) return;
    setBusy(true);
    try {
      const r = await window.ssAuth.authedFetch("/api/instances/" + instanceId + "/move", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ to_container_id: Number(toId) }),
      });
      if (r.ok) await reload();
    } catch (e) { /* row stays put */ }
    setBusy(false);
    setMoveFor(null);
  };

  const locs = find && Array.isArray(find.locations) ? find.locations : null;
  if (!locs) {
    // Static fallback while the live read loads (or if it fails).
    return card.locations && card.locations.length > 0 ? (
      <div style={{ font: "600 12.5px " + T.mono, color: T.good, marginTop: "0.4rem" }}>
        In your stash: {card.locations.map((l) => l.container_name + (l.quantity > 1 ? " \u00d7" + l.quantity : "")).join(" \u00b7 ")}
      </div>
    ) : null;
  }
  return (
    <div style={{ marginTop: "0.5rem" }}>
      <div style={{ font: "600 11.5px " + T.mono, color: T.good, textTransform: "uppercase", letterSpacing: "0.06em" }}>In your stash</div>
      {locs.map((loc, i) => {
        const targets = containers.filter((c) => c.id !== loc.container_id);
        return (
          <div key={(loc.instance_id || i) + "-" + i} style={{ padding: "0.35rem 0", borderBottom: "1px dashed " + T.line }}>
            <div style={{ display: "flex", alignItems: "center", gap: "0.5rem", justifyContent: "space-between" }}>
              <span style={{ font: "600 12.5px " + T.mono, color: T.ink, minWidth: 0, overflowWrap: "anywhere" }}>
                {loc.container_name} <span style={{ color: T.ink3 }}>({loc.kind}) ×{loc.quantity}{loc.finish && loc.finish !== "nonfoil" ? " " + loc.finish : ""}</span>
              </span>
              {loc.instance_id && targets.length > 0 && moveFor !== loc.instance_id && (
                <button type="button" className="cm-btn cm-btn--ghost cm-btn--sm" style={{ minHeight: 44, flexShrink: 0 }}
                  disabled={busy} onClick={() => setMoveFor(loc.instance_id)}>
                  Move
                </button>
              )}
            </div>
            {moveFor === loc.instance_id && (
              <div style={{ marginTop: "0.35rem", display: "flex", gap: "0.4rem", alignItems: "center" }}>
                <select disabled={busy} defaultValue="" onChange={(e) => doMove(loc.instance_id, e.target.value)}
                  style={{ flex: 1, minWidth: 0, boxSizing: "border-box", minHeight: 44, padding: "0.35rem", background: T.bg, border: "1px solid " + T.line, borderRadius: 6, color: T.ink, fontSize: "0.85rem" }}>
                  <option value="" disabled>{busy ? "Moving\u2026" : "Move to\u2026"}</option>
                  {targets.map((c) => <option key={c.id} value={c.id}>{c.name} ({c.kind})</option>)}
                </select>
                <button type="button" className="cm-btn cm-btn--ghost cm-btn--sm" style={{ minHeight: 44 }}
                  disabled={busy} onClick={() => setMoveFor(null)}>Cancel</button>
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
}

// ── Floating clip drawer (Dwayne 8-03): a paperclip badge pinned to the top
// corner showing how many cards are clipped, opening the list in place so
// selected-state is always one glance away. ──
function ClipDrawer({ picks, copied, onCopy, onBuyAll, onRemove, onClear, onOpenCard }) {
  const [open, setOpen] = useState(false);
  if (!picks.length) return null;
  return (
    <div style={{ position: "fixed", top: 74, right: 12, zIndex: 60 }}>
      <button type="button" onClick={() => setOpen((o) => !o)}
        aria-expanded={open} aria-label={"Your buy list: " + picks.length + " clipped"}
        style={{
          display: "inline-flex", alignItems: "center", gap: 6,
          background: T.accent, color: T.accentFg, border: "none",
          borderRadius: 999, padding: "0.5rem 0.8rem", minHeight: 44,
          font: "700 13px " + T.mono, cursor: "pointer",
          boxShadow: "0 4px 14px rgba(0,0,0,0.25)",
        }}>
        <ClipIcon filled /> {picks.length}
      </button>
      {open && (
        <div style={{
          position: "absolute", right: 0, marginTop: 8, width: "min(86vw, 320px)",
          background: T.bg2, border: "1px solid " + T.line, borderRadius: 12,
          boxShadow: "0 10px 30px rgba(0,0,0,0.3)", padding: "0.6rem",
          maxHeight: "60vh", overflowY: "auto",
        }}>
          <div style={{ font: "700 11px " + T.mono, color: T.ink3, textTransform: "uppercase", letterSpacing: "0.06em", padding: "0.2rem 0.3rem" }}>
            Your buy list
          </div>
          {picks.map((p) => (
            <div key={p.name} style={{ display: "flex", alignItems: "center", gap: 8, padding: "0.35rem 0.3rem", borderBottom: "1px dashed " + T.line }}>
              <button type="button" onClick={() => onOpenCard && onOpenCard(p)} aria-label={"Open " + p.name}
                style={{ flex: 1, minWidth: 0, textAlign: "left", border: "none", background: "transparent", cursor: onOpenCard ? "pointer" : "default", overflowWrap: "anywhere", color: T.ink, fontSize: "0.9rem", padding: 0 }}>
                {p.name}
              </button>
              <button type="button" onClick={() => onRemove(p)} aria-label={"Remove " + p.name}
                style={{ border: "1px solid " + T.line, background: "transparent", color: T.ink3, borderRadius: 999, width: 44, height: 44, cursor: "pointer", flexShrink: 0 }}>✕</button>
            </div>
          ))}
          <div style={{ display: "flex", gap: 8, marginTop: "0.6rem", flexWrap: "wrap" }}>
            <button type="button" className="cm-btn cm-btn--primary cm-btn--sm" style={{ minHeight: 44 }} onClick={onBuyAll}>Buy all at CK</button>
            <button type="button" className="cm-btn cm-btn--ghost cm-btn--sm" style={{ minHeight: 44 }} onClick={onCopy}>{copied ? "✓ Copied" : "Copy"}</button>
            <button type="button" className="cm-btn cm-btn--ghost cm-btn--sm" style={{ minHeight: 44 }} onClick={onClear}>Clear</button>
          </div>
        </div>
      )}
    </div>
  );
}

// ── Card popup (plain mode) ───────────────────────────────────────────
// Same lightbox chrome as synergy.jsx's SynergyCardModal in its plain
// commander mode: just the card, readable, with prev/next arrows walking the
// list it was opened from. Gap and spice rows and the commander hero all
// open here.
function BracketizerCardModal({ cards, startIndex, onClose, onWant, onOwnThis, isWishlisted, isClipped, onToggleClip, suspendKeys }) {
  const list = Array.isArray(cards) && cards.length ? cards : [];
  const [idx, setIdx] = useState(() => Math.min(Math.max(startIndex || 0, 0), Math.max(list.length - 1, 0)));
  const card = list[idx] || {};
  const hasPrev = idx > 0;
  const hasNext = idx < list.length - 1;
  const goPrev = useCallback(() => setIdx((i) => Math.max(0, i - 1)), []);
  const goNext = useCallback(() => setIdx((i) => Math.min(list.length - 1, i + 1)), [list.length]);
  // Swipe forward/back (Dwayne 8-06, same pattern as the collection
  // lightbox): horizontal drag pages, taps and vertical scrolls pass through.
  const swipeStart = useRef(null);
  const onSwipeTouchStart = (e) => {
    if (e.touches && e.touches.length === 1) {
      swipeStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY };
    }
  };
  const onSwipeTouchEnd = (e) => {
    const s = swipeStart.current;
    swipeStart.current = null;
    if (!s || list.length < 2 || !e.changedTouches || !e.changedTouches.length) return;
    const dx = e.changedTouches[0].clientX - s.x;
    const dy = e.changedTouches[0].clientY - s.y;
    if (Math.abs(dx) < 48 || Math.abs(dx) < Math.abs(dy) * 1.5) return;
    if (dx < 0) goNext(); else goPrev();
  };


  useEffect(() => {
    const onKey = (e) => {
      // The wishlist modal stacks above this popup; its keys win while open.
      if (suspendKeys) return;
      if (e.key === "Escape") onClose();
      else if (e.key === "ArrowLeft") goPrev();
      else if (e.key === "ArrowRight") goNext();
    };
    window.addEventListener("keydown", onKey);
    lockScroll();
    return () => { window.removeEventListener("keydown", onKey); unlockScroll(); };
  }, [onClose, goPrev, goNext, suspendKeys]);

  const img = card.image_normal || card.image_small;

  // Double-faced cards: Scryfall serves the back face at the same URL with
  // /back/ in place of /front/. Probe on card change; single-faced cards 404
  // the probe and no flip button appears. Same pattern as the collection
  // lightbox.
  const [showBack, setShowBack] = useState(false);
  const [hasBack, setHasBack] = useState(false);
  const backSrc = img && img.indexOf("/front/") >= 0 ? img.replace("/front/", "/back/") : null;
  useEffect(() => {
    setShowBack(false);
    setHasBack(false);
    if (!backSrc) return;
    let live = true;
    const probe = new Image();
    probe.onload = () => { if (live) setHasBack(true); };
    probe.src = backSrc;
    return () => { live = false; };
  }, [backSrc]);

  return (
    <div className="cm-modal-backdrop" onClick={onClose}>
      <div onClick={(e) => e.stopPropagation()} onTouchStart={onSwipeTouchStart} onTouchEnd={onSwipeTouchEnd} style={{ position: "relative", maxWidth: "min(90vw, 440px)", width: "100%" }}>
        <button className="cm-card-lightbox-close" onClick={onClose} aria-label="Close"
          style={{ position: "absolute", top: 6, right: 6, zIndex: 40, background: "rgba(20,16,10,0.65)", color: "#fff", border: "none", borderRadius: 999, width: 32, height: 32, lineHeight: "32px", textAlign: "center", cursor: "pointer" }}>✕</button>
        <div className="cm-card-lightbox"
          style={{ maxWidth: "min(90vw, 440px)", maxHeight: "calc(100dvh - 40px)", overflowY: "auto", overscrollBehavior: "contain", paddingBottom: "max(16px, env(safe-area-inset-bottom))" }}>
        <div className="cm-card-lightbox-imgwrap">
          {img
            ? <img src={showBack && hasBack && backSrc ? backSrc : img} alt={card.name} className="cm-card-lightbox-img"
                style={{ maxWidth: "min(90vw, 440px)", maxHeight: "58dvh", width: "auto", height: "auto", objectFit: "contain", display: "block", margin: "0 auto" }} />
            : <div className="cm-card-lightbox-img" style={{ minHeight: 300, background: T.bg3 }} />}
          {list.length > 1 && (
            <>
              <button type="button" className="cm-card-lightbox-nav cm-card-lightbox-nav--prev" onClick={goPrev} disabled={!hasPrev} aria-label="Previous card">‹</button>
              <button type="button" className="cm-card-lightbox-nav cm-card-lightbox-nav--next" onClick={goNext} disabled={!hasNext} aria-label="Next card">›</button>
              <span className="cm-card-lightbox-count cm-mono">{idx + 1} / {list.length}</span>
            </>
          )}
        </div>
        <div className="cm-card-lightbox-history">
          {hasBack && (
            <button type="button" className="cm-btn cm-btn--ghost cm-btn--sm" style={{ minHeight: 44, marginBottom: "0.5rem" }}
              onClick={() => setShowBack((s) => !s)}>
              {showBack ? "Show front ⟲" : "Flip to back ⟳"}
            </button>
          )}
          <div style={{ fontFamily: T.serif, fontSize: "1.05rem", color: T.ink, fontWeight: 700 }}>{card.name}</div>
          {card.type_line && (
            <div style={{ font: "600 12px " + T.mono, color: T.ink3, marginTop: "0.15rem" }}>{card.type_line}</div>
          )}
          {card.sub && (
            <div style={{ font: "600 12px " + T.mono, color: T.ink3, marginTop: "0.15rem" }}>{card.sub}</div>
          )}
          {(typeof card.synergy_pct === "number" || typeof card.inclusion_rate === "number") && (
            <div style={{ display: "flex", gap: "0.4rem", flexWrap: "wrap", marginTop: "0.4rem" }}>
              {typeof card.synergy_pct === "number" && (
                <span style={{ border: "1px solid " + T.line, borderRadius: 999, padding: "0.18rem 0.55rem", font: "600 11.5px " + T.mono, color: T.ink3 }}>
                  {card.synergy_pct}% synergy
                </span>
              )}
              {typeof card.inclusion_rate === "number" && (
                <span style={{ border: "1px solid " + T.line, borderRadius: 999, padding: "0.18rem 0.55rem", font: "600 11.5px " + T.mono, color: T.ink3 }}>
                  in {card.inclusion_rate}% of decks
                </span>
              )}
            </div>
          )}
          {card.owned && <StashActions card={card} />}
          {card.buy_url && (
            <div style={{ marginTop: "0.6rem" }}>
              {/* Owned cards keep the buy path, just not the loud one: a
                  second copy is a legitimate want (Dwayne 8-03). */}
              <a className={card.owned ? "cm-btn cm-btn--ghost cm-btn--sm" : "cm-btn cm-btn--primary"}
                style={{ minHeight: 44, textDecoration: "none", display: "inline-flex", alignItems: "center" }}
                href={card.buy_url} target="_blank" rel="noopener noreferrer"
                onClick={() => track("BracketizerBuyClick", { card: card.name, price: card.ck_price, owned: card.owned ? "yes" : "no" })}>
                {(card.owned ? "Buy another \u00b7 " : "") + (card.ck_price != null ? "$" + card.ck_price.toFixed(2) + " at Card Kingdom" : "Buy at Card Kingdom")}
              </a>
            </div>
          )}
          {card.clippable && onToggleClip && (() => {
            const popClipped = isClipped ? isClipped(card.name) : false;
            return (
              <div style={{ marginTop: "0.6rem" }}>
                <button type="button" className="cm-btn cm-btn--sm" style={{
                    minHeight: 44,
                    background: popClipped ? T.accent : "transparent",
                    color: popClipped ? T.accentFg : T.ink3,
                    border: "1px solid " + (popClipped ? T.accent : T.line),
                    borderRadius: 8,
                  }}
                  aria-pressed={popClipped} aria-label={popClipped ? "Remove from your buy list" : "Clip to your buy list"}
                  title={popClipped ? "On your buy list. Tap to remove." : "Clip to your buy list"}
                  onClick={() => onToggleClip(card)}>
                  <ClipIcon filled={popClipped} />{popClipped ? " ✓" : ""}
                </button>
              </div>
            );
          })()}
          {card.wantable && (onWant || onOwnThis) && (
            <div style={{ marginTop: "0.6rem", display: "flex", gap: "0.5rem", flexWrap: "wrap" }}>
              {onOwnThis && (
                <button type="button" className="cm-btn cm-btn--ghost cm-btn--sm" style={{ minHeight: 44 }}
                  onClick={() => onOwnThis(card)}
                  title="Already own a copy that isn't tracked? File it into a container.">
                  Own this
                </button>
              )}
              {onWant && (isWishlisted && isWishlisted(card.oracle_id) ? (
                <button type="button" className="cm-btn cm-btn--ghost" style={{ minHeight: 44 }} disabled>
                  ✓ On your wishlist
                </button>
              ) : (
                <button type="button" className="cm-btn cm-btn--ghost cm-btn--sm" style={{ minHeight: 44 }}
                  onClick={() => onWant(card)}>
                  Want it
                </button>
              ))}
            </div>
          )}
        </div>
        </div>
      </div>
    </div>
  );
}

// ── Verdict banner (DISPLAY LAW: renders BEFORE the lists) ────────────
// Headline: the bracket this deck reads as. Sub-line: the overlap ranking.
// Then the money line for the SELECTED bracket, honest about partial pricing
// the same way Synergy Check's banner is.
function VerdictBanner({ result, sel, selLabel }) {
  const numbered = (result.brackets || []).filter((b) => b.bracket != null);
  const best = numbered.find((b) => b.bracket === result.best_bracket);
  const ranked = numbered.slice().sort((a, b) => (b.overlap_pct || 0) - (a.overlap_pct || 0));
  const rankLine = ranked.length
    ? ranked.map((b, i) => i === 0
        ? (b.overlap_pct || 0) + "% of the " + b.label + " average"
        : (b.overlap_pct || 0) + "% of " + b.label).join(", ") + "."
    : null;

  // Money line for the selected bracket's gap.
  const cardsMap = result.cards || {};
  const gapNames = (sel && Array.isArray(sel.gap)) ? sel.gap : [];
  const gapCount = gapNames.length;
  const unowned = gapNames.map((n) => cardsMap[n] || {}).filter((c) => !c.owned);
  const priced = unowned.filter((c) => typeof c.ck_price === "number");
  const gapCost = Math.round(priced.reduce((sum, c) => sum + c.ck_price, 0));

  const isCollection = result.mode === "collection";
  let gapLine = null;
  let costLine = null;
  // Cost to complete: a stat, not a sentence (display law). Rendered big
  // below; these lines carry the words around it.
  let costStat = null;
  if (gapCount === 0) {
    gapLine = isCollection
      ? "You own every card in the " + selLabel + " average. You could sleeve it tonight."
      : "The " + selLabel + " average runs nothing this deck does not already have.";
  } else {
    gapLine = isCollection
      ? "The " + selLabel + " average runs " + gapCount.toLocaleString() + (gapCount === 1 ? " card" : " cards") + " you do not own."
      : "The " + selLabel + " average runs " + gapCount.toLocaleString() + (gapCount === 1 ? " card" : " cards") + " this deck does not.";
    if (unowned.length === 0) {
      costLine = "You already own every one of them somewhere in your stash.";
    } else if (priced.length === 0) {
      costLine = "The " + unowned.length.toLocaleString() + " you do not own anywhere have no Card Kingdom price right now.";
    }
  }
  // Two different numbers, two different wants (Dwayne 8-03): the WHOLE deck
  // at Card Kingdom (rebuy everything, owned or not) and YOUR gap (only what
  // you own nowhere). Owning a card kills the accident, not the option.
  const avgAllNames = sel
    ? [...(Array.isArray(sel.have) ? sel.have : []), ...gapNames]
    : [];
  const wholePricedCards = avgAllNames.map((n) => cardsMap[n] || {}).filter((c) => typeof c.ck_price === "number");
  const wholeCost = Math.round(wholePricedCards.reduce((sum, c) => sum + c.ck_price, 0));
  if (wholePricedCards.length > 0) {
    costStat = {
      whole: wholeCost,
      wholeSub: wholePricedCards.length.toLocaleString() + " of " + avgAllNames.length.toLocaleString() + " priced · commander not counted",
      gap: gapCost,
      gapSub: unowned.length === 0
        ? "you own every card somewhere"
        : unowned.length.toLocaleString() + " unowned"
          + (priced.length < unowned.length ? " · " + priced.length.toLocaleString() + " priced" : ""),
    };
  }

  return (
    <div style={{
      border: "1px solid " + T.line, borderLeft: "3px solid " + T.accent,
      background: T.bg2, borderRadius: 10, padding: "0.9rem 1.1rem", margin: "1.1rem 0 0.9rem",
    }}>
      {best && (
        <div style={{ fontFamily: T.serif, fontSize: "1.12rem", fontWeight: 700, color: T.ink }}>
          {isCollection
            ? "Your collection covers the Bracket " + best.bracket + " (" + best.label + ") average best."
            : "This deck reads like a Bracket " + best.bracket + " (" + best.label + ") deck."}
        </div>
      )}
      {rankLine && (
        <div style={{ color: T.ink2, fontSize: "0.9rem", marginTop: "0.35rem" }}>{rankLine}</div>
      )}
      {gapLine && (
        <div style={{ color: T.ink2, fontSize: "0.9rem", marginTop: "0.35rem" }}>
          {gapLine}{costLine ? " " + costLine : ""}
        </div>
      )}
      {costStat && (
        <div style={{ display: "flex", gap: "1.6rem", flexWrap: "wrap", marginTop: "0.6rem" }}>
          <div>
            <div style={{ font: "700 11px " + T.mono, color: T.ink3, textTransform: "uppercase", letterSpacing: "0.06em" }}>
              The whole deck · {selLabel}
            </div>
            <div style={{ fontFamily: T.serif, fontWeight: 700, fontSize: "1.6rem", color: T.ink, lineHeight: 1.15 }}>
              ${costStat.whole.toLocaleString()}
            </div>
            <div style={{ font: "600 11.5px " + T.mono, color: T.ink3 }}>{costStat.wholeSub}</div>
          </div>
          <div>
            <div style={{ font: "700 11px " + T.mono, color: T.ink3, textTransform: "uppercase", letterSpacing: "0.06em" }}>
              Your gap
            </div>
            <div style={{ fontFamily: T.serif, fontWeight: 700, fontSize: "1.6rem", color: T.accent, lineHeight: 1.15 }}>
              ${costStat.gap.toLocaleString()}
            </div>
            <div style={{ font: "600 11.5px " + T.mono, color: T.ink3 }}>{costStat.gapSub}</div>
          </div>
        </div>
      )}
    </div>
  );
}

// ── Bracket chips row ─────────────────────────────────────────────────
// One chip per entry in the payload's brackets array, "All decks" first.
// Selecting a chip is a DISPLAY SWITCH only: it changes which bracket's
// detail renders below, from data already returned. No network.
function BracketChipsRow({ brackets, selected, onSelect }) {
  return (
    <div style={{ display: "flex", flexWrap: "wrap", gap: "0.45rem", marginTop: "0.35rem" }}
      role="group" aria-label="Bracket detail">
      {brackets.map((b) => {
        const value = b.bracket == null ? null : b.bracket;
        const active = selected === value;
        const name = b.bracket == null ? b.label : b.bracket + " " + b.label;
        return (
          <button key={name} type="button" onClick={() => onSelect(value)} aria-pressed={active}
            style={{
              border: "1px solid " + (active ? T.accent : T.line),
              background: active ? T.accentSoft : "transparent",
              color: active ? T.ink : T.ink2,
              borderRadius: 999, padding: "0.65rem 0.9rem", minHeight: 44,
              font: "600 12.5px " + T.mono, cursor: "pointer",
            }}>
            {name} · {Number(b.deck_count || 0).toLocaleString()} · {b.overlap_pct || 0}%
          </button>
        );
      })}
    </div>
  );
}

// Section divider, same look as synergy.jsx's type dividers (static here;
// both sections stay open).
function SectionHead({ label, count, action }) {
  return (
    <div style={{
      display: "flex", alignItems: "center", gap: "0.45rem", width: "100%",
      margin: "0.9rem 0 0.35rem", padding: "0.45rem 0.6rem", boxSizing: "border-box",
      background: T.bg2, border: "1px solid " + T.line, borderRadius: 8,
      color: T.ink, font: "700 12.5px " + T.mono, textTransform: "uppercase",
      letterSpacing: "0.06em", textAlign: "left",
    }}>
      {label}
      <span style={{ color: T.ink3, fontWeight: 600 }}>{count.toLocaleString()}</span>
      {action && <span style={{ marginLeft: "auto" }}>{action}</span>}
    </div>
  );
}

// ── Gap row ───────────────────────────────────────────────────────────
// A card the bracket average runs that this deck does not. Owned copies read
// as move-it-in candidates (first location shown); unowned get the CK price,
// Buy link, and paperclip. Tap anywhere on the card to read it big.
function GapRow({ card, onOpen, clipped, onToggleClip }) {
  const loc = Array.isArray(card.locations) && card.locations.length ? card.locations[0] : null;
  return (
    <div className="cm-radar-nrow">
      <div className="cm-radar-ntap" style={{ cursor: "pointer" }} onClick={onOpen}
        role="button" tabIndex={0} aria-label={"Open " + card.name}
        onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpen(); } }}>
        {card.image_normal
          ? <img className="cm-radar-nthumb" src={card.image_normal} alt="" loading="lazy" />
          : <div className="cm-radar-nthumb" />}
        <div className="cm-radar-nid">
          <div className="cm-radar-nname">{card.name}</div>
          {card.owned && loc && (
            <div style={{ font: "600 11.5px " + T.mono, color: T.ink3, marginTop: 2 }}>
              {loc.container_name} ({loc.kind})
            </div>
          )}
        </div>
      </div>
      {card.owned ? (
        <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: "0.35rem" }}>
          <span className="cm-radar-owned">✓ in your stash</span>
          <button type="button" onClick={(e) => { e.stopPropagation(); onToggleClip(card); }}
            aria-pressed={clipped} aria-label={clipped ? "Remove from your buy list" : "Clip to your buy list"}
            title={clipped ? "On your buy list. Tap to remove." : "Clip to your buy list"}
            style={{
              border: "1px solid " + (clipped ? T.accent : T.line),
              background: clipped ? T.accent : "transparent",
              color: clipped ? T.accentFg : T.ink3,
              borderRadius: 999, padding: "0.3rem 0.7rem",
              font: "600 11.5px " + T.mono, cursor: "pointer", whiteSpace: "nowrap",
            }}>
            <ClipIcon filled={clipped} />
          </button>
        </div>
      ) : (
        <div style={{ display: "flex", flexDirection: "column", alignItems: "flex-end", gap: "0.35rem" }}>
          {card.buy_url
            ? <a className="cm-radar-buy" href={card.buy_url} target="_blank" rel="noopener noreferrer"
                onClick={() => track("BracketizerBuyClick", { card: card.name })}>
                {typeof card.ck_price === "number" ? "$" + card.ck_price.toFixed(2) + " at Card Kingdom" : "Buy at Card Kingdom"}
              </a>
            : <span className="cm-radar-notowned">no CK price</span>}
          <button type="button" onClick={(e) => { e.stopPropagation(); onToggleClip(card); }}
            aria-pressed={clipped} aria-label={clipped ? "Remove from your buy list" : "Clip to your buy list"}
            title={clipped ? "On your buy list. Tap to remove." : "Clip to your buy list"}
            style={{
              border: "1px solid " + (clipped ? T.accent : T.line),
              background: clipped ? T.accent : "transparent",
              color: clipped ? T.accentFg : T.ink3,
              borderRadius: 999, padding: "0.22rem 0.6rem",
              font: "600 11.5px " + T.mono, cursor: "pointer", whiteSpace: "nowrap",
            }}>
            <ClipIcon filled={clipped} />
          </button>
        </div>
      )}
    </div>
  );
}

// ── Spice row ─────────────────────────────────────────────────────────
// A card this deck runs that the bracket average does not. No buy UI: it is
// owned and already in the deck. Tap to read it big.
function SpiceRow({ card, subLine, onOpen }) {
  return (
    <div className="cm-radar-nrow">
      <div className="cm-radar-ntap" style={{ cursor: "pointer" }} onClick={onOpen}
        role="button" tabIndex={0} aria-label={"Open " + card.name}
        onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpen(); } }}>
        {card.image_normal
          ? <img className="cm-radar-nthumb" src={card.image_normal} alt="" loading="lazy" />
          : <div className="cm-radar-nthumb" />}
        <div className="cm-radar-nid">
          <div className="cm-radar-nname">{card.name}</div>
          <div style={{ font: "600 11.5px " + T.mono, color: T.ink3, marginTop: 2 }}>{subLine}</div>
        </div>
      </div>
    </div>
  );
}

// ── The floating buy-list bar ─────────────────────────────────────────
function PickBar({ picks, copied, onBuyAll, onCopy, onClear }) {
  if (!picks.length) return null;
  return (
    <div className="cm-bulkbar" role="region" aria-label="Buy list">
      <span className="cm-mono" style={{ color: T.accent, fontWeight: 700 }}>
        <ClipIcon filled /> {picks.length} clipped
      </span>
      <button type="button" className="cm-btn cm-btn--primary cm-btn--sm" onClick={onBuyAll}>Buy all at Card Kingdom →</button>
      <button type="button" className="cm-btn cm-btn--ghost cm-btn--sm" onClick={onCopy}>{copied ? "Copied ✓" : "Copy list"}</button>
      <button type="button" className="cm-btn cm-btn--ghost cm-btn--sm" onClick={onClear}>Clear</button>
    </div>
  );
}

// ── Cut / Add: the two actionable diffs for the selected bracket ──────────
// ADD = cards in the bracket average your deck is missing (the gap).
// CUT = cards in your deck the average does not run (the spice). One tap,
// both lists, each copyable, so you can edit a decklist off it.
function CutAddModal({ label, addCards, cutCards, onClose, onOpenCard, suspendKeys, isClipped, onToggleClip }) {
  const [addCopied, setAddCopied] = useState(false);
  const [cutCopied, setCutCopied] = useState(false);
  useEffect(() => {
    // Escape defers to the card popup when it is open on top.
    const onKey = (e) => { if (e.key === "Escape" && !suspendKeys) onClose(); };
    window.addEventListener("keydown", onKey);
    lockScroll();
    return () => { window.removeEventListener("keydown", onKey); unlockScroll(); };
  }, [onClose, suspendKeys]);
  const copyList = (cards, setFlag) => {
    try {
      if (navigator.clipboard) navigator.clipboard.writeText(cards.map((c) => "1 " + c.name).join("\n"));
      setFlag(true);
      setTimeout(() => setFlag(false), 1600);
    } catch (e) { /* best effort */ }
  };
  // Rows open the same signature card popup as the rest of the app (tap the
  // name/thumb) and show the stash connection (owned = a green check).
  const Row = ({ list, i, showClip }) => {
    const c = list[i];
    return (
      <div style={{ display: "flex", alignItems: "center", gap: "0.6rem", padding: "0.5rem 0", borderBottom: "1px solid " + T.line }}>
        <div role="button" tabIndex={0} onClick={() => onOpenCard(list, i)}
          onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onOpenCard(list, i); } }}
          style={{ flex: 1, minWidth: 0, cursor: "pointer", display: "flex", flexDirection: "column", gap: "0.15rem" }}>
          <span style={{ font: "600 14px " + T.sans, color: T.ink, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{c.name}</span>
          {c.owned && (
            <span style={{ font: "600 11px " + T.mono, color: T.good }}>
              ✓ in your stash{Array.isArray(c.locations) && c.locations.length ? " · " + c.locations.map((l) => l.container_name).join(", ") : ""}
            </span>
          )}
        </div>
        {typeof c.ck_price === "number" && (
          <span style={{ font: "600 12px " + T.mono, color: T.ink3, whiteSpace: "nowrap" }}>${c.ck_price.toFixed(2)}</span>
        )}
        {showClip && c.oracle_id && (
          <button type="button" onClick={() => onToggleClip(c)} aria-label={isClipped(c.name) ? "Clipped" : "Clip to buy"}
            style={{ minWidth: 34, minHeight: 34, borderRadius: 8, cursor: "pointer",
              border: "1.5px solid " + (isClipped(c.name) ? T.accent : T.line),
              background: isClipped(c.name) ? T.accent : "transparent", color: isClipped(c.name) ? T.accentFg : T.ink3,
              font: "700 13px " + T.mono }}>
            {isClipped(c.name) ? "✓" : "\u{1F4CE}"}
          </button>
        )}
      </div>
    );
  };
  return (
    <div className="cm-modal-backdrop" onClick={onClose}>
      <div onClick={(e) => e.stopPropagation()} role="dialog" aria-modal="true" aria-label="Cut and add lists"
        style={{ position: "relative", width: "100%", maxWidth: "min(94vw, 460px)", maxHeight: "calc(100dvh - 32px)",
          display: "flex", flexDirection: "column", overflow: "hidden",
          background: T.bg, border: "2px solid " + T.ink, borderRadius: 14,
          boxShadow: "0 24px 64px rgba(0,0,0,0.4)" }}>
        {/* Fixed header: the close stays reachable no matter how long the list
            scrolls (the "can't close it" trap). */}
        <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.6rem",
          padding: "0.9rem 1.15rem", borderBottom: "1px solid " + T.line, flexShrink: 0 }}>
          <span style={{ font: "700 13px " + T.mono, color: T.ink3, textTransform: "uppercase", letterSpacing: "0.06em" }}>
            Cut / Add · {label}
          </span>
          <button type="button" onClick={onClose} aria-label="Close"
            style={{ flexShrink: 0, width: 40, height: 40, borderRadius: 999, cursor: "pointer",
              border: "1.5px solid " + T.line, background: T.bg2, color: T.ink, font: "700 16px " + T.mono,
              display: "flex", alignItems: "center", justifyContent: "center" }}>✕</button>
        </div>
        <div style={{ overflowY: "auto", overscrollBehavior: "contain", padding: "0.4rem 1.15rem 1.1rem", flex: 1 }}>
        <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginTop: "0.6rem" }}>
          <span style={{ font: "800 15px " + T.sans, color: T.ink }}>Add ({addCards.length})</span>
          {addCards.length > 0 && (
            <button type="button" onClick={() => copyList(addCards, setAddCopied)}
              style={{ border: "none", background: "transparent", color: T.accent, font: "700 12px " + T.mono, cursor: "pointer" }}>
              {addCopied ? "✓ Copied" : "Copy add list"}
            </button>
          )}
        </div>
        <div style={{ font: "600 12px " + T.mono, color: T.ink3, marginBottom: "0.3rem", lineHeight: 1.4 }}>
          In the average, not in your deck.
        </div>
        {addCards.length === 0
          ? <div style={{ font: "600 13px " + T.sans, color: T.ink3, padding: "0.4rem 0" }}>Nothing to add. Your deck runs the whole average.</div>
          : addCards.map((c, i) => <Row key={"add-" + c.name} list={addCards} i={i} showClip />)}

        <div style={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", marginTop: "1.3rem" }}>
          <span style={{ font: "800 15px " + T.sans, color: T.ink }}>Cut ({cutCards.length})</span>
          {cutCards.length > 0 && (
            <button type="button" onClick={() => copyList(cutCards, setCutCopied)}
              style={{ border: "none", background: "transparent", color: T.accent, font: "700 12px " + T.mono, cursor: "pointer" }}>
              {cutCopied ? "✓ Copied" : "Copy cut list"}
            </button>
          )}
        </div>
        <div style={{ font: "600 12px " + T.mono, color: T.ink3, marginBottom: "0.3rem", lineHeight: 1.4 }}>
          In your deck, not in the average. Your spice, or your cut candidates.
        </div>
        {cutCards.length === 0
          ? <div style={{ font: "600 13px " + T.sans, color: T.ink3, padding: "0.4rem 0" }}>Nothing off-average. Your deck IS the average.</div>
          : cutCards.map((c, i) => <Row key={"cut-" + c.name} list={cutCards} i={i} showClip={false} />)}
        </div>
      </div>
    </div>
  );
}

// ── Header ────────────────────────────────────────────────────────────
function BracketizerHead() {
  return (
    <header className="cm-radar-head">
      <div className="cm-radar-head-main">
        <div className="cm-eyebrow">The Bracket Brewer <span className="cm-beta-chip">BETA</span></div>
        <h1 className="cm-h1 cm-h1--sm">THE BRACKET BREWER</h1>
        <p className="cm-radar-lead">
          Compare a deck you own against the average deck at every bracket, or
          brew something new straight from your collection. Every check shows
          what you already own, what is missing, and what the gap costs.
        </p>
      </div>
    </header>
  );
}

// ── Type sections (same bucketing as synergy.jsx) ─────────────────────
const TYPE_SECTIONS = [
  { key: "creature", label: "Creatures" },
  { key: "instant", label: "Instants" },
  { key: "sorcery", label: "Sorceries" },
  { key: "artifact", label: "Artifacts" },
  { key: "enchantment", label: "Enchantments" },
  { key: "planeswalker", label: "Planeswalkers" },
  { key: "battle", label: "Battles" },
  { key: "land", label: "Lands" },
  { key: "other", label: "Other" },
];
function typeBucket(typeLine) {
  const t = String(typeLine || "").split(" // ")[0].toLowerCase();
  if (!t) return "other";
  if (t.includes("creature")) return "creature";
  if (t.includes("land")) return "land";
  if (t.includes("instant")) return "instant";
  if (t.includes("sorcery")) return "sorcery";
  if (t.includes("artifact")) return "artifact";
  if (t.includes("enchantment")) return "enchantment";
  if (t.includes("planeswalker")) return "planeswalker";
  if (t.includes("battle")) return "battle";
  return "other";
}
// Group a FLAT list; entries keep their flat index so the popup arrows walk
// the whole section-ordered list straight through the dividers.
function groupByType(cards) {
  const buckets = {};
  cards.forEach((card, flatIdx) => {
    const k = typeBucket(card.type_line);
    (buckets[k] = buckets[k] || []).push({ card, flatIdx });
  });
  return buckets;
}

function BracketizerApp() {
  const [session, sessionLoading] = window.ssAuth.useSession();
  const [decks, setDecks] = useState(null); // null = loading
  const [containerId, setContainerId] = useState("");
  // "deck" = grade a saved deck / collection; "paste" = grade a pasted list.
  const [mode, setMode] = useState("deck");
  const [pasteText, setPasteText] = useState("");
  const [commander, setCommander] = useState("");
  const [isPartner, setIsPartner] = useState(false);
  const [commander2, setCommander2] = useState("");
  const [result, setResult] = useState(null);
  const [checking, setChecking] = useState(false);
  const [error, setError] = useState(null);
  // The chip selection: null = All decks, 1-5 = that bracket. Display only.
  const [selBracket, setSelBracket] = useState(null);
  // The names the current result was checked with (drives the not_found copy).
  const checkedRef = useRef(null);
  // Card popup: { cards, idx } or null.
  const [modal, setModal] = useState(null);
  // Guards the prefill against a stale resolve after another deck pick.
  const prefillSeq = useRef(0);

  // Paperclip buy list (persisted; survives new checks and reloads).
  const [picks, setPicks] = useState(loadPicks);
  const [copied, setCopied] = useState(false);
  useEffect(() => {
    try { localStorage.setItem(PICKS_KEY, JSON.stringify(picks)); } catch (e) { /* best effort */ }
  }, [picks]);
  const isClipped = useCallback((name) => picks.some((p) => p.name === name), [picks]);
  const toggleClip = useCallback((card) => {
    if (!card || !card.name) return;
    setPicks((prev) => prev.some((p) => p.name === card.name)
      ? prev.filter((p) => p.name !== card.name)
      : [...prev, { name: card.name, oracle_id: card.oracle_id || null, buy_url: card.buy_url || null }]);
  }, []);
  const copyPicks = useCallback(() => {
    try {
      if (navigator.clipboard) navigator.clipboard.writeText(picksToPlainList(picks));
      setCopied(true);
      setTimeout(() => setCopied(false), 1600);
    } catch (e) { /* best effort */ }
  }, [picks]);
  // Full-average-list copy (rank 5 export bar). Copied-state flip cloned from
  // copyPicks; the actual list builds at click time from sel + result below.
  const [listCopied, setListCopied] = useState(false);

  // Wishlist flow. wishlisted = session-local oracle_ids already added (drives
  // the "On your wishlist" state on the popup's Want it button). wishlistModal
  // holds the cards the open modal would add. Opening never adds anything;
  // only the modal's Add button acts.
  const [wishlisted, setWishlisted] = useState(() => new Set());
  const [wishlistModal, setWishlistModal] = useState(null); // { cards: [{ oracle_id, name }] } | null
  const [ownModal, setOwnModal] = useState(null); // { name, oracle_id } | null
  const [startModal, setStartModal] = useState(false); // Start this deck (PRO)
  const [cutAddModal, setCutAddModal] = useState(false); // Cut / Add lists
  const [collapsedTypes, setCollapsedTypes] = useState(() => new Set());

  // "Own this" success: flip the card to owned in place — cards map, the
  // gap→have move in every bracket, and the overlap ladder — no 30s re-run.
  const markOwnedLocal = (card, containerId, containerName, kind) => {
    setResult((prev) => {
      if (!prev || prev.status !== "ok") return prev;
      const cards = { ...prev.cards };
      const c = cards[card.name];
      if (c) {
        cards[card.name] = {
          ...c, owned: true,
          locations: [{ container_id: containerId, container_name: containerName, kind, quantity: 1 }, ...(c.locations || [])],
        };
      }
      const brackets = (prev.brackets || []).map((b) => {
        if (!Array.isArray(b.gap) || !b.gap.includes(card.name)) return b;
        const have = [...(b.have || []), card.name];
        return {
          ...b,
          gap: b.gap.filter((n) => n !== card.name),
          have,
          overlap_pct: b.avg_size > 0 ? Math.round((100 * have.length) / b.avg_size) : b.overlap_pct,
        };
      });
      return { ...prev, cards, brackets };
    });
  };
  const toggleType = (key) => setCollapsedTypes((prev) => {
    const next = new Set(prev);
    if (next.has(key)) next.delete(key); else next.add(key);
    return next;
  });
  const markWishlisted = useCallback((ids) => {
    setWishlisted((prev) => {
      const next = new Set(prev);
      ids.forEach((id) => next.add(id));
      return next;
    });
  }, []);
  const isWishlisted = useCallback((oid) => wishlisted.has(oid), [wishlisted]);
  const wantCard = useCallback((card) => {
    if (!card || !card.oracle_id) return;
    setWishlistModal({ cards: [{ oracle_id: card.oracle_id, name: card.name }] });
  }, []);

  // Auth guard: signed-out visitors go to login with a return path.
  useEffect(() => {
    if (sessionLoading) return;
    if (!session) window.location.href = "/login?next=/bracketizer";
  }, [session, sessionLoading]);

  // Deep links + last-run restore. ?deck=&commander=(&commander2=)(&bracket=)
  // auto-runs the check (arriving with a commander in the URL is an explicit
  // ask); ?deck= alone prefills like a picker tap; a bare open prefills the
  // form from the last run. Only an explicit commander in the URL ever runs
  // a check without a Bracketize tap.
  const pendingBracketRef = useRef(null);
  const deepLinkedRef = useRef(false);
  useEffect(() => {
    if (!session || decks === null || deepLinkedRef.current) return;
    deepLinkedRef.current = true;
    let q = null;
    try { q = new URLSearchParams(window.location.search); } catch (e) { return; }
    const deck = (q.get("deck") || "").trim();
    const cmdr = (q.get("commander") || "").trim();
    const cmdr2 = (q.get("commander2") || "").trim();
    const br = parseInt(q.get("bracket") || "", 10);
    if (Number.isInteger(br) && br >= 1 && br <= 5) pendingBracketRef.current = br;
    if (deck) {
      if (cmdr) {
        setContainerId(deck);
        setCommander(cmdr);
        if (cmdr2) { setIsPartner(true); setCommander2(cmdr2); }
        runCheck(deck, cmdr, cmdr2);
      } else {
        pickDeck(deck);
      }
      return;
    }
    try {
      const last = JSON.parse(localStorage.getItem(LAST_RUN_KEY) || "null");
      if (last && last.deck && last.commander) {
        setContainerId(String(last.deck));
        setCommander(last.commander);
        if (last.commander2) { setIsPartner(true); setCommander2(last.commander2); }
      }
    } catch (e) { /* best effort */ }
  }, [session, decks, runCheck]);

  // Deck picker data: the user's deck-kind containers. Same list endpoint the
  // rest of the app uses; wishlists and archived containers never show
  // (the list default already excludes archived).
  useEffect(() => {
    if (!session) return;
    let cancelled = false;
    (async () => {
      try {
        const r = await window.ssAuth.authedFetch("/api/containers");
        const rows = r.ok ? await r.json() : [];
        if (cancelled) return;
        const list = (Array.isArray(rows) ? rows : []).filter((c) =>
          c && c.kind === "deck" && !c.archived_at && !c.is_wishlist);
        setDecks(list);
      } catch (e) {
        if (!cancelled) setDecks([]);
      }
    })();
    return () => { cancelled = true; };
  }, [session]);

  // Picking a deck fills the picker and, when Deck Tech has a stored
  // commander pick for that container, prefills the commander input(s).
  // Prefill only. The check never runs until Bracketize is clicked.
  const pickDeck = (id) => {
    setContainerId(id);
    if (id === "collection") return; // whole-collection mode has no stored commander
    const seq = ++prefillSeq.current;
    const oid = readDeckTechCommander(id);
    const oid2 = readDeckTechPartner(id);
    if (!oid && !oid2) return;
    (async () => {
      try {
        if (oid) {
          const r = await window.ssAuth.authedFetch("/api/cards/" + encodeURIComponent(oid));
          if (r.ok) {
            const card = await r.json();
            if (prefillSeq.current === seq && card && card.name) setCommander(card.name);
          }
        }
        if (oid2) {
          const r2 = await window.ssAuth.authedFetch("/api/cards/" + encodeURIComponent(oid2));
          if (r2.ok) {
            const card2 = await r2.json();
            if (prefillSeq.current === seq && card2 && card2.name) {
              setIsPartner(true);
              setCommander2(card2.name);
            }
          }
        }
      } catch (e) { /* prefill is best effort */ }
    })();
  };

  // The one and only trigger: the Bracketize button.
  const runCheck = useCallback(async (cid, name, name2) => {
    const q = (name || "").trim();
    const q2 = (name2 || "").trim();
    if (!cid || !q) return;
    setChecking(true);
    setError(null);
    try {
      let url = "/api/bracketizer/check?container_id=" + encodeURIComponent(cid) +
        "&commander=" + encodeURIComponent(q);
      if (q2) url += "&commander2=" + encodeURIComponent(q2);
      const r = await window.ssAuth.authedFetch(url);
      if (!r.ok) {
        const b = await r.json().catch(() => ({}));
        setError(b.error || "Could not run the check. Try again.");
        setResult(null);
      } else {
        const data = await r.json();
        setResult(data);
        setCollapsedTypes(new Set());
        checkedRef.current = { name: q, name2: q2 || null };
        if (data.status === "ok") {
          const numbered = (data.brackets || []).filter((b) => b.bracket != null);
          const best = numbered.find((b) => b.bracket === data.best_bracket);
          const pb = pendingBracketRef.current;
          pendingBracketRef.current = null;
          const chosen = pb != null && numbered.some((b) => b.bracket === pb)
            ? pb
            : (best ? best.bracket : null);
          setSelBracket(chosen);
          // Remember the run (bare-open prefill) and make the URL shareable.
          try { localStorage.setItem(LAST_RUN_KEY, JSON.stringify({ deck: cid, commander: q, commander2: q2 || null })); } catch (e) { /* best effort */ }
          try {
            const u = new URLSearchParams();
            u.set("deck", String(cid));
            u.set("commander", q);
            if (q2) u.set("commander2", q2);
            if (chosen != null) u.set("bracket", String(chosen));
            history.replaceState(null, "", window.location.pathname + "?" + u.toString());
          } catch (e) { /* cosmetic */ }
          // Once per run, with the verdict riding along.
          track("BracketizerCheck", {
            bracket_best: data.best_bracket,
            overlap: best ? (best.overlap_pct || 0) : 0,
          });
        }
      }
    } catch (e) {
      setError("Could not run the check. Try again.");
      setResult(null);
    } finally {
      setChecking(false);
    }
  }, []);

  // Paste path: same result shape as runCheck, different source. Kept separate
  // so the saved-deck path stays untouched; a pasted list has no saved deck, so
  // it skips the shareable-URL and last-run remember that only make sense for a
  // real container.
  const runPasteCheck = useCallback(async (text, name, name2) => {
    const q = (name || "").trim();
    const q2 = (name2 || "").trim();
    if (!text.trim() || !q) return;
    setChecking(true);
    setError(null);
    try {
      const r = await window.ssAuth.authedFetch("/api/bracketizer/check-list", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ decklist_text: text, commander: q, commander2: q2 || undefined }),
      });
      if (!r.ok) {
        const b = await r.json().catch(() => ({}));
        setError(b.error || "Could not run the check. Try again.");
        setResult(null);
      } else {
        const data = await r.json();
        setResult(data);
        setCollapsedTypes(new Set());
        checkedRef.current = { name: q, name2: q2 || null };
        if (data.status === "ok") {
          const numbered = (data.brackets || []).filter((b) => b.bracket != null);
          const best = numbered.find((b) => b.bracket === data.best_bracket);
          const pb = pendingBracketRef.current;
          pendingBracketRef.current = null;
          const chosen = pb != null && numbered.some((b) => b.bracket === pb) ? pb : (best ? best.bracket : null);
          setSelBracket(chosen);
          track("BracketizerCheck", {
            source: "paste",
            bracket_best: data.best_bracket,
            overlap: best ? (best.overlap_pct || 0) : 0,
          });
        }
      }
    } catch (e) {
      setError("Could not run the check. Try again.");
      setResult(null);
    } finally {
      setChecking(false);
    }
  }, []);

  const submit = (e) => {
    e.preventDefault();
    if (mode === "paste") runPasteCheck(pasteText, commander, isPartner ? commander2 : "");
    else runCheck(containerId, commander, isPartner ? commander2 : "");
  };

  // Chip taps keep the shareable URL current (only once a result is up).
  useEffect(() => {
    if (!result || result.status !== "ok") return;
    try {
      const u = new URLSearchParams(window.location.search);
      if (!u.get("deck")) return; // URL not stamped by a run yet
      if (selBracket == null) u.delete("bracket"); else u.set("bracket", String(selBracket));
      history.replaceState(null, "", window.location.pathname + "?" + u.toString());
    } catch (e) { /* cosmetic */ }
  }, [selBracket, result]);

  // The full average as a copyable decklist: commander line(s) from the
  // result, then the server's full_list (counts + basics included). Stale
  // cached JS or a legacy response shape falls back to the on-screen average
  // at count 1 so the button never goes dead.
  const copyFullList = () => {
    if (!sel) return;
    const lines = [];
    if (result && result.commander) {
      const c = result.commander;
      const primaryName = c.partner ? c.name.replace(" + " + c.partner.name, "") : c.name;
      lines.push("1 " + primaryName);
      if (c.partner) lines.push("1 " + c.partner.name);
    }
    if (Array.isArray(sel.full_list) && sel.full_list.length > 0) {
      for (const l of sel.full_list) lines.push((l.count || 1) + " " + l.name);
    } else {
      for (const c of avgCards) lines.push("1 " + c.name);
    }
    try {
      if (navigator.clipboard) navigator.clipboard.writeText(lines.join("\n"));
      setListCopied(true);
      setTimeout(() => setListCopied(false), 1600);
      track("BracketizerCopyList", { bracket: sel.bracket == null ? "all" : sel.bracket });
    } catch (e) { /* best effort */ }
  };

  const status = result && result.status;
  const brackets = (status === "ok" && Array.isArray(result.brackets)) ? result.brackets : [];
  const cardsMap = (status === "ok" && result.cards) ? result.cards : {};
  const sel = brackets.find((b) => (b.bracket == null ? null : b.bracket) === selBracket) || brackets[0] || null;
  const selLabel = sel ? (sel.bracket == null ? "All decks" : sel.label) : "";
  const spiceSub = sel && sel.bracket != null
    ? "not in the bracket " + sel.bracket + " average"
    : "not in the average deck";

  // Sorted into type-section order so the popup arrows walk the list exactly
  // as it reads on screen, straight through the dividers.
  const sectionRank = (c) => {
    const i = TYPE_SECTIONS.findIndex((s) => s.key === typeBucket(c.type_line));
    return i < 0 ? TYPE_SECTIONS.length : i;
  };
  const gapCards = sel
    ? sel.gap.map((n) => Object.assign({ name: n }, cardsMap[n] || {})).sort((a, b) => sectionRank(a) - sectionRank(b))
    : [];
  const spiceCards = sel
    ? sel.spice.map((n) => Object.assign({ name: n }, cardsMap[n] || {})).sort((a, b) => sectionRank(a) - sectionRank(b))
    : [];
  // The COMPLETE average list: owned cards (checked off, with locations)
  // alongside the missing ones. Owned-first within each type section.
  const haveCards = sel && Array.isArray(sel.have)
    ? sel.have.map((n) => Object.assign({ name: n }, cardsMap[n] || {}))
    : [];
  const avgCards = [...haveCards, ...gapCards].sort((a, b) =>
    (sectionRank(a) - sectionRank(b)) || ((b.owned ? 1 : 0) - (a.owned ? 1 : 0)));

  // The selected bracket's gap cards the user does not own anywhere. Cards
  // without an oracle_id can't become wishlist rows and are skipped silently.
  const gapWishlistCards = gapCards
    .filter((c) => c.owned === false && c.oracle_id)
    .map((c) => ({ oracle_id: c.oracle_id, name: c.name }));
  const allGapWished = gapWishlistCards.length > 0 && gapWishlistCards.every((c) => wishlisted.has(c.oracle_id));

  // Popup lists: plain-mode cards, arrows walk the section the tap came from.
  // Gap cards carry the wishlist bits so the popup can offer Want it on
  // unowned ones.
  // The full popup shape (image, ownership, locations, price, buy, want) so
  // every card surface opens the same signature popup and shows the stash
  // connection. Shared by the average-deck rows and the Cut/Add lists.
  const toPopupCard = (c) => ({
    name: c.name, image_normal: c.image_normal,
    oracle_id: c.oracle_id || null,
    type_line: c.type_line || null,
    synergy_pct: typeof c.synergy_pct === "number" ? c.synergy_pct : null,
    inclusion_rate: typeof c.inclusion_rate === "number" ? c.inclusion_rate : null,
    clippable: true,
    owned: c.owned === true,
    locations: Array.isArray(c.locations) ? c.locations : [],
    ck_price: typeof c.ck_price === "number" ? c.ck_price : null,
    buy_url: c.buy_url || null,
    wantable: c.owned === false && !!c.oracle_id,
  });
  const openGap = (idx) => setModal({ cards: avgCards.map(toPopupCard), idx });
  const openCutAddCard = (cards, idx) => setModal({ cards: cards.map(toPopupCard), idx });
  // Buy-list row tap: open the popup, hydrated from the current result's card
  // map when the card is still on screen, else a minimal name+buy card.
  const openPickCard = (pick) => {
    const c = cardsMap[pick.name];
    const card = c
      ? toPopupCard({ name: pick.name, ...c })
      : { name: pick.name, oracle_id: pick.oracle_id || null, buy_url: pick.buy_url || null, image_normal: null, clippable: true, owned: false, locations: [] };
    setModal({ cards: [card], idx: 0 });
  };
  const openSpice = (idx) => setModal({
    cards: spiceCards.map((c) => ({
      name: c.name, image_normal: c.image_normal,
      type_line: c.type_line || null, sub: spiceSub,
      clippable: true,
    })), idx,
  });

  // The commander (and partner) as popup-openable cards, same as synergy.jsx:
  // the route names a pair "A + B", so the primary's own name is the pair
  // minus the partner.
  const cmdCards = (() => {
    if (status !== "ok" || !result.commander) return [];
    const c = result.commander;
    const primaryName = c.partner ? c.name.replace(" + " + c.partner.name, "") : c.name;
    return [
      { name: primaryName, image_normal: c.image_normal },
      c.partner ? { name: c.partner.name, image_normal: c.partner.image_normal } : null,
    ].filter((x) => x && x.image_normal);
  })();

  const body = () => {
    if (sessionLoading) return <p className="cm-radar-status-msg">Loading…</p>;
    if (!session) return <p className="cm-radar-status-msg">Redirecting to sign in…</p>;
    return (
      <>
        <BracketizerHead />

        <form className="cm-radar-addbar syn-box" onSubmit={submit} autoComplete="off">
          {/* Source toggle: a saved deck / whole collection, or a pasted list. */}
          <div style={{ display: "flex", gap: "0.4rem", marginBottom: "0.7rem" }}>
            {[["deck", "My decks"], ["paste", "Paste a list"]].map(([m, label]) => (
              <button key={m} type="button" disabled={checking}
                onClick={() => { setMode(m); setError(null); }}
                style={{
                  flex: 1, minHeight: 44, borderRadius: 999, cursor: "pointer",
                  border: "2px solid " + (mode === m ? T.accent : T.line),
                  background: mode === m ? T.accent : "transparent",
                  color: mode === m ? T.accentFg : T.ink,
                  font: "700 13px " + T.mono, letterSpacing: "0.02em",
                }}>
                {label}
              </button>
            ))}
          </div>
          {mode === "deck" ? (
            <>
              <label htmlFor="ss-bracketizer-deck">Which deck?</label>
              <select id="ss-bracketizer-deck" value={containerId} disabled={checking}
                onChange={(e) => pickDeck(e.target.value)}
                style={{
                  width: "100%", boxSizing: "border-box", minHeight: 48,
                  border: "2px solid " + T.ink, borderRadius: 10,
                  background: T.bg, padding: "12px 14px",
                  font: "600 16px " + T.sans, color: T.ink,
                }}>
                <option value="">Pick a deck…</option>
                <option value="collection">My whole collection (could I build it?)</option>
                {(decks || []).map((d) => (
                  <option key={d.id} value={d.id}>
                    {d.name}{typeof d.card_count === "number" ? " · " + d.card_count.toLocaleString() + " cards" : ""}
                  </option>
                ))}
              </select>
              {containerId === "collection" && (
                <div style={{ font: "600 12px " + T.mono, color: T.ink3, marginTop: "0.5rem", textTransform: "none", letterSpacing: 0 }}>
                  Checks the average deck at every bracket against everything you own, anywhere in your stash.
                </div>
              )}
              {decks && decks.length === 0 && (
                <div style={{ font: "600 12px " + T.mono, color: T.ink3, marginTop: "0.5rem", textTransform: "none", letterSpacing: 0 }}>
                  No decks in your stash yet. Paste a list instead, or start a deck on the Collection page.
                </div>
              )}
            </>
          ) : (
            <>
              <label htmlFor="ss-bracketizer-paste">Paste a decklist</label>
              <textarea id="ss-bracketizer-paste" value={pasteText} disabled={checking}
                onChange={(e) => setPasteText(e.target.value)}
                rows={7} placeholder={"One card per line:\n1 Sol Ring\n1 Arcane Signet\nCultivate\n..."}
                style={{
                  width: "100%", boxSizing: "border-box", minHeight: 150,
                  border: "2px solid " + T.ink, borderRadius: 10,
                  background: T.bg, padding: "12px 14px",
                  font: "600 15px " + T.mono, color: T.ink, resize: "vertical", lineHeight: 1.5,
                }} />
              <div style={{ font: "600 12px " + T.mono, color: T.ink3, marginTop: "0.5rem", textTransform: "none", letterSpacing: 0, lineHeight: 1.5 }}>
                Leave the commander out of the list. Name it in the box below instead.
                Paste the rest from anywhere. We cross it against your stash, so you still see what you own and what the gap costs.
              </div>
            </>
          )}
          <label style={{ marginTop: "0.85rem" }}>Which commander?</label>
          <CommanderInput value={commander} onChange={setCommander} onPick={setCommander} disabled={checking} showButton={false} />
          <label style={{ display: "flex", alignItems: "center", gap: "0.45rem", marginTop: "0.6rem", font: "600 12.5px " + T.mono, color: T.ink3, textTransform: "none", letterSpacing: 0, cursor: "pointer" }}>
            <input type="checkbox" checked={isPartner} disabled={checking}
              onChange={(e) => { setIsPartner(e.target.checked); if (!e.target.checked) setCommander2(""); }} />
            Partner commanders (two at the helm)
          </label>
          {isPartner && (
            <div style={{ marginTop: "0.5rem" }}>
              <CommanderInput value={commander2} onChange={setCommander2} onPick={setCommander2}
                disabled={checking} showButton={false}
                placeholder="e.g. Tymna the Weaver" ariaLabel="Partner commander name" />
            </div>
          )}
          <button type="submit" className="cm-btn cm-btn--primary syn-check"
            disabled={checking || !commander.trim() || (mode === "deck" ? !containerId : !pasteText.trim())}>
            {checking ? "Bracketizing…" : "Bracketize"}
          </button>
        </form>

        {checking && <p className="cm-radar-status-msg">Reading the averages bracket by bracket…</p>}
        {!checking && error && <p className="cm-radar-status-msg is-err">{error}</p>}
        {!checking && !error && !result && (
          <p className="cm-radar-status-msg">
            {mode === "paste"
              ? "Paste a decklist, name its commander, and hit Bracketize."
              : "Pick a deck, name its commander, and hit Bracketize."}
          </p>
        )}

        {!checking && !error && status === "not_found" && (
          <p className="cm-radar-status-msg">
            {checkedRef.current && checkedRef.current.name2
              ? "EDHREC has no combined page for that pairing yet. Check both names, or try the partners one at a time."
              : "EDHREC has no page for that commander yet."}
          </p>
        )}
        {!checking && !error && status === "unavailable" && (
          <p className="cm-radar-status-msg is-warn">EDHREC data is temporarily unavailable. Try again in a minute.</p>
        )}

        {!checking && !error && status === "ok" && sel && (
          <>
            {result.commander && (
              <div style={{ textAlign: "center", marginTop: "1.1rem" }}>
                <div style={{ fontFamily: T.serif, fontWeight: 700, fontSize: "1.15rem", color: T.ink }}>{result.commander.name}</div>
                <div style={{ font: "600 12px " + T.mono, color: T.ink3, marginTop: 2 }}>
                  {result.container ? result.container.name + " · " : ""}
                  {typeof result.deck_size === "number" ? result.deck_size.toLocaleString() + " cards checked" : ""}
                </div>
                {cmdCards.length > 0 && (
                  <>
                    <div style={{ display: "flex", justifyContent: "center", gap: "0.75rem", marginTop: "0.75rem" }}>
                      {cmdCards.map((c, i) => (
                        <button key={c.name} type="button" onClick={() => setModal({ cards: cmdCards, idx: i })} aria-label={"Read " + c.name}
                          style={{ border: 0, background: "none", padding: 0, cursor: "zoom-in", borderRadius: 9 }}>
                          <img src={c.image_normal} alt="" loading="lazy"
                            style={{ width: 148, maxWidth: "40vw", borderRadius: 9, display: "block", background: T.bg3, boxShadow: "0 8px 22px rgba(0,0,0,0.3)" }} />
                        </button>
                      ))}
                    </div>
                    <div style={{ font: "600 11px " + T.mono, color: T.ink3, marginTop: "0.5rem" }}>
                      {cmdCards.length > 1 ? "Tap a card to read it" : "Tap the card to read it"}
                    </div>
                  </>
                )}
              </div>
            )}

            <VerdictBanner result={result} sel={sel} selLabel={selLabel} />

            <BracketChipsRow brackets={brackets} selected={selBracket} onSelect={setSelBracket} />

            {/* Export bar: take the average with you. Copy feeds any deck
                builder; the wishlist button moved here from the section head
                so every take-it-home action lives in one row. */}
            <div style={{ display: "flex", flexWrap: "wrap", gap: "0.5rem", margin: "0.9rem 0 0" }}>
              <button type="button" className="cm-btn cm-btn--ghost cm-btn--sm" onClick={copyFullList}
                style={{ minHeight: 44, textTransform: "none", letterSpacing: 0, whiteSpace: "nowrap" }}>
                {listCopied ? "\u2713 List copied" : "Copy the full list"}
              </button>
              <button type="button" className="cm-btn cm-btn--ghost cm-btn--sm" onClick={() => setStartModal(true)}
                style={{ minHeight: 44, textTransform: "none", letterSpacing: 0, whiteSpace: "nowrap" }}
                title="Turn this average into a real deck: pull the cards you own out of your boxes, track the rest.">
                Start this deck<span style={{ font: "700 9px " + T.mono, color: T.accent, marginLeft: 5, letterSpacing: "0.06em" }}>PRO</span>
              </button>
              {gapWishlistCards.length > 0 && (
                <button type="button" className="cm-btn cm-btn--ghost cm-btn--sm" disabled={allGapWished}
                  onClick={() => setWishlistModal({ cards: gapWishlistCards })}
                  style={{ minHeight: 44, textTransform: "none", letterSpacing: 0, whiteSpace: "nowrap" }}>
                  {allGapWished ? "\u2713 Missing cards on your wishlist" : "Wishlist the " + gapCards.length + " missing"}
                </button>
              )}
              {result.mode !== "collection" && (gapCards.length > 0 || spiceCards.length > 0) && (
                <button type="button" className="cm-btn cm-btn--ghost cm-btn--sm" onClick={() => setCutAddModal(true)}
                  style={{ minHeight: 44, textTransform: "none", letterSpacing: 0, whiteSpace: "nowrap" }}
                  title="The two lists to edit your deck: what to add from the average, what to cut that the average does not run.">
                  Cut / Add
                </button>
              )}
            </div>

            <div className="cm-radar-listwrap">
              <SectionHead label="The average deck ·" count={avgCards.length} />
              {gapCards.length === 0 && (
                <p className="cm-radar-status-msg">
                  {result.mode === "collection"
                    ? "You own every card the bracket average runs."
                    : "This deck runs everything the bracket average runs."}
                </p>
              )}
              {(() => {
                const buckets = groupByType(avgCards);
                return TYPE_SECTIONS.map((sec) => {
                  const entries = buckets[sec.key];
                  if (!entries || entries.length === 0) return null;
                  // Entries ARE the average's cards of this type now (owned +
                  // missing), so the tally is a straight count over them.
                  const tally = {
                    owned: entries.filter((en) => en.card.owned).length,
                    total: entries.length,
                    label: "you own",
                    // What filling this section's gap runs at Card Kingdom.
                    gapCost: entries.reduce((sum, en) =>
                      sum + (!en.card.owned && typeof en.card.ck_price === "number" ? en.card.ck_price : 0), 0),
                  };
                  const open = !collapsedTypes.has("gap:" + sec.key);
                  return (
                    <div key={sec.key}>
                      <button type="button" onClick={() => toggleType("gap:" + sec.key)} aria-expanded={open}
                        style={{
                          display: "flex", alignItems: "center", gap: "0.45rem", width: "100%",
                          margin: "0.9rem 0 0.35rem", padding: "0.45rem 0.6rem",
                          background: T.bg2, border: "1px solid " + T.line, borderRadius: 8,
                          color: T.ink, font: "700 12.5px " + T.mono, textTransform: "uppercase",
                          letterSpacing: "0.06em", cursor: "pointer", textAlign: "left",
                        }}>
                          <span aria-hidden="true" style={{ color: T.accent }}>{open ? "▾" : "▸"}</span>
                        {sec.label}
                        {tally.gapCost > 0 && (
                          <span style={{ marginLeft: "auto", color: T.ink3, fontWeight: 600, textTransform: "none", letterSpacing: 0 }}>
                            ${Math.round(tally.gapCost)} missing
                          </span>
                        )}
                        <span style={{ marginLeft: tally.gapCost > 0 ? "0.6rem" : "auto", color: tally.owned > 0 ? T.good : T.ink3, fontWeight: 600, textTransform: "none", letterSpacing: 0 }}>
                          {tally.label} {tally.owned} of {tally.total}
                        </span>
                      </button>
                      {open && entries.map((en) => (
                        <GapRow key={en.card.name + "-" + en.flatIdx} card={en.card}
                          onOpen={() => openGap(en.flatIdx)}
                          clipped={isClipped(en.card.name)}
                          onToggleClip={toggleClip} />
                      ))}
                    </div>
                  );
                });
              })()}

              {result.mode !== "collection" && (
                <>
                  <SectionHead label="Your spice ·" count={spiceCards.length} />
                  {spiceCards.length === 0 && (
                    <p className="cm-radar-status-msg">No spice. This deck IS the average.</p>
                  )}
                  {(() => {
                    const buckets = groupByType(spiceCards);
                    return TYPE_SECTIONS.map((sec) => {
                      const entries = buckets[sec.key];
                      if (!entries || entries.length === 0) return null;
                      const open = !collapsedTypes.has("spice:" + sec.key);
                      return (
                        <div key={sec.key}>
                          <button type="button" onClick={() => toggleType("spice:" + sec.key)} aria-expanded={open}
                            style={{
                              display: "flex", alignItems: "center", gap: "0.45rem", width: "100%",
                              margin: "0.9rem 0 0.35rem", padding: "0.45rem 0.6rem",
                              background: T.bg2, border: "1px solid " + T.line, borderRadius: 8,
                              color: T.ink, font: "700 12.5px " + T.mono, textTransform: "uppercase",
                              letterSpacing: "0.06em", cursor: "pointer", textAlign: "left",
                            }}>
                            <span aria-hidden="true" style={{ color: T.accent }}>{open ? "▾" : "▸"}</span>
                            {sec.label}
                            <span style={{ color: T.ink3, fontWeight: 600 }}>{entries.length}</span>
                          </button>
                          {open && entries.map((en) => (
                            <SpiceRow key={en.card.name + "-" + en.flatIdx} card={en.card} subLine={spiceSub}
                              onOpen={() => openSpice(en.flatIdx)} />
                          ))}
                        </div>
                      );
                    });
                  })()}
                </>
              )}
            </div>
          </>
        )}

        <p className="cm-radar-note">
          Commander and average deck data from EDHREC (edhrec.com), used with permission. Spellstash is not affiliated with or endorsed by EDHREC.
          Spellstash is unaffiliated with Wizards of the Coast. Magic: The Gathering and all card data are property of Wizards of the Coast LLC.
        </p>
      </>
    );
  };

  return (
    <window.ssShell.AppShell active="bracketizer" session={session}>
      <main className="cm-radar" style={picks.length ? { paddingBottom: "5.5rem" } : undefined}>
        {body()}
      </main>
      {/* Cut/Add renders BEFORE the card popup so the popup (tapping a row)
          stacks on top of it, not behind. */}
      {cutAddModal && status === "ok" && sel && (
        <CutAddModal label={selLabel} addCards={gapCards} cutCards={spiceCards}
          onOpenCard={openCutAddCard}
          suspendKeys={modal != null}
          isClipped={isClipped} onToggleClip={toggleClip}
          onClose={() => setCutAddModal(false)} />
      )}
      {modal != null && modal.cards.length > 0 && (
        <BracketizerCardModal cards={modal.cards} startIndex={modal.idx}
          onClose={() => setModal(null)}
          onWant={wantCard}
          onOwnThis={(card) => setOwnModal({ name: card.name, oracle_id: card.oracle_id })}
          isWishlisted={isWishlisted}
          isClipped={isClipped}
          onToggleClip={toggleClip}
          suspendKeys={wishlistModal != null || ownModal != null} />
      )}
      {wishlistModal != null && (
        <WishlistModal cards={wishlistModal.cards}
          onAdded={markWishlisted}
          onClose={() => setWishlistModal(null)} />
      )}
      {ownModal != null && (
        <OwnThisModal card={ownModal}
          onOwned={(card, cid, cname, kind) => markOwnedLocal(card, cid, cname, kind)}
          onClose={() => { setOwnModal(null); setModal(null); }} />
      )}
      {startModal && status === "ok" && sel && (
        <StartDeckModal sel={sel} cardsMap={cardsMap} commander={result.commander}
          onClose={() => setStartModal(false)} />
      )}
      <PickBar picks={picks} copied={copied}
        onBuyAll={() => ckBulkBuy(picks)}
        onCopy={copyPicks}
        onClear={() => setPicks([])} />
      <ClipDrawer picks={picks} copied={copied}
        onBuyAll={() => ckBulkBuy(picks)}
        onCopy={copyPicks}
        onOpenCard={openPickCard}
        onRemove={(cardPick) => setPicks((prev) => prev.filter((x) => x.name !== cardPick.name))}
        onClear={() => setPicks([])} />
    </window.ssShell.AppShell>
  );
}

ReactDOM.createRoot(document.getElementById("root")).render(<BracketizerApp />);
