// Per-user GM decision layer. The shared board remains league-wide and stable;
// this layer personalizes only advice, button pick, and clock-expiry pick.
// Ask GM stays a local rules engine: classify intent, score with weights, format.

const ADP_SD_BY_ROUND = [19, 19, 19, 23, 20, 22, 22, 23, 23, 21, 28, 25, 23, 22, 24, 28];

const BASE_GM_WEIGHTS = {
  ranking: 0.22,
  value: 0.18,
  rosterFit: 0.18,
  tierUrgency: 0.14,
  survivalRisk: 0.12,
  marginal: 0.10,
  cf: 0.06,
  adpDiscount: 0,
  floor: 0,
  boom: 0,
  ceiling: 0,
  expected: 0,
};

const INTENT_WEIGHTS = {
  best: { ...BASE_GM_WEIGHTS },
  value: {
    ranking: 0.10, value: 0.26, rosterFit: 0.08, tierUrgency: 0.08,
    survivalRisk: 0.18, marginal: 0.04, cf: 0.06, adpDiscount: 0.20,
    floor: 0, boom: 0, ceiling: 0, expected: 0,
  },
  position: {
    ranking: 0.16, value: 0.12, rosterFit: 0.22, tierUrgency: 0.20,
    survivalRisk: 0.16, marginal: 0.08, cf: 0.06, adpDiscount: 0,
    floor: 0, boom: 0, ceiling: 0, expected: 0,
  },
  safe: {
    ranking: 0.10, value: 0.12, rosterFit: 0.14, tierUrgency: 0.08,
    survivalRisk: 0.08, marginal: 0.08, cf: 0.10, adpDiscount: 0,
    floor: 0.16, boom: -0.06, ceiling: 0, expected: 0.14,
  },
  upside: {
    ranking: 0.10, value: 0.10, rosterFit: 0.10, tierUrgency: 0.16,
    survivalRisk: 0.08, marginal: 0.06, cf: 0.08, adpDiscount: 0,
    floor: 0, boom: 0.16, ceiling: 0.16, expected: 0,
  },
  need: {
    ranking: 0.10, value: 0.10, rosterFit: 0.28, tierUrgency: 0.12,
    survivalRisk: 0.10, marginal: 0.20, cf: 0.06, adpDiscount: 0.04,
    floor: 0, boom: 0, ceiling: 0, expected: 0,
  },
};

function adpSdForRound(round) {
  return ADP_SD_BY_ROUND[Math.min(Math.max(1, round), 16) - 1] || 23;
}

function normalCdf(x, mean = 0, sd = 1) {
  const z = (x - mean) / (sd * Math.SQRT2);
  const t = 1 / (1 + 0.3275911 * Math.abs(z));
  const y = 1 - (((((1.061405429 * t - 1.453152027) * t) + 1.421413741) * t - 0.284496736) * t + 0.254829592) * t * Math.exp(-z * z);
  return 0.5 * (1 + (z >= 0 ? y : -y));
}

function survivalProbability(player, pickNumber, targetPick, teamCount = 12) {
  if (!targetPick || targetPick <= pickNumber) return 1;
  const round = Math.max(1, Math.ceil(pickNumber / teamCount));
  const sd = adpSdForRound(round);
  const expected = player.liveExpectedPick || player.adp || pickNumber;
  return 1 - normalCdf(targetPick, expected, sd);
}

function survivalBucket(survives) {
  if (survives < 0.30) return "likely gone";
  if (survives < 0.55) return "coin flip";
  return "likely available";
}

function lineupPoints(roster, rosterSlots) {
  const lineup = window.buildScoredLineup(roster, rosterSlots);
  return lineup
    .filter(item => item.slot !== "BENCH" && item.player)
    .reduce((sum, item) => sum + seasonProj(item.player), 0);
}

function starterGainFor(player, userRoster, rosterSlots) {
  if (!rosterSlots || !rosterSlots.length || !window.buildScoredLineup) return 0;
  return lineupPoints([...userRoster, player], rosterSlots) - lineupPoints(userRoster, rosterSlots);
}

function marginalValueOverRoster(player, userRoster, rosterSlots, fallback = 0) {
  if (!rosterSlots || !rosterSlots.length) return fallback;
  const gain = starterGainFor(player, userRoster, rosterSlots);
  const rosterFill = Math.min(1, userRoster.length / Math.max(1, rosterSlots.length * 0.5));
  return gain * rosterFill + fallback * (1 - rosterFill);
}

function runPressure(position, pickNumber, nextUserPick, draftOrder, rosters, teams, rosterSlots) {
  if (!nextUserPick || nextUserPick <= pickNumber) return 0;
  const neededBySlot = {
    QB: Math.max(1, (rosterSlots || []).filter(slot => slot === "QB" || slot === "SUPERFLEX").length),
    RB: Math.max(1, (rosterSlots || []).filter(slot => slot === "RB").length),
    WR: Math.max(1, (rosterSlots || []).filter(slot => slot === "WR").length),
    TE: Math.max(1, (rosterSlots || []).filter(slot => slot === "TE").length),
  };
  let needy = 0;
  let total = 0;

  for (let i = pickNumber - 1; i < nextUserPick - 1 && i < draftOrder.length; i += 1) {
    const teamNumber = draftOrder[i];
    const team = teams[teamNumber - 1];
    if (!team) continue;
    total += 1;
    const counts = window.countPositions(rosters[team.id] || []);
    if ((counts[position] || 0) < (neededBySlot[position] || 1)) needy += 1;
  }

  return total ? needy / total : 0;
}

function clamp01to100(value) {
  return Math.max(0, Math.min(100, Number(value) || 0));
}

function weeklyProj(player) {
  const format = scoringOf(null, player);
  if (typeof window.formatSeasonPoints === "function") {
    const season = window.formatSeasonPoints(player, format);
    if (Number.isFinite(season) && season > 0) return season / 17;
  }
  return Number(player.adjusted || player.proj || 0) || 0;
}

function scoringOf(ctx, player) {
  return ctx?.scoring || player?.scoring || window.ACTIVE_DRAFT_SCORING || "ppr";
}

function seasonProj(player, scoring) {
  const format = scoring || player?.scoring || window.ACTIVE_DRAFT_SCORING || "ppr";
  if (typeof window.formatSeasonPoints === "function") {
    const total = window.formatSeasonPoints(player, format);
    if (Number.isFinite(total) && total > 0) return total;
  }
  return weeklyProj(player) * 17;
}

