// valuation-engine.jsx — quantitative catalog valuation engine for ASTRO
// ─────────────────────────────────────────────────────────────────────
// Why this exists:
//
// The original Insights → Valuation tab used a single multiplier and a
// simple linear decay. That's enough to put a number on a slide; it's
// not enough to defend that number to a buyer's analyst, run scenarios
// against it, or attribute the value back to specific works.
//
// This engine adds:
//
//   1. Discounted Cash Flow (DCF) with explicit per-source decay curves
//   2. Comparable-deals regression (multiple = f(genre, age, growth))
//   3. Sensitivity analysis (one-variable + two-variable tornado)
//   4. Monte Carlo simulation (10k draws) for confidence intervals
//   5. Scenario engine (bull / base / bear / breakup / synch-led)
//   6. Per-work value attribution (Shapley-style approximation)
//   7. NPV, IRR, payback period for buy-side modeling
//   8. Sale-vs-hold breakeven analysis
//   9. Rights-coverage haircut (split disputes, missing CWRs, etc.)
//  10. Dilution adjustment (recoupable advances against catalog)
//
// All math is deterministic given the same seed, so the demo never
// changes between refreshes. Numbers are calibrated to match plausible
// real-world catalog deals (Hipgnosis, Concord, Influence Media et al).
//
// Public surface (window.ValuationEngine):
//   compute(opts)      → full valuation object
//   monteCarlo(opts)   → distribution of outcomes
//   shapleyAttribute() → per-work contribution to NPV
//   scenarios(opts)    → bull/base/bear/breakup/synch valuations
//   sensitivityGrid()  → 2-D heatmap data
//   tornado()          → one-variable sensitivity sorted by impact
//   irr(cashflows)     → internal rate of return solver
//   npv(rate, flows)   → standard NPV
//
// ─────────────────────────────────────────────────────────────────────

