import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { formatTime, formatSpeed, SPEED_PRESETS } from "./controls.js";

// Install a stubbed contentDocument getter on the given iframe element. The
// new stopMedia / muted tests repeat this `Object.defineProperty(... { get })`
// shape; routing through a named helper keeps the per-test bodies focused on
// the actual assertion.
function stubIframeContentDocument(iframe: HTMLIFrameElement, doc: Document): void {
  Object.defineProperty(iframe, "contentDocument", {
    configurable: true,
    get: () => doc,
  });
}

function createForeignFrameMediaDocument(): {
  doc: Document;
  video: HTMLMediaElement & { pause: ReturnType<typeof vi.fn> };
  audio: HTMLMediaElement & { pause: ReturnType<typeof vi.fn> };
} {
  class FrameElement {
    readonly tagName: string;
    ownerDocument: {
      defaultView: { Element: typeof FrameElement; HTMLMediaElement: typeof FrameElement };
    } | null = null;

    constructor(tagName: string) {
      this.tagName = tagName;
    }
  }

  class FrameMedia extends FrameElement {
    muted = false;
    defaultMuted = false;
    pause = vi.fn();
  }

  const video = new FrameMedia("VIDEO");
  const audio = new FrameMedia("AUDIO");
  const fakeDoc = {
    defaultView: { Element: FrameElement, HTMLMediaElement: FrameMedia },
    querySelectorAll: () => [video, audio],
  };
  video.ownerDocument = fakeDoc;
  audio.ownerDocument = fakeDoc;

  return {
    doc: fakeDoc as unknown as Document,
    video: video as unknown as HTMLMediaElement & { pause: ReturnType<typeof vi.fn> },
    audio: audio as unknown as HTMLMediaElement & { pause: ReturnType<typeof vi.fn> },
  };
}

// ── Controls unit tests ──

describe("SPEED_PRESETS", () => {
  it("contains logarithmic speed steps", () => {
    expect(SPEED_PRESETS).toEqual([0.25, 0.5, 1, 1.5, 2, 4]);
  });

  it("includes 1x as default speed", () => {
    expect(SPEED_PRESETS).toContain(1);
  });
});

describe("formatSpeed", () => {
  it("formats integer speeds", () => {
    expect(formatSpeed(1)).toBe("1x");
    expect(formatSpeed(2)).toBe("2x");
    expect(formatSpeed(4)).toBe("4x");
  });

  it("formats fractional speeds", () => {
    expect(formatSpeed(0.25)).toBe("0.25x");
    expect(formatSpeed(0.5)).toBe("0.5x");
    expect(formatSpeed(1.5)).toBe("1.5x");
  });
});

describe("formatTime", () => {
  it("formats 0 seconds", () => {
    expect(formatTime(0)).toBe("0:00");
  });

  it("formats seconds under a minute", () => {
    expect(formatTime(45)).toBe("0:45");
  });

  it("formats exact minutes", () => {
    expect(formatTime(120)).toBe("2:00");
  });

  it("formats minutes and seconds", () => {
    expect(formatTime(95)).toBe("1:35");
  });

  it("pads seconds with leading zero", () => {
    expect(formatTime(61)).toBe("1:01");
  });

  it("floors fractional seconds", () => {
    expect(formatTime(3.7)).toBe("0:03");
  });

  it("handles negative input", () => {
    expect(formatTime(-5)).toBe("0:00");
  });
});

// ── Parent-frame audio proxies (ownership-based) ──
//
// Parent-frame audio/video copies are preloaded mirror proxies of the iframe's
// timed media. They exist as a fallback for environments that block iframe
// `.play()`. Under the default `runtime` audio ownership, the iframe drives
// audible playback and the proxies stay paused. Ownership flips to `parent`
// only when the runtime posts `media-autoplay-blocked` — then the proxies
// become the audible source and the iframe is silenced via bridge.

describe("HyperframesPlayer parent-frame media", () => {
  type PlayerElement = HTMLElement & {
    play: () => void;
    pause: () => void;
    seek: (t: number) => void;
    _audioOwner?: "runtime" | "parent";
    _promoteToParentProxy?: () => void;
  };

  let player: PlayerElement;
  let mockAudio: {
    src: string;
    preload: string;
    muted: boolean;
    playbackRate: number;
    currentTime: number;
    paused: boolean;
    play: ReturnType<typeof vi.fn>;
    pause: ReturnType<typeof vi.fn>;
    load: ReturnType<typeof vi.fn>;
  };

  beforeEach(async () => {
    await import("./hyperframes-player.js");

    mockAudio = {
      src: "",
      preload: "",
      muted: false,
      playbackRate: 1,
      currentTime: 0,
      paused: true,
      play: vi.fn().mockResolvedValue(undefined),
      pause: vi.fn(),
      load: vi.fn(),
    };

    vi.spyOn(globalThis, "Audio").mockImplementation(
      () => mockAudio as unknown as HTMLAudioElement,
    );

    player = document.createElement("hyperframes-player") as PlayerElement;
  });

  afterEach(() => {
    player.remove();
    vi.restoreAllMocks();
  });

  it("includes audio-src in observedAttributes", () => {
    const Ctor = player.constructor as typeof HTMLElement & {
      observedAttributes: string[];
    };
    expect(Ctor.observedAttributes).toContain("audio-src");
  });

  it("creates Audio and starts preloading when audio-src is set", () => {
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    expect(globalThis.Audio).toHaveBeenCalled();
    expect(mockAudio.preload).toBe("auto");
    expect(mockAudio.src).toBe("https://cdn.example.com/narration.mp3");
    expect(mockAudio.load).toHaveBeenCalled();
  });

  it("syncs muted attribute to parent media", () => {
    player.setAttribute("muted", "");
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    expect(mockAudio.muted).toBe(true);
  });

  it("syncs playback-rate to parent media", () => {
    player.setAttribute("playback-rate", "1.5");
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    expect(mockAudio.playbackRate).toBe(1.5);
  });

  it("play() does NOT start parent-proxy under runtime ownership", () => {
    // Default ownership is `runtime` — the iframe drives audible playback.
    // If we also started parent proxies here, both would play and the user
    // would hear doubled, slightly-offset audio (the original bug).
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    player.play();
    expect(mockAudio.play).not.toHaveBeenCalled();
    expect(player._audioOwner).toBe("runtime");
  });

  it("pause() does NOT touch parent-proxy under runtime ownership", () => {
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    player.pause();
    expect(mockAudio.pause).not.toHaveBeenCalled();
  });

  it("seek() does NOT update parent currentTime under runtime ownership", () => {
    // Under runtime ownership the iframe is authoritative for time; touching
    // the proxy's currentTime would just trigger a re-buffer for no gain.
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    player.seek(12.5);
    expect(mockAudio.currentTime).toBe(0);
  });

  it("after promotion to parent ownership: play/pause/seek drive parent proxy", () => {
    // Simulates the runtime having posted `media-autoplay-blocked`. Post
    // promotion: the web component owns audible output and fully drives
    // the parent proxy.
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    player._promoteToParentProxy?.();
    expect(player._audioOwner).toBe("parent");

    player.play();
    expect(mockAudio.play).toHaveBeenCalled();

    player.seek(12.5);
    expect(mockAudio.currentTime).toBe(12.5);

    player.pause();
    expect(mockAudio.pause).toHaveBeenCalled();
  });

  function dispatchAutoplayBlockedFromPlayerFrame(player: HTMLElement): HTMLMediaElement {
    const iframe = player.shadowRoot?.querySelector("iframe");
    if (!(iframe instanceof HTMLIFrameElement)) throw new Error("expected player iframe");
    const iframeDoc = iframe.contentDocument;
    if (!iframeDoc) throw new Error("expected player iframe document");
    const video = iframeDoc.createElement("video");
    video.setAttribute("data-start", "0");
    video.setAttribute("data-duration", "10");
    video.muted = false;
    iframeDoc.body.appendChild(video);

    window.dispatchEvent(
      new MessageEvent("message", {
        source: iframe.contentWindow,
        data: { source: "hf-preview", type: "media-autoplay-blocked" },
      }),
    );

    return video;
  }

  it("does not mute iframe media on autoplay fallback inside presenter slideshow", () => {
    const slideshow = document.createElement("hyperframes-slideshow");
    slideshow.appendChild(player);
    document.body.appendChild(slideshow);

    const video = dispatchAutoplayBlockedFromPlayerFrame(player);

    expect(video.muted).toBe(false);
    expect(player._audioOwner).toBe("runtime");
    slideshow.remove();
  });

  it("does not promote autoplay fallback inside audience slideshow", () => {
    const slideshow = document.createElement("hyperframes-slideshow");
    slideshow.setAttribute("mode", "audience");
    slideshow.appendChild(player);
    document.body.appendChild(slideshow);

    const video = dispatchAutoplayBlockedFromPlayerFrame(player);

    expect(video.muted).toBe(false);
    expect(player._audioOwner).toBe("runtime");
    slideshow.remove();
  });

  it("seek() while playing pauses parent proxy (prevents mirrorTime stutter loop)", () => {
    // Regression: previously `seek()` only called `seekAll()`, leaving the
    // proxy playing. With the timeline frozen at the new seek target, the
    // parent's `mirrorTime` drift-correction would yank `currentTime` back
    // every ~80ms of accumulated drift, producing an audible audio stutter
    // loop while the video frame stayed frozen. `seek()` must be symmetric
    // with `pause()` for the parent-owned audio path.
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    player._promoteToParentProxy?.();
    player.play();
    expect(mockAudio.play).toHaveBeenCalled();
    mockAudio.pause.mockClear();

    player.seek(12.5);
    expect(mockAudio.pause).toHaveBeenCalled();
    expect(mockAudio.currentTime).toBe(12.5);
  });

  it("promotion is idempotent", () => {
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    player._promoteToParentProxy?.();
    player._promoteToParentProxy?.();
    player._promoteToParentProxy?.();
    // Only one play() attempt is triggered by promotion itself (gated on
    // `!this._paused`, which is true by default so it doesn't trigger at all).
    // The test's meaning is: ownership stays `parent`, no thrash, no errors.
    expect(player._audioOwner).toBe("parent");
  });

  it("dispatches audioownershipchange on promotion", () => {
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    const events: Array<{ owner: string; reason: string }> = [];
    player.addEventListener("audioownershipchange", (e: Event) => {
      const detail = (e as CustomEvent<{ owner: string; reason: string }>).detail;
      events.push(detail);
    });

    player._promoteToParentProxy?.();
    expect(events).toEqual([{ owner: "parent", reason: "autoplay-blocked" }]);

    // Second promote is idempotent — no duplicate event.
    player._promoteToParentProxy?.();
    expect(events).toHaveLength(1);
  });

  it("promotion mid-playback plays parent proxy immediately", () => {
    // Previously-missing coverage: if the user is already playing when
    // the runtime reports autoplay-blocked, the proxy must start audible
    // right away — not wait for the user to hit pause/play again.
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    player.play(); // `_paused = false`, owner still `runtime` → no parent play yet
    expect(mockAudio.play).not.toHaveBeenCalled();

    player._promoteToParentProxy?.();
    expect(mockAudio.play).toHaveBeenCalled();
  });

  it("surfaces playbackerror when parent proxy play() rejects", async () => {
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    const rejection = Object.assign(new Error("blocked"), { name: "NotAllowedError" });
    mockAudio.play = vi.fn().mockRejectedValueOnce(rejection);

    const errors: unknown[] = [];
    player.addEventListener("playbackerror", (e: Event) => {
      errors.push((e as CustomEvent).detail);
    });

    player._promoteToParentProxy?.();
    player.play();
    // Promise rejection delivered on a microtask — flush.
    await Promise.resolve();
    await Promise.resolve();

    expect(errors.length).toBeGreaterThan(0);
    expect((errors[0] as { source: string }).source).toBe("parent-proxy");
  });

  it("playbackerror dedup: fires at most once per parent-ownership session", async () => {
    // Under parent ownership with parent-also-blocked, every iframe
    // paused→playing transition in the state loop re-invokes `_playParentMedia`.
    // Without a latch, each rejection would re-fire `playbackerror`, spamming
    // subscribers. Mirrors the runtime's `mediaAutoplayBlockedPosted` latch.
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    const rejection = Object.assign(new Error("blocked"), { name: "NotAllowedError" });
    mockAudio.play = vi.fn().mockRejectedValue(rejection);

    const errors: unknown[] = [];
    player.addEventListener("playbackerror", (e: Event) => {
      errors.push((e as CustomEvent).detail);
    });

    player._promoteToParentProxy?.();
    player.play();
    player.pause();
    player.play();
    player.pause();
    player.play();
    await Promise.resolve();
    await Promise.resolve();
    await Promise.resolve();

    expect(errors).toHaveLength(1);
  });

  it("cleans up parent media on disconnect", () => {
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    player.remove();
    expect(mockAudio.pause).toHaveBeenCalled();
    expect(mockAudio.src).toBe("");
  });

  it("updates parent media when playback-rate changes after setup", () => {
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    player.setAttribute("playback-rate", "2");
    expect(mockAudio.playbackRate).toBe(2);
  });

  it("updates parent media when muted toggles after setup", () => {
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    player.setAttribute("muted", "");
    expect(mockAudio.muted).toBe(true);

    player.removeAttribute("muted");
    expect(mockAudio.muted).toBe(false);
  });
});

