import { afterEach, describe, expect, test, vi } from "vitest";
import {
    confirmMp4AlphaLayoutMetadata,
    createMp4AlphaLayout,
    detectMp4AlphaLayout,
    detectMp4AlphaLayoutFromFrames,
    isMp4Source,
    mp4AlphaLayoutFromMetadata,
    mp4FileFromSource,
    updateMp4AlphaLayoutFrame,
    updateMp4AlphaLayoutKind,
} from "./mp4AlphaLayout.js";

afterEach(() => {
    vi.unstubAllGlobals();
});

describe("mp4 alpha layout metadata", () => {
    test("builds a confirmed normal layout for a full-frame MP4", () => {
        const layout = createMp4AlphaLayout({
            alphaLayout: "normal",
            confirmed: true,
            videoH: 1334,
            videoW: 750,
        });

        expect(layout).toMatchObject({
            alpha_frame: null,
            alpha_layout: "normal",
            confirmed: true,
            rgb_frame: [0, 0, 750, 1334],
            video_h: 1334,
            video_w: 750,
        });
    });

    test("builds left-alpha and right-rgb frames for a 1500x1334 MP4", () => {
        const layout = createMp4AlphaLayout({
            alphaLayout: "alpha_left_rgb_right",
            confirmed: true,
            videoH: 1334,
            videoW: 1500,
        });

        expect(layout).toMatchObject({
            alpha_frame: [0, 0, 750, 1334],
            alpha_layout: "alpha_left_rgb_right",
            rgb_frame: [750, 0, 750, 1334],
            video_h: 1334,
            video_w: 1500,
        });
    });

    test("builds split frames for an uneven 1472x1328 MP4", () => {
        const layout = createMp4AlphaLayout({
            alphaLayout: "rgb_left_alpha_right",
            confirmed: true,
            videoH: 1328,
            videoW: 1472,
        });

        expect(layout).toMatchObject({
            alpha_frame: [736, 0, 736, 1328],
            alpha_layout: "rgb_left_alpha_right",
            rgb_frame: [0, 0, 736, 1328],
        });
    });

    test("keeps custom rgb and alpha rectangles", () => {
        const layout = createMp4AlphaLayout({
            alphaFrame: [10, 20, 200, 300],
            alphaLayout: "custom",
            confirmed: true,
            rgbFrame: [240, 20, 200, 300],
            videoH: 812,
            videoW: 750,
        });

        expect(layout).toMatchObject({
            alpha_frame: [10, 20, 200, 300],
            alpha_layout: "custom",
            rgb_frame: [240, 20, 200, 300],
        });
    });

    test("marks metadata unconfirmed when layout or frames are edited", () => {
        const metadataJson = confirmMp4AlphaLayoutMetadata(
            JSON.stringify({
                animation_format: "mp4",
                mp4_alpha_layout: createMp4AlphaLayout({
                    alphaLayout: "alpha_left_rgb_right",
                    confirmed: false,
                    videoH: 1334,
                    videoW: 1500,
                }),
            }),
        );

        expect(mp4AlphaLayoutFromMetadata(metadataJson)?.confirmed).toBe(true);
        expect(mp4AlphaLayoutFromMetadata(updateMp4AlphaLayoutKind(metadataJson, "normal"))).toMatchObject({
            alpha_layout: "normal",
            confirmed: false,
        });
        expect(
            mp4AlphaLayoutFromMetadata(updateMp4AlphaLayoutFrame(metadataJson, "rgb_frame", "760,0,700,1334")),
        ).toMatchObject({
            confirmed: false,
            rgb_frame: [760, 0, 700, 1334],
        });
    });

    test("detects and downloads mp4 source urls for row recalculation", async () => {
        const fetchMock = vi.fn(async () => ({
            blob: async () => new Blob(["mp4"], { type: "video/mp4" }),
            ok: true,
        }));
        vi.stubGlobal("fetch", fetchMock);

        expect(isMp4Source("https://cdn.example.test/assets/礼物_love_animation.mp4?token=1")).toBe(true);
        expect(isMp4Source("https://cdn.example.test/assets/rose.svga")).toBe(false);

        const file = await mp4FileFromSource("https://cdn.example.test/assets/%E7%A4%BC%E7%89%A9.mp4?token=1");

        expect(fetchMock).toHaveBeenCalledWith("https://cdn.example.test/assets/%E7%A4%BC%E7%89%A9.mp4?token=1");
        expect(file.name).toBe("礼物.mp4");
        expect(file.type).toBe("video/mp4");
    });

    test("keeps a regular 750x1334 portrait MP4 as normal layout", () => {
        const layout = detectMp4AlphaLayoutFromFrames([
            buildSyntheticFrame(750, 1334, {
                alphaFrame: [0, 0, 375, 1334],
                rgbFrame: [375, 0, 375, 1334],
            }),
        ]);

        expect(layout).toMatchObject({
            alpha_frame: null,
            alpha_layout: "normal",
            rgb_frame: [0, 0, 750, 1334],
            video_h: 1334,
            video_w: 750,
        });
    });

    test("detects a wide left-alpha right-rgb MP4 layout", () => {
        const layout = detectMp4AlphaLayoutFromFrames([
            buildSyntheticFrame(1500, 1334, {
                alphaFrame: [0, 0, 750, 1334],
                rgbFrame: [750, 0, 750, 1334],
            }),
        ]);

        expect(layout).toMatchObject({
            alpha_frame: [0, 0, 750, 1334],
            alpha_layout: "alpha_left_rgb_right",
            rgb_frame: [750, 0, 750, 1334],
            video_h: 1334,
            video_w: 1500,
        });
    });

    test("uses VAPC payload info as a custom transparent MP4 layout", async () => {
        const createElementSpy = vi.spyOn(document, "createElement").mockImplementation((tagName, options) => {
            if (String(tagName).toLowerCase() === "video") {
                throw new Error("VAPC payload should be parsed without decoding video frames");
            }
            return Document.prototype.createElement.call(document, tagName, options);
        });
        const file = buildVapcMp4File({
            info: {
                aFrame: [724, 0, 360, 780],
                rgbFrame: [0, 0, 720, 1560],
                videoH: 1568,
                videoW: 1088,
            },
        });

        try {
            const layout = await detectMp4AlphaLayout(file);

            expect(layout?.alpha_layout).not.toBe("normal");
            expect(layout).toMatchObject({
                alpha_frame: [724, 0, 360, 780],
                alpha_layout: "custom",
                rgb_frame: [0, 0, 720, 1560],
                video_h: 1568,
                video_w: 1088,
            });
        } finally {
            createElementSpy.mockRestore();
        }
    });

    test("ignores VAPC-looking bytes outside a real MP4 box and falls back to frame detection", async () => {
        const restoreMockVideo = mockVideoFrameRead({ height: 30, width: 20 });
        const file = buildMdatWithVapcBytesFile({
            info: {
                aFrame: [724, 0, 360, 780],
                rgbFrame: [0, 0, 720, 1560],
                videoH: 1568,
                videoW: 1088,
            },
        });

        try {
            const layout = await detectMp4AlphaLayout(file);

            expect(layout).toMatchObject({
                alpha_frame: null,
                alpha_layout: "normal",
                rgb_frame: [0, 0, 20, 30],
                video_h: 30,
                video_w: 20,
            });
        } finally {
            restoreMockVideo();
        }
    });
});

