// ── TraySpec.js ───────────────────────────────────────────────────────────────
//
// Unit specs for tray.js — the per-seat, per-room slide-out panel anchored
// to the right edge of the viewport.
//
// DOM contract assumed by the module:
//   #id_tray_wrap — outermost container; JS sets style.left for positioning
//   #id_tray_btn  — the drawer-handle button
//   #id_tray      — the tray panel (hidden by default)
//
// Public API under test:
//   Tray.init()    — compute bounds, apply vertical bounds, attach listeners
//   Tray.open()    — reveal tray, animate wrap to minLeft
//   Tray.close()   — hide tray, animate wrap to maxLeft
//   Tray.isOpen()  — state predicate
//   Tray.reset()   — restore initial state (for afterEach)
//
// Drag model: tray follows pointer in real-time; position persists on release.
//   Any leftward drag opens the tray.
//   Drag > 10px suppresses the subsequent click event.
//
// ─────────────────────────────────────────────────────────────────────────────

describe("Tray", () => {
    let btn, tray, wrap;

    beforeEach(() => {
        wrap = document.createElement("div");
        wrap.id = "id_tray_wrap";

        btn = document.createElement("button");
        btn.id = "id_tray_btn";

        tray = document.createElement("div");
        tray.id = "id_tray";
        tray.style.display = "none";

        wrap.appendChild(btn);
        document.body.appendChild(wrap);
        document.body.appendChild(tray);

        Tray._testSetLandscape(false);  // force portrait regardless of window size
        Tray.init();
    });

    afterEach(() => {
        Tray.reset();
        wrap.remove();
        tray.remove();
    });

    // ---------------------------------------------------------------------- //
    // open()                                                                   //
    // ---------------------------------------------------------------------- //

    describe("open()", () => {
        it("makes #id_tray visible", () => {
            Tray.open();
            expect(tray.style.display).not.toBe("none");
        });

        it("adds .open to #id_tray_btn", () => {
            Tray.open();
            expect(btn.classList.contains("open")).toBe(true);
        });

        it("sets wrap left to minLeft (0)", () => {
            Tray.open();
            expect(wrap.style.left).toBe("0px");
        });

        it("calling open() twice does not duplicate .open", () => {
            Tray.open();
            Tray.open();
            const openCount = btn.className.split(" ").filter(c => c === "open").length;
            expect(openCount).toBe(1);
        });
    });

    // ---------------------------------------------------------------------- //
    // close()                                                                  //
    // ---------------------------------------------------------------------- //

    describe("close()", () => {
        beforeEach(() => Tray.open());

        it("hides #id_tray after slide + snap both complete", () => {
            Tray.close();
            wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" }));
            wrap.dispatchEvent(new Event("animationend"));
            expect(tray.style.display).toBe("none");
        });

        it("adds .snap to wrap after slide transition completes", () => {
            Tray.close();
            wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" }));
            expect(wrap.classList.contains("snap")).toBe(true);
        });

        it("removes .snap from wrap once animationend fires", () => {
            Tray.close();
            wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "left" }));
            wrap.dispatchEvent(new Event("animationend"));
            expect(wrap.classList.contains("snap")).toBe(false);
        });

        it("removes .open from #id_tray_btn", () => {
            Tray.close();
            expect(btn.classList.contains("open")).toBe(false);
        });

        it("sets wrap left to maxLeft", () => {
            Tray.close();
            expect(parseInt(wrap.style.left, 10)).toBeGreaterThan(0);
        });

        it("does not throw if already closed", () => {
            Tray.close();
            expect(() => Tray.close()).not.toThrow();
        });
    });

    // ---------------------------------------------------------------------- //
    // isOpen()                                                                 //
    // ---------------------------------------------------------------------- //

    describe("isOpen()", () => {
        it("returns false by default", () => {
            expect(Tray.isOpen()).toBe(false);
        });

        it("returns true after open()", () => {
            Tray.open();
            expect(Tray.isOpen()).toBe(true);
        });

        it("returns false after close()", () => {
            Tray.open();
            Tray.close();
            expect(Tray.isOpen()).toBe(false);
        });
    });

    // ---------------------------------------------------------------------- //
    // Click when closed — wobble wrap, do not open                            //
    // ---------------------------------------------------------------------- //

    describe("clicking btn when closed", () => {
        it("adds .wobble to wrap", () => {
            btn.click();
            expect(wrap.classList.contains("wobble")).toBe(true);
        });

        it("does not open the tray", () => {
            btn.click();
            expect(Tray.isOpen()).toBe(false);
        });

        it("removes .wobble once animationend fires on wrap", () => {
            btn.click();
            wrap.dispatchEvent(new Event("animationend"));
            expect(wrap.classList.contains("wobble")).toBe(false);
        });
    });

    // ---------------------------------------------------------------------- //
    // Click when open — close, no wobble                                      //
    // ---------------------------------------------------------------------- //

    describe("clicking btn when open", () => {
        beforeEach(() => Tray.open());

        it("closes the tray", () => {
            btn.click();
            expect(Tray.isOpen()).toBe(false);
        });

        it("does not add .wobble", () => {
            btn.click();
            expect(wrap.classList.contains("wobble")).toBe(false);
        });
    });

    // ---------------------------------------------------------------------- //
    // Drag interaction — continuous positioning                               //
    // ---------------------------------------------------------------------- //

    describe("drag interaction", () => {
        function simulateDrag(deltaX) {
            const startX = 800;
            btn.dispatchEvent(new PointerEvent("pointerdown", { clientX: startX, bubbles: true }));
            btn.dispatchEvent(new PointerEvent("pointermove", { clientX: startX + deltaX, bubbles: true }));
            btn.dispatchEvent(new PointerEvent("pointerup",   { clientX: startX + deltaX, bubbles: true }));
        }

        it("dragging left opens the tray", () => {
            simulateDrag(-60);
            expect(Tray.isOpen()).toBe(true);
        });

        it("any leftward drag opens the tray", () => {
            simulateDrag(-20);
            expect(Tray.isOpen()).toBe(true);
        });

        it("dragging right does not open the tray", () => {
            simulateDrag(100);
            expect(Tray.isOpen()).toBe(false);
        });

        it("drag > 10px suppresses the subsequent click", () => {
            simulateDrag(-60);
            btn.click();  // should be swallowed — tray stays open
            expect(Tray.isOpen()).toBe(true);
        });

        it("does not add .wobble during drag", () => {
            simulateDrag(-60);
            expect(wrap.classList.contains("wobble")).toBe(false);
        });
    });

    // ---------------------------------------------------------------------- //
    // Landscape mode — Y-axis drag, top-positioned wrap                      //
    // ---------------------------------------------------------------------- //

    describe("landscape mode", () => {
        // Re-init in landscape after the portrait init from outer beforeEach.
        beforeEach(() => {
            Tray.reset();
            Tray._testSetLandscape(true);
            Tray.init();
        });

        function simulateDragY(deltaY) {
            const startY = 50;
            btn.dispatchEvent(new PointerEvent("pointerdown", { clientY: startY, clientX: 0, bubbles: true }));
            btn.dispatchEvent(new PointerEvent("pointermove", { clientY: startY + deltaY, clientX: 0, bubbles: true }));
            btn.dispatchEvent(new PointerEvent("pointerup",   { clientY: startY + deltaY, clientX: 0, bubbles: true }));
        }

        // ── open() in landscape ─────────────────────────────────────────── //

        describe("open()", () => {
            it("makes #id_tray visible", () => {
                Tray.open();
                expect(tray.style.display).not.toBe("none");
            });

            it("adds .open to #id_tray_btn", () => {
                Tray.open();
                expect(btn.classList.contains("open")).toBe(true);
            });

            it("positions wrap via style.top, not style.left", () => {
                Tray.open();
                expect(wrap.style.top).not.toBe("");
                expect(wrap.style.left).toBe("");
            });
        });

        // ── close() in landscape ────────────────────────────────────────── //

        describe("close()", () => {
            beforeEach(() => Tray.open());

            it("closes the tray (display not toggled in landscape)", () => {
                Tray.close();
                expect(Tray.isOpen()).toBe(false);
            });

            it("removes .open from #id_tray_btn", () => {
                Tray.close();
                expect(btn.classList.contains("open")).toBe(false);
            });

            it("closed top is less than open top (wrap slides up to close)", () => {
                const openTop = parseInt(wrap.style.top, 10);
                Tray.close();
                const closedTop = parseInt(wrap.style.top, 10);
                expect(closedTop).toBeLessThan(openTop);
            });

            it("adds .snap to wrap after top transition completes", () => {
                Tray.close();
                wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "top" }));
                expect(wrap.classList.contains("snap")).toBe(true);
            });

            it("removes .snap from wrap once animationend fires", () => {
                Tray.close();
                wrap.dispatchEvent(new TransitionEvent("transitionend", { propertyName: "top" }));
                wrap.dispatchEvent(new Event("animationend"));
                expect(wrap.classList.contains("snap")).toBe(false);
            });
        });

        // ── drag — Y axis ──────────────────────────────────────────────── //

        describe("drag interaction", () => {
            it("dragging down opens the tray", () => {
                simulateDragY(100);
                expect(Tray.isOpen()).toBe(true);
            });

            it("dragging up does not open the tray", () => {
                simulateDragY(-100);
                expect(Tray.isOpen()).toBe(false);
            });

            it("drag > 10px downward suppresses subsequent click", () => {
                simulateDragY(100);
                btn.click();  // should be swallowed — tray stays open
                expect(Tray.isOpen()).toBe(true);
            });

            it("does not set style.left (Y axis only)", () => {
                simulateDragY(100);
                expect(wrap.style.left).toBe("");
            });

            it("does not add .wobble during drag", () => {
                simulateDragY(100);
                expect(wrap.classList.contains("wobble")).toBe(false);
            });
        });

        // ── click when closed — wobble, no open ───────────────────────── //

        describe("clicking btn when closed", () => {
            it("adds .wobble to wrap", () => {
                btn.click();
                expect(wrap.classList.contains("wobble")).toBe(true);
            });

            it("does not open the tray", () => {
                btn.click();
                expect(Tray.isOpen()).toBe(false);
            });
        });

        // ── click when open — close ────────────────────────────────────── //

        describe("clicking btn when open", () => {
            beforeEach(() => Tray.open());

            it("closes the tray", () => {
                btn.click();
                expect(Tray.isOpen()).toBe(false);
            });
        });

        // ── init positions wrap at closed (top) ────────────────────────── //

        it("init sets wrap to closed position (top < 0 or = maxTop)", () => {
            // After landscape init with no real elements, _maxTop = -(wrapH_fallback - handleH_fallback)
            // which will be negative. Wrap starts off-screen above.
            const top = parseInt(wrap.style.top, 10);
            expect(top).toBeLessThan(0);
        });

        // ── resize closes landscape tray ─────────────────────────────── //

        describe("resize closes the tray", () => {
            it("closes when landscape tray is open", () => {
                Tray.open();
                window.dispatchEvent(new Event("resize"));
                expect(Tray.isOpen()).toBe(false);
            });

            it("removes .open from btn on resize", () => {
                Tray.open();
                window.dispatchEvent(new Event("resize"));
                expect(btn.classList.contains("open")).toBe(false);
            });

            it("resets wrap to closed top position on resize", () => {
                Tray.open();
                window.dispatchEvent(new Event("resize"));
                expect(parseInt(wrap.style.top, 10)).toBeLessThan(0);
            });

            it("does not re-open a closed tray on resize", () => {
                window.dispatchEvent(new Event("resize"));
                expect(Tray.isOpen()).toBe(false);
            });
        });
    });

    // ---------------------------------------------------------------------- //
    // window resize — portrait                                                 //
    // ---------------------------------------------------------------------- //

    describe("window resize (portrait)", () => {
        it("closes the tray when open", () => {
            Tray.open();
            window.dispatchEvent(new Event("resize"));
            expect(Tray.isOpen()).toBe(false);
        });

        it("removes .open from btn on resize", () => {
            Tray.open();
            window.dispatchEvent(new Event("resize"));
            expect(btn.classList.contains("open")).toBe(false);
        });

        it("hides the tray panel on resize", () => {
            Tray.open();
            window.dispatchEvent(new Event("resize"));
            expect(tray.style.display).toBe("none");
        });

        it("resets wrap to closed left position on resize", () => {
            Tray.open();
            expect(wrap.style.left).toBe("0px");
            window.dispatchEvent(new Event("resize"));
            expect(parseInt(wrap.style.left, 10)).toBeGreaterThan(0);
        });

        it("does not re-open a closed tray on resize", () => {
            window.dispatchEvent(new Event("resize"));
            expect(Tray.isOpen()).toBe(false);
        });
    });

    // ---------------------------------------------------------------------- //
    // placeCard()                                                              //
    // ---------------------------------------------------------------------- //
    //
    // placeCard(roleCode, onComplete):
    //   1. Marks the first .tray-cell with .tray-role-card + data-role.
    //   2. Opens the tray.
    //   3. Fade-in animates the cell (.fade-in class, animationend fires).
    //   4. forceClose() — tray closes instantly.
    //   5. Calls onComplete.
    //
    // The grid always has exactly 8 .tray-cell elements (from the template);
    // no new elements are inserted.
    //
    // ---------------------------------------------------------------------- //

    describe("placeCard()", () => {
        let grid, firstCell;

        beforeEach(() => {
            grid = document.createElement("div");
            grid.id = "id_tray_grid";
            for (let i = 0; i < 8; i++) {
                const cell = document.createElement("div");
                cell.className = "tray-cell";
                grid.appendChild(cell);
            }
            document.body.appendChild(grid);
            // Re-init so _grid is set (reset() in outer afterEach clears it)
            Tray.init();
            firstCell = grid.querySelector(".tray-cell");
        });

        afterEach(() => {
            grid.remove();
        });

        it("adds .tray-role-card to the first .tray-cell", () => {
            Tray.placeCard("PC", null);
            expect(firstCell.classList.contains("tray-role-card")).toBe(true);
        });

        it("sets data-role on the first cell", () => {
            Tray.placeCard("NC", null);
            expect(firstCell.dataset.role).toBe("NC");
        });

        it("sets tabIndex=0 on the placed cell so :focus persists the hover-tilt", () => {
            Tray.placeCard("PC", null);
            expect(firstCell.tabIndex).toBe(0);
        });

        it("grid cell count stays at 8", () => {
            Tray.placeCard("PC", null);
            expect(grid.children.length).toBe(8);
        });

        it("opens the tray", () => {
            Tray.placeCard("PC", null);
            expect(Tray.isOpen()).toBe(true);
        });

        it("adds .fade-in to the first cell", () => {
            Tray.placeCard("PC", null);
            expect(firstCell.classList.contains("fade-in")).toBe(true);
        });

        it("removes .fade-in and closes after animationend", () => {
            Tray.placeCard("PC", null);
            expect(Tray.isOpen()).toBe(true);
            firstCell.dispatchEvent(new Event("animationend"));
            expect(firstCell.classList.contains("fade-in")).toBe(false);
            expect(Tray.isOpen()).toBe(false);
        });

        it("calls onComplete after the tray closes", () => {
            let called = false;
            Tray.placeCard("PC", () => { called = true; });
            firstCell.dispatchEvent(new Event("animationend"));
            // Simulate the close transition completing (portrait: 'left' property)
            const te = new Event("transitionend");
            te.propertyName = "left";
            wrap.dispatchEvent(te);
            expect(called).toBe(true);
        });

        it("landscape: same behaviour — first cell gets role card", () => {
            Tray._testSetLandscape(true);
            Tray.init();
            Tray.placeCard("EC", null);
            expect(firstCell.classList.contains("tray-role-card")).toBe(true);
            expect(firstCell.dataset.role).toBe("EC");
        });

        it("reset() removes .tray-role-card and data-role from cells", () => {
            Tray.placeCard("PC", null);
            Tray.reset();
            expect(firstCell.classList.contains("tray-role-card")).toBe(false);
            expect(firstCell.dataset.role).toBeUndefined();
        });

        it("reset() also clears tabindex from the placed cell", () => {
            Tray.placeCard("PC", null);
            Tray.reset();
            expect(firstCell.hasAttribute("tabindex")).toBe(false);
        });
    });

    // ---------------------------------------------------------------------- //
    // placeSig()                                                                //
    // ---------------------------------------------------------------------- //
    //
    // placeSig(sourceEl, onComplete) — analogue of placeCard, applied to the
    // SECOND tray cell (the sig slot). sourceEl is the user's selected sig
    // .sig-stage-card (during sig-select), whose data-* attrs and inner
    // markup are copied into the tray's .sig-stage-card.sea-sig-card.
    //
    // Sequence: open → fade-in → animationend → close → onComplete.

    describe("placeSig()", () => {
        let grid, firstCell, secondCell, sourceEl;

        beforeEach(() => {
            grid = document.createElement("div");
            grid.id = "id_tray_grid";
            for (let i = 0; i < 8; i++) {
                const cell = document.createElement("div");
                cell.className = "tray-cell";
                grid.appendChild(cell);
            }
            document.body.appendChild(grid);
            Tray.init();
            firstCell  = grid.children[0];
            secondCell = grid.children[1];

            // Source: the user's selected sig stage card during sig-select.
            // Carries the same data-* shape that StageCard.fromDataset reads
            // and the same rank+icon child markup the tray template renders.
            sourceEl = document.createElement("div");
            sourceEl.className = "sig-stage-card";
            sourceEl.setAttribute("aria-label", "The Tester");
            sourceEl.dataset.energies   = '[{"type":"TESTLIBIDO","effect":"e1"}]';
            sourceEl.dataset.operations = '[{"type":"TESTOP","effect":"o1"}]';
            sourceEl.innerHTML =
                '<span class="fan-corner-rank">XCVIII</span>' +
                '<i class="fa-solid fa-flask"></i>';
        });

        afterEach(() => grid.remove());

        it("adds .tray-sig-card to the SECOND .tray-cell", () => {
            Tray.placeSig(sourceEl, null);
            expect(secondCell.classList.contains("tray-sig-card")).toBe(true);
            expect(firstCell.classList.contains("tray-sig-card")).toBe(false);
        });

        it("sets tabIndex=0 on the placed cell", () => {
            Tray.placeSig(sourceEl, null);
            expect(secondCell.tabIndex).toBe(0);
        });

        it("renders the .sig-stage-card.sea-sig-card child w. copied data + markup", () => {
            Tray.placeSig(sourceEl, null);
            const stage = secondCell.querySelector(".sig-stage-card.sea-sig-card");
            expect(stage).not.toBe(null);
            expect(stage.getAttribute("aria-label")).toBe("The Tester");
            expect(stage.dataset.energies).toBe(sourceEl.dataset.energies);
            expect(stage.dataset.operations).toBe(sourceEl.dataset.operations);
            expect(stage.querySelector(".fan-corner-rank").textContent).toBe("XCVIII");
            expect(stage.querySelector("i.fa-solid")).not.toBe(null);
        });

        it("grid cell count stays at 8", () => {
            Tray.placeSig(sourceEl, null);
            expect(grid.children.length).toBe(8);
        });

        it("opens the tray", () => {
            Tray.placeSig(sourceEl, null);
            expect(Tray.isOpen()).toBe(true);
        });

        it("adds .fade-in to the placed cell", () => {
            Tray.placeSig(sourceEl, null);
            expect(secondCell.classList.contains("fade-in")).toBe(true);
        });

        it("removes .fade-in and closes after animationend", () => {
            Tray.placeSig(sourceEl, null);
            secondCell.dispatchEvent(new Event("animationend"));
            expect(secondCell.classList.contains("fade-in")).toBe(false);
            expect(Tray.isOpen()).toBe(false);
        });

        it("calls onComplete after the tray closes", () => {
            let called = false;
            Tray.placeSig(sourceEl, () => { called = true; });
            secondCell.dispatchEvent(new Event("animationend"));
            const te = new Event("transitionend");
            te.propertyName = "left";
            wrap.dispatchEvent(te);
            expect(called).toBe(true);
        });

        it("landscape: same behaviour — second cell gets sig card", () => {
            Tray._testSetLandscape(true);
            Tray.init();
            Tray.placeSig(sourceEl, null);
            expect(secondCell.classList.contains("tray-sig-card")).toBe(true);
        });

        it("reset() removes .tray-sig-card from cells", () => {
            Tray.placeSig(sourceEl, null);
            Tray.reset();
            expect(secondCell.classList.contains("tray-sig-card")).toBe(false);
        });
    });

    // ---------------------------------------------------------------------- //
    // init() — focusable tray cards                                            //
    // ---------------------------------------------------------------------- //
    //
    // .tray-sig-card is rendered server-side by room.html when the seat has a
    // significator; .tray-role-card may be too if the seat already has a role.
    // init() must mark these cells tabbable so the SCSS :focus rule persists
    // the hover-tilt animation after the user clicks the card.

    describe("init() — focusable tray cards", () => {
        let grid;

        beforeEach(() => {
            grid = document.createElement("div");
            grid.id = "id_tray_grid";
            document.body.appendChild(grid);
        });

        afterEach(() => grid.remove());

        function _addCell(extraClass) {
            const cell = document.createElement("div");
            cell.className = "tray-cell" + (extraClass ? " " + extraClass : "");
            grid.appendChild(cell);
            return cell;
        }

        it("sets tabIndex=0 on a template-rendered .tray-sig-card", () => {
            const sigCell = _addCell("tray-sig-card");
            Tray.init();
            expect(sigCell.tabIndex).toBe(0);
        });

        it("sets tabIndex=0 on a template-rendered .tray-role-card", () => {
            const roleCell = _addCell("tray-role-card");
            Tray.init();
            expect(roleCell.tabIndex).toBe(0);
        });

        it("does NOT set tabindex on bare .tray-cell elements", () => {
            const empty = _addCell();
            Tray.init();
            expect(empty.hasAttribute("tabindex")).toBe(false);
        });
    });
});