// ── Shader transition preview controls ──
//
// Shader transition capture scale and loading UI ownership are player-level
// preview concerns. The player forwards those options into the iframe before
// the composition runs, then renders transition-prep progress from runtime
// messages when `shader-loading="player"` is enabled.

describe("HyperframesPlayer shader transition options", () => {
  type PlayerWithIframe = HTMLElement & {
    iframeElement: HTMLIFrameElement;
  };

  beforeEach(async () => {
    await import("./hyperframes-player.js");
  });

  afterEach(() => {
    vi.useRealTimers();
    document.body.innerHTML = "";
  });

  it("observes shader capture scale and loading attributes", () => {
    const player = document.createElement("hyperframes-player");
    const Ctor = player.constructor as typeof HTMLElement & {
      observedAttributes: string[];
    };

    expect(Ctor.observedAttributes).toContain("shader-capture-scale");
    expect(Ctor.observedAttributes).toContain("shader-loading");
  });

  it("passes shader options through src query parameters", () => {
    const player = document.createElement("hyperframes-player") as PlayerWithIframe;
    player.setAttribute("shader-capture-scale", "0.5");
    player.setAttribute("shader-loading", "player");
    player.setAttribute("src", "/api/projects/demo/preview?x=1#stage");

    const url = new URL(player.iframeElement.src);
    expect(url.pathname).toBe("/api/projects/demo/preview");
    expect(url.searchParams.get("x")).toBe("1");
    expect(url.searchParams.get("__hf_shader_capture_scale")).toBe("0.5");
    expect(url.searchParams.get("__hf_shader_loading")).toBe("player");
    expect(url.hash).toBe("#stage");
  });

  it("injects shader options into srcdoc before composition scripts run", () => {
    const player = document.createElement("hyperframes-player") as PlayerWithIframe;
    player.setAttribute("shader-capture-scale", "0.5");
    player.setAttribute("shader-loading", "player");
    player.setAttribute(
      "srcdoc",
      '<!doctype html><html><head><script src="composition.js"></script></head><body></body></html>',
    );

    const srcdoc = player.iframeElement.srcdoc;
    expect(srcdoc).toContain('window.__HF_SHADER_CAPTURE_SCALE="0.5";');
    expect(srcdoc).toContain('window.__HF_SHADER_LOADING="player";');
    expect(srcdoc.indexOf("data-hyperframes-player-shader-options")).toBeLessThan(
      srcdoc.indexOf("composition.js"),
    );
  });

  it("shows and hides the player-owned shader loader from transition state messages", () => {
    vi.useFakeTimers();
    const player = document.createElement("hyperframes-player") as PlayerWithIframe;
    player.setAttribute("shader-loading", "player");
    document.body.appendChild(player);

    const iframeWindow = player.iframeElement.contentWindow;
    expect(iframeWindow).toBeTruthy();
    window.dispatchEvent(
      new MessageEvent("message", {
        source: iframeWindow,
        data: {
          source: "hf-preview",
          type: "shader-transition-state",
          compositionId: "main",
          state: {
            loading: true,
            progress: 3,
            total: 10,
            currentTransition: 1,
            transitionTotal: 2,
            transitionFrame: 3,
            transitionFrames: 5,
            phase: "capturing",
          },
        },
      }),
    );

    const loader = player.shadowRoot?.querySelector(".hfp-shader-loader");
    expect(loader?.classList.contains("hfp-visible")).toBe(true);
    expect(loader?.textContent).toContain("1/2");
    expect(loader?.textContent).toContain("3/5");

    const playEvents: Event[] = [];
    player.addEventListener("play", (event) => playEvents.push(event));
    loader?.dispatchEvent(new MouseEvent("click", { bubbles: true, composed: true }));
    expect(playEvents).toHaveLength(0);

    window.dispatchEvent(
      new MessageEvent("message", {
        source: iframeWindow,
        data: {
          source: "hf-preview",
          type: "shader-transition-state",
          compositionId: "main",
          state: { loading: false, ready: true },
        },
      }),
    );
    window.dispatchEvent(
      new MessageEvent("message", {
        source: iframeWindow,
        data: {
          source: "hf-preview",
          type: "shader-transition-state",
          compositionId: "main",
          state: { loading: false, ready: true },
        },
      }),
    );
    expect(loader?.classList.contains("hfp-visible")).toBe(false);
    expect(loader?.classList.contains("hfp-hiding")).toBe(true);
    vi.advanceTimersByTime(420);
    expect(loader?.classList.contains("hfp-hiding")).toBe(false);
    vi.useRealTimers();
  });
});

// ── Shared stylesheet (adoptedStyleSheets) ──
//
// Every player constructed in the same document should adopt the *same*
// CSSStyleSheet instance instead of getting its own <style> element. This is
// the studio thumbnail-grid win — N players, one parsed sheet.

describe("HyperframesPlayer adoptedStyleSheets", () => {
  type AdoptingShadowRoot = ShadowRoot & { adoptedStyleSheets: CSSStyleSheet[] };
  type PlayerWithShadow = HTMLElement & { shadowRoot: AdoptingShadowRoot | null };

  beforeEach(async () => {
    await import("./hyperframes-player.js");
  });

  afterEach(() => {
    document.body.innerHTML = "";
  });

  it("shares a single CSSStyleSheet across multiple player instances", () => {
    const a = document.createElement("hyperframes-player") as PlayerWithShadow;
    const b = document.createElement("hyperframes-player") as PlayerWithShadow;
    document.body.appendChild(a);
    document.body.appendChild(b);

    const sheetsA = a.shadowRoot?.adoptedStyleSheets ?? [];
    const sheetsB = b.shadowRoot?.adoptedStyleSheets ?? [];

    expect(sheetsA.length).toBeGreaterThan(0);
    expect(sheetsB.length).toBeGreaterThan(0);
    expect(sheetsA.at(-1)).toBe(sheetsB.at(-1));
  });

  it("does not inject a per-instance <style> when adoption succeeds", () => {
    const player = document.createElement("hyperframes-player") as PlayerWithShadow;
    document.body.appendChild(player);

    expect(player.shadowRoot?.querySelector("style")).toBeNull();
  });
});

