/**
 * @vitest-environment jsdom
 */
import { describe, expect, it, vi, beforeEach, afterEach, Mock } from "vitest";

import {
    isTouchDevice,
    isHandheldDevice,
    resolveButtonFromElement,
    TouchInputManager,
    NES_BUTTON,
} from "./touch_controls";

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

/** Create a minimal DOM element with a data-button attribute. */
function makeButtonElement(button: string): Element {
    const el = document.createElement("div");
    el.setAttribute("data-button", button);
    return el;
}

/** Create a touch zone element with a fixed client rect for geometry tests. */
function makeTouchZoneElement(
    zone: string,
    rect: { left: number; top: number; width: number; height: number },
): Element {
    const el = document.createElement("div");
    el.setAttribute("data-touch-zone", zone);
    Object.defineProperty(el, "getBoundingClientRect", {
        configurable: true,
        value: () => ({
            x: rect.left,
            y: rect.top,
            left: rect.left,
            top: rect.top,
            width: rect.width,
            height: rect.height,
            right: rect.left + rect.width,
            bottom: rect.top + rect.height,
            toJSON: () => rect,
        }),
    });
    return el;
}

/** Build a container that houses several button elements and can resolve
 *  elementFromPoint queries. */
function makeContainer(buttons: Record<string, Element>) {
    const container = document.createElement("div");
    for (const el of Object.values(buttons)) {
        container.appendChild(el);
    }
    return container;
}

/** Create a fake Touch object with the given identifier and target element. */
function fakeTouch(identifier: number, target: Element, x = 0, y = 0): Touch {
    return {
        identifier,
        target,
        clientX: x,
        clientY: y,
        pageX: x,
        pageY: y,
        screenX: x,
        screenY: y,
        radiusX: 0,
        radiusY: 0,
        rotationAngle: 0,
        force: 0,
    };
}

// ---------------------------------------------------------------------------
// isTouchDevice
// ---------------------------------------------------------------------------

describe("isTouchDevice", () => {
    it("returns true when ontouchstart is in window", () => {
        // Arrange: simulate a touch-capable browser
        Object.defineProperty(window, "ontouchstart", {
            value: null,
            writable: true,
            configurable: true,
        });

        // Act & Assert
        expect(isTouchDevice()).toBe(true);

        // Cleanup
        delete (window as any).ontouchstart;
    });

    it("returns true when navigator.maxTouchPoints > 0", () => {
        Object.defineProperty(navigator, "maxTouchPoints", {
            value: 5,
            writable: true,
            configurable: true,
        });

        expect(isTouchDevice()).toBe(true);

        Object.defineProperty(navigator, "maxTouchPoints", {
            value: 0,
            writable: true,
            configurable: true,
        });
    });

    it("returns false on a desktop browser", () => {
        // Ensure neither touch indicator is present
        delete (window as any).ontouchstart;
        Object.defineProperty(navigator, "maxTouchPoints", {
            value: 0,
            writable: true,
            configurable: true,
        });

        expect(isTouchDevice()).toBe(false);
    });
});

// ---------------------------------------------------------------------------
// isHandheldDevice
// ---------------------------------------------------------------------------

/** Helper: install a mock window.matchMedia that matches `(pointer: coarse)`. */
function mockMatchMedia(matchesPointerCoarse: boolean) {
    vi.stubGlobal(
        "matchMedia",
        vi.fn((query: string) => ({
            matches: query === "(pointer: coarse)" ? matchesPointerCoarse : false,
            media: query,
            onchange: null,
            addListener: vi.fn(),
            removeListener: vi.fn(),
            addEventListener: vi.fn(),
            removeEventListener: vi.fn(),
            dispatchEvent: vi.fn(),
        })),
    );
}

describe("isHandheldDevice", () => {
    beforeEach(() => {
        // Default to a large desktop viewport
        vi.stubGlobal("innerWidth", 1920);
        vi.stubGlobal("innerHeight", 1080);
    });

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

    it("returns true when pointer:coarse and small portrait viewport", () => {
        mockMatchMedia(true);
        vi.stubGlobal("innerWidth", 390);
        vi.stubGlobal("innerHeight", 844);
        expect(isHandheldDevice()).toBe(true);
    });

    it("returns true in landscape orientation where min dimension is still small", () => {
        mockMatchMedia(true);
        vi.stubGlobal("innerWidth", 844);
        vi.stubGlobal("innerHeight", 390);
        expect(isHandheldDevice()).toBe(true);
    });

    it("returns false when pointer:coarse but viewport is too large", () => {
        mockMatchMedia(true);
        // innerWidth/innerHeight set to 1920x1080 by beforeEach
        expect(isHandheldDevice()).toBe(false);
    });

    it("returns false when not pointer:coarse even with small viewport", () => {
        mockMatchMedia(false);
        vi.stubGlobal("innerWidth", 390);
        vi.stubGlobal("innerHeight", 844);
        expect(isHandheldDevice()).toBe(false);
    });

    it("returns false when matchMedia is not available", () => {
        vi.stubGlobal("matchMedia", undefined);
        vi.stubGlobal("innerWidth", 390);
        vi.stubGlobal("innerHeight", 844);
        expect(isHandheldDevice()).toBe(false);
    });
});

