/* RECORD STORE GUY — FMV QTE player. Vanilla ES6, no deps. Spec: ENGINE.md */
"use strict";

// feel-tuning constants (see ENGINE.md)
const WINDOW_DEFAULT = 0.7; // s
const EARLY_GRACE = 0.25;   // s before window open where input = fail
const DEATH_HOLD = 600;     // ms on last death frame before retry
const CUE_LEAD = 0.0;       // s cue shows before window opens
const HOLD_SLACK = 0.35;    // s after a hold window opens before "not holding" = fail
const RESTART_TIGHTEN = 0.15; // s shaved off window closes per endurance restart
const REPLAY_CAP = 8;       // max died-in scenes replayed before T7

const GLYPH = { left: "◀", right: "▶", up: "▲", down: "▼", action: "●" };
const KEYMAP = { ArrowLeft: "left", ArrowRight: "right", ArrowUp: "up", ArrowDown: "down",
                 " ": "action", Enter: "action" };
const MIRROR = { left: "right", right: "left" };

const $ = (id) => document.getElementById(id);
const vids = [$("vidA"), $("vidB")];
let cur = 0; // index of active video

const G = {
  data: null, state: "ATTRACT", scene: null, sceneId: null, retryId: null,
  wi: 0, windows: [], lives: 0, score: 0, checkpoint: null,
  labels: new Set(), deathLog: [], replay: null, // null = normal play, [] = replay mode
  restarts: 0, pendingGoto: null, mirror: false,
  held: new Set(), // inputs currently held down (for hold windows)
  debug: new URLSearchParams(location.search).has("debug"),
  blobs: new Map(), // url -> objectURL
};

// ---------- boot ----------
async function boot() {
  G.data = await (await fetch("scenes.json?v=" + Date.now())).json();
  $("hiscore").textContent = "HI-SCORE " + (localStorage.rsg_hiscore || 0);
  if (G.debug) { initDebug(); window.RSG = { G, input, vid }; }
  const first = resolve(G.data.start);
  preload(first.clip);
  preload(first.death);
  attractPlay();
  addEventListener("keydown", onKey);
  addEventListener("keyup", (e) => { const d = KEYMAP[e.key]; if (d) G.held.delete(d); });
  initTouch();
  requestAnimationFrame(tick);
}

const vurl = (url) => url + "?v=" + (G.data?.meta?.version || 0); // cache-bust per data version
const resolve = (id) => { // follow random -> concrete variant scene
  let s = G.data.scenes[id];
  if (s.random) s = G.data.scenes[s.random[Math.floor(Math.random() * s.random.length)]];
  return s;
};

async function preload(url) {
  if (!url || G.blobs.has(url)) return G.blobs.get(url);
  try {
    const b = await (await fetch(vurl(url))).blob();
    const o = URL.createObjectURL(b);
    G.blobs.set(url, o);
    return o;
  } catch { return url; } // fall back to streaming
}

// ---------- clip playback (A/B swap) ----------
function playClip(url, onEnded) {
  const next = vids[1 - cur];
  next.src = G.blobs.get(url) || vurl(url);
  next.onended = null;
  next.currentTime = 0;
  const p = next.play();
  if (p) p.catch(() => {}); // pre-gesture autoplay rejection is fine
  next.onended = onEnded;
  next.classList.add("active");
  next.classList.toggle("mirror", G.mirror);
  vids[cur].classList.remove("active");
  vids[cur].pause();
  cur = 1 - cur;
  return next;
}
const vid = () => vids[cur];

// ---------- scene flow ----------
function attractPlay() { // looping mood video behind the title screen
  G.mirror = false;
  const v = playClip("clips/attract.mp4", null);
  v.loop = true;
  v.muted = true;
}

function startGame() {
  G.lives = G.data.meta.lives;
  G.score = 0;
  G.labels.clear();
  G.deathLog = [];
  G.replay = null;
  G.checkpoint = G.data.start;
  vids.forEach((v) => { v.muted = false; v.loop = false; });
  $("attract").classList.add("hidden");
  playScene(G.data.start);
}