function fmtPpg(player) {
  return weeklyProj(player).toFixed(1);
}

function rankingTerm(player) {
  const projection = player.profile?.projection || {};
  if (Number.isFinite(projection.rankingScore)) return clamp01to100(projection.rankingScore);
  if (Number.isFinite(player.rankingScore)) return clamp01to100(player.rankingScore);
  const rank = Number(player.displayRank || player.overallRank || player.rank || player.adp || 120);
  return clamp01to100(100 - Math.min(99, rank * 0.55));
}

function boomPct(player) {
  return Number(player.boom ?? player.profile?.projection?.boomPct ?? 0) || 0;
}

function bustPct(player) {
  return Number(player.bust ?? player.profile?.projection?.bustPct ?? 0) || 0;
}

function expectedPct(player) {
  const fromProfile = Number(player.profile?.projection?.expectedPct);
  if (Number.isFinite(fromProfile)) return fromProfile;
  return Math.max(0, 100 - boomPct(player) - bustPct(player));
}

function cfTerm(player) {
  const proj = weeklyProj(player) || 1;
  const floor = Number(player.floor || 0);
  const ceiling = Number(player.ceiling || proj);
  const tightness = Math.max(0, Math.min(1, floor / proj));
  const upside = Math.max(0, (ceiling - proj) / Math.max(1, proj));
  return clamp01to100(40 + tightness * 40 + Math.min(20, upside * 25));
}

function byeWeekOf(player) {
  const teamRow = (window.NFL_SOS || []).find((row) => row.team === player.team);
  return Number(teamRow?.byeWeek || player.byeWeek || player.bye) || null;
}

function valueLetterOf(player) {
  return window.seasonValueGrade ? window.seasonValueGrade(player).letter : (player.valueLetter || "C");
}

function valueScoreOf(player) {
  return window.seasonValueScore ? window.seasonValueScore(player) : 60;
}

function roleOf(player) {
  return window.roleProjectionLabel ? window.roleProjectionLabel(player) : "BENCH";
}

function gmRequestedPosition(q) {
  if (/\brb\b|\brb1\b|\brb2\b|running back/.test(q)) return "RB";
  if (/\bwr\b|\bwr1\b|\bwr2\b|wide receiver/.test(q)) return "WR";
  if (/\bqb\b|\bqb1\b|quarterback/.test(q)) return "QB";
  if (/\bte\b|\bte1\b|tight end/.test(q)) return "TE";
  if (/\bdst\b|defense/.test(q)) return "DST";
  if (/\bkicker|\bk\b/.test(q) && !/\bqb\b|\brb\b|\bwr\b|\bte\b/.test(q)) return "K";
  return null;
}

const GM_NAME_STOP = new Set([
  "will", "this", "that", "wait", "best", "safe", "take", "make", "back", "last",
  "need", "what", "who", "should", "floor", "round", "pick", "still", "there",
  "your", "next", "from", "have", "been", "with", "into", "over", "than", "then",
  "when", "does", "just", "also", "only", "them", "they", "him", "her",
]);

function gmNameToken(token) {
  const clean = String(token || "").toLowerCase().replace(/[^a-z]/g, "");
  if (clean.length < 4 || GM_NAME_STOP.has(clean)) return null;
  return clean;
}

function matchGmNamedPlayers(q, board) {
  const text = String(q || "").toLowerCase();
  if (!text) return [];
  const hits = [];
  (board || []).forEach((player) => {
    const full = String(player.name || "").toLowerCase();
    if (!full) return;
    const parts = full.split(/\s+/);
    const first = gmNameToken(parts[0]);
    const last = gmNameToken(parts[parts.length - 1]);
    const firstHit = first && new RegExp(`\\b${first}\\b`).test(text);
    const lastHit = last && new RegExp(`\\b${last}\\b`).test(text);
    if (text.includes(full) || firstHit || lastHit) hits.push(player);
  });
  const seen = new Set();
  return hits.filter((player) => {
    if (seen.has(player.id)) return false;
    seen.add(player.id);
    return true;
  });
}

function classifyGmIntent(question, board) {
  const q = String(question || "").toLowerCase().trim();
  const pos = gmRequestedPosition(q);
  const players = matchGmNamedPlayers(q, board);
  const orWait = players.length >= 1 && /\bor wait\b|\bwait on (him|them|this)\b/.test(q);

  if (!q) return { intent: "best", pos: null, players: [], orWait: false };
  if (players.length >= 2 || orWait) return { intent: "compare", pos, players, orWait };
  if (/make it back|still be there|will (he|she|this|they).*(be there|make it)|can i wait/.test(q)) {
    return { intent: "survival", pos, players, orWait: false };
  }
  if (/tier break|tier cliff|last (wr|rb|te|qb|player)|end of (the )?tier/.test(q)) {
    return { intent: "tier", pos, players, orWait: false };
  }
  if (/draft grade|what happens if|improves my draft|if i take/.test(q)) {
    return { intent: "grade", pos, players, orWait: false };
  }
  if (/what do i need|bench (upside|or)|starter floor|roster fit|bye overlap/.test(q)) {
    return { intent: "rosterFit", pos, players, orWait: false };
  }
  if (/\bneed\b/.test(q) && !pos) return { intent: "need", pos, players, orWait: false };
  if (pos) return { intent: "position", pos, players, orWait: false };
  if (/\bsafe|safest|floor\b/.test(q)) return { intent: "safe", pos, players, orWait: false };
  if (/upside|ceiling|boom/.test(q)) return { intent: "upside", pos, players, orWait: false };
  if (/\bvalue\b|steal|discount/.test(q)) return { intent: "value", pos, players, orWait: false };
  if (/\bbest\b|who should i take|who do i take/.test(q)) return { intent: "best", pos, players, orWait: false };
  return { intent: "unknown", pos, players, orWait: false };
}

function normalizeWeights(raw) {
  const keys = Object.keys(BASE_GM_WEIGHTS);
  let positive = 0;
  keys.forEach((key) => {
    if ((raw[key] || 0) > 0) positive += raw[key];
  });
  const out = {};
  keys.forEach((key) => {
    const weight = raw[key] || 0;
    out[key] = weight > 0 && positive ? weight / positive : weight;
  });
  return out;
}

function positionPointGap(player, board, scoring) {
  const peers = (board || [])
    .filter((row) => row.pos === player.pos && row.id !== player.id)
    .sort((a, b) => seasonProj(b, scoring) - seasonProj(a, scoring));
  const next = peers[0];
  if (!next) return 0;
  return Math.max(0, seasonProj(player, scoring) - seasonProj(next, scoring));
}

