// In-memory song database backed by the bundled offline snapshot.
//   index.json      -> all songs (no lyric text), loaded once
//   lyrics/<h>.json -> 16 shards, lazy-loaded by first hex char of the group id
// Index record keys (compact): id, t(itle), o(rchestra), s(inger), y(ear),
//   g(enre), k(ey), b(pm), d(ims), l(=1 if lyrics). tf/of/sf/blob are derived
//   here at load time (so search folding always matches the app).
import { fold } from "./fold.js";
import { dataURL } from "./util.js";

let _songs = null;
let _byId = null;
let _byTitle = null;
let _meta = null;
let _orchSurnames = null;
let _aliases = null;
const _shardCache = new Map();

export async function init() {
  if (_songs) return;
  const [idx, meta, aliases] = await Promise.all([
    fetch(dataURL("index.json")).then(r => r.json()),
    fetch(dataURL("meta.json")).then(r => r.json()).catch(() => null),
    fetch(dataURL("aliases.json")).then(r => r.json()).catch(() => null),
  ]);
  _meta = meta;
  _aliases = (aliases && aliases.aliases) || {};
  _songs = idx.songs;
  _byId = new Map();
  _byTitle = new Map();
  for (const s of _songs) {
    s.tf = fold(s.t);
    s.of = fold(s.o || "");
    s.sf = fold(s.s || "");
    s.blob = (s.tf + " " + s.of + " " + s.sf).trim();
    _byId.set(s.id, s);
    let bucket = _byTitle.get(s.tf);
    if (!bucket) _byTitle.set(s.tf, (bucket = []));
    bucket.push(s);
  }
  // Distinctive orchestra surnames (the last name token of each orchestra) — used
  // by matchTitle to drop candidates whose orchestra is contradicted by one named
  // in the video TITLE (e.g. a "Di Sarli … Julián Centella" video must not fall
  // back to a bare "Julián" by Donato/Fresedo/Víctor).
  _orchSurnames = new Set();
  for (const s of _songs) {
    const ot = s.of.split(" ").filter(t => t.length >= 4 && !NOISE.has(t) && !ORCH_STOP.has(t));
    if (ot.length) _orchSurnames.add(ot[ot.length - 1]);
  }
}

export function meta() { return _meta; }
export function count() { return _songs ? _songs.length : 0; }

export function search(query, limit = 60) {
  const q = fold(query);
  if (!q || !_songs) return [];
  const tokens = q.split(" ");
  const scored = [];
  for (const s of _songs) {
    if (!tokens.every(t => s.blob.includes(t))) continue;
    let score = 0;
    if (s.tf === q) score += 100;
    else if (s.tf.startsWith(q)) score += 50;
    else if (s.tf.includes(q)) score += 20;
    if (s.of === q || s.sf === q) score += 14;
    else if (s.of.includes(q) || s.sf.includes(q)) score += 8;
    if (s.l) score += 5;       // prefer recordings that actually have lyrics
    if (s.y) score += 2;       // and ones with a known year (cleaner metadata)
    scored.push({ s, score });
  }
  scored.sort((a, b) =>
    b.score - a.score ||
    a.s.tf.localeCompare(b.s.tf) ||
    (a.s.y || "").localeCompare(b.s.y || ""));
  return scored.slice(0, limit).map(o => o.s);
}

// Resolve a possibly-stale group id (from a stored URL-confirm, timing, or an old
// crawl) to the catalog entry it was deduplicated into. Identity for live ids.
export function resolveId(id) { return (_aliases && _aliases[id]) || id; }

export function getSong(id) {
  if (!_byId) return null;
  return _byId.get(id) || _byId.get(resolveId(id)) || null;
}

// Same song (folded title) recorded by other orchestras / years.
export function otherRecordings(song) {
  if (!song || !_byTitle) return [];
  return (_byTitle.get(song.tf) || [])
    .filter(s => s.id !== song.id)
    .sort((a, b) => (a.y || "").localeCompare(b.y || "") || a.of.localeCompare(b.of));
}

// Fuzzy match a noisy free-text title (e.g. a YouTube video title) to songs.
// Unlike search(), it does NOT require every query token to match — it scores by
// how much of each SONG's title appears in the query, plus orchestra/singer/year
// bonuses. Returns ranked candidates with hit flags so callers can judge confidence.
const NOISE = new Set([
  "tango", "vals", "milonga", "instrumental", "official", "video", "audio", "hd",
  "hq", "remastered", "remasterizado", "lyrics", "letra", "subtitulado", "full",
  "completo", "orquesta", "orq", "y", "el", "la", "los", "las", "de", "del", "con",
]);

// Generic ensemble words that are never a distinguishing orchestra identity — kept
// out of the orchestra-surname vocabulary so they can't act as a contradiction signal.
const ORCH_STOP = new Set([
  "orquesta", "tipica", "tipico", "sexteto", "quinteto", "cuarteto", "conjunto",
  "orchestra", "trio", "septeto", "seleccion", "estudiantina",
]);