// ── Media MutationObserver scoping ──
//
// The observer that catches late-attached `<audio data-start>` from
// sub-composition activation used to watch `iframe.contentDocument.body`
// wholesale. That fired on every body-level mutation — analytics scripts,
// runtime telemetry markers, dev-only overlays — even though only
// composition-tree changes can introduce new timed media. The fix is to
// scope per top-level composition host (see `selectMediaObserverTargets`);
// these tests verify the player honors that scoping.

describe("HyperframesPlayer media MutationObserver scoping", () => {
  type PlayerInternal = HTMLElement & {
    _observeDynamicMedia?: (doc: Document) => void;
  };

  beforeEach(async () => {
    await import("./hyperframes-player.js");
  });

  afterEach(() => {
    document.body.innerHTML = "";
    vi.restoreAllMocks();
  });

  it("attaches the observer to each top-level composition host (not the body)", () => {
    const observeSpy = vi.spyOn(MutationObserver.prototype, "observe");

    const player = document.createElement("hyperframes-player") as PlayerInternal;
    document.body.appendChild(player);
    // The constructor doesn't install an observer — only `_observeDynamicMedia`
    // does — so the spy starts clean for the call we care about.
    observeSpy.mockClear();

    // Simulates the iframe document the runtime hands the player after mount.
    // Bypassing the iframe lifecycle keeps the test deterministic; the
    // selection logic itself is exercised in `mediaObserverScope.test.ts`.
    const fakeDoc = document.implementation.createHTMLDocument("test");
    fakeDoc.body.innerHTML = `
      <div data-composition-id="root-a"></div>
      <div data-composition-id="root-b"></div>
      <script>// runtime telemetry — body-level, must NOT be observed</script>
    `;

    player._observeDynamicMedia?.(fakeDoc);

    expect(observeSpy).toHaveBeenCalledTimes(2);
    const observedTargets = observeSpy.mock.calls.map((call) => call[0]);
    expect(observedTargets.map((t) => (t as Element).getAttribute("data-composition-id"))).toEqual([
      "root-a",
      "root-b",
    ]);
    expect(observedTargets).not.toContain(fakeDoc.body);
    // Subtree is still required — sub-composition media can be deeply nested
    // inside the host (e.g. wrapper div around the `<audio>`).
    // Attribute observation on "preload" is required so the player creates
    // parent proxies just-in-time when the preloader promotes a clip.
    for (const call of observeSpy.mock.calls) {
      expect(call[1]).toEqual({
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ["preload"],
      });
    }
  });

  it("falls back to observing the document body when no composition hosts exist", () => {
    // Preserves the legacy behavior for documents that haven't bootstrapped
    // a composition tree yet (e.g. a blank iframe between src changes).
    const observeSpy = vi.spyOn(MutationObserver.prototype, "observe");

    const player = document.createElement("hyperframes-player") as PlayerInternal;
    document.body.appendChild(player);
    observeSpy.mockClear();

    const fakeDoc = document.implementation.createHTMLDocument("test");
    fakeDoc.body.innerHTML = `<div class="not-a-composition"></div>`;

    player._observeDynamicMedia?.(fakeDoc);

    expect(observeSpy).toHaveBeenCalledTimes(1);
    expect(observeSpy.mock.calls[0]?.[0]).toBe(fakeDoc.body);
  });
});

// ── Parent-proxy time-mirror coalescing ──
//
// `_mirrorParentMediaTime` is the steady-state correction loop that nudges
// every parent-frame audio/video proxy back onto the iframe's timeline. The
// post-`P1-4` contract: a single over-threshold sample (one slow bridge tick,
// one tab-throttled rAF, one GC pause) is absorbed by a per-proxy counter and
// does NOT cost a `currentTime` write. Only a *trending* drift — two
// consecutive samples above the 50 ms threshold — triggers a seek. Forced
// callers (audio-ownership promotion, brand-new proxy initialization) bypass
// the gate so the listener never hears a misaligned sample on cut-over.

describe("HyperframesPlayer parent-proxy time-mirror coalescing", () => {
  type DriftEntry = {
    el: { currentTime: number; src: string; pause: () => void };
    start: number;
    duration: number;
    driftSamples: number;
  };
  type PlayerInternal = HTMLElement & {
    _parentMedia: DriftEntry[];
    _mirrorParentMediaTime: (timelineSeconds: number, options?: { force?: boolean }) => void;
    _promoteToParentProxy?: () => void;
  };

  let player: PlayerInternal;

  beforeEach(async () => {
    await import("./hyperframes-player.js");
    player = document.createElement("hyperframes-player") as PlayerInternal;
    document.body.appendChild(player);
    // No audio-src was set, so `_parentMedia` is empty. Tests push synthetic
    // POJO entries — `_mirrorParentMediaTime` only reads/writes
    // `el.currentTime`, so a plain object stands in fine for HTMLMediaElement.
  });

  afterEach(() => {
    player.remove();
    vi.restoreAllMocks();
  });

  function makeEntry(
    opts: {
      currentTime?: number;
      start?: number;
      duration?: number;
      driftSamples?: number;
    } = {},
  ): DriftEntry {
    // Include `pause`/`src` so `disconnectedCallback`'s teardown loop
    // (`m.el.pause(); m.el.src = ""`) doesn't blow up when the player is
    // removed at the end of the test — `_mirrorParentMediaTime` itself only
    // touches `currentTime`.
    const entry: DriftEntry = {
      el: {
        currentTime: opts.currentTime ?? 0,
        src: "",
        pause: vi.fn(),
      },
      start: opts.start ?? 0,
      duration: opts.duration ?? 100,
      driftSamples: opts.driftSamples ?? 0,
    };
    player._parentMedia.push(entry);
    return entry;
  }

  it("initializes new parent-media entries with driftSamples=0", () => {
    // Mock Audio just for this test so the audio-src bootstrap path produces
    // a real entry rather than throwing on construction.
    const mockAudio = {
      src: "",
      preload: "",
      muted: false,
      playbackRate: 1,
      currentTime: 0,
      paused: true,
      play: vi.fn().mockResolvedValue(undefined),
      pause: vi.fn(),
      load: vi.fn(),
    };
    vi.spyOn(globalThis, "Audio").mockImplementation(
      () => mockAudio as unknown as HTMLAudioElement,
    );

    const fresh = document.createElement("hyperframes-player") as PlayerInternal;
    fresh.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(fresh);

    expect(fresh._parentMedia).toHaveLength(1);
    expect(fresh._parentMedia[0]?.driftSamples).toBe(0);
    fresh.remove();
  });

  it("does nothing when drift is within the 50 ms threshold", () => {
    const m = makeEntry({ currentTime: 5 });
    player._mirrorParentMediaTime(5.04);
    expect(m.el.currentTime).toBe(5);
    expect(m.driftSamples).toBe(0);
  });

  it("absorbs a single over-threshold spike without writing currentTime", () => {
    const m = makeEntry({ currentTime: 5 });
    player._mirrorParentMediaTime(5.5);
    expect(m.el.currentTime).toBe(5);
    expect(m.driftSamples).toBe(1);
  });

  it("issues a seek on the second consecutive over-threshold sample", () => {
    const m = makeEntry({ currentTime: 5 });
    player._mirrorParentMediaTime(5.5);
    expect(m.el.currentTime).toBe(5);
    expect(m.driftSamples).toBe(1);
    // Second sample with the same drift: the gate trips, the write fires,
    // and the counter resets so the proxy doesn't re-seek every later tick.
    player._mirrorParentMediaTime(5.5);
    expect(m.el.currentTime).toBe(5.5);
    expect(m.driftSamples).toBe(0);
  });

  it("resets the counter when a sample comes back within threshold", () => {
    const m = makeEntry({ currentTime: 5 });
    player._mirrorParentMediaTime(5.5);
    expect(m.driftSamples).toBe(1);
    // Recovery — counter must clear so a later isolated spike doesn't
    // accidentally satisfy the 2-sample gate by piggy-backing on stale state.
    player._mirrorParentMediaTime(5.02);
    expect(m.driftSamples).toBe(0);
    expect(m.el.currentTime).toBe(5);
    player._mirrorParentMediaTime(5.5);
    expect(m.driftSamples).toBe(1);
    expect(m.el.currentTime).toBe(5);
  });

  it("force: true writes immediately on the first over-threshold sample", () => {
    const m = makeEntry({ currentTime: 5 });
    player._mirrorParentMediaTime(5.5, { force: true });
    expect(m.el.currentTime).toBe(5.5);
    expect(m.driftSamples).toBe(0);
  });

  it("force: true clears any pre-existing drift counter", () => {
    const m = makeEntry({ currentTime: 5, driftSamples: 1 });
    player._mirrorParentMediaTime(5.5, { force: true });
    expect(m.el.currentTime).toBe(5.5);
    expect(m.driftSamples).toBe(0);
  });

  it("does not seek out-of-range entries and resets their counters", () => {
    // Active window [10, 15). currentTime=99 is a sentinel — if the function
    // ever writes inside an out-of-range branch the test catches it because
    // relTime would be 5 (or 15), not 99.
    const m = makeEntry({
      currentTime: 99,
      start: 10,
      duration: 5,
      driftSamples: 5,
    });
    player._mirrorParentMediaTime(5);
    expect(m.el.currentTime).toBe(99);
    expect(m.driftSamples).toBe(0);
    // Boundary: relTime === duration → still out of range (the loop uses `>=`).
    m.driftSamples = 7;
    player._mirrorParentMediaTime(15);
    expect(m.el.currentTime).toBe(99);
    expect(m.driftSamples).toBe(0);
  });

  it("tracks drift independently across multiple proxies", () => {
    // a is drifted; b is aligned. A single tick must increment a's counter
    // and reset b's — proving the per-entry state is genuinely per-entry.
    const a = makeEntry({ currentTime: 5 });
    const b = makeEntry({ currentTime: 7.01, driftSamples: 1 });
    player._mirrorParentMediaTime(7);
    expect(a.el.currentTime).toBe(5);
    expect(a.driftSamples).toBe(1);
    expect(b.el.currentTime).toBe(7.01);
    expect(b.driftSamples).toBe(0);
  });

  it("force: true bypasses the gate for every proxy in a single sweep", () => {
    const a = makeEntry({ currentTime: 5 });
    const b = makeEntry({ currentTime: 8 });
    player._mirrorParentMediaTime(7, { force: true });
    expect(a.el.currentTime).toBe(7);
    expect(b.el.currentTime).toBe(7);
    expect(a.driftSamples).toBe(0);
    expect(b.driftSamples).toBe(0);
  });

  it("_promoteToParentProxy invokes _mirrorParentMediaTime with force: true", () => {
    // Integration check of the promotion call site — we cannot tolerate even
    // ~80 ms of audible drift across an ownership flip, so the call site
    // must opt out of the jitter gate.
    const spy = vi.spyOn(player, "_mirrorParentMediaTime");
    player._promoteToParentProxy?.();
    const forcedCall = spy.mock.calls.find(([, opts]) => opts?.force === true);
    expect(forcedCall).toBeDefined();
  });
});

