/* global window */
// Forecast engine — deterministic risk-adjusted forecast calculator.
// Pure functions, no React, no DOM. Exposed on window.ForecastEngine so the
// rest of the prototype can compose against it without an import system.

const STAGE_PROBABILITY = {
  Discovery: 0.10,
  Qualification: 0.18,
  Demo: 0.30,
  Proposal: 0.55,
  Negotiation: 0.75,
  "Closed Won": 1.0,
  "Closed Lost": 0.0,
};

const FORECAST_VARIABLES = [
  { key: "stageProbability", label: "Stage probability",          source: "CRM stage",                weight: 60, enabled: true },
  { key: "engagement",       label: "Engagement level",           source: "Buyer score (Nudge)",      weight: 70, enabled: true },
  { key: "nextStep",         label: "Next-step clarity",          source: "Activity + meetings",      weight: 80, enabled: true },
  { key: "stakeholder",      label: "Stakeholder coverage",       source: "Account map",              weight: 70, enabled: true },
  { key: "decisionProcess",  label: "Decision process",           source: "Discovery notes",          weight: 65, enabled: true },
  { key: "champion",         label: "Champion strength",          source: "Stakeholder + activity",   weight: 55, enabled: true },
  { key: "methodology",      label: "Methodology completeness",   source: "Required fields",         weight: 50, enabled: true },
  { key: "risk",             label: "Risk score",                  source: "Nudge gap detection",      weight: 60, enabled: true },
  { key: "gapScore",         label: "Gap score",                   source: "Buyer vs seller",          weight: 65, enabled: true },
];

const RISK_BUFFERS = [
  { key: "globalHaircut",                label: "Global forecast haircut",          value: 10, enabled: true,  appliesTo: "All deals" },
  { key: "lateStageBuffer",              label: "Late-stage risk buffer",           value: 5,  enabled: true,  appliesTo: "Negotiation / Proposal" },
  { key: "closeDateSlippage",            label: "Close-date slippage buffer",       value: 8,  enabled: true,  appliesTo: "Close < 30d with weak proof" },
  { key: "noNextStepPenalty",            label: "No next step penalty",             value: 15, enabled: true,  appliesTo: "Evidence" },
  { key: "missingDecisionMakerPenalty",  label: "Missing decision-maker penalty",   value: 18, enabled: true,  appliesTo: "Evidence" },
  { key: "weakChampionPenalty",          label: "Weak champion penalty",            value: 12, enabled: true,  appliesTo: "Evidence" },
  { key: "noBusinessPainPenalty",        label: "No confirmed business pain",       value: 10, enabled: false, appliesTo: "Evidence" },
  { key: "noMutualPlanPenalty",          label: "No mutual action plan",            value: 8,  enabled: false, appliesTo: "Evidence" },
];

const SCENARIOS = {
  conservative: { label: "Conservative", multiplier: 0.85, addGlobalHaircut: 8 },
  balanced:     { label: "Balanced",     multiplier: 1.00, addGlobalHaircut: 0 },
  aggressive:   { label: "Aggressive",   multiplier: 1.10, addGlobalHaircut: -5 },
};

// "Today" is pinned to the demo date so seed close-dates produce stable numbers.
const DEMO_TODAY = new Date(2026, 4, 7);

