// On-device matcher for the slim landmark index (data/fp/), built by
// EjePlayerCodes/extension/build_fp_index.py from the Swift tango_landmark.bin.
//
// Mirrors Sources/LicenseCore/LandmarkIndex.{match,decide}: time-coherent
// offset voting per song + a precision gate that ABSTAINS rather than guess.
// Lazy: the ~37 MB index is fetched only on the first detect, then cached.
//
// Shard file layout (little-endian), one per code>>shardBits bucket:
//   "EFP1" | u32 shardIndex | u32 numCodes | u32 numPostings
//          | u32 codeBytes  | u32 countBytes
//          | varint code deltas (from base = shard<<shardBits)
//          | varint counts (postings per code)
//          | u32[numPostings] postings  ((refId<<timeBits) | timeFrame)
import { dataURL } from "../util.js";

let _idx = null;       // parsed index (cached)
let _loading = null;   // in-flight load promise

function readVarints(bytes, off, n) {
  const out = new Uint32Array(n);
  let p = off;
  for (let i = 0; i < n; i++) {
    let shift = 0, v = 0, b;
    do { b = bytes[p++]; v |= (b & 0x7f) << shift; shift += 7; } while (b & 0x80);
    out[i] = v >>> 0;
  }
  return { out, next: p };
}

function parseShard(buf) {
  const dv = new DataView(buf);
  const magic = String.fromCharCode(dv.getUint8(0), dv.getUint8(1), dv.getUint8(2), dv.getUint8(3));
  if (magic !== "EFP1") throw new Error("bad fp shard magic: " + magic);
  const shardIndex = dv.getUint32(4, true);
  const numCodes = dv.getUint32(8, true);
  const numPostings = dv.getUint32(12, true);
  const codeBytes = dv.getUint32(16, true);
  const countBytes = dv.getUint32(20, true);
  let off = 24;
  const bytes = new Uint8Array(buf);

  // codes: delta-varint from base = shardIndex << shardBits
  const base = shardIndex << _shardBits;
  const cd = readVarints(bytes, off, numCodes);
  const codes = cd.out;
  let acc = base;
  for (let i = 0; i < numCodes; i++) { acc += codes[i]; codes[i] = acc >>> 0; }
  off += codeBytes;

  // counts → offsets (prefix sum), Uint32Array[numCodes+1]
  const cc = readVarints(bytes, off, numCodes);
  off += countBytes;
  const offsets = new Uint32Array(numCodes + 1);
  for (let i = 0; i < numCodes; i++) offsets[i + 1] = offsets[i] + cc.out[i];

  // postings — slice() yields a fresh 0-offset ArrayBuffer, so the Uint32Array
  // view is guaranteed 4-byte aligned.
  const postings = new Uint32Array(buf.slice(off, off + numPostings * 4));

  return { codes, offsets, postings };
}

let _shardBits = 22;

// The slim index now lives on Cloudflare R2 (served at a custom domain), so it can be UPDATED
// WITHOUT a Web-Store release: rebuild + re-upload (EjePlayerCodes/extension/upload_fp_r2.sh) and
// clients pick it up via the `generated` cache-bust. Fetched once, cached on-device (Cache API);
// the BUNDLED copy is the offline / first-run fallback so detection never breaks.
const R2_BASE = "https://fp.el-eje.com";   // R2 bucket on this custom domain
const FP_CACHE = "eleje-fp";               // Cache API bucket for downloaded shards
const hasCaches = () => typeof caches !== "undefined";

function buildIndex(meta, shards) {
  _shardBits = meta.shardBits;
  return { meta, refs: meta.refs, gate: meta.gate, shardBits: meta.shardBits, timeBits: meta.timeBits, shards };
}

// fpmeta is always fetched FRESH (it's tiny) so a new build's `generated` stamp is seen at once.
async function fetchR2Meta() {
  const r = await fetch(`${R2_BASE}/fpmeta.json`, { cache: "no-store" });
  if (!r.ok) throw new Error(`fp R2 meta ${r.status}`);
  return r;
}
// A shard: from the Cache API (keyed by index version) if present, else fetched from R2 + cached.
async function fetchR2Shard(k, version) {
  const url = `${R2_BASE}/shard-${k}.bin`;
  if (!hasCaches()) {
    const r = await fetch(url, { cache: "no-store" });
    if (!r.ok) throw new Error(`fp R2 shard ${k} ${r.status}`);
    return r;
  }
  const cache = await caches.open(FP_CACHE);
  const key = `${url}?v=${version}`;
  const hit = await cache.match(key);
  if (hit) return hit;
  const r = await fetch(url, { cache: "no-store" });
  if (!r.ok) throw new Error(`fp R2 shard ${k} ${r.status}`);
  try {
    await cache.put(key, r.clone());
    for (const req of await cache.keys()) {        // prune older versions of this shard
      if (req.url.includes(`/shard-${k}.bin?v=`) && req.url !== key) await cache.delete(req);
    }
  } catch { /* cache unavailable — still return the response */ }
  return r;
}

