// Crowd-sourced lyric-timing authoring: let a viewer tap along on a YouTube watch
// page to capture per-line timestamps for the recording playing, plus the song's
// start/end in that upload. Stored locally (chrome.storage.local) keyed by video id;
// exported as JSON from the side panel so the owner can fold the contributions back
// into the desktop app's alignments.json (warping online→app time via start/end).
//
// This module is the pure core + storage. The overlay (content.js) renders the UI and
// drives a TimingSession against the page's <video>. Loaded via dynamic import, and by
// the side panel + node tests directly.

export const CONTRIB_SCHEMA = 1;
const STORE_KEY = "eleje_timings";

// Editable timing state for one recording. `n` = number of (displayed) lyric lines.
// `times[i]` is the online time (seconds) at which line i begins, or null. A cursor
// tracks the next line to stamp via the tap key; clicking a line stamps it directly.
export class TimingSession {
  constructor(n, prior) {
    this.n = n;
    this.times = new Array(n).fill(null);
    this.start = null;   // song start in the upload (audible/first sung), seconds
    this.end = null;     // song end, seconds
    if (prior) this._restore(prior);
    this.cursor = this._firstUnstamped();
  }

  _restore(prior) {
    if (Number.isFinite(prior.startSec)) this.start = prior.startSec;
    if (Number.isFinite(prior.endSec)) this.end = prior.endSec;
    for (const l of prior.lines || []) {
      if (l && Number.isInteger(l.i) && l.i >= 0 && l.i < this.n && Number.isFinite(l.t)) {
        this.times[l.i] = l.t;
      }
    }
  }

  _firstUnstamped() {
    for (let i = 0; i < this.n; i++) if (this.times[i] == null) return i;
    return this.n;   // all stamped → cursor parks past the end
  }

  // Stamp the cursor line at time `t`, then advance the cursor to the next line.
  // Returns the line index that was stamped (or -1 if the cursor is past the end).
  stampNext(t) {
    if (!Number.isFinite(t) || this.cursor >= this.n) return -1;
    const i = this.cursor;
    this.times[i] = t;
    this.cursor = i + 1;
    return i;
  }

  // Directly (re)stamp a specific line, and park the cursor just after it.
  stampLine(i, t) {
    if (i < 0 || i >= this.n || !Number.isFinite(t)) return false;
    this.times[i] = t;
    this.cursor = i + 1;
    return true;
  }

  clearLine(i) {
    if (i < 0 || i >= this.n) return false;
    this.times[i] = null;
    if (i < this.cursor) this.cursor = i;
    return true;
  }

  // Undo the most recent stamp (the line just before the cursor).
  undo() {
    let i = this.cursor - 1;
    while (i >= 0 && this.times[i] == null) i--;
    if (i < 0) return -1;
    this.times[i] = null;
    this.cursor = i;
    return i;
  }

  markStart(t) { if (Number.isFinite(t)) this.start = t; }
  markEnd(t) { if (Number.isFinite(t)) this.end = t; }

  stampedCount() { return this.times.reduce((n, t) => n + (t != null ? 1 : 0), 0); }
  isEmpty() { return this.stampedCount() === 0 && this.start == null && this.end == null; }

  // Build the stored contribution. `lineText` maps a line index to its Spanish text.
  // `source` is "youtube" (timed against a video) or "panel" (timed in the side panel
  // against a stopwatch, no video) — defaults from whether a videoId is present.
  build({ videoId, url, videoTitle, song, onlineDuration, lineText, source }) {
    const lines = [];
    for (let i = 0; i < this.n; i++) {
      if (this.times[i] != null) {
        lines.push({ i, text: (lineText && lineText[i]) || "", t: round(this.times[i]) });
      }
    }
    lines.sort((a, b) => a.t - b.t);
    return {
      v: CONTRIB_SCHEMA,
      source: source || (videoId ? "youtube" : "panel"),
      videoId: videoId || null,
      url: url || null,
      videoTitle: videoTitle || null,
      songId: (song && song.id) || null,
      song: song ? { t: song.t, o: song.o, s: song.s, y: song.y } : null,
      onlineDuration: Number.isFinite(onlineDuration) ? round(onlineDuration) : null,
      startSec: this.start != null ? round(this.start) : null,
      endSec: this.end != null ? round(this.end) : null,
      lines,
      updatedAt: stampNow(),
    };
  }
}

