// royalty-viz-engine.jsx — Royalty Visualization data engine
// ─────────────────────────────────────────────────────────────────
// Pulls from window.__STMT_INDEX (real reconciled inbox) and falls
// back to synthetic data so charts always look populated. Provides:
//
//   • RVE.fetchLines({ filter }) → flat array of normalized line objects
//   • RVE.groupBy(lines, dim)    → { key, value, lineCount, share } rows
//   • RVE.timeSeries(lines, gran) → { key, value, prior } points
//   • RVE.heatmap(lines, rows, cols) → { rowKey, colKey, value } cells
//   • RVE.sankey(lines, dims) → nodes + links (3-tier flow)
//   • RVE.cohort(lines)       → release-age × earnings matrix
//   • RVE.pareto(lines, dim)  → sorted + cumulative curve
//
// Cross-filter store: any chart can call setFilter() to update all
// other subscribed charts. Subscribe via subscribe()/unsubscribe.
//
// Exports: window.RVE
// ─────────────────────────────────────────────────────────────────
(function () {
  if (typeof window === 'undefined') return;
  if (window.RVE) return;

  // ─── DIMENSIONS ──────────────────────────────────────────────
  const DIM_LABELS = {
    source:      'Source',
    period:      'Period',
    work:        'Work',
    recording:   'Recording',
    release:     'Release',
    territory:   'Territory',
    productType: 'Product type',
    writer:      'Writer',
    publisher:   'Publisher',
    currency:    'Currency',
    dsp:         'DSP',
  };

  // ─── DETERMINISTIC RANDOM (synthetic fallback) ───────────────
  function seedRng(seed) {
    let s = seed | 0;
    return () => {
      s = (s * 1664525 + 1013904223) | 0;
      return ((s >>> 0) % 100000) / 100000;
    };
  }

  // ─── DATA SOURCES ────────────────────────────────────────────
  // Multi-parser key index — different parsers use different field names
  const KEYS = {
    work:      ['workTitle', 'work', 'title', 'songTitle', 'composition'],
    recording: ['recordingTitle', 'trackTitle', 'recording', 'title', 'track', 'songTitle'],
    release:   ['albumTitle', 'releaseTitle', 'releaseName', 'album', 'release'],
    territory: ['territory', 'country', 'countryCode', 'iso', 'isoCode'],
    writer:    ['writers', 'writer', 'composers', 'composer', 'songwriter', 'songwriters'],
    artist:    ['trackArtists', 'releaseArtists', 'artists', 'artist', 'performer'],
    publisher: ['publisher', 'pubName'],
    isrc:      ['isrc'],
    iswc:      ['iswc'],
    dsp:       ['dsp', 'platform', 'service', 'reporter'],
    gross:     ['grossUsd', 'royaltyUsd', 'royalty', 'gross', 'amountUsd', 'amount', 'netUsd', 'net'],
    units:     ['units', 'count', 'quantity', 'streams', 'plays'],
    period:    ['activityPeriod', 'periodLabel', 'reportingPeriodLabel'],
    periodStart: ['periodStart', 'reportingStart', 'start', 'activityDate'],
    productType: ['productType', 'royaltyType', 'rightType', 'incomeType', 'usageType', 'delivery'],
    currency:  ['currency', 'currencyCode'],
    fxToUsd:   ['fxToUsd', 'fxRate'],
  };
  function pick(obj, names, dflt) {
    for (let i = 0; i < names.length; i++) {
      const v = obj[names[i]];
      if (v != null && v !== '') return v;
    }
    return dflt;
  }
  // Tiny TIS lookup for the most common codes (CISAC TIS → ISO-2)
  const TIS_TO_ISO = {
    '0840':'US','0826':'GB','0276':'DE','0250':'FR','0392':'JP','0076':'BR','0124':'CA',
    '0036':'AU','0554':'NZ','0484':'MX','0032':'AR','0152':'CL','0170':'CO','0604':'PE',
    '0724':'ES','0620':'PT','0380':'IT','0528':'NL','0056':'BE','0756':'CH','0040':'AT',
    '0752':'SE','0578':'NO','0208':'DK','0246':'FI','0616':'PL','0203':'CZ','0643':'RU',
    '0792':'TR','0300':'GR','0410':'KR','0156':'CN','0356':'IN','0360':'ID','0608':'PH',
    '0764':'TH','0704':'VN','0458':'MY','0702':'SG','0344':'HK','0158':'TW','0710':'ZA',
    '0566':'NG','0818':'EG','0682':'SA','0784':'AE','0372':'IE',
  };
  // ISO-3 → ISO-2 + a few common name → ISO-2 mappings (subset)
  const TERR_MAP = {
    USA:'US', GBR:'GB', DEU:'DE', FRA:'FR', JPN:'JP', BRA:'BR', CAN:'CA', AUS:'AU', NZL:'NZ',
    MEX:'MX', ARG:'AR', CHL:'CL', COL:'CO', PER:'PE', ESP:'ES', PRT:'PT', ITA:'IT', NLD:'NL',
    BEL:'BE', CHE:'CH', AUT:'AT', SWE:'SE', NOR:'NO', DNK:'DK', FIN:'FI', POL:'PL', CZE:'CZ',
    RUS:'RU', TUR:'TR', GRC:'GR', KOR:'KR', CHN:'CN', IND:'IN', IDN:'ID', PHL:'PH', THA:'TH',
    VNM:'VN', MYS:'MY', SGP:'SG', HKG:'HK', TWN:'TW', ZAF:'ZA', NGA:'NG', EGY:'EG', SAU:'SA',
    ARE:'AE', IRL:'IE', UNITED_STATES:'US', 'United States':'US', 'United Kingdom':'GB',
    Germany:'DE', France:'FR', Japan:'JP', Brazil:'BR', Canada:'CA', Australia:'AU', Mexico:'MX',
  };
  function normTerr(v) {
    if (!v) return 'XW';
    let s = String(v).trim();
    // 4-digit TIS → keep, but mark as XW since map has no geo
    if (/^\d{4}$/.test(s)) return TIS_TO_ISO[s] || 'XW';
    // ISO-3 → ISO-2
    if (s.length === 3 && /^[A-Z]{3}$/i.test(s)) return TERR_MAP[s.toUpperCase()] || s.slice(0, 2).toUpperCase();
    if (s.length === 2 && /^[A-Z]{2}$/i.test(s)) return s.toUpperCase();
    return TERR_MAP[s] || TERR_MAP[s.toUpperCase()] || 'XW';
  }
  // Normalize period to YYYY-MM (best effort)
  function normPeriod(v, fallback) {
    if (!v) return fallback || '2025-12';
    const s = String(v);
    if (/^\d{4}-\d{2}/.test(s)) return s.slice(0, 7);
    if (/^\d{4}\/\d{1,2}/.test(s)) {
      const [y, m] = s.split('/');
      return y + '-' + String(m).padStart(2, '0');
    }
    // "January 2026"
    const months = { january:'01', february:'02', march:'03', april:'04', may:'05', june:'06', july:'07', august:'08', september:'09', october:'10', november:'11', december:'12', jan:'01', feb:'02', mar:'03', apr:'04', jun:'06', jul:'07', aug:'08', sep:'09', oct:'10', nov:'11', dec:'12' };
    const m = s.match(/(\w+)\s+(\d{4})/);
    if (m) {
      const mo = months[m[1].toLowerCase()];
      if (mo) return `${m[2]}-${mo}`;
    }
    if (/^\d{4}/.test(s)) return s.slice(0, 4) + '-01';
    return fallback || '2025-12';
  }

  function fetchRealLines() {
    const idx = window.__STMT_INDEX;
    if (!idx?.statements?.length) return null;
    const lines = [];
    idx.statements.forEach(stmt => {
      const stmtPeriod = stmt.period?.label || stmt.period?.id || stmt.periodLabel || stmt.reportingPeriodLabel;
      (stmt.lines || []).forEach((line, i) => {
        const gross = +pick(line, KEYS.gross, 0);
        if (gross <= 0) return;
        const periodRaw = pick(line, KEYS.period, stmtPeriod);
        const periodKey = normPeriod(periodRaw, normPeriod(stmtPeriod, '2025-12'));
        // Product type — derive from delivery/contentType too
        let productType = pick(line, KEYS.productType, null);
        if (!productType) {
          productType = (stmt.parser?.includes('mech') || stmt.parser?.includes('mlc') ? 'mechanical' :
            stmt.parser?.includes('soundex') ? 'neighbour' :
            stmt.parser?.includes('sync') ? 'sync' :
            stmt.sourceKind === 'pro' ? 'performance' :
            stmt.sourceKind === 'dsp' || stmt.parser?.includes('rsd') || stmt.parser?.includes('distributor') ? 'master' :
            'performance');
        }
        // Map common delivery values → cleaner buckets
        const ptLow = String(productType).toLowerCase();
        if (ptLow.includes('stream')) productType = 'stream';
        else if (ptLow.includes('download') || ptLow === 'dlx' || ptLow === 'dl') productType = 'download';
        else if (ptLow.includes('mech')) productType = 'mechanical';
        else if (ptLow.includes('perf')) productType = 'performance';
        else if (ptLow.includes('sync')) productType = 'sync';
        else if (ptLow.includes('neigh')) productType = 'neighbour';

        const recording = pick(line, KEYS.recording, '—');
        const work = pick(line, KEYS.work, recording); // fall back to recording title
        const release = pick(line, KEYS.release, '');
        const writer = pick(line, KEYS.writer, '') || pick(line, KEYS.artist, '');

        lines.push({
          id: `${stmt.id}_${i}`,
          source: stmt.sourceName || stmt.source?.name || 'Unknown',
          sourceKind: stmt.sourceKind || stmt.source?.kind || stmt.parser?.split('-')[0] || 'other',
          period: periodKey,
          periodSort: stmt.period?.start || pick(line, KEYS.periodStart, ''),
          work: work || '—',
          workId: String(pick(line, KEYS.iswc, work || '')).toLowerCase().trim(),
          recording: recording || work || '—',
          recordingId: String(pick(line, KEYS.isrc, '')),
          release: release || '',
          territory: normTerr(pick(line, KEYS.territory, 'XW')),
          productType,
          writer: writer || '',
          publisher: pick(line, KEYS.publisher, stmt.sourceName || ''),
          currency: pick(line, KEYS.currency, 'USD'),
          dsp: pick(line, KEYS.dsp, stmt.sourceName || '—'),
          gross,
          fxToUsd: +pick(line, KEYS.fxToUsd, 1),
          units: +pick(line, KEYS.units, 0),
        });
      });
    });
    return lines.length ? lines : null;
  }

  // ─── SYNTHETIC FALLBACK ──────────────────────────────────────
  const SYN_SOURCES = [
    { name: 'Spotify',   kind: 'dsp', dsp: 'Spotify',   pt: 'mechanical' },
    { name: 'Apple Music',kind: 'dsp', dsp: 'Apple Music',pt: 'mechanical' },
    { name: 'YouTube',   kind: 'dsp', dsp: 'YouTube',   pt: 'mechanical' },
    { name: 'Tidal',     kind: 'dsp', dsp: 'Tidal',     pt: 'mechanical' },
    { name: 'Amazon',    kind: 'dsp', dsp: 'Amazon Music',pt: 'mechanical' },
    { name: 'ASCAP',     kind: 'pro', dsp: 'Radio/TV',  pt: 'performance' },
    { name: 'BMI',       kind: 'pro', dsp: 'Radio/TV',  pt: 'performance' },
    { name: 'PRS',       kind: 'pro', dsp: 'UK',        pt: 'performance' },
    { name: 'GEMA',      kind: 'pro', dsp: 'DE',        pt: 'performance' },
    { name: 'SACEM',     kind: 'pro', dsp: 'FR',        pt: 'performance' },
    { name: 'MLC',       kind: 'mech',dsp: 'US Mech',   pt: 'mechanical' },
    { name: 'HFA',       kind: 'mech',dsp: 'US Mech',   pt: 'mechanical' },
    { name: 'Sync · Universal', kind: 'sync', dsp: 'Sync', pt: 'sync' },
    { name: 'Sync · Netflix', kind: 'sync', dsp: 'Sync', pt: 'sync' },
    { name: 'SoundExchange', kind: 'neighbour', dsp: 'US Digital Radio', pt: 'neighbour' },
  ];
  const SYN_TERRITORIES = ['US','GB','DE','FR','JP','BR','CA','AU','MX','IT','ES','NL','SE','KR','IN','XW'];
  const SYN_WORKS = [
    'Velvet Hour','Honey Static','Cobalt Lullaby','Riverbend','Paper Cities','North Bridge',
    'Magnolia Drag','Brass Sermon','Saltwater Coda','Lower Field','Polar Hush','Saint Marie',
    'Blue Acres','Granite & Glass','Tin Roof Choir','Quiet Riot Reprise','Antenna Garden','Mercurial',
    'After Image','Drosophila','Fontana Park','Heliotrope','Late Bloom','Marrowbone','Nightside',
  ];
  const SYN_WRITERS = ['Aria Vance','Beck Holloway','Cora Reyes','Dane Whitlock','Elise Mori','Faye Park',
    'Gus Allard','Hina Sato','Ira Vega','June Maeda','Kit Tanaka','Liv Romero'];
  const SYN_PUBS = ['Vault & Branch','Northern Bell Pub','Glasshouse Music','Atlas Songs','Tidal Plain Pub'];

  function synthLines() {
    const rng = seedRng(42);
    const lines = [];
    const periods = [];
    const start = new Date('2024-01-01');
    for (let m = 0; m < 24; m++) {
      const d = new Date(start.getFullYear(), start.getMonth() + m, 1);
      periods.push({ label: d.toISOString().slice(0, 7), sort: d.toISOString().slice(0, 10) });
    }
    let id = 0;
    SYN_WORKS.forEach((work, wi) => {
      const workId = 'work_' + wi;
      const writer = SYN_WRITERS[wi % SYN_WRITERS.length];
      const pub = SYN_PUBS[wi % SYN_PUBS.length];
      // Each work has a release-age effect (long-tail decay)
      periods.forEach((p, pi) => {
        SYN_SOURCES.forEach((src, si) => {
          // Skip many combinations randomly to stay sparse-ish
          if (rng() < 0.6) return;
          // Territory mix per source
          const terrCount = src.kind === 'dsp' ? 4 : src.kind === 'pro' ? 1 : 2;
          for (let t = 0; t < terrCount; t++) {
            const territory = SYN_TERRITORIES[Math.floor(rng() * SYN_TERRITORIES.length)];
            // Decay curve: peak month 2-4, then long tail
            const monthFromRelease = pi - (wi % 6);
            const decay = monthFromRelease < 0 ? 0 : Math.exp(-monthFromRelease / (8 + wi % 5));
            const base = src.kind === 'sync' ? 5000 : src.kind === 'pro' ? 800 : 200;
            const noise = 0.3 + rng() * 1.4;
            const gross = base * decay * noise * (1 + (24 - wi) * 0.1);
            if (gross < 1) continue;
            lines.push({
              id: 'syn_' + (id++),
              source: src.name,
              sourceKind: src.kind,
              period: p.label,
              periodSort: p.sort,
              work,
              workId,
              recording: work,
              recordingId: 'isrc_' + workId,
              release: 'Album · ' + (wi % 4 === 0 ? 'Northbound' : wi % 4 === 1 ? 'Echo Drift' : wi % 4 === 2 ? 'Field Notes' : 'Halftone'),
              territory,
              productType: src.pt,
              writer,
              publisher: pub,
              currency: territory === 'GB' ? 'GBP' : territory === 'DE' || territory === 'FR' || territory === 'IT' || territory === 'ES' || territory === 'NL' ? 'EUR' : territory === 'JP' ? 'JPY' : territory === 'BR' ? 'BRL' : 'USD',
              dsp: src.dsp,
              gross,
              fxToUsd: 1,
              units: Math.floor(gross * 1000),
            });
          }
        });
      });
    });
    return lines;
  }

  let _cachedLines = null;
  let _cachedSource = null; // 'real' | 'synthetic'

  function getAllLines() {
    if (_cachedLines) return _cachedLines;
    const real = fetchRealLines();
    if (real && real.length > 100) {
      _cachedLines = real;
      _cachedSource = 'real';
    } else {
      // Mix: real lines (if any) + synthetic for breadth
      const syn = synthLines();
      _cachedLines = real ? real.concat(syn) : syn;
      _cachedSource = real ? 'mixed' : 'synthetic';
    }
    return _cachedLines;
  }

  function dataSource() { getAllLines(); return _cachedSource; }
  function bustCache() { _cachedLines = null; _cachedSource = null; }

  // Refresh on inbox updates
  if (typeof window !== 'undefined') {
    window.addEventListener('astro-stmt-index-updated', bustCache);
  }

  // ─── FILTERING ───────────────────────────────────────────────
  function applyFilter(lines, filter) {
    if (!filter) return lines;
    return lines.filter(l => {
      for (const [dim, vals] of Object.entries(filter)) {
        if (!vals || (Array.isArray(vals) && !vals.length)) continue;
        const v = l[dim];
        if (Array.isArray(vals)) { if (!vals.includes(v)) return false; }
        else if (vals !== v) return false;
      }
      return true;
    });
  }

  function fetchLines(opts) {
    const filter = opts?.filter;
    const all = getAllLines();
    return applyFilter(all, filter);
  }

  // ─── AGGREGATIONS ────────────────────────────────────────────
  function groupBy(lines, dim, opts) {
    const limit = opts?.limit;
    const map = new Map();
    let total = 0;
    lines.forEach(l => {
      const k = l[dim] ?? '—';
      if (!map.has(k)) map.set(k, { key: k, value: 0, lineCount: 0 });
      const r = map.get(k);
      r.value += l.gross;
      r.lineCount += 1;
      total += l.gross;
    });
    let rows = Array.from(map.values()).sort((a, b) => b.value - a.value);
    rows.forEach(r => { r.share = total ? r.value / total : 0; });
    if (limit) rows = rows.slice(0, limit);
    return { rows, total };
  }

  function timeSeries(lines, opts) {
    const granularity = opts?.granularity || 'month'; // month | quarter | year
    const map = new Map();
    lines.forEach(l => {
      let k = l.period;
      if (granularity === 'quarter' && /^\d{4}-\d{2}$/.test(k)) {
        const [y, m] = k.split('-');
        const q = Math.ceil(+m / 3);
        k = `${y}Q${q}`;
      } else if (granularity === 'year' && /^\d{4}/.test(k)) {
        k = k.slice(0, 4);
      }
      if (!map.has(k)) map.set(k, { key: k, value: 0, lineCount: 0 });
      const r = map.get(k);
      r.value += l.gross;
      r.lineCount += 1;
    });
    const points = Array.from(map.values()).sort((a, b) => String(a.key).localeCompare(String(b.key)));
    // YoY/MoM overlay
    points.forEach((p, i) => {
      if (i > 0) p.delta = points[i - 1].value ? (p.value - points[i - 1].value) / points[i - 1].value : 0;
      // YoY
      const yi = granularity === 'month' ? i - 12 : granularity === 'quarter' ? i - 4 : i - 1;
      if (yi >= 0) p.yoy = points[yi].value ? (p.value - points[yi].value) / points[yi].value : 0;
    });
    return points;
  }

  function heatmap(lines, rowDim, colDim, opts) {
    const map = new Map();
    const rowSet = new Map(), colSet = new Map();
    lines.forEach(l => {
      const r = l[rowDim] ?? '—';
      const c = l[colDim] ?? '—';
      const k = r + '||' + c;
      if (!map.has(k)) map.set(k, { row: r, col: c, value: 0 });
      map.get(k).value += l.gross;
      rowSet.set(r, (rowSet.get(r) || 0) + l.gross);
      colSet.set(c, (colSet.get(c) || 0) + l.gross);
    });
    let rows = Array.from(rowSet.entries()).sort((a, b) => b[1] - a[1]);
    let cols = Array.from(colSet.entries()).sort((a, b) => String(a[0]).localeCompare(String(b[0])));
    if (opts?.rowLimit) rows = rows.slice(0, opts.rowLimit);
    if (opts?.colLimit) cols = cols.slice(-opts.colLimit);
    const rowKeys = rows.map(r => r[0]);
    const colKeys = cols.map(c => c[0]);
    const cells = rowKeys.flatMap(r => colKeys.map(c => ({
      row: r, col: c, value: (map.get(r + '||' + c)?.value) || 0,
    })));
    return { rowKeys, colKeys, cells };
  }

  function sankey(lines, dims) {
    const [d1, d2, d3] = dims; // e.g. ['source','work','writer']
    const nodes = new Map();
    const links = new Map();
    function addNode(name, layer) {
      const k = layer + '::' + name;
      if (!nodes.has(k)) nodes.set(k, { id: k, name, layer, value: 0 });
      return k;
    }
    function addLink(srcK, tgtK, value) {
      const k = srcK + '->' + tgtK;
      if (!links.has(k)) links.set(k, { source: srcK, target: tgtK, value: 0 });
      links.get(k).value += value;
    }
    lines.forEach(l => {
      const a = addNode(l[d1] ?? '—', 0);
      const b = addNode(l[d2] ?? '—', 1);
      addLink(a, b, l.gross);
      nodes.get(a).value += l.gross;
      nodes.get(b).value += l.gross;
      if (d3) {
        const c = addNode(l[d3] ?? '—', 2);
        addLink(b, c, l.gross);
        nodes.get(c).value += l.gross;
      }
    });
    // Limit each layer to top-N for legibility
    function limitLayer(layer, n) {
      const layerNodes = Array.from(nodes.values()).filter(n => n.layer === layer).sort((a, b) => b.value - a.value);
      const keep = new Set(layerNodes.slice(0, n).map(n => n.id));
      Array.from(nodes.keys()).forEach(k => { if (nodes.get(k).layer === layer && !keep.has(k)) nodes.delete(k); });
      Array.from(links.keys()).forEach(k => {
        const l = links.get(k);
        if (!nodes.has(l.source) || !nodes.has(l.target)) links.delete(k);
      });
    }
    limitLayer(0, 8); limitLayer(1, 12); limitLayer(2, 8);
    return { nodes: Array.from(nodes.values()), links: Array.from(links.values()) };
  }

  function pareto(lines, dim) {
    const { rows, total } = groupBy(lines, dim);
    let cum = 0;
    const out = rows.map(r => {
      cum += r.value;
      return { ...r, cum, cumPct: total ? cum / total : 0 };
    });
    return { rows: out, total };
  }

  function cohort(lines) {
    // Group by release (using `release` field) and compute earnings per
    // month-from-first-appearance.
    const releases = new Map();
    lines.forEach(l => {
      if (!releases.has(l.release)) releases.set(l.release, { firstPeriod: l.period, periods: new Map() });
      const r = releases.get(l.release);
      if (l.period < r.firstPeriod) r.firstPeriod = l.period;
      r.periods.set(l.period, (r.periods.get(l.period) || 0) + l.gross);
    });
    // Convert to age-bucket matrix
    function diffMonths(a, b) {
      const [ay, am] = String(a).split('-').map(Number);
      const [by, bm] = String(b).split('-').map(Number);
      if (!ay || !by || !am || !bm) return 0;
      return (ay - by) * 12 + (am - bm);
    }
    const cohorts = Array.from(releases.entries()).map(([release, r]) => {
      const cells = Array.from(r.periods.entries()).map(([period, value]) => ({
        ageMonths: diffMonths(period, r.firstPeriod), value,
      })).sort((a, b) => a.ageMonths - b.ageMonths);
      return { release, firstPeriod: r.firstPeriod, total: cells.reduce((a, c) => a + c.value, 0), cells };
    }).sort((a, b) => b.total - a.total);
    return cohorts;
  }

  function sparkGrid(lines, opts) {
    // Per-work (or per-dim) mini sparkline data
    const dim = opts?.dim || 'work';
    const map = new Map();
    lines.forEach(l => {
      const k = l[dim];
      if (!map.has(k)) map.set(k, { key: k, total: 0, byPeriod: new Map() });
      const r = map.get(k);
      r.total += l.gross;
      r.byPeriod.set(l.period, (r.byPeriod.get(l.period) || 0) + l.gross);
    });
    return Array.from(map.values())
      .map(r => ({
        key: r.key,
        total: r.total,
        points: Array.from(r.byPeriod.entries())
          .sort((a, b) => String(a[0]).localeCompare(String(b[0])))
          .map(([k, v]) => ({ period: k, value: v })),
      }))
      .sort((a, b) => b.total - a.total)
      .slice(0, opts?.limit || 24);
  }

  // ─── CROSS-FILTER STORE ──────────────────────────────────────
  const _xfilter = {};
  const _subs = new Set();
  function getFilter() { return { ..._xfilter }; }
  function setFilter(patch) {
    Object.entries(patch || {}).forEach(([k, v]) => {
      if (v == null || (Array.isArray(v) && !v.length)) delete _xfilter[k];
      else _xfilter[k] = v;
    });
    _subs.forEach(fn => { try { fn(getFilter()); } catch (e) {} });
  }
  function clearFilter() {
    Object.keys(_xfilter).forEach(k => delete _xfilter[k]);
    _subs.forEach(fn => { try { fn(getFilter()); } catch (e) {} });
  }
  function toggleFilter(dim, value) {
    const cur = _xfilter[dim];
    if (Array.isArray(cur)) {
      if (cur.includes(value)) {
        const next = cur.filter(v => v !== value);
        if (!next.length) delete _xfilter[dim]; else _xfilter[dim] = next;
      } else _xfilter[dim] = [...cur, value];
    } else if (cur === value) {
      delete _xfilter[dim];
    } else {
      _xfilter[dim] = [value];
    }
    _subs.forEach(fn => { try { fn(getFilter()); } catch (e) {} });
  }
  function subscribe(fn) { _subs.add(fn); return () => _subs.delete(fn); }

  // ─── PUBLIC ──────────────────────────────────────────────────
  function stackedBar(lines, seriesDim, opts) {
    const granularity = opts?.granularity || 'month';
    const periodSet = new Set();
    const seriesMap = new Map(); // key → Map<period, value>
    lines.forEach(l => {
      let p = l.period;
      if (granularity === 'quarter' && /^\d{4}-\d{2}$/.test(p)) {
        const [y, m] = p.split('-');
        p = `${y}Q${Math.ceil(+m / 3)}`;
      } else if (granularity === 'year' && /^\d{4}/.test(p)) p = p.slice(0, 4);
      periodSet.add(p);
      const sk = l[seriesDim] ?? '—';
      if (!seriesMap.has(sk)) seriesMap.set(sk, new Map());
      const sm = seriesMap.get(sk);
      sm.set(p, (sm.get(p) || 0) + l.gross);
    });
    const periods = Array.from(periodSet).sort();
    // Sort series by total desc; cap at top 8, lump rest into "Other"
    const seriesArr = Array.from(seriesMap.entries()).map(([k, m]) => {
      let total = 0; m.forEach(v => total += v);
      return { key: k, total, m };
    }).sort((a, b) => b.total - a.total);
    const top = seriesArr.slice(0, 8);
    const rest = seriesArr.slice(8);
    let series = top.map(s => ({
      key: s.key,
      values: periods.map(p => s.m.get(p) || 0),
    }));
    if (rest.length) {
      const otherVals = periods.map(p => rest.reduce((a, s) => a + (s.m.get(p) || 0), 0));
      series.push({ key: 'Other (' + rest.length + ')', values: otherVals });
    }
    return { periods, series };
  }

  function treemap(lines, dim, opts) {
    const { rows } = groupBy(lines, dim, opts?.limit || 40);
    return rows;
  }

  function choropleth(lines) {
    return groupBy(lines, 'territory').rows;
  }

  window.RVE = {
    DIM_LABELS,
    fetchLines,
    getAllLines,
    dataSource,
    bustCache,
    groupBy,
    timeSeries,
    stackedBar,
    treemap,
    choropleth,
    heatmap,
    sankey,
    pareto,
    cohort,
    sparkGrid,
    getFilter,
    setFilter,
    clearFilter,
    toggleFilter,
    subscribe,
  };
})();