function isTierCliffPlayer(player, board, scoring) {
  return positionPointGap(player, board, scoring) >= 12 || (player.gmFeatures?.valueLetter === "A+" && positionPointGap(player, board, scoring) >= 6);
}

function buildGmFeatures(player, ctx, extras) {
  const {
    pickNumber, nextUserPick, teamCount = 12, userRoster = [], rosterSlots = [],
  } = ctx || {};
  const projection = player.profile?.projection || {};
  const letter = valueLetterOf(player);
  const role = roleOf(player);
  const bye = byeWeekOf(player);
  const counts = extras.counts || {};
  const need = window.rosterNeedScore
    ? window.rosterNeedScore(player.pos, counts, pickNumber, rosterSlots)
    : 0;
  const starterGain = extras.starterGain ?? 0;
  const overlap = (userRoster || []).some((row) => byeWeekOf(row) === bye && bye && row.pos === player.pos);
  const ranking = rankingTerm(player);
  const value = valueScoreOf(player);
  const weekly = weeklyProj(player);
  const season = seasonProj(player, scoringOf(ctx, player));
  const floor = Number(player.floor || weekly * 0.75);
  const ceiling = Number(player.ceiling || weekly * 1.2);
  const sos = window.scheduleEaseRank ? window.scheduleEaseRank(player) : null;
  const marketDelta = Number(player.marketDelta || 0);
  const adpDiscount = clamp01to100(50 + Math.max(-40, Math.min(40, marketDelta * 2.2)));
  const rosterFit = clamp01to100(50 + need * 18 + (starterGain > 8 ? 16 : starterGain > 0 ? 6 : -8) - (overlap ? 12 : 0));
  const tierUrgency = clamp01to100(40 + Math.min(40, extras.pointGap * 2) + (letter === "A+" ? 12 : 0));
  const survivalRisk = clamp01to100((1 - extras.survives) * Math.min(1, extras.dropoff / 24) * 100);
  const marginalTerm = clamp01to100(Number(extras.marginal || 0) * 1.2);
  const floorTerm = clamp01to100((floor / Math.max(1, weekly)) * 90);
  const boomTerm = clamp01to100(boomPct(player));
  const ceilingTerm = clamp01to100((ceiling / Math.max(1, weekly)) * 50);
  const expectedTerm = clamp01to100(expectedPct(player));

  return {
    ranking,
    value,
    rosterFit,
    tierUrgency,
    survivalRisk,
    marginal: marginalTerm,
    cf: cfTerm(player),
    adpDiscount,
    floor: floorTerm,
    boom: boomTerm,
    ceiling: ceilingTerm,
    expected: expectedTerm,
    role,
    valueLetter: letter,
    weekly,
    season,
    floorPts: floor,
    ceilingPts: ceiling,
    boomPct: boomPct(player),
    bustPct: bustPct(player),
    expectedPct: expectedPct(player),
    risk: player.risk || "med",
    sos,
    bye,
    byeOverlap: overlap,
    starterGain,
    need,
    marketDelta,
    adp: Number(player.adp || 0),
    positionRank: Number(player.positionRank || projection.positionRank || 0),
    overallRank: Number(player.displayRank || player.overallRank || player.rank || 0),
    waitCost: extras.dropoff,
    survives: extras.survives,
    bucket: survivalBucket(extras.survives),
    secondQbPenalty: extras.secondQbPenalty || false,
    needRbNow: extras.needRbNow || false,
    rbFoundationCliff: extras.rbFoundationCliff || false,
    wrFoundationCliff: extras.wrFoundationCliff || false,
    committeeSplit: extras.committeeSplit || false,
    cleanerBackfield: extras.cleanerBackfield || false,
    wingMustTake: extras.wingMustTake || false,
    canSlideToNextPick: extras.canSlideToNextPick || false,
    qbDelay: extras.qbDelay || false,
    qbLateFill: extras.qbLateFill || false,
    wingGap: extras.wingGap || 0,
  };
}

function positionRankOf(player) {
  return Number(
    player.positionRank
    || player.gmFeatures?.positionRank
    || player.profile?.projection?.positionRank
    || 0,
  );
}

function inFoundationBand(player) {
  const rank = positionRankOf(player);
  if (player.pos === "RB") return rank > 0 ? rank <= 14 : (Number(player.overallRank || player.displayRank || 99) <= 36);
  if (player.pos === "WR") return rank > 0 ? rank <= 18 : (Number(player.overallRank || player.displayRank || 99) <= 40);
  if (player.pos === "QB") return rank > 0 ? rank <= 8 : (Number(player.overallRank || player.displayRank || 99) <= 24);
  if (player.pos === "TE") return rank > 0 ? rank <= 6 : false;
  return false;
}

function foundationCliff(players, pos, wingGap) {
  const now = (players || []).filter((player) => player.pos === pos && inFoundationBand(player));
  if (!now.length) return false;
  const later = now.filter((player) => (player.survives || 0) >= 0.45);
  if (later.length === 0) return true;
  return wingGap >= 12 && now.length <= 2 && later.length <= 1;
}

function gmCandidateScore(player, intent = "best") {
  const weights = normalizeWeights(INTENT_WEIGHTS[intent] || INTENT_WEIGHTS.best);
  const f = player.gmFeatures || {};
  let score = 0;
  Object.keys(weights).forEach((key) => {
    score += (weights[key] || 0) * (Number(f[key]) || 0);
  });
  if ((player.risk || f.risk) === "high") score -= 12;
  else if ((player.risk || f.risk) === "med") score -= 5;
  if (f.byeOverlap) score -= 8;
  if ((f.marketDelta || 0) < -9) score -= 10;
  if (f.secondQbPenalty) score -= 55;
  if (f.qbDelay && player.pos === "QB") score -= 22;
  if (f.qbLateFill && player.pos === "QB") score += 14;
  if (f.rbFoundationCliff && player.pos === "RB" && inFoundationBand(player)) score += 26;
  if (f.rbFoundationCliff && player.pos !== "RB" && f.canSlideToNextPick) score -= 18;
  if (f.committeeSplit) score -= 8;
  if (f.cleanerBackfield) score += 6;
  if (f.wingMustTake && f.survives < 0.5) score += 12;
  return score;
}

function isStashCandidate(player) {
  const f = player.gmFeatures || {};
  const wait = Number(f.waitCost || 0);
  const gain = Number(f.starterGain || 0);
  return wait < 1 && gain < 8;
}