function buildVapcMp4File(payload) {
    return new File(
        [
            mp4Box("ftyp", new Uint8Array([105, 115, 111, 109, 0, 0, 0, 1, 105, 115, 111, 109])),
            mp4Box("vapc", new TextEncoder().encode(JSON.stringify(payload))),
        ],
        "vapc-layout.mp4",
        { type: "video/mp4" },
    );
}

function buildMdatWithVapcBytesFile(payload) {
    return new File(
        [
            mp4Box("ftyp", new Uint8Array([105, 115, 111, 109, 0, 0, 0, 1, 105, 115, 111, 109])),
            mp4Box("mdat", new TextEncoder().encode(`frame bytes vapc ${JSON.stringify(payload)}`)),
        ],
        "ordinary-with-vapc-bytes.mp4",
        { type: "video/mp4" },
    );
}

function mp4Box(type, payload) {
    const bytes = new Uint8Array(8 + payload.length);
    const view = new DataView(bytes.buffer);
    view.setUint32(0, bytes.length);
    for (let index = 0; index < 4; index += 1) {
        bytes[index + 4] = type.charCodeAt(index);
    }
    bytes.set(payload, 8);
    return bytes;
}

function mockVideoFrameRead({ width, height }) {
    const originalCreateElement = document.createElement.bind(document);
    const createElementSpy = vi.spyOn(document, "createElement").mockImplementation((tagName, options) => {
        const normalizedTag = String(tagName).toLowerCase();
        if (normalizedTag === "video") {
            return mockVideoElement({ height, width });
        }
        if (normalizedTag === "canvas") {
            const canvas = originalCreateElement(tagName, options);
            canvas.getContext = vi.fn(() => ({
                drawImage: vi.fn(),
                getImageData: vi.fn((left, top, canvasWidth, canvasHeight) => ({
                    data: new Uint8ClampedArray(canvasWidth * canvasHeight * 4),
                    height: canvasHeight,
                    width: canvasWidth,
                })),
            }));
            return canvas;
        }
        return originalCreateElement(tagName, options);
    });
    vi.stubGlobal("URL", {
        createObjectURL: vi.fn(() => "blob:mock-video"),
        revokeObjectURL: vi.fn(),
    });
    return () => {
        createElementSpy.mockRestore();
        vi.unstubAllGlobals();
    };
}

