// ml-bbrank.jsx — Black-box claim ranking
// ────────────────────────────────────────────────────────────────────
// Ranks unmatched-pool money by likelihood of being yours.
// Inputs: black-box pool entries (society + work-fragment + amount + period).
// Score per entry: P(yours) ∈ [0,1] based on:
//
//   01 TITLE-MATCH      fuzzy match against your catalog (Levenshtein + token sort)
//   02 WRITER-MATCH     IPI overlap with your roster
//   03 ISWC-MATCH       any ISWC variant fits
//   04 TERRITORY-FIT    society aligns with where work was registered
//   05 PERIOD-FIT       money sits in claim window (no Statute of Limitations issue)
//
// Returns score, $ likely-recovery, and per-factor explainer.
// ────────────────────────────────────────────────────────────────────
(function () {
  if (typeof window === 'undefined') return;

  // Levenshtein distance, capped for perf
  function lev(a, b, max) {
    a = (a || '').toLowerCase(); b = (b || '').toLowerCase();
    if (a === b) return 0;
    if (!a.length) return b.length;
    if (!b.length) return a.length;
    max = max || Math.max(a.length, b.length);
    const m = a.length, n = b.length;
    let prev = new Array(n + 1), curr = new Array(n + 1);
    for (let j = 0; j <= n; j++) prev[j] = j;
    for (let i = 1; i <= m; i++) {
      curr[0] = i;
      let rowMin = i;
      for (let j = 1; j <= n; j++) {
        const cost = a.charCodeAt(i-1) === b.charCodeAt(j-1) ? 0 : 1;
        curr[j] = Math.min(prev[j] + 1, curr[j-1] + 1, prev[j-1] + cost);
        if (curr[j] < rowMin) rowMin = curr[j];
      }
      if (rowMin > max) return max + 1;
      [prev, curr] = [curr, prev];
    }
    return prev[n];
  }

  function titleSim(a, b) {
    if (!a || !b) return 0;
    const norm = s => (s || '').toLowerCase().replace(/[^a-z0-9 ]/g, '').replace(/\s+/g, ' ').trim();
    const A = norm(a), B = norm(b);
    if (!A || !B) return 0;
    if (A === B) return 1;
    const tokA = A.split(' ').sort().join(' ');
    const tokB = B.split(' ').sort().join(' ');
    if (tokA === tokB) return 0.95;
    const d = lev(A, B, 12);
    const len = Math.max(A.length, B.length);
    return Math.max(0, 1 - d / len);
  }

  function rankEntry(entry, opts) {
    opts = opts || {};
    const works = opts.works || (window.__WORKS || window.WORKS || []).slice(0, 600);
    const writers = opts.writers || (window.__PROFILES || window.PROFILES || []).filter(p => p.kind === 'writer' || !p.kind).slice(0, 400);

    // 01 title — find best match in our catalog
    let bestTitle = { sim: 0, work: null };
    for (const w of works) {
      const sim = titleSim(entry.title, w.title);
      if (sim > bestTitle.sim) bestTitle = { sim, work: w };
    }

    // 02 writer IPI overlap
    let writerHits = 0;
    if (entry.writers) {
      for (const ew of entry.writers) {
        const ipi = ew.ipi;
        if (ipi && writers.some(w => w.ipi === ipi || (w.ipiNameNumber && String(w.ipiNameNumber) === String(ipi)))) {
          writerHits += 1;
        }
      }
    }
    const writerScore = entry.writers?.length ? writerHits / entry.writers.length : 0;

    // 03 ISWC match (against best title's work)
    const iswcMatch = bestTitle.work && entry.iswc && bestTitle.work.iswc &&
      bestTitle.work.iswc.replace(/[^A-Z0-9]/gi, '') === entry.iswc.replace(/[^A-Z0-9]/gi, '');

    // 04 territory fit — society vs registration footprint
    const SOCIETY_TERR = {
      'ASCAP': 'US', 'BMI': 'US', 'SESAC': 'US', 'GMR': 'US',
      'PRS': 'GB', 'MCPS': 'GB',
      'GEMA': 'DE', 'SACEM': 'FR', 'SIAE': 'IT',
      'SOCAN': 'CA', 'APRA': 'AU', 'STIM': 'SE', 'KODA': 'DK',
      'JASRAC': 'JP', 'NEXTONE': 'JP',
      'KOMCA': 'KR', 'CASH': 'HK', 'COMPASS': 'SG',
      'AKM': 'AT', 'SUISA': 'CH', 'BUMA': 'NL', 'TONO': 'NO',
      'SAYCO': 'CO', 'SAYCE': 'EC', 'SADAIC': 'AR', 'SBACEM': 'BR',
      'MLC': 'US', 'HFA': 'US',
    };
    const expectedTerr = SOCIETY_TERR[entry.society] || null;
    const terrFit = expectedTerr && bestTitle.work?.territories?.includes?.(expectedTerr) ? 1
      : expectedTerr ? 0.4 : 0.5;

    // 05 period fit — most societies have 3-yr SoL
    const periodAge = entry.periodAgeMonths || 0;
    const periodFit = periodAge < 24 ? 1 : periodAge < 36 ? 0.85 : periodAge < 60 ? 0.55 : 0.20;

    // Weighted ensemble
    const w = { title: 0.36, writer: 0.24, iswc: 0.14, terr: 0.16, period: 0.10 };
    const score =
      bestTitle.sim   * w.title +
      writerScore     * w.writer +
      (iswcMatch ? 1 : 0) * w.iswc +
      terrFit         * w.terr +
      periodFit       * w.period;

    const expectedRecovery = (entry.amount || 0) * Math.min(1, score * 1.05);

    return {
      ...entry,
      score: Math.round(score * 100),
      bucket: score > 0.78 ? 'likely-yours' : score > 0.55 ? 'possible' : score > 0.32 ? 'longshot' : 'noise',
      expectedRecovery: Math.round(expectedRecovery * 100) / 100,
      attribution: [
        { k: 'Title match',     v: Math.round(bestTitle.sim * 100), w: w.title, detail: bestTitle.work ? `Closest: "${bestTitle.work.title}"` : 'No match in catalog' },
        { k: 'Writer IPI',      v: Math.round(writerScore * 100),   w: w.writer, detail: `${writerHits}/${entry.writers?.length || 0} writers matched by IPI` },
        { k: 'ISWC',            v: iswcMatch ? 100 : 0,             w: w.iswc, detail: iswcMatch ? 'Exact ISWC match' : 'No ISWC match' },
        { k: 'Territory',       v: Math.round(terrFit * 100),       w: w.terr, detail: expectedTerr ? `Society ${entry.society} → ${expectedTerr}` : 'Society territory unknown' },
        { k: 'Period freshness', v: Math.round(periodFit * 100),    w: w.period, detail: `${periodAge}mo old` },
      ],
      bestMatch: bestTitle.work,
      conf: 0.55 + (writerHits ? 0.15 : 0) + (iswcMatch ? 0.15 : 0) + (bestTitle.sim > 0.8 ? 0.10 : 0),
    };
  }

  function rankPool(entries, opts) {
    return (entries || []).map(e => rankEntry(e, opts)).sort((a, b) => b.expectedRecovery - a.expectedRecovery);
  }

  function summarize(ranked) {
    const sum = { total: 0, likelyYours: 0, possible: 0, longshot: 0, noise: 0, totalRecovery: 0, expectedRecovery: 0 };
    ranked.forEach(r => {
      sum.total += 1;
      sum.totalRecovery += r.amount || 0;
      sum.expectedRecovery += r.expectedRecovery || 0;
      sum[r.bucket === 'likely-yours' ? 'likelyYours' : r.bucket] = (sum[r.bucket === 'likely-yours' ? 'likelyYours' : r.bucket] || 0) + 1;
    });
    sum.totalRecovery = Math.round(sum.totalRecovery * 100) / 100;
    sum.expectedRecovery = Math.round(sum.expectedRecovery * 100) / 100;
    return sum;
  }

  window.BBRankEngine = { rankEntry, rankPool, summarize, titleSim };
  console.log('[BBRankEngine] loaded');
})();