function sortByIntent(board, intent) {
  return board.slice().sort((a, b) =>
    gmCandidateScore(b, intent) - gmCandidateScore(a, intent)
    || (b.gmBandScore || -999) - (a.gmBandScore || -999)
    || (a.adp || 999) - (b.adp || 999)
  );
}

function buildUserDecisionBoard(availablePlayers, ctx) {
  const {
    pickNumber, nextUserPick, teamCount = 12,
    userRoster = [], rosterSlots = [], draftOrder = [], rosters = {}, teams = [],
  } = ctx || {};
  const counts = window.countPositions ? window.countPositions(userRoster) : {};

  const withMarginal = availablePlayers.map((player) => {
    const starterGain = starterGainFor(player, userRoster, rosterSlots);
    const survives = survivalProbability(player, pickNumber, nextUserPick, teamCount);
    const marginal = marginalValueOverRoster(player, userRoster, rosterSlots, player.overallImpact || 0);
    return { ...player, starterGain, survives, marginal };
  });

  const fallbackByPos = {};
  withMarginal.forEach((player) => {
    if (player.survives < 0.5) return;
    const current = fallbackByPos[player.pos];
    if (!current || player.marginal > current.marginal) fallbackByPos[player.pos] = player;
  });

  const wingGap = Math.max(0, (nextUserPick || pickNumber) - pickNumber);
  const rbFoundationCliff = foundationCliff(withMarginal, "RB", wingGap);
  const wrFoundationCliff = foundationCliff(withMarginal, "WR", wingGap);

  const featured = withMarginal.map((player) => {
    const fallback = fallbackByPos[player.pos];
    const dropoff = Math.max(0, player.marginal - (fallback && fallback.id !== player.id ? fallback.marginal : 0));
    const pressure = runPressure(player.pos, pickNumber, nextUserPick, draftOrder, rosters, teams, rosterSlots);
    const pGoneAdjusted = Math.min(1, (1 - player.survives) * (1 + pressure * 0.5));
    const legacyScore = pGoneAdjusted * dropoff + player.marginal * 0.15;
    const superflex = (rosterSlots || []).includes("SUPERFLEX");
    const committeeRole = String(player.depthChartRole || player.rbRoleContext?.role || "").toLowerCase();
    const gmFeatures = buildGmFeatures(player, ctx, {
      counts,
      starterGain: player.starterGain,
      survives: player.survives,
      dropoff,
      marginal: player.marginal,
      pointGap: positionPointGap(player, withMarginal, scoringOf(ctx, player)),
      secondQbPenalty: player.pos === "QB" && (counts.QB || 0) >= 1 && !superflex,
      qbDelay: player.pos === "QB" && (counts.QB || 0) === 0 && !superflex && pickNumber < 84,
      qbLateFill: (counts.QB || 0) === 0 && !superflex && pickNumber >= 84,
      needRbNow: rbFoundationCliff && (counts.RB || 0) === 0,
      rbFoundationCliff,
      wrFoundationCliff,
      committeeSplit: player.pos === "RB" && /committee|split/.test(committeeRole),
      cleanerBackfield: player.pos === "RB" && /bellcow|starter|lead/.test(committeeRole),
      wingMustTake: rbFoundationCliff && inFoundationBand(player) && player.pos === "RB",
      canSlideToNextPick: wingGap >= 12 && player.survives >= 0.55 && !(rbFoundationCliff && player.pos === "RB"),
      wingGap,
    });
    const scored = {
      ...player,
      scoring: scoringOf(ctx, player),
      livePickNumber: pickNumber,
      liveNextUserPick: nextUserPick,
      gmFeatures,
      decisionScore: legacyScore,
      decision: {
        survives: player.survives,
        dropoffIfGone: dropoff,
        runPressure: pressure,
        fallbackName: fallback && fallback.id !== player.id ? fallback.name : null,
        marginal: player.marginal,
        starterGain: player.starterGain,
        stash: false,
      },
    };
    scored.gmScore = gmCandidateScore(scored, "best");
    scored.decision.stash = isStashCandidate(scored);
    return scored;
  });

  return sortByIntent(featured, "best");
}

function pickOverallBest(ranked, scoring, ctx = {}) {
  const counts = window.countPositions ? window.countPositions(ctx.userRoster || []) : {};
  const superflex = (ctx.rosterSlots || []).includes("SUPERFLEX");
  const pickNumber = ctx.pickNumber || 0;
  const nextUserPick = ctx.nextUserPick || pickNumber;
  const wingGap = Math.max(0, nextUserPick - pickNumber);
  let pool = ranked.filter((player) => player.pos !== "K" && player.pos !== "DST");
  if ((counts.QB || 0) >= 1 && !superflex) pool = pool.filter((player) => player.pos !== "QB");
  const rbCliff = foundationCliff(ranked, "RB", wingGap);
  const wrCliff = foundationCliff(ranked, "WR", wingGap);
  if (wingGap >= 12 && rbCliff && (counts.RB || 0) === 0) {
    const rbs = pool.filter((player) => player.pos === "RB" && inFoundationBand(player));
    if (rbs.length) pool = rbs;
  } else if (wingGap >= 12 && wrCliff && (counts.WR || 0) === 0 && !rbCliff) {
    const wrs = pool.filter((player) => player.pos === "WR" && inFoundationBand(player));
    if (wrs.length) pool = wrs;
  } else if (!superflex && (counts.QB || 0) === 0 && pickNumber >= 84) {
    const qbs = pool.filter((player) => player.pos === "QB");
    if (qbs.length) pool = qbs;
  }
  const starterOrCliff = pool.filter((player) => !isStashCandidate(player) || isTierCliffPlayer(player, ranked, scoring));
  return starterOrCliff[0] || pool[0] || ranked[0] || null;
}

function pickStashValue(ranked) {
  const falling = ranked.filter((player) => isStashCandidate(player) || (player.marketDelta || 0) >= 4);
  const byValue = sortByIntent(falling.length ? falling : ranked, "value");
  return byValue[0] || ranked[0] || null;
}

