import { test, expect, Page } from "@playwright/test";

// These assert the two behaviors the operator asked for:
//   1. zooming plays the morph matching the clip we land on, and
//   2. after landing the clip is LOCKED — it does not change while parked.
//
// The played morph paths are read from window.__hefMorphs (a diagnostic seam in
// advance()), which is reliable even when the morph is served from an in-memory
// blob cache. The locked clip is read from the Dev-Mode "#scale-name" readout
// ("<scale> · <clip>  (i/n)").

async function boot(page: Page) {
  await page.addInitScript(() => localStorage.setItem("hef.devMode", "1"));
  await page.goto("/");
  // Wait for the "Loading Universe" preload to finish (splash gets class "done").
  await page.waitForFunction(
    () => document.querySelector("#loading")?.classList.contains("done"),
    null,
    { timeout: 45_000 },
  );
  await expect(page.locator("#scale-name")).toContainText("cosmos");
}

async function morphs(page: Page): Promise<string[]> {
  return await page.evaluate(() => (window as any).__hefMorphs || []);
}

async function wheelOnStage(page: Page, deltaY: number) {
  await page.locator("#stage").dispatchEvent("wheel", { deltaY });
}

// The Audio control is a 0–10 level slider; set it to full + fire `input` directly
// (headless Chromium relaxes the autoplay gesture requirement).
async function enableAudio(page: Page) {
  await page.evaluate(() => {
    const c = document.querySelector("#audio") as HTMLInputElement;
    c.value = "10";
    c.dispatchEvent(new Event("input", { bubbles: true }));
  });
}

// The Video toggle gates the base-clip loop (update() only loads media when video is
// shown). Same hidden-checkbox pattern.
async function enableVideo(page: Page) {
  await page.evaluate(() => {
    const c = document.querySelector("#visual") as HTMLInputElement;
    c.checked = true;
    c.dispatchEvent(new Event("change", { bubbles: true }));
  });
}

test("two audio elements exist for crossfade", async ({ page }) => {
  await boot(page);
  const n = await page.evaluate(() => document.querySelectorAll("audio#aud, audio#aud-b").length);
  expect(n).toBe(2);
});

test("a new volume fade supersedes a stale one (audio not silenced after landing)", async ({ page }) => {
  await boot(page);
  // Reproduce the race: a fade-to-0 in flight, then the same element is brought back
  // up (as happens when a faded idle element becomes the newly-landed active scale).
  // The stale fade must NOT drag it back to 0.
  const vol = await page.evaluate(async () => {
    const a = document.querySelector("#aud") as HTMLAudioElement;
    a.volume = 1;
    (window as any).__hefFade(a, 0, 600);              // start a slow fade to 0
    await new Promise((r) => setTimeout(r, 120));      // let it ramp partway down
    (window as any).__hefFade(a, 0.8, 90);             // new fade up — must cancel the old one
    await new Promise((r) => setTimeout(r, 350));      // well past both fades
    return a.volume;
  });
  expect(vol).toBeGreaterThan(0.7);                    // ended near 0.8, not dragged to 0
});

test("audio is a 0-10 level dial", async ({ page }) => {
  await boot(page);
  const ctl = await page.evaluate(() => {
    const c = document.querySelector("#audio") as HTMLInputElement;
    return { type: c.type, min: c.min, max: c.max };
  });
  expect(ctl.type).toBe("range");
  expect(ctl.min).toBe("0");
  expect(ctl.max).toBe("10");
});