// Warm the on-device cache from R2 (no parse) — called on install for offline-first.
export async function prefetch() {
  try {
    const meta = await fetchR2Meta().then(r => r.json());
    const version = String(meta.generated || meta.version || "1");
    await Promise.all(Array.from({ length: meta.shards }, (_, k) => fetchR2Shard(k, version)));
    return true;
  } catch { return false; }
}

async function _loadBundled() {
  const meta = await fetch(dataURL("fp/fpmeta.json")).then(r => r.json());
  _shardBits = meta.shardBits;
  const shards = await Promise.all(Array.from({ length: meta.shards }, (_, k) =>
    fetch(dataURL(`fp/shard-${k}.bin`)).then(r => r.arrayBuffer()).then(parseShard)));
  return buildIndex(meta, shards);
}

async function _load() {
  // R2 first (updatable, cached on-device); fall back to the bundled copy if R2 is unreachable
  // (offline / first run with no cache) so detection always works.
  try {
    const meta = await fetchR2Meta().then(r => r.json());
    const version = String(meta.generated || meta.version || "1");
    _shardBits = meta.shardBits;            // parseShard reads this, so set it BEFORE parsing
    const shards = await Promise.all(Array.from({ length: meta.shards }, (_, k) =>
      fetchR2Shard(k, version).then(r => r.arrayBuffer()).then(parseShard)));
    return buildIndex(meta, shards);
  } catch (e) {
    return _loadBundled();
  }
}

export async function loadIndex() {
  if (_idx) return _idx;
  if (!_loading) _loading = _load().then(x => (_idx = x));
  return _loading;
}

export function isLoaded() { return !!_idx; }

// binary search: index of `code` in sorted Uint32Array, or -1
function find(codes, code) {
  let lo = 0, hi = codes.length;
  while (lo < hi) { const m = (lo + hi) >> 1; if (codes[m] < code) lo = m + 1; else hi = m; }
  return lo < codes.length && codes[lo] === code ? lo : -1;
}

// Vote query hashes against the index: per song, the peak time-offset bin count.
// query = { codes: Uint32Array, ts: Uint32Array } from engine.fingerprint().
export function rank(idx, query, offsetTolerance) {
  const tol = offsetTolerance || idx.gate.offsetTolerance || 2;
  const timeMask = (1 << idx.timeBits) - 1;
  const { codes: qc, ts: qt } = query;
  // votes: Map<refId, Map<bin, count>>
  const votes = new Map();
  for (let i = 0; i < qc.length; i++) {
    const code = qc[i];
    const sh = idx.shards[code >>> idx.shardBits];
    if (!sh) continue;
    const ci = find(sh.codes, code);
    if (ci < 0) continue;
    const start = sh.offsets[ci], end = sh.offsets[ci + 1];
    const qti = qt[i];
    for (let p = start; p < end; p++) {
      const v = sh.postings[p];
      const ref = v >>> idx.timeBits;
      const t = v & timeMask;
      const bin = Math.floor((t - qti) / tol);
      let m = votes.get(ref);
      if (!m) { m = new Map(); votes.set(ref, m); }
      m.set(bin, (m.get(bin) || 0) + 1);
    }
  }
  const scored = [];
  for (const [ref, hist] of votes) {
    let best = 0;
    for (const c of hist.values()) if (c > best) best = c;
    scored.push({ ref, score: best, songId: idx.refs[ref] });
  }
  scored.sort((a, b) => (b.score - a.score) || (a.ref - b.ref));
  return scored;
}

// Gate: confident accept needs a high peak vote AND clear dominance over the
// runner-up; otherwise suggest (borderline) or abstain (insufficient evidence).
export function decide(scored, gate) {
  const g = gate || (_idx && _idx.gate) || { minScore: 25, marginRatio: 3.0, suggestRatio: 1.6 };
  const top = scored[0];
  if (!top || top.score < g.minScore) {
    return { verdict: "abstain", score: top ? top.score : 0,
             second: scored.length > 1 ? scored[1].score : 0, candidates: scored.slice(0, 5) };
  }
  const second = scored.length > 1 ? scored[1].score : 0;
  const ratio = second === 0 ? Infinity : top.score / second;
  const out = { songId: top.songId, score: top.score, second, ratio, candidates: scored.slice(0, 5) };
  if (ratio >= g.marginRatio) return { verdict: "accept", ...out };
  if (ratio >= g.suggestRatio) return { verdict: "suggest", ...out };
  return { verdict: "abstain", ...out };
}

// Convenience: load (if needed), rank, decide.
export async function detect(query) {
  const idx = await loadIndex();
  return decide(rank(idx, query), idx.gate);
}
