// fc-engine.jsx, the decision layer behind the screens (Logic & Flows §14–§15).
// Pure data + classifiers; no rendering. Attaches helpers to window.
//   FC_SPORTS       , per-sport rules + official division tables (§14.4)
//   planTypeFor     , camp-cut vs same-day "walk-it-down" (§14.5/F6)
//   feasibility     , required-rate vs 1%/wk ceiling (§14.6)
//   paceZone        , symmetric pace classifier for the gauge (§14.1)
//   projectionVerdict, cone clears / straddles / misses (§14.1)
//   CYCLE_MODES + effectiveOffsetKg, natural / contraception / absent (§15.1)
//   amenorrheaCheck , RED-S safeguard (§15.2)
//   LEARNED_CYCLE   , demo of the personalised model (§15.4)

// ── Per-sport rules + division limits (kg). Limits are the SCALE limit. ───────
// Per-sport rules + division limits. See Logic & Flows §17 for sources + confidence.
// conf: 'verified' = checked vs governing body / literature this session; 'structure'
// = established format, confirm exact kg vs current rulebook before wiring.
// See OnWeight - Rules Verification.md for the full cross-check.
// `coach` = the performance "why" behind the cut, surfaced in the bespoke designs.
const FC_SPORTS = {
  Boxing:      { conf: 'verified', weighIn: 'same-or-day-before', equipmentReq: false, equipmentKg: 0, reweigh: false, planType: 'context',
    note: 'World Boxing Elite kg categories (in force 1 Jan 2025). Amateur weighs in same day (no water cut); pro is day-before on a separate ~17-class pound system.',
    coach: 'Amateur boxing weighs in the same day, no time to rehydrate, so the cut is all real weight. Keep your wind and a clear head; dehydration dulls reactions.',
    divisions: {
      men: [['50 kg', 50], ['55 kg', 55], ['60 kg', 60], ['65 kg', 65], ['70 kg', 70], ['75 kg', 75], ['80 kg', 80], ['85 kg', 85], ['90 kg', 90], ['+90 kg', 999]],
      women: [['48 kg', 48], ['51 kg', 51], ['54 kg', 54], ['57 kg', 57], ['60 kg', 60], ['65 kg', 65], ['70 kg', 70], ['75 kg', 75], ['80 kg', 80], ['+80 kg', 999]] } },
  MMA:         { conf: 'verified', weighIn: 'day-before', equipmentReq: false, equipmentKg: 0, reweigh: false, planType: 'camp',
    note: 'Day-before morning weigh-in (early format since 2016). ABC Unified classes; 1-lb non-title allowance.',
    coach: 'Day-before weigh-in gives ~24h to rebuild, so a moderate water cut works. Past ~4 minutes a round leans aerobic, pace the cut to protect round-to-round recovery.',
    divisions: {
      men: [['Flyweight', 56.7], ['Bantamweight', 61.2], ['Featherweight', 65.8], ['Lightweight', 70.3], ['Welterweight', 77.1], ['Middleweight', 83.9], ['Light Heavy', 93.0], ['Heavyweight', 120.2]],
      women: [['Strawweight', 52.2], ['Flyweight', 56.7], ['Bantamweight', 61.2], ['Featherweight', 65.8]] } },
  'Muay Thai': { conf: 'verified', weighIn: 'same-or-day-before', equipmentReq: false, equipmentKg: 0, reweigh: false, planType: 'context', freeEntry: true,
    note: 'No universal class standard (WBC/IFMA/Thai stadiums all differ), enter your limit. Pros ~24h before; amateurs (IFMA) same-day, in fight attire.',
    coach: 'No single Muay Thai standard, so we work from your target. Where it’s same-day, treat it like amateur boxing, real weight only, keep the clinch-and-pace engine intact.',
    divisions: {
      men: [['Flyweight', 51], ['Bantamweight', 53.5], ['Featherweight', 57], ['Lightweight', 61], ['Welterweight', 67], ['Middleweight', 72.5]],
      women: [['Atomweight', 47], ['Flyweight', 50.8], ['Bantamweight', 53.5], ['Featherweight', 57], ['Lightweight', 61]] } },
  BJJ:         { conf: 'verified', weighIn: 'within-2h', equipmentReq: true, equipmentKg: 1.7, reweigh: false, planType: 'minimal',
    note: 'IBJJF: weigh-in IN the gi, minutes before you roll, single weigh-in, no tolerance, almost no rehydration.',
    coach: 'You weigh in dressed, right before you compete, no time to rehydrate. Keep the cut conservative and real; a flat, depleted grappler gasses fast and loses grip.',
    divisions: {
      men: [['Galo', 57.5], ['Pluma', 64], ['Pena', 70], ['Leve', 76], ['Médio', 82.3], ['Meio-Pesado', 88.3], ['Pesado', 94.3], ['Super-Pesado', 100.5], ['Pesadíssimo', 999]],
      women: [['Galo', 48.5], ['Pluma', 53.5], ['Pena', 58.5], ['Leve', 64], ['Médio', 69], ['Meio-Pesado', 74], ['Pesado', 79.3], ['Super-Pesado', 999]] } },
  'BJJ (no-gi)': { conf: 'verified', weighIn: 'within-2h', equipmentReq: true, equipmentKg: 0.5, reweigh: false, planType: 'minimal',
    note: 'IBJJF no-gi: weigh-in just before you roll in your fight kit (rashguard + shorts). Classes run ~1.5–2.5 kg lighter than gi; almost no rehydration window.',
    coach: 'No-gi weigh-in is in your rashguard right before you compete, so barely any gear allowance and no time to rehydrate. Cut conservative and real, keep grip and gas tank, slick scrambles punish a flat body.',
    divisions: {
      men: [['Galo', 55.5], ['Pluma', 61.5], ['Pena', 67.5], ['Leve', 73.5], ['Médio', 79.5], ['Meio-Pesado', 85.5], ['Pesado', 91.5], ['Super-Pesado', 97.5], ['Pesadíssimo', 999]],
      women: [['Galo', 46.5], ['Pluma', 51.5], ['Pena', 56.5], ['Leve', 61.5], ['Médio', 66.5], ['Meio-Pesado', 71.5], ['Pesado', 76.5], ['Super-Pesado', 999]] } },
  Taekwondo:   { conf: 'verified', weighIn: 'day-before', equipmentReq: true, equipmentKg: 1.2, reweigh: true, reweighPct: 5, planType: 'camp',
    note: 'Day-before + random same-day control: must stay ≤ +5% of the category limit.',
    coach: 'Day-before, but a random morning check caps you at +5% of your category, so you can’t over-dehydrate. Lean on that guardrail and protect your explosive, repeated kicks.',
    divisions: {
      men: [['Fly −58', 58], ['Feather −68', 68], ['Middle −80', 80], ['Heavy +80', 999]],
      women: [['Fly −49', 49], ['Feather −57', 57], ['Middle −67', 67], ['Heavy +67', 999]] } },
  Judo:        { conf: 'verified', weighIn: 'day-before', equipmentReq: true, equipmentKg: 1.2, reweigh: true, reweighPct: 5, planType: 'camp',
    note: 'Evening weigh-in + random comp-day control: must stay ≤ +5% of the category limit (IJF 2017).',
    coach: 'Since 2017: weigh in the evening before, then a random morning check caps you at +5% of your category. Cut gradually, heavy training plus a hard cut spikes muscle-damage risk.',
    divisions: {
      men: [['−60', 60], ['−66', 66], ['−73', 73], ['−81', 81], ['−90', 90], ['−100', 100], ['+100', 999]],
      women: [['−48', 48], ['−52', 52], ['−57', 57], ['−63', 63], ['−70', 70], ['−78', 78], ['+78', 999]] } },
  Wrestling:   { conf: 'verified', weighIn: 'same-day', equipmentReq: false, equipmentKg: 0, reweigh: false, planType: 'walk',
    note: 'UWW: weigh-in the morning of competition (in singlet), then wrestle the same day — almost no rehydration window. Multi-day events re-weigh each morning.',
    coach: 'You weigh in the morning you wrestle, so there’s no time to rehydrate — make the cut real weight, not water. Protect your scramble power and gas tank; a flat wrestler gets ridden and gives up the go-behind.',
    divisions: {
      men: [['57 kg', 57], ['61 kg', 61], ['65 kg', 65], ['70 kg', 70], ['74 kg', 74], ['79 kg', 79], ['86 kg', 86], ['92 kg', 92], ['97 kg', 97], ['125 kg', 999]],
      women: [['50 kg', 50], ['53 kg', 53], ['55 kg', 55], ['57 kg', 57], ['59 kg', 59], ['62 kg', 62], ['65 kg', 65], ['68 kg', 68], ['72 kg', 72], ['76 kg', 76]] } },
  Other:       { conf: 'generic', weighIn: 'day-before', equipmentReq: false, equipmentKg: 0, reweigh: false, planType: 'camp', freeEntry: true,
    note: 'Any combat sport not listed. We can’t auto-fill class limits or weigh-in rules, so you confirm your promotion’s limit and timing — the cut science is the same either way.',
    coach: 'We don’t have your promotion’s rule book, so we work from the limit and date you give us and keep the cut gradual and real — that’s what protects strength and stamina in any combat sport.' },
};