test("boots silent (video off, audio 0); turning video on couples audio to 3/10", async ({ page }) => {
  await boot(page);
  // Boots SILENT — no un-gestured auto-start, so the browser autoplay block never bites.
  expect(await page.evaluate(() => (document.querySelector("#visual") as HTMLInputElement).checked)).toBe(false);
  expect(await page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("0");
  // Turning Video on (a click gesture) lifts the audio dial to a gentle 3/10.
  await enableVideo(page);
  await expect.poll(() => page.evaluate(() => (document.querySelector("#audio") as HTMLInputElement).value)).toBe("3");
  expect(await page.evaluate(() => document.querySelector("#audio-level-val")?.textContent)).toBe("3");
});

test("dragging the dial scrubs morph currentTime and audio gains", async ({ page }) => {
  await boot(page);
  await enableAudio(page);                                 // hidden custom toggle → set + dispatch change
  const box = (await page.locator("#dial").boundingBox())!;
  const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
  await page.mouse.move(cx, cy - 30);
  await page.mouse.down();
  await page.mouse.move(cx + 18, cy - 24, { steps: 8 });  // partial clockwise turn (~0.5 detent)
  // The currentTime seek is throttled to one rAF; wait for it to land.
  await page.waitForFunction(() => (document.querySelector("video") as HTMLVideoElement).currentTime > 0, null, { timeout: 5000 });
  const state = await page.evaluate(() => ({
    t: (document.querySelector("video") as HTMLVideoElement).currentTime,
    ga: (document.querySelector("#aud") as HTMLAudioElement).volume,
    gb: (document.querySelector("#aud-b") as HTMLAudioElement).volume,
  }));
  expect(state.t).toBeGreaterThan(0);          // morph scrubbed off frame 0
  expect(state.gb).toBeGreaterThan(0);         // next scale fading in
  expect(state.ga).toBeLessThan(1);            // current scale fading out
  await page.mouse.up();
});

test("wheel auto-scrubs one altitude and locks", async ({ page }) => {
  await boot(page);
  const start = (await page.locator("#scale-name").textContent())!;
  await wheelOnStage(page, 60);                          // wheel down = descend one altitude
  await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
  const landed = (await page.locator("#scale-name").textContent())!;
  expect(landed).toContain("orbit");
  await page.waitForTimeout(1500);
  expect(await page.locator("#scale-name").textContent()).toBe(landed);    // locked
});

test("auto-scrub speed is constant per altitude (a 2-step jump takes ~2x a 1-step)", async ({ page }) => {
  await boot(page);
  async function timeJump(deltaY: number, target: string): Promise<number> {
    const t0 = await page.evaluate(() => performance.now());
    await page.locator("#stage").dispatchEvent("wheel", { deltaY });
    await page.waitForFunction((tgt) => !!document.querySelector("#scale-name")?.textContent?.includes(tgt), target, { timeout: 20000 });
    return (await page.evaluate(() => performance.now())) - t0;
  }
  const t1 = await timeJump(60, "orbit");    // cosmos -> orbit (1 altitude)
  const t2 = await timeJump(120, "coast");   // orbit -> coast (2 altitudes; ring is cosmos·orbit·sky·coast·reef·abyss)
  const ratio = t2 / t1;
  expect(ratio).toBeGreaterThan(1.5);        // proportional to distance, not a fixed total
  expect(ratio).toBeLessThan(2.7);           // ~2x, not more
});

test("a landed clip loops from the morph-tail offset (no jump-back to frame 0)", async ({ page }) => {
  await boot(page);
  await enableVideo(page);                                // base loop only loads when video is shown
  const start = (await page.locator("#scale-name").textContent())!;
  await wheelOnStage(page, 60);
  await page.waitForFunction((s) => document.querySelector("#scale-name")?.textContent !== s, start, { timeout: 20000 });
  // After landing, the dedicated loop element (#vid-loop) is armed (loopTail="1") and
  // playing from the tail (~3s) — preloaded during the scrub, so the landing was a
  // source swap, not a reload that restarts at 0.
  await page.waitForFunction(
    () => { const v = document.querySelector("#vid-loop") as HTMLVideoElement; return v.dataset.loopTail === "1" && v.currentTime >= 2.5; },
    null,
    { timeout: 10000 },
  );
  const v = await page.evaluate(() => {
    const el = document.querySelector("#vid-loop") as HTMLVideoElement;
    return { t: el.currentTime, loopTail: el.dataset.loopTail, paused: el.paused };
  });
  expect(v.loopTail).toBe("1");            // tail-loop armed
  expect(v.paused).toBe(false);            // the loop is actually playing
  expect(v.t).toBeGreaterThanOrEqual(2.5); // looping from ~3s, not 0
});

test("zoom in plays the matching morph, lands locked, and stays put while parked", async ({ page }) => {
  await boot(page);
  const start = (await page.locator("#scale-name").textContent())!;

  await wheelOnStage(page, 60);                        // wheel down = one detent inward (+)
  // Wait until we've landed somewhere new (the morph finished and advance updated).
  await page.waitForFunction(
    (s) => document.querySelector("#scale-name")?.textContent !== s,
    start,
    { timeout: 20_000 },
  );

  const played = await morphs(page);
  expect(played.length).toBeGreaterThanOrEqual(1);
  // A forward (zoom-in) morph FROM a cosmos clip — every cosmos pool id starts "cosmos".
  expect(played[0]).toMatch(/^transitions\/cosmos.*\.mp4$/);
  expect(played[0]).not.toMatch(/\.rev\.mp4$/);

  // Landed on the next altitude (orbit), and it is now LOCKED.
  const landed = (await page.locator("#scale-name").textContent())!;
  expect(landed).toContain("orbit");
  const countAfterLanding = (await morphs(page)).length;

  await page.waitForTimeout(2500);                     // sit parked
  expect(await page.locator("#scale-name").textContent()).toBe(landed);  // clip unchanged
  expect((await morphs(page)).length).toBe(countAfterLanding);           // no extra swap
});

test("turn-back scrubs the same morph in reverse and re-locks the start clip", async ({ page }) => {
  await boot(page);
  await enableAudio(page);
  const box = (await page.locator("#dial").boundingBox())!;
  const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
  const read = () => page.evaluate(() => ({
    t: (document.querySelector("video") as HTMLVideoElement).currentTime,
    ga: (document.querySelector("#aud") as HTMLAudioElement).volume,
    gb: (document.querySelector("#aud-b") as HTMLAudioElement).volume,
    name: document.querySelector("#scale-name")?.textContent,
    morphs: ((window as any).__hefMorphs || []).slice(),
  }));

  await page.mouse.move(cx, cy - 30);
  await page.mouse.down();
  await page.mouse.move(cx + 22, cy - 20, { steps: 10 });   // descend partway into the segment
  await page.waitForFunction(() => (document.querySelector("video") as HTMLVideoElement).currentTime > 0, null, { timeout: 5000 });
  const fwd = await read();
  // The single segment morph is the descend/forward file (cosmos__orbit), NOT a `.rev`.
  expect(fwd.morphs[fwd.morphs.length - 1]).toMatch(/^transitions\/cosmos.*\.mp4$/);
  expect(fwd.morphs[fwd.morphs.length - 1]).not.toMatch(/\.rev\.mp4$/);

  await page.mouse.move(cx + 4, cy - 30, { steps: 10 });    // turn back toward the start
  await page.waitForTimeout(120);                           // let the throttled rAF seek land
  const back = await read();
  await page.mouse.up();

  expect(back.t).toBeLessThan(fwd.t);          // SAME morph file, seeking backward
  expect(back.gb).toBeLessThan(fwd.gb);        // next-scale audio receding
  expect(back.name).toContain("cosmos");       // re-locked the altitude we left
});

test("crossing a detent commits and locks the destination clip", async ({ page }) => {
  await boot(page);
  const box = (await page.locator("#dial").boundingBox())!;
  const cx = box.x + box.width / 2, cy = box.y + box.height / 2;
  // Drag clockwise across one full detent (top -> right ~= 90deg = 1.25 detents),
  // crossing integer 1 (orbit) so it commits, then hold past it (no auto-complete).
  await page.mouse.move(cx, cy - 30);
  await page.mouse.down();
  await page.mouse.move(cx + 30, cy + 6, { steps: 14 });
  await page.waitForFunction(() => (window as any).__hefState().ringIndex === 1, null, { timeout: 5000 });
  const st = await page.evaluate(() => (window as any).__hefState());
  expect(st.ringIndex).toBe(1);                          // committed to orbit
  expect(String(st.activeClipId)).toMatch(/^orbit/);     // locked the destination pool pick
  const played = await page.evaluate(() => (window as any).__hefMorphs || []);
  expect(played[0]).toMatch(/^transitions\/cosmos.*\.mp4$/);
  expect(played[0]).not.toMatch(/\.rev\.mp4$/);
  // Hold: the committed clip does not change while parked mid-gesture.
  await page.waitForTimeout(800);
  const st2 = await page.evaluate(() => (window as any).__hefState());
  expect(st2.activeClipId).toBe(st.activeClipId);
  await page.mouse.up();
});
