/* ============================================================
   shared helpers — loaded first, attached to window
   ============================================================ */

/* R12.B launch config. Flip to `false` before public launch to hide the
   named-scenarios gear toggle entirely. Single-stack Undo (always on) is
   the only scenario behavior users see in the public build. */
const SHOW_SCENARIOS_TOGGLE = true;

// R1: source the exact-match, league-set figures from the canonical engine
// (window.ENGINE_CBA, set by engine-adapter.js *before* this script runs) so a
// cap change propagates from one place. cap/tax/both aprons/mle/bae are proven
// EXACTLY equal to the engine (Private/_integration/parity-capfigures.md); the
// literal fallbacks preserve today's values if the engine module is ever absent.
// tpMLE/roomMLE stay literal — the engine derives them to the cent (6,065,239.93
// / 9,368,700) vs these $1k-rounded figures; adopting the exact values
// (display-invisible) is the owner's call. `||` is safe: no cap figure is ever 0.
const _E = (typeof window !== "undefined" && window.ENGINE_CBA) || {};
const _Eex = _E.exceptions || {};
const CAP_2026 = {
  cap: _E.cap || 165_000_000,
  taxLine: _E.taxLevel || 200_474_000,
  apron1: _E.firstApron || 209_063_000,
  apron2: _E.secondApron || 221_737_000,
  vetMin: 1_300_000,
  vetMinCapCharge: 2_450_000,
  mle: _Eex.ntMle || 15_048_000,
  tpmle: 6_065_000,
  bae: _Eex.bae || 5_478_000,
  roomMle: 9_369_000,
  leagueAvg: 13_870_000,
  // Incomplete-roster charge (cap mode only, below 12): the 0-years-of-
  // service rookie minimum, 2026-27 projection.
  rosterMin: 12,
  incompleteCharge: 1_425_988,
};

/* 2026-27 luxury-tax repeaters (tax in 3 of prior 4 yrs); source: Hoops Rumors
   repeater tracker — edit as the season's history firms up */
const REPEATER_TEAMS = new Set(["GSW", "BOS", "DEN", "LAL", "MIL", "PHX", "LAC"]);

/* tools list (15 total). 1–4 always available */
const TOOLS = [
  { n: 1,  name: "Sign vet-minimum player",       cap: null },
  { n: 2,  name: "Draft a new player",            cap: null },
  { n: 3,  name: "Re-sign (Bird / Early / Non-Bird)", cap: null },
  { n: 4,  name: "Trade (restricted above 2nd apron)", cap: null },
  { n: 5,  name: "Pay cash in a trade",           cap: "apron2" },
  { n: 6,  name: "Use TPMLE",                     cap: "apron2" },
  { n: 7,  name: "Send player in sign-and-trade", cap: "apron2" },
  { n: 8,  name: "Combine outgoing salaries",     cap: "apron2" },
  { n: 9,  name: "Incoming sign-and-trade",       cap: "apron1" },
  { n: 10, name: "Take in more salary than sent", cap: "apron1" },
  { n: 11, name: "Use MLE beyond TPMLE amount",   cap: "apron1" },
  { n: 12, name: "Use MLE on trade/waiver claim", cap: "apron1" },
  { n: 13, name: "Use BAE",                       cap: "apron1" },
  { n: 14, name: "Use prior-year trade exception",cap: "apron1" },
  { n: 15, name: "Sign waived mid-season player", cap: "apron1" },
];

function fmt$(n) {
  if (n == null) return "—";
  const abs = Math.abs(n);
  if (abs >= 1_000_000) {
    // Strip ALL trailing zeros: "$5.00M" → "$5M", "$5.50M" → "$5.5M".
    let s = (n / 1_000_000).toFixed(abs >= 10_000_000 ? 1 : 2);
    if (s.includes(".")) s = s.replace(/0+$/, "").replace(/\.$/, "");
    return "$" + s + "M";
  }
  if (abs >= 1_000) return "$" + Math.round(n / 1000) + "K";
  return "$" + n;
}
function fmt$Full(n) {
  if (n == null) return "—";
  return "$" + n.toLocaleString("en-US");
}

function parseSalaryInput(s) {
  if (!s) return null;
  s = s.toString().trim().toLowerCase().replace(/[$,_\s]/g, "");
  let mult = 1;
  if (s.endsWith("m")) { mult = 1_000_000; s = s.slice(0, -1); }
  else if (s.endsWith("k")) { mult = 1_000; s = s.slice(0, -1); }
  const n = parseFloat(s);
  if (isNaN(n)) return null;
  return Math.round(n * mult);
}

/* The inline editor in salary cells is suffixed with "M", so a bare value
   like "61" or "57.5" means $61M / $57.5M. Multiply by 1M BEFORE rounding
   so the decimal survives — the old code rounded "57.5" → 58 first, which
   read as $58M and tripped a false "above max". A bare number ≥ 1000 is
   assumed to already be in dollars; an explicit m/k suffix defers to
   parseSalaryInput. */
function parseSalaryFromMField(s) {
  if (s == null) return null;
  const str = s.toString().trim().toLowerCase().replace(/[$,_\s]/g, "");
  if (str === "") return null;
  if (/[mk]$/.test(str)) return parseSalaryInput(str);   // explicit suffix
  const f = parseFloat(str);
  if (isNaN(f)) return null;
  return f < 1000 ? Math.round(f * 1_000_000) : Math.round(f);
}

/* Format a dollar figure as a millions-input string at full precision
   with no trailing zeros (e.g. 30256243 → "30.256243"), so the salary
   editor starts short instead of making the user type all the zeros. */
function toMField(n, decimals = 6) {
  return String(+(((n || 0) / 1_000_000).toFixed(decimals)));
}
// Seed a PROPOSED salary as a clean figure (1 decimal in $M, e.g. 20.9) — used when an editor pre-fills
// a salary to propose (a cap hold, option, or last salary), so it doesn't show $20.906361M (owner #5).
function toMFieldSeed(n) { return toMField(n, 1); }

/* Whether the millions "M" suffix should show for the current input:
   yes while it reads as millions (bare value < 1000, no m/k suffix),
   hidden once a long raw-dollar figure (≥1000) is typed. */
function mFieldShowM(val) {
  if (val == null || val === "") return true;
  const s = String(val).trim().toLowerCase().replace(/[$,_\s]/g, "");
  if (/[mk]$/.test(s)) return false;
  const f = parseFloat(s);
  return isNaN(f) ? true : f < 1000;
}