// rehydration window by weigh-in timing → max safe fight-week WATER cut (% bw)
const WATER_CAP_BY_TIMING = { 'same-day': 0, 'within-2h': 0.01, 'day-before': 0.05, 'same-or-day-before': 0.0 };

function planTypeFor(sportKey, amateur = true) {
  const s = FC_SPORTS[sportKey];
  if (!s) return 'camp';
  if (s.planType === 'context') return amateur ? 'walk' : 'camp';   // boxing/MT: amateur same-day → walk-it-down
  if (s.planType === 'minimal') return 'minimal';
  return s.planType;
}
function maxWaterCutPct(sportKey, amateur = true) {
  const s = FC_SPORTS[sportKey]; if (!s) return 0.05;
  const timing = s.weighIn === 'same-or-day-before' ? (amateur ? 'same-day' : 'day-before') : s.weighIn;
  return WATER_CAP_BY_TIMING[timing] ?? 0.05;
}

// ── Feasibility (§14.6) ──────────────────────────────────────────────────────
function feasibility(currentWeight, bodyTarget, weeksToWeighIn) {
  const totalKg = currentWeight - bodyTarget;
  const requiredWeekly = totalKg / Math.max(0.1, weeksToWeighIn);
  const ceilingWeekly = currentWeight * 0.01;            // 1%/wk
  return {
    totalKg, requiredWeekly, ceilingWeekly,
    requiredPct: (requiredWeekly / currentWeight) * 100,
    feasible: requiredWeekly <= ceilingWeekly + 1e-6,
    veryHighCut: totalKg / currentWeight > 0.10,
  };
}