// ── Synchronous seek() with same-origin detection ──
//
// Studio has long reached past the postMessage bridge and called the runtime's
// `__player.seek` directly (`useTimelinePlayer.ts:233`) — that's the only way
// to land a scrubbed frame in the same task as the input event so the user
// sees no perceived lag. P3-1 promotes that pattern to a public API: the
// player element's own `seek()` now tries the same shortcut first, and only
// falls back to the async postMessage bridge when the iframe is genuinely
// cross-origin (or the runtime hasn't installed `__player` yet). The tests
// here stub `iframe.contentWindow` so we can exercise the branch matrix
// without booting an actual runtime.

describe("HyperframesPlayer seek() sync path", () => {
  type SyncPlayerStub = {
    seek?: (t: number) => void;
    play?: () => void;
    pause?: () => void;
  };
  type TimelineStub = {
    duration: () => number;
    time: () => number;
    seek: (t: number) => void;
    play: () => void;
    pause: () => void;
  };
  type FakeContentWindow = {
    __player?: SyncPlayerStub;
    __timelines?: Record<string, TimelineStub>;
    postMessage?: ReturnType<typeof vi.fn>;
  };
  type PlayerInternal = HTMLElement & {
    seek: (t: number) => void;
    play: () => void;
    pause: () => void;
    stopMedia: () => void;
    iframe: HTMLIFrameElement;
    _currentTime: number;
    _parentMedia: Array<{
      el: { pause: ReturnType<typeof vi.fn>; src: string };
      start: number;
      duration: number;
      driftSamples: number;
      source?: HTMLMediaElement | null;
    }>;
  };

  let player: PlayerInternal;

  beforeEach(async () => {
    await import("./hyperframes-player.js");
    player = document.createElement("hyperframes-player") as PlayerInternal;
    document.body.appendChild(player);
  });

  afterEach(() => {
    player.remove();
    vi.restoreAllMocks();
  });

  // Replace the iframe's `contentWindow` getter so the test controls what the
  // sync path sees. Passing `"throw"` simulates the cross-origin SecurityError
  // a real browser raises when reading `contentWindow.<anything>`.
  function stubContentWindow(stub: FakeContentWindow | "throw") {
    Object.defineProperty(player.iframe, "contentWindow", {
      configurable: true,
      get() {
        if (stub === "throw") throw new Error("SecurityError");
        return stub;
      },
    });
  }

  it("calls __player.seek directly on the same-origin path", () => {
    // The whole point of P3-1: when the runtime is reachable, scrubs land in
    // the same task as the input. `postMessage` must NOT also fire — that
    // would cause a duplicate, async re-seek a tick later.
    const sync = vi.fn();
    const post = vi.fn();
    stubContentWindow({ __player: { seek: sync }, postMessage: post });

    player.seek(12.5);

    expect(sync).toHaveBeenCalledTimes(1);
    expect(sync).toHaveBeenCalledWith(12.5);
    expect(post).not.toHaveBeenCalled();
  });

  it("passes the raw time-in-seconds through, not a rounded frame number", () => {
    // The postMessage bridge has to round to a frame at the wire boundary,
    // but the in-process call accepts seconds directly — preserving the
    // caller's precision for fractional scrubs.
    const sync = vi.fn();
    stubContentWindow({ __player: { seek: sync } });

    player.seek(7.3333);

    expect(sync).toHaveBeenCalledWith(7.3333);
  });

  it("falls back to postMessage when __player has not been installed yet", () => {
    // Before the runtime bootstraps, `contentWindow` exists but `__player` is
    // undefined. The fallback queues the seek via postMessage, which the
    // runtime drains once `installRuntimeControlBridge` runs.
    const post = vi.fn();
    stubContentWindow({ postMessage: post });

    player.seek(12.5);

    expect(post).toHaveBeenCalledTimes(1);
    expect(post).toHaveBeenCalledWith(
      expect.objectContaining({
        source: "hf-parent",
        type: "control",
        action: "seek",
        frame: Math.round(12.5 * 30),
      }),
      "*",
    );
  });

  it("seeks same-origin __timelines when no runtime bridge exists", () => {
    const timeline: TimelineStub = {
      duration: vi.fn(() => 5),
      time: vi.fn(() => 0),
      seek: vi.fn(),
      play: vi.fn(),
      pause: vi.fn(),
    };
    const post = vi.fn();
    stubContentWindow({ __timelines: { main: timeline }, postMessage: post });

    player.seek(2);

    expect(timeline.seek).toHaveBeenCalledTimes(1);
    // suppressEvents=false so onUpdate fires (imperative-visibility compositions repaint).
    expect(timeline.seek).toHaveBeenCalledWith(2, false);
    expect(post).not.toHaveBeenCalled();
  });

  it("plays and pauses same-origin __timelines when no runtime bridge exists", () => {
    const timeline: TimelineStub = {
      duration: vi.fn(() => 5),
      time: vi.fn(() => 0),
      seek: vi.fn(),
      play: vi.fn(),
      pause: vi.fn(),
    };
    const post = vi.fn();
    stubContentWindow({ __timelines: { main: timeline }, postMessage: post });

    player.play();
    player.pause();

    expect(timeline.play).toHaveBeenCalledTimes(1);
    expect(timeline.pause).toHaveBeenCalledTimes(1);
    expect(post).not.toHaveBeenCalled();
  });

  it("pauses same-origin __timelines after seek while playing", () => {
    const pause = vi.fn();
    const timeline: TimelineStub = {
      duration: vi.fn(() => 5),
      time: vi.fn(() => 0),
      seek: vi.fn(),
      play: vi.fn(),
      pause,
    };
    const post = vi.fn();
    stubContentWindow({ __timelines: { main: timeline }, postMessage: post });

    player.play();
    pause.mockClear();
    player.seek(2);

    expect(timeline.seek).toHaveBeenCalledWith(2, false);
    expect(pause).toHaveBeenCalledTimes(1);
    expect(post).not.toHaveBeenCalled();
  });

  it("stopMedia pauses slide media without stopping global audio-src proxies", () => {
    const post = vi.fn();
    const doc = document.implementation.createHTMLDocument("composition");
    const iframeVideo = doc.createElement("video");
    const iframeAudio = doc.createElement("audio");
    const iframeVideoPause = vi.fn();
    const iframeAudioPause = vi.fn();
    Object.defineProperty(iframeVideo, "pause", { configurable: true, value: iframeVideoPause });
    Object.defineProperty(iframeAudio, "pause", { configurable: true, value: iframeAudioPause });
    doc.body.append(iframeVideo, iframeAudio);
    stubIframeContentDocument(player.iframe, doc);
    stubContentWindow({ postMessage: post });

    const slideProxyPause = vi.fn();
    const globalProxyPause = vi.fn();
    player._parentMedia.push(
      {
        el: { pause: slideProxyPause, src: "https://cdn.example.com/slide.mp4" },
        start: 0,
        duration: 5,
        driftSamples: 0,
        source: iframeVideo,
      },
      {
        el: { pause: globalProxyPause, src: "https://cdn.example.com/background.mp3" },
        start: 0,
        duration: Infinity,
        driftSamples: 0,
        source: null,
      },
    );

    player.stopMedia();

    expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "stop-media" }), "*");
    expect(iframeVideoPause).toHaveBeenCalledOnce();
    expect(iframeAudioPause).toHaveBeenCalledOnce();
    expect(slideProxyPause).toHaveBeenCalledOnce();
    expect(globalProxyPause).not.toHaveBeenCalled();
  });

  it("stopMedia pauses iframe-realm media elements", () => {
    const post = vi.fn();
    const { doc, video, audio } = createForeignFrameMediaDocument();
    stubIframeContentDocument(player.iframe, doc);
    stubContentWindow({ postMessage: post });

    player.stopMedia();

    expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "stop-media" }), "*");
    expect(video.pause).toHaveBeenCalledOnce();
    expect(audio.pause).toHaveBeenCalledOnce();
  });

  it("does not bypass an installed runtime bridge for direct __timelines playback", () => {
    const timeline: TimelineStub = {
      duration: vi.fn(() => 5),
      time: vi.fn(() => 0),
      seek: vi.fn(),
      play: vi.fn(),
      pause: vi.fn(),
    };
    const post = vi.fn();
    stubContentWindow({
      __player: { play: vi.fn(), pause: vi.fn() },
      __timelines: { main: timeline },
      postMessage: post,
    });

    player.play();
    player.pause();

    expect(timeline.play).not.toHaveBeenCalled();
    expect(timeline.pause).not.toHaveBeenCalled();
    expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "play" }), "*");
    expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "pause" }), "*");
  });

  it("falls back to postMessage when __player exists but lacks seek()", () => {
    // Defensive: a partial `__player` (e.g. older runtime, mocked stub) must
    // not be assumed callable. `typeof seek !== "function"` guards this.
    const post = vi.fn();
    stubContentWindow({
      __player: { play: vi.fn(), pause: vi.fn() },
      postMessage: post,
    });

    player.seek(7);

    expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "seek", frame: 210 }), "*");
  });

  it("does not throw when contentWindow access raises (cross-origin embed)", () => {
    // Reading `iframe.contentWindow` on a true cross-origin iframe throws a
    // DOMException. Both `_trySyncSeek` AND the postMessage fallback hit the
    // same getter, so both swallow the error — the public seek() must remain
    // a clean no-op surface for the caller.
    stubContentWindow("throw");

    expect(() => player.seek(12.5)).not.toThrow();
  });

  it("falls back to postMessage when __player.seek throws at runtime", () => {
    // If the runtime's seek implementation panics, we catch in `_trySyncSeek`
    // and degrade to the bridge. The postMessage path runs in a separate
    // task — it may succeed where the sync call failed, and at worst the
    // failure mode is identical.
    const sync = vi.fn(() => {
      throw new Error("runtime panic");
    });
    const post = vi.fn();
    stubContentWindow({ __player: { seek: sync }, postMessage: post });

    expect(() => player.seek(12.5)).not.toThrow();
    expect(post).toHaveBeenCalledWith(expect.objectContaining({ action: "seek" }), "*");
  });

  it("updates _currentTime regardless of which path is taken", () => {
    // `_currentTime` is the parent-side cache that drives controls and parent
    // proxy mirroring. It must update unconditionally — otherwise scrubs on a
    // cross-origin embed leave the controls UI showing stale time.
    const sync = vi.fn();
    stubContentWindow({ __player: { seek: sync } });
    player.seek(8.25);
    expect(player._currentTime).toBe(8.25);

    // Reset and verify the fallback path produces the same caching behavior.
    stubContentWindow({ postMessage: vi.fn() });
    player.seek(11);
    expect(player._currentTime).toBe(11);
  });
});

