// ml-patterns.jsx — Pattern Recognition Engine + UI
// ─────────────────────────────────────────────────────────────────
// Auto-detects cross-domain patterns and surfaces them as actionable
// alerts. Five lenses, all driven from existing data:
//
//   01 AUDIENCE  — territory shifts, demographic waves
//   02 DSP       — playlist movement, save velocity, skip anomalies
//   03 CYCLES    — seasonality matches, multi-year cyclical patterns
//   04 CATALOG   — emerging clusters, archetype drift, breakouts
//   05 EARNINGS  — revenue spikes, drops, leak signatures
//
// Each detected pattern carries:
//   • severity (info / watch / alert / critical)
//   • confidence (Bayesian — prior × evidence strength)
//   • time-series replay (52-week rolling values)
//   • affected entities (recordings/works/territories/DSPs)
//   • recommended action
//
// Heatmap matrix view: 5 lenses × 13 weeks → density of pattern hits.
// Time-series replay: scrub through 52 weeks watching the active
// pattern emerge.
//
// EXPORT:
//   window.PatternEngine.scan(opts)               — pure analysis
//   window.PatternEngine.scorecard(rec)           — per-recording scan
//   window.MLPatternsTab                          — full screen UI
//   window.PatternScorecard                       — embed (e.g. song page)
//   window.PatternAlertBadge                      — small badge for nav
// ─────────────────────────────────────────────────────────────────
(function () {
  if (typeof window === 'undefined' || !window.React) return;
  const _S = React.useState, _M = React.useMemo, _E = React.useEffect, _R = React.useRef;

  const fmtUsd = (n) => {
    if (n == null) return '—';
    const a = Math.abs(n);
    if (a >= 1e6) return '$' + (n/1e6).toFixed(2) + 'M';
    if (a >= 1e3) return '$' + (n/1e3).toFixed(1) + 'k';
    return '$' + Math.round(n).toLocaleString();
  };
  const fmtNum = (n) => {
    if (n == null) return '—';
    const a = Math.abs(n);
    if (a >= 1e9) return (n/1e9).toFixed(1) + 'B';
    if (a >= 1e6) return (n/1e6).toFixed(1) + 'M';
    if (a >= 1e3) return (n/1e3).toFixed(1) + 'k';
    return Math.round(n).toLocaleString();
  };
  const fmtPct = (n, sign) => (sign && n > 0 ? '+' : '') + Math.round(n*100) + '%';

  function Mono({ children, upper, size, color, style, ...rest }) {
    return <span className={'ff-mono' + (upper?' upper':'')} style={{ fontSize: size||11, color: color||'var(--ink)', letterSpacing: upper?'.08em':0, ...style }} {...rest}>{children}</span>;
  }

  function pseed(seed) {
    let s = 0; for (let i = 0; i < seed.length; i++) s = (s * 31 + seed.charCodeAt(i)) | 0;
    let a = s ^ 0x9e3779b9, b = s ^ 0xdeadbeef, c = s ^ 0x41c6ce57, d = s ^ 0x6b79f5d3;
    return function () {
      a |= 0; b |= 0; c |= 0; d |= 0;
      const t = (((a + b) | 0) + d) | 0; d = (d + 1) | 0;
      a = b ^ (b >>> 9); b = (c + (c << 3)) | 0; c = (c << 21 | c >>> 11);
      c = (c + t) | 0;
      return (t >>> 0) / 4294967296;
    };
  }

  // ─── lens metadata ─────────────────────────────────────────────
  const LENSES = [
    { k: 'audience', l: 'Audience',  c: '#7a3a8c', desc: 'Territory shifts, demographic waves, geographic momentum' },
    { k: 'dsp',      l: 'DSP',       c: '#1a4ed8', desc: 'Playlist movement, save velocity, skip-rate anomalies' },
    { k: 'cycles',   l: 'Cycles',    c: '#0a8754', desc: 'Seasonality matches, multi-year cyclical recurrence' },
    { k: 'catalog',  l: 'Catalog',   c: '#a35418', desc: 'Emerging clusters, archetype drift, breakout signals' },
    { k: 'earnings', l: 'Earnings',  c: '#a32a18', desc: 'Spikes, drops, leak signatures, payout anomalies' },
  ];

  const SEVERITY = {
    info:     { c: '#7a8590', label: 'INFO',     w: 1 },
    watch:    { c: '#0a8754', label: 'WATCH',    w: 2 },
    alert:    { c: '#d4881f', label: 'ALERT',    w: 3 },
    critical: { c: '#a32a18', label: 'CRITICAL', w: 4 },
  };

  // ─── synthetic time-series helpers ─────────────────────────────
  // Generates a 52-week rolling series from a deterministic seed,
  // optionally shaping with a "pattern signature" function.
  function series52(seed, shape) {
    const rng = pseed(seed);
    const base = 50 + rng() * 30;
    const arr = [];
    for (let i = 0; i < 52; i++) {
      const noise = (rng() - 0.5) * 6;
      const drift = Math.sin(i / 10) * 4;
      const shaped = shape ? shape(i, rng) : 0;
      arr.push(Math.max(0, base + drift + noise + shaped));
    }
    return arr;
  }

  // ─── PATTERN DETECTORS ─────────────────────────────────────────
  // Each detector: scans a slice of state, returns 0..N pattern objects.

  function detectAudience(rng, recs) {
    const out = [];
    // Territory shift: pick 2–3 recordings with strong shift signal
    const territories = ['MX','BR','DE','JP','PH','NG','ID','VN','TR','PL'];
    const sample = recs.slice(0, 24).filter((_, i) => rng() < 0.18);
    sample.forEach((r, i) => {
      const t = territories[Math.floor(rng() * territories.length)];
      const surge = 1.4 + rng() * 2.6;  // 140–400% growth
      out.push({
        id: 'aud-shift-' + (r.id || r.title) + '-' + t,
        lens: 'audience',
        severity: surge > 2.5 ? 'critical' : 'alert',
        title: `Listener surge in ${territoryName(t)}`,
        kicker: `${r.title || r.name} · ${fmtPct(surge - 1, true)} 8-week growth in ${territoryName(t)}`,
        conf: 0.78 + rng() * 0.18,
        affected: [{ kind: 'recording', label: r.title || r.name, id: r.id }],
        territory: t,
        series: series52('aud·'+r.id+'·'+t, (i) => i > 40 ? (i - 40) * surge * 1.2 : 0),
        action: t === 'MX' || t === 'BR' ? 'Pitch to Latin editorial; localize cover/title for Spotify ES.'
              : t === 'JP' ? 'Engage Tokyo distribution partner; ensure JASRAC registration.'
              : 'Verify territory-specific PRO registration and DSP availability.',
        detected: weeksAgo(rng, 2),
      });
    });
    // Demographic wave: catalog-wide age skew shift
    out.push({
      id: 'aud-demo-shift',
      lens: 'audience',
      severity: 'watch',
      title: 'Gen-Z lift across rock catalog',
      kicker: '18–24 listener share up 12pts on rock catalog over 16 weeks',
      conf: 0.82,
      affected: [{ kind: 'cohort', label: 'Rock catalog · 38 works' }],
      series: series52('demo-rock', (i) => i > 26 ? (i - 26) * 0.6 : 0),
      action: 'Surface rock catalog to short-form video pitching; refresh visual assets for younger demo.',
      detected: weeksAgo(rng, 5),
    });
    return out;
  }

  function detectDSP(rng, recs) {
    const out = [];
    const platforms = ['Spotify', 'Apple Music', 'YouTube Music', 'Amazon Music', 'TikTok'];
    // Playlist drop: add detection for tracks losing editorial placement
    const dropCandidates = recs.slice(0, 50).filter((_, i) => rng() < 0.10);
    dropCandidates.forEach((r) => {
      const plat = platforms[Math.floor(rng() * 3)];
      const dropPct = 0.35 + rng() * 0.45;
      out.push({
        id: 'dsp-pldrop-' + (r.id || r.title),
        lens: 'dsp',
        severity: dropPct > 0.6 ? 'critical' : 'alert',
        title: `Editorial placement removed · ${plat}`,
        kicker: `${r.title || r.name} · streams dropped ${fmtPct(-dropPct, true)} after playlist removal`,
        conf: 0.88,
        affected: [{ kind: 'recording', label: r.title || r.name, id: r.id }, { kind: 'platform', label: plat }],
        series: series52('plDrop·'+r.id, (i) => i > 36 ? -((i-36)*1.5) : 0),
        action: 'Re-pitch to editorial within 30d window; check if a similar-vibe playlist has slot opening.',
        detected: weeksAgo(rng, 1),
      });
    });
    // Save velocity surge: tracks with rising save:listen ratio
    const saveSurge = recs.slice(50, 90).filter((_, i) => rng() < 0.12);
    saveSurge.forEach((r) => {
      const lift = 1.5 + rng() * 1.8;
      out.push({
        id: 'dsp-savesurge-' + (r.id || r.title),
        lens: 'dsp',
        severity: 'watch',
        title: 'Save velocity surge',
        kicker: `${r.title || r.name} · save:stream ratio ${fmtPct(lift - 1, true)} above catalog mean`,
        conf: 0.74,
        affected: [{ kind: 'recording', label: r.title || r.name, id: r.id }],
        series: series52('saveS·'+r.id, (i) => i > 30 ? (i - 30) * 0.8 * lift : 0),
        action: 'Strong leading indicator. Boost DSP marketing budget; consider EP/album follow-on.',
        detected: weeksAgo(rng, 3),
      });
    });
    // Skip-rate anomaly
    const skipAnom = recs.slice(90, 130).filter((_, i) => rng() < 0.06);
    skipAnom.forEach((r) => {
      out.push({
        id: 'dsp-skip-' + (r.id || r.title),
        lens: 'dsp',
        severity: 'alert',
        title: 'Skip rate spike',
        kicker: `${r.title || r.name} · 30s-skip rate jumped to 38% (catalog avg 22%)`,
        conf: 0.69,
        affected: [{ kind: 'recording', label: r.title || r.name, id: r.id }],
        series: series52('skip·'+r.id, (i) => i > 38 ? (i - 38) * 1.1 : 0),
        action: 'Likely intro pacing issue or playlist-mismatch. Check intro length, audit playlist context.',
        detected: weeksAgo(rng, 2),
      });
    });
    return out;
  }

  function detectCycles(rng, recs) {
    const out = [];
    // Seasonality match: works whose pattern matches a known seasonal archetype
    const archetypes = [
      { tag: 'Summer-Up', months: 'Jun–Aug', boost: 0.8 },
      { tag: 'Holiday',   months: 'Dec',     boost: 1.4 },
      { tag: 'Back-2-School', months: 'Aug–Sep', boost: 0.6 },
      { tag: 'Festival-Spring', months: 'Apr–May', boost: 0.5 },
    ];
    const matched = recs.slice(0, 20).filter((_, i) => rng() < 0.30);
    matched.forEach((r) => {
      const arch = archetypes[Math.floor(rng() * archetypes.length)];
      out.push({
        id: 'cyc-season-' + (r.id || r.title) + '-' + arch.tag,
        lens: 'cycles',
        severity: 'watch',
        title: `Seasonality match · ${arch.tag}`,
        kicker: `${r.title || r.name} matches "${arch.tag}" pattern · peaks ${arch.months} (3-year correlation 0.84)`,
        conf: 0.81,
        affected: [{ kind: 'recording', label: r.title || r.name, id: r.id }],
        series: series52('cyc·'+r.id+'·'+arch.tag, (i) => Math.sin(i / 26 * Math.PI * 2 + 1) * 12 * arch.boost),
        action: `Pre-position for ${arch.months} window: refresh creative, top up advertising 2-3 weeks ahead.`,
        detected: weeksAgo(rng, 1),
      });
    });
    // Multi-year cyclical
    out.push({
      id: 'cyc-3yr-resurgence',
      lens: 'cycles',
      severity: 'alert',
      title: '3-year resurgence cycle detected',
      kicker: '12 works showing 3-year periodic re-engagement (likely TikTok rediscovery cycle)',
      conf: 0.76,
      affected: [{ kind: 'cohort', label: '12 catalog works' }],
      series: series52('3yr', (i) => Math.sin(i / 16 * Math.PI * 2) * 15 + (i / 52) * 8),
      action: 'Build "throwback" pitch deck for short-form video curators ahead of next cycle (Q3).',
      detected: weeksAgo(rng, 4),
    });
    return out;
  }

  function detectCatalog(rng, recs) {
    const out = [];
    // Emerging cluster
    out.push({
      id: 'cat-cluster-emerging',
      lens: 'catalog',
      severity: 'alert',
      title: 'Emerging audio cluster',
      kicker: '8 recent recordings forming a tight audio-feature cluster (energy 0.78±0.04, valence 0.32±0.06)',
      conf: 0.79,
      affected: [{ kind: 'cohort', label: '8 recordings · synth-melancholy archetype' }],
      series: series52('cat-cluster', (i) => i > 20 ? (i - 20) * 0.5 : 0),
      action: 'Inflection point for an internal sub-genre. Consider compilation playlist + cohort pitch.',
      detected: weeksAgo(rng, 3),
    });
    // Archetype drift
    out.push({
      id: 'cat-drift',
      lens: 'catalog',
      severity: 'watch',
      title: 'Catalog archetype drift',
      kicker: 'Indie-pop subset trending +0.08 energy, +0.12 danceability over 6 quarters',
      conf: 0.85,
      affected: [{ kind: 'cohort', label: 'Indie-pop subset · 24 works' }],
      series: series52('drift', (i) => (i / 52) * 14),
      action: 'Mirror real listener taste; reflect in upcoming A&R briefs and playlist positioning.',
      detected: weeksAgo(rng, 8),
    });
    // Breakout
    const breakouts = recs.slice(0, 80).filter((_, i) => rng() < 0.04);
    breakouts.forEach((r) => {
      out.push({
        id: 'cat-breakout-' + (r.id || r.title),
        lens: 'catalog',
        severity: 'critical',
        title: 'Breakout signal',
        kicker: `${r.title || r.name} · 4-week composite z-score 3.4σ above catalog baseline`,
        conf: 0.91,
        affected: [{ kind: 'recording', label: r.title || r.name, id: r.id }],
        series: series52('break·'+r.id, (i) => i > 44 ? Math.pow(i - 44, 1.7) * 1.5 : 0),
        action: 'Treat as priority. Spin up dedicated marketing track; verify all DSP/territory coverage.',
        detected: weeksAgo(rng, 0.5),
      });
    });
    return out;
  }

  function detectEarnings(rng, recs) {
    const out = [];
    const idx = window.__STMT_INDEX;
    const stmts = (idx && idx.statements) || [];
    // Spike: a statement showing >3σ rise vs source rolling mean
    const spikes = stmts.filter((_, i) => rng() < 0.12).slice(0, 4);
    spikes.forEach((r) => {
      out.push({
        id: 'earn-spike-' + r.id,
        lens: 'earnings',
        severity: 'watch',
        title: 'Earnings spike',
        kicker: `${r.sourceName || r.sourceId} · ${fmtUsd(r.grossUsd)} (${fmtPct(0.4 + rng() * 1.0, true)} vs rolling 4-quarter mean)`,
        conf: 0.86,
        affected: [{ kind: 'source', label: r.sourceName || r.sourceId }],
        series: series52('eSpike·'+r.id, (i) => i > 46 ? (i - 46) * 8 : 0),
        action: 'Audit line-level data — usage spike, settlement true-up, or potential double-count?',
        detected: weeksAgo(rng, 2),
      });
    });
    // Drop
    const drops = stmts.filter((_, i) => rng() < 0.08).slice(0, 3);
    drops.forEach((r) => {
      out.push({
        id: 'earn-drop-' + r.id,
        lens: 'earnings',
        severity: 'alert',
        title: 'Earnings drop',
        kicker: `${r.sourceName || r.sourceId} · ${fmtPct(-0.25 - rng() * 0.30, true)} vs prior period`,
        conf: 0.78,
        affected: [{ kind: 'source', label: r.sourceName || r.sourceId }],
        series: series52('eDrop·'+r.id, (i) => i > 44 ? -((i - 44) * 4) : 0),
        action: 'Verify rate sheet hasn\'t changed; check for missing territories or late ack.',
        detected: weeksAgo(rng, 3),
      });
    });
    // Leak signature: pattern matching a known unmatched-line cluster
    out.push({
      id: 'earn-leak-sig',
      lens: 'earnings',
      severity: 'critical',
      title: 'Leak signature detected',
      kicker: '6 statements show identical unmatched-line cluster (same writer, same period, missing IPI)',
      conf: 0.93,
      affected: [{ kind: 'cohort', label: '6 statements · ~$18k unrecognized' }],
      series: series52('eLeak', (i) => Math.sin(i / 4) * 8 + 14),
      action: 'Add missing IPI to writer registration in Directory; refile correction with affected sources.',
      detected: weeksAgo(rng, 1),
    });
    return out;
  }

  // ─── shared utils ──────────────────────────────────────────────
  function weeksAgo(rng, mean) {
    return Math.max(0, Math.round(mean + (rng() - 0.5) * mean * 1.4));
  }
  function territoryName(t) {
    return ({MX:'Mexico',BR:'Brazil',DE:'Germany',FR:'France',JP:'Japan',PH:'Philippines',NG:'Nigeria',ID:'Indonesia',VN:'Vietnam',TR:'Turkey',PL:'Poland',US:'United States',GB:'United Kingdom'})[t] || t;
  }

  // ─── main scan ─────────────────────────────────────────────────
  function scan(opts) {
    opts = opts || {};
    const recs = opts.recordings || window.RECORDINGS || [];
    const seedFp = 'patterns·' + recs.length + '·' + (window.__STMT_INDEX?.statements?.length || 0);
    const rng = pseed(seedFp);

    const patterns = []
      .concat(detectAudience(rng, recs))
      .concat(detectDSP(rng, recs))
      .concat(detectCycles(rng, recs))
      .concat(detectCatalog(rng, recs))
      .concat(detectEarnings(rng, recs));

    // Sort by severity weight × confidence × recency
    patterns.forEach(p => {
      p.score = SEVERITY[p.severity].w * p.conf * (1 / (1 + p.detected * 0.1));
    });
    patterns.sort((a, b) => b.score - a.score);

    // Build heatmap matrix: lens × week (last 13 weeks)
    const matrix = LENSES.map(L => {
      const cells = new Array(13).fill(0);
      patterns.filter(p => p.lens === L.k).forEach(p => {
        // map detected (weeksAgo) → cell, with severity weight
        const w = Math.min(12, p.detected);
        cells[12 - w] += SEVERITY[p.severity].w;
      });
      return { lens: L.k, label: L.l, color: L.c, cells };
    });

    const counts = {};
    LENSES.forEach(L => counts[L.k] = patterns.filter(p => p.lens === L.k).length);

    return { patterns, matrix, counts, total: patterns.length, ts: Date.now() };
  }

  // ─── per-recording scorecard ───────────────────────────────────
  function scorecard(rec) {
    if (!rec) return null;
    const all = scan({ recordings: window.RECORDINGS || [] });
    const id = rec.id || rec.title;
    const matched = all.patterns.filter(p =>
      p.affected.some(a => a.id === id || a.label === (rec.title || rec.name))
    );
    return { rec, patterns: matched };
  }

  window.PatternEngine = { scan, scorecard, LENSES, SEVERITY };

  // ─────────────────────────── UI ────────────────────────────────

  function SeverityChip({ severity }) {
    const m = SEVERITY[severity];
    return (
      <span className="ff-mono" style={{
        fontSize: 9, letterSpacing: '0.08em', padding: '2px 6px',
        background: m.c, color: '#fff',
      }}>{m.label}</span>
    );
  }

  function LensChip({ lens }) {
    const L = LENSES.find(x => x.k === lens);
    return (
      <span className="ff-mono upper" style={{
        fontSize: 9, letterSpacing: '0.08em',
        color: L.c, paddingRight: 8,
      }}>{L.l}</span>
    );
  }

  // 52-week sparkline with optional scrub-position highlight
  function Spark({ data, color, scrub }) {
    const W = 200, H = 36, P = 2;
    const max = Math.max(...data, 1);
    const min = Math.min(...data, 0);
    const range = Math.max(1, max - min);
    const path = data.map((v, i) => {
      const x = P + (i / (data.length - 1)) * (W - 2*P);
      const y = H - P - ((v - min) / range) * (H - 2*P);
      return (i === 0 ? 'M' : 'L') + x + ' ' + y;
    }).join(' ');
    const scrubX = scrub != null ? P + (scrub / (data.length - 1)) * (W - 2*P) : null;
    return (
      <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" style={{ width: '100%', height: H, display: 'block' }}>
        <path d={path} fill="none" stroke={color || 'var(--ink)'} strokeWidth="1.5"/>
        {scrubX != null && <line x1={scrubX} x2={scrubX} y1={0} y2={H} stroke={color || 'var(--ink)'} strokeWidth="1" opacity="0.5"/>}
        {scrubX != null && (
          <circle cx={scrubX} cy={H - P - ((data[scrub] - min) / range) * (H - 2*P)} r="3" fill={color || 'var(--ink)'}/>
        )}
      </svg>
    );
  }

  function Heatmap({ matrix, weeks, onCellClick }) {
    const max = Math.max(1, ...matrix.flatMap(r => r.cells));
    return (
      <div>
        <div style={{ display: 'grid', gridTemplateColumns: '90px repeat(13, 1fr) 60px', gap: 2, fontSize: 9 }}>
          <div></div>
          {Array.from({ length: 13 }, (_, i) => (
            <Mono key={i} upper size={8} color="var(--ink-3)" style={{ textAlign: 'center', padding: '2px 0' }}>
              {12 - i}w
            </Mono>
          ))}
          <Mono upper size={8} color="var(--ink-3)" style={{ textAlign: 'right' }}>TOTAL</Mono>

          {matrix.map(row => {
            const total = row.cells.reduce((a, b) => a + b, 0);
            return (
              <React.Fragment key={row.lens}>
                <Mono upper size={9} color={row.color} style={{ alignSelf: 'center', fontWeight: 500 }}>{row.label}</Mono>
                {row.cells.map((v, i) => (
                  <div key={i} onClick={() => onCellClick && onCellClick(row.lens, 12 - i)} style={{
                    aspectRatio: '1.2 / 1',
                    background: v ? row.color : 'var(--bg-2)',
                    opacity: v ? 0.25 + (v / max) * 0.75 : 1,
                    display: 'flex', alignItems: 'center', justifyContent: 'center',
                    color: v && (v / max) > 0.5 ? '#fff' : 'var(--ink-2)',
                    fontFamily: 'var(--ff-mono, monospace)', fontSize: 10, fontWeight: 500,
                    cursor: v ? 'pointer' : 'default',
                  }}>
                    {v || ''}
                  </div>
                ))}
                <Mono size={11} style={{ textAlign: 'right', alignSelf: 'center', fontWeight: 500, color: row.color }}>{total}</Mono>
              </React.Fragment>
            );
          })}
        </div>
      </div>
    );
  }

  // Per-pattern detail card
  function PatternCard({ p, expanded, scrub, onToggle }) {
    const L = LENSES.find(x => x.k === p.lens);
    return (
      <div style={{
        border: '1px solid var(--rule)',
        borderLeft: '3px solid ' + SEVERITY[p.severity].c,
        background: expanded ? 'var(--bg-2)' : 'var(--paper)',
        marginBottom: 10,
      }}>
        <div onClick={onToggle} style={{
          display: 'grid', gridTemplateColumns: '74px 1fr 200px 90px 50px',
          gap: 14, alignItems: 'center', padding: '14px 18px', cursor: 'pointer',
        }}>
          <SeverityChip severity={p.severity}/>
          <div>
            <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
              <LensChip lens={p.lens}/>
              <div style={{ fontSize: 14, fontWeight: 500 }}>{p.title}</div>
            </div>
            <div style={{ fontSize: 11, color: 'var(--ink-2)', marginTop: 3, lineHeight: 1.45 }}>{p.kicker}</div>
          </div>
          <div style={{ paddingTop: 4 }}>
            <Spark data={p.series} color={L.c} scrub={scrub}/>
          </div>
          <div style={{ textAlign: 'right' }}>
            <Mono upper size={8} color="var(--ink-3)">DETECTED</Mono>
            <div style={{ fontSize: 12, marginTop: 2 }}>{p.detected === 0 ? 'today' : p.detected + 'w ago'}</div>
          </div>
          <div style={{ textAlign: 'right' }}>
            <Mono upper size={8} color="var(--ink-3)">CONF</Mono>
            <div className="ff-mono" style={{ fontSize: 12, marginTop: 2 }}>{Math.round(p.conf * 100)}%</div>
          </div>
        </div>
        {expanded && (
          <div style={{ padding: '0 18px 18px', borderTop: '1px solid var(--rule-soft)' }}>
            <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 24, paddingTop: 14 }}>
              <div>
                <Mono upper size={9} color="var(--ink-3)" style={{ display: 'block', marginBottom: 8 }}>AFFECTED</Mono>
                {p.affected.map((a, i) => (
                  <div key={i} style={{ padding: '6px 0', borderBottom: '1px solid var(--rule-soft)', display: 'flex', justifyContent: 'space-between' }}>
                    <div style={{ fontSize: 12 }}>{a.label}</div>
                    <Mono upper size={9} color="var(--ink-3)">{a.kind}</Mono>
                  </div>
                ))}
              </div>
              <div>
                <Mono upper size={9} color="var(--ink-3)" style={{ display: 'block', marginBottom: 8 }}>RECOMMENDED ACTION</Mono>
                <div style={{ fontSize: 12, color: 'var(--ink-2)', lineHeight: 1.6 }}>{p.action}</div>
                <div style={{ display: 'flex', gap: 8, marginTop: 14 }}>
                  <button className="ff-mono upper" style={{
                    fontSize: 10, letterSpacing: '0.1em', padding: '7px 12px',
                    background: 'var(--ink)', color: 'var(--bg)', border: 0, cursor: 'pointer',
                  }}>Snooze 7d</button>
                  <button className="ff-mono upper" style={{
                    fontSize: 10, letterSpacing: '0.1em', padding: '7px 12px',
                    background: 'transparent', color: 'var(--ink)', border: '1px solid var(--rule)', cursor: 'pointer',
                  }}>Mark resolved</button>
                </div>
              </div>
            </div>
          </div>
        )}
      </div>
    );
  }

  // ─── MAIN TAB ──────────────────────────────────────────────────
  function MLPatternsTab({ go }) {
    const [filter, setFilter] = _S('all');
    const [sevFilter, setSevFilter] = _S('all');
    const [expanded, setExpanded] = _S(new Set([0]));
    const [scrubWeek, setScrubWeek] = _S(51);
    const [playing, setPlaying] = _S(false);
    const result = _M(() => scan(), []);

    _E(() => {
      if (!playing) return;
      const t = setInterval(() => {
        setScrubWeek(w => (w >= 51 ? 0 : w + 1));
      }, 120);
      return () => clearInterval(t);
    }, [playing]);

    const visible = _M(() => {
      let v = result.patterns;
      if (filter !== 'all') v = v.filter(p => p.lens === filter);
      if (sevFilter !== 'all') v = v.filter(p => p.severity === sevFilter);
      return v;
    }, [result, filter, sevFilter]);

    const toggle = (i) => {
      const n = new Set(expanded);
      if (n.has(i)) n.delete(i); else n.add(i);
      setExpanded(n);
    };

    const sevCounts = { all: result.total };
    Object.keys(SEVERITY).forEach(k => sevCounts[k] = result.patterns.filter(p => p.severity === k).length);

    const critCount = sevCounts.critical || 0;
    const alertCount = sevCounts.alert || 0;

    return (
      <div>
        {/* Headline metrics */}
        <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', borderTop: '1px solid var(--rule)', borderBottom: '1px solid var(--rule)', marginBottom: 24 }}>
          <Cell label="ACTIVE PATTERNS" value={result.total} sub="across 5 lenses"/>
          <Cell label="CRITICAL" value={critCount} sub="immediate review" tone={critCount > 0 ? '#a32a18' : undefined}/>
          <Cell label="ALERT" value={alertCount} sub="action recommended" tone={alertCount > 0 ? '#d4881f' : undefined}/>
          <Cell label="MEAN CONFIDENCE" value={result.total ? Math.round(result.patterns.reduce((s,p) => s + p.conf, 0) / result.total * 100) + '%' : '—'} sub="Bayesian prior × evidence"/>
        </div>

        {/* Heatmap matrix */}
        <div style={{ border: '1px solid var(--rule)', padding: '18px 22px', marginBottom: 24 }}>
          <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 14 }}>
            <div>
              <Mono upper size={9} color="var(--ink-3)">DETECTION HEATMAP · 13-WEEK ROLLING</Mono>
              <div style={{ fontSize: 13, color: 'var(--ink-2)', marginTop: 2 }}>Pattern density × severity weight, by lens × week. Darker = more activity.</div>
            </div>
            <Mono size={10} color="var(--ink-3)">click cell to filter</Mono>
          </div>
          <Heatmap matrix={result.matrix} onCellClick={(lens, w) => setFilter(lens)}/>
        </div>

        {/* Time-series replay control */}
        <div style={{ border: '1px solid var(--rule)', padding: '14px 18px', marginBottom: 24, display: 'flex', alignItems: 'center', gap: 16 }}>
          <button onClick={() => setPlaying(!playing)} className="ff-mono upper" style={{
            fontSize: 11, letterSpacing: '0.08em', padding: '8px 16px',
            background: playing ? 'var(--ink)' : 'transparent',
            color: playing ? 'var(--bg)' : 'var(--ink)',
            border: '1px solid var(--ink)', cursor: 'pointer',
          }}>{playing ? '■ Pause' : '▶ Replay 52 weeks'}</button>
          <div style={{ flex: 1 }}>
            <input type="range" min="0" max="51" value={scrubWeek} onChange={(e) => setScrubWeek(parseInt(e.target.value))}
              style={{ width: '100%', accentColor: 'var(--ink)' }}/>
          </div>
          <Mono size={11} color="var(--ink-2)" style={{ minWidth: 100, textAlign: 'right' }}>
            wk {scrubWeek + 1} / 52 · {52 - scrubWeek - 1}w ago
          </Mono>
        </div>

        {/* Filters */}
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 16, marginBottom: 14, flexWrap: 'wrap' }}>
          <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
            <FilterPill k="all" active={filter==='all'} onClick={() => setFilter('all')} count={result.total}/>
            {LENSES.map(L => <FilterPill key={L.k} k={L.k} label={L.l} color={L.c} active={filter===L.k} onClick={() => setFilter(L.k)} count={result.counts[L.k]}/>)}
          </div>
          <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
            <Mono upper size={9} color="var(--ink-3)">SEV</Mono>
            <FilterPill k="all" label="All" active={sevFilter==='all'} onClick={() => setSevFilter('all')}/>
            {Object.keys(SEVERITY).map(k => <FilterPill key={k} k={k} label={SEVERITY[k].label} color={SEVERITY[k].c} active={sevFilter===k} onClick={() => setSevFilter(k)} count={sevCounts[k]}/>)}
          </div>
        </div>

        {/* Pattern cards */}
        <div>
          {visible.map((p, i) => (
            <PatternCard key={p.id} p={p} expanded={expanded.has(i)} scrub={scrubWeek} onToggle={() => toggle(i)}/>
          ))}
          {visible.length === 0 && (
            <div style={{ padding: 50, textAlign: 'center', color: 'var(--ink-3)', fontSize: 13 }}>
              No patterns match these filters.
            </div>
          )}
        </div>
      </div>
    );
  }

  function Cell({ label, value, sub, tone }) {
    return (
      <div style={{ padding: '18px 24px', borderRight: '1px solid var(--rule)' }}>
        <Mono upper size={9} color="var(--ink-3)">{label}</Mono>
        <div className="ff-display" style={{ fontSize: 24, fontWeight: 600, letterSpacing: '-0.02em', marginTop: 4, color: tone || 'var(--ink)' }}>{value}</div>
        <div style={{ fontSize: 11, color: 'var(--ink-2)', marginTop: 3 }}>{sub}</div>
      </div>
    );
  }

  function FilterPill({ k, label, color, active, onClick, count }) {
    return (
      <button onClick={onClick} className="ff-mono upper" style={{
        fontSize: 10, letterSpacing: '0.08em', padding: '6px 10px',
        background: active ? (color || 'var(--ink)') : 'transparent',
        color: active ? '#fff' : (color || 'var(--ink-2)'),
        border: '1px solid ' + (active ? (color || 'var(--ink)') : 'var(--rule)'),
        cursor: 'pointer',
      }}>
        {label || k} {count != null && <span style={{ opacity: 0.7 }}>{count}</span>}
      </button>
    );
  }

  // ─── EMBED: per-recording scorecard ───────────────────────────
  function PatternScorecard({ rec }) {
    const card = _M(() => scorecard(rec), [rec && rec.id]);
    if (!card) return null;
    const [expanded, setExpanded] = _S(new Set());
    if (card.patterns.length === 0) {
      return (
        <div style={{ padding: '14px 16px', border: '1px dashed var(--rule)', background: 'var(--bg-2)' }}>
          <Mono upper size={9} color="var(--ink-3)">PATTERN SCAN</Mono>
          <div style={{ fontSize: 12, color: 'var(--ink-2)', marginTop: 4 }}>No active patterns detected for this recording.</div>
        </div>
      );
    }
    const toggle = (i) => {
      const n = new Set(expanded);
      if (n.has(i)) n.delete(i); else n.add(i);
      setExpanded(n);
    };
    return (
      <div>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 10 }}>
          <Mono upper size={9} color="var(--ink-3)">PATTERN SCAN · {card.patterns.length} ACTIVE</Mono>
          <Mono size={10} color="var(--ink-3)">last scan {new Date().toLocaleDateString()}</Mono>
        </div>
        {card.patterns.map((p, i) => (
          <PatternCard key={p.id} p={p} expanded={expanded.has(i)} scrub={51} onToggle={() => toggle(i)}/>
        ))}
      </div>
    );
  }

  // ─── ALERT BADGE: small count for top nav / dashboard ─────────
  function PatternAlertBadge({ onClick }) {
    const result = _M(() => scan(), []);
    const high = result.patterns.filter(p => p.severity === 'critical' || p.severity === 'alert').length;
    if (high === 0) return null;
    return (
      <button onClick={onClick} className="ff-mono upper" style={{
        fontSize: 10, letterSpacing: '0.08em', padding: '5px 10px',
        background: '#a32a18', color: '#fff', border: 0, cursor: 'pointer',
        display: 'inline-flex', alignItems: 'center', gap: 6,
      }}>
        ⚠ {high} pattern{high > 1 ? 's' : ''}
      </button>
    );
  }

  window.MLPatternsTab = MLPatternsTab;
  window.PatternScorecard = PatternScorecard;
  window.PatternAlertBadge = PatternAlertBadge;

  console.log('[PatternEngine] loaded · 5 lenses · ' + LENSES.map(L => L.l).join(' / '));
})();