function maxEligible(player) {
  const y = player.yearsOfExperience || 0;
  // Higher-Max bump (§15): a team re-signing/extending its OWN designated player gets the next tier
  // up — Rose Rule 25→30 (<7 YOS), Designated-Vet supermax 30→35 (7-9 YOS). Data ships `designated`
  // ("rose"|"supermax") on the 12 eligible players. 10+ YOS is already at the 35% ceiling.
  const hm = !!player.designated;
  if (y >= 10) return Math.round(CAP_2026.cap * 0.35);
  if (y >= 7)  return Math.round(CAP_2026.cap * (hm ? 0.35 : 0.30));
  return Math.round(CAP_2026.cap * (hm ? 0.30 : 0.25));
}

/* Compact "years of experience" label shown under salary in the main-site
   roster rows. Returns null when the field isn't on the player record so
   the caller can conditionally render. */
function formatExp(player) {
  const y = player && player.yearsOfExperience;
  if (y == null) return null;
  if (y === 0) return "Rookie";
  return y + "y exp";
}

/* Approximate 2026 rookie-scale cap holds (120% of year-1 rookie-scale
   salary) for first-round picks. Values rounded; actual signing can vary.
   Picks 31–60 → vet-min cap charge. */
const ROOKIE_SCALE_HOLDS_2026 = [
  null,           // pick 0 (unused — picks are 1-indexed)
  16_500_000,     // #1
  14_790_000,     // #2
  13_280_000,     // #3
  12_000_000,     // #4
  10_850_000,     // #5
  9_810_000,      // #6
  8_870_000,      // #7
  8_000_000,      // #8
  7_300_000,      // #9
  6_760_000,      // #10
  6_190_000,      // #11
  5_650_000,      // #12
  5_260_000,      // #13
  4_910_000,      // #14
  4_660_000,      // #15
  4_470_000,      // #16
  4_280_000,      // #17
  4_080_000,      // #18
  3_890_000,      // #19
  3_760_000,      // #20
  3_620_000,      // #21
  3_490_000,      // #22
  3_370_000,      // #23
  3_270_000,      // #24
  3_183_120,      // #25  (actual LAL '26 pick — keeps exact value)
  3_100_000,      // #26
  3_030_000,      // #27
  2_960_000,      // #28
  2_890_000,      // #29
  2_840_000,      // #30
];
function capHoldForPick(n) {
  if (!n || !Number.isFinite(n)) return CAP_2026.vetMinCapCharge;
  if (n >= 1 && n <= 30) return ROOKIE_SCALE_HOLDS_2026[n];
  if (n >= 31 && n <= 60) return 0;   // R0: unsigned 2nd-round picks carry NO cap hold (CBA — holds are 1st-round only). Was vetMinCapCharge ($2.45M), a fabricated charge.
  return CAP_2026.vetMinCapCharge;
}

/* Non-blocking hint shown in the Cap-Roster salary editor: a Bird /
   Early-Bird player being re-signed for MORE than their cap hold is
   better done in the Apron stage (Bird exception doesn't consume cap
   room). Non-Bird players genuinely need room, so no nudge for them.
   `computeCapHold` is global (state.jsx) by call time. */
function birdResignNudge(p, salary) {
  if (!p || p._lite || !salary) return false;
  if (p.birdRights !== "Bird" && p.birdRights !== "EarlyBird") return false;
  const fn = (typeof computeCapHold === "function") ? computeCapHold : null;
  const hold = fn ? fn(p) : (p.capHold || 0);
  return salary > hold;
}

/* ============================================================
   Cap-Moves engine — max payouts per Bird exception + the
   Sign / Hold-for-Trade / Renounce classifier.
   ============================================================ */
function earlyBirdMax(p) {
  const prev = (p && p.priorSeasonSalary) || 0;
  return Math.round(Math.max(prev * 1.75, CAP_2026.leagueAvg * 1.045));
}
function nonBirdMax(p) {
  return Math.round(((p && p.priorSeasonSalary) || 0) * 1.20);
}
const SIGN_NOW_MSG = "Signs now (salary at or below the cap hold).";
const AUTO_DELAY_MSG = "Auto-delays the signing (salary above the cap hold).";

/* Given a free agent and the user's intended FINAL salary, classify the
   optimal disposition and what it does to cap room. End-state room is
   order-independent, so `action` is advisory; `consumes` tells the room
   math what to count: "salary" (the actual deal), "hold" (the cap hold
   kept on the books), or — when renounced — $0. `computeCapHold` and
   `maxEligible` are global by call time. */
function capSignClassify(p, finalSalary) {
  const hold = (typeof computeCapHold === "function") ? computeCapHold(p) : (p && p.capHold) || 0;
  const s = finalSalary || 0;
  const bird = p && p.birdRights;
  const TOL = 1; // $1 tolerance so a seed equal to the ceiling isn't "over"
  if (bird === "Bird") {
    // Floor the ceiling at the player's own hold so a capped/explicit hold
    // (e.g. a max player, or a null-prior two-way) is never flagged "over".
    const max = Math.max(maxEligible(p), hold);
    if (s > max + TOL) return { action: "illegal", consumes: "hold", hold, ceiling: max,
                                reason: `Over this player's max (${fmt$(max)}).` };
    if (s <= hold + TOL) return { action: "sign-first", consumes: "salary", hold, ceiling: max,
                                  reason: SIGN_NOW_MSG };
    return { action: "hold-last", consumes: "hold", hold, ceiling: max,
             reason: AUTO_DELAY_MSG };
  }
  if (bird === "EarlyBird") {
    const ebMax = Math.max(earlyBirdMax(p), hold);
    if (s <= hold + TOL)  return { action: "sign-first", consumes: "salary", hold, ceiling: ebMax,
                                   reason: SIGN_NOW_MSG };
    if (s <= ebMax + TOL) return { action: "hold-last", consumes: "hold", hold, ceiling: ebMax,
                                   reason: AUTO_DELAY_MSG };
    return { action: "renounce-room", consumes: "salary", hold, ceiling: ebMax, needsRoom: true,
             reason: `Signs now using cap room (salary above the Early-Bird max, ${fmt$(ebMax)}).` };
  }
  // Non-Bird (or unknown): max raise == the 120% hold, floored at the
  // player's own cap hold (handles null-prior players with an explicit hold).
  const nbMax = Math.max(nonBirdMax(p), hold);
  if (s <= nbMax + TOL) return { action: "sign-now", consumes: "salary", hold: nbMax, ceiling: nbMax,
                                 reason: SIGN_NOW_MSG };
  return { action: "renounce-room", consumes: "salary", hold: nbMax, ceiling: nbMax, needsRoom: true,
           reason: `Signs now using cap room (salary above the Non-Bird max, ${fmt$(nbMax)}).` };
}