describe("HyperframesPlayer loop end-state handling", () => {
  type PlayerInternal = HTMLElement & {
    iframe: HTMLIFrameElement;
    play: () => void;
    seek: (timeInSeconds: number) => void;
    loop: boolean;
    _duration: number;
    _currentTime: number;
    _paused: boolean;
    _onMessage: (event: MessageEvent) => void;
  };

  let player: PlayerInternal;
  let frameWindow: Window;

  beforeEach(async () => {
    await import("./hyperframes-player.js");
    player = document.createElement("hyperframes-player") as PlayerInternal;
    frameWindow = window;
    vi.spyOn(frameWindow, "postMessage").mockImplementation(() => undefined);
    Object.defineProperty(player.iframe, "contentWindow", {
      configurable: true,
      get: () => frameWindow,
    });
    document.body.appendChild(player);
  });

  afterEach(() => {
    player.remove();
    vi.restoreAllMocks();
  });

  it("wraps and keeps playing when a looping composition posts its final paused state", () => {
    const seek = vi.spyOn(player, "seek");
    const play = vi.spyOn(player, "play");
    player.loop = true;
    player._duration = 4;
    player._paused = false;

    player._onMessage(
      new MessageEvent("message", {
        source: frameWindow,
        data: {
          source: "hf-preview",
          type: "state",
          frame: 120,
          isPlaying: false,
        },
      }),
    );

    expect(seek).toHaveBeenCalledWith(0);
    expect(play).toHaveBeenCalled();
    expect(player._paused).toBe(false);
  });

  it("fires ended and stays paused when a non-looping composition posts its final paused state", () => {
    const seek = vi.spyOn(player, "seek");
    const play = vi.spyOn(player, "play");
    const ended = vi.fn();
    player.addEventListener("ended", ended);
    player.loop = false;
    player._duration = 4;
    player._paused = false;

    player._onMessage(
      new MessageEvent("message", {
        source: frameWindow,
        data: {
          source: "hf-preview",
          type: "state",
          frame: 120,
          isPlaying: false,
        },
      }),
    );

    expect(seek).not.toHaveBeenCalled();
    expect(play).not.toHaveBeenCalled();
    expect(ended).toHaveBeenCalledTimes(1);
    expect(player._paused).toBe(true);
  });

  it("play() seeks to 0 and replays when called after the video has ended", () => {
    const seek = vi.spyOn(player, "seek");
    player.loop = false;
    player._duration = 4;
    player._paused = false;

    player._onMessage(
      new MessageEvent("message", {
        source: frameWindow,
        data: {
          source: "hf-preview",
          type: "state",
          frame: 120,
          isPlaying: false,
        },
      }),
    );

    expect(player._paused).toBe(true);
    seek.mockClear();

    player.play();

    expect(seek).toHaveBeenCalledWith(0);
    expect(player._paused).toBe(false);
  });

  it("play() does not seek to 0 when called mid-playback", () => {
    const seek = vi.spyOn(player, "seek");
    player._duration = 4;
    player._paused = true;
    // Simulate mid-video position (frame 60 = 2s into a 4s video)
    player._onMessage(
      new MessageEvent("message", {
        source: frameWindow,
        data: {
          source: "hf-preview",
          type: "state",
          frame: 60,
          isPlaying: false,
        },
      }),
    );

    player.play();

    expect(seek).not.toHaveBeenCalled();
    expect(player._paused).toBe(false);
  });

  it("clamps _currentTime to _duration when a state message reports a frame past the end", () => {
    // Regression test: the postMessage state path previously set _currentTime
    // without clamping, while the direct timeline path already clamped. A frame
    // count slightly past the end (common on final-frame messages) would set
    // _currentTime > _duration, causing the progress bar to overflow the
    // scrubber track and the time display to show e.g. "0:05 / 0:04".
    player._duration = 4; // 4s = 120 frames at 30fps
    player._paused = false;

    player._onMessage(
      new MessageEvent("message", {
        source: frameWindow,
        data: {
          source: "hf-preview",
          type: "state",
          frame: 150, // 5s — past the 4s duration
          isPlaying: false,
        },
      }),
    );

    expect(player._currentTime).toBe(4);
  });
});