// Storage key for a contribution: the YouTube video id, else "song:<songId>" for a
// side-panel (no-video) timing. Lets both surfaces share one store + export.
export function contribKey(c) {
  if (!c) return null;
  if (c.videoId) return c.videoId;
  if (c.songId) return "song:" + c.songId;
  return null;
}

// ---- transfer a saved timing to ANOTHER upload of the same song ----------
// We can't read a YouTube video's audio (cross-origin → analyser reads silence), so
// we can't detect the new upload's start/end. Instead we warp the saved timing's
// SUNG SPAN [start..end] onto the new video's [0..duration] — line-level accurate for
// a normal single-song upload; the user can Mark start/end on a video for exactness.

// Pick the best saved contribution for a song (most timed lines), excluding one video.
export function pickSongContribution(contributions, songId, excludeVideoId) {
  if (!songId || !Array.isArray(contributions)) return null;
  const cands = contributions.filter(c =>
    c && c.songId === songId && Array.isArray(c.lines) && c.lines.length &&
    (!excludeVideoId || c.videoId !== excludeVideoId));
  cands.sort((a, b) => b.lines.length - a.lines.length);
  return cands[0] || null;
}

// Per-ORIGINAL-es-line times (object {esIndex: seconds}) for `targetDuration`.
// CRUCIAL: the authored times already include the instrumental intro (singing does NOT
// start at 0), so we must PRESERVE that offset — we warp 0-anchored by the duration
// ratio (same as the bundled-alignment path), which keeps the intro and corrects a
// speed-/length-different transfer; identity when the source duration is unknown.
// (We do NOT subtract the marked start — that would slide the first line to 0 and make
// the singing appear to begin at the very start.) Returns null if not warpable.
export function transferredLineTimes(c, targetDuration) {
  if (!c || !Array.isArray(c.lines)) return null;
  const ts = c.lines.filter(l => Number.isFinite(l.t));
  if (ts.length < 2) return null;
  const refDur = Number.isFinite(c.onlineDuration) && c.onlineDuration > 0 ? c.onlineDuration : null;
  const scale = (refDur && targetDuration > 0) ? targetDuration / refDur : 1;
  const out = {};
  for (const l of ts) {
    let v = l.t * scale;
    if (targetDuration > 0) v = Math.min(targetDuration, v);
    out[l.i] = Math.max(0, round(v));
  }
  return out;
}

function round(x) { return Math.round(x * 100) / 100; }
// Date is unavailable in some sandboxes; in the browser it's fine. Guarded so the
// pure logic stays testable even where Date.now is stubbed out.
function stampNow() { try { return Date.now(); } catch { return 0; } }

// ---- storage (chrome.storage.local) ---------------------------------------
// All helpers tolerate a missing chrome.storage (return empty / no-op) so callers
// and tests don't have to guard.

function store() {
  return (typeof chrome !== "undefined" && chrome.storage && chrome.storage.local) || null;
}

async function readAll() {
  const s = store();
  if (!s) return {};
  const o = await s.get(STORE_KEY);
  return (o && o[STORE_KEY]) || {};
}

export async function loadContribution(key) {
  if (!key) return null;
  const all = await readAll();
  return all[key] || null;
}

export async function saveContribution(c) {
  const s = store();
  const key = contribKey(c);
  if (!s || !key) return false;
  const all = await readAll();
  all[key] = c;
  await s.set({ [STORE_KEY]: all });
  // Opt-in: auto-share to the server (no-op unless the user enabled sharing). Dynamic
  // import keeps this module (and its node tests) free of any hard upload dependency.
  try { (await import("./upload.js")).maybeSubmit(c); } catch { /* ignore */ }
  return true;
}

export async function deleteContribution(key) {
  const s = store();
  if (!s || !key) return false;
  const all = await readAll();
  if (!(key in all)) return false;
  delete all[key];
  await s.set({ [STORE_KEY]: all });
  return true;
}

export async function allContributions() {
  const all = await readAll();
  return Object.values(all).sort((a, b) => (b.updatedAt || 0) - (a.updatedAt || 0));
}

export async function clearContributions() {
  const s = store();
  if (s) await s.set({ [STORE_KEY]: {} });
}