// ── Pace gauge classifier (§14.1), symmetric, hard ceiling at 1%/wk ─────────
// targetPct derived from the plan; r = current 7-day loss as %bw/wk.
function plannedRatePct() {
  const FC = window.FC;
  const plannedWeeklyKg = (FC.planStart - FC.goalWeight) / (FC.campDays / 7);
  return (plannedWeeklyKg / FC.planStart) * 100;          // ≈ 0.71 %/wk
}
function currentRatePct(paceKey) {
  const p = window.computePace(paceKey);
  return (Math.abs(p.weeklyVel) / p.trendingToday) * 100;
}
function paceZone(r, target = plannedRatePct()) {
  if (r >= 1.0) return { key: 'unsafe', tone: 'red',   label: 'Unsafe pace' };
  if (r > target + 0.20) return { key: 'fast', tone: 'amber', label: 'Cutting fast' };
  if (r >= target - 0.15) return { key: 'onplan', tone: 'green', label: 'On plan' };
  if (r >= target - 0.40) return { key: 'slow', tone: 'amber', label: 'Behind pace' };
  return { key: 'stalled', tone: 'red', label: 'Stalled' };
}
function projectionVerdict(paceKey) {
  const FC = window.FC, p = window.computePace(paceKey);
  const daysLeft = FC.campDays - FC.todayDay, halfEnd = 0.45 + daysLeft * 0.035;
  const upEnd = p.projected + halfEnd + Math.max(0, p.weighInOffset);
  const loEnd = p.projected - halfEnd;
  if (upEnd <= FC.goalWeight + 0.02) return { key: 'clear', tone: 'green', label: 'On track to make weight' };
  if (loEnd >= FC.goalWeight - 0.02) return { key: 'miss', tone: 'red', label: 'Off track to make weight' };
  return { key: 'straddle', tone: 'amber', label: 'Borderline, could go either way' };
}