function playScene(id, restarts = 0) {
  const parent = G.data.scenes[id];
  const s = resolve(id);
  G.state = "PLAYING"; G.sceneId = id; G.retryId = id; G.scene = s;
  G.wi = 0; G.restarts = restarts; G.pendingGoto = null;
  G.mirror = !!(G.replay && s.mirrorOk); // replayed scenes come back flipped — the Dead Wax remembers
  if (parent.checkpoint) G.checkpoint = id;
  const shave = restarts * RESTART_TIGHTEN;
  G.windows = s.windows.map((w) => ({
    t: w.t[1] != null ? [w.t[0], Math.max(w.t[0] + 0.25, w.t[1] - shave)]
                      : [w.t[0], w.t[0] + WINDOW_DEFAULT],
    input: w.input, choices: w.choices, hold: w.hold,
    cue: w.cue !== false, cued: false, done: false,
  }));
  // needledrop / collect are one-shots, disabled during the replay gauntlet
  G.nd = (!G.replay && s.needledrop) ? { ...s.needledrop, done: false, cued: false } : null;
  G.col = (!G.replay && s.collect && !G.labels.has(s.collect.label))
    ? { ...s.collect, done: false } : null;
  hud();
  cueHide(); cueNd(false);
  playClip(s.clip, onSceneClipEnd);
  // preload likely-next clips: this death, then success path
  preload(s.death).then(() => {
    if (G.nd) preload(G.nd.clip);
    const n = s.onSuccess;
    if (n && n !== "WIN" && n !== "REPLAY") {
      const ns = resolve(n);
      preload(ns.clip); preload(ns.death);
    } else preload("clips/win.mp4");
  });
  if (G.debug) debugScene();
}

function onSceneClipEnd() {
  if (G.state !== "PLAYING") return;
  if (G.wi < G.windows.length) return fail("clip ended mid-window"); // safety net
  sceneCleared();
}

function sceneCleared() {
  cueHide();
  G.score += 1000 + (G.scene.score || 0);
  if (G.scene.label) grabLabel(G.scene.label, 0);
  hud();
  advance(G.pendingGoto || G.scene.onSuccess);
}

function advance(next) {
  if (G.replay) return replayNext();
  if (next === "WIN") return win();
  if (next === "REPLAY") return startReplay();
  playScene(next);
}

// ---------- replay queue ("the Dead Wax remembers") ----------
function startReplay() {
  const elig = (id) => {
    const s = G.data.scenes[id];
    return s && !s.random && !s.windows.some((w) => w.choices);
  };
  G.replay = G.deathLog.filter(elig).slice(0, REPLAY_CAP);
  if (!G.replay.length) { G.replay = null; return playScene("t7s1"); }
  replayNext();
}
function replayNext() {
  G.state = "CUT";
  G.mirror = false;
  playClip("clips/needle_skip.mp4", () => {
    const id = G.replay.shift();
    if (id) return playScene(id);
    G.replay = null;
    playScene("t7s1");
  });
}

function fail(why, deathClip, gotoAfter) {
  if (G.state !== "PLAYING") return;
  cueHide();
  const s = G.scene;
  // soft fail (t5s4 platter lock): no death, no life lost, just spat backwards
  if (s.failGoto && !deathClip) {
    G.state = "CUT";
    if (s.death) return playClip(s.death, () => playScene(s.failGoto));
    return playScene(s.failGoto);
  }
  // endurance (sh4): restart the clip with tighter windows until maxRestarts
  if (s.onMiss === "restart" && G.restarts + 1 < (s.maxRestarts || 3)) {
    G.state = "CUT";
    return playScene(G.retryId, G.restarts + 1);
  }
  G.state = "DEATH";
  if (!G.replay && !G.deathLog.includes(G.retryId)) G.deathLog.push(G.retryId);
  if (G.debug) $("dbg-state").textContent = "DEATH (" + why + ")";
  playClip(deathClip || s.death, () => {
    setTimeout(() => {
      if (G.state !== "DEATH") return; // scene was jumped/reset while death played
      G.lives--; G.score = Math.max(0, G.score - 100); hud();
      if (G.lives <= 0) return gameOver();
      playScene(gotoAfter || G.retryId); // retry (or branch: misfile dimension)
    }, DEATH_HOLD);
  });
}