// ---------------------------------------------------------------------------
// resolveButtonFromElement
// ---------------------------------------------------------------------------

describe("resolveButtonFromElement", () => {
    it("returns the button number for element with data-button='a'", () => {
        const el = makeButtonElement("a");
        expect(resolveButtonFromElement(el)).toBe(NES_BUTTON.A);
    });

    it("returns the button number for element with data-button='b'", () => {
        const el = makeButtonElement("b");
        expect(resolveButtonFromElement(el)).toBe(NES_BUTTON.B);
    });

    it("returns the button number for each d-pad direction", () => {
        expect(resolveButtonFromElement(makeButtonElement("up"))).toBe(NES_BUTTON.UP);
        expect(resolveButtonFromElement(makeButtonElement("down"))).toBe(NES_BUTTON.DOWN);
        expect(resolveButtonFromElement(makeButtonElement("left"))).toBe(NES_BUTTON.LEFT);
        expect(resolveButtonFromElement(makeButtonElement("right"))).toBe(NES_BUTTON.RIGHT);
    });

    it("returns the button number for start and select", () => {
        expect(resolveButtonFromElement(makeButtonElement("start"))).toBe(NES_BUTTON.START);
        expect(resolveButtonFromElement(makeButtonElement("select"))).toBe(NES_BUTTON.SELECT);
    });

    it("returns undefined for element without data-button", () => {
        const el = document.createElement("div");
        expect(resolveButtonFromElement(el)).toBeUndefined();
    });

    it("returns undefined for null element", () => {
        expect(resolveButtonFromElement(null)).toBeUndefined();
    });
});

// ---------------------------------------------------------------------------
// TouchInputManager — basic press/release
// ---------------------------------------------------------------------------