/* Trade Machine: cycle a selected asset's destination to the next team
   in the trade (skipping its own team). */
function cycleDestination(current, allCodes, excludeCode) {
  const targets = (allCodes || []).filter(c => c && c !== excludeCode);
  if (targets.length === 0) return null;
  const i = targets.indexOf(current);
  return targets[(i + 1) % targets.length];
}

/* Placeholder FUTURE draft picks (2027+). This-year picks are real
   (managed = state.draftPicks; opponents = their draft_pick entries),
   so they're excluded here. Window rolls forward with the year. */
function getPlaceholderPicks(code) {
  const y0 = new Date().getFullYear();
  const base = { originalTeam: code, currentOwner: code, viaTeam: null,
                 slotNumber: null, protection: null, isResidual: false,
                 residualNote: null, tradedTo: null, future: true };
  const out = [];
  for (let yr = y0 + 1; yr <= y0 + 6; yr++)
    out.push({ ...base, id: `${code}-${yr}-1`, year: yr, round: 1, label: `${yr} 1st` });
  for (let yr = y0 + 2; yr <= y0 + 6; yr += 2)
    out.push({ ...base, id: `${code}-${yr}-2`, year: yr, round: 2, label: `${yr} 2nd` });
  // Placeholder conditional/residual example (one per team): a 1st dealt
  // away with top-4 protection — selectable, distinct, info note.
  const ry = y0 + 2;
  out.push({ ...base, id: `${code}-${ry}-1-res`, year: ry, round: 1,
    label: `${ry} 1st — Top-4 protection`, currentOwner: "UTA",
    protection: "Top 4 → UTA", isResidual: true, tradedTo: "UTA",
    residualNote: `${code} dealt its ${ry} 1st (top-4 protected) to UTA. ${code} keeps it only if it lands 1–4. Counts as traded for Stepien — it is NOT an owned pick.` });
  return out;
}

/* Per-team per-SEASON cash limit (CBA), cap-scaled the same way as the
   trade-engine's $7.5M cushion. Same value for cash in and out. */
function cashLimit() {
  const anchor = (typeof CAP_2023_24 === "number") ? CAP_2023_24 : 136021000;
  return Math.round(7_500_000 * (CAP_2026.cap / anchor));
}

/* Swap targets for a year/round across the other teams in the trade.
   otherSlots: [{ code, owned:[pick], arriving:[{pick, from}] }]. */
function getAvailableSwapTargets(year, round, otherSlots) {
  const t = [];
  for (const s of otherSlots || []) {
    for (const pk of (s.owned || []))
      if (pk.year === year && pk.round === round && !pk.isResidual)
        t.push({ ...pk, team: s.code, isArriving: false });
    for (const a of (s.arriving || []))
      if (a.pick.year === year && a.pick.round === round)
        t.push({ ...a.pick, team: s.code, isArriving: true, arrivingFrom: a.from });
  }
  return t;
}

/* status helpers */
function statusBadge(p, decision) {
  // returns {kind, label, sub}
  if (p && p.deadMoney) return { kind: "waived", label: "Dead money" };   // one-world #14: waived+stretched, not an option
  if (decision?.kind === "signed") return { kind: "signed", label: "Re-signed", sub: `${decision.years || 1}y · ${fmt$(decision.salary)}` };
  if (decision?.kind === "renounced") return { kind: "renounced", label: "Renounced" };
  if (decision?.kind === "traded")    return { kind: "renounced", label: "Traded away" };
  if (decision?.kind === "kept-hold") return { kind: "kept", label: "Cap hold" };
  if (decision?.kind === "opt-in")   return { kind: "optin",  label: "Opted In" };
  if (decision?.kind === "opt-out")  return { kind: p.birdRights === "Bird" ? "ufa-bird" : "ufa-nb", label: "→ FA" };
  if (decision?.kind === "exercise") return { kind: "optin",  label: "Exercised" };
  if (decision?.kind === "decline")  return { kind: "ufa-nb", label: "Declined" };
  if (decision?.kind === "keep")     return { kind: "guar",   label: "Kept" };
  if (decision?.kind === "waive")    return { kind: "waived", label: "Waived" };
  if (decision?.kind === "drafted")  return { kind: "signed", label: "Signed" };

  switch (p.offseasonStatus) {
    case "under_contract": return { kind: "guar",       label: "Guaranteed" };
    case "player_option":  return { kind: "player-opt", label: "Player Option" };
    case "team_option":    return { kind: "team-opt",   label: "Team Option" };
    case "partially_guaranteed": return { kind: "non-guar", label: "Partial" };
    case "non_guaranteed": return { kind: "non-guar",   label: "Non-Guar" };
    case "UFA":            return { kind: p.birdRights === "Bird" ? "ufa-bird" : "ufa-nb", label: `UFA · ${p.birdRights}` };
    case "RFA":            return { kind: "rfa",        label: "RFA" };
    case "draft_pick":     return { kind: "draft",      label: "Pick" };
    default:               return { kind: "locked",     label: p.offseasonStatus };
  }
}

/* Cap-sheet status label that records the path: opted-out/declined then
   re-signed reads "Opted-out & Re-signed" etc. Falls back to statusBadge. */
function capStatusLabel(p, decision) {
  const k = decision?.kind;
  const gate = p.offseasonStatus === "player_option" ? "Opted out"
             : p.offseasonStatus === "team_option"   ? "Declined"
             : null;
  if (k === "signed")    return gate ? `${gate} & Re-signed` : "Re-signed";
  if (k === "renounced") return gate ? `${gate} & Renounced` : "Renounced";
  const isFA = p.offseasonStatus === "UFA" || p.offseasonStatus === "RFA";
  if (k === "kept-hold" || (!k && isFA)) return gate ? `${gate} & Held` : "Hold for trade";
  return statusBadge(p, decision).label;
}

/* Real 30-team list, built from data/teams-trade-data.json (the V1
   reconciled capsheets authority). `rank` = conference standing
   (confPos). `recommendedMode` is the cap strategy the tool nudges
   toward: a team already over the cap is steered to "apron"; one with
   room to "cap" (computed for every team now that all 30 are rich). */