// ── Cycle modes (§15.1) + effective offset wrapper ───────────────────────────
const CYCLE_MODES = {
  natural:       { key: 'natural', label: 'Natural cycle', usesPhase: true },
  contraception: { key: 'contraception', label: 'Hormonal contraception', usesPhase: false,
    note: 'Hormonal contraception suppresses the natural cycle, so we don’t apply phase shifts. We watch your logged symptoms instead.' },
  absent:        { key: 'absent', label: 'No / irregular period', usesPhase: false,
    note: 'We won’t predict phases from a calendar. If a period goes missing unexpectedly, we’ll flag it.' },
};
// effective water offset honours the mode: phase model only for 'natural'
function effectiveOffsetKg(campDay, mode = 'natural') {
  return CYCLE_MODES[mode] && CYCLE_MODES[mode].usesPhase ? window.cycleOffsetKg(campDay) : 0;
}

// ── RED-S safeguard (§15.2) ──────────────────────────────────────────────────
// missedCycles = consecutive expected periods not logged; lengthDays = last cycle length
function amenorrheaCheck({ missedCycles = 0, lastCycleLengthDays = 28, mode = 'natural' }) {
  if (mode !== 'natural') return { flag: false };
  if (missedCycles >= 3) return { flag: true, severity: 'amenorrhea', label: 'Period missing 3+ cycles' };
  if (lastCycleLengthDays > 45) return { flag: true, severity: 'oligomenorrhea', label: 'Cycle longer than 45 days' };
  if (missedCycles >= 1) return { flag: true, severity: 'watch', label: 'A period looks late' };
  return { flag: false };
}

// ── Per-sport performance "why" (§17.4), what the cut must PROTECT ───────────
// `tag` = a 1–2 word chip; `protect` = the performance trait the cut threatens
// first (reach, power, grip, the engine…). Surfaced in onboarding so the athlete
// sees the cut framed around their sport, not a generic number.
// ── Social proof content, by sport (§ see "OnWeight - Social Proof Research.md") ──
// Pre-launch, we borrow trust with REAL, cited category facts — not fake user
// counts or testimonials. `stat` = the headline number for this sport; `line` =
// the recognition/credibility beat; `coachLine` = a short reinforce for the
// Coach's Read. `src` = the citation tag. Falls back to the combat-wide figure.
const FC_SOCIAL_PROOF = {
  MMA:         { stat: '88–95%', statOf: 'of MMA fighters cut to make weight',
    line: 'Among the highest rates in combat sport — how you cut decides how you walk in.',
    coachLine: 'Almost every MMA fighter cuts. The edge is doing it like a pro.', src: 'Combat Sports Law · MDPI 2019',
    edge: { stat: '+4.5%', line: 'higher win chance for every 1% of bodyweight you rehydrate after weigh-in.', src: 'Faro et al. — largest real-world MMA sample' } },
  Boxing:      { stat: '92%', statOf: 'of elite boxers cut weight before a fight',
    line: 'One of the two highest-cutting sports. Same-day weigh-ins mean it has to be real weight — not water.',
    coachLine: 'Over 9 in 10 elite boxers cut. Same-day weigh-in rewards a real cut.', src: 'MDPI Healthcare 2022' },
  'Muay Thai': { stat: '5%+', statOf: 'of bodyweight cut routinely — among the steepest in the sport',
    line: 'Muay Thai cuts run past 5% of bodyweight more than almost any sport. Staying inside the ceiling is what keeps you sharp.',
    coachLine: 'Muay Thai runs steep. Inside the ceiling is how you walk in sharp.', src: 'MDPI Nutrients 2024' },
  BJJ:         { stat: '59%', statOf: 'of BJJ competitors cut to make their division',
    line: '57–88% across studies — and the smart ones do it through diet, not dehydration; you weigh in dressed, minutes before you roll.',
    coachLine: 'Most BJJ players cut — the smart ones through diet, not dehydration.', src: 'UK BJJ 2021 · Frontiers 2023' },
  'BJJ (no-gi)': { stat: '59%', statOf: 'of no-gi competitors cut to make their division',
    line: 'No-gi weigh-ins happen minutes before you roll — no time to rehydrate, so the cut has to leave you fresh.',
    coachLine: 'No-gi weighs in right before you roll — the cut has to leave you fresh.', src: 'UK BJJ 2021 · Frontiers 2023' },
  Judo:        { stat: '90%', statOf: 'of judoka have cut to make a category',
    line: 'With the random +5% morning check, gradual is the only cut that works — which is exactly how OnWeight paces it.',
    coachLine: 'Around 9 in 10 judoka cut. The +5% check rewards a gradual one.', src: 'JISSN 2012 · MDPI 2022' },
  Taekwondo:   { stat: '63%', statOf: 'of taekwondo athletes cut for competition',
    line: 'The +5% same-day control rewards a steady cut over a crash — exactly what OnWeight builds.',
    coachLine: 'Nearly two-thirds of TKD athletes cut. The +5% check rewards steady.', src: 'JISSN 2012' },
  Wrestling:   { stat: '90%', statOf: 'of wrestlers cut before competition',
    line: 'Among the most cut-heavy of any sport (up to 9 in 10). Weigh in and wrestle the same day means real weight, not a crash.',
    coachLine: 'Wrestlers cut more than almost anyone. Same-day means real weight.', src: 'JISSN 2012 · MDPI 2022' },
};
// Combat-sports-wide fallback + the cross-sport authority/safety beat.
const FC_SOCIAL_PROOF_DEFAULT = { stat: '60–80%', statOf: 'of combat-sport athletes cut weight',
  line: 'The difference isn\u2019t whether you cut — it\u2019s whether it\u2019s planned or panicked.',
  coachLine: 'Most fighters cut. The edge is a plan, not a panic.', src: 'Frontiers 2025; MDPI Sports 2019' };
