// @vitest-environment node
import { describe, it, expect, vi } from "vitest";
import { parseHTML } from "linkedom";
import { type Page } from "puppeteer-core";
import {
  pageScreenshotCapture,
  cdpSessionCache,
  injectVideoFramesBatch,
  syncVideoFrameVisibility,
} from "./screenshotService.js";

// Stub a Page + CDPSession just enough that pageScreenshotCapture can call
// `client.send("Page.captureScreenshot", ...)` and we can inspect the args.
function makeFakePageWithCdp(send: (method: string, params: object) => Promise<{ data: string }>) {
  const fakeSession = { send } as unknown as import("puppeteer-core").CDPSession;
  // Stub a Page object — the WeakMap cache is the only Page-thing used in the
  // path under test, so we can pre-seed it and skip page.createCDPSession().
  const fakePage = {} as Page;
  cdpSessionCache.set(fakePage, fakeSession);
  return fakePage;
}

describe("pageScreenshotCapture supersample plumbing", () => {
  // Minimal 1×1 transparent PNG, base64. The function returns Buffer.from(data, "base64")
  // and we never inspect the bytes — only the params we pass to client.send.
  const ONE_PIXEL_PNG_B64 =
    "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=";

  it("passes `clip` with scale 1 when deviceScaleFactor is undefined (default 1)", async () => {
    const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
    const page = makeFakePageWithCdp(send);

    await pageScreenshotCapture(page, {
      width: 1920,
      height: 1080,
      fps: { num: 30, den: 1 },
      format: "jpeg",
      quality: 80,
    });

    expect(send).toHaveBeenCalledWith(
      "Page.captureScreenshot",
      expect.objectContaining({
        clip: { x: 0, y: 0, width: 1920, height: 1080, scale: 1 },
      }),
    );
  });

  it("passes `clip` with scale 1 when deviceScaleFactor is exactly 1", async () => {
    const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
    const page = makeFakePageWithCdp(send);

    await pageScreenshotCapture(page, {
      width: 1920,
      height: 1080,
      fps: { num: 30, den: 1 },
      format: "jpeg",
      deviceScaleFactor: 1,
    });

    const params = send.mock.calls[0]?.[1] as { clip?: { scale: number } };
    expect(params.clip).toEqual({ x: 0, y: 0, width: 1920, height: 1080, scale: 1 });
  });

  it("passes `clip` with `scale = dpr` when deviceScaleFactor > 1 (the supersample contract)", async () => {
    const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
    const page = makeFakePageWithCdp(send);

    await pageScreenshotCapture(page, {
      width: 1920,
      height: 1080,
      fps: { num: 30, den: 1 },
      format: "jpeg",
      deviceScaleFactor: 2,
    });

    expect(send).toHaveBeenCalledWith(
      "Page.captureScreenshot",
      expect.objectContaining({
        clip: { x: 0, y: 0, width: 1920, height: 1080, scale: 2 },
      }),
    );
  });

  it("propagates a non-2 supersample factor (e.g. 720p → 4K = 3×)", async () => {
    const send = vi.fn().mockResolvedValue({ data: ONE_PIXEL_PNG_B64 });
    const page = makeFakePageWithCdp(send);

    await pageScreenshotCapture(page, {
      width: 1280,
      height: 720,
      fps: { num: 30, den: 1 },
      format: "jpeg",
      deviceScaleFactor: 3,
    });

    const params = send.mock.calls[0]?.[1] as { clip?: { scale: number } };
    expect(params.clip?.scale).toBe(3);
  });
});