// Resolve the active period for a given business cadence. Inputs:
//   type:          "monthly" | "quarterly" | "semester" | "annual"
//   fyStartMonth:  0–11 (0 = January)
//   offset:        -1 | 0 | 1  (previous / current / next)
//   today:         reference Date (defaults to DEMO_TODAY)
// Returns { name, start, end, daysRemaining, cycleMonths, indexInFY }.
function computePeriod(type = "quarterly", fyStartMonth = 0, offset = 0, today = DEMO_TODAY) {
  const cycleMonths = { monthly: 1, quarterly: 3, semester: 6, annual: 12 }[type] || 3;
  const tYear = today.getFullYear();
  const tMonth = today.getMonth();
  // FY containing today: it starts in fyYear at fyStartMonth.
  const fyYear = tMonth >= fyStartMonth ? tYear : tYear - 1;
  const monthsIntoFY = (tYear - fyYear) * 12 + (tMonth - fyStartMonth);
  const currentIndex = Math.floor(monthsIntoFY / cycleMonths);
  const targetIndex = currentIndex + offset;
  const startMonth = fyStartMonth + targetIndex * cycleMonths;
  const periodStart = new Date(fyYear, startMonth, 1);
  const periodEnd = new Date(fyYear, startMonth + cycleMonths, 0);

  // The period's own FY (in case the offset rolls into the next FY).
  const psYear = periodStart.getFullYear();
  const psMonth = periodStart.getMonth();
  const periodFY = psMonth >= fyStartMonth ? psYear : psYear - 1;
  const indexInFY = Math.floor(((psYear - periodFY) * 12 + (psMonth - fyStartMonth)) / cycleMonths);

  let name;
  if (type === "monthly") {
    name = periodStart.toLocaleString("en-US", { month: "short", year: "numeric" });
  } else if (type === "quarterly") {
    name = `Q${indexInFY + 1} ${periodFY}`;
  } else if (type === "semester") {
    name = `H${indexInFY + 1} ${periodFY}`;
  } else {
    name = `FY ${periodFY}`;
  }

  const msPerDay = 1000 * 60 * 60 * 24;
  const daysRemaining = Math.max(0, Math.ceil((periodEnd - today) / msPerDay));

  return { name, start: periodStart, end: periodEnd, daysRemaining, cycleMonths, indexInFY };
}

function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }

function defaultModel() {
  return {
    id: "default",
    name: "Default forecast model",
    // Business cadence drives every period-aware label across the screen.
    // Defaults to a calendar-year quarterly cadence on the current quarter.
    cadence: {
      type: "quarterly",     // "monthly" | "quarterly" | "semester" | "annual"
      fyStartMonth: 0,       // 0 = January, 3 = April, 6 = July, 9 = October
      offset: 0,             // -1 = previous, 0 = current, 1 = next
    },
    variables: FORECAST_VARIABLES.map((v) => ({ ...v })),
    riskBuffers: RISK_BUFFERS.map((b) => ({ ...b })),
    scenario: "balanced",
    createdBy: "Manager",
    updatedAt: new Date().toISOString(),
  };
}

function daysToClose(closeDate) {
  if (!closeDate) return null;
  const d = new Date(closeDate);
  if (Number.isNaN(d.getTime())) return null;
  return Math.round((d - DEMO_TODAY) / (1000 * 60 * 60 * 24));
}

// Build feature signals from a deal. Robust to both AE-shape (SEED_DEALS) and
// manager-shape deals (built by ManagerView). Each feature is normalised to
// [0,1] where 1 = strong evidence, 0 = no evidence / high risk.
function dealFeatures(deal) {
  const stage = deal.stage || "Discovery";
  const baseProb = STAGE_PROBABILITY[stage] ?? 0.20;
  const buyer = (deal.buyerScore ?? 50) / 100;
  const seller = (deal.sellerScore ?? 70) / 100;
  const gapMag = Math.abs(buyer - seller);
  const missing = (deal.evidenceMissing || []).map((s) => String(s).toLowerCase());
  const has = (kw) => missing.some((m) => kw.split("|").some((k) => m.includes(k)));

  const sev = deal.severity || (deal.status === "at-risk" ? "critical" : deal.status === "needs-attention" ? "warn" : "good");
  const sevScore = sev === "critical" ? 0.15 : sev === "warn" ? 0.55 : 0.95;

  const noNextStep = has("next step|next meeting|buyer-owned");
  const noEB = has("economic buyer|finance owner|cfo|budget validation");
  const noDecisionOwner = has("decision owner|decision-maker|decision maker");
  const noApprovalPath = has("approval path|decision process");
  const weakChampion = sev === "critical" || (deal.gap || 0) <= -25;
  const closeDays = daysToClose(deal.closeDate);
  const closeRisk = closeDays !== null && closeDays > 0 && closeDays < 30 && (noNextStep || noApprovalPath);

  return {
    stage, baseProb, buyer, seller, gapMag, sev, sevScore,
    closeDays, noNextStep, noEB, noDecisionOwner, noApprovalPath, weakChampion, closeRisk,
    feats: {
      stageProbability: clamp(baseProb / 0.75, 0, 1),
      engagement:       buyer,
      nextStep:         noNextStep ? 0.15 : 0.9,
      stakeholder:      (noEB || noDecisionOwner) ? 0.3 : 0.85,
      decisionProcess:  noApprovalPath ? 0.25 : 0.85,
      champion:         weakChampion ? 0.2 : 0.8,
      methodology:      clamp(1 - missing.length * 0.18, 0.15, 0.95),
      risk:             sevScore,
      gapScore:         1 - clamp(gapMag, 0, 0.6) / 0.6,
    },
  };
}