describe("HyperframesPlayer srcdoc attribute", () => {
  type PlayerInternal = HTMLElement & {
    iframe: HTMLIFrameElement;
    _ready: boolean;
  };

  beforeEach(async () => {
    await import("./hyperframes-player.js");
  });

  it("includes srcdoc in observedAttributes", () => {
    // `attributeChangedCallback` only fires for observed attributes. Without
    // this, runtime srcdoc swaps from studio would silently drop on the floor.
    const ctor = customElements.get("hyperframes-player") as
      | (typeof HTMLElement & { observedAttributes: string[] })
      | undefined;
    expect(ctor).toBeDefined();
    expect(ctor!.observedAttributes).toContain("srcdoc");
  });

  it("forwards an initial srcdoc attribute to the iframe on connect", () => {
    // Studio's primary use case: render the player with composition HTML
    // already in hand, no network round-trip. Setting the attribute before
    // the element is connected must still apply on connect.
    const player = document.createElement("hyperframes-player") as PlayerInternal;
    const html = "<!doctype html><html><body>hello</body></html>";
    player.setAttribute("srcdoc", html);
    document.body.appendChild(player);

    expect(player.iframe.getAttribute("srcdoc")).toBe(html);

    player.remove();
  });

  it("forwards a srcdoc attribute set after connect to the iframe", () => {
    // The composition-switching flow: same player element, new HTML.
    // Without `attributeChangedCallback` wiring this would no-op.
    const player = document.createElement("hyperframes-player") as PlayerInternal;
    document.body.appendChild(player);

    const html = "<!doctype html><html><body>after connect</body></html>";
    player.setAttribute("srcdoc", html);

    expect(player.iframe.getAttribute("srcdoc")).toBe(html);

    player.remove();
  });

  it("resets _ready when srcdoc changes so onIframeLoad replays setup", () => {
    // The ready flag gates probe intervals, controls hookup, and poster
    // tear-down. Switching documents must invalidate it so the next `load`
    // event re-runs that setup against the fresh window.
    const player = document.createElement("hyperframes-player") as PlayerInternal;
    document.body.appendChild(player);
    player._ready = true;

    player.setAttribute("srcdoc", "<!doctype html><html></html>");

    expect(player._ready).toBe(false);

    player.remove();
  });

  it("removes iframe.srcdoc when the attribute is removed so src can take over", () => {
    // Per HTML spec, iframe.srcdoc beats iframe.src whenever both are
    // present. Studio's fetch-fail fallback path needs srcdoc cleared so
    // setting src afterwards actually navigates to that URL.
    const player = document.createElement("hyperframes-player") as PlayerInternal;
    player.setAttribute("srcdoc", "<!doctype html><html></html>");
    document.body.appendChild(player);
    expect(player.iframe.hasAttribute("srcdoc")).toBe(true);

    player.removeAttribute("srcdoc");

    expect(player.iframe.hasAttribute("srcdoc")).toBe(false);

    player.remove();
  });

  it("treats an empty-string srcdoc as a deliberate empty document, not removal", () => {
    // `setAttribute("srcdoc", "")` and `removeAttribute("srcdoc")` send
    // different signals from the caller — empty string means "load a blank
    // doc," removal means "fall back to src." We have to distinguish them.
    const player = document.createElement("hyperframes-player") as PlayerInternal;
    document.body.appendChild(player);

    player.setAttribute("srcdoc", "");

    expect(player.iframe.hasAttribute("srcdoc")).toBe(true);
    expect(player.iframe.getAttribute("srcdoc")).toBe("");

    player.remove();
  });

  it("forwards both src and srcdoc to the iframe and lets the browser arbitrate", () => {
    // We deliberately don't strip src when srcdoc is set: the HTML spec
    // already says srcdoc wins, and keeping both lets the browser fall back
    // to src automatically if the embed re-renders without srcdoc.
    const player = document.createElement("hyperframes-player") as PlayerInternal;
    player.setAttribute("src", "/api/projects/foo/preview");
    player.setAttribute("srcdoc", "<!doctype html><html></html>");
    document.body.appendChild(player);

    expect(player.iframe.getAttribute("src")).toBe("/api/projects/foo/preview");
    expect(player.iframe.getAttribute("srcdoc")).toBe("<!doctype html><html></html>");

    player.remove();
  });
});

// ── Volume / Mute controls ──

describe("HyperframesPlayer volume and mute", () => {
  let player: HTMLElement & {
    muted: boolean;
    volume: number;
    iframeElement: HTMLIFrameElement;
  };
  let mockAudio: {
    preload: string;
    src: string;
    muted: boolean;
    volume: number;
    playbackRate: number;
    currentTime: number;
    load: ReturnType<typeof vi.fn>;
    play: ReturnType<typeof vi.fn>;
    pause: ReturnType<typeof vi.fn>;
  };

  beforeEach(async () => {
    await import("./hyperframes-player.js");

    mockAudio = {
      preload: "",
      src: "",
      muted: false,
      volume: 1,
      playbackRate: 1,
      currentTime: 0,
      load: vi.fn(),
      play: vi.fn().mockResolvedValue(undefined),
      pause: vi.fn(),
    };
    vi.spyOn(globalThis, "Audio").mockImplementation(
      () => mockAudio as unknown as HTMLAudioElement,
    );

    player = document.createElement("hyperframes-player") as typeof player;
  });

  afterEach(() => {
    vi.restoreAllMocks();
    document.body.innerHTML = "";
  });

  it("defaults volume to 1", () => {
    document.body.appendChild(player);
    expect(player.volume).toBe(1);
  });

  it("sets volume on parent media when audio-src is configured", () => {
    player.setAttribute("volume", "0.5");
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    expect(mockAudio.volume).toBe(0.5);
  });

  it("updates parent media volume when volume attribute changes", () => {
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    player.setAttribute("volume", "0.3");
    expect(mockAudio.volume).toBe(0.3);
  });

  it("clamps volume to [0, 1]", () => {
    document.body.appendChild(player);

    player.volume = 1.5;
    expect(player.volume).toBe(1);

    player.volume = -0.5;
    expect(player.volume).toBe(0);
  });

  it("dispatches volumechange event when volume changes", () => {
    document.body.appendChild(player);

    const handler = vi.fn();
    player.addEventListener("volumechange", handler);

    player.setAttribute("volume", "0.7");
    expect(handler).toHaveBeenCalledTimes(1);
  });

  it("muted property toggles the muted attribute", () => {
    document.body.appendChild(player);

    player.muted = true;
    expect(player.hasAttribute("muted")).toBe(true);

    player.muted = false;
    expect(player.hasAttribute("muted")).toBe(false);
  });

  it("muted property directly mutes same-origin iframe media", () => {
    document.body.appendChild(player);
    const doc = document.implementation.createHTMLDocument("composition");
    const video = doc.createElement("video");
    const authoredMuted = doc.createElement("audio");
    authoredMuted.defaultMuted = true;
    doc.body.append(video, authoredMuted);
    stubIframeContentDocument(player.iframeElement, doc);

    player.muted = true;
    expect(video.muted).toBe(true);
    expect(authoredMuted.muted).toBe(true);

    player.muted = false;
    expect(video.muted).toBe(false);
    expect(authoredMuted.muted).toBe(true);
  });

  it("muted property mutes iframe-realm media elements", () => {
    document.body.appendChild(player);
    const { doc, video, audio } = createForeignFrameMediaDocument();
    audio.defaultMuted = true;
    stubIframeContentDocument(player.iframeElement, doc);

    player.muted = true;
    expect(video.muted).toBe(true);
    expect(audio.muted).toBe(true);

    player.muted = false;
    expect(video.muted).toBe(false);
    expect(audio.muted).toBe(true);
  });

  it("sends set-volume control to iframe", () => {
    document.body.appendChild(player);

    const postMessageSpy = vi.fn();
    Object.defineProperty(player.iframeElement, "contentWindow", {
      value: { postMessage: postMessageSpy },
      configurable: true,
    });

    player.setAttribute("volume", "0.6");
    expect(postMessageSpy).toHaveBeenCalledWith(
      expect.objectContaining({
        source: "hf-parent",
        type: "control",
        action: "set-volume",
        volume: 0.6,
      }),
      "*",
    );
  });

  it("controls bar shows mute button when controls are enabled", () => {
    player.setAttribute("controls", "");
    document.body.appendChild(player);

    const shadow = player.shadowRoot!;
    const muteBtn = shadow.querySelector(".hfp-mute-btn");
    expect(muteBtn).toBeTruthy();
    expect(muteBtn?.getAttribute("aria-label")).toBe("Mute");
  });

  it("controls bar shows volume slider when controls are enabled", () => {
    player.setAttribute("controls", "");
    document.body.appendChild(player);

    const shadow = player.shadowRoot!;
    const slider = shadow.querySelector(".hfp-volume-slider");
    expect(slider).toBeTruthy();
  });

  it("volume slider has ARIA slider attributes", () => {
    player.setAttribute("controls", "");
    document.body.appendChild(player);

    const shadow = player.shadowRoot!;
    const slider = shadow.querySelector(".hfp-volume-slider")!;
    expect(slider.getAttribute("role")).toBe("slider");
    expect(slider.getAttribute("aria-label")).toBe("Volume");
    expect(slider.getAttribute("aria-valuemin")).toBe("0");
    expect(slider.getAttribute("aria-valuemax")).toBe("100");
    expect(slider.getAttribute("aria-valuenow")).toBe("100");
    expect(slider.getAttribute("tabindex")).toBe("0");
  });

  it("removes and recreates one controls bar when the controls attribute toggles", () => {
    document.body.appendChild(player);

    player.setAttribute("controls", "");
    expect(player.shadowRoot!.querySelectorAll(".hfp-controls")).toHaveLength(1);

    player.removeAttribute("controls");
    expect(player.shadowRoot!.querySelectorAll(".hfp-controls")).toHaveLength(0);

    player.setAttribute("controls", "");
    expect(player.shadowRoot!.querySelectorAll(".hfp-controls")).toHaveLength(1);
  });

  it("dispatches volumechange when muted toggles (HTML5 spec)", () => {
    document.body.appendChild(player);

    const handler = vi.fn();
    player.addEventListener("volumechange", handler);

    player.muted = true;
    expect(handler).toHaveBeenCalledTimes(1);

    player.muted = false;
    expect(handler).toHaveBeenCalledTimes(2);
  });

  it("muted icon differs from volume=0 unmuted icon", () => {
    player.setAttribute("controls", "");
    document.body.appendChild(player);

    const shadow = player.shadowRoot!;
    const muteBtn = shadow.querySelector(".hfp-mute-btn")!;

    player.setAttribute("volume", "0");
    const zeroVolumeHtml = muteBtn.innerHTML;

    player.muted = true;
    const mutedHtml = muteBtn.innerHTML;

    expect(zeroVolumeHtml).not.toBe(mutedHtml);
  });
});

// ── Audio lock ──

