// Lyric timing sync: warp the app's per-line timestamps onto the recording playing
// online, then highlight + auto-scroll the current line in time with the media.
//
// The online recording is a DIFFERENT transfer than the El Eje library file — a
// different intro length, total duration, sometimes a slightly different speed — so
// the app's absolute timestamps don't line up. We WARP them with an affine map
//   online_time = onlineStart + (app_time - appStart) * scale,  scale = onlineSpan / appSpan
// anchored on the audible span of each side (start/end of sound), mirroring the app's
// SilenceAnalyzer. On YouTube we read <video>.currentTime/.duration directly and take
// the whole file as the audible span (onlineStart=0, onlineEnd=duration) — no audio
// capture (rerouting cross-origin media risks muting it and yields tainted data). The
// app side uses appStart=0, appEnd=<exported track duration>. With matching trims this
// reduces to "play the app timeline as-is"; when durations differ it corrects the drift.
//
// This module is pure (no DOM) except for the LyricSync controller, which takes an
// injected media element so it stays node-testable. Loaded by content.js via dynamic
// import() and by tests/sync.test.mjs directly.

// A lyric line is a separator (instrumental/blank marker) when it's empty or made
// only of dots / ellipses / middots — the lyric sheets use long "........." runs for
// instrumental passages. We don't render those (the user doesn't want dot-only lines,
// especially in the translation).
export function isSeparatorLine(s) {
  const t = (s == null ? "" : String(s)).trim();
  return !t || !/[^.…·• \s]/.test(t);
}

// Turn a lyrics record {es,en} into interleaved line groups for display: each group
// is { i, es, en } where `i` is the ORIGINAL es-line index (so per-line timings still
// apply after filtering), `es` the Spanish line, and `en` its English translation
// (when es/en are line-parallel and the en line isn't itself a separator). Separator/
// blank Spanish lines are dropped entirely.
export function lyricGroups(ly) {
  if (!ly || !ly.es) return [];
  const es = ly.es.split("\n");
  const en = (ly.en || "").split("\n");
  const parallel = en.length === es.length;
  const groups = [];
  for (let i = 0; i < es.length; i++) {
    if (isSeparatorLine(es[i])) continue;
    const enText = parallel && !isSeparatorLine(en[i]) ? en[i].trim() : "";
    groups.push({ i, es: es[i].trim(), en: enText });
  }
  return groups;
}

// Build an affine app_time -> online_time mapping from two anchor pairs:
// appStart->onlineStart and appEnd->onlineEnd. Returns a clamped warp function, or
// null when there is no usable online frame (caller then shows static lyrics).
// If appEnd is unknown, scale falls back to 1 (offset-only) — better than nothing
// for a same-master upload, and still bounded by the clamp.
export function makeWarp({ appStart = 0, appEnd = null, onlineStart = 0, onlineEnd = null } = {}) {
  if (!Number.isFinite(onlineStart)) onlineStart = 0;
  if (!Number.isFinite(onlineEnd) || onlineEnd <= onlineStart) return null;
  let scale = 1;
  if (Number.isFinite(appStart) && Number.isFinite(appEnd) && appEnd > appStart) {
    scale = (onlineEnd - onlineStart) / (appEnd - appStart);
  } else {
    appStart = 0;
  }
  return function warp(appTime) {
    if (!Number.isFinite(appTime)) return null;
    const t = onlineStart + (appTime - appStart) * scale;
    return t < onlineStart ? onlineStart : t > onlineEnd ? onlineEnd : t;
  };
}

// Map a per-line app-time array (nulls = untimed lines) to online times.
export function mapLineTimes(times, warp) {
  if (!Array.isArray(times)) return [];
  if (!warp) return times.map(() => null);
  return times.map((t) => (t == null ? null : warp(t)));
}

// Index of the latest line whose online time is <= currentTime; -1 before the first
// timed line. Untimed (null) lines are skipped. The timed entries are assumed
// non-decreasing (the build guarantees monotonic times), so a forward scan with an
// early break is correct and plenty fast for a few dozen lines.
export function activeLineIndex(onlineTimes, currentTime) {
  if (!Array.isArray(onlineTimes) || !Number.isFinite(currentTime)) return -1;
  let ans = -1;
  for (let i = 0; i < onlineTimes.length; i++) {
    const t = onlineTimes[i];
    if (t == null) continue;
    if (t <= currentTime) ans = i;
    else break;
  }
  return ans;
}

// Drives the active-line highlight off a media element's playback clock. `media` is
// any HTMLMediaElement-like object exposing currentTime and add/removeEventListener
// (so tests can pass a mock). `onlineTimes` is the warped per-line time array.
// `onActive(index)` fires only when the active line changes (-1 = none yet).
export class LyricSync {
  constructor(media, onlineTimes, onActive) {
    this.media = media;
    this.onlineTimes = onlineTimes || [];
    this.onActive = onActive || (() => {});
    this.cur = -2; // force a first onActive even if it resolves to -1
    this._onTick = this._onTick.bind(this);
  }

  start() {
    for (const ev of ["timeupdate", "seeking", "seeked", "play", "playing"]) {
      this.media.addEventListener(ev, this._onTick);
    }
    this._onTick();
    return this;
  }

  stop() {
    for (const ev of ["timeupdate", "seeking", "seeked", "play", "playing"]) {
      this.media.removeEventListener(ev, this._onTick);
    }
    return this;
  }

  // Recompute and re-emit (e.g. after the times array is replaced).
  refresh(onlineTimes) {
    if (onlineTimes) this.onlineTimes = onlineTimes;
    this.cur = -2;
    this._onTick();
  }

  _onTick() {
    const i = activeLineIndex(this.onlineTimes, this.media.currentTime);
    if (i !== this.cur) {
      this.cur = i;
      this.onActive(i);
    }
  }
}