describe("injectVideoFramesBatch replacement layout", () => {
  it("does not copy opposing inset constraints onto the injected frame image", async () => {
    const { window, document } = parseHTML(
      '<html><body><div id="root"><video id="clip" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover"></video></div></body></html>',
    );

    Object.defineProperty(window.HTMLImageElement.prototype, "decode", {
      configurable: true,
      value: () => Promise.resolve(),
    });

    const video = document.getElementById("clip") as HTMLVideoElement;
    Object.defineProperties(video, {
      offsetLeft: { configurable: true, get: () => 0 },
      offsetTop: { configurable: true, get: () => 0 },
      offsetWidth: { configurable: true, get: () => 1920 },
      offsetHeight: { configurable: true, get: () => 1080 },
    });
    video.getBoundingClientRect = () =>
      ({
        x: 0,
        y: 0,
        left: 0,
        top: 0,
        right: 1920,
        bottom: 1080,
        width: 1920,
        height: 1080,
        toJSON: () => ({}),
      }) as DOMRect;

    const computedStyle = document.createElement("div").style;
    computedStyle.position = "absolute";
    computedStyle.width = "1920px";
    computedStyle.height = "1080px";
    computedStyle.top = "0px";
    computedStyle.left = "0px";
    computedStyle.right = "0px";
    computedStyle.bottom = "0px";
    computedStyle.inset = "0px";
    computedStyle.objectFit = "cover";
    computedStyle.objectPosition = "center center";
    computedStyle.zIndex = "3";
    computedStyle.opacity = "1";
    Object.defineProperty(window, "getComputedStyle", {
      configurable: true,
      value: () => computedStyle,
    });

    const globals = globalThis as unknown as {
      window?: typeof window;
      document?: Document;
    };
    const previousWindow = globals.window;
    const previousDocument = globals.document;
    globals.window = window;
    globals.document = document;
    try {
      const page = {
        evaluate: async (
          fn: (
            updates: Array<{ videoId: string; dataUri: string }>,
            visualProperties: string[],
          ) => Promise<void>,
          updates: Array<{ videoId: string; dataUri: string }>,
          visualProperties: string[],
        ) => fn(updates, visualProperties),
      } as unknown as Page;

      await injectVideoFramesBatch(page, [
        {
          videoId: "clip",
          dataUri:
            "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
        },
      ]);
    } finally {
      globals.window = previousWindow;
      globals.document = previousDocument;
    }

    const img = video.nextElementSibling as HTMLImageElement | null;
    expect(img).not.toBeNull();
    expect(img?.style.position).toBe("absolute");
    expect(img?.style.left).toBe("0px");
    expect(img?.style.top).toBe("0px");
    expect(img?.style.width).toBe("1920px");
    expect(img?.style.height).toBe("1080px");
    expect(img?.style.right).toBe("auto");
    expect(img?.style.bottom).toBe("auto");
    expect(img?.style.inset).toBe("auto");
  });
});