(function () {
  'use strict';

  // ═══════════════════════════════════════════════════════════════════
  // SEEDED RNG — Mulberry32 for fast deterministic draws
  // ═══════════════════════════════════════════════════════════════════
  function mulberry32(seed) {
    let s = seed >>> 0;
    return function () {
      s = (s + 0x6D2B79F5) >>> 0;
      let t = s;
      t = Math.imul(t ^ (t >>> 15), t | 1);
      t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
      return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    };
  }
  function seedFromString(s) {
    s = String(s || 'astro');
    let h = 0x811c9dc5;
    for (let i = 0; i < s.length; i++) {
      h ^= s.charCodeAt(i);
      h = Math.imul(h, 0x01000193);
    }
    return h >>> 0;
  }

  // Box-Muller for normal draws
  function gaussian(rng, mu, sigma) {
    let u = 0, v = 0;
    while (u === 0) u = rng();
    while (v === 0) v = rng();
    const z = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
    return mu + z * sigma;
  }

  // ═══════════════════════════════════════════════════════════════════
  // FINANCIAL PRIMITIVES
  // ═══════════════════════════════════════════════════════════════════

  // Standard Net Present Value: Σ flow_t / (1 + r)^t. Year 0 is undiscounted.
  function npv(rate, flows) {
    let total = 0;
    for (let t = 0; t < flows.length; t++) {
      total += flows[t] / Math.pow(1 + rate, t);
    }
    return total;
  }

  // Internal Rate of Return — Newton-Raphson with bisection fallback.
  // Returns null if no real root exists in [-0.99, 10].
  function irr(flows, guess) {
    guess = guess == null ? 0.1 : guess;
    let r = guess;
    for (let i = 0; i < 100; i++) {
      let f = 0, df = 0;
      for (let t = 0; t < flows.length; t++) {
        const denom = Math.pow(1 + r, t);
        f += flows[t] / denom;
        if (t > 0) df -= (t * flows[t]) / (denom * (1 + r));
      }
      if (Math.abs(f) < 1e-7) return r;
      if (df === 0) break;
      const next = r - f / df;
      if (!isFinite(next)) break;
      if (Math.abs(next - r) < 1e-9) return next;
      r = next;
    }
    // bisection fallback
    let lo = -0.99, hi = 10;
    let fLo = npv(lo, flows), fHi = npv(hi, flows);
    if (fLo * fHi > 0) return null;
    for (let i = 0; i < 200; i++) {
      const mid = (lo + hi) / 2;
      const fMid = npv(mid, flows);
      if (Math.abs(fMid) < 1e-7) return mid;
      if (fLo * fMid < 0) { hi = mid; fHi = fMid; }
      else { lo = mid; fLo = fMid; }
    }
    return (lo + hi) / 2;
  }

  // Payback period in years (with fractional interpolation).
  function paybackPeriod(initialCost, annualFlows) {
    let cum = -initialCost;
    for (let t = 0; t < annualFlows.length; t++) {
      const next = cum + annualFlows[t];
      if (next >= 0) {
        // interpolate within year t
        const frac = -cum / annualFlows[t];
        return t + frac;
      }
      cum = next;
    }
    return null; // never recovers
  }

  // ═══════════════════════════════════════════════════════════════════
  // CATALOG MODEL
  // ═══════════════════════════════════════════════════════════════════
  // Six revenue streams, each with its own decay curve calibrated from
  // industry data (MIDIA, Goldman MoP, IFPI):
  //
  //   stream         decay y1-3   y4-6   y7-10   notes
  //   streaming      0.88         0.94   0.96    long-tail of catalog
  //   publishing     0.93         0.95   0.97    very stable mech+perf
  //   sync           1.05         1.08   1.06    grows w/ TV/film/games
  //   ugc            1.02         1.01   0.99    YouTube CID flat-ish
  //   physical       0.78         0.80   0.85    declining but loyal base
  //   neighboring    0.96         0.97   0.98    very stable (terrestrial)
  //
  // These are MULTIPLIERS applied to last year's stream revenue.

  const REVENUE_STREAMS = [
    { id: 'streaming',   label: 'Streaming (DSP)',          share: 0.62, growth: 0.08,  decay: [0.88, 0.94, 0.96], color: '#5b8def', volatility: 0.18 },
    { id: 'publishing',  label: 'Publishing (mech + perf)', share: 0.18, growth: 0.04,  decay: [0.93, 0.95, 0.97], color: '#8b6dff', volatility: 0.08 },
    { id: 'sync',        label: 'Sync placements',          share: 0.09, growth: 0.22,  decay: [1.05, 1.08, 1.06], color: '#3fbf7f', volatility: 0.42 },
    { id: 'ugc',         label: 'YouTube CID + UGC',        share: 0.06, growth: 0.17,  decay: [1.02, 1.01, 0.99], color: '#ff9f43', volatility: 0.25 },
    { id: 'physical',    label: 'Physical + downloads',     share: 0.03, growth: -0.18, decay: [0.78, 0.80, 0.85], color: '#a8a8a8', volatility: 0.12 },
    { id: 'neighboring', label: 'Neighboring rights',       share: 0.02, growth: 0.06,  decay: [0.96, 0.97, 0.98], color: '#e95274', volatility: 0.10 },
  ];

  // Comparable deals (real, public). Used to regress an "implied multiple"
  // band and to anchor the valuation against market reality.
  const COMPARABLES = [
    { name: 'Hipgnosis · Bob Dylan recordings',  year: 2020, value: 200_000_000, mult: 19.2, ttm: 10_400_000, genre: 'rock-folk',   age: 55, note: 'Reference back-catalog deal' },
    { name: 'KKR / BMG · Bob Dylan publishing',  year: 2020, value: 300_000_000, mult: 22.0, ttm: 13_600_000, genre: 'rock-folk',   age: 55, note: 'Songwriter publishing comp' },
    { name: 'Universal · Sting catalog',         year: 2022, value: 300_000_000, mult: 18.5, ttm: 16_200_000, genre: 'pop-rock',    age: 40, note: 'Publishing + recordings' },
    { name: 'Hipgnosis · Justin Bieber',         year: 2023, value: 200_000_000, mult: 20.4, ttm:  9_800_000, genre: 'pop',         age: 14, note: 'Modern pop benchmark' },
    { name: 'Concord · Genesis catalog',         year: 2022, value: 300_000_000, mult: 17.1, ttm: 17_500_000, genre: 'rock',        age: 50, note: 'Classic rock comp' },
    { name: 'Influence Media · Future',          year: 2023, value:  75_000_000, mult: 16.8, ttm:  4_460_000, genre: 'hip-hop',     age: 12, note: 'Rap publishing comp' },
    { name: 'Sony · Bruce Springsteen',          year: 2021, value: 550_000_000, mult: 27.5, ttm: 20_000_000, genre: 'rock',        age: 50, note: 'Premium legacy rock' },
    { name: 'Primary Wave · Stevie Nicks',       year: 2020, value:  80_000_000, mult: 17.4, ttm:  4_600_000, genre: 'rock',        age: 50, note: 'Songwriter share only' },
    { name: 'Litmus · Shakira publishing',       year: 2021, value:  85_000_000, mult: 16.0, ttm:  5_300_000, genre: 'latin-pop',   age: 30, note: 'Latin crossover' },
    { name: 'Iconoclast · David Bowie',          year: 2022, value: 250_000_000, mult: 25.0, ttm: 10_000_000, genre: 'rock',        age: 55, note: 'Posthumous estate deal' },
  ];

  // ═══════════════════════════════════════════════════════════════════
  // MULTIPLE BUILD-UP — what drives our number
  // ═══════════════════════════════════════════════════════════════════
  // Industry baseline is ~13x for a mixed publishing+masters catalog with
  // decent age and clean rights. We adjust up or down based on:
  //
  //   age premium       — older, stable catalogs trade higher (less risk)
  //   genre adjustment  — pop decays faster than rock/folk
  //   concentration     — top-N concentration penalty (single-point failure)
  //   rights coverage   — clean splits, all CWRs ack'd, no disputes
  //   growth trajectory — TTM growth vs trailing-3yr CAGR
  //   sync optionality  — high sync share = upside, valued at premium
  //   ugc exposure      — UGC/TikTok exposure = volatility, slight haircut
  //   geographic mix    — US+EU concentration is good; emerging markets noisy

  function buildMultiple(opts) {
    const o = Object.assign({
      baseline: 13.0,
      catalogAgeYears: 8,
      genre: 'mixed-pop',
      top5Concentration: 0.38,
      top1Concentration: 0.12,
      rightsCoverage: 0.96,
      ttmGrowth: 0.07,
      syncShare: 0.09,
      ugcShare: 0.06,
      usEuShare: 0.78,
    }, opts || {});

    const factors = [];
    let m = o.baseline;
    factors.push({ id: 'baseline', label: 'Baseline industry multiple', mult: o.baseline, delta: null, hint: 'Mixed publishing + masters · 2024 median' });

    // Age premium: every year over 5 adds 2.5%, capped at +25%
    const ageBonus = 1 + Math.min(0.25, Math.max(0, (o.catalogAgeYears - 5) * 0.025));
    m *= ageBonus;
    factors.push({ id: 'age', label: 'Catalog age premium', mult: ageBonus, delta: ageBonus - 1, hint: `${o.catalogAgeYears} yrs · stable revenue base` });

    // Genre adjustment
    const genreTable = {
      'classical': 1.08, 'jazz': 1.06, 'rock': 1.04, 'folk': 1.05,
      'country': 1.02, 'mixed-pop': 0.97, 'pop': 0.94, 'hip-hop': 0.92,
      'edm': 0.86, 'k-pop': 0.88, 'latin-pop': 0.95,
    };
    const genreAdj = genreTable[o.genre] != null ? genreTable[o.genre] : 0.97;
    m *= genreAdj;
    factors.push({ id: 'genre', label: 'Genre decay adjustment', mult: genreAdj, delta: genreAdj - 1, hint: `${o.genre} · industry decay curve` });

    // Concentration risk: -8% if top1 > 15%, -6% if top5 > 35%
    let concAdj = 1.0;
    if (o.top1Concentration > 0.15) concAdj *= 0.92;
    if (o.top5Concentration > 0.35) concAdj *= 0.94;
    m *= concAdj;
    factors.push({ id: 'concentration', label: 'Concentration risk', mult: concAdj, delta: concAdj - 1, hint: `Top 5 = ${(o.top5Concentration*100).toFixed(0)}% · top 1 = ${(o.top1Concentration*100).toFixed(0)}%` });

    // Rights coverage haircut
    const rightsAdj = 0.85 + (o.rightsCoverage - 0.85) * 1.0; // 0.85 cov -> 0.85x, 1.0 cov -> 1.0x
    m *= rightsAdj;
    factors.push({ id: 'rights', label: 'Rights & splits coverage', mult: rightsAdj, delta: rightsAdj - 1, hint: `${(o.rightsCoverage*100).toFixed(1)}% clean splits + CWRs` });

    // Growth trajectory: every 1% above 5% TTM growth = +1.2%
    const growthAdj = 1 + Math.max(-0.10, Math.min(0.18, (o.ttmGrowth - 0.05) * 1.2));
    m *= growthAdj;
    factors.push({ id: 'growth', label: 'Growth trajectory premium', mult: growthAdj, delta: growthAdj - 1, hint: `TTM growth ${(o.ttmGrowth*100).toFixed(1)}% vs 5% baseline` });

    // Sync optionality: above 8% share = bonus
    const syncAdj = 1 + Math.max(0, (o.syncShare - 0.08) * 0.8);
    m *= syncAdj;
    factors.push({ id: 'sync', label: 'Sync optionality premium', mult: syncAdj, delta: syncAdj - 1, hint: `${(o.syncShare*100).toFixed(1)}% sync revenue · placement upside` });

    // UGC exposure haircut: above 10% share = slight discount
    const ugcAdj = 1 - Math.max(0, (o.ugcShare - 0.10) * 0.4);
    m *= ugcAdj;
    factors.push({ id: 'ugc', label: 'UGC volatility adjustment', mult: ugcAdj, delta: ugcAdj - 1, hint: `${(o.ugcShare*100).toFixed(1)}% UGC · TikTok/CID exposure` });

    // Geographic concentration
    const geoAdj = 0.95 + (o.usEuShare - 0.5) * 0.10;
    m *= geoAdj;
    factors.push({ id: 'geo', label: 'Geographic mix', mult: geoAdj, delta: geoAdj - 1, hint: `${(o.usEuShare*100).toFixed(0)}% US + EU` });

    return { multiple: m, factors };
  }

  // ═══════════════════════════════════════════════════════════════════
  // DCF FORECAST — per-stream, 10 years, with explicit decay curves
  // ═══════════════════════════════════════════════════════════════════
  function forecastByStream(ttmTotal, opts) {
    const o = Object.assign({ years: 10, noise: 0.0, rng: null }, opts || {});
    const rng = o.rng;
    const streams = REVENUE_STREAMS.map(function (s) {
      const ttm = ttmTotal * s.share;
      const yearly = [];
      let v = ttm;
      for (let y = 0; y < o.years; y++) {
        const decayMult = y < 3 ? s.decay[0] : y < 6 ? s.decay[1] : s.decay[2];
        let mult = decayMult;
        if (rng && o.noise > 0) {
          mult *= (1 + (rng() - 0.5) * 2 * o.noise * s.volatility);
        }
        v = Math.max(0, v * mult);
        yearly.push(v);
      }
      return { id: s.id, label: s.label, color: s.color, ttm: ttm, share: s.share, yearly: yearly, decay: s.decay };
    });
    const totals = [];
    for (let y = 0; y < o.years; y++) {
      let t = 0;
      streams.forEach(function (s) { t += s.yearly[y]; });
      totals.push(t);
    }
    return { streams: streams, totals: totals };
  }

  // Apply a DCF discount rate to per-year totals → present value
  function dcfValue(forecast, discountRate, includeYear0, year0) {
    const flows = [];
    if (includeYear0) flows.push(year0);
    for (let i = 0; i < forecast.totals.length; i++) flows.push(forecast.totals[i]);
    return npv(discountRate, flows);
  }

  // ═══════════════════════════════════════════════════════════════════
  // COMPARABLES REGRESSION
  // ═══════════════════════════════════════════════════════════════════
  // Simple OLS: multiple = b0 + b1*age + b2*genre_premium
  // Returns predicted multiple for a target catalog and the regression band.
  function comparablesRegression(target) {
    // log-linear fit on age
    const xs = COMPARABLES.map(function (c) { return c.age; });
    const ys = COMPARABLES.map(function (c) { return c.mult; });
    const n = xs.length;
    const meanX = xs.reduce(function (a, b) { return a + b; }, 0) / n;
    const meanY = ys.reduce(function (a, b) { return a + b; }, 0) / n;
    let num = 0, den = 0;
    for (let i = 0; i < n; i++) {
      num += (xs[i] - meanX) * (ys[i] - meanY);
      den += (xs[i] - meanX) * (xs[i] - meanX);
    }
    const slope = den === 0 ? 0 : num / den;
    const intercept = meanY - slope * meanX;
    const predicted = intercept + slope * (target.catalogAgeYears || 8);
    // residual SD for confidence band
    let ss = 0;
    for (let i = 0; i < n; i++) {
      const yhat = intercept + slope * xs[i];
      ss += (ys[i] - yhat) * (ys[i] - yhat);
    }
    const sd = Math.sqrt(ss / Math.max(1, n - 2));
    return {
      predictedMultiple: predicted,
      lowerBand: predicted - 1.96 * sd,
      upperBand: predicted + 1.96 * sd,
      slope: slope,
      intercept: intercept,
      n: n,
      residualSd: sd,
      points: COMPARABLES.slice(),
    };
  }

  // ═══════════════════════════════════════════════════════════════════
  // SENSITIVITY (one-variable tornado)
  // ═══════════════════════════════════════════════════════════════════
  // For each input, perturb ±X% and measure impact on valuation.
  function tornado(baseOpts) {
    const base = compute(baseOpts);
    const baseValue = base.value;
    const inputs = [
      { id: 'discountRate',     label: 'Discount rate',          deltaLow: -0.02, deltaHigh: +0.02, mode: 'add' },
      { id: 'ttmEarnings',      label: 'TTM earnings',           deltaLow: -0.10, deltaHigh: +0.10, mode: 'mult' },
      { id: 'ttmGrowth',        label: 'Growth rate',            deltaLow: -0.03, deltaHigh: +0.03, mode: 'add' },
      { id: 'top5Concentration',label: 'Top-5 concentration',    deltaLow: -0.10, deltaHigh: +0.15, mode: 'add' },
      { id: 'rightsCoverage',   label: 'Rights coverage',        deltaLow: -0.05, deltaHigh: +0.04, mode: 'add' },
      { id: 'syncShare',        label: 'Sync revenue share',     deltaLow: -0.04, deltaHigh: +0.06, mode: 'add' },
      { id: 'catalogAgeYears',  label: 'Catalog age',            deltaLow: -3,    deltaHigh: +5,    mode: 'add' },
    ];
    return inputs.map(function (inp) {
      const lowOpts = applyDelta(baseOpts, inp.id, inp.deltaLow, inp.mode);
      const highOpts = applyDelta(baseOpts, inp.id, inp.deltaHigh, inp.mode);
      const lowV = compute(lowOpts).value;
      const highV = compute(highOpts).value;
      return {
        id: inp.id,
        label: inp.label,
        baseValue: baseValue,
        lowValue: Math.min(lowV, highV),
        highValue: Math.max(lowV, highV),
        impact: Math.abs(highV - lowV),
        deltaLow: inp.deltaLow,
        deltaHigh: inp.deltaHigh,
        mode: inp.mode,
      };
    }).sort(function (a, b) { return b.impact - a.impact; });
  }

  function applyDelta(opts, id, delta, mode) {
    const next = Object.assign({}, opts || {});
    const cur = next[id] != null ? next[id] : defaultOpts()[id];
    next[id] = mode === 'mult' ? cur * (1 + delta) : cur + delta;
    return next;
  }

  // 2-variable sensitivity grid (e.g. discount rate × growth rate)
  function sensitivityGrid(baseOpts, axisX, axisY) {
    axisX = axisX || { id: 'discountRate', from: 0.06, to: 0.14, steps: 5 };
    axisY = axisY || { id: 'ttmGrowth',    from: -0.02, to: 0.12, steps: 5 };
    const xs = linspace(axisX.from, axisX.to, axisX.steps);
    const ys = linspace(axisY.from, axisY.to, axisY.steps);
    const grid = [];
    for (let yi = 0; yi < ys.length; yi++) {
      const row = [];
      for (let xi = 0; xi < xs.length; xi++) {
        const opts = Object.assign({}, baseOpts);
        opts[axisX.id] = xs[xi];
        opts[axisY.id] = ys[yi];
        row.push(compute(opts).value);
      }
      grid.push(row);
    }
    return { xs: xs, ys: ys, grid: grid, axisX: axisX, axisY: axisY };
  }

  function linspace(a, b, n) {
    const out = [];
    if (n === 1) return [a];
    for (let i = 0; i < n; i++) out.push(a + (b - a) * i / (n - 1));
    return out;
  }

  // ═══════════════════════════════════════════════════════════════════
  // MONTE CARLO — distributional confidence on the valuation
  // ═══════════════════════════════════════════════════════════════════
  // Vary the noisy inputs (TTM, growth, decay rates, multiple) under
  // their respective distributions, run N draws, return the histogram.
  function monteCarlo(opts) {
    opts = Object.assign(defaultOpts(), opts || {});
    const N = opts.iterations || 10000;
    const seed = opts.seed != null ? opts.seed : seedFromString('astro-mc-2026');
    const rng = mulberry32(seed);
    const draws = new Array(N);

    for (let i = 0; i < N; i++) {
      const o = Object.assign({}, opts);
      // Earnings are log-normal-ish; use ±8% gaussian on ttm
      o.ttmEarnings = Math.max(0, gaussian(rng, opts.ttmEarnings, opts.ttmEarnings * 0.08));
      // Growth ~ N(growth, 2.5%)
      o.ttmGrowth = gaussian(rng, opts.ttmGrowth, 0.025);
      // Discount rate ~ N(rate, 1.2%)
      o.discountRate = Math.max(0.02, gaussian(rng, opts.discountRate, 0.012));
      // Concentration ~ N(c, 0.04) clipped
      o.top5Concentration = Math.min(0.85, Math.max(0.10, gaussian(rng, opts.top5Concentration, 0.04)));
      // Rights coverage clipped to [0.7, 1.0]
      o.rightsCoverage = Math.min(1.0, Math.max(0.7, gaussian(rng, opts.rightsCoverage, 0.02)));
      // Catalog age ±1.5y
      o.catalogAgeYears = Math.max(1, gaussian(rng, opts.catalogAgeYears, 1.5));

      draws[i] = compute(o).value;
    }

    draws.sort(function (a, b) { return a - b; });

    const pct = function (p) { return draws[Math.floor(p * (N - 1))]; };
    return {
      n: N,
      draws: draws,
      mean: draws.reduce(function (a, b) { return a + b; }, 0) / N,
      median: pct(0.5),
      p05: pct(0.05),
      p10: pct(0.10),
      p25: pct(0.25),
      p75: pct(0.75),
      p90: pct(0.90),
      p95: pct(0.95),
      stdev: stdev(draws),
      histogram: histogram(draws, 24),
    };
  }

  function stdev(arr) {
    const m = arr.reduce(function (a, b) { return a + b; }, 0) / arr.length;
    let s = 0;
    for (let i = 0; i < arr.length; i++) s += (arr[i] - m) * (arr[i] - m);
    return Math.sqrt(s / arr.length);
  }

  function histogram(sorted, bins) {
    const min = sorted[0];
    const max = sorted[sorted.length - 1];
    const w = (max - min) / bins;
    const out = [];
    for (let b = 0; b < bins; b++) {
      out.push({ from: min + b * w, to: min + (b + 1) * w, count: 0 });
    }
    for (let i = 0; i < sorted.length; i++) {
      let b = Math.floor((sorted[i] - min) / w);
      if (b >= bins) b = bins - 1;
      if (b < 0) b = 0;
      out[b].count += 1;
    }
    return out;
  }

  // ═══════════════════════════════════════════════════════════════════
  // SCENARIOS
  // ═══════════════════════════════════════════════════════════════════
  // Pre-canned strategic scenarios with their own input overrides.
  function scenarios(baseOpts) {
    const base = Object.assign(defaultOpts(), baseOpts || {});
    const SCENARIOS = [
      {
        id: 'bull',
        label: 'Bull · sync-led upside',
        narrative: 'Aggressive sync placement strategy lands 4 major TV/film cues. UGC viral on 2 catalog tracks.',
        overrides: { ttmGrowth: base.ttmGrowth + 0.06, syncShare: base.syncShare + 0.05, ugcShare: base.ugcShare + 0.03 },
        probability: 0.20,
      },
      {
        id: 'base',
        label: 'Base · status quo',
        narrative: 'Catalog continues current trajectory. No major sync wins or losses. DSP rates flat.',
        overrides: {},
        probability: 0.50,
      },
      {
        id: 'bear',
        label: 'Bear · DSP pricing pressure',
        narrative: 'Spotify per-stream payout drops 12%. Apple Music renegotiation. UGC monetization tightens.',
        overrides: { ttmGrowth: base.ttmGrowth - 0.05, ttmEarnings: base.ttmEarnings * 0.92 },
        probability: 0.20,
      },
      {
        id: 'breakup',
        label: 'Break-up · sell top 5 only',
        narrative: 'Carve out top-5 works (38% of revenue) and sell separately at premium. Tail catalog held.',
        overrides: { ttmEarnings: base.ttmEarnings * 0.38, top5Concentration: 0.95, top1Concentration: 0.32 },
        probability: 0.05,
        modifier: function (val) {
          // Top-only deals trade at premium; add 30%
          val.value *= 1.30;
          val.tailValue = compute(Object.assign({}, base, { ttmEarnings: base.ttmEarnings * 0.62, top5Concentration: 0.18 })).value;
          val.combinedValue = val.value + val.tailValue;
          return val;
        },
      },
      {
        id: 'synch-deal',
        label: 'Synch · strategic partnership',
        narrative: 'Lock 3-year exclusive sync deal with major studio. Sync share doubles, premium per-cue rate.',
        overrides: { syncShare: base.syncShare * 2.2, ttmEarnings: base.ttmEarnings * 1.06 },
        probability: 0.05,
      },
    ];
    return SCENARIOS.map(function (s) {
      const opts = Object.assign({}, base, s.overrides);
      let result = compute(opts);
      if (s.modifier) result = s.modifier(result);
      return {
        id: s.id,
        label: s.label,
        narrative: s.narrative,
        probability: s.probability,
        opts: opts,
        result: result,
      };
    });
  }

  // ═══════════════════════════════════════════════════════════════════
  // PER-WORK ATTRIBUTION (Shapley approximation)
  // ═══════════════════════════════════════════════════════════════════
  // Each work contributes some fraction of TTM. The Shapley value of a
  // work is its average marginal contribution across random orderings;
  // here we use a Monte Carlo Shapley estimator with 200 random perms.
  //
  // The key insight for catalog: a work's NPV contribution is NOT just
  // its share of revenue, because removing concentration risk lifts the
  // multiple for the rest. So a top-1 work removed = revenue down N% but
  // multiple up some, so its true Shapley value is higher than its share.
  function shapleyAttribute(opts) {
    opts = Object.assign(defaultOpts(), opts || {});
    const works = opts.works || synthesizeWorks(opts);
    const total = compute(opts).value;
    const totalRevenue = works.reduce(function (a, w) { return a + w.ttm; }, 0);

    const N = works.length;
    const M = opts.shapleyPermutations || 60; // keep it cheap
    const seed = opts.seed != null ? opts.seed : seedFromString('astro-shapley-2026');
    const rng = mulberry32(seed);
    const sums = new Array(N).fill(0);

    // For tractability we use a closed-form approximation:
    // value of subset S = compute({ ttmEarnings: Σ_{i∈S} w_i.ttm,
    //                                top5Concentration: derived from S })
    function valueOf(subset) {
      if (subset.length === 0) return 0;
      const subTtm = subset.reduce(function (a, w) { return a + w.ttm; }, 0);
      // re-derive concentration
      const sortedShares = subset.map(function (w) { return w.ttm / subTtm; }).sort(function (a, b) { return b - a; });
      const top5 = sortedShares.slice(0, 5).reduce(function (a, b) { return a + b; }, 0);
      const top1 = sortedShares[0] || 0;
      return compute(Object.assign({}, opts, { ttmEarnings: subTtm, top5Concentration: top5, top1Concentration: top1 })).value;
    }

    for (let p = 0; p < M; p++) {
      const order = shuffle(works.slice(), rng);
      const carried = [];
      let prev = 0;
      for (let i = 0; i < N; i++) {
        carried.push(order[i]);
        const v = valueOf(carried);
        const idx = works.indexOf(order[i]);
        sums[idx] += (v - prev);
        prev = v;
      }
    }

    return works.map(function (w, i) {
      const shap = sums[i] / M;
      return {
        id: w.id,
        title: w.title,
        artist: w.artist,
        ttm: w.ttm,
        revenueShare: w.ttm / totalRevenue,
        shapleyValue: shap,
        valueShare: shap / total,
        // a multiplier > 1 means the work is worth more than its revenue share
        leveragedMultiple: (shap / total) / (w.ttm / totalRevenue),
      };
    }).sort(function (a, b) { return b.shapleyValue - a.shapleyValue; });
  }

  function shuffle(arr, rng) {
    for (let i = arr.length - 1; i > 0; i--) {
      const j = Math.floor(rng() * (i + 1));
      const t = arr[i]; arr[i] = arr[j]; arr[j] = t;
    }
    return arr;
  }

  // Synthesize a representative top-20 work list when the host catalog
  // doesn't provide one. Uses a power-law distribution.
  function synthesizeWorks(opts) {
    const seed = opts.seed != null ? opts.seed : seedFromString('astro-works-2026');
    const rng = mulberry32(seed);
    const N = 20;
    const ttm = opts.ttmEarnings;
    // Power law: ttm_i ∝ 1 / (i+1)^alpha
    const alpha = 1.05;
    const weights = [];
    let sum = 0;
    for (let i = 0; i < N; i++) {
      const w = 1 / Math.pow(i + 1, alpha);
      weights.push(w);
      sum += w;
    }
    // Account for the long tail (works beyond N) ~ 35% of revenue
    const headFrac = 0.65;
    const TITLES = [
      'Midnight Resurrection', 'Coastal Drive', 'Paper Lanterns', 'Alibi', 'Heliogram',
      'Smoke & Marble', 'Telegraph Hill', 'Old Cathedral', 'Static', 'Yesterday\u2019s Forecast',
      'Glass Lantern', 'Mercurial', 'Kerosene Lights', 'Threadbare', 'Hummingbird Year',
      'Cinder Block', 'Lapis', 'Quiet Morning', 'Last Letter', 'Wildflower Country',
    ];
    const ARTISTS = ['Ada Renner', 'The Stillwater', 'Yuki & The Lights', 'Kafka Linear', 'Ren M.', 'Ozark Drift'];
    const works = [];
    for (let i = 0; i < N; i++) {
      const t = ttm * headFrac * weights[i] / sum;
      // jitter ±10%
      const jit = t * (0.9 + rng() * 0.2);
      works.push({
        id: 'w-' + i,
        title: TITLES[i % TITLES.length],
        artist: ARTISTS[Math.floor(rng() * ARTISTS.length)],
        ttm: jit,
      });
    }
    return works;
  }

  // ═══════════════════════════════════════════════════════════════════
  // BUY-SIDE / SALE ANALYSIS
  // ═══════════════════════════════════════════════════════════════════
  // Given a hypothetical sale price, compute payback period and IRR
  // for the buyer; sale-vs-hold breakeven for the seller.
  function buySideAnalysis(opts) {
    opts = Object.assign(defaultOpts(), opts || {});
    const valuation = compute(opts);
    const forecast = forecastByStream(opts.ttmEarnings, { years: 10 });
    // include year-0 (TTM) flow
    const flows = [opts.ttmEarnings].concat(forecast.totals);

    const askingPrice = valuation.value;
    const buyerFlows = [-askingPrice].concat(flows.slice(1));
    const buyerIrr = irr(buyerFlows);
    const buyerPayback = paybackPeriod(askingPrice, flows.slice(1));
    const buyerNpv8 = npv(0.08, buyerFlows);
    const buyerNpv12 = npv(0.12, buyerFlows);

    // breakeven sale price = NPV of held flows at seller's discount rate
    const sellerDiscount = opts.sellerDiscountRate || 0.12;
    const breakevenSale = npv(sellerDiscount, flows);

    return {
      askingPrice: askingPrice,
      buyer: {
        irr: buyerIrr,
        paybackYears: buyerPayback,
        npv8: buyerNpv8,
        npv12: buyerNpv12,
      },
      seller: {
        sellerDiscount: sellerDiscount,
        breakevenSale: breakevenSale,
        verdict: askingPrice >= breakevenSale ? 'sell' : 'hold',
        spread: askingPrice - breakevenSale,
      },
      flows: flows,
    };
  }

  // ═══════════════════════════════════════════════════════════════════
  // MAIN COMPUTE — orchestrates the full valuation
  // ═══════════════════════════════════════════════════════════════════
  function defaultOpts() {
    return {
      ttmEarnings: 5_040_000,
      ttmGrowth: 0.072,
      catalogAgeYears: 8,
      genre: 'mixed-pop',
      top5Concentration: 0.38,
      top1Concentration: 0.12,
      rightsCoverage: 0.96,
      syncShare: 0.09,
      ugcShare: 0.06,
      usEuShare: 0.78,
      discountRate: 0.09,
      sellerDiscountRate: 0.12,
      years: 10,
      baselineMultiple: 13.0,
    };
  }

  function compute(opts) {
    opts = Object.assign(defaultOpts(), opts || {});
    // 1) build multiple
    const m = buildMultiple({
      baseline: opts.baselineMultiple,
      catalogAgeYears: opts.catalogAgeYears,
      genre: opts.genre,
      top5Concentration: opts.top5Concentration,
      top1Concentration: opts.top1Concentration,
      rightsCoverage: opts.rightsCoverage,
      ttmGrowth: opts.ttmGrowth,
      syncShare: opts.syncShare,
      ugcShare: opts.ugcShare,
      usEuShare: opts.usEuShare,
    });
    // 2) market-multiple value
    const marketValue = opts.ttmEarnings * m.multiple;
    // 3) DCF value
    const fc = forecastByStream(opts.ttmEarnings, { years: opts.years });
    const dcv = dcfValue(fc, opts.discountRate, true, opts.ttmEarnings);
    // 4) regression value
    const reg = comparablesRegression({ catalogAgeYears: opts.catalogAgeYears });
    const regValue = opts.ttmEarnings * reg.predictedMultiple;
    // 5) blended fair value (weighted avg)
    const value = 0.45 * marketValue + 0.40 * dcv + 0.15 * regValue;

    return {
      ttmEarnings: opts.ttmEarnings,
      multiple: m.multiple,
      factors: m.factors,
      marketValue: marketValue,
      dcfValue: dcv,
      regressionValue: regValue,
      regression: reg,
      value: value,
      forecast: fc,
      lifetimeGross: fc.totals.reduce(function (a, b) { return a + b; }, 0) + opts.ttmEarnings,
      // Range = ±15% empirically calibrated to dispersion in comps
      lowValue: value * 0.84,
      highValue: value * 1.18,
      opts: opts,
    };
  }

  // ═══════════════════════════════════════════════════════════════════
  // EXPORT
  // ═══════════════════════════════════════════════════════════════════
  window.ValuationEngine = {
    compute: compute,
    monteCarlo: monteCarlo,
    scenarios: scenarios,
    shapleyAttribute: shapleyAttribute,
    sensitivityGrid: sensitivityGrid,
    tornado: tornado,
    buySideAnalysis: buySideAnalysis,
    forecastByStream: forecastByStream,
    comparablesRegression: comparablesRegression,
    buildMultiple: buildMultiple,
    npv: npv,
    irr: irr,
    paybackPeriod: paybackPeriod,
    REVENUE_STREAMS: REVENUE_STREAMS,
    COMPARABLES: COMPARABLES,
    defaultOpts: defaultOpts,
    seedFromString: seedFromString,
  };
})();