describe("HyperframesPlayer audio lock", () => {
  let player: HTMLElement & { muted: boolean; audioLocked: boolean };

  beforeEach(async () => {
    await import("./hyperframes-player.js");
    player = document.createElement("hyperframes-player") as typeof player;
  });

  afterEach(() => {
    vi.restoreAllMocks();
    document.body.innerHTML = "";
  });

  it("audioLocked property toggles the audio-locked attribute", () => {
    document.body.appendChild(player);

    player.audioLocked = true;
    expect(player.hasAttribute("audio-locked")).toBe(true);

    player.audioLocked = false;
    expect(player.hasAttribute("audio-locked")).toBe(false);
  });

  it("forces muted when audio-locked is set", () => {
    document.body.appendChild(player);
    expect(player.hasAttribute("muted")).toBe(false);

    player.setAttribute("audio-locked", "");
    expect(player.muted).toBe(true);
    expect(player.hasAttribute("muted")).toBe(true);
  });

  it("re-asserts mute when something tries to unmute while locked", () => {
    document.body.appendChild(player);
    player.setAttribute("audio-locked", "");

    // Direct property unmute
    player.muted = false;
    expect(player.hasAttribute("muted")).toBe(true);

    // Raw attribute removal
    player.removeAttribute("muted");
    expect(player.hasAttribute("muted")).toBe(true);
  });

  it("allows unmute again once unlocked", () => {
    document.body.appendChild(player);
    player.setAttribute("audio-locked", "");
    expect(player.muted).toBe(true);

    // Unlock does NOT auto-unmute — it only lifts the restriction.
    player.removeAttribute("audio-locked");
    expect(player.muted).toBe(true);

    // Now the viewer/host can unmute.
    player.muted = false;
    expect(player.hasAttribute("muted")).toBe(false);
  });

  it("hides the volume controls when locked after controls exist", () => {
    player.setAttribute("controls", "");
    document.body.appendChild(player);

    const volumeWrap = player.shadowRoot!.querySelector(".hfp-volume-wrap") as HTMLElement;
    expect(volumeWrap.style.display).not.toBe("none");

    player.setAttribute("audio-locked", "");
    expect(volumeWrap.style.display).toBe("none");
  });

  it("hides the volume controls when controls are created while already locked", () => {
    player.setAttribute("audio-locked", "");
    player.setAttribute("controls", "");
    document.body.appendChild(player);

    const volumeWrap = player.shadowRoot!.querySelector(".hfp-volume-wrap") as HTMLElement;
    expect(volumeWrap.style.display).toBe("none");
  });

  it("restores the volume controls when unlocked", () => {
    player.setAttribute("controls", "");
    player.setAttribute("audio-locked", "");
    document.body.appendChild(player);

    const volumeWrap = player.shadowRoot!.querySelector(".hfp-volume-wrap") as HTMLElement;
    expect(volumeWrap.style.display).toBe("none");

    player.removeAttribute("audio-locked");
    expect(volumeWrap.style.display).not.toBe("none");
  });
});

describe("HyperframesPlayer runtime ready handshake", () => {
  // When the iframe runtime announces `{type: "ready"}` the player replays
  // current bridge state (muted, volume, playback rate) so any control message
  // that arrived before the iframe runtime registered its listener isn't lost.
  // This fixes a deterministic race on warm-cache reloads of claude.ai and
  // inside the Claude desktop Electron client where the iframe finishes
  // loading after the player has already set audio-locked.
  interface PlayerInternal extends HTMLElement {
    muted: boolean;
    volume: number;
    audioLocked: boolean;
    playbackRate: number;
    ready: boolean;
    duration: number;
    paused: boolean;
    iframe: HTMLIFrameElement;
    _onMessage: (event: MessageEvent) => void;
  }

  let player: PlayerInternal;
  let frameWindow: Window;
  let postSpy: ReturnType<typeof vi.spyOn>;

  function readyMessage() {
    return new MessageEvent("message", {
      source: frameWindow,
      data: { source: "hf-preview", type: "ready" },
    });
  }

  function timelineMessage(durationInFrames = 120) {
    return new MessageEvent("message", {
      source: frameWindow,
      data: {
        source: "hf-preview",
        type: "timeline",
        durationInFrames,
        scenes: [],
      },
    });
  }

  function findControlCalls(action: string) {
    return postSpy.mock.calls.filter((call) => {
      const data = call[0] as { type?: string; action?: string };
      return data?.type === "control" && data?.action === action;
    });
  }

  beforeEach(async () => {
    await import("./hyperframes-player.js");
    player = document.createElement("hyperframes-player") as PlayerInternal;
    frameWindow = window;
    postSpy = vi.spyOn(frameWindow, "postMessage").mockImplementation(() => undefined);
    Object.defineProperty(player.iframe, "contentWindow", {
      configurable: true,
      get: () => frameWindow,
    });
    document.body.appendChild(player);
  });

  afterEach(() => {
    player.remove();
    vi.restoreAllMocks();
  });

  it("replays current muted state when runtime emits ready", () => {
    player.muted = true;
    postSpy.mockClear();

    player._onMessage(readyMessage());

    const muteCalls = findControlCalls("set-muted");
    expect(muteCalls).toHaveLength(1);
    expect(muteCalls[0]?.[0]).toMatchObject({
      source: "hf-parent",
      type: "control",
      action: "set-muted",
      muted: true,
    });
  });

  it("replays volume and playback-rate alongside muted", () => {
    player.volume = 0.5;
    player.playbackRate = 1.25;
    postSpy.mockClear();

    player._onMessage(readyMessage());

    expect(findControlCalls("set-muted")).toHaveLength(1);
    expect(findControlCalls("set-volume")[0]?.[0]).toMatchObject({
      action: "set-volume",
      volume: 0.5,
    });
    expect(findControlCalls("set-playback-rate")[0]?.[0]).toMatchObject({
      action: "set-playback-rate",
      playbackRate: 1.25,
    });
  });

  it("keeps runtime WebAudio media enabled outside slideshow embeds", () => {
    postSpy.mockClear();

    player._onMessage(readyMessage());

    expect(findControlCalls("set-native-media-sync-disabled")[0]?.[0]).toMatchObject({
      action: "set-native-media-sync-disabled",
      disabled: false,
    });
    expect(findControlCalls("set-web-audio-media-disabled")[0]?.[0]).toMatchObject({
      action: "set-web-audio-media-disabled",
      disabled: false,
    });
  });

  it("disables runtime WebAudio media inside slideshow embeds", () => {
    const slideshow = document.createElement("hyperframes-slideshow");
    slideshow.appendChild(player);
    document.body.appendChild(slideshow);
    postSpy.mockClear();

    player._onMessage(readyMessage());

    expect(findControlCalls("set-native-media-sync-disabled")[0]?.[0]).toMatchObject({
      action: "set-native-media-sync-disabled",
      disabled: true,
    });
    expect(findControlCalls("set-web-audio-media-disabled")[0]?.[0]).toMatchObject({
      action: "set-web-audio-media-disabled",
      disabled: true,
    });
    slideshow.remove();
  });

  it("replays the muted state forced by audio-locked", () => {
    // The audio-locked attribute is the original motivating case for this
    // handshake — its `muted = true` side effect must survive an iframe race.
    player.setAttribute("audio-locked", "");
    expect(player.muted).toBe(true);
    postSpy.mockClear();

    player._onMessage(readyMessage());

    const muteCalls = findControlCalls("set-muted");
    expect(muteCalls).toHaveLength(1);
    expect(muteCalls[0]?.[0]).toMatchObject({ action: "set-muted", muted: true });
  });

  it("replays again on a second ready (idempotent — iframe reloads emit again)", () => {
    player.muted = true;
    postSpy.mockClear();

    player._onMessage(readyMessage());
    player._onMessage(readyMessage());

    expect(findControlCalls("set-muted")).toHaveLength(2);
  });

  it("ignores ready events from a different window", () => {
    postSpy.mockClear();
    const otherSource = {} as Window;

    player._onMessage(
      new MessageEvent("message", {
        source: otherSource,
        data: { source: "hf-preview", type: "ready" },
      }),
    );

    expect(findControlCalls("set-muted")).toHaveLength(0);
  });

  it("treats a cross-origin runtime timeline message as player ready", () => {
    const readyEvents: Array<{ duration: number }> = [];
    player.addEventListener("ready", (event) => {
      readyEvents.push((event as CustomEvent<{ duration: number }>).detail);
    });

    player._onMessage(timelineMessage(120));

    expect(player.ready).toBe(true);
    expect(player.duration).toBe(4);
    expect(readyEvents).toEqual([{ duration: 4 }]);
  });

  it("honors autoplay after cross-origin runtime timeline readiness", () => {
    player.setAttribute("autoplay", "");
    postSpy.mockClear();

    player._onMessage(timelineMessage(120));

    expect(player.paused).toBe(false);
    expect(findControlCalls("play")).toHaveLength(1);
  });

  it("rescales the iframe on cross-origin timeline readiness even without a stage-size message", () => {
    // Regression: the runtime's postTimeline() only sends `stage-size` when it
    // can resolve the root's data-width/data-height at that instant — a race
    // that can lose on first paint. onRuntimeTimelineReady must not depend on
    // stage-size having arrived, or the iframe is left unscaled/untranslated
    // (rendered pinned to the top-left instead of centered and fit).
    Object.defineProperty(player, "offsetWidth", { value: 400, configurable: true });
    Object.defineProperty(player, "offsetHeight", { value: 300, configurable: true });

    expect(player.iframe.style.transform).toBe("");

    player._onMessage(timelineMessage(120));

    expect(player.iframe.style.transform).not.toBe("");
    expect(player.iframe.style.transform).toContain("translate(-50%, -50%)");
  });

  it("warns at most once per instance when rescale keeps no-oping after ready", () => {
    // A player that stays zero-size after ready (hidden tab, collapsed
    // carousel card) keeps getting rescale attempts from every subsequent
    // width/height attribute change and ResizeObserver tick. The diagnostic
    // warning must not spam the console once per instance.
    const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);

    player._onMessage(timelineMessage(120)); // first no-op after ready
    player.setAttribute("width", "800"); // still zero-size — would no-op again
    player.setAttribute("height", "450"); // ditto

    expect(warnSpy).toHaveBeenCalledTimes(1);
  });
});

