// ── VoiceGlowSpec.js ─────────────────────────────────────────────────────────
//
// Unit specs for voice-glow.js — the my-sea voice-affordance glow/pulse state
// machine (Phase 3, user-spec 2026-05-29).
//
// DOM contract:
//   #id_burger_btn — burger btn (carries .active when fan open, .glow-handoff
//                    when the sea draw-nudge is on it)
//   #id_voice_btn  — voice sub-btn (.active = voice available; .in-call once
//                    the mic is live)
//   #id_sea_btn    — sea sub-btn (.glow-handoff = sea draw-nudge active)
//
// Public API under test (window.bindVoiceGlow):
//   var vg = bindVoiceGlow();        // {render, setVoiceState, destroy}
//   vg.setVoiceState({inCall, peerCount});
//
// Glow classes: .voice-glow (steady), .voice-pulse (alone), .voice-pulse--fast
// (a 2nd+ party connected).
// ─────────────────────────────────────────────────────────────────────────────

describe("VoiceGlow", () => {
    let burger, voice, sea, vg;

    function mk(id) {
        const el = document.createElement("button");
        el.id = id;
        document.body.appendChild(el);
        return el;
    }

    beforeEach(() => {
        // Ensure no stale singleton subscription from a prior page load.
        if (window.VoiceRoom) window.VoiceRoom._onState = null;
        burger = mk("id_burger_btn");
        voice = mk("id_voice_btn");
        sea = mk("id_sea_btn");
        voice.classList.add("active");   // voice available by default
    });

    afterEach(() => {
        if (vg) { vg.destroy(); vg = null; }
        [burger, voice, sea].forEach((el) => el && el.remove());
    });

    const hasGlow = (el) => el.classList.contains("voice-glow");
    const hasPulse = (el) => el.classList.contains("voice-pulse");
    const hasEq = (el) => el.classList.contains("voice-eq");
    const hasMuted = (el) => el.classList.contains("voice-muted");

    describe("bindVoiceGlow()", () => {
        it("returns null when the burger btn is absent", () => {
            burger.remove(); burger = null;
            expect(bindVoiceGlow()).toBeNull();
        });

        it("returns null when the voice btn is absent", () => {
            voice.remove(); voice = null;
            expect(bindVoiceGlow()).toBeNull();
        });
    });

    describe("pre-join nudge (voice available, mic not live)", () => {
        it("glows the burger when the fan is closed and no sea nudge", () => {
            vg = bindVoiceGlow();
            expect(hasGlow(burger)).toBe(true);
            expect(hasGlow(voice)).toBe(false);
        });

        it("does NOT glow when voice is unavailable (no .active)", () => {
            voice.classList.remove("active");
            vg = bindVoiceGlow();
            expect(hasGlow(burger)).toBe(false);
            expect(hasGlow(voice)).toBe(false);
        });

        it("yields burger precedence to the sea draw-nudge (sea glowing)", () => {
            sea.classList.add("glow-handoff");
            vg = bindVoiceGlow();
            expect(hasGlow(burger)).toBe(false);   // sea keeps the burger
        });

        it("reclaims the burger once the sea nudge clears", () => {
            sea.classList.add("glow-handoff");
            vg = bindVoiceGlow();
            expect(hasGlow(burger)).toBe(false);
            sea.classList.remove("glow-handoff");
            vg.render();
            expect(hasGlow(burger)).toBe(true);
        });

        it("moves the glow to the voice sub-btn when the fan opens", () => {
            vg = bindVoiceGlow();
            burger.classList.add("active");        // fan open
            vg.render();
            expect(hasGlow(voice)).toBe(true);
            expect(hasGlow(burger)).toBe(false);
        });
    });

    describe("live (mic in use)", () => {
        it("pulses the burger (base cadence) while alone + fan closed", () => {
            vg = bindVoiceGlow();
            vg.setVoiceState({ inCall: true, peerCount: 0 });
            expect(hasGlow(burger)).toBe(true);
            expect(hasPulse(burger)).toBe(true);
            expect(hasEq(burger)).toBe(false);
        });

        it("pulses the voice sub-btn while alone + fan open", () => {
            vg = bindVoiceGlow();
            burger.classList.add("active");
            vg.setVoiceState({ inCall: true, peerCount: 0 });
            expect(hasPulse(voice)).toBe(true);
            expect(hasPulse(burger)).toBe(false);
        });

        it("switches to the equalizer once a 2nd party connects", () => {
            vg = bindVoiceGlow();
            vg.setVoiceState({ inCall: true, peerCount: 1 });
            expect(hasEq(burger)).toBe(true);
            expect(hasPulse(burger)).toBe(false);
        });

        it("recolors to muted (--priRd) when the mic is muted, both alone…", () => {
            vg = bindVoiceGlow();
            vg.setVoiceState({ inCall: true, peerCount: 0, muted: true });
            expect(hasPulse(burger)).toBe(true);    // still pulsing (alone)
            expect(hasMuted(burger)).toBe(true);    // …but in the muted colour
        });

        it("…and while the equalizer runs with others connected", () => {
            vg = bindVoiceGlow();
            vg.setVoiceState({ inCall: true, peerCount: 2, muted: true });
            expect(hasEq(burger)).toBe(true);
            expect(hasMuted(burger)).toBe(true);
        });

        it("drops the muted colour on unmute", () => {
            vg = bindVoiceGlow();
            vg.setVoiceState({ inCall: true, peerCount: 0, muted: true });
            expect(hasMuted(burger)).toBe(true);
            vg.setVoiceState({ inCall: true, peerCount: 0, muted: false });
            expect(hasMuted(burger)).toBe(false);
        });

        it("auto-disconnect (muted→not-in-call) stops the priRd/.fa-ban path + returns to the available nudge", () => {
            // The 3-min mute timeout leaves voice → the mesh notifies
            // {inCall:false, muted:false}; the glow must drop the muted colour
            // (priRd/.fa-ban) + fall back to the channel-available nudge, live —
            // no refresh (user-spec 2026-05-30).
            vg = bindVoiceGlow();
            burger.classList.add("active");   // fan open → cues land on the voice btn
            vg.setVoiceState({ inCall: true, peerCount: 0, muted: true });
            expect(hasMuted(voice)).toBe(true);
            vg.setVoiceState({ inCall: false, peerCount: 0, muted: false });
            expect(hasMuted(voice)).toBe(false);   // priRd/.fa-ban path stopped
            expect(hasMuted(burger)).toBe(false);
            expect(hasGlow(voice)).toBe(true);      // back to the available nudge
            expect(hasPulse(voice)).toBe(false);    // not "live" anymore
            expect(hasEq(voice)).toBe(false);
        });

        it("hands the pulse to whichever surface is showing (fan toggles)", () => {
            vg = bindVoiceGlow();
            vg.setVoiceState({ inCall: true, peerCount: 0 });
            expect(hasPulse(burger)).toBe(true);
            burger.classList.add("active");        // fan opens mid-call
            vg.render();
            expect(hasPulse(voice)).toBe(true);
            expect(hasPulse(burger)).toBe(false);
        });

        it("clears the pulse when the call ends", () => {
            vg = bindVoiceGlow();
            vg.setVoiceState({ inCall: true, peerCount: 0 });
            expect(hasPulse(burger)).toBe(true);
            vg.setVoiceState({ inCall: false, peerCount: 0 });
            expect(hasPulse(burger)).toBe(false);
            expect(hasGlow(burger)).toBe(true);    // back to the pre-join nudge
        });
    });

    describe("idempotent reconcile", () => {
        it("does not stack duplicate classes across repeated renders", () => {
            vg = bindVoiceGlow();
            vg.setVoiceState({ inCall: true, peerCount: 1 });
            vg.render(); vg.render();
            const eqCount = (burger.className.match(/voice-eq/g) || []).length;
            expect(eqCount).toBe(1);
        });
    });

    // Regression (2026-05-30): voice-glow keeps `voice.dataset.inCall` — the
    // flag burger-btn.js reads to tell join-from-mute apart — in sync with the
    // mesh truth SYMMETRICALLY. `setOnStateChange` pushes the current state on
    // subscribe, so voice-glow often subscribes mid-join (inCall=false). The old
    // rule only deleted on false, so that transient wiped the flag burger-btn had
    // set + never restored it → the next click re-joined instead of muting.
    describe("dataset.inCall sync (join-vs-mute flag)", () => {
        it("SETS voice.dataset.inCall when the mesh reports in-call", () => {
            vg = bindVoiceGlow();
            vg.setVoiceState({ inCall: true, peerCount: 0 });
            expect(voice.dataset.inCall).toBe("1");
        });

        it("CLEARS voice.dataset.inCall when the mesh reports not-in-call", () => {
            vg = bindVoiceGlow();
            voice.dataset.inCall = "1";
            vg.setVoiceState({ inCall: false, peerCount: 0 });
            expect(voice.dataset.inCall).toBeUndefined();
        });

        it("RESTORES the flag once a mid-join false is followed by true", () => {
            vg = bindVoiceGlow();
            voice.dataset.inCall = "1";                 // burger-btn set it
            vg.setVoiceState({ inCall: false, peerCount: 0 });  // subscribe mid-join
            expect(voice.dataset.inCall).toBeUndefined();
            vg.setVoiceState({ inCall: true, peerCount: 0 });   // stream resolved
            expect(voice.dataset.inCall).toBe("1");     // mute now works
        });
    });
});