function buildTeams(allTeams) {
  // Phase 1b (one-world #14): the 30-team switcher list now derives from
  // all-teams-detail.json (passed in as the teams MAP) — the same single source
  // the roster page + Trade Machine use — instead of teams-trade-data.json.
  // `total` mirrors teamCommitted (apron base: real 2026-27 salaries + dead money
  // + drafted-rookie holds, two-ways excluded) so `recommendedMode` is accurate.
  if (!allTeams) return [];
  const cap = CAP_2026.cap;
  const PES = (typeof window !== "undefined") ? window.playerEffectiveSalary : null;
  const ONC = { under_contract: 1, player_option: 1, team_option: 1, non_guaranteed: 1, partially_guaranteed: 1 };
  return Object.entries(allTeams).map(([code, t]) => {
    let total = 0;
    for (const p of (t.players || [])) {
      if (p.contractType === "two_way") continue;
      if (p.offseasonStatus === "draft_pick") { total += Number(p.capHold) || 0; continue; }
      if (PES) { const v = PES(p, {}, "apron", false); total += Number.isFinite(v) ? v : 0; }
      else if (ONC[p.offseasonStatus]) total += (p.seasons || []).find(s => s.season === "2026-27")?.salary || 0;
    }
    total = Math.round(total);
    return {
      code,
      name: t.fullName || t.name,
      shortName: t.name,
      // V1 logo paths look like "team_logos/atlanta_hawks_1610612737.svg";
      // assets were copied flat into assets/logos/, so keep the filename.
      logo: (t.logo || "").replace(/^.*[\/\\]/, ""),
      rank: t.confPos || 99,
      conf: t.conf === "Western" ? "W" : "E",
      total,
      recommendedMode: total > cap ? "apron" : "cap",
    };
  });
}

/* Empty until data/teams-trade-data.json loads; App threads the real
   list down as the `teams` prop. Kept as a safe global fallback so any
   stray reference can't throw before the fetch resolves. */
let TEAMS_DEMO = [];

/* "Lite" managed roster for any non-LAL team, built from the 30-team
   trade data. Only the trade-grade fields exist (2026-27 salary +
   status + no-trade + photo), so the rich LAL flow (bird rights, cap
   holds, option amounts, multi-year) is intentionally absent — the
   `_lite` marker lets state.jsx skip all cap-hold math for these. */
function liteTeamPlayers(tradeData, code) {
  const t = tradeData && tradeData.teams && tradeData.teams[code];
  if (!t || !Array.isArray(t.players)) return [];
  const STATUS = {
    guaranteed: "under_contract",
    player_option: "player_option",
    team_option: "team_option",
    non_guaranteed: "non_guaranteed",
    draft_pick: "draft_pick",
  };
  const order = { under_contract: 0, player_option: 1, team_option: 2, non_guaranteed: 3, draft_pick: 6 };
  return t.players.map(p => {
    const os = STATUS[p.status] || "under_contract";
    const sal = p.salary2026_27 || 0;
    return {
      name: p.name, nbaId: p.nbaId || null, position: "", team: code,
      offseasonStatus: os,
      birdRights: null, capHold: null, contractType: "standard",
      yearsOfExperience: 0, yearsWithTeam: 0, noTrade: !!p.noTrade,
      currentSalary: sal, optionSalary: sal, optionAmount: sal,
      priorSeasonSalary: sal, lastSalary: sal,
      seasons: [{ season: "2026-27", salary: sal }],
      _lite: true,
    };
  }).sort((a, b) => {
    const d = (order[a.offseasonStatus] ?? 9) - (order[b.offseasonStatus] ?? 9);
    if (d) return d;
    return (b.seasons[0].salary || 0) - (a.seasons[0].salary || 0);
  });
}