function mockVideoElement({ width, height }) {
    return {
        currentTime: 0,
        duration: 0,
        load: vi.fn(),
        muted: false,
        onerror: null,
        playsInline: false,
        preload: "",
        readyState: 2,
        removeAttribute: vi.fn(),
        src: "",
        videoHeight: height,
        videoWidth: width,
        addEventListener: vi.fn(),
        removeEventListener: vi.fn(),
        set onloadedmetadata(handler) {
            if (typeof handler === "function") {
                queueMicrotask(handler);
            }
        },
    };
}

function buildSyntheticFrame(width, height, { alphaFrame, rgbFrame }) {
    const data = new Uint8ClampedArray(width * height * 4);
    fillRect(data, width, [0, 0, width, height], [32, 32, 32]);
    fillCheckerRect(data, width, alphaFrame, [0, 0, 0], [255, 255, 255]);
    fillCheckerRect(data, width, rgbFrame, [230, 30, 20], [20, 150, 240]);
    return { data, height, width };
}

function fillRect(data, canvasWidth, rect, color) {
    const [left, top, width, height] = rect;
    for (let y = top; y < top + height; y += 1) {
        for (let x = left; x < left + width; x += 1) {
            const offset = (y * canvasWidth + x) * 4;
            data[offset] = color[0];
            data[offset + 1] = color[1];
            data[offset + 2] = color[2];
            data[offset + 3] = 255;
        }
    }
}

function fillCheckerRect(data, canvasWidth, rect, firstColor, secondColor) {
    const [left, top, width, height] = rect;
    for (let y = top; y < top + height; y += 1) {
        for (let x = left; x < left + width; x += 1) {
            const color =
                (Math.floor(x / 12) + Math.floor(y / 12)) % 2 === 0 ? firstColor : secondColor;
            const offset = (y * canvasWidth + x) * 4;
            data[offset] = color[0];
            data[offset + 1] = color[1];
            data[offset + 2] = color[2];
            data[offset + 3] = 255;
        }
    }
}