const FC_SOCIAL_PROOF_AUTHORITY = 'Sports-science consensus: keep a cut under ~5% of bodyweight and it won\u2019t cost you performance. Every OnWeight plan is built to that ceiling.';
function socialProofFor(sportKey) { return FC_SOCIAL_PROOF[sportKey] || FC_SOCIAL_PROOF_DEFAULT; }

// Real, attributable fighter quotes on the weight cut (researched, NOT fabricated —
// see "OnWeight - Social Proof Research.md"). Balanced male/female, elite & widely
// respected, cross-combat credibility (Harrison = 2x Olympic judo gold; Whittaker
// = karateka). Kept short + attributed. Every line is verbatim (… marks a trim).
const FC_FIGHTER_QUOTES = [
  { quote: 'My previous fight camps were all about the weight cut\u2026 the fight had become an afterthought.',
    who: 'Robert Whittaker', cred: 'Former UFC Middleweight Champion', src: 'Fighters Only' },
  { quote: 'It\u2019s chosen suffering\u2026 and it\u2019ll be worth it.',
    who: 'Kayla Harrison', cred: '2\u00d7 Olympic judo gold \u00b7 UFC Bantamweight Champion', src: 'The Ariel Helwani Show, 2025' },
  { quote: 'Camp performance really suffers on the back end of a weight cut, and a lot of fighters can testify.',
    who: 'Robert Whittaker', cred: 'Former UFC Middleweight Champion', src: 'Submission Radio, 2026' },
  { quote: 'It\u2019s torture. But everything I\u2019ve wanted is on the other side of that torture.',
    who: 'Kayla Harrison', cred: '2\u00d7 Olympic judo gold \u00b7 UFC Bantamweight Champion', src: 'Uncrowned, 2025' },
];