// Genre/media words that are NOT distinctive song-title tokens — excluded from the
// title-coverage match on BOTH sides. Nearly every YouTube tango title contains
// "tango" (in the title, the festival name, or the channel — "Tangobro", "030tango"),
// so without this a junk catalog entry like "Tango Argentino" or "1 Y 1 Tango" scores
// on that one ubiquitous word and outranks the real song, which then sits just below a
// non-showable top and the overlay abstains. (Articles like el/la/de are kept so "La
// cumparsita" stays a 2-token title.)
const TITLE_NOISE = new Set([
  "tango", "vals", "valsecito", "milonga", "milongon", "candombe", "foxtrot",
  "instrumental", "official", "video", "audio", "hd", "hq", "remastered",
  "remasterizado", "lyrics", "letra", "subtitulado", "full", "completo", "orquesta", "orq",
]);

// Articles/prepositions kept in titles (so "La milonga de Buenos Aires" stays a 4-token,
// more-specific match than "Buenos Aires") — but a title left with ONLY these after the
// genre words are dropped (e.g. a junk catalog entry "La tango" → ["la"]) has no
// distinctive content and must not match every "La …" query.
const ARTICLE = new Set(["el", "la", "los", "las", "un", "una", "de", "del", "al", "con", "y", "e", "o", "a"]);

// An artist (orchestra/singer) is "mentioned" in the query only if its SURNAME
// (last name token) appears, or 2+ of its name tokens do — so a lone common first
// name ("carlos", "juan", "francisco") doesn't trigger a false confident match.
function artistHit(tokens, qset) {
  if (!tokens.length) return false;
  if (qset.has(tokens[tokens.length - 1])) return true;
  let n = 0;
  for (const t of tokens) if (qset.has(t)) n++;
  return n >= 2;
}

export function matchTitle(rawTitle, limit = 5, context = "") {
  const q = fold(rawTitle);
  if (!q || !_songs) return [];
  const qset = new Set(q.split(" ").filter(t => t.length >= 2 && !TITLE_NOISE.has(t)));
  if (!qset.size) return [];
  // Artist disambiguation may also draw on the video DESCRIPTION (folded): dance /
  // performance titles often name only the dancers + song and put the ORCHESTRA in
  // the description, so without it every version ties and the wrong one can win.
  // COVERAGE still uses the title alone — description tokens must not inflate it.
  const aset = context
    ? new Set([...qset, ...fold(context).split(" ").filter(t => t.length >= 2)])
    : qset;
  // Orchestra explicitly named IN THE TITLE (by its distinctive surname). When the
  // title names one, candidates by a different orchestra are contradicted and get
  // dropped — turning an absent song into an honest abstain instead of a wrong
  // same-named match. Title-only (not the channel) so Topic-page pages, where the
  // orchestra lives in the channel and the title is just the song, are unaffected.
  const qOrch = _orchSurnames ? [...qset].filter(t => _orchSurnames.has(t)) : [];
  const scored = [];
  for (const s of _songs) {
    // Score coverage over MEANINGFUL title tokens (>=2 chars). 1-letter words like
    // "a" can never be in qset (which is also >=2), so counting them always misses
    // and wrongly sinks short titles — e.g. "A bailar" capped at 0.5 coverage, so a
    // song literally titled "Aníbal Troilo" beat it. Drop them from the denominator.
    // A title token that is ALSO the candidate's OWN orchestra/singer name can't anchor
    // coverage. Without this, a recording mis-titled "Di Di" (a data typo for "Didí") by
    // "Carlos DI Sarli" scores full coverage on the "di" in "Di Sarli" and hijacks EVERY
    // Di Sarli video (it folds to ["di","di"], both covered by the orchestra's "di").
    const own = new Set([...s.of.split(" "), ...s.sf.split(" ")]);
    const tt = s.tf.split(" ").filter(t => t.length >= 2 && !own.has(t) && !TITLE_NOISE.has(t));
    if (!tt.length) continue;
    if (!tt.some(t => !ARTICLE.has(t))) continue;   // only articles left → no real title content

    let hit = 0;
    for (const t of tt) if (qset.has(t)) hit++;
    const coverage = hit / tt.length;           // fraction of the song title present
    if (coverage < 0.6) continue;
    const distinctiveHit = tt.filter(t => !ARTICLE.has(t) && qset.has(t)).length;
    // Drop if the title names a specific orchestra that ISN'T this candidate's. A
    // query surname that appears in the candidate's OWN title is a title word, not
    // a contradiction (so "Canaro en París" by Di Sarli keeps the "canaro" token).
    // ONLY for SINGLE-token titles ("Julián", "Sur") where the contradiction is the real
    // disambiguator. A 2+-token exact title is already routed by the orchestra BONUS, and
    // a stray surname-shaped token in a noisy dance title (the DANCER "Miguel Ángel Zotto"
    // or "…Marqués", a venue "La Viruta") collides with catalog orchestra surnames and
    // would otherwise false-drop the correct multi-word song.
    if (qOrch.length && distinctiveHit <= 1) {
      const candOf = new Set(s.of.split(" "));
      const candTf = new Set(s.tf.split(" "));
      const eff = qOrch.filter(t => !candTf.has(t));
      if (eff.length && !eff.some(t => candOf.has(t))) continue;
    }
    const oTokens = s.of.split(" ").filter(t => t.length >= 3 && !NOISE.has(t));
    const sTokens = s.sf.split(" ").filter(t => t.length >= 3 && !NOISE.has(t));
    // An artist counts as matched only on its SURNAME (the distinctive last token)
    // or on 2+ of its name tokens — NOT a lone common first name. Otherwise
    // "...Carlos Gardel" confidently matches a "Carlos Dante" recording on "carlos".
    const oHit = artistHit(oTokens, aset);
    const sHit = artistHit(sTokens, aset);
    let score = coverage * 100;
    if (oHit) score += 25;
    if (sHit) score += 15;
    if (s.y && qset.has(s.y)) score += 12;
    if (s.l) score += 4;
    if (tt.length === 1 && !oHit && !sHit) score -= 35;   // 1-word title alone is weak
    scored.push({ song: s, score, coverage, oHit, sHit, hit });
  }
  // Tie-break toward SPECIFICITY: when two titles both fully match the query, prefer
  // the one that explains MORE of it (more meaningful tokens matched). Without this a
  // subset title hijacks the longer real one on the alphabetical tiebreak — e.g.
  // "La milonga de Buenos Aires" lost to "Buenos Aires", "La última curda" to "La última".
  scored.sort((a, b) =>
    b.score - a.score || b.hit - a.hit ||
    (b.song.l || 0) - (a.song.l || 0) || a.song.tf.localeCompare(b.song.tf));
  return scored.slice(0, limit);
}