function effectiveBuffers(model) {
  const scenario = SCENARIOS[model.scenario] || SCENARIOS.balanced;
  return model.riskBuffers.map((buffer) => {
    if (!buffer.enabled) return buffer;
    if (buffer.key === "globalHaircut") {
      return { ...buffer, value: clamp(buffer.value + scenario.addGlobalHaircut, 0, 50) };
    }
    return buffer;
  });
}

function recommendCategory(p) {
  if (p >= 0.70) return "Commit";
  if (p >= 0.45) return "Most likely";
  if (p >= 0.20) return "Best case";
  return "Omitted";
}

function declaredCategoryFor(features, declared) {
  if (features.sev === "good" && declared >= 0.55) return "Commit";
  if (features.sev !== "critical" && declared >= 0.45) return "Commit";
  if (declared >= 0.30) return "Best case";
  return "Pipeline";
}

function calcDeal(deal, model) {
  const features = dealFeatures(deal);
  const buffers = effectiveBuffers(model);

  // Variable multipliers — high weight + low feature score = bigger penalty.
  // stageProbability is the base, not a multiplier, so skip it here.
  const multipliers = {};
  let combined = 1;
  model.variables.forEach((variable) => {
    if (!variable.enabled || variable.key === "stageProbability") {
      multipliers[variable.key] = 1;
      return;
    }
    const weight = clamp(variable.weight / 100, 0, 1);
    const feat = clamp(features.feats[variable.key] ?? 0.7, 0, 1);
    const m = clamp(1 - weight * (1 - feat) * 0.6, 0.4, 1);
    multipliers[variable.key] = m;
    combined *= m;
  });

  // Risk buffer multiplier — only enabled buffers whose conditions match.
  const bufferReasons = [];
  let bufferMul = 1;
  const apply = (buffer, reason) => {
    bufferMul *= clamp(1 - buffer.value / 100, 0.4, 1);
    bufferReasons.push(`${buffer.label} (-${buffer.value}%) — ${reason}`);
  };
  buffers.forEach((b) => {
    if (!b.enabled) return;
    if (b.key === "globalHaircut") apply(b, "applied to all deals");
    else if (b.key === "lateStageBuffer" && /negotiation|proposal/i.test(features.stage)) apply(b, `late stage (${features.stage})`);
    else if (b.key === "closeDateSlippage" && features.closeRisk) apply(b, `close in ${features.closeDays}d with weak proof`);
    else if (b.key === "noNextStepPenalty" && features.noNextStep) apply(b, "no buyer-owned next step");
    else if (b.key === "missingDecisionMakerPenalty" && (features.noEB || features.noDecisionOwner)) apply(b, "decision-maker not identified");
    else if (b.key === "weakChampionPenalty" && features.weakChampion) apply(b, "champion weak / silent");
    else if (b.key === "noBusinessPainPenalty") apply(b, "manual rule (always-on)");
    else if (b.key === "noMutualPlanPenalty") apply(b, "manual rule (always-on)");
  });

  // Declared probability — rep optimism on top of stage probability.
  const optimismBias = features.sev === "critical" ? 0.20 : features.sev === "warn" ? 0.10 : 0.02;
  const declaredProbability = clamp(features.baseProb + optimismBias, 0, 1);
  const adjustedProbability = clamp(features.baseProb * combined * bufferMul, 0, 1);
  const amount = deal.amount ?? deal.value ?? 0;

  const recommendedCategory = recommendCategory(adjustedProbability);
  const declaredCategory = declaredCategoryFor(features, declaredProbability);

  // Plain-language reasons for the manager review.
  const reasons = [];
  if (features.noNextStep) reasons.push("No buyer-owned next step on the calendar");
  if (features.noEB || features.noDecisionOwner) reasons.push("Economic buyer / decision owner not identified");
  if (features.noApprovalPath) reasons.push("Approval path not documented");
  if (features.weakChampion) reasons.push("Champion engagement is weak");
  if (features.closeRisk) reasons.push(`Close date in ${features.closeDays}d but evidence is thin`);
  if (features.gapMag > 0.2) reasons.push(`Buyer vs seller gap is ${(features.gapMag * 100).toFixed(0)} pts`);

  // Forecast theatre flags — manager-readable warnings.
  const flags = [];
  if (declaredCategory === "Commit" && features.noNextStep)
    flags.push("Marked Commit, but Nudge found no confirmed next step.");
  if (declaredCategory === "Commit" && (features.noEB || features.noDecisionOwner))
    flags.push("Marked Commit, but the economic buyer is not identified.");
  if (features.closeDays !== null && features.closeDays < 30 && features.noApprovalPath)
    flags.push("Close within 30 days, but approval path is not documented.");
  const declaredPct = Math.round(declaredProbability * 100);
  const adjustedPct = Math.round(adjustedProbability * 100);
  if (declaredPct - adjustedPct >= 25)
    flags.push(`Forecast may be inflated: declared ${declaredPct}%, evidence-based ${adjustedPct}%.`);
  if (amount >= 250000 && (features.noEB || features.weakChampion))
    flags.push("Manager review recommended: high-value deal with weak stakeholder coverage.");

  return {
    dealId: deal.id,
    company: deal.company,
    dealName: deal.dealName,
    owner: deal.aeName || deal.owner || "—",
    stage: features.stage,
    closeDate: deal.closeDate,
    declaredAmount: amount,
    crmWeightedAmount: Math.round(amount * features.baseProb),
    nudgeAdjustedAmount: Math.round(amount * adjustedProbability),
    declaredProbability,
    crmProbability: features.baseProb,
    adjustedProbability,
    forecastGap: Math.round(amount * (declaredProbability - adjustedProbability)),
    declaredCategory,
    recommendedCategory,
    gapScore: Math.round((1 - features.gapMag) * 100),
    severity: features.sev,
    reasons,
    bufferReasons,
    theatreFlags: flags,
    multipliers,
    bufferMultiplier: bufferMul,
  };
}