// Sport × sex bespoke social proof — durable records only (sourced; see the
// “Social-Proof Matrix” doc). Surfaced on the biological-sex step BEFORE a division
// is chosen, so these are sport + gender anchors, not per-class. Nothing invented;
// records chosen to not date (“first…”, “most…”, Olympic golds, retired greats).
const FC_PROOF = {
  MMA: {
    F: { fact: 'Amanda Nunes is the only woman to win UFC titles in two divisions — and retired as the consensus GOAT.', who: 'Amanda Nunes', cred: 'Two-division UFC champion' },
    M: { fact: 'Khabib Nurmagomedov retired undefeated as UFC lightweight champion.', who: 'Khabib Nurmagomedov', cred: 'Undefeated UFC champion' },
  },
  Boxing: {
    F: { fact: 'Katie Taylor won Olympic gold, then became the first undisputed female lightweight champion.', who: 'Katie Taylor', cred: 'Olympic gold · undisputed champ' },
    M: { fact: 'Oleksandr Usyk ruled both cruiserweight and heavyweight as an undisputed champion.', who: 'Oleksandr Usyk', cred: 'Two-weight undisputed champ' },
  },
  Judo: {
    F: { fact: 'Clarisse Agbegnenou leads all women in Olympic judo gold — and defended her title after giving birth.', who: 'Clarisse Agbegnenou', cred: 'Olympic & world champion' },
    M: { fact: 'Teddy Riner is the most decorated Olympic judoka ever, with 12 world titles.', who: 'Teddy Riner', cred: 'Most decorated Olympic judoka' },
  },
  Wrestling: {
    F: { fact: 'Kaori Icho was the first woman in any sport to win individual gold at four straight Olympics.', who: 'Kaori Icho', cred: '4× Olympic champion' },
    M: { fact: 'Mijáin López won four Olympic golds — among the most successful Olympic wrestlers ever.', who: 'Mijáin López', cred: '4× Olympic champion' },
  },
  Taekwondo: {
    F: { fact: 'Panipak Wongpattanakit became Thailand’s first two-time Olympic gold medallist in any sport.', who: 'Panipak Wongpattanakit', cred: '2× Olympic champion' },
    M: { fact: 'Hadi Saei, a two-time Olympic champion, is one of Iran’s greatest Olympians.', who: 'Hadi Saei', cred: '2× Olympic champion' },
  },
  BJJ: {
    F: { fact: 'Beatriz Mesquita holds 10 world titles — the most in women’s jiu-jitsu history.', who: 'Beatriz Mesquita', cred: '10× world champion' },
    M: { fact: 'Marcus “Buchecha” Almeida holds the record 13 world titles — more than anyone in history.', who: 'Marcus Almeida', cred: '13× world champion' },
  },
  'Muay Thai': {
    F: { fact: 'Women hold world Muay Thai titles too — like Anissa Meksen, a multi-organisation world champion.', who: 'Anissa Meksen', cred: 'Multi-org world champion' },
    M: { fact: 'Samart Payakaroon won Muay Thai and world boxing titles — widely the most gifted Nak Muay ever.', who: 'Samart Payakaroon', cred: 'Muay Thai & boxing champion' },
  },
};
function bespokeProof(sportKey, sex) {
  if (sex !== 'F' && sex !== 'M') return null;
  let key = sportKey === 'BJJ (no-gi)' ? 'BJJ' : sportKey;
  const e = FC_PROOF[key];
  return e ? e[sex] : null;
}

const FC_SPORT_PROTECT = {
  Boxing:      { tag: 'Reach · speed',    protect: 'Your reach, hand speed and a clear head, dehydration blunts reactions before it ever touches raw strength.' },
  MMA:         { tag: 'Your engine',      protect: 'Your round-to-round engine. Past about four minutes a round runs mostly aerobic, so the cut can’t cost you recovery between exchanges.' },
  'Muay Thai': { tag: 'Clinch · pace',    protect: 'Clinch strength and a sustained pace, the aerobic base that carries you through five hard rounds.' },
  BJJ:         { tag: 'Grip · gas',       protect: 'Grip and anaerobic capacity. A flat, depleted grappler gasses out and loses the grip battle fast.' },
  'BJJ (no-gi)': { tag: 'Grip · gas',     protect: 'Grip, anaerobic capacity and scramble speed. No-gi rewards a fresh, explosive body, a depleted one gets passed and gassed.' },
  Taekwondo:   { tag: 'Explosive power',  protect: 'Explosive, repeatable kicks, speed and power off both legs, round after round.' },
  Judo:        { tag: 'Power · grip',     protect: 'Explosive power and grip strength, and with a heavy training load, a gradual cut guards against injury.' },
  Wrestling:   { tag: 'Scramble · gas',  protect: 'Explosive scramble power and your gas tank. Same-day weigh-in leaves no time to rehydrate, so a hard water cut just leaves you flat in the first period.' },
  Other:       { tag: 'Strength · gas',  protect: 'Your strength and stamina across the whole fight — for any combat sport, a gradual, real cut is what keeps both intact.' },
};