function buildGmReadoutForUser(availablePlayers, ctx) {
  const ranked = buildUserDecisionBoard(availablePlayers, ctx || {});
  const counts = window.countPositions(ctx?.userRoster || []);
  const needOrder = ["RB", "WR", "QB", "TE"].sort((a, b) =>
    window.rosterNeedScore(b, counts, ctx?.pickNumber, ctx?.rosterSlots)
    - window.rosterNeedScore(a, counts, ctx?.pickNumber, ctx?.rosterSlots)
  );
  const biggestNeed = needOrder[0];
  const best = pickOverallBest(ranked, scoringOf(ctx), ctx || {});
  const valuePick = sortByIntent(ranked, "value")[0] || best;
  const value = pickStashValue(ranked);
  const safe = sortByIntent(ranked, "safe")[0] || best;
  const upside = sortByIntent(ranked, "upside")[0] || best;
  const reach = ranked.find((p) => p.marketDelta < -9) || null;
  const canWait = ranked.slice()
    .filter((p) => {
      if ((p.decision?.survives || 0) < 0.55) return false;
      if (p.gmFeatures?.rbFoundationCliff && p.pos === "RB" && inFoundationBand(p)) return false;
      return true;
    })
    .sort((a, b) => (b.marginal || 0) - (a.marginal || 0))[0] || null;
  const needPick = sortByIntent(ranked.filter((p) => p.pos === biggestNeed), "need")[0] || best;
  return {
    best,
    valuePick,
    value,
    safe,
    upside,
    reach,
    canWait,
    needPick,
    biggestNeed,
    counts,
    ranked,
    board: ranked,
    ctx: ctx || {},
  };
}

function explainGmPick(player, ctx = {}) {
  if (!player?.decision) return "";
  const { survives, dropoffIfGone, fallbackName } = player.decision;
  const gap = (ctx.nextUserPick || ctx.pickNumber || 0) - (ctx.pickNumber || 0);
  const pct = Math.round(survives * 100);
  const nextLabel = ctx.nextUserPick && typeof window.formatRoundPick === "function"
    ? window.formatRoundPick(ctx.nextUserPick - 1, ctx.teamCount || 12)
    : ctx.nextUserPick;
  const counts = window.countPositions ? window.countPositions(ctx.userRoster || []) : {};
  if ((counts.QB || 0) >= 1 && player.pos === "QB" && !(ctx.rosterSlots || []).includes("SUPERFLEX")) {
    return `${player.name} is a second quarterback. You already have your QB; 0QB means waiting on the first one, not stacking two. Spend this pick on skill talent.`;
  }
  if (player.gmFeatures?.qbDelay && player.pos === "QB") {
    return `${player.name} can wait. 0QB is taking a QB as late as possible, then a similar-tier QB or streaming week to week, so the early roster is RB/WR talent.`;
  }
  if (player.gmFeatures?.qbLateFill && player.pos === "QB") {
    return `${player.name} is the late/stream QB. 0QB does not mean never; it means take him now that the skill core is set.`;
  }
  if (player.gmFeatures?.rbFoundationCliff && player.pos === "RB") {
    return `${player.name} is in the last RB11–14 band on the board. Take him now so RB1 is not Tuten / Skattebo / Breece-tier after the wait to ${nextLabel}. Slide a WR like Higgins if he lives a round.`;
  }
  if (isStashCandidate(player) && !isTierCliffPlayer(player, ctx.board || [])) {
    return `${player.name} is a stash, not a starter upgrade. ${pct}% chance he is still there at ${nextLabel}.`;
  }
  if (gap <= 1) return `${player.name}: back-to-back pick, so this is the best current add to your lineup.`;
  if (survives >= 0.6 && gap < 12) {
    return `${player.name}: ${pct}% survival to ${nextLabel}. You can wait if a scarcer player is close.`;
  }
  return `${player.name}: ${pct}% survival across your ${gap}-pick wait to ${nextLabel}${fallbackName ? `; next comparable option is ${fallbackName}` : ""}. Wait cost is about ${Math.round(dropoffIfGone)} points.`;
}

// Formatter examples (Ask GM 5-beat):
// Bijan vs Henry → Rec: Take Bijan over Henry. Why: GM profile / weekly / 949 Value. Risk: boom/bust. Wait: survival to next pick. Alt: when Henry wins.
// WR round 5 last-in-tier → Rec: last WR in this tier. Why: projected-point / value-grade cliff. Risk: reach vs drop. Wait: take now if likely gone. Alt: next WR.
// Jennings as stash → Rec: Best stash value: Jauan Jennings. Never "Best current value" when wait cost ≈ 0 and the add is BENCH.
function formatGmBeats(parts) {
  return [parts.rec, parts.why, parts.risk, parts.wait, parts.alt].filter(Boolean).join(" ");
}

function boomOf(player) {
  return Number(player?.gmFeatures?.boomPct ?? player?.gmFeatures?.boom ?? boomPct(player) ?? 0);
}

function bustOf(player) {
  return Number(player?.gmFeatures?.bustPct ?? player?.bust ?? bustPct(player) ?? 0);
}

function playerShort(player) {
  if (!player) return "no one";
  const f = player.gmFeatures || {};
  const value = f.valueLetter || valueLetterOf(player);
  return `${player.name} (${player.pos}, 949 ${value}, ${player.valueLabel || "Expected"})`;
}

function waitLine(player, nextUserPick, teamCount = 12) {
  if (!player) return "No wait read.";
  const f = player.gmFeatures || {};
  const pct = Math.round((f.survives || 0) * 100);
  const bucket = f.bucket || survivalBucket(f.survives || 0);
  const now = Number(player.livePickNumber || 0);
  const gap = Number(f.wingGap) || (nextUserPick && now ? nextUserPick - now : 0);
  const nextLabel = nextUserPick && typeof window.formatRoundPick === "function"
    ? window.formatRoundPick(nextUserPick - 1, teamCount)
    : nextUserPick;
  if (!nextUserPick) return `This is your pick now (${pct}% would survive a later gap).`;
  if (gap >= 12) {
    if (f.qbDelay && player.pos === "QB") {
      return `0QB means wait: take a similar-tier QB late or stream. ${pct}% he lasts to ${nextLabel}; spend the wing on RB/WR talent.`;
    }
    if (f.rbFoundationCliff && player.pos === "RB") {
      return `Last names in the RB11–14 talent band. Waiting ${gap} picks to ${nextLabel} leaves a much worse RB1. Take this RB; a Higgins-class WR can wait a round.`;
    }
    if (pct >= 55) return `ADP trend: ${pct}% he is still there at ${nextLabel}. On the wing that is still a ${gap}-pick wait, so only slide him if a bigger hole is on the clock.`;
    return `Take him now. You do not pick again until ${nextLabel} (${gap} picks, ${pct}% survival, ${bucket}).`;
  }
  if (pct < 30) return `Take him now: ${pct}% chance he survives to pick ${nextLabel} (${bucket}).`;
  if (pct < 55) return `Coin flip to pick ${nextLabel} (${pct}%). Do not assume he comes back.`;
  return `You can wait: ${pct}% likely available at pick ${nextLabel}.`;
}