describe("TouchInputManager", () => {
    let callback: Mock<(button: number, pressed: boolean) => void>;
    let manager: TouchInputManager;
    let container: HTMLElement;
    let btnA: Element;
    let btnB: Element;
    let btnUp: Element;
    let btnRight: Element;
    let joystickZone: Element;

    beforeEach(() => {
        callback = vi.fn();
        manager = new TouchInputManager(callback);

        btnA = makeButtonElement("a");
        btnB = makeButtonElement("b");
        btnUp = makeButtonElement("up");
        btnRight = makeButtonElement("right");
        joystickZone = makeTouchZoneElement("joystick", {
            left: 0,
            top: 0,
            width: 128,
            height: 128,
        });
        container = makeContainer({
            a: btnA,
            b: btnB,
            up: btnUp,
            right: btnRight,
            joystick: joystickZone,
        }) as HTMLElement;
    });

    it("fires pressed callback when touch starts on a button", () => {
        const touch = fakeTouch(0, btnA);
        manager.handleTouchStart(touch);

        expect(callback).toHaveBeenCalledWith(NES_BUTTON.A, true);
        expect(manager.activeCount).toBe(1);
    });

    it("fires released callback when touch ends", () => {
        const touch = fakeTouch(0, btnA);
        manager.handleTouchStart(touch);
        callback.mockClear();

        manager.handleTouchEnd(touch);

        expect(callback).toHaveBeenCalledWith(NES_BUTTON.A, false);
        expect(manager.activeCount).toBe(0);
    });

    it("fires released callback on touchcancel", () => {
        const touch = fakeTouch(0, btnA);
        manager.handleTouchStart(touch);
        callback.mockClear();

        manager.handleTouchCancel(touch);

        expect(callback).toHaveBeenCalledWith(NES_BUTTON.A, false);
        expect(manager.activeCount).toBe(0);
    });

    it("does not fire callback when touch starts outside any button", () => {
        const outsideEl = document.createElement("div");
        container.appendChild(outsideEl);
        const touch = fakeTouch(0, outsideEl);

        manager.handleTouchStart(touch);

        expect(callback).not.toHaveBeenCalled();
        expect(manager.activeCount).toBe(0);
    });

    it("tracks the active buttons for each touch identifier", () => {
        manager.handleTouchStart(fakeTouch(0, btnA));
        manager.handleTouchStart(fakeTouch(1, btnB));

        expect(manager.getButtonsForTouch(0)).toEqual([NES_BUTTON.A]);
        expect(manager.getButtonsForTouch(1)).toEqual([NES_BUTTON.B]);
        expect(manager.activeCount).toBe(2);
    });

    // -----------------------------------------------------------------------
    // Multi-touch — simultaneous button presses
    // -----------------------------------------------------------------------

    it("supports simultaneous presses on different buttons", () => {
        manager.handleTouchStart(fakeTouch(0, btnRight));
        manager.handleTouchStart(fakeTouch(1, btnA));

        expect(callback).toHaveBeenCalledWith(NES_BUTTON.RIGHT, true);
        expect(callback).toHaveBeenCalledWith(NES_BUTTON.A, true);
        expect(manager.activeCount).toBe(2);
    });

    it("releases only the specific touch that ended", () => {
        manager.handleTouchStart(fakeTouch(0, btnRight));
        manager.handleTouchStart(fakeTouch(1, btnA));
        callback.mockClear();

        manager.handleTouchEnd(fakeTouch(0, btnRight));

        expect(callback).toHaveBeenCalledTimes(1);
        expect(callback).toHaveBeenCalledWith(NES_BUTTON.RIGHT, false);
        expect(manager.activeCount).toBe(1);
        expect(manager.getButtonsForTouch(1)).toEqual([NES_BUTTON.A]);
    });

    it("does not release a button while another touch still holds the same button", () => {
        manager.handleTouchStart(fakeTouch(0, btnRight));
        callback.mockClear();

        manager.handleTouchStart(fakeTouch(1, btnRight));

        expect(callback).not.toHaveBeenCalled();

        manager.handleTouchEnd(fakeTouch(0, btnRight));

        expect(callback).not.toHaveBeenCalled();

        manager.handleTouchEnd(fakeTouch(1, btnRight));

        expect(callback).toHaveBeenCalledTimes(1);
        expect(callback).toHaveBeenCalledWith(NES_BUTTON.RIGHT, false);
    });

    // -----------------------------------------------------------------------
    // Touch move — sliding between buttons
    // -----------------------------------------------------------------------

    it("fires release old + press new when finger slides to another button", () => {
        manager.handleTouchStart(fakeTouch(0, btnUp));
        callback.mockClear();

        // Simulate finger sliding to the Right button
        manager.handleTouchMove(fakeTouch(0, btnRight));

        expect(callback).toHaveBeenCalledWith(NES_BUTTON.UP, false);
        expect(callback).toHaveBeenCalledWith(NES_BUTTON.RIGHT, true);
        expect(manager.getButtonsForTouch(0)).toEqual([NES_BUTTON.RIGHT]);
    });

    it("does not fire callback when finger stays on the same button during move", () => {
        manager.handleTouchStart(fakeTouch(0, btnA));
        callback.mockClear();

        manager.handleTouchMove(fakeTouch(0, btnA));

        expect(callback).not.toHaveBeenCalled();
    });

    it("releases button when finger slides off all buttons", () => {
        manager.handleTouchStart(fakeTouch(0, btnA));
        callback.mockClear();

        const outsideEl = document.createElement("div");
        container.appendChild(outsideEl);
        manager.handleTouchMove(fakeTouch(0, outsideEl));

        expect(callback).toHaveBeenCalledWith(NES_BUTTON.A, false);
        expect(manager.activeCount).toBe(0);
    });

    // -----------------------------------------------------------------------
    // Geometry-driven touch zones — joystick
    // -----------------------------------------------------------------------

    it.each([
        ["up", 64, 14, [NES_BUTTON.UP]],
        ["up-right", 110, 18, [NES_BUTTON.UP, NES_BUTTON.RIGHT]],
        ["right", 118, 64, [NES_BUTTON.RIGHT]],
        ["down-right", 110, 110, [NES_BUTTON.DOWN, NES_BUTTON.RIGHT]],
        ["down", 64, 118, [NES_BUTTON.DOWN]],
        ["down-left", 18, 110, [NES_BUTTON.DOWN, NES_BUTTON.LEFT]],
        ["left", 10, 64, [NES_BUTTON.LEFT]],
        ["up-left", 18, 18, [NES_BUTTON.UP, NES_BUTTON.LEFT]],
    ])(
        "Given a captured joystick touch, When the thumb moves to the %s sector, Then the matching directions are pressed",
        (_label, clientX, clientY, expectedButtons) => {
            manager.handleTouchStart(fakeTouch(0, joystickZone, 64, 64));
            callback.mockClear();

            manager.handleTouchMove(fakeTouch(0, joystickZone, clientX, clientY));

            expect(manager.activeCount).toBe(1);
            expect(manager.getButtonsForTouch(0)).toEqual(expectedButtons);
            expect(callback).toHaveBeenCalledTimes(expectedButtons.length);
            expect(callback.mock.calls).toEqual(
                expect.arrayContaining(expectedButtons.map((button) => [button, true])),
            );
        },
    );

    it("does not release a direction while another joystick touch still holds it", () => {
        manager.handleTouchStart(fakeTouch(0, joystickZone, 118, 64));
        callback.mockClear();

        manager.handleTouchStart(fakeTouch(1, joystickZone, 118, 64));

        expect(callback).not.toHaveBeenCalled();

        manager.handleTouchEnd(fakeTouch(0, joystickZone, 118, 64));

        expect(callback).not.toHaveBeenCalled();
        expect(manager.getButtonsForTouch(1)).toEqual([NES_BUTTON.RIGHT]);

        manager.handleTouchEnd(fakeTouch(1, joystickZone, 118, 64));

        expect(callback).toHaveBeenCalledTimes(1);
        expect(callback).toHaveBeenCalledWith(NES_BUTTON.RIGHT, false);
    });

    it("Given a captured joystick touch, When the thumb returns to the deadzone, Then the active directions are released", () => {
        manager.handleTouchStart(fakeTouch(0, joystickZone, 64, 64));
        manager.handleTouchMove(fakeTouch(0, joystickZone, 118, 64));
        callback.mockClear();

        manager.handleTouchMove(fakeTouch(0, joystickZone, 64, 64));

        expect(manager.activeCount).toBe(1);
        expect(callback).toHaveBeenCalledTimes(1);
        expect(callback).toHaveBeenCalledWith(NES_BUTTON.RIGHT, false);
    });

    it("Given a captured joystick touch, When the thumb moves beyond the circle, Then the direction stays active until release", () => {
        manager.handleTouchStart(fakeTouch(0, joystickZone, 64, 64));
        manager.handleTouchMove(fakeTouch(0, joystickZone, 18, 110));
        callback.mockClear();

        manager.handleTouchMove(fakeTouch(0, joystickZone, -48, 192));

        expect(manager.activeCount).toBe(1);
        expect(callback).not.toHaveBeenCalled();

        manager.handleTouchEnd(fakeTouch(0, joystickZone, -48, 192));

        expect(callback.mock.calls).toEqual(
            expect.arrayContaining([
                [NES_BUTTON.DOWN, false],
                [NES_BUTTON.LEFT, false],
            ]),
        );
    });

    // -----------------------------------------------------------------------
    // Visual feedback
    // -----------------------------------------------------------------------

    it("Given a button touch, When it starts and ends, Then the pressed class follows the button state", () => {
        manager.handleTouchStart(fakeTouch(0, btnA));
        expect(btnA.classList.contains("pressed")).toBe(true);

        manager.handleTouchEnd(fakeTouch(0, btnA));
        expect(btnA.classList.contains("pressed")).toBe(false);
    });

    it("Given a joystick touch, When it moves away from center and ends, Then the joystick pressed state and knob offset are updated", () => {
        const knob = document.createElement("div");
        knob.className = "touch-joystick-knob";
        joystickZone.appendChild(knob);

        manager.handleTouchStart(fakeTouch(0, joystickZone, 64, 64));
        manager.handleTouchMove(fakeTouch(0, joystickZone, 118, 64));

        expect(joystickZone.classList.contains("pressed")).toBe(true);
        expect(joystickZone.style.getPropertyValue("--touch-stick-x")).not.toBe("");
        expect(joystickZone.style.getPropertyValue("--touch-stick-y")).not.toBe("");

        manager.handleTouchEnd(fakeTouch(0, joystickZone, 118, 64));

        expect(joystickZone.classList.contains("pressed")).toBe(false);
        expect(joystickZone.style.getPropertyValue("--touch-stick-x")).toBe("0px");
        expect(joystickZone.style.getPropertyValue("--touch-stick-y")).toBe("0px");
    });

    // -----------------------------------------------------------------------
    // Edge cases
    // -----------------------------------------------------------------------

    it("ignores duplicate touchend for the same identifier", () => {
        const touch = fakeTouch(0, btnA);
        manager.handleTouchStart(touch);
        manager.handleTouchEnd(touch);
        callback.mockClear();

        manager.handleTouchEnd(touch);

        expect(callback).not.toHaveBeenCalled();
    });

    it("ignores touchend for unknown identifier", () => {
        manager.handleTouchEnd(fakeTouch(99, btnA));
        expect(callback).not.toHaveBeenCalled();
    });
});
