// =============================================================================
// ROI Calculator — useROICalculator()
// -----------------------------------------------------------------------------
// A pure, auditable hook that turns the org's live CRM data into a defensible
// ROI story. Every number it returns is derived from the formulas documented
// inline below — nothing is hard-coded or fudged. The hook returns each metric
// twice: `.raw` (the number, for charts/exports) and `.formatted` (the display
// string, already run through the app's fmt helpers).
//
//   const roi = useROICalculator({ deals, contacts, reps, targets, plan });
//   roi.formatted.roiMultiplier   // "12.4×"
//   roi.raw.roiMultiplier         // 12.43...
//
// All tunable assumptions live in DEFAULTS and can be overridden per-call, e.g.
//   useROICalculator({ ...inputs, hourlyRate: 45, plan: 'scale' })
// =============================================================================

(function () {
  // ---------------------------------------------------------------------------
  // PLAN PRICING — what the organisation pays for Stagevo, per seat per month.
  // "Monthly CRM cost" is pricePerSeat × seats (seats default to the rep count).
  // Override by passing a plan object instead of an id, e.g.
  //   plan: { id: 'custom', name: 'Custom', pricePerSeat: 70 }
  // ---------------------------------------------------------------------------
  const PLAN_PRICING = {
    free:       { id: 'free',       name: 'Free',       pricePerSeat: 0 },
    starter:    { id: 'starter',    name: 'Starter',    pricePerSeat: 25 },
    growth:     { id: 'growth',     name: 'Growth',     pricePerSeat: 45 },
    scale:      { id: 'scale',      name: 'Scale',      pricePerSeat: 79 },
    enterprise: { id: 'enterprise', name: 'Enterprise', pricePerSeat: 129 },
  };

  // ---------------------------------------------------------------------------
  // WIN RATES BY STAGE — the probability a deal in each stage eventually closes.
  // We prefer the live stage definitions (window.SALES_DATA.STAGES[].prob), then
  // any `stages`/`winRates` passed in, then this fallback. Sold = 1, Lost = 0.
  // ---------------------------------------------------------------------------
  const DEFAULT_WIN_RATES = {
    possible: 0.25,
    in_progress: 0.5,
    on_hold: 0.15,
    sold: 1,
    lost: 0,
  };

  // ---------------------------------------------------------------------------
  // ASSUMPTIONS — every lever behind the "value" side of the ROI. Conservative
  // by design; all overridable per-call. These are the only soft numbers in the
  // model and they are surfaced in the return value under `.assumptions`.
  // ---------------------------------------------------------------------------
  const DEFAULTS = {
    hourlyRate: 35,            // $/hour — blended cost of a sales person's time
    horizonDays: 90,           // projection window for "90-day revenue"
    stuckDays: 30,             // a deal with no movement for ≥ this is "stuck"
    recoveryRate: 0.15,        // share of at-risk revenue the CRM helps recover
    activeContactDays: 90,     // a contact touched within this is "active"
    // Time the CRM gives back each week (the productivity dividend):
    adminHoursSavedPerRepPerWeek: 1.5,    // auto reports, no spreadsheet wrangling
    minutesSavedPerOpenDealPerWeek: 6,    // auto follow-ups, activity logging, stage tracking
    minutesSavedPerContactPerWeek: 1,     // dedupe, auto-capture, lifecycle updates
    weeksPerMonth: 52 / 12,    // 4.333… — convert weekly figures to monthly
    daysPerMonth: 30.4375,     // 365.25 / 12 — convert monthly figures to daily
    // "What-if" scenario levers:
    winRateLiftPts: 0.05,      // scenario 1: improve win rate by 5 percentage points
    newRepRamp: 0.75,          // scenario 3: a new rep's first-year output vs team average
  };

  const OPEN_STAGES_EXCLUDED = new Set(['sold', 'lost']);
  // Lifecycle values that mean a contact is no longer worth servicing.
  const DEAD_LIFECYCLES = new Set(['lost', 'lost_quote', 'no_sale', 'dead']);

  // ---- small helpers --------------------------------------------------------

  // Resolve a plan input (id string OR object) into a normalized plan record.
  function resolvePlan(plan) {
    if (plan && typeof plan === 'object') {
      return {
        id: plan.id || 'custom',
        name: plan.name || 'Custom',
        pricePerSeat: Number(plan.pricePerSeat) || 0,
      };
    }
    return PLAN_PRICING[plan] || PLAN_PRICING.growth; // sensible default
  }

  // Build a stageId → winRate lookup, preferring live data, then overrides.
  function buildWinRates(stages, winRates) {
    const out = { ...DEFAULT_WIN_RATES };
    const liveStages = stages || (window.SALES_DATA && window.SALES_DATA.STAGES);
    if (Array.isArray(liveStages)) {
      liveStages.forEach(s => { if (s && s.id != null && typeof s.prob === 'number') out[s.id] = s.prob; });
    }
    if (winRates && typeof winRates === 'object') Object.assign(out, winRates);
    return out;
  }

  // Days between an ISO date string and "today" (positive = future, negative = past).
  function daysUntil(iso, today) {
    if (!iso) return null;
    const a = new Date(iso), b = new Date(today);
    if (isNaN(a) || isNaN(b)) return null;
    return Math.round((a - b) / 86400000);
  }

  // Number formatters — prefer the app's shared fmt, fall back if absent.
  const F = {
    money(n) {
      if (window.fmt && window.fmt.money) return window.fmt.money(n);
      if (n == null || isNaN(n)) return '—';
      return '$' + Math.round(n).toLocaleString();
    },
    moneyCompact(n) {
      if (window.fmt && window.fmt.money) return window.fmt.money(n, true);
      return this.money(n);
    },
    hours(n) {
      if (n == null || isNaN(n)) return '—';
      return (Math.round(n * 10) / 10).toLocaleString() + ' hrs';
    },
    mult(n) {
      if (n == null || isNaN(n)) return '—';
      if (!isFinite(n)) return '∞';
      return (Math.round(n * 10) / 10).toLocaleString() + '×';
    },
    days(n) {
      if (n == null || isNaN(n)) return '—';
      if (!isFinite(n)) return '∞';
      if (n < 1) return '< 1 day';
      const v = n < 10 ? Math.round(n * 10) / 10 : Math.round(n);
      return v.toLocaleString() + (v === 1 ? ' day' : ' days');
    },
    count(n) { return (n || 0).toLocaleString(); },
    pct(n, d = 0) {
      if (window.fmt && window.fmt.pct) return window.fmt.pct(n, d);
      if (n == null || isNaN(n)) return '—';
      return (n * 100).toFixed(d) + '%';
    },
  };

  // ===========================================================================
  // computeROI — the pure function. No React, no side effects. Safe to unit-test.
  // ===========================================================================
  function computeROI(opts = {}) {
    const {
      deals = [],
      contacts = [],
      reps = [],
      targets = {},          // accepted for completeness; not used in the money math
      plan = 'growth',
      stages,                // optional: [{id, prob}]
      winRates,              // optional: { stageId: prob }
      seats,                 // optional: override seat count (defaults to rep count)
      today = (window.SALES_DATA && window.SALES_DATA.TODAY) || new Date().toISOString().slice(0, 10),
    } = opts;

    // Merge assumption overrides on top of DEFAULTS.
    const A = { ...DEFAULTS };
    Object.keys(DEFAULTS).forEach(k => { if (opts[k] != null) A[k] = opts[k]; });

    const planRec = resolvePlan(plan);
    const rates = buildWinRates(stages, winRates);
    const rateOf = (stageId) => (typeof rates[stageId] === 'number' ? rates[stageId] : 0);

    // --- Deal segmentation -----------------------------------------------------
    // Open = anything still live in the pipeline (not Sold, not Lost).
    const openDeals = deals.filter(d => d && !OPEN_STAGES_EXCLUDED.has(d.stage));

    // === 1. PIPELINE VALUE =====================================================
    //   pipelineValue = Σ value of every open deal
    // The raw, unweighted money currently sitting in the pipeline.
    const pipelineValue = openDeals.reduce((s, d) => s + (Number(d.value) || 0), 0);

    // === 2. PROJECTED 90-DAY REVENUE (weighted win rates by stage) =============
    //   For each open deal:
    //     contribution = value × winRate(stage)
    //   counted only if the deal is expected to land inside the horizon:
    //     - expectedClose within the next `horizonDays` (overdue deals still count,
    //       they're live and expected to close) → included
    //     - no expectedClose date → assumed to close this quarter → included
    //     - expectedClose beyond the horizon → excluded
    //   projected90 = Σ contribution
    // Also keep a per-stage breakdown for an auditable bar chart.
    const stageAgg = {}; // stageId → { raw, weighted, count }
    let projected90DayRevenue = 0;
    openDeals.forEach(d => {
      const value = Number(d.value) || 0;
      const wr = rateOf(d.stage);
      const dd = daysUntil(d.expectedClose, today);
      const inHorizon = dd == null ? true : dd <= A.horizonDays;
      if (!stageAgg[d.stage]) stageAgg[d.stage] = { raw: 0, weighted: 0, count: 0 };
      stageAgg[d.stage].raw += value;
      stageAgg[d.stage].weighted += value * wr;
      stageAgg[d.stage].count += 1;
      if (inHorizon) projected90DayRevenue += value * wr;
    });
    const weightedByStage = Object.keys(stageAgg).map(id => ({
      stage: id,
      winRate: rateOf(id),
      raw: stageAgg[id].raw,
      weighted: stageAgg[id].weighted,
      count: stageAgg[id].count,
    })).sort((a, b) => b.weighted - a.weighted);

    // === 3. REVENUE AT RISK FROM STUCK DEALS ===================================
    //   stuck = open deal with no movement for ≥ stuckDays
    //     movementDays = lastActivityDaysAgo ?? stageEnteredDaysAgo ?? createdDaysAgo
    //   revenueAtRisk = Σ (value × winRate) over stuck deals
    // We weight by win rate so this is the *expected* revenue in jeopardy, directly
    // comparable to the projected-revenue figure above.
    let revenueAtRisk = 0;
    let stuckPipelineRaw = 0;
    let stuckDealCount = 0;
    openDeals.forEach(d => {
      const movementDays = (d.lastActivityDaysAgo != null ? d.lastActivityDaysAgo
        : d.stageEnteredDaysAgo != null ? d.stageEnteredDaysAgo
        : d.createdDaysAgo != null ? d.createdDaysAgo : 0);
      if (movementDays >= A.stuckDays) {
        const value = Number(d.value) || 0;
        revenueAtRisk += value * rateOf(d.stage);
        stuckPipelineRaw += value;
        stuckDealCount += 1;
      }
    });

    // === 4. TIME SAVED PER WEEK (hours) ========================================
    //   adminHours   = reps × adminHoursSavedPerRepPerWeek
    //   dealMinutes  = openDeals × minutesSavedPerOpenDealPerWeek
    //   contactMins  = activeContacts × minutesSavedPerContactPerWeek
    //     activeContacts = contacts touched within activeContactDays AND not dead
    //   timeSaved (hrs/wk) = adminHours + (dealMinutes + contactMins) / 60
    const repCount = reps.length;
    const activeContacts = contacts.filter(c => {
      if (!c) return false;
      if (c.lifecycle && DEAD_LIFECYCLES.has(c.lifecycle)) return false;
      const since = c.daysSinceContact;
      return since == null ? true : since <= A.activeContactDays;
    });
    const activeContactCount = activeContacts.length;

    const adminHours = repCount * A.adminHoursSavedPerRepPerWeek;
    const dealHours = (openDeals.length * A.minutesSavedPerOpenDealPerWeek) / 60;
    const contactHours = (activeContactCount * A.minutesSavedPerContactPerWeek) / 60;
    const timeSavedHoursPerWeek = adminHours + dealHours + contactHours;

    // === 5. COST OF TIME SAVED (at $hourlyRate, default $35) ====================
    //   perWeek  = timeSaved × hourlyRate
    //   perMonth = perWeek × weeksPerMonth
    const costOfTimeSavedPerWeek = timeSavedHoursPerWeek * A.hourlyRate;
    const costOfTimeSavedPerMonth = costOfTimeSavedPerWeek * A.weeksPerMonth;

    // === 6. MONTHLY CRM COST (based on plan) ===================================
    //   seats = override ?? rep count (min 1)
    //   monthlyCrmCost = plan.pricePerSeat × seats
    const seatCount = Math.max(1, seats != null ? seats : repCount);
    const monthlyCrmCost = planRec.pricePerSeat * seatCount;

    // === 7. TOTAL MONTHLY VALUE ================================================
    //   monthlyTimeValue        = cost of time saved per month (productivity dividend)
    //   monthlyRecoveredRevenue = revenueAtRisk × recoveryRate, spread across the
    //                             horizon → expressed per month:
    //                             = revenueAtRisk × recoveryRate × (daysPerMonth / horizonDays)
    //   totalMonthlyValue       = monthlyTimeValue + monthlyRecoveredRevenue
    const monthlyTimeValue = costOfTimeSavedPerMonth;
    const monthlyRecoveredRevenue =
      revenueAtRisk * A.recoveryRate * (A.daysPerMonth / A.horizonDays);
    const totalMonthlyValue = monthlyTimeValue + monthlyRecoveredRevenue;

    // === 8. ROI MULTIPLIER =====================================================
    //   roiMultiplier = totalMonthlyValue / monthlyCrmCost
    // (Infinity when the plan is free / cost is 0.)
    const roiMultiplier = monthlyCrmCost > 0 ? totalMonthlyValue / monthlyCrmCost : Infinity;

    // === 9. PAYBACK PERIOD (days) ==============================================
    //   dailyValue  = totalMonthlyValue / daysPerMonth
    //   paybackDays = monthlyCrmCost / dailyValue
    // i.e. how many days into the month before the value generated covers the bill.
    const dailyValue = totalMonthlyValue / A.daysPerMonth;
    const paybackDays = dailyValue > 0 ? monthlyCrmCost / dailyValue : Infinity;

    // === WHAT-IF SCENARIOS ====================================================
    // Pull win rate and average deal value straight from closed deals in scope,
    // then project each scenario as ADDITIONAL ANNUAL revenue.
    //
    //   winRate      = won ÷ (won + lost)                       — actual conversion
    //   avgDealValue = won revenue ÷ won count                  — actual average sale
    //   periodMonths = oldest in-scope deal age ÷ days-per-month — the data window
    //   annualizer   = 12 ÷ periodMonths                        — scale window → year
    const wonDeals = deals.filter(d => d.stage === 'sold');
    const lostDeals = deals.filter(d => d.stage === 'lost');
    const wonCount = wonDeals.length;
    const lostCount = lostDeals.length;
    const closedCount = wonCount + lostCount;
    const wonRevenue = wonDeals.reduce((s, d) => s + (Number(d.value) || 0), 0);
    const winRate = closedCount > 0 ? wonCount / closedCount : 0;
    const valuedDeals = deals.filter(d => (Number(d.value) || 0) > 0);
    const avgDealValue = wonCount > 0
      ? wonRevenue / wonCount
      : (valuedDeals.length ? valuedDeals.reduce((s, d) => s + Number(d.value), 0) / valuedDeals.length : 0);

    const ages = deals.map(d => d.createdDaysAgo).filter(n => typeof n === 'number');
    const spanDays = ages.length ? Math.max(...ages) : 365;
    const periodMonths = Math.min(24, Math.max(1, spanDays / A.daysPerMonth));
    const annualizer = 12 / periodMonths;
    const annualClosedDeals = closedCount * annualizer;
    const annualWonRevenue = wonRevenue * annualizer;

    // 1) Improve win rate by +winRateLiftPts → extra closed deals become wins.
    //    extraWins/yr = annualClosedDeals × liftPts;  $ = extraWins × avgDealValue
    const scenarioExtraWins = annualClosedDeals * A.winRateLiftPts;
    const scenarioWinRate = scenarioExtraWins * avgDealValue;

    // 2) Follow up every stuck deal within 7 days → win the stalled pipeline back
    //    at your normal win rate, annualized over the data window.
    //    $ = stuckPipelineRaw × winRate × annualizer
    const scenarioFollowUp = stuckPipelineRaw * winRate * annualizer;

    // 3) Add one more designer → average team output, ramped for year one.
    //    $ = (annualWonRevenue ÷ reps) × newRepRamp
    const perRepAnnual = repCount > 0 ? annualWonRevenue / repCount : annualWonRevenue;
    const scenarioNewRep = perRepAnnual * A.newRepRamp;

    // --- Assemble result -------------------------------------------------------
    const raw = {
      pipelineValue,
      projected90DayRevenue,
      revenueAtRisk,
      stuckDealCount,
      stuckPipelineRaw,
      timeSavedHoursPerWeek,
      costOfTimeSavedPerWeek,
      costOfTimeSavedPerMonth,
      monthlyCrmCost,
      totalMonthlyValue,
      roiMultiplier,
      paybackDays,
      // What-if basis + scenarios
      winRate,
      avgDealValue,
      wonCount,
      lostCount,
      closedCount,
      periodMonths,
      annualWonRevenue,
      scenarioWinRate,
      scenarioFollowUp,
      scenarioNewRep,
    };

    const formatted = {
      pipelineValue: F.money(pipelineValue),
      projected90DayRevenue: F.money(projected90DayRevenue),
      revenueAtRisk: F.money(revenueAtRisk),
      stuckDealCount: F.count(stuckDealCount),
      stuckPipelineRaw: F.money(stuckPipelineRaw),
      timeSavedHoursPerWeek: F.hours(timeSavedHoursPerWeek),
      costOfTimeSavedPerWeek: F.money(costOfTimeSavedPerWeek),
      costOfTimeSavedPerMonth: F.money(costOfTimeSavedPerMonth),
      monthlyCrmCost: F.money(monthlyCrmCost),
      totalMonthlyValue: F.money(totalMonthlyValue),
      roiMultiplier: F.mult(roiMultiplier),
      paybackDays: F.days(paybackDays),
      winRate: F.pct(winRate, 0),
      avgDealValue: F.money(avgDealValue),
      annualWonRevenue: F.money(annualWonRevenue),
      scenarioWinRate: F.money(scenarioWinRate),
      scenarioFollowUp: F.money(scenarioFollowUp),
      scenarioNewRep: F.money(scenarioNewRep),
    };

    return {
      inputs: {
        dealCount: deals.length,
        openDealCount: openDeals.length,
        contactCount: contacts.length,
        activeContactCount,
        repCount,
        seats: seatCount,
        plan: planRec,
        today,
      },
      assumptions: A,
      raw,
      formatted,
      breakdown: {
        weightedByStage,
        timeSaved: { adminHours, dealHours, contactHours },
        monthlyTimeValue,
        monthlyRecoveredRevenue,
        winRates: rates,
        scenarios: {
          winRate, avgDealValue, periodMonths, annualizer, annualWonRevenue,
          items: [
            { id: 'winrate', addRevenue: scenarioWinRate },
            { id: 'followup', addRevenue: scenarioFollowUp },
            { id: 'newrep', addRevenue: scenarioNewRep },
          ],
        },
      },
    };
  }

  // ===========================================================================
  // useROICalculator — React hook wrapper. Memoizes on the meaningful inputs so
  // it only recomputes when data or assumptions actually change.
  // ===========================================================================
  function useROICalculator(opts = {}) {
    const {
      deals, contacts, reps, targets, plan, stages, winRates, seats, today,
    } = opts;
    // Pull out assumption overrides so they participate in the memo key too.
    const overrideKey = Object.keys(DEFAULTS)
      .map(k => (opts[k] != null ? k + ':' + opts[k] : ''))
      .join('|');

    return React.useMemo(
      () => computeROI(opts),
      // eslint-disable-next-line react-hooks/exhaustive-deps
      [
        deals, contacts, reps, targets, plan, stages, winRates, seats, today,
        overrideKey,
      ]
    );
  }

  // Exports
  window.useROICalculator = useROICalculator;
  window.computeROI = computeROI;
  window.ROI_PLAN_PRICING = PLAN_PRICING;
  window.ROI_DEFAULTS = DEFAULTS;
})();