function comparePlayers(players, ctx = {}, orWait = false) {
  const board = ctx.board || [];
  const live = players.filter((player) => board.some((row) => row.id === player.id) || !player.drafted);
  if (orWait && live.length === 1) {
    const player = live[0];
    const f = player.gmFeatures || {};
    const takeNow = (f.survives || 0) < 0.30 || (f.waitCost || 0) >= 12 || isTierCliffPlayer(player, board);
    return formatGmBeats({
      rec: takeNow ? `Take ${player.name} now instead of waiting.` : `You can wait on ${player.name}.`,
      why: `${playerShort(player)} is ${f.bucket || survivalBucket(f.survives)} to your next pick, with wait cost ${Math.round(f.waitCost || 0)} and ${f.role} projection.`,
      risk: `${player.risk || "med"} risk · boom ${Math.round(boomOf(player))}% / bust ${Math.round(bustOf(player))}%.`,
      wait: waitLine(player, ctx.nextUserPick, ctx.teamCount),
      alt: takeNow ? "Wait only if a clearer positional need is on the clock this pick." : `If the board starts a ${player.pos} run, come back and take him.`,
    });
  }
  if (live.length < 2) {
    return "Neutral read: I need two available names, or one name plus wait, to compare.";
  }
  const ranked = sortByIntent(live, "best");
  const winner = ranked[0];
  const other = ranked[1];
  const wf = winner.gmFeatures || {};
  const of = other.gmFeatures || {};
  const edge = gmCandidateScore(winner, "best") - gmCandidateScore(other, "best");
  const safer = (wf.floorPts || 0) >= (of.floorPts || 0) ? winner : other;
  const whenOther = other.adp && winner.adp && other.adp > winner.adp + 8
    ? `Take ${other.name} only if ${winner.name} is gone or ${other.name} falls well past ADP.`
    : `Take ${other.name} if you specifically want ${of.role} ${other.risk || "med"}-risk over ${winner.name}'s profile.`;
  return formatGmBeats({
    rec: `Take ${winner.name} over ${other.name}.`,
    why: `${winner.name} has the cleaner current GM profile: ${wf.role}, 949 Value ${wf.valueLetter}, weekly ${fmtPpg(winner)}, and a ${Math.round(edge)}-point GM-score edge. ${other.name} is ${of.role} at ${fmtPpg(other)} weekly.`,
    risk: `${winner.name} is ${winner.risk || "med"} risk with boom ${Math.round(boomOf(winner))}% / bust ${Math.round(bustOf(winner))}%. ${other.name} is ${other.risk || "med"} with boom ${Math.round(boomOf(other))}% / bust ${Math.round(bustOf(other))}%.`,
    wait: waitLine(winner, ctx.nextUserPick, ctx.teamCount),
    alt: whenOther + (safer.id !== winner.id ? ` ${safer.name} is the safer floor if you want to fade that risk.` : ""),
  });
}

function recommendPosition(board, pos, ctx = {}) {
  const pool = sortByIntent((board || []).filter((player) => player.pos === pos), "position");
  if (!pool.length) return `Neutral read: no ${pos} is available with a usable GM signal.`;
  const value = sortByIntent(pool, "value")[0];
  const safe = sortByIntent(pool, "safe")[0];
  const upside = sortByIntent(pool, "upside")[0];
  const best = pool[0];
  const cliff = tierCliff(board, pos, ctx);
  const takeNow = (best.gmFeatures?.survives || 0) < 0.30 || /last player|tier/.test(cliff);
  return formatGmBeats({
    rec: `At ${pos}, take ${best.name}${takeNow ? " now" : ""}.`,
    why: `${playerShort(best)} leads this position on roster fit, tier pressure, and projection (${fmtPpg(best)} ppg). ${cliff}`,
    risk: `${best.name} is ${best.risk || "med"} risk · boom ${Math.round(boomOf(best))}% / bust ${Math.round(bustOf(best))}%.`,
    wait: waitLine(best, ctx.nextUserPick, ctx.teamCount),
    alt: `${value && value.id !== best.id ? `Best value is ${value.name}. ` : ""}${safe && safe.id !== best.id ? `Safer is ${safe.name}. ` : ""}${upside && upside.id !== best.id ? `Upside swing is ${upside.name}.` : "No separate upside split."}`,
  });
}

function survivalForecast(board, pos, player, ctx = {}) {
  const pool = (board || []).filter((row) => !pos || row.pos === pos);
  const focus = player || (pos ? sortByIntent(pool, "position")[0] : sortByIntent(board, "best")[0]);
  if (!focus) return "Neutral read: no one to forecast.";
  const f = focus.gmFeatures || {};
  const scoring = scoringOf(ctx, focus);
  const sameTier = pool.filter((row) => Math.abs(seasonProj(row, scoring) - seasonProj(focus, scoring)) <= 12).slice(0, 4);
  const gone = sameTier.filter((row) => (row.gmFeatures?.survives || 0) < 0.30);
  const drop = positionPointGap(focus, board, scoring);
  return formatGmBeats({
    rec: `${focus.name} is ${f.bucket} (${Math.round((f.survives || 0) * 100)}%) to your next pick.`,
    why: `ADP ${Math.round(f.adp || focus.adp || 0)} vs pick ${ctx.pickNumber}. Wait cost ${Math.round(f.waitCost || 0)} · next ${focus.pos} drop is about ${Math.round(drop)} season points.`,
    risk: gone.length >= 2 ? `${gone.length} players in this ${focus.pos} cluster are likely gone before you pick again.` : "The rest of this cluster is not in a full run yet.",
    wait: waitLine(focus, ctx.nextUserPick, ctx.teamCount),
    alt: pos ? `If you wait, the fallback is ${(sortByIntent(pool, "position")[1] || {}).name || "a lower-tier " + pos}.` : "Ask a position if you want the RB/WR/TE wait, not one name.",
  });
}