// ── Division recommender (§14.4), suggest the natural class for this athlete ──
// Nearest official division at or below current weight (a sensible "make weight
// without crashing" target). Returns null for free-entry sports (e.g. Muay Thai).
function recommendDivision(sportKey, currentWeight, sex = 'men') {
  const s = FC_SPORTS[sportKey];
  if (!s || s.freeEntry || !s.divisions) return null;
  const list = (s.divisions[sex] || s.divisions.men || []).filter(d => d[1] < 900);
  if (!list.length) return null;
  // nearest division at/just below current weight; else the lowest available.
  const atOrBelow = list.filter(d => d[1] <= currentWeight);
  const pick = atOrBelow.length ? atOrBelow[atOrBelow.length - 1] : list[0];
  const bodyTarget = +(pick[1] - (s.equipmentReq ? s.equipmentKg : 0)).toFixed(1);
  const cutKg = +(currentWeight - bodyTarget).toFixed(1);
  return {
    name: pick[0], limitKg: pick[1], bodyTarget,
    cutKg, cutPct: +((cutKg / currentWeight) * 100).toFixed(1),
    dressed: s.equipmentReq,
  };
}

// ── Tracking cascade (§9.1), the evidence-based on-track judgement ───────────
// Escalates through timescales so daily water noise never drives a verdict. Every
// comparison is against the CYCLE-ADJUSTED expected weight, never a raw line.
// Thresholds are evidence-set (§9.1): daily ±0.2 kg silent gate; 3-day ±0.4 kg
// matched to its real noise; the actual verdict comes from the 7-day rate.
const TRACK_BAND_DAILY = 0.2;     // kg, silent "fine" gate vs cycle-adjusted expected
const TRACK_BAND_3DAY = 0.4;      // kg, 3-day provisional filter (≈0.5%bw, matched to noise)
const SAFE_RATE_PCT = 1.0;        // %/wk hard ceiling (trigger B)

// expected SCALE weight on a given camp day = true linear plan + cycle water
function expectedScaleAt(campDay) {
  const FC = window.FC;
  const truePlan = FC.planStart + (FC.goalWeight - FC.planStart) * (campDay / FC.campDays);
  return truePlan + window.cycleOffsetKg(campDay);
}
// trackingStatus: returns {stage, verdict, tone, replan, detail}
//   stage  = where the cascade settled ('daily' | '3day' | 'rate')
//   verdict= 'onTrack' | 'fast' | 'behind' | 'unsafe'
//   replan = null | 'projection' | 'safety'  (which re-plan trigger fired, if any)
function trackingStatus(paceKey = 'onTrack') {
  const FC = window.FC, p = window.computePace(paceKey);
  const expected = expectedScaleAt(FC.todayDay);
  // Step 0/1, displayed trend weight is the EWMA; daily silent gate vs expected.
  const trend = p.trendingToday;
  const dailyDelta = trend - expected;            // + = heavier than plan, − = lighter
  if (Math.abs(dailyDelta) <= TRACK_BAND_DAILY) {
    return { stage: 'daily', verdict: 'onTrack', tone: 'green', replan: null,
      detail: { expected, trend, dailyDelta, band: TRACK_BAND_DAILY } };
  }
  // Step 2, 3-day provisional filter (wider band, matched to its real noise).
  const win3 = p.trend.slice(-3).map(x => x[1]);
  const avg3 = win3.reduce((a, b) => a + b, 0) / Math.max(1, win3.length);
  const delta3 = avg3 - expected;
  if (Math.abs(delta3) <= TRACK_BAND_3DAY) {
    return { stage: '3day', verdict: 'onTrack', tone: 'green', replan: null,
      detail: { expected, avg3, delta3, band: TRACK_BAND_3DAY } };
  }
  // Step 3, the rate verdict (7-day vs prior-7-day true-weight rate).
  const rate = currentRatePct(paceKey);           // %bw/wk, current trend rate
  const zone = paceZone(rate);                    // unsafe | fast | onplan | slow | stalled
  const over = delta3 > 0;                         // heavier than plan → behind; lighter → too fast
  // re-plan triggers (§9.2): (B) safety ceiling regardless of projection
  if (rate >= SAFE_RATE_PCT) {
    return { stage: 'rate', verdict: 'unsafe', tone: 'red', replan: 'safety',
      detail: { expected, avg3, delta3, rate, zone } };
  }
  // (A) projection miss, confirmed by the cone verdict (hysteresis lives in the
  // confirm count the UI tracks; here we report the projection's read).
  const proj = window.projectionVerdict(paceKey);
  if (over && proj.key === 'miss') {
    return { stage: 'rate', verdict: 'behind', tone: 'red', replan: 'projection',
      detail: { expected, avg3, delta3, rate, zone, proj } };
  }
  return { stage: 'rate', verdict: over ? 'behind' : 'fast', tone: 'amber', replan: null,
    detail: { expected, avg3, delta3, rate, zone, proj } };
}

