// sortof-shared - D proxy, pure helpers, and leaf components
// Loaded before sortof-app.jsx; defines globals that App and extracted components reference.

window._liveSortData = null;
const D = new Proxy({}, {
  get(_, key) {
    const src = window._liveSortData ?? window.SORTOF_DATA;
    return src ? src[key] : undefined;
  }
});
// Make globally visible for sortof-app.jsx (loaded as separate script).
Object.assign(window, { D, _liveSortData: null, parseWorkshopInput, buildModsLine,
  _applyAppendWsid, _applyStripWsid, _applyEnsureAdded, _applyEnsureRemoved, _applyEnsureSwapped,
  pollJobOnce, pollJobLoop, isRadioMode, defaultSelectionForBranches, fullSelectionForWsid,
  COLUMN_COUNT, wsidUrl, WsidLink, IconCopy, IconCheck, IconGit, IconLink,
  CopyBtn, ShareBtn, rowStateForMod, WarnMsg, warnAccent, actionStaged, unknownRemovalWarnings,
  failedIndexWarnings });

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));
}

function buildModsLine(ids, mode) {
  if (!ids || !ids.length) return '';
  if (mode === 'B42') return '\\' + ids.join(';\\');
  return ids.join(';');
}

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;
  if (existing.includes(t)) return _applyStripWsid(input, f);
  return input.replace(new RegExp(`(?<=^|[\\s;,])${f}(?=$|[\\s;,])`, 'g'), t);
}

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();
  });
}

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];
}

function fullSelectionForWsid(wsid, selections) {
  if (selections && selections[wsid] !== undefined) return selections[wsid];
  const active = new Set(D.SORTED_ORDER || []);
  return (D.MOD_DB || [])
    .filter(m => String(m.wsid) === String(wsid) && active.has(m.modId))
    .map(m => m.modId);
}

const COLUMN_COUNT = 6;

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'}>{children ?? wsid}</a>;
}

function IconCopy() { return <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>; }

function IconCheck() { return <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"><polyline points="20 6 9 17 4 12"/></svg>; }

function IconGit() { return <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22"/></svg>; }

function IconLink() { return <svg aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>; }

function CopyBtn({ text, label, minimal }) {
  const [copied, setCopied] = React.useState(false);
  const doCopy = async () => {
    try { await navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1800); }
    catch { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px';
      document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta);
      setCopied(true); setTimeout(() => setCopied(false), 1800);
    }
  };
  if (minimal) return <button type="button" className={`copy-link ${copied ? 'copied' : ''}`} onClick={doCopy}><span className="visually-hidden">{copied ? 'copied' : 'copy'}</span>{copied ? <IconCheck /> : <IconLink />}</button>;
  return <button type="button" className={`pill-action${copied ? ' copied' : ''}`} onClick={doCopy}>{copied ? 'copied!' : 'copy'} <IconCopy /></button>;
}

function ShareBtn({ jobId }) {
  const [saving, setSaving] = React.useState(false);
  const [code, setCode] = React.useState(null);
  if (!jobId) return null;
  const onClick = async () => {
    setSaving(true); try {
      const res = await fetch(`/api/jobs/${jobId}/share`, { method: 'POST' });
      if (res.ok) { const json = await res.json(); setCode(json.code); } else { setCode('error'); }
    } catch { setCode('error'); } setSaving(false);
  };
  if (code) return <span className="share-code">sortof.indifferentketchup.com/s/{code}<CopyBtn text={`https://sortof.indifferentketchup.com/s/${code}`} minimal /></span>;
  return <button type="button" className="pill-action" onClick={onClick} disabled={saving}>{saving ? 'saving…' : 'share'}</button>;
}


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';
}

const _WARN_PRIORITY = { cycle: 10, error: 20, incompatible: 30, 'build-mismatch': 35, 'duplicate-mod_id': 37, missing: 40, 'missing-addon': 45, 'auto-picked-branch': 50, 'unmatched-addons': 55, 'rules-applied': 60, info: 70 };

function _warnSortKey(w) {
  return _WARN_PRIORITY[w.tag] ?? _WARN_PRIORITY.info;
}

function WarnMsg({ msg, wsid }) {
  if (!msg) return null;
  // modId -> wsid map so mod-name tokens in a message become Steam links too.
  const modMap = {};
  for (const m of (D.MOD_DB || [])) {
    if (m && m.modId && m.wsid && !(m.modId in modMap)) modMap[m.modId] = String(m.wsid);
  }
  const link = (key, href, cls, text) => (
    <a key={key} href={href} target="_blank" rel="noopener noreferrer" className={cls}>{text}</a>
  );
  // Split a run of text into plain strings, wsid links (7-12 digits, with ↗), and known-modId links.
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
  const modIds = Object.keys(modMap).sort((a, b) => b.length - a.length); // longest first so prefixes don't win
  const parts = ['\\b\\d{7,12}\\b'].concat(modIds.map(id => '\\b' + escapeRe(id) + '\\b'));
  const re = new RegExp('(' + parts.join('|') + ')', 'g');
  const tokenize = (text, keyBase) => {
    const out = [];
    text.split(re).forEach((piece, i) => {
      if (!piece) return;
      if (/^\d{7,12}$/.test(piece)) out.push(link(`${keyBase}-${i}`, wsidUrl(piece), 'w-msg-link', piece));
      else if (piece in modMap)    out.push(link(`${keyBase}-${i}`, wsidUrl(modMap[piece]), 'w-msg-title-link', piece));
      else                         out.push(piece);
    });
    return out;
  };
  // Messages shaped "<title> (<wsid>, ...)" make the leading mod title a link to the same item.
  // Split on the "(<wsid>" marker so titles containing parentheses stay intact.
  const marker = wsid ? '(' + String(wsid) : '';
  const idx = marker ? msg.indexOf(marker) : -1;
  const nodes = [];
  if (idx > 0) {
    const title = msg.slice(0, idx).replace(/\s+$/, '');
    nodes.push(link('title', wsidUrl(wsid), 'w-msg-title-link', title));
    nodes.push(...tokenize(msg.slice(title.length), 'rest'));
  } else {
    nodes.push(...tokenize(msg, 'm'));
  }
  return <span className="w-msg">{nodes}</span>;
}

function warnAccent(w) {
  if (Array.isArray(w.alternatives) && w.alternatives.length) return 'amber';
  const types = new Set((Array.isArray(w.actions) ? w.actions : []).map(a => a.type));
  if (types.has('add-wsid')) return 'blue';
  if (types.has('remove-wsid')) return 'red';
  if (types.has('swap-wsid')) return 'green';
  return w.level === 'red' ? 'red' : 'amber';
}

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 unknownRemovalWarnings(unknownWsids) {
  return (unknownWsids || []).map((w) => ({
    tag: 'unknown', level: 'amber', type: 'remove-wsid',
    msg: `unknown wsid ${w}: not included in sort; consider removing it from your input.`,
    wsid: w,
  }));
}

function failedIndexWarnings(failedWsids) {
  return (failedWsids || []).map((w) => ({
    tag: 'failed', level: 'red', type: 'retry',
    msg: `wsid ${w} failed: Steam rejected or returned no data.`,
    wsid: w, action: { type: 'retry-wsid', wsid: w },
  }));
}