function tierCliff(board, pos, ctx = {}) {
  const scoring = scoringOf(ctx);
  const pool = (board || [])
    .filter((player) => !pos || player.pos === pos)
    .sort((a, b) => seasonProj(b, scoring) - seasonProj(a, scoring) || (a.positionRank || 99) - (b.positionRank || 99));
  if (pool.length < 2) return "No tier drop is visible with the remaining players.";
  let bestGap = 0;
  let idx = 0;
  for (let i = 0; i < Math.min(pool.length - 1, 8); i += 1) {
    const gap = seasonProj(pool[i], scoring) - seasonProj(pool[i + 1], scoring);
    const letterGap = (pool[i].gmFeatures?.valueLetter || "") !== (pool[i + 1].gmFeatures?.valueLetter || "");
    const adpGap = Math.abs((pool[i].adp || 0) - (pool[i + 1].adp || 0));
    const score = gap + (letterGap ? 8 : 0) + Math.min(10, adpGap / 4);
    if (score > bestGap) {
      bestGap = score;
      idx = i;
    }
  }
  if (bestGap < 8) return `No sharp ${pos || "board"} cliff; the next names are still in the same band.`;
  const last = pool[idx];
  const next = pool[idx + 1];
  return `${last.name} is the last player in this ${last.pos} tier before ${next.name} (${Math.round(seasonProj(last, scoring) - seasonProj(next, scoring))} projected points drop).`;
}

function rosterFit(ctx = {}) {
  const roster = ctx.userRoster || [];
  const slots = ctx.rosterSlots || [];
  const ranked = ctx.board || [];
  const lineup = window.buildScoredLineup ? window.buildScoredLineup(roster, slots) : [];
  const starterSlots = (slots || []).filter((slot) => slot !== "BENCH");
  const filledStarters = lineup.filter((item) => item.slot !== "BENCH" && item.player).length;
  const openCount = Math.max(0, starterSlots.length - filledStarters);
  const openSlot = starterSlots[filledStarters] || null;
  const counts = window.countPositions ? window.countPositions(roster) : {};
  const pickNumber = ctx.pickNumber || 0;
  const needOrder = ["RB", "WR", "QB", "TE"].sort((a, b) =>
    (window.rosterNeedScore(b, counts, pickNumber, slots) || 0) - (window.rosterNeedScore(a, counts, pickNumber, slots) || 0)
  );
  const need = needOrder[0];
  const delayedQb = !(counts.QB || 0) && pickNumber < 84 && !(slots || []).includes("SUPERFLEX");
  const byes = roster.map(byeWeekOf).filter(Boolean);
  const byeHeavy = byes.sort((a, b) => byes.filter((x) => x === b).length - byes.filter((x) => x === a).length)[0];
  const starterFloor = ranked.find((player) => (player.gmFeatures?.starterGain || 0) >= 8 && player.risk !== "high");
  const benchUpside = ranked.find((player) => isStashCandidate(player) && boomOf(player) >= 20);
  const takeStarter = (openCount > 0 && !(delayedQb && openSlot === "QB")) || (window.rosterNeedScore(need, counts, pickNumber, slots) || 0) > 0;
  return formatGmBeats({
    rec: delayedQb
      ? "0QB is delay, not skip. Load RB/WR talent now; take a similar-tier QB late or stream week to week."
      : takeStarter
      ? `Fill ${openSlot || need} next. You still have ${openCount || 1} starter/flex hole${openCount === 1 ? "" : "s"}.`
      : "Starters are set. Prefer bench upside only if it does not duplicate a bye or a high-risk pile.",
    why: `Roster is ${roster.length}/${slots.length}. Counts: QB ${counts.QB || 0}, RB ${counts.RB || 0}, WR ${counts.WR || 0}, TE ${counts.TE || 0}. ${delayedQb ? "Missing QB on purpose until late." : `Biggest remaining need is ${need}.`}`,
    risk: byeHeavy ? `Bye week ${byeHeavy} is already crowded; avoid stacking another ${need} there.` : "Bye overlap is not the main constraint yet.",
    wait: delayedQb
      ? "Do not spend an early pick filling QB just because the slot is empty."
      : takeStarter ? "Do not stash while a starter slot is empty unless the stash is a true last-in-tier name." : "Stash is allowed; make sure the player is actually falling.",
    alt: `${starterFloor ? `Starter-floor add: ${starterFloor.name}.` : ""} ${benchUpside ? `Bench-upside add: ${benchUpside.name}.` : ""}`.trim() || "Use the position question if you want a specific WR/RB name.",
  });
}

function draftGradeDelta(player, ctx = {}) {
  if (!player) return "Neutral read: name a player or ask who improves the draft grade most.";
  const roster = ctx.userRoster || [];
  const slots = ctx.rosterSlots || [];
  const before = window.gradeUserDraft ? window.gradeUserDraft(roster, slots) : { letter: "—", score: 0 };
  const after = window.gradeUserDraft ? window.gradeUserDraft([...roster, player], slots) : before;
  const delta = (after.score || 0) - (before.score || 0);
  const counts = window.countPositions ? window.countPositions([...roster, player]) : {};
  const stillNeed = ["RB", "WR", "QB", "TE"].filter((pos) => (window.rosterNeedScore(pos, counts, ctx.pickNumber, slots) || 0) > 0);
  return formatGmBeats({
    rec: `Taking ${player.name} moves your draft grade ${delta >= 0 ? "+" : ""}${delta.toFixed(1)} (${before.letter || "—"} → ${after.letter || "—"}).`,
    why: `${playerShort(player)} changes construction and spend as much as raw points. Starter gain is ${Math.round(player.gmFeatures?.starterGain || 0)} season points.`,
    risk: player.risk === "high" ? "That grade bump still carries high-risk weekly variance." : "Risk is acceptable relative to the grade change.",
    wait: waitLine(player, ctx.nextUserPick, ctx.teamCount),
    alt: stillNeed.length ? `After this pick you still need ${stillNeed.join(" / ")}.` : "After this pick, remaining needs are bench/depth only.",
  });
}