// Is the top candidate trustworthy enough to show automatically?
export function isConfident(cand) {
  return !!cand && cand.coverage >= 0.8 && (cand.oHit || cand.sHit);
}

// Should a (non-confirmed) title match be surfaced at all? Confident matches always;
// otherwise only when the candidate's OWN title is substantially present (coverage
// >= 0.7). A weak partial match — e.g. a junk-titled rip that merely echoes the
// query's orchestra/singer label, or a song that isn't actually in the library —
// abstains instead of showing a wrong muted pill. (Sound-detect is the backstop.)
export function isShowable(cand) {
  return !!cand && (isConfident(cand) || cand.coverage >= 0.7);
}

// Tandas (curated sets), lazy-loaded — only the side panel needs them.
let _tandas = null;
let _tandaBySong = null;
async function ensureTandas() {
  if (_tandas) return;
  const t = await fetch(dataURL("tandas.json")).then(r => r.json()).catch(() => ({ tandas: [], byId: {} }));
  _tandas = t.tandas || [];
  _tandaBySong = t.byId || {};
}

// The curated tandas this song appears in (empty if none).
export async function tandasForSong(id) {
  await ensureTandas();
  id = resolveId(id);
  return (_tandaBySong[id] || []).map(i => _tandas[i]).filter(Boolean);
}

// Songs in the library by an orchestra or singer (exact folded-name match) —
// powers the hover card's "N recordings in your library" + samples.
export function byArtist(name, limit = 3) {
  const f = fold(name);
  if (!f || !_songs) return { count: 0, samples: [] };
  const hits = _songs.filter(s => s.of === f || s.sf === f);
  hits.sort((a, b) => (b.l || 0) - (a.l || 0) || (a.y || "").localeCompare(b.y || ""));
  return {
    count: hits.length,
    samples: hits.slice(0, limit).map(s => ({ id: s.id, t: s.t, o: s.o, s: s.s, y: s.y })),
  };
}

export async function getLyrics(id) {
  id = resolveId(id);
  const shard = id.length > 1 ? id[1] : "0";
  if (!_shardCache.has(shard)) {
    _shardCache.set(shard,
      fetch(dataURL("lyrics/" + shard + ".json")).then(r => r.json()).catch(() => ({})));
  }
  const obj = await _shardCache.get(shard);
  return obj[id] || null;
}

// Per-line lyric timestamps for online sync: {d: appDurationSec, t: [perEsLineSec|null]}.
// Sharded like lyrics. Absent (or older snapshot without align/) -> null -> static lyrics.
const _alignCache = new Map();
export async function getAlignment(id) {
  id = resolveId(id);
  const shard = id.length > 1 ? id[1] : "0";
  if (!_alignCache.has(shard)) {
    _alignCache.set(shard,
      fetch(dataURL("align/" + shard + ".json")).then(r => r.json()).catch(() => ({})));
  }
  const obj = await _alignCache.get(shard);
  return obj[id] || null;
}