describe("HyperframesPlayer audio lock — Claude desktop UA fallback", () => {
  // Some host renderers (observed on the Claude desktop Electron client) strip
  // unknown custom-element attributes before they reach the DOM, so the
  // `audio-locked` attribute is lost. The player self-imposes the lock based
  // on UA detection so chat-host audio stays muted even without the attribute.
  let player: HTMLElement & { muted: boolean; audioLocked: boolean };
  let originalUserAgent: PropertyDescriptor | undefined;

  function stubUserAgent(ua: string) {
    Object.defineProperty(navigator, "userAgent", {
      value: ua,
      configurable: true,
    });
  }

  beforeEach(async () => {
    originalUserAgent = Object.getOwnPropertyDescriptor(
      Object.getPrototypeOf(navigator),
      "userAgent",
    );
    await import("./hyperframes-player.js");
    player = document.createElement("hyperframes-player") as typeof player;
  });

  afterEach(() => {
    if (originalUserAgent) {
      Object.defineProperty(Object.getPrototypeOf(navigator), "userAgent", originalUserAgent);
    }
    vi.restoreAllMocks();
    document.body.innerHTML = "";
  });

  it("forces muted on Claude desktop UA even without the audio-locked attribute", () => {
    stubUserAgent(
      "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
        "(KHTML, like Gecko) Claude/1.11187.4 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36",
    );

    document.body.appendChild(player);

    expect(player.hasAttribute("audio-locked")).toBe(false);
    expect(player.muted).toBe(true);
    expect(player.hasAttribute("muted")).toBe(true);
  });

  it("re-asserts mute on Claude desktop when something tries to unmute", () => {
    stubUserAgent("Claude/1.11187.4 Chrome/126.0.0.0 Electron/31.0.0");
    document.body.appendChild(player);

    player.muted = false;
    expect(player.hasAttribute("muted")).toBe(true);

    player.removeAttribute("muted");
    expect(player.hasAttribute("muted")).toBe(true);
  });

  it("hides the volume controls on Claude desktop without the attribute", () => {
    stubUserAgent("Claude/1.11187.4 Chrome/126.0.0.0 Electron/31.0.0");
    player.setAttribute("controls", "");
    document.body.appendChild(player);

    const volumeWrap = player.shadowRoot!.querySelector(".hfp-volume-wrap") as HTMLElement;
    expect(volumeWrap.style.display).toBe("none");
  });

  it("does NOT force mute on a regular browser UA", () => {
    stubUserAgent(
      "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
        "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
    );

    document.body.appendChild(player);

    expect(player.hasAttribute("audio-locked")).toBe(false);
    expect(player.muted).toBe(false);
    expect(player.hasAttribute("muted")).toBe(false);
  });

  it("does NOT force mute on Electron apps that aren't Claude desktop", () => {
    // Other Electron clients (e.g. VS Code embedded view) shouldn't be muted.
    stubUserAgent(
      "Mozilla/5.0 (Macintosh) AppleWebKit/537.36 Chrome/126.0.0.0 Electron/31.0.0 Safari/537.36",
    );

    document.body.appendChild(player);

    expect(player.muted).toBe(false);
  });

  it("keeps `audioLocked` property reflecting only the attribute, not the UA fallback", () => {
    // External consumers (pacific widget, etc.) read `audioLocked` to mirror
    // their own state. The UA fallback is an internal safety net and must not
    // leak into the public property — otherwise unsetting `audioLocked` would
    // appear to have no effect from the consumer's perspective.
    stubUserAgent("Claude/1.11187.4 Chrome/126.0.0.0 Electron/31.0.0");
    document.body.appendChild(player);

    expect(player.audioLocked).toBe(false);
  });
});

// ── Playback rate ──

describe("HyperframesPlayer playback rate", () => {
  let player: HTMLElement & {
    playbackRate: number;
    iframeElement: HTMLIFrameElement;
  };
  let mockAudio: {
    preload: string;
    src: string;
    muted: boolean;
    volume: number;
    playbackRate: number;
    currentTime: number;
    load: ReturnType<typeof vi.fn>;
    play: ReturnType<typeof vi.fn>;
    pause: ReturnType<typeof vi.fn>;
  };

  beforeEach(async () => {
    await import("./hyperframes-player.js");

    mockAudio = {
      preload: "",
      src: "",
      muted: false,
      volume: 1,
      playbackRate: 1,
      currentTime: 0,
      load: vi.fn(),
      play: vi.fn().mockResolvedValue(undefined),
      pause: vi.fn(),
    };
    vi.spyOn(globalThis, "Audio").mockImplementation(
      () => mockAudio as unknown as HTMLAudioElement,
    );

    player = document.createElement("hyperframes-player") as typeof player;
  });

  afterEach(() => {
    vi.restoreAllMocks();
    document.body.innerHTML = "";
  });

  it("defaults playbackRate to 1", () => {
    document.body.appendChild(player);
    expect(player.playbackRate).toBe(1);
  });

  it("clamps playbackRate to [0.1, 5]", () => {
    document.body.appendChild(player);

    player.playbackRate = 100;
    expect(player.playbackRate).toBe(5);
    // Assert the reflected attribute directly so the setter clamp is pinned
    // independently of the getter clamp.
    expect(player.getAttribute("playback-rate")).toBe("5");

    player.playbackRate = 0.01;
    expect(player.playbackRate).toBe(0.1);
    expect(player.getAttribute("playback-rate")).toBe("0.1");
  });

  it("falls back to 1 for a non-positive or non-finite playbackRate", () => {
    document.body.appendChild(player);

    player.playbackRate = -2;
    expect(player.playbackRate).toBe(1);
    expect(player.getAttribute("playback-rate")).toBe("1");

    player.playbackRate = 0;
    expect(player.playbackRate).toBe(1);

    player.playbackRate = NaN;
    expect(player.playbackRate).toBe(1);
  });

  it("propagates the clamped rate to parent media, never an out-of-range value", () => {
    player.setAttribute("audio-src", "https://cdn.example.com/narration.mp3");
    document.body.appendChild(player);

    player.setAttribute("playback-rate", "50");
    expect(mockAudio.playbackRate).toBe(5);

    player.setAttribute("playback-rate", "0.01");
    expect(mockAudio.playbackRate).toBe(0.1);
  });

  it("sends the clamped rate as a set-playback-rate control to the iframe", () => {
    document.body.appendChild(player);

    const postMessageSpy = vi.fn();
    Object.defineProperty(player.iframeElement, "contentWindow", {
      value: { postMessage: postMessageSpy },
      configurable: true,
    });

    player.setAttribute("playback-rate", "50");
    expect(postMessageSpy).toHaveBeenCalledWith(
      expect.objectContaining({
        source: "hf-parent",
        type: "control",
        action: "set-playback-rate",
        playbackRate: 5,
      }),
      "*",
    );
  });

  it("clamps an out-of-range value set directly via the attribute on read", () => {
    document.body.appendChild(player);

    player.setAttribute("playback-rate", "999");
    expect(player.playbackRate).toBe(5);
  });
});

// ── Composition dimension attributes ──
//
// width/height feed scaleIframeToFit's `w / compositionWidth` division. A
// non-numeric, zero, or negative attribute must fall back to the defaults
// instead of reaching the scale math as NaN (invalid `scale(NaN)` transform)
// or zero (division by zero) — both blank the player with no signal.

describe("HyperframesPlayer composition dimension attributes", () => {
  type PlayerWithDimensions = HTMLElement & {
    _compositionWidth?: number;
    _compositionHeight?: number;
  };

  let player: PlayerWithDimensions;

  beforeEach(async () => {
    await import("./hyperframes-player.js");
    player = document.createElement("hyperframes-player") as PlayerWithDimensions;
    document.body.appendChild(player);
  });

  afterEach(() => {
    document.body.innerHTML = "";
  });

  it("applies a valid width and height", () => {
    player.setAttribute("width", "1280");
    player.setAttribute("height", "720");
    expect(player._compositionWidth).toBe(1280);
    expect(player._compositionHeight).toBe(720);
  });

  it("falls back to defaults for non-numeric values", () => {
    player.setAttribute("width", "abc");
    player.setAttribute("height", "abc");
    expect(player._compositionWidth).toBe(1920);
    expect(player._compositionHeight).toBe(1080);
  });

  it("falls back to defaults for zero", () => {
    player.setAttribute("width", "0");
    player.setAttribute("height", "0");
    expect(player._compositionWidth).toBe(1920);
    expect(player._compositionHeight).toBe(1080);
  });

  it("falls back to defaults for negative values", () => {
    player.setAttribute("width", "-500");
    player.setAttribute("height", "-500");
    expect(player._compositionWidth).toBe(1920);
    expect(player._compositionHeight).toBe(1080);
  });

  it("recovers the defaults when the attribute is removed", () => {
    player.setAttribute("width", "1280");
    player.removeAttribute("width");
    expect(player._compositionWidth).toBe(1920);
  });
});