function answerBest(gm, ctx, intent) {
  const board = ctx.board || gm.board || [];
  const best = gm.best;
  const stash = (gm.value && isStashCandidate(gm.value) && !isTierCliffPlayer(gm.value, board)) ? gm.value : null;
  if (!best) return "GM is waiting for available players.";
  if (intent === "value") {
    const valuePick = gm.valuePick || sortByIntent(board, "value")[0] || stash || best;
    const asStash = isStashCandidate(valuePick);
    if (asStash) {
      return formatGmBeats({
        rec: `Best stash value: ${valuePick.name}.`,
        why: `He is a ${valuePick.valueLabel || "High Steal"} who is unlikely to make it back (${Math.round((valuePick.gmFeatures?.survives || 0) * 100)}%), but wait cost is ${Math.round(valuePick.gmFeatures?.waitCost || 0)} and the lineup gain is a ${valuePick.gmFeatures?.role || "BENCH"} add, not a starter upgrade.`,
        risk: `${valuePick.risk || "med"} risk · 949 Value ${valuePick.gmFeatures?.valueLetter || "—"} · boom ${Math.round(boomOf(valuePick))}% / bust ${Math.round(bustOf(valuePick))}%.`,
        wait: waitLine(valuePick, ctx.nextUserPick, ctx.teamCount),
        alt: best.id !== valuePick.id
          ? `Best overall pick: ${best.name}. Use that if you want the starter or tier-cliff add instead of the falling name.`
          : "If you need a starter, ask what you need or who to take at the empty position instead.",
      });
    }
    return formatGmBeats({
      rec: `Best current value: ${valuePick.name}.`,
      why: `${playerShort(valuePick)} leads the value weights on ADP discount, 949 Value, and wait cost that actually changes the next pick.`,
      risk: `${valuePick.risk || "med"} risk · boom ${Math.round(boomOf(valuePick))}% / bust ${Math.round(bustOf(valuePick))}% · SOS ${valuePick.gmFeatures?.sos || "—"}/32.`,
      wait: waitLine(valuePick, ctx.nextUserPick, ctx.teamCount),
      alt: stash && stash.id !== valuePick.id
        ? `Best stash/value pick: ${stash.name}, who is falling but does not upgrade starters.`
        : (best.id !== valuePick.id ? `Best overall pick is ${best.name}.` : "No separate stash split on this pick."),
    });
  }
  const label = intent === "safe" ? "Safest pick" : intent === "upside" ? "Upside pick" : intent === "need" ? `Need pick (${gm.biggestNeed})` : "Best overall pick";
  const player = intent === "safe" ? gm.safe : intent === "upside" ? gm.upside : intent === "need" ? gm.needPick : best;
  if (!player) return "Neutral read: no GM signal on this board.";
  return formatGmBeats({
    rec: `${label}: ${player.name}.`,
    why: `${playerShort(player)} leads the ${intent} weights: ${player.gmFeatures?.role} · starter gain ${Math.round(player.gmFeatures?.starterGain || 0)} · 949 ${player.gmFeatures?.valueLetter}.`,
    risk: `${player.risk || "med"} risk · boom ${Math.round(boomOf(player))}% / bust ${Math.round(bustOf(player))}% · SOS ${player.gmFeatures?.sos || "—"}/32.`,
    wait: waitLine(player, ctx.nextUserPick, ctx.teamCount),
    alt: stash && stash.id !== player.id
      ? `Best stash/value pick: ${stash.name}, who is falling but does not upgrade starters.`
      : (gm.canWait && gm.canWait.id !== player.id ? `If you want to wait, ${gm.canWait.name} is the one most likely to come back.` : "No separate stash split on this pick."),
  });
}

function answerGmQuestion(question, gm, roster, extra = {}) {
  const board = gm.board || gm.ranked || [];
  const ctx = {
    ...(gm.ctx || {}),
    ...extra,
    userRoster: extra.userRoster || roster || gm.ctx?.userRoster || [],
    board,
    scoring: extra.scoring || gm.ctx?.scoring || window.ACTIVE_DRAFT_SCORING,
    grade: extra.grade || gm.ctx?.grade,
    rosterSlots: extra.rosterSlots || gm.ctx?.rosterSlots || [],
    pickNumber: extra.pickNumber || gm.ctx?.pickNumber || gm.best?.livePickNumber,
    nextUserPick: extra.nextUserPick || gm.ctx?.nextUserPick || gm.best?.liveNextUserPick,
  };
  const classified = classifyGmIntent(question, board);
  const named = classified.players.map((player) => board.find((row) => row.id === player.id) || player);

  if (classified.intent === "unknown") {
    return "Neutral read: I can answer best pick, value, a position (WR/RB/QB/TE), safe, upside, need, will he make it back, tier break, or a name vs name compare. I do not have enough signal from that wording.";
  }
  if (classified.intent === "compare") return comparePlayers(named, ctx, classified.orWait);
  if (classified.intent === "survival") return survivalForecast(board, classified.pos, named[0], ctx);
  if (classified.intent === "tier") {
    const text = tierCliff(board, classified.pos, ctx);
    const pos = classified.pos;
    const last = (board || []).filter((p) => !pos || p.pos === pos).sort((a, b) => seasonProj(b, scoringOf(ctx, a)) - seasonProj(a, scoringOf(ctx, a)))[0];
    return formatGmBeats({
      rec: text,
      why: pos ? `That is a ${pos} drop-off on projected points, value grade, and ADP cluster, not a generic rank label.` : "That cliff is measured on the remaining board, not ADP alone.",
      risk: last ? `${last.name} is ${last.risk || "med"} risk if you reach to keep the tier.` : "No extra risk read.",
      wait: last ? waitLine(last, ctx.nextUserPick, ctx.teamCount) : "No wait read.",
      alt: pos ? `If you miss this cliff, the next ${pos} is a lower tier. Ask who to take at ${pos} for the A/B/C split.` : "Name a position if you want the last WR/RB/TE specifically.",
    });
  }
  if (classified.intent === "grade") {
    const target = named[0] || gm.best;
    return draftGradeDelta(target, ctx);
  }
  if (classified.intent === "rosterFit") return rosterFit(ctx);
  if (classified.intent === "position") return recommendPosition(board, classified.pos, ctx);
  if (classified.intent === "need") return answerBest(gm, ctx, "need");
  if (classified.intent === "safe") return answerBest(gm, ctx, "safe");
  if (classified.intent === "upside") return answerBest(gm, ctx, "upside");
  if (classified.intent === "value") return answerBest(gm, ctx, "value");
  return answerBest(gm, ctx, "best");
}

Object.assign(window, {
  ADP_SD_BY_ROUND,
  adpSdForRound,
  normalCdf,
  survivalProbability,
  survivalBucket,
  lineupPoints,
  marginalValueOverRoster,
  runPressure,
  classifyGmIntent,
  matchGmNamedPlayers,
  gmCandidateScore,
  isStashCandidate,
  buildUserDecisionBoard,
  buildGmReadoutForUser,
  explainGmPick,
  comparePlayers,
  recommendPosition,
  survivalForecast,
  tierCliff,
  rosterFit,
  draftGradeDelta,
  answerGmQuestion,
});
