// predict-engine.jsx — Pure scoring math for ASTRO predictions
// ─────────────────────────────────────────────────────────────────
// Five algorithm surfaces, all driven from the same feature vector:
//
//   01 SALES        — projected territory/DSP/window growth (linear regression on velocity)
//   02 PLAYLIST     — likelihood-of-placement per editorial playlist (cosine match)
//   03 SUCCESS      — overall hit-potential score (weighted ensemble)
//   04 MINING       — catalog rank by upside (high score × low recent streams)
//   05 SYNC         — cinematic fit per sync category (rule-based + cosine)
//
// Math is real where it's tractable (linear projection, cosine, weighted means);
// "deep learning" outputs are mocked deterministically from the feature vector
// so they're stable across reloads and scrubable to a seed.
//
// EXPORT: window.PredictEngine = { ... }
// ─────────────────────────────────────────────────────────────────
(function () {
  if (typeof window === 'undefined') return;

  // ─── Deterministic RNG (sfc32) ────────────────────────────────
  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;
    };
  }

  // ─── Feature vector for any recording ─────────────────────────
  // Shape: { audio:{bpm,key,energy,valence,danceability,acousticness,instrumentalness},
  //          velocity:{d30,d90,d365,growth30,growth90}, catalog:{age,genre,writerScore},
  //          external:{tiktok,shazam,radio,press}, peer:[{recId,sim,success}] }
  function getFeatures(rec) {
    if (!rec) return null;
    const id = rec.id || rec.title || 'unk';
    const rng = pseed('feat·' + id);

    // Audio: prefer real audio-intel data if cached, else synth.
    let audio = null;
    try {
      if (window.RecAudioIntelligence && window.__REC_AUDIO_CACHE && window.__REC_AUDIO_CACHE[id]) {
        audio = window.__REC_AUDIO_CACHE[id];
      }
    } catch {}
    if (!audio) {
      audio = {
        bpm: Math.round(85 + rng() * 70),
        key: ['C','C♯','D','D♯','E','F','F♯','G','G♯','A','A♯','B'][Math.floor(rng()*12)],
        mode: rng() < 0.55 ? 'Major' : 'Minor',
        energy: 0.45 + rng() * 0.5,
        valence: 0.2 + rng() * 0.7,
        danceability: 0.4 + rng() * 0.55,
        acousticness: rng() * 0.6,
        instrumentalness: rng() * 0.35,
      };
    }

    // Velocity: prefer real streaming history, else synth using existing patterns.
    const baseStreams = (rec.plays || rec.streams || 100000 + Math.floor(rng() * 4_000_000));
    const d30  = Math.round(baseStreams * (0.04 + rng() * 0.06));
    const d90  = Math.round(baseStreams * (0.14 + rng() * 0.10));
    const d365 = Math.round(baseStreams * (0.55 + rng() * 0.30));
    const growth30 = -0.30 + rng() * 0.85;   // -30% .. +55%
    const growth90 = -0.20 + rng() * 0.55;
    const velocity = { d30, d90, d365, growth30, growth90, total: baseStreams };

    // Catalog metadata
    const releaseYear = parseInt((rec.releaseDate || rec.year || '2022').toString().slice(0,4), 10);
    const age = Math.max(0, 2026 - releaseYear);
    const genrePool = ['Indie Pop','Hip-Hop','R&B','Electronic','Folk','Country','Rock','Latin','K-Pop','Lo-Fi'];
    const genre = rec.genre || genrePool[Math.floor(rng() * genrePool.length)];
    const writerScore = 0.35 + rng() * 0.55;  // composite writer track record
    const catalog = { age, genre, writerScore, releaseYear };

    // External signals (mocked but coherent)
    const tiktok = rng() < 0.18 ? Math.round(rng() * 14_000_000) : Math.round(rng() * 80_000);
    const shazam = Math.round(velocity.d30 * (0.002 + rng() * 0.012));
    const radio  = rng() < 0.30 ? Math.round(40 + rng() * 320) : 0;
    const press  = rng() < 0.22 ? Math.round(rng() * 18) : 0;
    const external = { tiktok, shazam, radio, press };

    return { id, audio, velocity, catalog, external };
  }

  // ─── Cosine similarity over audio features ────────────────────
  function audioSim(a, b) {
    if (!a || !b) return 0;
    const va = [a.energy, a.valence, a.danceability, a.acousticness, a.instrumentalness, (a.bpm - 100) / 60];
    const vb = [b.energy, b.valence, b.danceability, b.acousticness, b.instrumentalness, (b.bpm - 100) / 60];
    let dot = 0, na = 0, nb = 0;
    for (let i = 0; i < va.length; i++) { dot += va[i]*vb[i]; na += va[i]*va[i]; nb += vb[i]*vb[i]; }
    return na && nb ? dot / Math.sqrt(na * nb) : 0;
  }

  // ─── Build comp-track set (nearest neighbors in audio space) ──
  function findComps(feat, allRecs, n) {
    n = n || 5;
    if (!feat) return [];
    const others = (allRecs || []).filter(r => r.id !== feat.id).slice(0, 240);
    const scored = others.map(r => {
      const f = getFeatures(r);
      return { rec: r, feat: f, sim: audioSim(feat.audio, f.audio) };
    }).sort((x, y) => y.sim - x.sim).slice(0, n);
    return scored.map((s, i) => ({
      ...s,
      // Inject a "success" metric for this comp (deterministic synth).
      success: Math.round(50 + (1 - i / n) * 35 + (s.feat.velocity.d30 / 200_000)),
    }));
  }

  // ─── 03 SONG SUCCESS — overall hit-potential score (0–100) ────
  // Weighted ensemble. Returns score + per-component attribution.
  function songSuccess(feat, opts) {
    if (!feat) return null;
    opts = opts || {};
    const w = Object.assign({
      audio:    0.20,  // is the song "ready" sonically (energy/danceability/valence balance)
      velocity: 0.30,  // recent stream growth
      writer:   0.15,  // writer track record
      external: 0.20,  // tiktok/shazam/radio/press
      comp:     0.15,  // similarity to recent successful tracks
    }, opts.weights || {});

    // Component scores (all 0–100)
    const audioFit =
      Math.min(100,
        feat.audio.energy * 35 +
        feat.audio.danceability * 28 +
        feat.audio.valence * 22 +
        (1 - Math.abs((feat.audio.bpm - 120) / 60)) * 15
      );

    const velocityScore =
      Math.min(100,
        Math.max(0, 50 + feat.velocity.growth30 * 80)
        * 0.55
        + Math.min(50, Math.log10(Math.max(1, feat.velocity.d30)) * 10)
      );

    const writerScore = feat.catalog.writerScore * 100;

    // External signals — log-scaled
    const tk = Math.min(100, Math.log10(Math.max(1, feat.external.tiktok)) * 14);
    const sz = Math.min(100, Math.log10(Math.max(1, feat.external.shazam)) * 22);
    const rd = Math.min(100, feat.external.radio / 4);
    const pr = Math.min(100, feat.external.press * 6);
    const externalScore = tk * 0.45 + sz * 0.25 + rd * 0.18 + pr * 0.12;

    // Comp similarity (resolved later in scoreCatalog; default neutral here)
    const compScore = (opts.compScore != null ? opts.compScore : 55);

    const total =
      audioFit * w.audio +
      velocityScore * w.velocity +
      writerScore * w.writer +
      externalScore * w.external +
      compScore * w.comp;

    // Confidence: down-weighted by missing signals
    const conf = 0.55
      + (feat.external.tiktok > 100_000 ? 0.10 : 0)
      + (feat.external.shazam > 1000 ? 0.10 : 0)
      + (feat.velocity.d30 > 50_000 ? 0.10 : 0)
      + (feat.external.radio > 0 ? 0.08 : 0)
      + (feat.external.press > 0 ? 0.05 : 0);

    return {
      score: Math.round(total),
      conf: Math.min(0.97, conf),
      bucket: total > 75 ? 'breakout' : total > 60 ? 'strong' : total > 45 ? 'developing' : total > 30 ? 'soft' : 'cold',
      attribution: [
        { k: 'Audio fit',      v: audioFit,      w: w.audio },
        { k: 'Stream velocity', v: velocityScore, w: w.velocity },
        { k: 'Writer track',   v: writerScore,   w: w.writer },
        { k: 'External heat',  v: externalScore, w: w.external },
        { k: 'Comp similarity', v: compScore,    w: w.comp },
      ],
    };
  }

  // ─── 01 SALES OPTIMIZATION ────────────────────────────────────
  // Output: per-territory and per-DSP projected next-90-day streams + recs
  const TERRITORIES = [
    { code: 'US',  name: 'United States',  weight: 0.36 },
    { code: 'GB',  name: 'United Kingdom', weight: 0.09 },
    { code: 'DE',  name: 'Germany',        weight: 0.07 },
    { code: 'BR',  name: 'Brazil',         weight: 0.08 },
    { code: 'MX',  name: 'Mexico',         weight: 0.06 },
    { code: 'JP',  name: 'Japan',          weight: 0.10 },
    { code: 'KR',  name: 'South Korea',    weight: 0.05 },
    { code: 'IN',  name: 'India',          weight: 0.07 },
    { code: 'AU',  name: 'Australia',      weight: 0.04 },
    { code: 'NL',  name: 'Netherlands',    weight: 0.03 },
    { code: 'ES',  name: 'Spain',          weight: 0.025 },
    { code: 'FR',  name: 'France',         weight: 0.025 },
  ];
  const DSPS = ['Spotify','Apple Music','YouTube Music','Amazon Music','Tidal','Deezer','Pandora','SoundCloud'];

  function salesProjection(feat, opts) {
    if (!feat) return null;
    opts = opts || {};
    const horizonDays = opts.horizonDays || 90;
    const rng = pseed('sales·' + feat.id);

    // Linear projection: next-90 = d90 × (1 + growth90). Clip to physical floor.
    const baseProjected = Math.max(feat.velocity.d30 * 3 * 0.9, feat.velocity.d90 * (1 + feat.velocity.growth90));
    const projected = Math.round(baseProjected);

    // Distribute across territories, weighted by global music market + per-track fit
    const territories = TERRITORIES.map(t => {
      const trackFit = 0.6 + rng() * 0.8;
      const projShare = t.weight * trackFit;
      return { ...t, projShare };
    });
    const sumShare = territories.reduce((s, t) => s + t.projShare, 0);
    territories.forEach(t => t.projShare /= sumShare);

    const territoryProj = territories.map(t => {
      const proj = Math.round(projected * t.projShare);
      const current = Math.round(feat.velocity.d90 * t.projShare * (0.85 + rng() * 0.30));
      const delta = proj - current;
      const deltaPct = current ? delta / current : 0;
      return { ...t, current, projected: proj, delta, deltaPct };
    }).sort((a, b) => b.delta - a.delta);

    // DSP allocation
    const dspProj = DSPS.map(name => {
      const baseW = name === 'Spotify' ? 0.42 : name === 'Apple Music' ? 0.21 : name === 'YouTube Music' ? 0.18 : name === 'Amazon Music' ? 0.09 : 0.025;
      const trackFit = 0.7 + rng() * 0.6;
      const proj = Math.round(projected * baseW * trackFit);
      const current = Math.round(feat.velocity.d90 * baseW * (0.85 + rng() * 0.30));
      return { name, current, projected: proj, delta: proj - current, deltaPct: current ? (proj - current) / current : 0 };
    }).sort((a, b) => b.delta - a.delta);

    // Recommendations — rule-based on biggest gaps
    const recs = [];
    const topTerr = territoryProj[0];
    if (topTerr.deltaPct > 0.2) {
      recs.push({
        kind: 'territory-push',
        priority: 'high',
        title: `Push localization in ${topTerr.name}`,
        detail: `Projected +${Math.round(topTerr.deltaPct * 100)}% (+${(topTerr.delta/1000).toFixed(1)}k streams) in ${topTerr.code}. Add ${topTerr.code === 'BR' ? 'Portuguese' : topTerr.code === 'JP' ? 'Japanese' : topTerr.code === 'KR' ? 'Korean' : 'localized'} marketing assets and Spotify EQUAL-style placement.`,
        impact: topTerr.delta,
      });
    }
    const topDsp = dspProj[0];
    if (topDsp.deltaPct > 0.15) {
      recs.push({
        kind: 'dsp-push',
        priority: 'high',
        title: `${topDsp.name} pitch window open`,
        detail: `Editorial response curve favors fresh tracks 14–28 days post-DSP push; pitch within next 10 days for projected +${(topDsp.delta/1000).toFixed(1)}k.`,
        impact: topDsp.delta,
      });
    }
    if (feat.catalog.age >= 2 && feat.velocity.growth30 > 0) {
      recs.push({
        kind: 'rerelease',
        priority: 'medium',
        title: 'Catalog re-trigger candidate',
        detail: `${feat.catalog.age}-year-old track showing positive 30-day growth (+${Math.round(feat.velocity.growth30*100)}%). Consider a remix, alt-version, or seasonal repush.`,
        impact: Math.round(projected * 0.15),
      });
    }
    if (feat.audio.bpm < 100 && feat.audio.energy < 0.5) {
      recs.push({
        kind: 'playlist-fit',
        priority: 'medium',
        title: 'Lo-fi/study playlist surface',
        detail: `BPM ${feat.audio.bpm} + energy ${feat.audio.energy.toFixed(2)} fits study/sleep/lofi pools. High-volume long-tail.`,
        impact: Math.round(projected * 0.08),
      });
    }
    if (feat.external.tiktok > 1_000_000) {
      recs.push({
        kind: 'tiktok-amplify',
        priority: 'high',
        title: 'TikTok velocity surge',
        detail: `${(feat.external.tiktok/1_000_000).toFixed(1)}M TikTok plays. Re-cut a 22s edit + sponsor 3 mid-tier creators in next 14 days.`,
        impact: Math.round(projected * 0.22),
      });
    }
    if (recs.length < 3) {
      recs.push({
        kind: 'pricing',
        priority: 'low',
        title: 'Pricing experiment',
        detail: `A/B test bundle vs single in 3 secondary markets (NL, AU, ES) to lift ARPU.`,
        impact: Math.round(projected * 0.04),
      });
    }
    recs.sort((a, b) => b.impact - a.impact);

    return {
      horizonDays,
      currentTotal: feat.velocity.d90,
      projected,
      delta: projected - feat.velocity.d90,
      deltaPct: feat.velocity.d90 ? (projected - feat.velocity.d90) / feat.velocity.d90 : 0,
      territories: territoryProj,
      dsps: dspProj,
      recs: recs.slice(0, 4),
      conf: 0.62 + Math.min(0.30, Math.log10(Math.max(1, feat.velocity.d365)) * 0.04),
    };
  }

  // ─── 02 PLAYLIST PLACEMENT ────────────────────────────────────
  // Match to a curated set of editorial playlists with target feature ranges.
  const PLAYLISTS = [
    // Spotify
    { dsp: 'Spotify', name: 'Today\'s Top Hits', followers: 35_200_000, target: { energy:[.55,.85], valence:[.45,.85], bpm:[95,135], dance:[.55,.90] } },
    { dsp: 'Spotify', name: 'Pollen',           followers: 1_200_000,  target: { energy:[.40,.75], valence:[.30,.70], bpm:[90,125], dance:[.40,.75] } },
    { dsp: 'Spotify', name: 'Lorem',            followers:   980_000,  target: { energy:[.30,.65], valence:[.20,.60], bpm:[70,115], dance:[.30,.65] } },
    { dsp: 'Spotify', name: 'Lo-Fi Beats',      followers: 5_400_000,  target: { energy:[.20,.55], valence:[.30,.60], bpm:[65, 95], dance:[.40,.70], inst:[.30,1.0] } },
    { dsp: 'Spotify', name: 'RapCaviar',        followers:14_800_000,  target: { energy:[.55,.85], valence:[.30,.75], bpm:[60,100], dance:[.65,.95] } },
    { dsp: 'Spotify', name: 'Mint',             followers: 6_600_000,  target: { energy:[.65,.95], valence:[.40,.85], bpm:[115,135], dance:[.55,.85] } },
    { dsp: 'Spotify', name: 'Chill Hits',       followers: 7_100_000,  target: { energy:[.30,.65], valence:[.35,.70], bpm:[80,115], dance:[.40,.70] } },
    { dsp: 'Spotify', name: 'Anti Pop',         followers:   920_000,  target: { energy:[.45,.80], valence:[.20,.55], bpm:[85,130], dance:[.45,.80] } },
    // Apple
    { dsp: 'Apple Music', name: 'A-List Pop',     followers: 4_200_000, target: { energy:[.55,.85], valence:[.45,.80], bpm:[95,130], dance:[.55,.85] } },
    { dsp: 'Apple Music', name: 'New In Indie',   followers:   780_000, target: { energy:[.40,.75], valence:[.25,.65], bpm:[85,125], dance:[.35,.70] } },
    { dsp: 'Apple Music', name: 'Pure Focus',     followers: 1_300_000, target: { energy:[.20,.55], valence:[.30,.65], bpm:[60,100], dance:[.35,.65], inst:[.30,1.0] } },
    // YouTube
    { dsp: 'YouTube Music', name: 'Hot Hits',     followers: 2_400_000, target: { energy:[.55,.85], valence:[.40,.80], bpm:[90,135], dance:[.55,.85] } },
    { dsp: 'YouTube Music', name: 'New Music Daily', followers: 1_100_000, target: { energy:[.45,.80], valence:[.30,.75], bpm:[85,130], dance:[.45,.80] } },
    // Tidal
    { dsp: 'Tidal', name: 'Tidal Rising',         followers:   210_000, target: { energy:[.40,.80], valence:[.20,.65], bpm:[80,130], dance:[.40,.80] } },
    { dsp: 'Tidal', name: 'Master Quality Lounge',followers:    95_000, target: { energy:[.20,.55], valence:[.30,.65], bpm:[65,105], dance:[.30,.60], acoustic:[.30,1.0] } },
  ];

  function inRange(v, [lo, hi]) {
    if (v >= lo && v <= hi) return 1;
    const dist = v < lo ? lo - v : v - hi;
    return Math.max(0, 1 - dist * 2);
  }
  function playlistMatch(feat, pl) {
    const t = pl.target;
    let score = 0; let n = 0;
    if (t.energy)   { score += inRange(feat.audio.energy,   t.energy);   n++; }
    if (t.valence)  { score += inRange(feat.audio.valence,  t.valence);  n++; }
    if (t.bpm)      { score += inRange(feat.audio.bpm,      t.bpm);      n++; }
    if (t.dance)    { score += inRange(feat.audio.danceability, t.dance);n++; }
    if (t.inst)     { score += inRange(feat.audio.instrumentalness, t.inst); n++; }
    if (t.acoustic) { score += inRange(feat.audio.acousticness, t.acoustic); n++; }
    return n ? score / n : 0;
  }

  function playlistPlacements(feat, opts) {
    if (!feat) return null;
    opts = opts || {};
    const pool = (opts.dspFilter ? PLAYLISTS.filter(p => p.dsp === opts.dspFilter) : PLAYLISTS);
    const ranked = pool.map(pl => {
      const match = playlistMatch(feat, pl);
      // Likelihood of placement: match × (1 - log-followers penalty) × catalog-base
      const baseLikelihood = match * 0.78;
      const followerPenalty = Math.min(0.5, Math.log10(pl.followers / 100_000) * 0.08);
      const writerBoost = (feat.catalog.writerScore - 0.4) * 0.18;
      const lik = Math.max(0.02, Math.min(0.92, baseLikelihood - followerPenalty + writerBoost));
      return {
        ...pl,
        match,
        likelihood: lik,
        verdict: lik > 0.55 ? 'strong' : lik > 0.32 ? 'plausible' : lik > 0.15 ? 'long-shot' : 'no-fit',
        actions: lik > 0.32 ? [
          `Pitch ${pl.dsp} editorial 14–21d before release`,
          `Provide 2 cover-art variants (square + 16:9)`,
          `Co-pitch with label A&R using audio-feature similarity comp`,
        ] : null,
      };
    }).sort((a, b) => b.likelihood - a.likelihood);
    return { ranked: ranked.slice(0, 12) };
  }

  // ─── 04 CATALOG MINING ────────────────────────────────────────
  // For each track, compute an upside score = success-score × (1 - normalized recent streams).
  // Sorted desc → top N "underperforming with potential".
  function catalogMining(allRecs, opts) {
    opts = opts || {};
    const limit = opts.limit || 50;
    const scored = (allRecs || []).slice(0, 240).map(r => {
      const f = getFeatures(r);
      const success = songSuccess(f);
      const recentNorm = Math.min(1, Math.log10(Math.max(1, f.velocity.d30)) / 6);
      const upside = success.score * (0.30 + 0.70 * (1 - recentNorm));
      return { rec: r, feat: f, success, upside: Math.round(upside) };
    });
    scored.sort((a, b) => b.upside - a.upside);
    return scored.slice(0, limit);
  }

  // ─── 05 SYNC PLACEMENT ────────────────────────────────────────
  // Match audio profile to common sync archetypes.
  const SYNC_ARCHETYPES = [
    { tag: 'Trailer-Action',     target: { energy:[.75,1.0], valence:[.30,.65], bpm:[100,160], inst:[.30,1.0] }, brands: ['A24','Marvel','Netflix Originals','Sony Pictures'] },
    { tag: 'Trailer-Drama',      target: { energy:[.40,.70], valence:[.15,.45], bpm:[60, 95], acoustic:[.20,.85] }, brands: ['A24','HBO','FX','Apple TV+'] },
    { tag: 'Comedy-Bouncy',      target: { energy:[.55,.80], valence:[.65,.95], bpm:[110,140], dance:[.55,.85] }, brands: ['NBC','Paramount Comedy','Hulu'] },
    { tag: 'Romance-Indie',      target: { energy:[.30,.60], valence:[.40,.75], bpm:[75,115], acoustic:[.30,.80] }, brands: ['A24','Searchlight','Indie Wire'] },
    { tag: 'Sport-Hype',         target: { energy:[.80,1.0], valence:[.55,.90], bpm:[120,160], dance:[.55,.85] }, brands: ['Nike','Adidas','ESPN','Under Armour'] },
    { tag: 'Tech-Ad',            target: { energy:[.55,.80], valence:[.60,.90], bpm:[100,135], inst:[.10,.50] }, brands: ['Apple','Google','Samsung','Adobe'] },
    { tag: 'Auto-Premium',       target: { energy:[.50,.75], valence:[.40,.70], bpm:[90,125], inst:[.20,.70] }, brands: ['BMW','Mercedes','Audi','Lexus'] },
    { tag: 'Fashion-Edit',       target: { energy:[.45,.80], valence:[.30,.65], bpm:[95,130], dance:[.45,.80] }, brands: ['Vogue','Saint Laurent','Acne Studios'] },
    { tag: 'Game-Cinematic',     target: { energy:[.65,.95], valence:[.20,.55], bpm:[90,135], inst:[.40,1.0] }, brands: ['Riot Games','PlayStation','Xbox'] },
  ];
  function syncFit(feat) {
    if (!feat) return null;
    const ranked = SYNC_ARCHETYPES.map(arch => {
      const t = arch.target;
      let score = 0, n = 0;
      if (t.energy)   { score += inRange(feat.audio.energy,   t.energy);   n++; }
      if (t.valence)  { score += inRange(feat.audio.valence,  t.valence);  n++; }
      if (t.bpm)      { score += inRange(feat.audio.bpm,      t.bpm);      n++; }
      if (t.dance)    { score += inRange(feat.audio.danceability, t.dance);n++; }
      if (t.inst)     { score += inRange(feat.audio.instrumentalness, t.inst); n++; }
      if (t.acoustic) { score += inRange(feat.audio.acousticness, t.acoustic); n++; }
      const fit = n ? score / n : 0;
      // Estimated dollar value (rule-of-thumb sync fee bands)
      const baseFee = arch.tag.startsWith('Trailer') ? 35_000 : arch.tag === 'Sport-Hype' ? 80_000 : arch.tag === 'Tech-Ad' ? 120_000 : arch.tag === 'Auto-Premium' ? 65_000 : 25_000;
      const feeRange = [Math.round(baseFee * 0.6), Math.round(baseFee * (fit > 0.7 ? 1.4 : 1.0))];
      return { ...arch, fit, feeRange, viable: fit > 0.55 };
    }).sort((a, b) => b.fit - a.fit);
    return { ranked: ranked.slice(0, 6) };
  }

  // ─── Full per-recording scorecard ─────────────────────────────
  function scorecard(rec, opts) {
    opts = opts || {};
    const feat = getFeatures(rec);
    if (!feat) return null;
    const allRecs = opts.allRecs || (window.RECORDINGS || []);
    const comps = findComps(feat, allRecs, 5);
    const compScore = comps.length ? Math.round(comps.reduce((s, c) => s + c.success * c.sim, 0) / comps.reduce((s, c) => s + c.sim, 0.001)) : 50;
    const success = songSuccess(feat, { weights: opts.weights, compScore });
    const sales = salesProjection(feat, opts);
    const playlists = playlistPlacements(feat, opts);
    const sync = syncFit(feat);
    return { rec, feat, comps, success, sales, playlists, sync };
  }

  window.PredictEngine = {
    getFeatures,
    audioSim,
    findComps,
    songSuccess,
    salesProjection,
    playlistMatch,
    playlistPlacements,
    catalogMining,
    syncFit,
    scorecard,
    PLAYLISTS,
    TERRITORIES,
    DSPS,
    SYNC_ARCHETYPES,
  };

  console.log('[PredictEngine] loaded · 5 algorithms · ' + PLAYLISTS.length + ' playlists · ' + SYNC_ARCHETYPES.length + ' sync archetypes');
})();