function win() {
  G.state = "WIN";
  G.mirror = false;
  saveHi();
  playClip("clips/win.mp4", () => {
    if (G.labels.size >= 7)
      return playClip("clips/stinger.mp4",
        () => showEnd("RECORD STORE GUY WILL RETURN", "all 7 white labels · score " + G.score));
    showEnd("YOU SURVIVED", "score " + G.score);
  });
}
function gameOver() {
  G.state = "GAMEOVER";
  G.mirror = false;
  saveHi();
  playClip("clips/gameover.mp4", () => showEnd("GAME OVER", "score " + G.score));
}
function showEnd(title, sub) {
  const m = $("msg");
  m.innerHTML = "<h2>" + title + "</h2><p class='sub'>" + sub + "</p><p class='blink'>PRESS ANY KEY</p>";
  m.classList.remove("hidden");
  G.state = "END";
}
function toAttract() {
  $("msg").classList.add("hidden");
  $("hiscore").textContent = "HI-SCORE " + (localStorage.rsg_hiscore || 0);
  $("attract").classList.remove("hidden");
  attractPlay();
  G.state = "ATTRACT";
}
function saveHi() {
  if (G.score > (+localStorage.rsg_hiscore || 0)) localStorage.rsg_hiscore = G.score;
}

// ---------- QTE core ----------
// qteCheck is driven from THREE sources so throttling can't starve it:
// rAF (smooth when focused), video timeupdate (fires while playing even
// unfocused), and a 100ms interval backup.
const flip = (d) => (G.mirror && MIRROR[d]) || d;

function qteCheck() {
  if (G.state !== "PLAYING") return;
  const t = vid().currentTime;
  const w = G.windows[G.wi];
  if (w) {
    if (!w.cued && w.cue && t >= w.t[0] - CUE_LEAD) { w.cued = true; cueShow(w); }
    if (w.hold) {
      if (t > w.t[0] + HOLD_SLACK && t < w.t[1] && !G.held.has(flip(w.input)))
        { w.done = "failed"; return fail("let go"); }
      if (t >= w.t[1]) { // held all the way through = pass
        w.done = "passed"; G.wi++; G.score += 100; hud(); cueGood(flip(w.input));
        if (G.debug) debugScene();
      }
    } else if (t >= w.t[1]) { w.done = "failed"; return fail("too late"); }
  }
  if (G.nd && !G.nd.done) { // needle-drop flash: optional, ignoring is not a fail
    if (!G.nd.cued && t >= G.nd.t[0]) { G.nd.cued = true; cueNd(true); }
    if (G.nd.cued && t >= G.nd.t[1]) { G.nd.done = true; cueNd(false); }
  }
  if (G.debug) debugTick(t);
}
function tick() { qteCheck(); requestAnimationFrame(tick); }
setInterval(qteCheck, 100);
vids.forEach((v) => v.addEventListener("timeupdate", qteCheck));

function input(dir) {
  if (G.state === "ATTRACT") return startGame();
  if (G.state === "END") return toAttract();
  if (G.state !== "PLAYING") return;
  G.held.add(dir);
  const t = vid().currentTime;
  // needle-drop: action during the flash branches to the power-up clip
  if (G.nd && !G.nd.done && dir === "action" && t >= G.nd.t[0] && t <= G.nd.t[1]) {
    G.nd.done = true; cueNd(false); cueHide();
    G.score += G.nd.bonus || 500;
    if (G.nd.label) grabLabel(G.nd.label);
    hud();
    G.state = "CUT";
    const target = G.nd.skipTo || G.scene.onSuccess;
    return playClip(G.nd.clip, () => { G.score += 1000; hud(); advance(target); });
  }
  // white label: unprompted input during a background flash — wrong guesses ignored
  if (G.col && !G.col.done && dir === G.col.input && t >= G.col.t[0] && t <= G.col.t[1]) {
    G.col.done = true;
    return grabLabel(G.col.label);
  }
  const w = G.windows[G.wi];
  if (!w) return; // windows all cleared, clip finishing
  if (w.hold) { // the hold press itself; wrong direction still kills
    if (dir !== flip(w.input) && t >= w.t[0]) { w.done = "failed"; fail("wrong input"); }
    return;
  }
  if (t < w.t[0] - EARLY_GRACE) return;            // idle fidget: ignore
  if (t < w.t[0]) { w.done = "failed"; return fail("too early"); } // Dragon's Lair rule
  if (w.choices) {
    const c = w.choices[dir];
    if (!c) { w.done = "failed"; return fail("wrong choice"); }
    w.done = "passed"; G.wi++;
    if (c.death) return fail("bad choice", c.death, c.goto);
    G.pendingGoto = c.goto; G.score += 100; hud(); cueGood(dir);
    if (G.debug) debugScene();
    return;
  }
  if (dir !== flip(w.input)) { w.done = "failed"; return fail("wrong input"); }
  // pass
  w.done = "passed"; G.wi++; G.score += 100; hud();
  cueGood(dir);
  if (G.debug) debugScene();
}