function summarize(deals, model) {
  // Open deals only — don't forecast Closed Won/Lost.
  const open = (deals || []).filter((d) => !/^closed/i.test(d.stage || ""));
  const rows = open.map((d) => calcDeal(d, model));

  const declaredForecast = rows.reduce((s, r) => s + Math.round(r.declaredAmount * r.declaredProbability), 0);
  const crmWeightedForecast = rows.reduce((s, r) => s + r.crmWeightedAmount, 0);
  const nudgeAdjustedForecast = rows.reduce((s, r) => s + r.nudgeAdjustedAmount, 0);
  const forecastGap = declaredForecast - nudgeAdjustedForecast;
  const forecastGapPercentage = declaredForecast > 0 ? Math.round((forecastGap / declaredForecast) * 100) : 0;

  const pipelineByCategory = rows.reduce((acc, r) => {
    acc[r.recommendedCategory] = (acc[r.recommendedCategory] || 0) + r.nudgeAdjustedAmount;
    return acc;
  }, {});

  const commitAtRisk = rows
    .filter((r) => r.declaredCategory === "Commit" && r.recommendedCategory !== "Commit")
    .reduce((s, r) => s + r.declaredAmount, 0);

  const bestCaseAtRisk = rows
    .filter((r) => r.declaredCategory === "Best case" && r.recommendedCategory === "Omitted")
    .reduce((s, r) => s + r.declaredAmount, 0);

  const flaggedRows = rows
    .filter((r) => r.theatreFlags.length)
    .sort((a, b) => Math.abs(b.forecastGap) - Math.abs(a.forecastGap));

  return {
    rows,
    declaredForecast,
    crmWeightedForecast,
    nudgeAdjustedForecast,
    forecastGap,
    forecastGapPercentage,
    pipelineByCategory,
    commitAtRisk,
    bestCaseAtRisk,
    flaggedRows,
  };
}

