// ── TrayTooltipSpec.js ─────────────────────────────────────────────────────────
//
// Unit specs for TrayTooltip — apps.tooltips portal-population on hover of a
// tray-cell child element. Phase 1 covers .tray-role-card > img only; Phase 2
// (sig-card with PRV/NXT pager) is a separate sprint.
//
// Public API under test:
//   TrayTooltip.init()    — binds document hover/move listeners
//   TrayTooltip.reset()   — detaches listeners + hides portal (afterEach)
//
// DOM contract assumed by the module:
//   #id_tooltip_portal   — fixed portal at page root, hidden by default
//   #id_tray_grid        — tray's grid container
//     .tray-role-card    — cell carrying the role art
//       > img            — hover trigger
//       > .tt            — server-rendered tooltip content (.tt-title, .tt-description, …)
//
// Behaviour:
//   * Hovering the img copies its sibling .tt's innerHTML into the portal,
//     marks the portal .active, and shows it (display: block).
//   * Pointer leaving the union of [trigger, portal] rects clears the portal.
//   * Position is clamped to the viewport: portal.left ≥ halfPortalW + 8;
//     portal.left ≤ viewportW − halfPortalW − 8.
//
// ─────────────────────────────────────────────────────────────────────────────

describe("TrayTooltip", () => {
    let portal, grid, cell, img, tt;

    beforeEach(() => {
        portal = document.createElement("div");
        portal.id = "id_tooltip_portal";
        portal.style.display = "none";
        document.body.appendChild(portal);

        grid = document.createElement("div");
        grid.id = "id_tray_grid";

        cell = document.createElement("div");
        cell.className = "tray-cell tray-role-card";
        cell.dataset.role = "PC";

        img = document.createElement("img");
        img.alt = "PC";

        tt = document.createElement("div");
        tt.className = "tt";
        tt.style.display = "none";
        tt.innerHTML =
            '<h4 class="tt-title">Player</h4>' +
            '<p class="tt-description">[Placeholder description]</p>';

        cell.appendChild(img);
        cell.appendChild(tt);
        grid.appendChild(cell);
        document.body.appendChild(grid);

        // Force a known geometry so clamp math has something to clamp against.
        // (jsdom-style env: getBoundingClientRect() returns zeroes by default;
        // override via stubs.)
        spyOn(img, "getBoundingClientRect").and.returnValue({
            left: 100, right: 148, top: 200, bottom: 248, width: 48, height: 48,
        });

        TrayTooltip.init();
    });

    afterEach(() => {
        TrayTooltip.reset();
        portal.remove();
        grid.remove();
    });

    // ---------------------------------------------------------------------- //
    // Hover → portal becomes active                                            //
    // ---------------------------------------------------------------------- //

    describe("on mouseenter of role-card img", () => {
        it("copies .tt innerHTML into the portal", () => {
            img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
            expect(portal.innerHTML).toContain('class="tt-title"');
            expect(portal.innerHTML).toContain("Player");
            expect(portal.innerHTML).toContain("[Placeholder description]");
        });

        it("marks the portal .active and shows it", () => {
            img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
            expect(portal.classList.contains("active")).toBe(true);
            expect(portal.style.display).not.toBe("none");
        });

        it("does nothing if there is no sibling .tt", () => {
            tt.remove();
            img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
            expect(portal.classList.contains("active")).toBe(false);
            expect(portal.style.display).toBe("none");
        });
    });

    // ---------------------------------------------------------------------- //
    // Mouseleave (extended) → portal clears                                    //
    // ---------------------------------------------------------------------- //

    describe("on pointer leaving the trigger+portal union", () => {
        beforeEach(() => {
            // Pin the portal to a known location after activation so the
            // mousemove union test is deterministic.
            img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
            spyOn(portal, "getBoundingClientRect").and.returnValue({
                left: 80, right: 280, top: 120, bottom: 200, width: 200, height: 80,
            });
        });

        it("clears the portal when the pointer is well outside both rects", () => {
            document.dispatchEvent(new MouseEvent("mousemove", {
                bubbles: true, clientX: 500, clientY: 500,
            }));
            expect(portal.classList.contains("active")).toBe(false);
            expect(portal.style.display).toBe("none");
        });

        it("keeps the portal active while the pointer is inside the trigger", () => {
            document.dispatchEvent(new MouseEvent("mousemove", {
                bubbles: true, clientX: 120, clientY: 220,
            }));
            expect(portal.classList.contains("active")).toBe(true);
        });

        it("keeps the portal active while the pointer is inside the portal", () => {
            document.dispatchEvent(new MouseEvent("mousemove", {
                bubbles: true, clientX: 180, clientY: 160,
            }));
            expect(portal.classList.contains("active")).toBe(true);
        });
    });

    // ---------------------------------------------------------------------- //
    // Clamping — portal left stays inside the viewport                         //
    // ---------------------------------------------------------------------- //

    // ---------------------------------------------------------------------- //
    // .tt-active on the cell — tilt persistence                                //
    // ---------------------------------------------------------------------- //
    //
    // The role-card / sig-card hover-tilt is keyed off :hover + :focus on the
    // cell. While the portal is open the pointer is typically OFF the cell
    // (hovering the portal itself), so :hover drops; if the cell never
    // received focus, :focus is also absent and the tilt reverts even though
    // the tooltip is still active. Solution: TrayTooltip adds .tt-active to
    // the cell while its tooltip is open and removes it on _hide. SCSS
    // includes .tt-active in the tilt selector list.

    describe(".tt-active class on the cell", () => {
        it("is added to the role-card cell on mouseenter and removed on hide", () => {
            img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
            expect(cell.classList.contains("tt-active")).toBe(true);

            // outer beforeEach already stubs img.getBoundingClientRect; just
            // pin the portal so the union test is deterministic.
            spyOn(portal, "getBoundingClientRect").and.returnValue({
                left: 0, right: 10, top: 0, bottom: 10, width: 10, height: 10,
            });
            document.dispatchEvent(new MouseEvent("mousemove", {
                bubbles: true, clientX: 9999, clientY: 9999,
            }));
            expect(cell.classList.contains("tt-active")).toBe(false);
        });
    });

    // ---------------------------------------------------------------------- //
    // Phase 2 — sig-card branch                                                //
    // ---------------------------------------------------------------------- //
    //
    // Hovering .tray-sig-card > .sig-stage-card populates the SAME portal with
    // a .sig-info FYI panel (Energy/Operation entries cycled via PRV/NXT btns).
    // No click-to-dismiss; mouseleave union covers the overhanging btn rects.

    describe("sig-card branch", () => {
        let sigCell, sigStage;

        beforeEach((done) => {
            sigCell = document.createElement("div");
            sigCell.className = "tray-cell tray-sig-card";

            sigStage = document.createElement("div");
            sigStage.className = "sig-stage-card sea-sig-card";
            // Two FYI entries — 1 energy + 1 operation — so PRV/NXT have
            // something to cycle through.
            sigStage.dataset.energies = JSON.stringify([
                { type: "TESTLIBIDO", effect: "First energy effect." },
            ]);
            sigStage.dataset.operations = JSON.stringify([
                { type: "TESTOP", effect: "First operation effect." },
            ]);

            sigCell.appendChild(sigStage);
            grid.appendChild(sigCell);

            spyOn(sigStage, "getBoundingClientRect").and.returnValue({
                left: 100, right: 160, top: 300, bottom: 396,
                width: 60, height: 96,
            });
            // MutationObserver fires asynchronously — yield once so the new
            // sig cell is bound before the spec body runs.
            setTimeout(done, 0);
        });

        it("populates the portal with a .sig-info panel + PRV/NXT btns", () => {
            sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
            expect(portal.querySelector(".sig-info-title")).not.toBe(null);
            expect(portal.querySelector(".sig-info-type")).not.toBe(null);
            expect(portal.querySelector(".sig-info-effect")).not.toBe(null);
            expect(portal.querySelector(".sig-info-index")).not.toBe(null);
            expect(portal.querySelector(".fyi-prev")).not.toBe(null);
            expect(portal.querySelector(".fyi-next")).not.toBe(null);
        });

        it("renders the first energy entry on hover", () => {
            sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
            expect(portal.querySelector(".sig-info-title").textContent).toBe("Energy");
            expect(portal.querySelector(".sig-info-type").textContent).toBe("TESTLIBIDO");
            expect(portal.querySelector(".sig-info-effect").textContent).toBe("First energy effect.");
            expect(portal.querySelector(".sig-info-index").textContent).toBe("1 / 2");
        });

        it("NXT advances to the operation entry; PRV cycles back", () => {
            sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
            portal.querySelector(".fyi-next").dispatchEvent(
                new MouseEvent("click", { bubbles: true })
            );
            expect(portal.querySelector(".sig-info-title").textContent).toBe("Operation");
            expect(portal.querySelector(".sig-info-type").textContent).toBe("TESTOP");
            expect(portal.querySelector(".sig-info-index").textContent).toBe("2 / 2");

            portal.querySelector(".fyi-prev").dispatchEvent(
                new MouseEvent("click", { bubbles: true })
            );
            expect(portal.querySelector(".sig-info-title").textContent).toBe("Energy");
            expect(portal.querySelector(".sig-info-index").textContent).toBe("1 / 2");
        });

        it("clicking the panel body does NOT dismiss (departure from fan stage)", () => {
            sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
            portal.querySelector(".sig-info-effect").dispatchEvent(
                new MouseEvent("click", { bubbles: true })
            );
            expect(portal.classList.contains("active")).toBe(true);
            expect(portal.style.display).not.toBe("none");
        });

        it("keeps portal active when pointer is over a fyi-prev / fyi-next btn rect", () => {
            sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
            // Stub the portal + btn rects so the union test is deterministic.
            spyOn(portal, "getBoundingClientRect").and.returnValue({
                left: 80, right: 280, top: 200, bottom: 320,
                width: 200, height: 120,
            });
            const prv = portal.querySelector(".fyi-prev");
            const nxt = portal.querySelector(".fyi-next");
            spyOn(prv, "getBoundingClientRect").and.returnValue({
                left: 60, right: 92, top: 240, bottom: 272, width: 32, height: 32,
            });
            spyOn(nxt, "getBoundingClientRect").and.returnValue({
                left: 268, right: 300, top: 240, bottom: 272, width: 32, height: 32,
            });
            // Pointer over the PRV btn (left:60–92) — outside the portal's left
            // edge (80) but inside the btn rect — should keep the portal alive.
            document.dispatchEvent(new MouseEvent("mousemove", {
                bubbles: true, clientX: 70, clientY: 256,
            }));
            expect(portal.classList.contains("active")).toBe(true);

            // Pointer over the NXT btn (right:268–300, past the portal right
            // edge 280) — likewise stays alive.
            document.dispatchEvent(new MouseEvent("mousemove", {
                bubbles: true, clientX: 290, clientY: 256,
            }));
            expect(portal.classList.contains("active")).toBe(true);
        });

        it("adds .tt-active to the sig cell on hover and removes it on _hide", () => {
            sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
            expect(sigCell.classList.contains("tt-active")).toBe(true);

            spyOn(portal, "getBoundingClientRect").and.returnValue({
                left: 0, right: 10, top: 0, bottom: 10, width: 10, height: 10,
            });
            document.dispatchEvent(new MouseEvent("mousemove", {
                bubbles: true, clientX: 9999, clientY: 9999,
            }));
            expect(sigCell.classList.contains("tt-active")).toBe(false);
        });

        it("clears portal when pointer leaves trigger + portal + btn rect union", () => {
            sigStage.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));
            spyOn(portal, "getBoundingClientRect").and.returnValue({
                left: 80, right: 280, top: 200, bottom: 320,
                width: 200, height: 120,
            });
            const prv = portal.querySelector(".fyi-prev");
            const nxt = portal.querySelector(".fyi-next");
            spyOn(prv, "getBoundingClientRect").and.returnValue({
                left: 60, right: 92, top: 240, bottom: 272, width: 32, height: 32,
            });
            spyOn(nxt, "getBoundingClientRect").and.returnValue({
                left: 268, right: 300, top: 240, bottom: 272, width: 32, height: 32,
            });
            document.dispatchEvent(new MouseEvent("mousemove", {
                bubbles: true, clientX: 9999, clientY: 9999,
            }));
            expect(portal.classList.contains("active")).toBe(false);
            expect(portal.style.display).toBe("none");
        });
    });

    describe("position clamping", () => {
        it("clamps to the right when the trigger is near the left edge", () => {
            // Trigger near x=0 should push portal centre rightward to halfW + 8.
            img.getBoundingClientRect.and.returnValue({
                left: 0, right: 48, top: 200, bottom: 248, width: 48, height: 48,
            });
            // Stub portal width so halfW is predictable AFTER innerHTML is set.
            const origDescriptor = Object.getOwnPropertyDescriptor(
                HTMLElement.prototype, "offsetWidth"
            );
            Object.defineProperty(portal, "offsetWidth", { configurable: true, value: 200 });

            img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));

            const left = parseFloat(portal.style.left);
            expect(left).toBeGreaterThanOrEqual(108);  // halfW(100) + 8

            if (origDescriptor) Object.defineProperty(HTMLElement.prototype, "offsetWidth", origDescriptor);
        });

        it("clamps to the left when the trigger is near the right edge", () => {
            const vw = window.innerWidth;
            img.getBoundingClientRect.and.returnValue({
                left: vw - 10, right: vw, top: 200, bottom: 248, width: 10, height: 48,
            });
            Object.defineProperty(portal, "offsetWidth", { configurable: true, value: 200 });

            img.dispatchEvent(new MouseEvent("mouseenter", { bubbles: true }));

            const left = parseFloat(portal.style.left);
            expect(left).toBeLessThanOrEqual(vw - 100 - 8);  // viewport − halfW − 8
        });
    });
});
