// sortof - main React app
// Fake state machine: empty → loading → partial → success/error/cold
// Three empty variants, three success variants, exposed via Tweaks.

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

// Live data shim: leaves keep reading D.X; we swap _liveSortData on each
// successful /api/sort response. Tweaks-preview mode leaves it null and
// falls back to the canned dataset.
let _liveSortData = null;
const D = new Proxy({}, {
  get(_, key) {
    const src = _liveSortData ?? window.SORTOF_DATA;
    return src ? src[key] : undefined;
  }
});

// Mirrors backend parse.py: accepts newlines, semicolons, commas, spaces,
// tabs, ini-style prefixes (WorkshopItems=, Mods=, Map=), and Workshop URLs.
// Returns deduped IDs in first-seen order.
function parseWorkshopInput(text) {
  if (!text) return [];
  const cleaned = String(text).replace(/^\s*(WorkshopItems|Mods|Map)\s*=\s*/gim, '');
  const ids = cleaned.match(/\b\d{7,12}\b/g) || [];
  return Array.from(new Set(ids));
}

// Render the Mods= value for a given PZ build.
//   B41: mod1;mod2;mod3;
//   B42: \mod1;\mod2;\mod3;
// Trailing semicolon kept so the line pastes verbatim into the server's .ini file.
function buildModsLine(ids, mode) {
  if (!ids || !ids.length) return '';
  if (mode === 'B42') return '\\' + ids.join(';\\') + ';';
  return ids.join(';') + ';';
}

// Pure helpers for input-textarea mutations. Used by both the per-warning
// toggle handlers (onAddWsid etc.) and the batch auto-fix handlers. Pure so
// they can be folded inside a functional setInput(prev => …) call without
// stale-closure races.
function _applyAppendWsid(input, wsid) {
  const trimmed = (input || '').replace(/\s+$/, '');
  const sep = trimmed ? '\n' : '';
  return trimmed + sep + wsid;
}
function _applyStripWsid(input, wsid) {
  return (input || '').replace(
    new RegExp(`(?:^|(?<=[\\s;,]))${wsid}(?=$|[\\s;,])`, 'g'),
    ''
  ).replace(/[\s;,]{2,}/g, m => m.includes('\n') ? '\n' : m[0]).trim();
}
function _applyEnsureAdded(input, wsid) {
  const wid = String(wsid);
  return parseWorkshopInput(input).includes(wid) ? input : _applyAppendWsid(input, wid);
}
function _applyEnsureRemoved(input, wsid) {
  const w = String(wsid);
  return parseWorkshopInput(input).includes(w) ? _applyStripWsid(input, w) : input;
}
function _applyEnsureSwapped(input, from, to) {
  const f = String(from);
  const t = String(to);
  const existing = parseWorkshopInput(input);
  if (!existing.includes(f)) return input;       // nothing to swap from
  if (existing.includes(t)) return _applyStripWsid(input, f); // target present, just drop from
  return input.replace(new RegExp(`(?<=^|[\\s;,])${f}(?=$|[\\s;,])`, 'g'), t);
}

// Spec B+F: poll a job. Resolves on terminal phase or AbortSignal.
const POLL_INTERVAL_MS = 2500;

async function pollJobOnce(jobId) {
  try {
    const res = await fetch(`/api/jobs/${jobId}`);
    if (res.status === 404) return { kind: 'expired' };
    if (!res.ok) return { kind: 'error', status: res.status };
    const json = await res.json();
    return { kind: 'ok', body: json };
  } catch (e) {
    console.warn('pollJobOnce: network error', e);
    return { kind: 'error', status: 0 };
  }
}

function pollJobLoop(jobId, signal, onTick) {
  return new Promise((resolve) => {
    let timer = null;
    async function tick() {
      if (signal.aborted) { if (timer) clearTimeout(timer); resolve({ kind: 'aborted' }); return; }
      const r = await pollJobOnce(jobId);
      if (signal.aborted) { resolve({ kind: 'aborted' }); return; }
      onTick(r);
      if (r.kind === 'expired' || r.kind === 'error') { resolve(r); return; }
      const phase = r.body.phase;
      if (phase === 'done' || phase === 'failed') { resolve(r); return; }
      timer = setTimeout(tick, POLL_INTERVAL_MS);
    }
    tick();
  });
}

// The picker's default selection mirrors the backend's auto-pick. After
// Spec C, the backend applies build-aware + input-cross-ref + prefix-base
// rules and emits SORTED_ORDER reflecting the chosen branches. The picker
// reads from SORTED_ORDER (passed as `activeSet`) and ticks any branch
// whose mod_id is in that set; falls back to first-only if none match.
// Spec A's contract holds - the picker is the source of truth.
function isRadioMode(branches) {
  if (!branches || branches.length < 2) return false;
  const ids = new Set(branches.map(b => b.modId));
  return branches.some(b => (b.conflicts || []).some(c => ids.has(c)));
}

function defaultSelectionForBranches(branches, activeSet) {
  if (!branches || !branches.length) return [];
  if (activeSet && activeSet.size) {
    const active = branches.filter(b => activeSet.has(b.modId)).map(b => b.modId);
    if (active.length) return active;
  }
  return [branches[0].modId];
}

// Shared column count for the Mod Details table. Keep in sync with the
// <thead> in ModTable; expansion-panel <tr colSpan> reads this constant.
// Spec C will add a 7th column; bump here, do not hardcode the integer.
const COLUMN_COUNT = 6;

// Steam Workshop deep-link for a wsid. Pattern is the same for mods and
// collections (the backend resolves the distinction; users see one URL).
function wsidUrl(wsid) {
  return `https://steamcommunity.com/sharedfiles/filedetails/?id=${wsid}`;
}

function WsidLink({ wsid, children, className }) {
  if (!wsid) return <span className={className}>{children ?? wsid}</span>;
  return (
    <a
      href={wsidUrl(wsid)}
      target="_blank"
      rel="noopener noreferrer"
      className={className ? className + ' wsid-link' : 'wsid-link'}
      title={`open Workshop page for ${wsid}`}
    >{children ?? wsid}</a>
  );
}

// Compare the wsids the user pasted (input order, deduped) against the wsids
// in WORKSHOP_ITEMS_LINE (sort order). Always available — no "previous sort"
// required. Surfaces drops (banned / missing mod.info / unknown / collection
// IDs that expanded), additions (collection expansion, branch picks), and
// position changes.
function computeDiff(inputWsids, outputWsids) {
  if (!inputWsids || !outputWsids) return null;
  const inSet  = new Set(inputWsids);
  const outSet = new Set(outputWsids);
  const added   = outputWsids.filter(w => !inSet.has(w));
  const removed = inputWsids.filter(w => !outSet.has(w));
  const inPos = new Map(inputWsids.map((w, i) => [w, i]));
  const movers = [];
  outputWsids.forEach((w, oi) => {
    if (!inSet.has(w)) return;
    const ii = inPos.get(w);
    if (ii !== oi) movers.push({ wsid: w, from: ii, to: oi, delta: oi - ii });
  });
  movers.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
  return { added, removed, movers };
}