// ---------- Backtest cohort ----------
// Hand-crafted snapshot of closed deals across two prior periods. Each entry is
// a deal-shape (compatible with calcDeal) plus an outcome (1 = won, 0 = lost).
// The features here are taken at the moment forecast calls were locked, so the
// engine evaluates the same evidence the manager had on forecast day.
const BACKTEST_COHORT = [
  // Q1 2026
  { id: "bt-01", company: "Lyric Media",          stage: "Negotiation", value: 180000, buyerScore: 88, sellerScore: 82, evidenceMissing: [],                                                              severity: "good",     closeDate: "Mar 14, 2026", outcome: 1, period: "Q1 2026", aeName: "Priya R." },
  { id: "bt-02", company: "Mercia Apps",          stage: "Negotiation", value: 240000, buyerScore: 42, sellerScore: 78, evidenceMissing: ["next step", "economic buyer"],                                  severity: "warn",     closeDate: "Feb 28, 2026", outcome: 0, period: "Q1 2026", aeName: "Marco D." },
  { id: "bt-03", company: "Halcyon Tools",        stage: "Proposal",    value: 95000,  buyerScore: 74, sellerScore: 71, evidenceMissing: [],                                                              severity: "good",     closeDate: "Mar 22, 2026", outcome: 1, period: "Q1 2026", aeName: "Priya R." },
  { id: "bt-04", company: "Stellaris Logistics",  stage: "Negotiation", value: 320000, buyerScore: 35, sellerScore: 80, evidenceMissing: ["decision-maker", "approval path"],                              severity: "critical", closeDate: "Mar 6, 2026",  outcome: 0, period: "Q1 2026", aeName: "Jen K." },
  { id: "bt-05", company: "Boreal Health",        stage: "Proposal",    value: 140000, buyerScore: 66, sellerScore: 70, evidenceMissing: ["mutual plan"],                                                  severity: "good",     closeDate: "Feb 18, 2026", outcome: 1, period: "Q1 2026", aeName: "Marco D." },
  { id: "bt-06", company: "Polaris Bank",         stage: "Negotiation", value: 410000, buyerScore: 52, sellerScore: 76, evidenceMissing: ["business pain"],                                                severity: "warn",     closeDate: "Mar 28, 2026", outcome: 1, period: "Q1 2026", aeName: "Jen K." },
  { id: "bt-07", company: "Ridgeline Co",         stage: "Demo",        value: 70000,  buyerScore: 58, sellerScore: 64, evidenceMissing: ["next step"],                                                    severity: "warn",     closeDate: "Mar 12, 2026", outcome: 0, period: "Q1 2026", aeName: "Priya R." },
  { id: "bt-08", company: "Castor Robotics",      stage: "Proposal",    value: 220000, buyerScore: 81, sellerScore: 78, evidenceMissing: [],                                                              severity: "good",     closeDate: "Feb 9, 2026",  outcome: 1, period: "Q1 2026", aeName: "Marco D." },
  { id: "bt-09", company: "Ember Foods",          stage: "Negotiation", value: 110000, buyerScore: 47, sellerScore: 75, evidenceMissing: ["next step", "approval path"],                                   severity: "warn",     closeDate: "Mar 19, 2026", outcome: 0, period: "Q1 2026", aeName: "Jen K." },
  { id: "bt-10", company: "Cobalt Energy",        stage: "Negotiation", value: 290000, buyerScore: 70, sellerScore: 74, evidenceMissing: [],                                                              severity: "good",     closeDate: "Mar 30, 2026", outcome: 1, period: "Q1 2026", aeName: "Priya R." },
  // Q4 2025
  { id: "bt-11", company: "Kindred Retail",       stage: "Proposal",    value: 130000, buyerScore: 63, sellerScore: 68, evidenceMissing: [],                                                              severity: "good",     closeDate: "Dec 18, 2025", outcome: 1, period: "Q4 2025", aeName: "Jen K." },
  { id: "bt-12", company: "Argent Trading",       stage: "Negotiation", value: 360000, buyerScore: 38, sellerScore: 79, evidenceMissing: ["economic buyer", "approval path", "next step"],                  severity: "critical", closeDate: "Dec 22, 2025", outcome: 0, period: "Q4 2025", aeName: "Marco D." },
  { id: "bt-13", company: "Solene Cosmetics",     stage: "Proposal",    value: 85000,  buyerScore: 72, sellerScore: 70, evidenceMissing: [],                                                              severity: "good",     closeDate: "Nov 24, 2025", outcome: 1, period: "Q4 2025", aeName: "Priya R." },
  { id: "bt-14", company: "Vandermark Industries",stage: "Negotiation", value: 250000, buyerScore: 55, sellerScore: 73, evidenceMissing: ["decision-maker"],                                                severity: "warn",     closeDate: "Dec 8, 2025",  outcome: 0, period: "Q4 2025", aeName: "Jen K." },
  { id: "bt-15", company: "Helix Education",      stage: "Demo",        value: 60000,  buyerScore: 64, sellerScore: 66, evidenceMissing: [],                                                              severity: "good",     closeDate: "Nov 30, 2025", outcome: 1, period: "Q4 2025", aeName: "Marco D." },
  { id: "bt-16", company: "Ironpeak Capital",     stage: "Negotiation", value: 480000, buyerScore: 78, sellerScore: 80, evidenceMissing: [],                                                              severity: "good",     closeDate: "Dec 19, 2025", outcome: 1, period: "Q4 2025", aeName: "Priya R." },
  { id: "bt-17", company: "Marrow Biosciences",   stage: "Proposal",    value: 170000, buyerScore: 41, sellerScore: 76, evidenceMissing: ["business pain", "champion"],                                     severity: "warn",     closeDate: "Dec 5, 2025",  outcome: 0, period: "Q4 2025", aeName: "Jen K." },
  { id: "bt-18", company: "Pinecrest SaaS",       stage: "Negotiation", value: 200000, buyerScore: 60, sellerScore: 72, evidenceMissing: ["mutual plan"],                                                  severity: "good",     closeDate: "Nov 14, 2025", outcome: 1, period: "Q4 2025", aeName: "Marco D." },
  { id: "bt-19", company: "Onyx Manufacturing",   stage: "Negotiation", value: 145000, buyerScore: 33, sellerScore: 78, evidenceMissing: ["next step", "decision-maker"],                                   severity: "critical", closeDate: "Dec 11, 2025", outcome: 0, period: "Q4 2025", aeName: "Priya R." },
  { id: "bt-20", company: "Sablefin Insurance",   stage: "Proposal",    value: 105000, buyerScore: 69, sellerScore: 71, evidenceMissing: [],                                                              severity: "good",     closeDate: "Nov 28, 2025", outcome: 1, period: "Q4 2025", aeName: "Jen K." },
];