describe("video-frame injection respects ancestor visibility", () => {
  // Regression guard: the runtime's `[data-start]` lifecycle hides
  // out-of-window sub-composition hosts with `visibility:hidden`, but the
  // injector used to ignore that and paint a replacement <img> for every
  // active `<video data-start>` element. Inner-PIP videos inside *other*
  // moments still appear active in the raw time-window check (their auto-
  // injected `data-start="0"` + probed full-source duration cover the
  // whole timeline), so the bug produced one full-bleed speaker overlay
  // per inactive sub-comp — covering whichever moment was actually visible.
  //
  // The skip is intentionally narrow: `visibility:hidden` on a regular
  // `[data-start]` container must NOT skip injection, because the
  // replacement <img>'s explicit `visibility:visible` overrides the
  // ancestor (CSS spec) and consumers rely on that to hold the final
  // GSAP-driven frame when an authored `data-duration` outlives the
  // composition's GSAP timeline. We therefore only treat
  // `visibility:hidden` as a skip signal on sub-composition hosts
  // (`[data-composition-src]` / `[data-composition-file]`). `display:none`,
  // by contrast, takes the whole subtree out of layout regardless of any
  // child override, so it always triggers the skip.

  type StyleLike = {
    display?: string;
    visibility?: string;
    opacity?: string;
    objectFit?: string;
    objectPosition?: string;
    zIndex?: string;
  };

  type HostAttribute = "data-composition-src" | "data-composition-file" | "data-start";

  function setupHostHiddenScenario(
    hostStyle: StyleLike,
    options: { hostAttribute?: HostAttribute } = {},
  ) {
    const hostAttribute = options.hostAttribute ?? "data-composition-src";
    const hostAttrMarkup =
      hostAttribute === "data-start"
        ? 'data-start="0" data-duration="10"'
        : `${hostAttribute}="sub.html"`;
    const { window, document } = parseHTML(
      `<html><body><div id="host" ${hostAttrMarkup}><div id="pip-frame"><video id="pip" data-start="0" data-duration="10"></video></div></div></body></html>`,
    );

    Object.defineProperty(window.HTMLImageElement.prototype, "decode", {
      configurable: true,
      value: () => Promise.resolve(),
    });

    const host = document.getElementById("host") as HTMLElement;
    const pipFrame = document.getElementById("pip-frame") as HTMLElement;
    const video = document.getElementById("pip") as HTMLVideoElement;

    Object.defineProperties(video, {
      offsetLeft: { configurable: true, get: () => 0 },
      offsetTop: { configurable: true, get: () => 0 },
      offsetWidth: { configurable: true, get: () => 1080 },
      offsetHeight: { configurable: true, get: () => 1920 },
    });
    video.getBoundingClientRect = () =>
      ({
        x: 0,
        y: 0,
        left: 0,
        top: 0,
        right: 1080,
        bottom: 1920,
        width: 1080,
        height: 1920,
        toJSON: () => ({}),
      }) as DOMRect;

    const styles = new Map<Element, StyleLike>();
    styles.set(host, hostStyle);
    styles.set(pipFrame, {});
    styles.set(video, { opacity: "1", objectFit: "cover", objectPosition: "center", zIndex: "1" });

    Object.defineProperty(window, "getComputedStyle", {
      configurable: true,
      value: (el: Element) => {
        const declared = styles.get(el) ?? {};
        return {
          display: declared.display ?? "block",
          visibility: declared.visibility ?? "visible",
          opacity: declared.opacity ?? "1",
          objectFit: declared.objectFit ?? "fill",
          objectPosition: declared.objectPosition ?? "50% 50%",
          zIndex: declared.zIndex ?? "auto",
          getPropertyValue: (prop: string) => {
            const camel = prop.replace(/-([a-z])/g, (_, c: string) =>
              c.toUpperCase(),
            ) as keyof StyleLike;
            return declared[camel] ?? "";
          },
        };
      },
    });

    return { window, document, video, host, pipFrame };
  }

  function withGlobals<T extends { window: Window; document: Document; video: HTMLVideoElement }>(
    setup: T,
  ): { teardown: () => void; setup: T } {
    const globals = globalThis as unknown as { window?: Window; document?: Document };
    const previousWindow = globals.window;
    const previousDocument = globals.document;
    globals.window = setup.window;
    globals.document = setup.document;
    return {
      setup,
      teardown: () => {
        globals.window = previousWindow;
        globals.document = previousDocument;
      },
    };
  }

  function passthroughPage(): Page {
    return {
      evaluate: async (fn: (...args: unknown[]) => unknown, ...args: unknown[]) =>
        // The implementation is built to run inside the page sandbox via
        // `page.evaluate`, but linkedom gives us a DOM compatible enough to
        // execute the function body directly in Node.
        Promise.resolve((fn as (...a: unknown[]) => unknown)(...args)),
    } as unknown as Page;
  }

  it("skips replacement-frame creation when the video's host has visibility:hidden", async () => {
    const { teardown, setup } = withGlobals(setupHostHiddenScenario({ visibility: "hidden" }));
    try {
      await injectVideoFramesBatch(passthroughPage(), [
        {
          videoId: "pip",
          dataUri:
            "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
        },
      ]);
    } finally {
      teardown();
    }

    // No replacement <img> should be injected next to the video — the host is
    // currently hidden, so painting a frame over it would bleed onto whichever
    // sibling host is actually visible on this seek.
    const sibling = setup.video.nextElementSibling as HTMLElement | null;
    expect(sibling).toBeNull();
  });

  it("skips replacement-frame creation when the video's host has display:none", async () => {
    const { teardown, setup } = withGlobals(setupHostHiddenScenario({ display: "none" }));
    try {
      await injectVideoFramesBatch(passthroughPage(), [
        {
          videoId: "pip",
          dataUri:
            "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
        },
      ]);
    } finally {
      teardown();
    }

    const sibling = setup.video.nextElementSibling as HTMLElement | null;
    expect(sibling).toBeNull();
  });

  it("hides an existing replacement <img> when the host becomes visibility:hidden", async () => {
    // First seed an existing __render_frame__ <img> next to the video (the
    // state the page is in after a previous seek when the host was visible).
    const { teardown, setup } = withGlobals(setupHostHiddenScenario({ visibility: "hidden" }));
    const seededImg = setup.document.createElement("img");
    seededImg.classList.add("__render_frame__");
    seededImg.style.visibility = "visible";
    setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);

    try {
      await injectVideoFramesBatch(passthroughPage(), [
        {
          videoId: "pip",
          dataUri:
            "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
        },
      ]);
    } finally {
      teardown();
    }

    expect(seededImg.style.visibility).toBe("hidden");
  });

  it("syncVideoFrameVisibility hides the replacement <img> for ancestor-hidden actives", async () => {
    const { teardown, setup } = withGlobals(setupHostHiddenScenario({ visibility: "hidden" }));
    const seededImg = setup.document.createElement("img");
    seededImg.classList.add("__render_frame__");
    seededImg.style.visibility = "visible";
    setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);

    try {
      // "pip" IS in the active set (per the raw time-window check) but the
      // host is hidden. sync must keep the <img> hidden, not flip it to
      // `visibility: visible`.
      await syncVideoFrameVisibility(passthroughPage(), ["pip"]);
    } finally {
      teardown();
    }

    expect(seededImg.style.visibility).toBe("hidden");
  });

  it("still injects when a plain [data-start] host is visibility:hidden (CSS-escapable)", async () => {
    // Regression guard for the style-9-prod symptom: a regular
    // `[data-start]` container whose GSAP timeline is shorter than its
    // authored `data-duration` ends up `visibility: hidden` past the
    // timeline end. The replacement <img>'s explicit `visibility: visible`
    // correctly overrides that per CSS spec, so the injector must NOT
    // short-circuit — it would otherwise drop the final-state frame and
    // produce blank tail frames.
    const { teardown, setup } = withGlobals(
      setupHostHiddenScenario({ visibility: "hidden" }, { hostAttribute: "data-start" }),
    );

    try {
      await injectVideoFramesBatch(passthroughPage(), [
        {
          videoId: "pip",
          dataUri:
            "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
        },
      ]);
    } finally {
      teardown();
    }

    const sibling = setup.video.nextElementSibling as HTMLElement | null;
    expect(sibling).not.toBeNull();
    expect(sibling?.classList.contains("__render_frame__")).toBe(true);
    expect(sibling?.style.visibility).toBe("visible");
  });

  it("syncVideoFrameVisibility shows the replacement <img> when a plain [data-start] host is visibility:hidden", async () => {
    const { teardown, setup } = withGlobals(
      setupHostHiddenScenario({ visibility: "hidden" }, { hostAttribute: "data-start" }),
    );
    const seededImg = setup.document.createElement("img");
    seededImg.classList.add("__render_frame__");
    seededImg.style.visibility = "hidden";
    setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);

    try {
      await syncVideoFrameVisibility(passthroughPage(), ["pip"]);
    } finally {
      teardown();
    }

    // The host's `visibility: hidden` is escapable; sync must flip the
    // <img> to `visibility: visible` so it overrides the ancestor.
    expect(seededImg.style.visibility).toBe("visible");
  });

  // Regression for the layered/HDR mask path: `applyDomLayerMask` writes an
  // `!important` stylesheet rule `#${showId} *{visibility:visible !important}`
  // which, if a sub-comp host id appears in the show set, would revive a
  // plain (non-important) inline `visibility: hidden` on a descendant
  // `__render_frame__` — the cascade rule is "important stylesheet author
  // beats non-important inline author". To stay safe regardless of which
  // layer ends up in `show`, the ancestor-hidden hide must be written with
  // `!important` so inline `!important` beats stylesheet `!important`.
  //
  // linkedom strips `!important` from `cssText`/`getPropertyPriority`, so we
  // pin the contract on the API call site instead: a `setProperty(name,
  // value, "important")` invocation on the live `<img>`'s style.
  it("injectVideoFramesBatch hides a stale <img> with !important so the layer mask cannot revive it", async () => {
    const { teardown, setup } = withGlobals(setupHostHiddenScenario({ visibility: "hidden" }));
    const seededImg = setup.document.createElement("img");
    seededImg.classList.add("__render_frame__");
    seededImg.style.visibility = "visible";
    setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);
    const setPropertySpy = vi.spyOn(seededImg.style, "setProperty");

    try {
      await injectVideoFramesBatch(passthroughPage(), [
        {
          videoId: "pip",
          dataUri:
            "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkAAIAAAoAAv/lxKUAAAAASUVORK5CYII=",
        },
      ]);
    } finally {
      teardown();
    }

    expect(seededImg.style.visibility).toBe("hidden");
    expect(setPropertySpy).toHaveBeenCalledWith("visibility", "hidden", "important");
  });

  it("syncVideoFrameVisibility hides an existing <img> with !important so the layer mask cannot revive it", async () => {
    const { teardown, setup } = withGlobals(setupHostHiddenScenario({ visibility: "hidden" }));
    const seededImg = setup.document.createElement("img");
    seededImg.classList.add("__render_frame__");
    seededImg.style.visibility = "visible";
    setup.video.parentNode?.insertBefore(seededImg, setup.video.nextSibling);
    const setPropertySpy = vi.spyOn(seededImg.style, "setProperty");

    try {
      await syncVideoFrameVisibility(passthroughPage(), ["pip"]);
    } finally {
      teardown();
    }

    expect(seededImg.style.visibility).toBe("hidden");
    expect(setPropertySpy).toHaveBeenCalledWith("visibility", "hidden", "important");
  });
});