// ── Drift-watch (§9.5), the CUSUM early-warning layer ────────────────────────
// Answers "7 days is too late for a fighter": a one-sided CUSUM accumulates each
// day's small deviation from the cycle-adjusted expected weight, so a CONSISTENT
// drift in one direction is caught in ~2–3 days, well before a flat 7-day rate
// could admit it, WITHOUT reacting to single-day water noise. This is the soft
// early-warning tier; it never fires the re-plan flow (that stays with the
// confirmed rate verdict, §9.2). Standard CUSUM form: S = max(0, S + (x − k)).
//   k (slack) = the daily noise we deliberately ignore (~0.15 kg).
//   h (decision limit) = how much accumulated drift trips the watch; it TIGHTENS
//     as the deadline nears (lost runway is costliest late, §9.5).
function driftWatch(paceKey = 'onTrack') {
  const FC = window.FC, p = window.computePace(paceKey);
  const k = 0.15;                                   // kg/day slack, ignore this much as noise
  const daysOut = FC.daysOut;
  const h = daysOut <= 14 ? 0.45 : daysOut <= 28 ? 0.65 : 0.85;  // tightens late
  const tr = p.trend;                               // EWMA-smoothed series [day, kg]
  const start = Math.max(1, tr.length - 14);        // trailing fortnight
  let sHigh = 0, sLow = 0, hiPeak = 0, loPeak = 0, hiDays = 0, loDays = 0;
  for (let i = start; i < tr.length; i++) {
    const expected = expectedScaleAt(tr[i][0]);
    const dev = tr[i][1] - expected;                // + heavier than plan, − lighter
    sHigh = Math.max(0, sHigh + (dev - k));         // behind (losing too slow)
    sLow = Math.max(0, sLow + (-dev - k));          // ahead (losing too fast)
    hiDays = sHigh > 0 ? hiDays + 1 : 0;
    loDays = sLow > 0 ? loDays + 1 : 0;
    hiPeak = Math.max(hiPeak, sHigh); loPeak = Math.max(loPeak, sLow);
  }
  const behind = sHigh >= h, fast = sLow >= h;
  const status = behind ? 'behind' : fast ? 'fast' : 'clear';
  return {
    status, h, k, sHigh: +sHigh.toFixed(2), sLow: +sLow.toFixed(2),
    days: behind ? hiDays : fast ? loDays : 0,
    pct: Math.min(1, (behind ? sHigh : fast ? sLow : Math.max(hiPeak, loPeak)) / h),
    tightened: daysOut <= 28,
  };
}

// Learned cycle model (§15.4), demo personalised state ───────────────────────
// What the app would derive from this athlete's last few confirmed cycles.
const LEARNED_CYCLE = {
  cyclesObserved: 3,
  populationPeakKg: 0.5, populationPeakDay: 24,
  learnedPeakKg: 1.4, learnedPeakDay: 26,        // this athlete holds more, later
  confidence: 'medium',                          // low | medium | high
  topSymptoms: ['Bloating', 'Cravings', 'Low energy'],
  blendKg: 1.3,                                  // current blended value cold-start→learned
};

Object.assign(window, {
  FC_SPORTS, FC_SPORT_PROTECT, FC_PROOF, bespokeProof, FC_SOCIAL_PROOF, FC_SOCIAL_PROOF_DEFAULT, FC_SOCIAL_PROOF_AUTHORITY, FC_FIGHTER_QUOTES, socialProofFor, recommendDivision, WATER_CAP_BY_TIMING, planTypeFor, maxWaterCutPct, feasibility,
  plannedRatePct, currentRatePct, paceZone, projectionVerdict,
  expectedScaleAt, trackingStatus, driftWatch, TRACK_BAND_DAILY, TRACK_BAND_3DAY, SAFE_RATE_PCT,
  CYCLE_MODES, effectiveOffsetKg, amenorrheaCheck, LEARNED_CYCLE,
});