function grabLabel(n, points = 250) {
  G.labels.add(n);
  G.score += points;
  hud();
  toast("WHITE LABEL " + G.labels.size + "/7");
}

// ---------- input sources ----------
function onKey(e) {
  if (e.repeat) return;
  if (G.state === "ATTRACT" || G.state === "END") return input("action");
  const d = KEYMAP[e.key];
  if (d) { e.preventDefault(); input(d); }
  if (G.debug) debugKeys(e);
}

function initTouch() {
  // ponytail: swipes+taps only; hold windows are keyboard-first for now,
  // add touch-hold (touchstart..touchend spanning the window) if mobile matters
  let sx, sy, st;
  addEventListener("touchstart", (e) => {
    sx = e.touches[0].clientX; sy = e.touches[0].clientY; st = Date.now();
  }, { passive: true });
  addEventListener("touchend", (e) => {
    const dx = e.changedTouches[0].clientX - sx, dy = e.changedTouches[0].clientY - sy;
    let d;
    if (Math.hypot(dx, dy) < 30 && Date.now() - st < 400) d = "action"; // tap
    else d = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? "right" : "left") : (dy > 0 ? "down" : "up");
    input(d);
    G.held.delete(d); // touches don't hold
  }, { passive: true });
}

// ---------- HUD + cue ----------
function hud() {
  $("lives").textContent = "●".repeat(Math.max(0, G.lives));
  $("score").textContent = G.score;
  $("labels").textContent = "◉".repeat(G.labels.size) + "○".repeat(7 - G.labels.size);
}
function cueShow(w) {
  const c = $("cue");
  if (w.choices) c.textContent = Object.keys(w.choices).map((d) => GLYPH[d]).join(" ");
  else c.textContent = GLYPH[flip(w.input)];
  c.className = "show" + (w.hold ? " hold" : "");
}
function cueHide() { $("cue").className = ""; }
function cueGood(dir) { const c = $("cue"); c.textContent = GLYPH[dir]; c.className = "good"; }
function cueNd(on) { $("nd").className = on ? "show" : ""; }
function toast(text) {
  const el = $("toast");
  el.textContent = text;
  el.className = "show";
  setTimeout(() => (el.className = ""), 1500);
}

// ---------- debug mode (?debug=1) ----------
function initDebug() {
  $("debug").classList.remove("hidden");
  const sel = $("dbg-scene");
  for (const id of Object.keys(G.data.scenes)) {
    const o = document.createElement("option"); o.value = o.textContent = id; sel.appendChild(o);
  }
  sel.onchange = () => { $("attract").classList.add("hidden"); $("msg").classList.add("hidden");
    if (!G.lives) G.lives = G.data.meta.lives; playScene(sel.value); };
}
function debugScene() {
  const tl = $("dbg-timeline");
  tl.querySelectorAll(".dbg-win").forEach((n) => n.remove());
  const d = vid().duration || 5;
  G.windows.forEach((w) => {
    const b = document.createElement("div");
    b.className = "dbg-win" + (w.done ? " " + w.done : "");
    b.style.left = (w.t[0] / d) * 100 + "%";
    b.style.width = ((w.t[1] - w.t[0]) / d) * 100 + "%";
    tl.appendChild(b);
  });
  $("dbg-scene").value = G.sceneId;
}
function debugTick(t) {
  const d = vid().duration || 5;
  $("dbg-playhead").style.left = (t / d) * 100 + "%";
  $("dbg-time").textContent = t.toFixed(2) + "s / " + d.toFixed(2) + "s  [" + G.sceneId + " w" + G.wi + "]";
  $("dbg-state").textContent = G.state;
}
function debugKeys(e) { // , . frame-step while paused
  if (e.key === ",") { vid().pause(); vid().currentTime -= 1 / 24; }
  if (e.key === ".") { vid().pause(); vid().currentTime += 1 / 24; }
  if (e.key === "p") vid().paused ? vid().play() : vid().pause();
}

boot();