// Run the model against a closed-deal cohort and return calibration metrics.
// - brier:           mean squared error of probabilities vs outcomes (lower = better)
// - calibrationScore: 0–100 derived from brier (rough manager-friendly grade)
// - accuracy:        % of deals where the recommended category matched outcome
// - predictedRevenue / actualRevenue: $ totals for "would have called" vs "actually closed"
// - buckets:         predicted-probability buckets with actual win rate (for chart)
// - misses:          deals with the largest probability error (sorted)
function backtest(model, cohort = BACKTEST_COHORT, period = "all") {
  const filtered = period === "all" ? cohort : cohort.filter((d) => d.period === period);
  if (!filtered.length) {
    return { rows: [], brier: 0, calibrationScore: 0, accuracy: 0, predictedRevenue: 0, actualRevenue: 0, buckets: [], misses: [], total: 0, wins: 0 };
  }

  const rows = filtered.map((deal) => {
    const calc = calcDeal(deal, model);
    const predicted = calc.adjustedProbability;
    const outcome = deal.outcome;
    const error = predicted - outcome;
    // A deal is "called won" if model recommends Commit or Most likely.
    const calledWon = calc.recommendedCategory === "Commit" || calc.recommendedCategory === "Most likely";
    const correctCategory = (calledWon && outcome === 1) || (!calledWon && outcome === 0);
    return {
      ...calc,
      outcome,
      predicted,
      error,
      squaredError: error * error,
      calledWon,
      correctCategory,
      period: deal.period,
      actualAmount: outcome ? (deal.value || 0) : 0,
    };
  });

  const brier = rows.reduce((s, r) => s + r.squaredError, 0) / rows.length;
  // Calibration score: 0.0 brier → 100, 0.25 brier (no skill) → 0.
  const calibrationScore = clamp(Math.round((1 - brier / 0.25) * 100), 0, 100);
  const accuracy = Math.round((rows.filter((r) => r.correctCategory).length / rows.length) * 100);
  const predictedRevenue = Math.round(rows.reduce((s, r) => s + (r.declaredAmount * r.predicted), 0));
  const actualRevenue = rows.reduce((s, r) => s + r.actualAmount, 0);
  const wins = rows.filter((r) => r.outcome === 1).length;

  // Buckets: 0–20, 20–40, 40–60, 60–80, 80–100. Each shows expected vs actual
  // win rate so the manager can spot where the model is over/underconfident.
  const bands = [
    { key: "0-20",   lo: 0.0,  hi: 0.2,  label: "0–20%",   midpoint: 0.10 },
    { key: "20-40",  lo: 0.2,  hi: 0.4,  label: "20–40%",  midpoint: 0.30 },
    { key: "40-60",  lo: 0.4,  hi: 0.6,  label: "40–60%",  midpoint: 0.50 },
    { key: "60-80",  lo: 0.6,  hi: 0.8,  label: "60–80%",  midpoint: 0.70 },
    { key: "80-100", lo: 0.8,  hi: 1.01, label: "80–100%", midpoint: 0.90 },
  ];
  const buckets = bands.map((band) => {
    const inBand = rows.filter((r) => r.predicted >= band.lo && r.predicted < band.hi);
    const won = inBand.filter((r) => r.outcome === 1).length;
    const expected = inBand.length ? inBand.reduce((s, r) => s + r.predicted, 0) / inBand.length : band.midpoint;
    const actual = inBand.length ? won / inBand.length : null;
    return { ...band, count: inBand.length, won, expected, actual };
  });

  const misses = [...rows]
    .sort((a, b) => Math.abs(b.error) - Math.abs(a.error))
    .slice(0, 5);

  return {
    rows,
    brier,
    calibrationScore,
    accuracy,
    predictedRevenue,
    actualRevenue,
    buckets,
    misses,
    total: rows.length,
    wins,
  };
}

window.ForecastEngine = {
  defaultModel,
  calcDeal,
  summarize,
  backtest,
  dealFeatures,
  computePeriod,
  STAGE_PROBABILITY,
  FORECAST_VARIABLES,
  RISK_BUFFERS,
  SCENARIOS,
  BACKTEST_COHORT,
  DEMO_TODAY,
};