function DiffPanel({ inputWsids, outputWsids, onClose }) {
  const diff = computeDiff(inputWsids, outputWsids);
  if (!diff) {
    return (
      <div className="diff-panel">
        <div className="diff-head">
          <span className="diff-title">diff: input → sorted</span>
          <button type="button" className="diff-close" onClick={onClose}>close</button>
        </div>
        <div className="diff-empty">no input or output yet.</div>
      </div>
    );
  }
  const { added, removed, movers } = diff;
  const empty = !added.length && !removed.length && !movers.length;
  return (
    <div className="diff-panel">
      <div className="diff-head">
        <span className="diff-title">diff: input → sorted</span>
        <span className="diff-summary">
          <span className="diff-stat add">+{added.length}</span>
          <span className="diff-stat rm">−{removed.length}</span>
          <span className="diff-stat mv">↕{movers.length}</span>
        </span>
        <button type="button" className="diff-close" onClick={onClose}>close</button>
      </div>
      {empty && <div className="diff-empty">order matches your input. nothing dropped or added.</div>}
      {added.length > 0 && (
        <div className="diff-section">
          <div className="diff-label">added ({added.length}) — collection expansion or branch-picker</div>
          {added.slice(0, 30).map(w => <div key={w} className="diff-row diff-add">+ {w}</div>)}
          {added.length > 30 && <div className="diff-more">…and {added.length - 30} more</div>}
        </div>
      )}
      {removed.length > 0 && (
        <div className="diff-section">
          <div className="diff-label">removed ({removed.length}) — dropped, banned, missing mod.info, or a collection ID that expanded</div>
          {removed.slice(0, 30).map(w => <div key={w} className="diff-row diff-rm">− {w}</div>)}
          {removed.length > 30 && <div className="diff-more">…and {removed.length - 30} more</div>}
        </div>
      )}
      {movers.length > 0 && (
        <div className="diff-section">
          <div className="diff-label">reordered by load order ({movers.length}, top {Math.min(10, movers.length)} shown)</div>
          {movers.slice(0, 10).map(({ wsid, from, to, delta }) => (
            <div key={wsid} className="diff-row diff-mv">
              <span className="diff-arrow">{delta < 0 ? '↑' : '↓'}</span>
              {wsid}
              <span className="diff-pos">pos {from + 1} → {to + 1} ({delta > 0 ? '+' : ''}{delta})</span>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ── icons ───────────────────────────────────────────────────────────────────
const IconCopy = () => (
  <svg className="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
    <rect x="9" y="9" width="13" height="13" rx="2"></rect>
    <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
  </svg>
);
const IconCheck = () => (
  <svg className="ico" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
    <polyline points="20 6 9 17 4 12"></polyline>
  </svg>
);
const IconGit = () => (
  <svg viewBox="0 0 24 24" fill="currentColor" width="14" height="14">
    <path d="M12 .5C5.7.5.5 5.7.5 12c0 5.1 3.3 9.4 7.9 10.9.6.1.8-.3.8-.6v-2c-3.2.7-3.9-1.5-3.9-1.5-.5-1.3-1.3-1.7-1.3-1.7-1-.7.1-.7.1-.7 1.2.1 1.8 1.2 1.8 1.2 1 1.8 2.7 1.3 3.4 1 .1-.8.4-1.3.8-1.6-2.6-.3-5.3-1.3-5.3-5.7 0-1.3.4-2.3 1.2-3.1-.1-.3-.5-1.5.1-3.1 0 0 1-.3 3.2 1.2.9-.3 1.9-.4 2.9-.4 1 0 2 .1 2.9.4 2.2-1.5 3.2-1.2 3.2-1.2.6 1.6.2 2.8.1 3.1.7.8 1.2 1.8 1.2 3.1 0 4.4-2.7 5.4-5.3 5.7.4.4.8 1.1.8 2.2v3.3c0 .3.2.7.8.6 4.6-1.5 7.9-5.8 7.9-10.9C23.5 5.7 18.3.5 12 .5z"/>
  </svg>
);

// ── small helpers ───────────────────────────────────────────────────────────
function CopyBtn({ text, label = 'copy', minimal = false }) {
  const [copied, setCopied] = useState(false);
  const onClick = async () => {
    try {
      await navigator.clipboard.writeText(text);
    } catch (e) {
      // fallback
      const ta = document.createElement('textarea');
      ta.value = text; document.body.appendChild(ta); ta.select();
      try { document.execCommand('copy'); } catch (_) {}
      document.body.removeChild(ta);
    }
    setCopied(true);
    setTimeout(() => setCopied(false), 1200);
  };
  if (minimal) {
    return (
      <button className={'cg-cp' + (copied ? ' copied' : '')} onClick={onClick} title="copy">
        {copied ? <IconCheck /> : <IconCopy />}
      </button>
    );
  }
  return (
    <button className={'copy-btn' + (copied ? ' copied' : '')} onClick={onClick}>
      {copied ? <IconCheck /> : <IconCopy />}
      {copied ? 'copied' : label}
    </button>
  );
}

// ── header & footer ─────────────────────────────────────────────────────────
function Header({ tagline, view, onReport, onSort }) {
  return (
    <header className="app">
      <div className="brand">
        <a
          href="https://indifferentketchup.com"
          target="_blank"
          rel="noopener noreferrer"
          aria-label="indifferent ketchup"
          className="brand-mark-link"
        >
          <img
            src="/img/ketchup_bottle.png"
            alt=""
            className="brand-mark"
            width="40"
            height="40"
          />
        </a>
        <span className="wordmark">sortof<span className="dot">.</span></span>
        {tagline && <span className="tagline">{tagline}</span>}
      </div>
      <div className="head-right">
        {view === 'reports' ? (
          <button type="button" className="icon-btn report" onClick={onSort}>
            ← back to sort
          </button>
        ) : (
          <button type="button" className="icon-btn report" onClick={onReport}>
            ⚠ report broken mod
          </button>
        )}
        <a className="icon-btn" href="#" onClick={(e) => e.preventDefault()}>
          <IconGit /> github
        </a>
        <a className="icon-btn" href="#" onClick={(e) => e.preventDefault()}>
          docs
        </a>
      </div>
    </header>
  );
}

function Footer() {
  return (
    <footer className="app">
      <div className="left">
        <span>based on{' '}
          <a className="footer-link footer-link-info"
             href="https://steamcommunity.com/sharedfiles/filedetails/?id=3423660713"
             target="_blank" rel="noopener noreferrer">
            REfRigERatoR's mod load order sorter
          </a>
        </span>
      </div>
      <div className="right">
        <span>
          a thing by{' '}
          <a className="footer-link footer-link-brand"
             href="https://indifferentketchup.com"
             target="_blank" rel="noopener noreferrer">
            indifferent ketchup <span className="ik-mark" aria-hidden="true">(:|)</span>
          </a>
        </span>
      </div>
    </footer>
  );
}

// ── empty-state variants ────────────────────────────────────────────────────
function EmptyRight({ variant }) {
  if (variant === 'bare') {
    return (
      <div className="right-empty">
        <span className="big">no mods. or maybe loads of them. hard to tell.</span>
        <span><span className="arrow">›</span> paste workshop ids on the left, hit sort. output lands here.</span>
      </div>
    );
  }
  if (variant === 'worked') {
    return (
      <div className="right-empty">
        <span className="big">what you'll get back</span>
        <div className="ex">
          <div><span style={{ color: 'var(--acc-blue)' }}>WorkshopItems</span>=2169435993;2392709985;…
          </div>
          <div><span style={{ color: 'var(--acc-blue)' }}>Mods</span>=modoptions;tsarslib;…</div>
          <div><span style={{ color: 'var(--acc-blue)' }}>Map</span>=Muldraugh, KY</div>
          <div className="ink-mut" style={{ marginTop: 6, fontSize: 11.5 }}>↑ paste these into your server's .ini</div>
        </div>
      </div>
    );
  }
  // docs
  return (
    <div className="docs-panel">
      <div className="dp-head">
        <span>how this works</span>
        <span style={{ color: 'var(--fg-2)', fontSize: 11.5 }}>read once, never again</span>
      </div>
      <div className="dp-body">
        <ol>
          <li>paste workshop ids - newlines, semicolons, commas, or urls all work. one mod per id.</li>
          <li>optionally provide <code>sorting_rules.txt</code> to pin libs first / maps last / mark incompatibles.</li>
          <li>hit sort. we resolve dependencies, apply your rules, and emit three lines for your server's <code>.ini</code>.</li>
          <li>copy each line. paste. boot the server. probably works.</li>
        </ol>
        <div className="hint">we cache mod metadata until the author publishes an update. cold-cache lookups take roughly 30s. you've been warned.</div>
      </div>
    </div>
  );
}

// ── status strip ────────────────────────────────────────────────────────────
function StatusStrip({ state, counts, progress }) {
  // a11y: each pill emits (color, glyph, text) - never color alone. The glyph
  // is hidden from screen readers (the surrounding text is the actual info)
  // and the dot-led is replaced by a state-specific block-circle/arrow char.
  const pill = (cls, glyph, label, title) => (
    <span className={'status-pill ' + cls} title={title || undefined}>
      <span className="status-glyph" aria-hidden="true">{glyph}</span>
      <span className="status-label">{label}</span>
    </span>
  );

  // State→(class, glyph, label) per brief mapping. Class names map to CSS
  // rules below that pin each state to its semantic color (--success/--info/
  // --warning/--error/--fg-muted) - color, glyph, AND label always together.
  if (state === 'idle' || state === 'success' || state === 'error' || state === 'cold' || state === 'done' || state === 'failed') {
    if (state === 'idle')    return <div className="status-strip">{pill('idle',    '●', 'idle')}</div>;
    if (state === 'success' || state === 'done')
                              return <div className="status-strip">{pill('done',    '✓', `done. ${counts.cached} mods, ${counts.warnings} warnings`)}</div>;
    if (state === 'error')   return <div className="status-strip">{pill('failed',  '✗', 'failed. something went sideways')}</div>;
    if (state === 'failed')  return <div className="status-strip">{pill('failed',  '✗', "failed. that didn't work")}</div>;
    if (state === 'cold')    return <div className="status-strip">{pill('warning', '⚠', 'cache miss. cold-cache lookups take ~30s')}</div>;
  }

  if (state === 'expanding') {
    return (
      <div className="status-strip">
        {pill('working', '⏵', 'working. expanding collection…')}
        <div className="progress-bar"><i style={{ width: `${progress}%` }}></i></div>
      </div>
    );
  }

  // 'queued' / 'draining' / legacy 'partial' / legacy 'loading' - live counts.
  return (
    <div className="status-strip">
      {pill('done',     '✓', `${counts.cached} cached`)}
      {pill('queued',   '●', `${counts.queued} queued`)}
      {pill('draining', '⏵', `${counts.parsing} draining`)}
      {counts.unknown > 0 && pill('failed',  '✗', `${counts.unknown} unknown`,
        "Steam doesn't recognize these IDs (deleted, typo'd, or private)")}
      {counts.nonMod > 0 && pill('muted',   '−', `${counts.nonMod} non-mod`,
        "Workshop items that aren't loadable mods (collections, art, etc.)")}
      <div className="progress-bar"><i style={{ width: `${progress}%` }}></i></div>
    </div>
  );
}

// ── output: three success variants ──────────────────────────────────────────
function SuccessStacked({ data, pzBuild }) {
  const modsLine = buildModsLine(D.SORTED_ORDER, pzBuild);
  return (
    <>
      <div className="code-block">
        <div className="cb-head">
          <span><span className="cb-key">WorkshopItems</span> <span className="cb-meta">· {data.count} ids</span></span>
          <CopyBtn text={`WorkshopItems=${D.WORKSHOP_ITEMS_LINE}`} />
        </div>
        <pre><span className="ink-key">WorkshopItems</span><span className="ink-sep">=</span>{D.WORKSHOP_ITEMS_LINE}</pre>
      </div>
      <div className="code-block">
        <div className="cb-head">
          <span><span className="cb-key">Mods</span> <span className="cb-meta">· {data.count} mods · {pzBuild}</span></span>
          <CopyBtn text={`Mods=${modsLine}`} />
        </div>
        <pre><span className="ink-key">Mods</span><span className="ink-sep">=</span>{modsLine}</pre>
      </div>
      <div className="code-block">
        <div className="cb-head">
          <span><span className="cb-key">Map</span> <span className="cb-meta">· 1 map detected</span></span>
          <CopyBtn text={`Map=${D.MAP_LINE}`} />
        </div>
        <pre><span className="ink-key">Map</span><span className="ink-sep">=</span>{D.MAP_LINE}</pre>
      </div>
    </>
  );
}

function SuccessCompact({ data, pzBuild }) {
  const modsLine = buildModsLine(D.SORTED_ORDER, pzBuild);
  return (
    <div className="compact-grid">
      <div className="cg-row">
        <div className="cg-key">WorkshopItems</div>
        <div className="cg-val">{D.WORKSHOP_ITEMS_LINE}</div>
        <CopyBtn text={`WorkshopItems=${D.WORKSHOP_ITEMS_LINE}`} minimal />
      </div>
      <div className="cg-row">
        <div className="cg-key">Mods <span className="cg-build">· {pzBuild}</span></div>
        <div className="cg-val">{modsLine}</div>
        <CopyBtn text={`Mods=${modsLine}`} minimal />
      </div>
      <div className="cg-row">
        <div className="cg-key">Map</div>
        <div className="cg-val">{D.MAP_LINE}</div>
        <CopyBtn text={`Map=${D.MAP_LINE}`} minimal />
      </div>
    </div>
  );
}

function SuccessNumbered({ data, pzBuild }) {
  const modsLine = buildModsLine(D.SORTED_ORDER, pzBuild);
  const blocks = [
    { num: '01', key: 'WorkshopItems', val: D.WORKSHOP_ITEMS_LINE, meta: `${data.count} ids · ${(D.WORKSHOP_ITEMS_LINE.length)} chars` },
    { num: '02', key: 'Mods',          val: modsLine,              meta: `${data.count} mods · ${pzBuild}` },
    { num: '03', key: 'Map',           val: D.MAP_LINE,            meta: '1 map' },
  ];
  return (
    <>
      {blocks.map(b => (
        <div key={b.key} className="numbered-block">
          <div className="nb-head">
            <span className="nb-num">{b.num}</span>
            <span className="nb-key">{b.key}</span>
            <span className="nb-meta">{b.meta}</span>
          </div>
          <pre className="nb-pre">{b.val}</pre>
          <div className="nb-cp">
            <CopyBtn text={`${b.key}=${b.val}`} />
          </div>
        </div>
      ))}
    </>
  );
}

// ── warnings ─────────────────────────────────────────────────────────────────
// Render a warning message with the leading mod name turned into a Workshop
// hyperlink. Backend warnings start with "<modId> ..." (or "Mod '<modId>' ..."
// for duplicate-mod_id). When `wsid` is provided, the first identifier of the
// message becomes the link target; the rest renders as plain text.
function WarnMsg({ msg, wsid }) {
  if (!wsid || !msg) return <span className="w-msg">{msg}</span>;
  // Match "<word>" at start (e.g., "ModId requires X"), or "Mod '<word>'"
  // (duplicate-mod_id phrasing). Bare-id form is the common case.
  const m = msg.match(/^(Mod ')([^']+)(')(.*)$/) || msg.match(/^([^\s]+)(.*)$/);
  if (!m) return <span className="w-msg">{msg}</span>;
  if (m.length === 5) {
    const [, lead, name, close, rest] = m;
    return (
      <span className="w-msg">
        {lead}
        <a href={wsidUrl(wsid)} target="_blank" rel="noopener noreferrer"
           className="w-msg-link" title={`open Workshop page for ${wsid}`}>{name}</a>
        {close}{rest}
      </span>
    );
  }
  const [, name, rest] = m;
  return (
    <span className="w-msg">
      <a href={wsidUrl(wsid)} target="_blank" rel="noopener noreferrer"
         className="w-msg-link" title={`open Workshop page for ${wsid}`}>{name}</a>
      {rest}
    </span>
  );
}

// True when this action's intended effect is already present in the current
// input — i.e., the user has staged this change but hasn't sorted yet. Drives
// the per-button ✓-pending label and the row-level strikethrough.
function actionStaged(a, inputWsids) {
  if (a.type === 'add-wsid')    return inputWsids.has(String(a.wsid));
  if (a.type === 'remove-wsid') return !inputWsids.has(String(a.wsid));
  if (a.type === 'swap-wsid')   return !inputWsids.has(String(a.from)) && inputWsids.has(String(a.to));
  return false;
}

function WarnRow({ w, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggleBranch }) {
  // Branch picker collapses by default; click "pick another" chev to expand.
  const [branchOpen, setBranchOpen] = useState(false);
  // Any warning that ships an `alternatives` array gets an inline picker.
  // Covers auto-picked-branch (mutually exclusive primary pick) AND
  // unmatched-addons (additive addon ticking) — both drive the same UI.
  const hasPicker = Array.isArray(w.alternatives);
  // Row is staged when ANY mutating action's effect is satisfied by the
  // current input. Per-button labels flip independently below.
  const rowStaged = (Array.isArray(w.actions) ? w.actions : []).some(a => actionStaged(a, inputWsids));
  return (
    <li className={rowStaged ? 'staged' : undefined}>
      <span className={'w-tag ' + w.level}>
        <span aria-hidden="true" className="w-tag-glyph">{w.level === 'red' ? '!' : '⚠'}</span>
        {w.tag}
      </span>
      <div className="w-content">
        <WarnMsg msg={w.msg} wsid={w.wsid} />
        {Array.isArray(w.actions) && w.actions.map((a, j) => {
          if (a.type === 'add-wsid') {
            const staged = actionStaged(a, inputWsids);
            return (
              <button
                key={j}
                type="button"
                className={'warn-action' + (staged ? ' staged' : '')}
                onClick={() => onAddWsid && onAddWsid(a.wsid, a.modId)}
                title={staged ? `click to undo (will remove ${a.wsid})` : `add ${a.wsid} to your input — click sort when ready`}
              >{staged ? `✓ ${a.label}` : a.label}</button>
            );
          }
          if (a.type === 'swap-wsid') {
            const staged = actionStaged(a, inputWsids);
            return (
              <button
                key={j}
                type="button"
                className={'warn-action swap' + (staged ? ' staged' : '')}
                onClick={() => onSwapWsid && onSwapWsid(a.from, a.to)}
                title={staged ? `click to undo (will swap back to ${a.from})` : `replace ${a.from} with ${a.to} — click sort when ready`}
              >{staged ? '✓' : '↔'} {a.label}</button>
            );
          }
          if (a.type === 'remove-wsid') {
            const staged = actionStaged(a, inputWsids);
            return (
              <button
                key={j}
                type="button"
                className={'warn-action remove' + (staged ? ' staged' : '')}
                onClick={() => onRemoveWsid && onRemoveWsid(a.wsid)}
                title={staged ? `click to undo (will re-add ${a.wsid})` : `remove ${a.wsid} from your input — click sort when ready`}
              >{staged ? '✓' : '✕'} {a.label}</button>
            );
          }
          if (a.type === 'search-workshop') {
            return (
              <a
                key={j}
                href={a.url}
                target="_blank"
                rel="noopener noreferrer"
                className="warn-action search"
                title={`search Steam Workshop for ${a.modId}`}
              >↗ {a.label}</a>
            );
          }
          return null;
        })}
        {hasPicker && (
          <>
            <button
              type="button"
              className="warn-action expand"
              onClick={() => setBranchOpen(b => !b)}
              title={w.tag === 'unmatched-addons' ? 'tick addons to include' : 'pick a different branch'}
            >{branchOpen ? '▾ branches' : '▸ branches'}</button>
            {branchOpen && (
              <div className="warn-branches">
                {w.alternatives.map(modId => {
                  // Two picker modes drive the same UI:
                  //   auto-picked-branch → radio (one of N), `picked` is the
                  //     currently-active mod_id; clicking switches.
                  //   unmatched-addons → checkbox-additive; the primary
                  //     (`picked`) stays loaded; clicking an addon toggles it
                  //     in/out of the selection. Active mod_ids come from
                  //     SORTED_ORDER so the ★ reflects the live state.
                  const isAdditive = w.tag === 'unmatched-addons';
                  const activeSet = new Set(D.SORTED_ORDER || []);
                  const isActive = isAdditive ? activeSet.has(modId) : modId === w.picked;
                  const isPrimary = modId === w.picked;
                  return (
                    <button
                      key={modId}
                      type="button"
                      className={'warn-branch-btn' + (isActive ? ' picked' : '')}
                      onClick={() => {
                        if (isAdditive) {
                          // Primary always stays loaded - no-op on clicking it.
                          if (isPrimary) return;
                          onToggleBranch && onToggleBranch(
                            w.wsid, modId,
                            w.alternatives.map(id => ({ modId: id })),
                          );
                        } else {
                          if (isActive) return;
                          onPickBranch && onPickBranch(w.wsid, modId, w.alternatives);
                        }
                      }}
                      title={
                        isPrimary && isAdditive ? 'main (always loaded)' :
                        isActive ? (isAdditive ? 'addon enabled - click to remove' : 'currently selected (main)') :
                        isAdditive ? `enable ${modId}` : `switch to ${modId}`
                      }
                    >
                      {isActive ? '★ ' : ''}{modId}
                    </button>
                  );
                })}
              </div>
            )}
          </>
        )}
      </div>
    </li>
  );
}

// Auto-fix bar: 3 batched-action buttons that sit ABOVE the warnings panel.
// Each button stages every eligible action of its type in a single pass; sort
// is NOT fired (consistent with the staged-warning UI). A warning lives in
// exactly one bucket: missing-dep → Add, build-mismatch w/ swap → Fix,
// build-mismatch w/o swap → Remove.
function AutoFixBar({ items, inputWsids, onAddDeps, onFixMismatches, onRemoveIncorrect }) {
  if (!items || items.length === 0) return null;

  let addCount = 0, swapCount = 0, removeCount = 0;
  for (const w of items) {
    if (!Array.isArray(w.actions)) continue;
    if (w.tag === 'missing') {
      // Eligible if any add-wsid target isn't yet in input.
      if (w.actions.some(a => a.type === 'add-wsid' && a.wsid && !inputWsids.has(String(a.wsid)))) {
        addCount++;
      }
    } else if (w.tag === 'build-mismatch') {
      const swap = w.actions.find(a => a.type === 'swap-wsid' && a.from && a.to);
      if (swap) {
        // Eligible if from is present and to is absent.
        if (inputWsids.has(String(swap.from)) && !inputWsids.has(String(swap.to))) {
          swapCount++;
        }
      } else {
        const rem = w.actions.find(a => a.type === 'remove-wsid' && a.wsid);
        if (rem && inputWsids.has(String(rem.wsid))) {
          removeCount++;
        }
      }
    }
  }

  if (addCount === 0 && swapCount === 0 && removeCount === 0) return null;

  return (
    <div className="auto-fix-bar" role="group" aria-label="auto-fix actions">
      {addCount > 0 && (
        <button
          type="button"
          className="auto-fix-btn add"
          onClick={onAddDeps}
          title="stage every missing dependency. click sort when ready."
        >+ add {addCount} {addCount === 1 ? 'dependency' : 'dependencies'}</button>
      )}
      {swapCount > 0 && (
        <button
          type="button"
          className="auto-fix-btn swap"
          onClick={onFixMismatches}
          title="stage every build-mismatch swap. click sort when ready."
        >↔ fix {swapCount} mismatched build{swapCount === 1 ? '' : 's'}</button>
      )}
      {removeCount > 0 && (
        <button
          type="button"
          className="auto-fix-btn remove"
          onClick={onRemoveIncorrect}
          title="stage removal of build-mismatch mods with no B42 successor. click sort when ready."
        >✕ remove {removeCount} incorrect build{removeCount === 1 ? '' : 's'}</button>
      )}
    </div>
  );
}

function Warnings({ items, defaultOpen = true, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onToggleBranch }) {
  const [open, setOpen] = useState(defaultOpen);
  if (!items || items.length === 0) return null;
  const reds = items.filter(w => w.level === 'red').length;
  const ambers = items.length - reds;
  return (
    <div className="panel warn-section">
      <div className="warn-head" onClick={() => setOpen(!open)}>
        <span>warnings</span>
        {reds > 0 && (
          <span className="badge red" title={`${reds} error warning${reds === 1 ? '' : 's'}`}>
            <span aria-hidden="true">!</span> {reds}
          </span>
        )}
        {ambers > 0 && (
          <span className="badge" title={`${ambers} caution warning${ambers === 1 ? '' : 's'}`}>
            <span aria-hidden="true">⚠</span> {ambers}
          </span>
        )}
        <span style={{ color: 'var(--fg-2)', fontSize: 11.5 }}>
          {reds > 0 ? 'fix these or your server will sulk' : 'non-fatal but read them'}
        </span>
        <span className="chev" aria-hidden="true">{open ? '▾' : '▸'}</span>
      </div>
      {open && (
        <ul className="warn-list">
          {items.map((w, i) => (
            <WarnRow key={i} w={w} inputWsids={inputWsids} onAddWsid={onAddWsid} onPickBranch={onPickBranch} onSwapWsid={onSwapWsid} onRemoveWsid={onRemoveWsid} onToggleBranch={onToggleBranch} />
          ))}
        </ul>
      )}
    </div>
  );
}

function BranchPicker({ wsid, branches, selected, userTouched, onToggle }) {
  const radio = isRadioMode(branches);
  const inputType = radio ? 'radio' : 'checkbox';
  // Default = whichever branch mod_ids are in SORTED_ORDER (set by backend
  // rules per Spec C §4). User-touched selections override.
  const activeSet = new Set(D.SORTED_ORDER || []);
  const effective = userTouched ? selected : defaultSelectionForBranches(branches, activeSet);
  return (
    <tr>
      <td colSpan={COLUMN_COUNT} className="branch-panel">
        <div className="branch-panel-inner">
          <div className="branch-panel-meta">
            workshop {wsid} · {branches.length} mod_ids · {radio ? 'pick one' : 'multi-select'}
          </div>
          {branches.map(b => {
            const checked = effective.includes(b.modId);
            return (
              <label key={b.modId} className={'branch-row' + (checked ? ' is-checked' : '')}>
                <input
                  type={inputType}
                  name={radio ? `branch-${wsid}` : undefined}
                  className="branch-input"
                  checked={checked}
                  onChange={() => onToggle(wsid, b.modId, branches)}
                />
                <WsidLink wsid={wsid} className="branch-modid">{b.modId}</WsidLink>
                <span className="branch-name">{b.name}</span>
                <span className={'cat ' + b.cat}>{b.cat}</span>
                <span className="branch-deps">
                  {b.deps && b.deps.length ? b.deps.join(', ') : '-'}
                </span>
                <span className="branch-pos">
                  {b.pos === 'first' ? 'first' : (b.pos === 'last' ? 'last' : '-')}
                </span>
                {b.hint && <span className="branch-hint">{b.hint}</span>}
              </label>
            );
          })}
        </div>
      </td>
    </tr>
  );
}

// ── mod details table ───────────────────────────────────────────────────────
// Classify each mod_id into a row-state for the Mod Details table per Spec
// brief 2026-05-01. Pattern-matches the warning messages to derive which
// mod_id is the *source* of a warning (the leading token before `requires`,
// `marked`, `incompatible`, or in a cycle path). Imperfect but cheap; if the
// backend grows a `WARNINGS[].affected: [mod_ids]` field this collapses to
// a simple lookup.
function rowStateForMod(modId) {
  const warns = (D.WARNINGS || []);
  for (const w of warns) {
    if (!w || !w.msg) continue;
    const msg = w.msg;
    const m = msg.match(/^([A-Za-z0-9_+\-]{2,})\s+(requires|marked|and\b)/)
           || msg.match(/cycle:\s*([A-Za-z0-9_+\-]+)/);
    if (!m || m[1] !== modId) continue;
    if (w.level === 'red')   return 'error';
    if (w.level === 'amber') return 'warning';
  }
  return 'resolved';
}

function ModTable({ defaultOpen = false, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion }) {
  const [open, setOpen] = useState(defaultOpen);

  // Group MOD_DB rows by wsid. For wsids with >1 mod, render a single parent
  // row and an expansion <tr colSpan={COLUMN_COUNT}> with the picker.
  const groupedByWsid = useMemo(() => {
    const g = {};
    for (const m of (D.MOD_DB || [])) {
      const w = m.wsid || '';
      if (!w) continue;
      (g[w] = g[w] || []).push(m);
    }
    return g;
  }, [D.MOD_DB]);

  // Walk SORTED_ORDER and emit one row-spec per wsid (deduped).
  const rowSpecs = useMemo(() => {
    const specs = [];
    const seenWsid = new Set();
    for (const modId of (D.SORTED_ORDER || [])) {
      const m = (D.MOD_DB || []).find(x => x.modId === modId);
      if (!m || !m.wsid) continue;
      if (seenWsid.has(m.wsid)) continue;
      seenWsid.add(m.wsid);
      const branches = groupedByWsid[m.wsid] || [m];
      const isMulti = branches.length >= 2;
      specs.push({ wsid: m.wsid, primary: m, branches, isMulti });
    }
    // Wsids whose parent had zero selected mods: not in SORTED_ORDER. Append.
    // NB: Object.keys order for numeric strings - wsids < 2^32 enumerate
    // ascending, larger ones in insertion order. Spec §6 declares display
    // position for zero-tick rows implementation-defined.
    for (const w of Object.keys(groupedByWsid)) {
      if (!seenWsid.has(w)) {
        const branches = groupedByWsid[w];
        specs.push({ wsid: w, primary: branches[0], branches, isMulti: branches.length >= 2 });
      }
    }
    return specs;
  }, [D.SORTED_ORDER, D.MOD_DB, groupedByWsid]);

  return (
    <div className="panel table-section">
      <div className="tbl-head" onClick={() => setOpen(!open)}>
        <span>mod details</span>
        <span className="count">· {(D.SORTED_ORDER || []).length} mods</span>
        <span style={{ color: 'var(--fg-2)', fontSize: 11.5, marginLeft: 8 }}>
          why everything ended up where it did
        </span>
        <span className="chev">{open ? '▾' : '▸'}</span>
      </div>
      {open && (
        <table className="mods-table">
          <thead>
            <tr>
              <th>#</th>
              <th>mod id</th>
              <th>workshop id</th>
              <th>category</th>
              <th>dependencies</th>
              <th>load</th>
            </tr>
          </thead>
          <tbody>
            {rowSpecs.map((spec, i) => {
              const idx = String(i + 1).padStart(2, '0');
              if (!spec.isMulti) {
                const m = spec.primary;
                const rowState = rowStateForMod(m.modId);
                const stateGlyph = { resolved: '✓', warning: '⚠', error: '✗' }[rowState] || '·';
                return (
                  <tr key={spec.wsid} className={'mod-row mod-row-' + rowState}>
                    <td className="idx">
                      <span className="row-state-glyph" aria-hidden="true">{stateGlyph}</span>{idx}
                    </td>
                    <td><WsidLink wsid={spec.wsid} className="modid">{m.modId}</WsidLink></td>
                    <td><span className="wsid">{spec.wsid}</span></td>
                    <td><span className={'cat ' + m.cat}>{m.cat}</span></td>
                    <td><span className="deps">{m.deps && m.deps.length ? m.deps.join(', ') : '-'}</span></td>
                    <td>
                      {m.pos === 'first' && <span className="pos first">first</span>}
                      {m.pos === 'last'  && <span className="pos last">last</span>}
                      {!m.pos && <span className="pos">-</span>}
                    </td>
                  </tr>
                );
              }
              // Multi-branch wsid: parent row + (optional) expansion panel.
              const selected = branchSelections[spec.wsid] || [];
              const N = spec.branches.length;
              const X = selected.length;
              const userTouched = (spec.wsid in branchSelections);
              const affordance = userTouched ? `✓ ${X} of ${N}` : `▾ ${N} branches`;
              const firstSelected = spec.branches.find(b => selected.includes(b.modId)) || spec.branches[0];
              const showAsZero = userTouched && X === 0;
              const display = showAsZero ? null : firstSelected;
              const expanded = expandedWsids.has(spec.wsid);
              const rowState = display ? rowStateForMod(display.modId) : 'resolved';
              const stateGlyph = { resolved: '✓', warning: '⚠', error: '✗' }[rowState] || '·';
              return (
                <React.Fragment key={spec.wsid}>
                  <tr className={'mod-row mod-row-' + rowState}>
                    <td className="idx">
                      <span className="row-state-glyph" aria-hidden="true">{stateGlyph}</span>{idx}
                    </td>
                    <td>
                      <button className="branch-affordance"
                              aria-expanded={expanded}
                              onClick={() => onToggleExpansion(spec.wsid)}>
                        {affordance}
                      </button>
                    </td>
                    <td><span className="wsid">{spec.wsid}</span></td>
                    <td>{display ? <span className={'cat ' + display.cat}>{display.cat}</span> : '-'}</td>
                    <td>{display && display.deps && display.deps.length ? display.deps.join(', ') : '-'}</td>
                    <td>
                      {display && display.pos === 'first' && <span className="pos first">first</span>}
                      {display && display.pos === 'last'  && <span className="pos last">last</span>}
                      {(!display || !display.pos) && <span className="pos">-</span>}
                    </td>
                  </tr>
                  {expanded && (
                    <BranchPicker
                      wsid={spec.wsid}
                      branches={spec.branches}
                      selected={selected}
                      userTouched={userTouched}
                      onToggle={onToggleBranch}
                    />
                  )}
                </React.Fragment>
              );
            })}
          </tbody>
        </table>
      )}
    </div>
  );
}

// ── right column dispatcher ────────────────────────────────────────────────
function BuildToggle({ value, onChange }) {
  const opts = ['B41', 'B42'];
  return (
    <div className="build-toggle" role="radiogroup" aria-label="Project Zomboid build">
      <span className="build-toggle-label">build</span>
      {opts.map(o => (
        <button
          key={o}
          role="radio"
          aria-checked={value === o}
          className={'build-toggle-btn' + (value === o ? ' is-active' : '')}
          onClick={() => onChange(o)}
        >{o}</button>
      ))}
    </div>
  );
}

function RightColumn({ state, counts, progress, emptyVariant, successVariant, modTableDefault, pzBuild, setPzBuild, branchSelections, onToggleBranch, expandedWsids, onToggleExpansion, inputWsids, onAddWsid, onPickBranch, onSwapWsid, onRemoveWsid, onAutoFixAddDeps, onAutoFixSwaps, onAutoFixRemoves, onRetry, inputWsidList, diffOpen, setDiffOpen }) {
  // Phase-state mapping: legacy `success` ≈ B+F `done`; legacy `partial` ≈ B+F `queued`/`draining`.
  const isTerminalDone = state === 'success' || state === 'done';
  const isInflightPartial = state === 'partial' || state === 'queued' || state === 'draining';
  const showWarnings = isTerminalDone || isInflightPartial;
  const showOutputs = isTerminalDone || isInflightPartial;

  return (
    <>
      {/* Warnings ABOVE outputs (always-inline treatment). Show the full
       * list in BOTH terminal-done and inflight-partial: build-mismatch
       * and other tag-based warnings stabilize as soon as the wsids they
       * apply to are cached, so truncating to slice(0,1) was hiding load-
       * order issues from users mid-poll. */}
      {showWarnings && (
        <>
          <AutoFixBar
            items={D.WARNINGS}
            inputWsids={inputWsids}
            onAddDeps={onAutoFixAddDeps}
            onFixMismatches={onAutoFixSwaps}
            onRemoveIncorrect={onAutoFixRemoves}
          />
          <Warnings
            items={D.WARNINGS}
            defaultOpen={isTerminalDone}
            inputWsids={inputWsids}
            onAddWsid={onAddWsid}
            onPickBranch={onPickBranch}
            onSwapWsid={onSwapWsid}
            onRemoveWsid={onRemoveWsid}
            onToggleBranch={onToggleBranch}
          />
        </>
      )}

      {/* Cold cache banner */}
      {state === 'cold' && (
        <div className="cold-banner">
          <span className="err-tag"><span aria-hidden="true">❄</span> cold</span>
          <div className="err-msg">
            indexing <span style={{ color: 'var(--acc-info)' }}>{counts.queued} {counts.queued === 1 ? 'mod' : 'mods'}</span> we haven't seen before. try again in ~30s. or don't, we're not your boss.
          </div>
        </div>
      )}

      {/* Error banner. Reason pulled from the most-recent retry/red WARNING
          when present; otherwise a generic line. The Warnings panel above
          carries the full detail; this banner is just the headline + retry. */}
      {state === 'error' && (() => {
        const warns = (D.WARNINGS || []);
        const retryWarn = [...warns].reverse().find(w => w.tag === 'retry');
        const reason = retryWarn ? retryWarn.msg : 'something failed - check warnings above';
        const hasCached = (D.MOD_DB || []).length > 0;
        return (
          <div className="err-banner">
            <span className="err-tag"><span aria-hidden="true">⚠</span> err</span>
            <div className="err-msg">
              {reason}{hasCached ? ' · cached results below still valid' : ''}
              <div className="err-actions">
                <button type="button" className="copy-btn" onClick={() => onRetry && onRetry()}>retry</button>
              </div>
            </div>
          </div>
        );
      })()}

      {/* Output area */}
      {state === 'idle' && <EmptyRight variant={emptyVariant} />}
      {(state === 'loading' || state === 'expanding') && (
        <div className="right-empty">
          <span className="big">{state === 'expanding' ? 'expanding your collection…' : 'parsing your collection…'}</span>
          <span style={{ color: 'var(--fg-2)' }}>resolving dependencies. applying rules. probably fine.</span>
        </div>
      )}

      {showOutputs && (
        <>
          {isInflightPartial && (
            <div style={{
              fontFamily: 'var(--mono)', fontSize: 12, color: 'var(--acc-amber)',
              padding: '4px 2px', display: 'flex', gap: 8, alignItems: 'center'
            }}>
              <span className="dot-led" style={{ background: 'var(--acc-amber)' }}></span>
              showing cached subset · {counts.queued + (counts.parsing || 0)} mods still in flight
            </div>
          )}
          <div className="diff-toggle-row">
            <button
              type="button"
              className={'diff-toggle' + (diffOpen ? ' open' : '')}
              onClick={() => setDiffOpen(o => !o)}
              title="diff your input against the sorted output"
            >
              {diffOpen ? '▾ diff' : '▸ diff'}
            </button>
          </div>
          {diffOpen && (
            <DiffPanel
              inputWsids={inputWsidList}
              outputWsids={(D.WORKSHOP_ITEMS_LINE || '').replace(/;+$/, '').split(';').filter(Boolean)}
              onClose={() => setDiffOpen(false)}
            />
          )}
          <BuildToggle value={pzBuild} onChange={setPzBuild} />
          {successVariant === 'stacked'  && <SuccessStacked  data={{ count: D.SORTED_ORDER.length }} pzBuild={pzBuild} />}
          {successVariant === 'compact'  && <SuccessCompact  data={{ count: D.SORTED_ORDER.length }} pzBuild={pzBuild} />}
          {successVariant === 'numbered' && <SuccessNumbered data={{ count: D.SORTED_ORDER.length }} pzBuild={pzBuild} />}
        </>
      )}

      {/* Mod details - only when we have something to show */}
      {(isTerminalDone || isInflightPartial) && (
        <ModTable
          defaultOpen={modTableDefault}
          branchSelections={branchSelections}
          onToggleBranch={onToggleBranch}
          expandedWsids={expandedWsids}
          onToggleExpansion={onToggleExpansion}
        />
      )}
    </>
  );
}

// ── default tweak values ────────────────────────────────────────────────────
const TWEAK_DEFAULTS = /*EDITMODE-BEGIN*/{
  "emptyVariant": "docs",
  "successVariant": "stacked",
  "stateOverride": "auto",
  "showTagline": true,
  "modTableDefault": false,
  "accentHue": 155
}/*EDITMODE-END*/;

// ── reported broken mods ────────────────────────────────────────────────────

// Format an ISO timestamp as the literal `HH:MM MM-DD-YYYY` layout the user
// asked for. 24-hour, zero-padded, locale-independent (uses UTC components
// so timestamps are identical for everyone — there's no "your local time"
// disambiguation needed since the value is just a relative recency cue).
function fmtReportDate(iso) {
  if (!iso) return '';
  const d = new Date(iso);
  if (isNaN(d.getTime())) return iso;
  const pad = (n) => String(n).padStart(2, '0');
  const HH = pad(d.getUTCHours());
  const MM = pad(d.getUTCMinutes());
  const mo = pad(d.getUTCMonth() + 1);
  const da = pad(d.getUTCDate());
  const yy = d.getUTCFullYear();
  return `${HH}:${MM} ${mo}-${da}-${yy}`;
}

const _VOTED_KEY = 'sortof.brokenmod.voted';
function loadVoted() {
  try { return JSON.parse(localStorage.getItem(_VOTED_KEY) || '{}') || {}; }
  catch { return {}; }
}
function saveVoted(v) {
  try { localStorage.setItem(_VOTED_KEY, JSON.stringify(v)); } catch {}
}

function ReportedModsPanel({ onClose }) {
  const [versions, setVersions] = useState({});
  const [reports, setReports]   = useState([]);
  const [search, setSearch]     = useState('');
  const [wsidIn, setWsidIn]     = useState('');
  const [verIn, setVerIn]       = useState('');
  const [submitting, setSubmitting] = useState(false);
  const [error, setError]       = useState('');
  const [voted, setVoted]       = useState(loadVoted());

  // Initial load: fetch version registry + all reports.
  useEffect(() => {
    let cancelled = false;
    (async () => {
      try {
        const [vRes, rRes] = await Promise.all([
          fetch('/api/pz-versions').then(r => r.json()),
          fetch('/api/broken-mods').then(r => r.json()),
        ]);
        if (cancelled) return;
        setVersions(vRes || {});
        setReports(Array.isArray(rRes) ? rRes : []);
        // Default the dropdown to the first key (typically 'stable' or 'unstable').
        const firstKey = Object.keys(vRes || {})[0];
        if (firstKey) setVerIn(firstKey);
      } catch (e) {
        if (!cancelled) setError(`load failed: ${e?.message || e}`);
      }
    })();
    return () => { cancelled = true; };
  }, []);

  async function onSubmit(e) {
    e.preventDefault();
    setError('');
    const wsid = (wsidIn || '').trim();
    if (!/^\d+$/.test(wsid)) {
      setError('workshop id must be digits only');
      return;
    }
    if (!verIn) {
      setError('pick a version');
      return;
    }
    setSubmitting(true);
    try {
      const res = await fetch('/api/broken-mods', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ workshop_id: wsid, version: verIn }),
      });
      if (!res.ok) {
        const txt = await res.text();
        setError(`submit failed: ${res.status} ${txt}`);
        return;
      }
      setWsidIn('');
      // Refresh list so the upserted entry bubbles to the top.
      const list = await fetch('/api/broken-mods').then(r => r.json());
      setReports(Array.isArray(list) ? list : []);
    } catch (e) {
      setError(`network error: ${e?.message || e}`);
    } finally {
      setSubmitting(false);
    }
  }

  async function onVote(id, direction) {
    if (voted[id]) return; // already voted this row in either direction
    try {
      const res = await fetch(`/api/broken-mods/${id}/vote`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ direction }),
      });
      if (!res.ok) return;
      const counts = await res.json();
      setReports(prev => prev.map(r => r.id === id
        ? { ...r, upvotes: counts.upvotes, downvotes: counts.downvotes }
        : r));
      const next = { ...voted, [id]: direction };
      setVoted(next);
      saveVoted(next);
    } catch {}
  }

  const filtered = useMemo(() => {
    const q = (search || '').trim().toLowerCase();
    if (!q) return reports;
    return reports.filter(r =>
      (r.workshop_id || '').toLowerCase().includes(q) ||
      (r.mod_name || '').toLowerCase().includes(q),
    );
  }, [reports, search]);

  return (
    <main className="reports-panel">
      <div className="reports-head">
        <div className="reports-title">Reported broken mods</div>
        <div className="reports-blurb">
          add a workshop id + which PZ build it broke on. duplicates of the
          same (id, version) bubble back to the top with their existing votes.
        </div>
        <form className="reports-form" onSubmit={onSubmit}>
          <input
            className="field"
            type="text"
            placeholder="workshop id (e.g. 2613146550)"
            value={wsidIn}
            onChange={(e) => setWsidIn(e.target.value)}
            inputMode="numeric"
            spellCheck={false}
          />
          <select
            className="field"
            value={verIn}
            onChange={(e) => setVerIn(e.target.value)}
          >
            {Object.entries(versions).map(([k, v]) => (
              <option key={k} value={k}>{v} ({k})</option>
            ))}
          </select>
          <button
            type="submit"
            className="sort-btn"
            disabled={submitting || !wsidIn.trim() || !verIn}
          >
            {submitting ? '…submitting' : '+ report'}
          </button>
        </form>
        {error && <div className="reports-error">{error}</div>}
        <input
          className="field reports-search"
          type="text"
          placeholder="search by workshop id or mod name…"
          value={search}
          onChange={(e) => setSearch(e.target.value)}
        />
        <div className="reports-meta">
          {filtered.length} {filtered.length === 1 ? 'report' : 'reports'}
          {search && reports.length !== filtered.length
            ? ` (of ${reports.length})` : ''}
        </div>
      </div>
      <ul className="reports-list">
        {filtered.length === 0 && (
          <li className="reports-empty">no reports yet — be the first.</li>
        )}
        {filtered.map(r => {
          const v = voted[r.id]; // 'up' | 'down' | undefined
          const verLabel = versions[r.version] || r.version;
          return (
            <li key={r.id} className="report-row">
              <div className="report-name">
                <WsidLink wsid={r.workshop_id}>
                  {r.mod_name || r.workshop_id}
                </WsidLink>
              </div>
              <div className="report-wsid">{r.workshop_id}</div>
              <div className="report-ver">
                <span className="cat ver-pill" title={r.version}>{verLabel}</span>
              </div>
              <div className="report-date">{fmtReportDate(r.updated_at)}</div>
              <div className="report-votes">
                <button
                  type="button"
                  className={'vote-btn up' + (v === 'up' ? ' voted' : '')}
                  onClick={() => onVote(r.id, 'up')}
                  disabled={!!v}
                  title={v === 'up' ? 'you upvoted' : 'agree (broken)'}
                >▲ {r.upvotes}</button>
                <button
                  type="button"
                  className={'vote-btn down' + (v === 'down' ? ' voted' : '')}
                  onClick={() => onVote(r.id, 'down')}
                  disabled={!!v}
                  title={v === 'down' ? 'you downvoted' : 'disagree (works for me)'}
                >▼ {r.downvotes}</button>
              </div>
            </li>
          );
        })}
      </ul>
    </main>
  );
}


// ── main app ────────────────────────────────────────────────────────────────
function App() {
  const [t, setTweak] = useTweaks(TWEAK_DEFAULTS);
  const [view, setView]   = useState('sort');           // sort | reports
  const [state, setState] = useState('idle');           // idle | loading | partial | success | error | cold
  const [counts, setCounts] = useState({ cached: 0, queued: 0, parsing: 0, warnings: 0, unknown: 0, nonMod: 0 });
  const [progress, setProgress] = useState(0);
  const [input, setInput] = useState(D.SAMPLE_INPUT);
  const [rules, setRules] = useState('');
  const [pzBuild, setPzBuild] = useState(() => {
    try { return localStorage.getItem('sortof.pzBuild') || 'B41'; }
    catch { return 'B41'; }
  });
  const [branchSelections, setBranchSelections] = useState(() => {
    try {
      const raw = localStorage.getItem('sortof.branch.selections');
      if (!raw) return {};
      const parsed = JSON.parse(raw);
      return (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) ? parsed : {};
    } catch { return {}; }
  });
  const latestResortSeqRef = useRef(0);  // monotonic; for stale-response drop
  // Tracks the input string at the moment the most recent /api/sort response
  // arrived. Drives the sort-button "pending" cue and the per-warning staged
  // strikethrough: when current `input` !== this ref, the user has staged
  // edits via warning action buttons (or manual textarea typing) since their
  // last sort. Stays null until the first successful sort.
  const lastSortedInputRef = useRef(null);
  // Contract: WORKSHOP_ITEMS_LINE, counts.queued, unknown[], non_mod[] are owned
  // by /api/sort's response for the lifetime of that result set. /api/resort
  // returns them but the client ignores those fields - wsid subscription and
  // ID-classification are independent of branch selection (spec §8). Captured
  // here at /api/sort time, applied on every /api/resort response. Do not
  // "fix" this back to using resort's fields.
  const sortContextRef = useRef({ workshopItemsLine: '', originalQueued: 0, unknown: [], nonMod: [] });
  const pollAbortRef = useRef(null);
  const [activeJobId, setActiveJobId] = useState(null);
  const [expandedWsids, setExpandedWsids] = useState(() => new Set());
  // Diff toggle. The diff is input-vs-output (textarea wsids vs sorted
  // WORKSHOP_ITEMS_LINE), recomputed live every render — no snapshot ref
  // needed. Always available, even on the first sort.
  const [diffOpen, setDiffOpen] = useState(false);
  // Ordered list of wsids currently in the input textarea (deduped, first-seen
  // order). The Set wrapper below is for warning rows that need O(1) `.has()`
  // lookup when deriving their staged state. Both are memoized off `input` so
  // re-renders triggered by unrelated state changes don't churn them.
  const inputWsidList = useMemo(() => parseWorkshopInput(input), [input]);
  const inputWsids = useMemo(() => new Set(inputWsidList), [inputWsidList]);
  // True when the user has staged edits since the last successful sort. Stays
  // false until the first sort completes (lastSortedInputRef starts null).
  const sortPending = lastSortedInputRef.current !== null && input !== lastSortedInputRef.current;

  const onToggleExpansion = (wsid) => {
    setExpandedWsids(prev => {
      const next = new Set(prev);
      if (next.has(wsid)) next.delete(wsid); else next.add(wsid);
      return next;
    });
  };

  const onToggleBranch = (wsid, modId, branches) => {
    setBranchSelections(prev => {
      const radio = isRadioMode(branches);
      const activeSet = new Set(D.SORTED_ORDER || []);
      const cur = prev[wsid] !== undefined
        ? prev[wsid]
        : defaultSelectionForBranches(branches, activeSet);
      let next;
      if (radio) {
        next = [modId];
      } else {
        next = cur.includes(modId)
          ? cur.filter(x => x !== modId)
          : [...cur, modId];
      }
      const updated = { ...prev, [wsid]: next };
      // Fire-and-forget: state set + resort dispatch.
      runResort(updated);
      return updated;
    });
  };

  // Append a wsid to the input textarea. Idempotent toggle: a second click
  // on the same [add wsid] button removes the wsid (undoes the stage).
  // Functional setInput so the auto-fix batch handlers can chain mutations
  // without stale-closure races. Sort is NOT fired here.
  const onAddWsid = (wsid /*, modId */) => {
    if (!wsid) return;
    setInput(prev => {
      const wid = String(wsid);
      return parseWorkshopInput(prev).includes(wid)
        ? _applyStripWsid(prev, wid)
        : _applyAppendWsid(prev, wid);
    });
  };

  // Remove a wsid from the input. Idempotent toggle: a second click re-appends.
  const onRemoveWsid = (wsid) => {
    if (!wsid) return;
    setInput(prev => {
      const w = String(wsid);
      return parseWorkshopInput(prev).includes(w)
        ? _applyStripWsid(prev, w)
        : _applyAppendWsid(prev, w);
    });
  };

  // Replace `from` with `to` in the input. Idempotent toggle: if input is
  // already in the swapped state (from absent, to present), reverse it.
  const onSwapWsid = (from, to) => {
    if (!from || !to) return;
    setInput(prev => {
      const f = String(from);
      const t = String(to);
      const existing = parseWorkshopInput(prev);
      if (!existing.includes(f) && existing.includes(t)) {
        // Reverse a previously-applied swap.
        return prev.replace(new RegExp(`(?<=^|[\\s;,])${t}(?=$|[\\s;,])`, 'g'), f);
      }
      return _applyEnsureSwapped(prev, f, t);
    });
  };

  // Auto-fix: stage every `add-wsid` action on `missing` warnings whose target
  // isn't yet in input. Single batched setInput so all adds land in one pass.
  // Sort is NOT fired — pending-cue + strikethroughs show the staged edits.
  const onAutoFixAddDeps = () => {
    const warns = (_liveSortData && _liveSortData.WARNINGS) || [];
    setInput(prev => {
      let working = prev;
      for (const w of warns) {
        if (w.tag !== 'missing' || !Array.isArray(w.actions)) continue;
        for (const a of w.actions) {
          if (a.type === 'add-wsid' && a.wsid) {
            working = _applyEnsureAdded(working, a.wsid);
          }
        }
      }
      return working;
    });
  };

  // Auto-fix: stage every `swap-wsid` action on `build-mismatch` warnings.
  // One swap per warning (warnings ship at most one swap action). Skips swaps
  // already in their applied state (from absent, to present).
  const onAutoFixSwaps = () => {
    const warns = (_liveSortData && _liveSortData.WARNINGS) || [];
    setInput(prev => {
      let working = prev;
      for (const w of warns) {
        if (w.tag !== 'build-mismatch' || !Array.isArray(w.actions)) continue;
        const swap = w.actions.find(a => a.type === 'swap-wsid' && a.from && a.to);
        if (!swap) continue;
        working = _applyEnsureSwapped(working, swap.from, swap.to);
      }
      return working;
    });
  };

  // Auto-fix: stage `remove-wsid` only on `build-mismatch` warnings that have
  // NO `swap-wsid` (i.e., no B42 successor exists — drop is the only option).
  // Warnings with both swap+remove are handled by onAutoFixSwaps and skipped here.
  const onAutoFixRemoves = () => {
    const warns = (_liveSortData && _liveSortData.WARNINGS) || [];
    setInput(prev => {
      let working = prev;
      for (const w of warns) {
        if (w.tag !== 'build-mismatch' || !Array.isArray(w.actions)) continue;
        if (w.actions.some(a => a.type === 'swap-wsid')) continue; // handled by Fix
        const rem = w.actions.find(a => a.type === 'remove-wsid' && a.wsid);
        if (!rem) continue;
        working = _applyEnsureRemoved(working, rem.wsid);
      }
      return working;
    });
  };

  // Branch-warning button: lock the wsid to the chosen mod_id and resort.
  // Always switches to a single selection (radio-style), since the warning
  // surfaces only the auto-pick case where one branch is canonical.
  const onPickBranch = (wsid, modId /*, alternatives */) => {
    setBranchSelections(prev => {
      const updated = { ...prev, [wsid]: [modId] };
      runResort(updated);
      return updated;
    });
  };

  // Retry: re-fire the current sort with the current input. The error
  // banner's [retry] button calls this. Also useful from the keyboard
  // shortcut path if we ever add one.
  const onRetry = () => onSort();

  async function runResort(nextSelections) {
    // Compose the flat list of selected mod_ids from MOD_DB + nextSelections.
    // For wsids not in nextSelections, use the §4 default (all-ticked or
    // first-only depending on radio mode). For wsids with N=1, include the
    // sole mod_id unconditionally.
    const byWsid = {};
    const sourceMods = (_liveSortData?.MOD_DB) || (window.SORTOF_DATA?.MOD_DB) || [];
    for (const m of sourceMods) {
      const w = m.wsid || '';
      if (!w) continue;
      (byWsid[w] = byWsid[w] || []).push(m);
    }
    const ids = [];
    for (const w of Object.keys(byWsid)) {
      const branches = byWsid[w];
      if (branches.length === 1) {
        ids.push(branches[0].modId);
        continue;
      }
      const stored = nextSelections[w];
      const activeSet = new Set(D.SORTED_ORDER || []);
      const eff = stored !== undefined ? stored : defaultSelectionForBranches(branches, activeSet);
      for (const id of eff) ids.push(id);
    }

    if (!ids.length) {
      // Nothing selected; no point hitting the API. Synthesize an empty payload
      // so the UI shows "0 of N" without dispatching a 400.
      const ctx = sortContextRef.current;
      _liveSortData = {
        ...(_liveSortData || {}),
        MOD_DB: [], SORTED_ORDER: [],
        MODS_LINE: '',
        WORKSHOP_ITEMS_LINE: ctx.workshopItemsLine,
        MAP_LINE: 'Muldraugh, KY',
        WARNINGS: [{ tag: 'selection', level: 'amber', msg: 'no mods selected - pick at least one branch' }],
        pending: [], status: 'success',
      };
      setCounts({
        cached: 0, queued: ctx.originalQueued, parsing: 0, warnings: 1,
        unknown: ctx.unknown.length, nonMod: ctx.nonMod.length,
      });
      return;
    }

    const seq = latestResortSeqRef.current + 1;
    latestResortSeqRef.current = seq;
    try {
      // Forward the original /api/sort input wsids so the backend can emit
      // wsid-keyed warnings (build-mismatch, conflict, required-items)
      // against the user's subscription set even when a mod_id has been
      // evicted to a different wsid in the cache (B41↔B42 pairs).
      const ctxWsids = (sortContextRef.current?.workshopItemsLine || '')
        .split(';').filter(Boolean);
      const res = await fetch('/api/resort', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json', 'X-Sortof-Seq': String(seq) },
        body: JSON.stringify({
          selected_mod_ids: ids,
          pz_build: pzBuild,
          input_wsids: ctxWsids,
        }),
      });
      // Drop stale: another resort has issued since this one started.
      if (seq < latestResortSeqRef.current) return;
      if (!res.ok) {
        console.error('resort failed', res.status);
        _liveSortData = {
          ...(_liveSortData || {}),
          WARNINGS: [
            ...((_liveSortData?.WARNINGS) || []),
            { tag: 'retry', level: 'red', msg: "couldn't recompute sort - try again" },
          ],
        };
        setCounts(c => ({ ...c, warnings: (c.warnings || 0) + 1 }));
        return;
      }
      const json = await res.json();
      const ctx = sortContextRef.current;
      // Spec A §8 ownership: keep the SET of wsids from the original /api/sort
      // (so toggling branches doesn't change subscription). But REORDER them
      // by the new sort - first mod's load position drives wsid placement.
      // Wsids without any selected mods (e.g., all branches deselected) keep
      // their relative input order at the tail. Same rule for unknown/non_mod.
      const inputWsids = (ctx.workshopItemsLine || '').split(';').filter(Boolean);
      const modById = {};
      for (const m of (json.MOD_DB || [])) modById[m.modId] = m;
      const wsidFirstPos = new Map();
      (json.SORTED_ORDER || []).forEach((modId, idx) => {
        const m = modById[modId];
        if (m && m.wsid && !wsidFirstPos.has(m.wsid)) wsidFirstPos.set(m.wsid, idx);
      });
      const known = inputWsids.filter(w => wsidFirstPos.has(w))
        .sort((a, b) => wsidFirstPos.get(a) - wsidFirstPos.get(b));
      const tail = inputWsids.filter(w => !wsidFirstPos.has(w));
      const reorderedWsl = inputWsids.length
        ? [...known, ...tail].join(';') + ';'
        : '';
      _liveSortData = { ...json, WORKSHOP_ITEMS_LINE: reorderedWsl };
      const cached = (json.MOD_DB || []).length;
      const warns  = (json.WARNINGS || []).length;
      setCounts({
        cached, queued: ctx.originalQueued, parsing: 0, warnings: warns,
        unknown: ctx.unknown.length, nonMod: ctx.nonMod.length,
      });
      setState(json.status || 'success');
    } catch (e) {
      console.error('resort threw', e);
      _liveSortData = {
        ...(_liveSortData || {}),
        WARNINGS: [
          ...((_liveSortData?.WARNINGS) || []),
          { tag: 'retry', level: 'red', msg: "couldn't recompute sort - try again" },
        ],
      };
      setCounts(c => ({ ...c, warnings: (c.warnings || 0) + 1 }));
    }
  }

  useEffect(() => {
    try { localStorage.setItem('sortof.pzBuild', pzBuild); } catch {}
  }, [pzBuild]);

  useEffect(() => {
    try {
      localStorage.setItem('sortof.branch.selections', JSON.stringify(branchSelections));
    } catch {}
  }, [branchSelections]);

  useEffect(() => {
    function onStorage(e) {
      if (e.key !== 'sortof.branch.selections' || e.newValue === null) return;
      try {
        const parsed = JSON.parse(e.newValue);
        if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
          setBranchSelections(parsed);
          runResort(parsed);
        }
      } catch {}
    }
    window.addEventListener('storage', onStorage);
    return () => window.removeEventListener('storage', onStorage);
  }, []);

  useEffect(() => {
    // Hydrate-driven resort: after the user submits and gets MOD_DB,
    // if branchSelections has entries, re-run sort against them.
    if (state === 'success' || state === 'partial' || state === 'done') {
      if (Object.keys(branchSelections).length > 0) {
        runResort(branchSelections);
      }
    }
    // eslint-disable-next-line
  }, [state]);

  const tagline = useMemo(() => {
    const taglines = [
      'sorted. sort of.',
      'your mods, sort of in order.',
      'a server-admin\u2019s tiny dignity.',
      'mostly correct, mostly the time.',
    ];
    return taglines[0];
  }, []);

  // accent override
  useEffect(() => {
    const root = document.documentElement;
    root.style.setProperty('--acc-green', `oklch(0.74 0.10 ${t.accentHue})`);
    root.style.setProperty('--acc-green-bg', `oklch(0.74 0.10 ${t.accentHue} / 0.12)`);
  }, [t.accentHue]);

  // state override (preview from Tweaks)
  useEffect(() => {
    if (t.stateOverride && t.stateOverride !== 'auto') {
      runState(t.stateOverride);
    }
    // eslint-disable-next-line
  }, [t.stateOverride]);

  const timersRef = useRef([]);
  const clearTimers = () => { timersRef.current.forEach(clearTimeout); timersRef.current = []; };

  function runState(target) {
    clearTimers();
    if (target === 'idle')    { setState('idle');    setProgress(0); setCounts({ cached: 0, queued: 0, parsing: 0, warnings: 0 }); return; }
    if (target === 'error')   { setState('error');   setProgress(100); setCounts({ cached: 7, queued: 0, parsing: 0, warnings: 1 }); return; }
    if (target === 'cold')    { setState('cold');    setProgress(45); setCounts({ cached: 0, queued: 3, parsing: 0, warnings: 0 }); return; }
    if (target === 'loading') { setState('loading'); setProgress(20); setCounts({ cached: 0, queued: 11, parsing: 1, warnings: 0 }); return; }
    if (target === 'partial') { setState('partial'); setProgress(70); setCounts({ cached: 7, queued: 4, parsing: 1, warnings: 1 }); return; }
    if (target === 'success') { setState('success'); setProgress(100); setCounts({ cached: 11, queued: 0, parsing: 0, warnings: D.WARNINGS.length }); return; }
  }

  async function onSort(inputOverride) {
    if (t.stateOverride !== 'auto') {
      // user is in preview mode; ignore real flow
      return;
    }
    // Allow callers (e.g., the missing-dep [add] button) to submit a freshly
    // computed input without waiting for setInput's async state update.
    // Type-check defensively: `onClick={onSort}` passes a React synthetic
    // event as arg 0 - reject anything that isn't a string and fall back
    // to the state input.
    const submitInput = typeof inputOverride === 'string' ? inputOverride : input;
    clearTimers();
    setState('loading');
    setProgress(15);
    setCounts({ cached: 0, queued: 0, parsing: 0, warnings: 0 });

    try {
      const res = await fetch('/api/sort', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ input: submitInput, rules, pz_build: pzBuild })
      });
      if (!res.ok) {
        const txt = await res.text();
        console.error('sort failed', res.status, txt);
        const detail = (() => { try { return JSON.parse(txt).detail || txt; } catch { return txt || `status ${res.status}`; } })();
        _liveSortData = {
          WARNINGS: [{ tag: 'retry', level: 'red', msg: `sort failed (${res.status}): ${detail}` }],
        };
        setState('error');
        setProgress(100);
        setCounts({ cached: 0, queued: 0, parsing: 0, warnings: 1 });
        return;
      }
      const json = await res.json();
      // Server accepted the input — clear the staged-edit pending cue. Both
      // sync and async paths fall through here; failure paths early-return
      // above and DO NOT update this ref (so the cue persists on retry).
      lastSortedInputRef.current = submitInput;
      if (json.job_id) {
        // Async path - start polling and let the loop drive state.
        if (pollAbortRef.current) { pollAbortRef.current.abort(); }
        const ctrl = new AbortController();
        pollAbortRef.current = ctrl;
        setActiveJobId(json.job_id);

        pollJobLoop(json.job_id, ctrl.signal, (r) => {
          if (r.kind !== 'ok') return;
          const b = r.body;
          if (b.result) {
            _liveSortData = b.result;
            sortContextRef.current = {
              workshopItemsLine: (b.result.WORKSHOP_ITEMS_LINE) || '',
              originalQueued: (b.result.pending || []).length,
              unknown: b.result.unknown || [],
              nonMod:  b.result.non_mod || [],
            };
          }
          const total = b.counts.cached + b.counts.queued + b.counts.draining;
          setProgress(
            b.phase === 'expanding'
              ? 5
              : 5 + Math.round(90 * b.counts.cached / Math.max(1, total))
          );
          // The legacy UI label is "parsing"; backend phase is "draining". Same thing.
          setCounts({
            cached: b.counts.cached,
            queued: b.counts.queued,
            parsing: b.counts.draining,
            warnings: ((b.result && b.result.WARNINGS) || []).length,
            unknown: ((b.result && b.result.unknown) || []).length,
            nonMod:  ((b.result && b.result.non_mod) || []).length,
          });
          setState(b.phase);
        }).then((final) => {
          setActiveJobId(null);
          pollAbortRef.current = null;
          if (final.kind === 'expired') {
            setState('error');
            _liveSortData = {
              ...(_liveSortData || {}),
              WARNINGS: [
                ...((_liveSortData?.WARNINGS) || []),
                { tag: 'retry', level: 'red', msg: 'this job expired - re-submit' },
              ],
            };
          } else if (final.kind === 'error') {
            setState('error');
            _liveSortData = {
              ...(_liveSortData || {}),
              WARNINGS: [
                ...((_liveSortData?.WARNINGS) || []),
                { tag: 'retry', level: 'red', msg: `polling failed (status ${final.status}) - re-submit` },
              ],
            };
          }
        });
        return;
      }
      // Sync fast path - existing code follows.
      _liveSortData = json;
      const unknownArr = json.unknown || [];
      const nonModArr  = json.non_mod || [];
      sortContextRef.current = {
        workshopItemsLine: json.WORKSHOP_ITEMS_LINE || '',
        originalQueued: (json.pending || []).length,
        unknown: unknownArr,
        nonMod: nonModArr,
      };
      const cached  = (json.MOD_DB || []).length;
      const queued  = (json.pending || []).length;
      const warns   = (json.WARNINGS || []).length;
      setProgress(100);
      setCounts({
        cached, queued, parsing: 0, warnings: warns,
        unknown: unknownArr.length, nonMod: nonModArr.length,
      });
      setState(json.status || 'success');
    } catch (e) {
      console.error('sort threw', e);
      _liveSortData = {
        WARNINGS: [{ tag: 'retry', level: 'red', msg: `network error: ${e && e.message ? e.message : String(e)}` }],
      };
      setState('error');
      setProgress(100);
      setCounts({ cached: 0, queued: 0, parsing: 0, warnings: 1 });
    }
  }

  function onClear() {
    clearTimers();
    setInput('');
    setRules('');
    setState('idle');
    setProgress(0);
    setCounts({ cached: 0, queued: 0, parsing: 0, warnings: 0 });
  }

  return (
    <div className="shell">
      <Header
        tagline={t.showTagline ? tagline : null}
        view={view}
        onReport={() => setView('reports')}
        onSort={() => setView('sort')}
      />

      {view === 'reports' && (
        <ReportedModsPanel onClose={() => setView('sort')} />
      )}

      {view === 'sort' && (
      <main className="app">
        {/* LEFT */}
        <section className="col">
          <div className="build-prepick">
            <BuildToggle value={pzBuild} onChange={setPzBuild} />
            <span className="build-prepick-hint">
              pick first - we'll flag mods tagged for the other build
            </span>
          </div>
          <div>
            <div className="label-row">
              <span className="label">workshop ids or collection url</span>
              <span className="label-meta">{parseWorkshopInput(input).length} ids</span>
            </div>
            <textarea
              className="field input-main"
              placeholder={"2169435993;2392709985;2487022075\nor newline-separated, or paste a steamcommunity.com URL"}
              value={input}
              onChange={(e) => setInput(e.target.value)}
              spellCheck={false}
            />
          </div>

          <details className="collapsible">
            <summary>sorting rules (optional)</summary>
            <div className="label-row">
              <span className="label">sorting_rules.txt</span>
              <span className="label-meta">{rules ? `${rules.split('\n').length} lines` : 'empty - defaults will apply'}</span>
            </div>
            <textarea
              className="field input-rules"
              placeholder={D.SAMPLE_RULES}
              value={rules}
              onChange={(e) => setRules(e.target.value)}
              spellCheck={false}
            />
          </details>

          <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
            <button
              type="button"
              className={'sort-btn' + (sortPending && state !== 'loading' ? ' sort-pending' : '')}
              onClick={onSort}
              disabled={state === 'loading' || !input.trim()}
              title={sortPending ? 'input has staged changes — click to apply' : undefined}
            >
              {state === 'loading'
                ? <span className="spin" aria-hidden="true"></span>
                : <span className="btn-glyph" aria-hidden="true">{(!input.trim()) ? '—' : '▶'}</span>}
              {state === 'loading' ? 'sorting…' : (sortPending ? 'sort •' : 'sort')}
            </button>
            {activeJobId && (
              <button
                type="button"
                className="cancel-btn"
                style={{ height: 40, padding: '0 14px', width: 'auto', marginTop: 0 }}
                onClick={() => {
                  const jobId = activeJobId;
                  if (pollAbortRef.current) pollAbortRef.current.abort();
                  setActiveJobId(null);
                  setState('idle');
                  setProgress(0);
                  setCounts({ cached: 0, queued: 0, parsing: 0, warnings: 0, unknown: 0, nonMod: 0 });
                  // Fire-and-forget DELETE; server handles idempotency.
                  fetch(`/api/jobs/${jobId}`, { method: 'DELETE' })
                    .catch((e) => console.warn('cancel DELETE failed', e));
                }}
              ><span aria-hidden="true">✗</span> cancel</button>
            )}
            {(input.trim() || state !== 'idle') && (
              <button type="button" className="clear-btn" onClick={onClear}>
                <span aria-hidden="true">✗</span> clear
              </button>
            )}
          </div>

          <StatusStrip state={state} counts={counts} progress={progress} />

          {(() => {
            // State indicator: color + glyph + label triple per brief mapping.
            const stateMap = {
              idle:      { cls: 'idle',      glyph: '●' },
              loading:   { cls: 'working',   glyph: '⏵' },
              expanding: { cls: 'working',   glyph: '⏵' },
              queued:    { cls: 'queued',    glyph: '●' },
              draining:  { cls: 'draining',  glyph: '⏵' },
              partial:   { cls: 'draining',  glyph: '⏵' },
              done:      { cls: 'done',      glyph: '✓' },
              success:   { cls: 'done',      glyph: '✓' },
              cold:      { cls: 'warning',   glyph: '⚠' },
              error:     { cls: 'failed',    glyph: '✗' },
              failed:    { cls: 'failed',    glyph: '✗' },
            };
            const m = stateMap[state] || stateMap.idle;
            return (
              <div className={'state-tag state-tag-' + m.cls}>
                <span className="state-tag-glyph" aria-hidden="true">{m.glyph}</span>
                state: {state}{t.stateOverride !== 'auto' ? ' (forced via tweaks)' : ''}
              </div>
            );
          })()}
        </section>

        {/* RIGHT */}
        <section className="col">
          <RightColumn
            state={state}
            counts={counts}
            progress={progress}
            emptyVariant={t.emptyVariant}
            successVariant={t.successVariant}
            modTableDefault={t.modTableDefault}
            pzBuild={pzBuild}
            setPzBuild={setPzBuild}
            branchSelections={branchSelections}
            onToggleBranch={onToggleBranch}
            expandedWsids={expandedWsids}
            onToggleExpansion={onToggleExpansion}
            inputWsids={inputWsids}
            onAddWsid={onAddWsid}
            onPickBranch={onPickBranch}
            onSwapWsid={onSwapWsid}
            onRemoveWsid={onRemoveWsid}
            onAutoFixAddDeps={onAutoFixAddDeps}
            onAutoFixSwaps={onAutoFixSwaps}
            onAutoFixRemoves={onAutoFixRemoves}
            onRetry={onRetry}
            inputWsidList={inputWsidList}
            diffOpen={diffOpen}
            setDiffOpen={setDiffOpen}
          />
        </section>
      </main>
      )}

      <Footer />

      {(typeof window !== 'undefined' &&
        new URLSearchParams(window.location.search).has('tweaks')) && (
        <TweaksPanel title="Tweaks">
          <TweakSection label="State preview" />
          <TweakSelect
            label="Force state"
            value={t.stateOverride}
            options={[
              { value: 'auto',    label: 'auto (drive via Sort)' },
              { value: 'idle',    label: 'idle / empty' },
              { value: 'loading', label: 'loading' },
              { value: 'partial', label: 'partial (cached + in-flight)' },
              { value: 'success', label: 'success' },
              { value: 'error',   label: 'error' },
              { value: 'cold',    label: 'cold cache miss' },
            ]}
            onChange={(v) => setTweak('stateOverride', v)}
          />
          <TweakButton label="run a fresh sort" onClick={() => { setTweak('stateOverride', 'auto'); setTimeout(onSort, 50); }} />

          <TweakSection label="Empty-state variant" />
          <TweakRadio
            label="Layout"
            value={t.emptyVariant}
            options={[
              { value: 'bare',   label: 'bare' },
              { value: 'worked', label: 'worked' },
              { value: 'docs',   label: 'docs' },
            ]}
            onChange={(v) => setTweak('emptyVariant', v)}
          />

          <TweakSection label="Success-state variant" />
          <TweakRadio
            label="Output style"
            value={t.successVariant}
            options={[
              { value: 'stacked',  label: 'stacked' },
              { value: 'compact',  label: 'compact' },
              { value: 'numbered', label: 'numbered' },
            ]}
            onChange={(v) => setTweak('successVariant', v)}
          />

          <TweakSection label="Chrome" />
          <TweakToggle  label="Show tagline"             value={t.showTagline}      onChange={(v) => setTweak('showTagline', v)} />
          <TweakToggle  label="Mod details open by default" value={t.modTableDefault} onChange={(v) => setTweak('modTableDefault', v)} />
          <TweakSlider  label="Accent hue (green band)" value={t.accentHue} min={120} max={170} step={1} onChange={(v) => setTweak('accentHue', v)} />
        </TweaksPanel>
      )}
    </div>
  );
}

ReactDOM.createRoot(document.getElementById('root')).render(<App />);