/* mini icon set (inline SVGs) */
const Icon = {
  Trade: (p) => (<svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 17l-4-4 4-4M3 13h14M17 7l4 4-4 4M21 11H7" /></svg>),
  UserPlus: (p) => (<svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" y1="8" x2="19" y2="14"/><line x1="22" y1="11" x2="16" y2="11"/></svg>),
  Plus: (p) => (<svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>),
  /* Points shifted up ~1 unit from the lucide default (was "20 6 9 17 4 12") so
     the glyph is OPTICALLY centered — a checkmark's vertex sits low, reading
     low even when geometrically centered. Baked into the viewBox (not a CSS
     transform) so it stays centered at every zoom/DPI (a fixed-px nudge rounds
     to the device grid inconsistently). */
  Check: (p) => (<svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round"><polyline points="20 7 9.5 17.5 4 12"/></svg>),
  X: (p) => (<svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>),
  Pencil: (p) => (<svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"/></svg>),
  Sun: (p) => (<svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>),
  Moon: (p) => (<svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>),
  Caret: (p) => (<svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 9 12 15 18 9"/></svg>),
  ArrowUp: (p) => (<svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 14 12 8 18 14"/></svg>),
  ArrowDown: (p) => (<svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><polyline points="6 10 12 16 18 10"/></svg>),
  HardHat: (p) => (<svg {...p} viewBox="0 0 24 24" fill="currentColor"><path d="M3 18h18v2H3v-2zm9-13c-3.5 0-6.5 2.5-7 6h2c.45-2.34 2.34-4 4.5-4h1c2.16 0 4.05 1.66 4.5 4h2c-.5-3.5-3.5-6-7-6zm-4 9h8v-1c0-1.1-.9-2-2-2h-4c-1.1 0-2 .9-2 2v1z"/></svg>),
  Settings: (p) => (<svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>),
  Trash: (p) => (<svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>),
  Reset: (p) => (<svg {...p} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>),
};

/* Real CapMVP wordmark (assets/brand/logo.svg). Replaces the old
   placeholder visor. --logo-filter lifts it on the dark surface, same
   treatment as the team logos. */
function BrandLogo({ height = 26 }) {
  // [COLOUR rework] Inlined (not <img>) so the wordmark's two fills
  // (.cls-1 = "Cap", .cls-2 = "MVP") are driven by --logo-cap / --logo-mvp
  // per palette. Fetch the asset once, strip its baked <defs><style> so the
  // page CSS can recolour it. (An <img> isolates the SVG from page CSS.)
  const ref = React.useRef(null);
  React.useEffect(() => {
    let alive = true;
    fetch("assets/brand/logo.svg").then(r => r.text()).then(txt => {
      if (!alive || !ref.current) return;
      const doc = new DOMParser().parseFromString(txt, "image/svg+xml");
      const svg = doc.querySelector("svg");
      if (!svg) return;
      const defs = svg.querySelector("defs"); if (defs) defs.remove();
      svg.removeAttribute("width"); svg.removeAttribute("height");
      ref.current.innerHTML = svg.outerHTML;
    }).catch(() => {});
    return () => { alive = false; };
  }, []);
  return <span className="brand-logo" ref={ref} role="img" aria-label="CapMVP"
               style={{ height, lineHeight: 0 }} />;
}
/* kept for back-compat (no longer rendered) */
const BrandMark = BrandLogo;

/* hex (#rrggbb) → rgba() string */
function hexA(hex, a) {
  const h = String(hex || "").replace("#", "");
  const n = h.length === 3
    ? h.split("").map(c => c + c).join("")
    : h.padEnd(6, "0").slice(0, 6);
  const r = parseInt(n.slice(0, 2), 16) || 0;
  const g = parseInt(n.slice(2, 4), 16) || 0;
  const b = parseInt(n.slice(4, 6), 16) || 0;
  return `rgba(${r}, ${g}, ${b}, ${a})`;
}

/* ============================================================
   TEAM_SKINS — curated for option B (team colour in the TOP salary
   meter) + colour-blind safety:
     • every fill reads clearly LIGHTER/brighter than the dark track
     • NO red↔green gradients (the one pair that can't be separated)
     • near-black / muddy teams AMENDED (marked * below) so the fill
       never disappears on the dark bar
   The over-2nd-apron / over-hard-cap state ignores these and uses a
   neutral GREY base + red overtake (handled inside mobile-bar.jsx).
   (Replaces the old official-ish palette; see Roster Colours handoff.)
   ============================================================ */
const TEAM_SKINS = {
  ATL: ["#E0556B", "#C9A0A8"],  // * red + soft rose (was red/charcoal volt)
  BOS: ["#1FA85B", "#C7A461"],  //   kelly + gold
  BKN: ["#E0556B", "#3E6DB5"],  // * historic ABA red→royal (was black/white)
  CHA: ["#3FA6C4", "#7A4FB5"],  //   teal + purple
  CHI: ["#E0556B", "#8A9098"],  // * red lifted + steel (was red/black)
  CLE: ["#A33A5A", "#E0B24A"],  // * wine lifted + gold
  DAL: ["#3A6FD6", "#AAB0B6"],  //   royal + silver
  DEN: ["#5A6FA8", "#E0C24A"],  // * navy lifted + gold
  DET: ["#D6455C", "#3A6FD6"],  //   red + royal
  GSW: ["#2A6FCC", "#FFC72C"],  //   royal + gold
  HOU: ["#D6455C", "#9A9EA4"],  // * red lifted + silver (was red/black)
  IND: ["#5A7BC4", "#E0C24A"],  // * navy lifted + gold
  LAC: ["#D6455C", "#3A6FD6"],  //   red + royal
  LAL: ["#7B57C2", "#FDB927"],  //   purple lifted + gold
  MEM: ["#6E8BC0", "#C6A86B"],  // * Beale-St blue lifted + gold
  MIA: ["#E0566E", "#F2A23C"],  // * red lifted + warm gold (was red/black)
  MIL: ["#1FA85B", "#E6D5A8"],  //   green + cream
  MIN: ["#3A6FD6", "#5AC4D6"],  // * navy + lake blue (avoid navy/green)
  NOP: ["#5A6FA8", "#C9A35A"],  // * navy lifted + gold
  NYK: ["#3A7DD6", "#F58426"],  //   blue + orange
  OKC: ["#3A82C9", "#E8623A"],  //   blue + sunset orange
  ORL: ["#3A8FD6", "#AAB0B6"],  //   blue + silver
  PHI: ["#3A6FD6", "#D6435A"],  //   royal + red
  PHX: ["#7A4FB5", "#E8843A"],  //   purple + orange
  POR: ["#D6455C", "#B6BCC2"],  // * red lifted + silver (was red/black)
  SAC: ["#7A4FB5", "#9A9EA4"],  //   purple + silver
  SAS: ["#C9CED2", "#888F97"],  // * metallic silver (was near-black)
  TOR: ["#D6455C", "#B6BCC2"],  // * red lifted + silver (was red/black)
  UTA: ["#5A6FA8", "#E0C24A"],  // * navy + gold
  WAS: ["#5A7BC4", "#D6455C"],  // * navy lifted + red
};

/* Multi-year contract helpers (apron signings). CBA: full Bird → up to 5
   years; everyone else (Early-Bird, Non-Bird, room/MLE/outside) → 4. Raises
   are 8% of the first-year salary for Bird/Early-Bird re-signs, 5% otherwise,
   applied linearly (Year i = base + (i-1)*base*rate) — matches the calculator.
   NOTE: single-season tool — these affect total value/labeling only, not the
   2026-27 cap hit (which is the first-year salary). */
/* ============================================================
   Partial-guarantee accessors (waiver / dead-money model).
   ------------------------------------------------------------
   A partially-guaranteed player is a LIVE roster contract: while KEPT he
   counts at his FULL salary. The `guarantee.amount` on the 2026-27 season
   is ONLY what would stick as dead money IF he is waived — it is never the
   player's on-books figure while kept. Single chokepoint so no caller
   re-parses the `seasons[].guarantee` shape.
   ============================================================ */
function seasonFor(p, season = "2026-27") {
  return (p && p.seasons || []).find(s => s.season === season) || null;
}
/* Dead money that would stick if `p` is waived straight up (no stretch),
   for the given season. 0 when the season carries no partial guarantee
   (a fully non-guaranteed min deal → waiving is free, the binary's right). */
function guaranteedAmountFor(p, season = "2026-27") {
  const s = seasonFor(p, season);
  // Legacy nested shape (seasons[].guarantee.amount) — kept for back-compat.
  if (s && s.guarantee && Number(s.guarantee.amount)) return Number(s.guarantee.amount);
  // Current data shape: a TOP-LEVEL guaranteedAmount on partially-guaranteed players.
  if (Number(p.guaranteedAmount) > 0) return Number(p.guaranteedAmount);
  return 0;
}
/* True when the season is genuinely PARTIALLY guaranteed (a non-guaranteed
   year carrying a non-zero guarantee floor). */
function isPartial(p, season = "2026-27") {
  return guaranteedAmountFor(p, season) > 0;
}

function maxContractYears(birdRights) { return birdRights === "Bird" ? 5 : 4; }
function contractRaiseRate(birdRights) {
  return (birdRights === "Bird" || birdRights === "EarlyBird") ? 0.08 : 0.05;
}
function contractTotal(base, years, rate) {
  let total = 0;
  for (let i = 1; i <= years; i++) total += base * (1 + (i - 1) * rate);
  return Math.round(total);
}
/* Per-year salaries. With `raises` (array of year-over-year step fractions of
   the base, length years-1) → Yr1=base, Yr_i = Yr_{i-1} + raises[i-2]*base.
   Otherwise a uniform `rate` (linear off base), matching the calculator. */
function contractYearSalaries(base, years, opts = {}) {
  const { rate = 0, raises = null } = opts;
  const out = [Math.round(base)];
  for (let i = 2; i <= years; i++) {
    const v = raises ? out[i - 2] + (raises[i - 2] || 0) * base
                     : base * (1 + (i - 1) * rate);
    out.push(Math.round(v));   // whole-dollar salaries; avoids float noise
  }
  return out;
}
function contractTotalFrom(base, years, opts = {}) {
  return contractYearSalaries(base, years, opts).reduce((a, b) => a + b, 0);
}
function optionTag(option) {
  return option === "player" ? "PO" : option === "team" ? "TO" : "";
}

/* ============================================================
   G1 (a) — engine-backed SIGNING LEGALITY. The engine is the oracle:
   limits come from window.Engine.{maxContractYears,maxAnnualRaisePct,
   maxStartingSalary} (ruleset-derived), NOT a literal 20%. Per owner
   decision (2026-06-02): BLOCK known-illegal, WARN when our data is
   indeterminate (e.g. years-of-service unknown).
     birdStatus is ENGINE vocab: 'bird' | 'early_bird' | 'other'.
     tool is the modal's signing-tool string.
   Returns { violations:[{code,severity,msg}], blocked, maxYears, maxRaise,
             playerMax, ceiling, toolCeil }.
   ============================================================ */
function birdRightsToStatus(birdRights) {
  if (birdRights === "Bird") return "bird";
  if (birdRights === "EarlyBird") return "early_bird";
  return "other";   // NonBird / Non-Bird / null / unknown
}
function signingLegality(opts = {}) {
  const {
    birdStatus = "other", yos = null, priorSalary = 0, higherMax = false,
    instrument = "new", years = 1, startingSalary = 0,
    raisePct = null, raises = null, tool = "Cap space", exceptionRemaining = null,
    availableRoom = null, usedExceptions = null, ownReSign = false,
    hardCapCeiling = null, apronAfter = null,
  } = opts;
  const E = (typeof window !== "undefined" && window.Engine) || {};
  const bs = birdStatus || "other";
  const yosKnown = Number.isFinite(yos);
  const yosUse = yosKnown ? yos : 10;   // unknown → top tier (least restrictive: don't over-block)
  // Phase 3b — the signing TOOL also caps the contract LENGTH (§10.C): TP-MLE/BAE 2, Room MLE 3,
  // NT-MLE 4, Min 2. Feed the engine the mechanism so maxContractYears returns the LESSER of the
  // rights length and the exception cap. (Cap space / bird re-sign → no mechanism cap.)
  const TOOL_MECH = { "MLE": "nt_mle", "TPMLE": "tp_mle", "BAE": "bae", "Room MLE": "room_mle", "Vet min": "minimum" };
  const mechanism = TOOL_MECH[tool] || null;
  const maxYears = (typeof E.maxContractYears === "function")
    ? E.maxContractYears({ birdStatus: bs, instrument, mechanism }) : (bs === "bird" ? 5 : 4);
  const maxRaise = (typeof E.maxAnnualRaisePct === "function")
    ? E.maxAnnualRaisePct({ birdStatus: bs, instrument }) : ((bs === "bird" || bs === "early_bird") ? 0.08 : 0.05);
  const playerMax = (typeof E.maxStartingSalary === "function")
    ? E.maxStartingSalary({ yos: yosUse, priorSalary: priorSalary || 0, higherMax })
    : Math.round(CAP_2026.cap * 0.35);
  const TOOL_CEIL = { "MLE": CAP_2026.mle, "TPMLE": CAP_2026.tpmle, "BAE": CAP_2026.bae, "Room MLE": CAP_2026.roomMle };
  const toolCeil = TOOL_CEIL[tool];
  // b (backlog): when the exception pool is partly spent, the binding ceiling is
  // what's LEFT (exceptionRemaining), not the full exception amount.
  const effToolCeil = (exceptionRemaining != null && Number.isFinite(exceptionRemaining)) ? exceptionRemaining : toolCeil;
  // Phase 2 — a CAP-SPACE signing is also bounded by AVAILABLE ROOM (holds-inclusive
  // cap − Team Salary, §6(c)). Only applies to the cap-space mechanism: Bird/Early-Bird
  // re-signs (which exceed the cap by right) pass no availableRoom, and exception signings
  // (MLE/BAE/Room MLE) are pool-bound, not room-bound. So callers opt in by passing it.
  const roomCeil = (tool === "Cap space" && availableRoom != null && Number.isFinite(availableRoom)) ? availableRoom : null;
  // Phase 3b-data — bird-RIGHTS first-year ceilings (own-FA re-sign only; §10.B): Early-Bird ≤ greater
  // of 175%·prior or 104.5%·EAPS; Non-Bird ≤ greater of 120%·prior or 120%·min-for-YOS. (Full Bird → no
  // rights ceiling, reaches the player max.) An EXTERNAL FA grants no rights, so this is gated to
  // ownReSign. meta.minScale/eaps come from the deployed dataset (eaps == CAP_2026.leagueAvg).
  const META = (typeof window !== "undefined" && window.__tmData && window.__tmData.meta) || {};
  const minScale = opts.minScale || META.minScale || null;
  const eaps = Number.isFinite(opts.eaps) ? opts.eaps : (META.eaps || CAP_2026.leagueAvg);
  const minForYos = (minScale && yosKnown) ? Number(minScale[Math.min(Math.max(yos, 0), 10)]) : null;
  const rightsCeil = !ownReSign ? null
    : bs === "early_bird" ? Math.round(Math.max(1.75 * (priorSalary || 0), 1.045 * eaps))
    : bs === "other"      ? Math.round(Math.max(1.20 * (priorSalary || 0), 1.20 * (minForYos || 0)))
    : null;   // Full Bird → no rights ceiling
  // Vet-min signing → bounded to the minimum-scale figure for the player's YOS (tool-driven, either camp).
  const minCeil = (tool === "Vet min" && minForYos != null) ? minForYos : null;
  const ceiling = Math.min(
    playerMax,
    (effToolCeil != null ? effToolCeil : Infinity),
    (roomCeil != null ? roomCeil : Infinity),
    (rightsCeil != null ? rightsCeil : Infinity),
    (minCeil != null ? minCeil : Infinity),
  );
  const bsLabel = bs === "bird" ? "Bird" : bs === "early_bird" ? "Early-Bird" : "non-Bird";

  const violations = [];
  if (years > maxYears)
    violations.push({ code: "years", severity: "block", msg: `Max ${maxYears} years for this signing (${bsLabel}${toolCeil != null ? " / " + tool : ""}).` });
  const reqRaise = Number.isFinite(raisePct) ? raisePct
    : (Array.isArray(raises) && raises.length ? Math.max.apply(null, raises) : 0);
  if (reqRaise > maxRaise + 1e-9)
    violations.push({ code: "raise", severity: "block", msg: `Max ${Math.round(maxRaise * 100)}% annual raise (${bsLabel}).` });
  if (startingSalary > ceiling + 1) {
    const roomBound = roomCeil != null && ceiling === roomCeil;
    const toolBound = effToolCeil != null && ceiling === effToolCeil;
    const rightsBound = rightsCeil != null && ceiling === rightsCeil;
    const minBound = minCeil != null && ceiling === minCeil;
    const partial = exceptionRemaining != null && toolCeil != null && exceptionRemaining < toolCeil - 1;
    const msg = roomBound ? `Over your available cap room (${fmt$(roomCeil)}).`
              : minBound ? `Over the vet-minimum for ${yos} years of service (${fmt$(minCeil)}).`
              : rightsBound ? `Over the ${bsLabel} re-sign max (${fmt$(rightsCeil)}).`
              : toolBound ? `Over the ${tool} ${partial ? "remaining" : "amount"} (${fmt$(effToolCeil)}).`
              : `Over this player's max salary (${fmt$(playerMax)}).`;
    violations.push({ code: "salary", severity: "block", msg });
  }
  // Phase 3b — exception SET legality (§6(g)(3) first-use camp-lock + §6(m) one-MLE): the team's
  // prior exception signings this cap year PLUS the pending tool can't form an illegal combo (e.g.
  // Room MLE + NT-MLE, or two MLEs). usedExceptions (engine keys) is supplied by the caller, which
  // tracks the team's signed additions' sources. Needs ≥2 to conflict.
  if (Array.isArray(usedExceptions) && usedExceptions.length > 1 && typeof E.signingSetViolations === "function") {
    for (const r of (E.signingSetViolations(usedExceptions).reasons || []))
      violations.push({ code: "set", severity: "block", msg: r });
  }
  // Phase 4d: a PRIOR hard cap (from an earlier NT-MLE/BAE/TP-MLE signing or applied
  // trade) blocks any signing that would push holds-free Apron Team Salary over that
  // apron ceiling (owner: block everything illegal).
  if (Number.isFinite(hardCapCeiling) && Number.isFinite(apronAfter) && apronAfter > hardCapCeiling + 1) {
    violations.push({ code: "hardcap", severity: "block", msg: `Hard-capped — this signing would exceed your apron hard cap (${fmt$(hardCapCeiling)}).` });
  }
  if (!yosKnown && (toolCeil == null) && roomCeil == null && startingSalary > 0)
    violations.push({ code: "yos", severity: "warn", msg: "Years-of-service unknown — the max-salary check assumed the top (35%) tier." });

  return { violations, blocked: violations.some(v => v.severity === "block"), maxYears, maxRaise, playerMax, ceiling, toolCeil, roomCeil };
}

/* Re-sign legality gate for the inline editors (Roster re-sign / Cap-Holds Sign /
   Cap-Sheet rows), mirroring what SignFAModal already does. An own-FA re-sign uses
   Bird / Early-Bird / Non-Bird rights, so there's no exception ceiling — the Art II
   §7 player max binds (this is what blocks "$300M"). Returns true AND toasts the
   reason when the signing is illegal; callers bail before dispatching the signing. */
function reSignBlocked(p, contract, dispatch, state) {
  if (!p) return false;
  const { salary = 0, years = 1, raisePct = null, raises = null } = contract || {};
  // Phase 4d: a hard cap binds EVERY move, incl. a Bird/Early-Bird own-FA re-sign. If the
  // managed team carries a stored hard cap, block a re-sign whose post-re-sign holds-free
  // Apron Team Salary would exceed the ceiling. apronAfter excludes the player's CURRENT
  // apron contribution (so an EDIT doesn't double-count), then adds the new first-year salary.
  // (Callers that don't pass `state` keep the legacy behavior — no hard-cap check.)
  let hardCapCeiling = null, apronAfter = null;
  if (state && typeof window !== "undefined") {
    const sc = (typeof window.curSc === "function") ? window.curSc(state) : null;
    const at = (sc && typeof window.strictestHardCap === "function") ? window.strictestHardCap((sc.hardCaps || {})[state.team]) : null;
    if (at) {
      hardCapCeiling = at === "firstApron" ? CAP_2026.apron1 : CAP_2026.apron2;
      const dft = (window.__derivedByTeam || {})[state.team] || {};
      const apronBase = (Number(dft.apronTotal) || 0) + (Number(dft.unlikely) || 0);
      const curContrib = (typeof window.playerEffectiveSalary === "function") ? (Number(window.playerEffectiveSalary(p, (state.decisions || {}), "apron")) || 0) : 0;
      apronAfter = apronBase - curContrib + (Number(salary) || 0);
    }
  }
  const lx = signingLegality({
    birdStatus: birdRightsToStatus(p.birdRights),
    instrument: "new",
    yos: p.yearsOfExperience, priorSalary: p.priorSeasonSalary,
    higherMax: !!p.designated,   // §15 Rose/supermax: own-team re-sign of a designated player gets the next tier
    ownReSign: true,             // §10.B: applies the Early-Bird 175% / Non-Bird 120% first-year rights ceiling
    years, startingSalary: salary, raisePct, raises, tool: "Cap space",
    hardCapCeiling, apronAfter,
  });
  if (lx.blocked && typeof dispatch === "function") {
    const v = lx.violations.find(x => x.severity === "block");
    dispatch({ type: "SET_TOAST", toast: v ? `Can't sign ${p.name} — ${v.msg}` : "That signing isn't legal." });
  }
  return lx.blocked;
}

/* G1 (b) — which signing tools a team may use (mirrors the Trade Machine OptionsPanel).
   capBase = HOLDS-INCLUSIVE Team Salary (gates room vs over-cap, §6(n)); apronTotal = HOLDS-FREE
   Apron Team Salary (gates WHICH over-cap exception, §2(e)(1)(iv)). Unknown capBase → don't gate. */
function eligibleSigningTools(capBase, apronTotal) {
  const { cap, apron1, apron2 } = CAP_2026;
  if (capBase == null || !Number.isFinite(capBase))
    return ["Cap space", "MLE", "TPMLE", "BAE", "Room MLE", "Vet min"];
  // Room vs over-cap = the §6(n)(2) arithmetic (engine signingMode), NOT a naive "< cap":
  // a team under the cap by LESS than its unused over-cap exceptions (~$20.5M) is absorbed
  // back over the cap and keeps the bigger NT-MLE+BAE rather than the Room MLE.
  const E = (typeof window !== "undefined" && window.Engine) || {};
  const room = (typeof E.signingMode === "function")
    ? E.signingMode({ teamSalaryWithHolds: capBase }).mode === "room"
    : (capBase < cap);   // fallback if the engine isn't loaded
  if (room) return ["Cap space", "Room MLE", "Vet min"];
  // Over-cap → WHICH over-cap exception is decided by the apron, measured on the HOLDS-FREE Apron
  // Team Salary (§2(e)(1)(iv) excludes the FA Amount) — so a high-holds team (LAL: apron ~$110M,
  // capBase ~$238M) gets its apron-appropriate MLE. Falls back to capBase if apron isn't supplied.
  const ap = Number.isFinite(apronTotal) ? apronTotal : capBase;
  if (ap >= apron2) return ["Vet min"];                                 // over 2nd apron
  if (ap >= apron1) return ["TPMLE", "Vet min"];                        // over 1st apron
  return ["MLE", "BAE", "Vet min"];                                     // over cap, under 1st apron
}

/* G1 (b) — exception POOL accounting (full consumption tracking). Derived from
   the team's signed additions (each carries its `source` tool + first-year
   salary), so no extra state. null for tools with no fixed pool (Cap space /
   Vet min). */
function exceptionRemainingFor(additions, tool) {
  const KEY = { "MLE": "mle", "TPMLE": "tpmle", "BAE": "bae", "Room MLE": "roomMle" };
  const k = KEY[tool];
  if (!k) return null;
  const amount = CAP_2026[k] || 0;
  const used = (additions || []).filter(a => a.source === tool).reduce((s, a) => s + (a.salary || 0), 0);
  return { amount, used, left: Math.max(0, amount - used) };
}

/* ============================================================
   G3 (e) — managed team's offseason MOVES for the snapshot card.
   Groups: signed (external FA), acquired (trade-in), sent (trade-out),
   reSigned (own FA), waived (→ dead money), renounced. Pure read of state.
   ============================================================ */
function computeManagedMoves(state, players) {
  const team = state && state.team;
  const view = (typeof selectTeamView === "function" && state)
    ? selectTeamView(state, team)
    : { decisions: (state && state.decisions) || {}, additions: (state && state.additions) || [] };
  const decisions = view.decisions || {};
  const additions = view.additions || [];
  const byName = {}; (players || []).forEach(p => { if (p && p.name) byName[p.name] = p; });

  const signed = [], acquired = [];
  for (const a of additions) {
    if (!a || !a.name) continue;
    if (a._fromTrade) acquired.push({ name: a.name, salary: a.salary || 0, years: a.years || 1, from: String(a.source || "").replace(/^traded from\s*/i, "").trim() || null });
    else signed.push({ name: a.name, salary: a.salary || 0, years: a.years || 1, source: a.source || "" });
  }

  const reSigned = [], waived = [], renounced = [];
  for (const name of Object.keys(decisions)) {
    const d = decisions[name] || {}; const p = byName[name];
    if (d.kind === "signed" && p && (p.offseasonStatus === "UFA" || p.offseasonStatus === "RFA"))
      reSigned.push({ name, salary: d.salary || 0, years: d.years || 1 });
    else if (d.kind === "waive") {
      const straight = (typeof playerEffectiveSalary === "function" && p) ? (playerEffectiveSalary(p, {}, "apron", false) || 0) : 0;
      const dead = (typeof waivedCharge === "function" && p) ? waivedCharge(p, d, straight) : (d.buyoutAmount || (d.stretchPerYear || 0) || straight);
      waived.push({ name, dead, label: d.deadMoneyKind === "buyout" ? "Buyout" : d.deadMoneyKind === "stretched" ? "Stretched" : "Waived" });
    } else if (d.kind === "renounced") renounced.push({ name });
  }

  const sent = [];
  const scen = state && state.scenarios && state.activeScenario && state.scenarios[state.activeScenario];
  const applied = (scen && scen.appliedTrades) || (state && state.appliedTrades) || [];
  for (const tr of applied) {
    for (const slot of ((tr && tr.slots) || [])) {
      if (!slot || slot.code !== team) continue;
      for (const op of (slot.outPlayers || [])) if (op && op.name) sent.push({ name: op.name, to: op.destCode || null });
      for (const of_ of (slot.outFas || [])) if (of_ && of_.name) sent.push({ name: of_.name, to: of_.destCode || null, snt: true });
    }
  }

  const count = signed.length + acquired.length + sent.length + reSigned.length + waived.length + renounced.length;
  return { team, signed, acquired, sent, reSigned, waived, renounced, count };
}

Object.assign(window, {
  SHOW_SCENARIOS_TOGGLE, computeManagedMoves,
  maxContractYears, contractRaiseRate, contractTotal, contractYearSalaries,
  contractTotalFrom, optionTag, signingLegality, reSignBlocked, birdRightsToStatus, eligibleSigningTools, exceptionRemainingFor,
  CAP_2026, TOOLS, REPEATER_TEAMS, fmt$, fmt$Full, parseSalaryInput, parseSalaryFromMField,
  maxEligible, formatExp, statusBadge, TEAMS_DEMO, buildTeams, liteTeamPlayers,
  Icon, BrandMark, BrandLogo, hexA, TEAM_SKINS,
  ROOKIE_SCALE_HOLDS_2026, capHoldForPick, birdResignNudge,
  cycleDestination, getPlaceholderPicks, cashLimit, getAvailableSwapTargets,
  earlyBirdMax, nonBirdMax, capSignClassify, toMField, mFieldShowM,
  capStatusLabel,
  seasonFor, guaranteedAmountFor, isPartial,
});
