from datetime import timedelta
from unittest.mock import ANY, patch

from django.test import TestCase
from django.urls import reverse
from django.utils import timezone

from apps.drama.models import GameEvent, Note
from apps.lyric.models import Token, User
from apps.epic.models import (
    Character, DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
)


class RoomCreationViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="founder@test.io")
        self.client.force_login(self.user)

    def test_post_creates_room_and_redirects_to_gatekeeper(self):
        response = self.client.post(
            reverse("epic:create_room"),
            data={"name": "Test Room"},
        )
        room = Room.objects.get(owner=self.user)
        self.assertRedirects(
            response, reverse(
                "epic:gatekeeper",
                args=[room.id],
            )
        )

    def test_post_requires_login(self):
        self.client.logout()
        response = self.client.post(
            reverse("epic:create_room"),
            data={"name": "Test Room"},
        )

    def test_create_room_get_redirects_to_gameboard(self):
        response = self.client.get(reverse("epic:create_room"))
        self.assertRedirects(response, "/gameboard/")

    def test_create_room_records_welcome_event_with_no_actor(self):
        """First scroll log on a fresh room is a system-authored welcome —
        not a user action, so actor=None. The visible greeting is the
        ROOM_CREATED event's `to_prose` ("Welcome to <name>!")."""
        self.client.post(
            reverse("epic:create_room"),
            data={"name": "Welcoming Room"},
        )
        room = Room.objects.get(owner=self.user)
        event = room.events.first()
        self.assertIsNotNone(event, "no ROOM_CREATED event recorded")
        self.assertEqual(event.verb, GameEvent.ROOM_CREATED)
        self.assertIsNone(event.actor, "welcome line must be system-authored")

    def test_create_room_welcome_event_renders_welcome_prose(self):
        self.client.post(
            reverse("epic:create_room"),
            data={"name": "Greenroom"},
        )
        room = Room.objects.get(owner=self.user)
        event = room.events.first()
        self.assertEqual(event.to_prose(), "Welcome to Greenroom!")


class MyGamesContextTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="gamer@example.com")
        self.client.force_login(self.user)

    def test_gameboard_context_includes_owned_rooms(self):
        room = Room.objects.create(name="Durango", owner=self.user)
        response = self.client.get("/gameboard/")
        self.assertIn(room, response.context["my_games"])

    def test_gameboard_context_includes_rooms_with_filled_slot(self):
        other = User.objects.create(email="friend@example.com")
        room = Room.objects.create(name="Their Room", owner=other)
        slot = room.gate_slots.get(slot_number=2)
        slot.gamer = self.user
        slot.status = "FILLED"
        slot.save()
        response = self.client.get("/gameboard/")
        self.assertIn(room, response.context["my_games"])


class GateStatusViewTest(TestCase):
    def setUp(self):
        self.owner = User.objects.create(email="founder@test.io")
        self.client.force_login(self.owner)
        self.room = Room.objects.create(name="Test Room", owner=self.owner)

    def test_gate_status_returns_launch_btn_when_open(self):
        self.room.gate_status = Room.OPEN
        self.room.save()
        response = self.client.get(reverse("epic:gate_status", kwargs={"room_id": self.room.id}))
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "launch-game-btn")

    def test_gate_status_returns_partial_when_gathering(self):
        response = self.client.get(
            reverse("epic:gate_status", kwargs={"room_id": self.room.id})
        )
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "gate-modal")


class DropTokenViewTest(TestCase):
    def setUp(self):
        self.gamer = User.objects.create(email="gamer@test.io")
        self.client.force_login(self.gamer)
        owner = User.objects.create(email="owner@test.io")
        self.room = Room.objects.create(name="Test Room", owner=owner)

    def test_drop_token_reserves_lowest_empty_slot(self):
        self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
        slot = self.room.gate_slots.get(slot_number=1)
        self.assertEqual(slot.status, GateSlot.RESERVED)
        self.assertEqual(slot.gamer, self.gamer)

    def test_drop_token_skips_already_filled_slots(self):
        other = User.objects.create(email="other@test.io")
        slot1 = self.room.gate_slots.get(slot_number=1)
        slot1.gamer = other
        slot1.status = GateSlot.FILLED
        slot1.save()
        self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
        slot2 = self.room.gate_slots.get(slot_number=2)
        self.assertEqual(slot2.status, GateSlot.RESERVED)
        self.assertEqual(slot2.gamer, self.gamer)

    def test_drop_token_blocked_when_another_slot_reserved(self):
        other = User.objects.create(email="other@test.io")
        slot1 = self.room.gate_slots.get(slot_number=1)
        slot1.gamer = other
        slot1.status = GateSlot.RESERVED
        slot1.reserved_at = timezone.now()
        slot1.save()
        self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
        # Slot 2 should remain EMPTY — lock held by other user
        slot2 = self.room.gate_slots.get(slot_number=2)
        self.assertEqual(slot2.status, GateSlot.EMPTY)

    def test_drop_token_blocked_when_user_already_has_filled_slot(self):
        slot1 = self.room.gate_slots.get(slot_number=1)
        slot1.gamer = self.gamer
        slot1.status = GateSlot.FILLED
        slot1.save()
        self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
        slot2 = self.room.gate_slots.get(slot_number=2)
        self.assertEqual(slot2.status, GateSlot.EMPTY)

    def test_drop_token_sets_reserved_at(self):
        self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
        slot = self.room.gate_slots.get(slot_number=1)
        self.assertIsNotNone(slot.reserved_at)

    def test_drop_token_redirects_to_gatekeeper(self):
        response = self.client.post(
            reverse("epic:drop_token", kwargs={"room_id": self.room.id})
        )
        self.assertRedirects(
            response, reverse("epic:gatekeeper", args=[self.room.id])
        )

    def test_carte_drop_sets_current_room(self):
        carte = Token.objects.create(user=self.gamer, token_type=Token.CARTE)
        self.client.post(
            reverse("epic:drop_token", kwargs={"room_id": self.room.id}),
            data={"token_id": carte.pk},
        )
        carte.refresh_from_db()
        self.assertEqual(carte.current_room, self.room)

    def test_carte_drop_unequips_trinket(self):
        carte = Token.objects.create(user=self.gamer, token_type=Token.CARTE)
        self.gamer.equipped_trinket = carte
        self.gamer.save(update_fields=["equipped_trinket"])
        self.client.post(
            reverse("epic:drop_token", kwargs={"room_id": self.room.id}),
            data={"token_id": carte.pk},
        )
        self.gamer.refresh_from_db()
        self.assertIsNone(self.gamer.equipped_trinket)

    def test_carte_drop_rejected_when_already_in_different_room(self):
        other_room = Room.objects.create(name="Other Room", owner=self.gamer)
        carte = Token.objects.create(
            user=self.gamer, token_type=Token.CARTE, current_room=other_room,
        )
        response = self.client.post(
            reverse("epic:drop_token", kwargs={"room_id": self.room.id}),
            data={"token_id": carte.pk},
        )
        self.assertEqual(response.status_code, 409)
        carte.refresh_from_db()
        self.assertEqual(carte.current_room, other_room)  # unchanged


class ConfirmTokenViewTest(TestCase):
    def setUp(self):
        self.gamer = User.objects.create(email="gamer@test.io")
        self.client.force_login(self.gamer)
        owner = User.objects.create(email="owner@test.io")
        self.room = Room.objects.create(name="Test Room", owner=owner)
        self.slot = self.room.gate_slots.get(slot_number=1)
        self.slot.gamer = self.gamer
        self.slot.status = GateSlot.RESERVED
        self.slot.reserved_at = timezone.now()
        self.slot.save()
        Token.objects.create(user=self.gamer, token_type=Token.FREE)

    def test_confirm_marks_slot_filled(self):
        self.client.post(
            reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
        )
        self.slot.refresh_from_db()
        self.assertEqual(self.slot.status, GateSlot.FILLED)

    def test_confirm_sets_gate_open_when_all_slots_filled(self):
        # Fill slots 2–6 via ORM
        for i in range(2, 7):
            other = User.objects.create(email=f"g{i}@test.io")
            s = self.room.gate_slots.get(slot_number=i)
            s.gamer = other
            s.status = GateSlot.FILLED
            s.save()
        self.client.post(
            reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
        )
        self.room.refresh_from_db()
        self.assertEqual(self.room.gate_status, Room.OPEN)

    def test_confirm_redirects_to_gatekeeper(self):
        response = self.client.post(
            reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
        )
        self.assertRedirects(
            response, reverse("epic:gatekeeper", args=[self.room.id])
        )

    def test_confirm_does_nothing_without_reserved_slot(self):
        self.slot.status = GateSlot.EMPTY
        self.slot.gamer = None
        self.slot.save()
        self.client.post(
            reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
        )
        self.slot.refresh_from_db()
        self.assertEqual(self.slot.status, GateSlot.EMPTY)


class ReturnTokenViewTest(TestCase):
    def setUp(self):
        self.gamer = User.objects.create(email="gamer@test.io")
        self.client.force_login(self.gamer)
        owner = User.objects.create(email="owner@test.io")
        self.room = Room.objects.create(name="Test Room", owner=owner)
        self.slot = self.room.gate_slots.get(slot_number=1)
        self.slot.gamer = self.gamer
        self.slot.status = GateSlot.RESERVED
        self.slot.reserved_at = timezone.now()
        self.slot.save()

    def test_return_clears_reserved_slot(self):
        self.client.post(
            reverse("epic:return_token", kwargs={"room_id": self.room.id})
        )
        self.slot.refresh_from_db()
        self.assertEqual(self.slot.status, GateSlot.EMPTY)
        self.assertIsNone(self.slot.gamer)
        self.assertIsNone(self.slot.reserved_at)

    def test_return_after_confirm_clears_filled_slot(self):
        self.slot.status = GateSlot.FILLED
        self.slot.save()
        self.client.post(
            reverse("epic:return_token", kwargs={"room_id": self.room.id})
        )
        self.slot.refresh_from_db()
        self.assertEqual(self.slot.status, GateSlot.EMPTY)
        self.assertIsNone(self.slot.gamer)

    def test_return_redirects_to_gatekeeper(self):
        response = self.client.post(
            reverse("epic:return_token", kwargs={"room_id": self.room.id})
        )
        self.assertRedirects(
            response, reverse("epic:gatekeeper", args=[self.room.id])
        )

    def test_return_restores_coin_token(self):
        coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
        coin.current_room = self.room
        coin.in_use_since = timezone.now()
        coin.save()
        self.slot.status = GateSlot.FILLED
        self.slot.debited_token_type = Token.COIN
        self.slot.save()
        self.client.post(
            reverse("epic:return_token", kwargs={"room_id": self.room.id})
        )
        coin.refresh_from_db()
        self.assertIsNone(coin.current_room)
        self.assertIsNone(coin.in_use_since)

    def test_return_restores_free_token(self):
        Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
        expires = timezone.now() + timedelta(days=3)
        self.slot.status = GateSlot.FILLED
        self.slot.debited_token_type = Token.FREE
        self.slot.debited_token_expires_at = expires
        self.slot.save()
        self.client.post(
            reverse("epic:return_token", kwargs={"room_id": self.room.id})
        )
        restored = Token.objects.filter(user=self.gamer, token_type=Token.FREE).first()
        self.assertIsNotNone(restored)
        self.assertEqual(restored.expires_at, expires)

    def test_return_restores_tithe_token(self):
        self.slot.status = GateSlot.FILLED
        self.slot.debited_token_type = Token.TITHE
        self.slot.save()
        self.client.post(
            reverse("epic:return_token", kwargs={"room_id": self.room.id})
        )
        self.assertTrue(
            Token.objects.filter(user=self.gamer, token_type=Token.TITHE).exists()
        )


class DropTokenAvailabilityViewTest(TestCase):
    def setUp(self):
        self.gamer = User.objects.create(email="gamer@test.io")
        self.client.force_login(self.gamer)
        owner = User.objects.create(email="owner@test.io")
        self.room = Room.objects.create(name="Test Room", owner=owner)
        self.other_room = Room.objects.create(name="Other Room", owner=owner)
        self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)

    def test_drop_reserves_slot_when_tokens_available(self):
        self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
        slot = self.room.gate_slots.get(slot_number=1)
        self.assertEqual(slot.status, GateSlot.RESERVED)
        # token not debited yet — that happens at confirm
        self.coin.refresh_from_db()
        self.assertIsNone(self.coin.current_room)

    def test_drop_returns_402_when_all_tokens_depleted(self):
        self.coin.current_room = self.other_room
        self.coin.save()
        Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
        response = self.client.post(
            reverse("epic:drop_token", kwargs={"room_id": self.room.id})
        )
        self.assertEqual(response.status_code, 402)


class ConfirmTokenPriorityViewTest(TestCase):
    def setUp(self):
        self.gamer = User.objects.create(email="gamer@test.io")
        self.client.force_login(self.gamer)
        owner = User.objects.create(email="owner@test.io")
        self.room = Room.objects.create(name="Test Room", owner=owner)
        self.other_room = Room.objects.create(name="Other Room", owner=owner)
        self.slot = self.room.gate_slots.get(slot_number=1)
        self.slot.gamer = self.gamer
        self.slot.status = GateSlot.RESERVED
        self.slot.reserved_at = timezone.now()
        self.slot.save()
        self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)

    def test_confirm_leases_coin_to_room(self):
        self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
        self.coin.refresh_from_db()
        self.assertEqual(self.coin.current_room, self.room)
        self.assertTrue(Token.objects.filter(pk=self.coin.pk).exists())

    def test_confirm_uses_free_token_when_coin_in_use(self):
        self.coin.current_room = self.other_room
        self.coin.save()
        self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
        self.assertEqual(
            Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(), 0
        )
        self.coin.refresh_from_db()
        self.assertEqual(self.coin.current_room, self.other_room)

    def test_confirm_uses_tithe_when_free_tokens_exhausted(self):
        self.coin.current_room = self.other_room
        self.coin.save()
        Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
        tithe = Token.objects.create(user=self.gamer, token_type=Token.TITHE)
        self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
        self.assertFalse(Token.objects.filter(pk=tithe.pk).exists())

    def test_pass_not_consumed_and_coin_not_leased(self):
        """Equipped PASS picked over the still-equipped-by-default COIN —
        confirm leaves PASS untouched + doesn't lease the COIN (PASS is
        never-consumed; COIN stays free for a future room). The equip
        slot is the precondition; DON-ing PASS swaps it in for the auto-
        equipped COIN."""
        self.gamer.is_staff = True
        self.gamer.save()
        pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS)
        self.gamer.equipped_trinket = pass_token
        self.gamer.save(update_fields=["equipped_trinket"])
        self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
        self.assertTrue(Token.objects.filter(pk=pass_token.pk).exists())
        self.coin.refresh_from_db()
        self.assertIsNone(self.coin.current_room)


class RoleSelectRenderingTest(TestCase):
    def setUp(self):
        self.founder = User.objects.create(email="founder@test.io")
        self.room = Room.objects.create(name="Test Room", owner=self.founder)
        self.gamers = [self.founder]
        for i in range(2, 7):
            self.gamers.append(User.objects.create(email=f"g{i}@test.io"))
        for i, gamer in enumerate(self.gamers, start=1):
            slot = self.room.gate_slots.get(slot_number=i)
            slot.gamer = gamer
            slot.status = GateSlot.FILLED
            slot.save()
        self.room.gate_status = Room.OPEN
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        for i, gamer in enumerate(self.gamers, start=1):
            TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
        self.url = reverse("epic:room", kwargs={"room_id": self.room.id})

    def test_room_view_includes_card_stack_when_role_select(self):
        self.client.force_login(self.founder)
        response = self.client.get(
            self.url
        )
        self.assertContains(response, "card-stack")

    def test_card_stack_eligible_for_slot1_gamer(self):
        self.client.force_login(self.founder)
        response = self.client.get(
            self.url
        )
        self.assertContains(response, 'data-state="eligible"')

    def test_card_stack_ineligible_for_slot2_gamer(self):
        self.client.force_login(self.gamers[1])
        response = self.client.get(
            self.url
        )
        self.assertContains(response, 'data-state="ineligible"')

    def test_card_stack_ineligible_shows_fa_ban(self):
        self.client.force_login(self.gamers[1])
        response = self.client.get(
            self.url
        )
        self.assertContains(response, "fa-ban")

    def test_card_stack_eligible_omits_fa_ban(self):
        self.client.force_login(self.founder)
        response = self.client.get(
            self.url
        )
        # Seat ban icons carry "position-status-icon"; card-stack ban does not.
        # Assert the bare "fa-solid fa-ban" (card-stack form) is absent.
        self.assertNotContains(response, 'class="fa-solid fa-ban"')

    def test_gatekeeper_overlay_absent_when_role_select(self):
        self.client.force_login(self.founder)
        response = self.client.get(
            self.url
        )
        self.assertNotContains(response, "gate-overlay")

    def test_tray_wrap_has_role_select_phase_class(self):
        # Tray handle hidden until gamer confirms a role pick
        self.client.force_login(self.founder)
        response = self.client.get(self.url)
        self.assertContains(response, 'id="id_tray_wrap" class="role-select-phase"')

    def test_tray_absent_during_gatekeeper_phase(self):
        # Tray must not render before the gamer occupies a seat
        room = Room.objects.create(name="Gate Room", owner=self.founder)
        self.client.force_login(self.founder)
        response = self.client.get(
            reverse("epic:gatekeeper", kwargs={"room_id": room.id})
        )
        self.assertNotContains(response, 'id="id_tray_wrap"')

    def test_six_table_seats_rendered(self):
        self.client.force_login(self.founder)
        response = self.client.get(
            self.url
        )
        self.assertContains(response, "table-seat", count=6)

    def test_table_seats_never_active_on_load(self):
        # Seat glow is JS-only (during tray animation); never server-rendered
        self.client.force_login(self.founder)
        response = self.client.get(self.url)
        self.assertNotContains(response, 'class="table-seat active"')

    def test_assigned_seat_renders_role_confirmed_class(self):
        # A seat with a role already picked must load as role-confirmed (opaque chair)
        self.gamers[0].refresh_from_db()
        seat = self.room.table_seats.get(slot_number=1)
        seat.role = "PC"
        seat.save()
        self.client.force_login(self.founder)
        response = self.client.get(self.url)
        self.assertContains(response, 'table-seat role-confirmed')

    def test_unassigned_seat_lacks_role_confirmed_class(self):
        self.client.force_login(self.founder)
        response = self.client.get(self.url)
        self.assertNotContains(response, 'table-seat role-confirmed')

    def test_assigned_slot_circle_renders_role_assigned_class(self):
        # Slot 1 circle hidden because 1 role was assigned (count-based, not role-label-based)
        seat = self.room.table_seats.get(slot_number=1)
        seat.role = "PC"
        seat.save()
        self.client.force_login(self.founder)
        response = self.client.get(self.url)
        self.assertContains(response, 'gate-slot filled role-assigned')

    def test_slot_circle_hides_by_count_not_role_label(self):
        # Gamer in slot 1 picks NC (not PC) — slot 1 circle must still hide, not slot 2's
        seat = self.room.table_seats.get(slot_number=1)
        seat.role = "NC"
        seat.save()
        self.client.force_login(self.founder)
        response = self.client.get(self.url)
        content = response.content.decode()
        import re
        # Template renders class before data-slot; capture both orderings
        circles = re.findall(r'class="([^"]*gate-slot[^"]*)"[^>]*data-slot="(\d)"', content)
        slot1_classes = next((cls for cls, slot in circles if slot == "1"), "")
        slot2_classes = next((cls for cls, slot in circles if slot == "2"), "")
        self.assertIn("role-assigned", slot1_classes)
        self.assertNotIn("role-assigned", slot2_classes)

    def test_unassigned_slot_circle_lacks_role_assigned_class(self):
        self.client.force_login(self.founder)
        response = self.client.get(self.url)
        self.assertNotContains(response, 'role-assigned')

    def test_position_strip_rendered_during_role_select(self):
        self.client.force_login(self.founder)
        response = self.client.get(self.url)
        self.assertContains(response, "position-strip")

    def test_position_strip_has_six_gate_slots(self):
        self.client.force_login(self.founder)
        response = self.client.get(self.url)
        self.assertContains(response, "gate-slot", count=6)

    def test_card_stack_has_data_user_slots_for_eligible_gamer(self):
        self.client.force_login(self.founder)  # founder is slot 1 only
        response = self.client.get(
            self.url
        )
        self.assertContains(response, 'data-user-slots="1"')

    def test_card_stack_has_data_user_slots_for_ineligible_gamer(self):
        self.client.force_login(self.gamers[1])  # slot 2 gamer
        response = self.client.get(
            self.url
        )
        self.assertContains(response, 'data-user-slots="2"')

    def test_assigned_seat_renders_check_icon(self):
        seat = self.room.table_seats.get(slot_number=1)
        seat.role = "PC"
        seat.save()
        self.client.force_login(self.founder)
        response = self.client.get(self.url)
        content = response.content.decode()
        # The PC seat should have fa-circle-check, not fa-ban
        pc_seat_start = content.index('data-role="PC"')
        pc_seat_chunk = content[pc_seat_start:pc_seat_start + 300]
        self.assertIn("fa-circle-check", pc_seat_chunk)
        self.assertNotIn("fa-ban", pc_seat_chunk)

    def test_unassigned_seat_renders_ban_icon(self):
        # slot 2's role is still null
        self.client.force_login(self.founder)
        response = self.client.get(self.url)
        content = response.content.decode()
        nc_seat_start = content.index('data-role="NC"')
        nc_seat_chunk = content[nc_seat_start:nc_seat_start + 300]
        self.assertIn("fa-ban", nc_seat_chunk)
        self.assertNotIn("fa-circle-check", nc_seat_chunk)


def _circle_start(content, slot_number):
    """Index of the gate-slot circle's opening `<div` for the given slot.
    Scopes to `.gate-slot` (room.html also renders `.table-seat` data-slot=N
    elements first, so a bare data-slot search would hit the seat, not the
    circle)."""
    needle = f'data-slot="{slot_number}"'
    pos = 0
    while True:
        idx = content.index('<div class="gate-slot', pos)
        end = content.index(">", idx)
        if needle in content[idx:end]:
            return idx
        pos = end


def _circle_tag(content, slot_number):
    """Return the opening `<div ...>` tag of the gate-slot circle for the
    given slot — class + every data-tt-* attr live on this one tag."""
    idx = _circle_start(content, slot_number)
    return content[idx:content.index(">", idx)]


class PositionTooltipRenderTest(TestCase):
    """Render-level coverage for the rich position-circle tooltip payload
    (sprint 2026-06-02) — the fast IT counterpart to the Selenium
    PositionTooltipTest in functional_tests/test_game_room_position_tooltips.py.
    Exercised on the GATE VIEW (room_gate), which rendered no circles before
    this sprint."""

    def setUp(self):
        from apps.epic.models import DeckVariant
        self.viewer = User.objects.create(email="disco@test.io", username="disco")
        self.deck, _ = DeckVariant.objects.get_or_create(
            slug="earthman",
            defaults={"name": "Earthman", "card_count": 106, "is_default": True},
        )
        self.viewer.equipped_deck = self.deck
        self.viewer.save(update_fields=["equipped_deck"])
        self.room = Room.objects.create(name="Whataburgher", owner=self.viewer)
        self.gamers = [self.viewer]
        for i in range(2, 7):
            self.gamers.append(
                User.objects.create(email=f"g{i}@test.io", username=f"g{i}")
            )
        for i, gamer in enumerate(self.gamers, start=1):
            slot = self.room.gate_slots.get(slot_number=i)
            slot.gamer = gamer
            slot.status = GateSlot.FILLED
            slot.filled_at = timezone.now()
            slot.debited_token_type = Token.FREE
            slot.save()
        self.room.gate_status = Room.OPEN
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        self.gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
        self.client.force_login(self.viewer)

    def _gate_content(self):
        return self.client.get(self.gate_url).content.decode()

    def test_gate_view_renders_six_position_circles(self):
        content = self._gate_content()
        self.assertContains(self.client.get(self.gate_url), "position-strip")
        self.assertEqual(content.count('class="gate-slot'), 6)

    def test_own_slot_is_me_current_others_are_gamer(self):
        content = self._gate_content()
        self.assertIn("tt-pos-me-current", _circle_tag(content, 1))
        slot2 = _circle_tag(content, 2)
        self.assertIn("tt-pos-gamer", slot2)
        self.assertNotIn("tt-pos-me", slot2)

    def test_other_gamer_handle_in_title_not_email(self):
        slot2 = _circle_tag(self._gate_content(), 2)
        self.assertIn('data-tt-title="@g2"', slot2)
        # No email field in the tooltip payload (user-spec).
        self.assertNotIn("data-tt-email", slot2)
        # Title carries the article — "the Earthman", not bare "Earthman".
        self.assertIn('data-tt-description="the Earthman"', slot2)

    def test_bud_occupant_carries_bud_class_and_shoptalk(self):
        from apps.billboard.models import BudshipNote
        amigo = self.gamers[1]
        self.viewer.buds.add(amigo)
        BudshipNote.objects.create(user=self.viewer, bud=amigo, shoptalk="met at the deli")
        slot2 = _circle_tag(self._gate_content(), 2)
        self.assertIn("tt-pos-bud", slot2)
        self.assertIn('data-tt-shoptalk="met at the deli"', slot2)

    def test_deposit_count_and_expiry_present(self):
        slot2 = _circle_tag(self._gate_content(), 2)
        self.assertIn('data-tt-tokens="1"', slot2)
        # FREE-funded slot → the deposited-token list reads "Free Token".
        self.assertIn('data-tt-token-types="Free Token"', slot2)
        # Lowercase "expires <when>" (relative timescale), not an ISO/locale date.
        self.assertIn('data-tt-expiry="expires ', slot2)

    def test_seat_significator_rank_rides_the_circle(self):
        sig = TarotCard.objects.create(
            deck_variant=self.deck, slug="queen-of-brands-em",
            arcana="MIDDLE", suit="BRANDS", number=13, name="Queen of Brands",
        )
        TableSeat.objects.create(
            room=self.room, gamer=self.gamers[1], slot_number=2, significator=sig,
        )
        slot2 = _circle_tag(self._gate_content(), 2)
        self.assertIn(f'data-tt-sign-rank="{sig.corner_rank}"', slot2)

    def test_no_occupant_email_anywhere_in_page_source(self):
        # The position circles render @handle (at_handle), never the raw login
        # email — not in the tooltip payload AND not in the hidden .slot-gamer
        # span. Assert on the FULL response, not just the circle's opening tag.
        content = self._gate_content()
        self.assertNotIn(self.gamers[1].email, content)  # g2@test.io
        self.assertNotIn(self.viewer.email, content)     # disco@test.io

    def test_gate_view_circles_never_fade_when_roles_assigned(self):
        # The gatekeeper's GATE VIEW is the canonical "who holds which
        # position" surface: it must ALWAYS show all six circles, even after
        # gamers get seated and the main table-hex has faded their circles away
        # one-by-one (the role-assigned animation). So when every role is
        # assigned — the moment SCAN SIGS appears on the hex — the gate view
        # keeps all six circles solid, with NO role-assigned (fade-out) class.
        roles = ["PC", "NC", "EC", "SC", "AC", "BC"]
        for i, gamer in enumerate(self.gamers, start=1):
            TableSeat.objects.create(
                room=self.room, gamer=gamer, slot_number=i, role=roles[i - 1],
            )
        content = self._gate_content()
        self.assertEqual(content.count('class="gate-slot'), 6)
        self.assertNotIn("role-assigned", content)


class PositionTooltipCarteRenderTest(TestCase):
    """CARTE-solo render contract: a single gamer owns all six slots — their
    non-current circles read tt-pos-me-also + carry a ?seat=N switch href, and
    the deposited count reflects the CARTE token's slots_claimed."""

    def setUp(self):
        from apps.epic.models import DeckVariant
        self.viewer = User.objects.create(email="disco@test.io", username="disco")
        self.deck, _ = DeckVariant.objects.get_or_create(
            slug="earthman",
            defaults={"name": "Earthman", "card_count": 106, "is_default": True},
        )
        self.viewer.equipped_deck = self.deck
        self.viewer.save(update_fields=["equipped_deck"])
        self.room = Room.objects.create(name="Carte Room", owner=self.viewer)
        self.room.gate_slots.update(
            gamer=self.viewer, status=GateSlot.FILLED,
            filled_at=timezone.now(), debited_token_type=Token.CARTE,
        )
        Token.objects.create(
            user=self.viewer, token_type=Token.CARTE,
            current_room=self.room, slots_claimed=6,
        )
        self.room.gate_status = Room.OPEN
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        self.client.force_login(self.viewer)
        self.room_url = reverse("epic:room", kwargs={"room_id": self.room.id})

    def test_each_carte_slot_costs_one_token(self):
        # A CARTE covers each seat at cost 1 — the per-slot expenditure count
        # is 1 (GateSlot.token_cost), NOT the CARTE slots_claimed high-water
        # mark. The deposited-token list reads "Carte Blanche".
        content = self.client.get(self.room_url).content.decode()
        # One CARTE covers ALL six seats — every slot reads cost 1 + "Carte
        # Blanche" (token combinations per slot aren't possible: a re-deposit
        # ejects the slot's token), so there is no mixed CARTE+FREE spread.
        for n in (1, 4, 6):
            tag = _circle_tag(content, n)
            self.assertIn('data-tt-tokens="1"', tag)
            self.assertIn('data-tt-token-types="Carte Blanche"', tag)

    def test_own_other_seat_is_me_also_with_switch_href(self):
        content = self.client.get(self.room_url).content.decode()
        slot4 = _circle_tag(content, 4)
        self.assertIn("tt-pos-me-also", slot4)
        # The switch anchor lands just after the opening tag.
        idx = _circle_start(content, 4)
        chunk = content[idx:idx + 800]
        self.assertIn("seat=4", chunk)

    def test_seat_param_previews_that_seat_in_card_stack(self):
        # Un-assigned owned seats → ?seat=4 previews seat 4, which is not the
        # table's current turn (slot 1) → card-stack ineligible (.fa-ban).
        for n in range(1, 7):
            TableSeat.objects.create(room=self.room, gamer=self.viewer, slot_number=n)
        content = self.client.get(self.room_url + "?seat=4").content.decode()
        self.assertIn('data-active-slot="4"', content)
        self.assertIn('data-state="ineligible"', content)

    def test_no_seat_param_keeps_canonical_active_slot(self):
        for n in range(1, 7):
            TableSeat.objects.create(room=self.room, gamer=self.viewer, slot_number=n)
        content = self.client.get(self.room_url).content.decode()
        # Default: the table's turn is slot 1, and the viewer owns it → eligible.
        self.assertIn('data-active-slot="1"', content)
        self.assertIn('data-state="eligible"', content)

    def test_room_gate_renders_circles_but_no_carte_action_forms(self):
        # The renewal gate-view shows the circles (for their hover tooltips)
        # but is NOT a gather surface — no CARTE drop/release forms.
        gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
        content = self.client.get(gate_url).content.decode()
        self.assertIn("position-strip", content)
        self.assertEqual(content.count('class="gate-slot'), 6)
        self.assertNotIn("slot-release-btn", content)
        self.assertNotIn("drop-token-btn", content)


class CarteTrayFollowsSelectedSeatTest(TestCase):
    """A multi-seat (CARTE) gamer's tray must mirror the seat at the
    ?seat-selected pos-circle (current_slot), not the canonical PC seat.

    Regression: my_tray_role / my_tray_sig were pinned to the role-sorted
    assigned_seats[0] (always PC) and _canonical_user_seat, so clicking ANY
    pos-circle put the PC icon in the tray. The seat-switch (?seat=N) only
    re-pointed the sig OVERLAY; the tray ignored it entirely."""

    # Reverse canonical pick order: select_role fills the lowest open slot
    # each turn, so picking BC,AC,SC,EC,NC,PC seats them at slots 1..6.
    SLOT_ROLES = {1: "BC", 2: "AC", 3: "SC", 4: "EC", 5: "NC", 6: "PC"}

    def setUp(self):
        self.viewer = User.objects.create(email="disco@test.io", username="disco")
        self.deck, _ = DeckVariant.objects.get_or_create(
            slug="earthman",
            defaults={"name": "Earthman", "card_count": 106, "is_default": True},
        )
        self.room = Room.objects.create(name="Carte Room", owner=self.viewer)
        self.room.gate_slots.update(
            gamer=self.viewer, status=GateSlot.FILLED,
            filled_at=timezone.now(), debited_token_type=Token.CARTE,
        )
        Token.objects.create(
            user=self.viewer, token_type=Token.CARTE,
            current_room=self.room, slots_claimed=6,
        )
        self.room.gate_status = Room.OPEN
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        for n, role in self.SLOT_ROLES.items():
            TableSeat.objects.create(
                room=self.room, gamer=self.viewer, slot_number=n,
                role=role, deck_variant=self.deck,
            )
        self.client.force_login(self.viewer)
        self.room_url = reverse("epic:room", kwargs={"room_id": self.room.id})

    def test_seat_param_points_tray_role_at_that_seat(self):
        # ?seat=4 → pos-circle 4 holds EC, so the tray role card is EC, not PC.
        response = self.client.get(self.room_url + "?seat=4")
        self.assertEqual(response.context["my_tray_role"], "EC")

    def test_each_owned_circle_yields_its_own_seat_role(self):
        for n, role in self.SLOT_ROLES.items():
            response = self.client.get(self.room_url + f"?seat={n}")
            self.assertEqual(
                response.context["my_tray_role"], role,
                f"pos-circle {n} should put role {role} in the tray",
            )

    def test_default_no_seat_tray_follows_lowest_owned_circle(self):
        # No ?seat → current_slot is the lowest owned pos-circle (1 = BC),
        # NOT the role-canonical PC seat (slot 6). (ROLE_SELECT — no redirect.)
        response = self.client.get(self.room_url)
        self.assertEqual(response.context["my_tray_role"], "BC")

    # ── SIG_SELECT seatless redirect (staging bug 2026-06-08) ──────────────
    # On seatless SIG_SELECT entry the sig overlay + reserve URL lock to the
    # canonical PC seat (slot 6 = levity) while the tray follows owned[0]
    # (slot 1 = BC = gravity) — so a reserved sig files against the WRONG seat,
    # that polarity never reaches 3-ready, and its countdown stalls at 0. Redirect
    # a seatless multi-seat owner to ?seat=<owned[0]> so every surface aligns on
    # one seat via the proven ?seat path. SIG_SELECT-only.

    def test_sig_select_seatless_multi_owner_redirects_to_owned0(self):
        self.room.table_status = Room.SIG_SELECT
        self.room.save()
        response = self.client.get(self.room_url)  # no ?seat
        self.assertRedirects(
            response, self.room_url + "?seat=1", fetch_redirect_response=False
        )

    def test_sig_select_with_seat_param_does_not_redirect(self):
        self.room.table_status = Room.SIG_SELECT
        self.room.save()
        response = self.client.get(self.room_url + "?seat=4")
        self.assertEqual(response.status_code, 200)

    def test_sig_select_overlay_and_tray_agree_after_redirect(self):
        # Bug-closing: post-redirect the sig overlay's seat/polarity match the
        # tray's owned[0] seat (slot 1 = BC = gravity), not the canonical PC.
        self.room.table_status = Room.SIG_SELECT
        self.room.save()
        response = self.client.get(self.room_url, follow=True)
        self.assertEqual(response.context["my_tray_role"], "BC")        # tray
        self.assertEqual(response.context["user_seat"].role, "BC")      # overlay
        self.assertEqual(response.context["user_polarity"], "gravity")  # overlay

    def test_sig_select_single_seat_owner_no_redirect(self):
        self.room.table_status = Room.SIG_SELECT
        self.room.save()
        self.room.gate_slots.exclude(slot_number=1).update(
            gamer=None, status=GateSlot.EMPTY, filled_at=None,
            debited_token_type=None,
        )
        TableSeat.objects.filter(room=self.room).exclude(slot_number=1).delete()
        response = self.client.get(self.room_url)
        self.assertEqual(response.status_code, 200)

    def test_sig_select_non_owner_no_redirect(self):
        self.room.table_status = Room.SIG_SELECT
        self.room.save()
        other = User.objects.create(email="spec@test.io", username="spec")
        self.client.force_login(other)
        response = self.client.get(self.room_url)
        self.assertEqual(response.status_code, 200)

    def test_sky_select_seatless_multi_owner_no_redirect(self):
        self.room.table_status = Room.SKY_SELECT
        self.room.save()
        response = self.client.get(self.room_url)
        self.assertEqual(response.status_code, 200)

    def test_seat_param_points_tray_sig_at_that_seat(self):
        sig = TarotCard.objects.create(
            deck_variant=self.deck, slug="ec-sig",
            arcana="MIDDLE", suit="BRANDS", number=13, name="Queen of Brands",
        )
        ec_seat = TableSeat.objects.get(room=self.room, slot_number=4)
        ec_seat.significator = sig
        ec_seat.save(update_fields=["significator"])
        # Switch to EC's circle → its sig rides the tray.
        response = self.client.get(self.room_url + "?seat=4")
        self.assertEqual(response.context["my_tray_sig"], sig)
        # The PC circle (slot 6) has no sig → switching there shows none
        # (proving the tray no longer falls back to a single canonical seat).
        response = self.client.get(self.room_url + "?seat=6")
        self.assertIsNone(response.context["my_tray_sig"])

    def test_gate_view_button_carries_acting_seat(self):
        # Regression (gate-view pos-1 lockout): the GATE VIEW buttons linked
        # to room_gate with NO ?seat, so the gate view fell back to owned[0]
        # (pos 1) → pos 1 was always me-current + never got a switch href:
        # the one seat you could never return to. The acting ?seat must ride
        # the gate-view nav so the gate view's current matches the table.
        gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
        response = self.client.get(self.room_url + "?seat=4")
        self.assertEqual(response.context["current_slot"], 4)
        self.assertContains(response, f"{gate_url}?seat=4")

    def test_gate_view_button_default_targets_lowest_owned(self):
        # No ?seat on the table → the gate button carries the lowest owned
        # slot (current_slot), keeping the table + gate views in agreement.
        gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
        response = self.client.get(self.room_url)
        self.assertEqual(response.context["current_slot"], 1)
        self.assertContains(response, f"{gate_url}?seat=1")

    def test_gate_page_exposes_and_re_carries_acting_seat(self):
        # The gate page's own navbar GATE VIEW re-click must keep the acting
        # seat → _gate_context exposes current_slot too, and pos 1 is now
        # me-also (switchable) while pos 4 is me-current — no lockout.
        gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
        response = self.client.get(gate_url + "?seat=4")
        self.assertEqual(response.context["current_slot"], 4)
        self.assertContains(response, f"{gate_url}?seat=4")
        by_slot = {
            p["slot"].slot_number: p
            for p in response.context["gate_positions"]
        }
        self.assertEqual(by_slot[4]["state_class"], "tt-pos-me-current")
        self.assertTrue(by_slot[1]["is_me_also"])
        self.assertEqual(by_slot[1]["state_class"], "tt-pos-me-also")

    def test_gate_view_cont_game_and_nvm_carry_acting_seat(self):
        # Regression (CONT GAME pos-1 shuttle): the gate-view CONT GAME btn +
        # the gear NVM both linked to epic:room with NO ?seat, so a CARTE
        # multi-seat gamer acting at pos 4 got shuttled back to owned[0] (pos 1)
        # instead of staying on his acting seat. Both must carry the acting
        # ?seat through to the table (same fix shape as the GATE VIEW nav btns).
        gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
        response = self.client.get(gate_url + "?seat=4")
        self.assertEqual(response.context["current_slot"], 4)
        # CONT GAME btn returns to the table hex at the acting seat.
        self.assertContains(
            response,
            f"window.location.href='{self.room_url}?seat=4'",
        )
        # Gear NVM (back to the hex) carries the acting seat too.
        self.assertContains(
            response,
            f'href="{self.room_url}?seat=4" class="btn btn-cancel"',
        )

    def test_gate_view_cont_game_default_targets_lowest_owned(self):
        # No ?seat on the gate → CONT GAME + NVM carry the lowest owned slot
        # (current_slot), keeping the gate + table views in agreement.
        gate_url = reverse("epic:room_gate", kwargs={"room_id": self.room.id})
        response = self.client.get(gate_url)
        self.assertEqual(response.context["current_slot"], 1)
        self.assertContains(
            response,
            f"window.location.href='{self.room_url}?seat=1'",
        )


class PickRolesViewTest(TestCase):
    def setUp(self):
        self.founder = User.objects.create(email="founder@test.io")
        self.client.force_login(self.founder)
        self.room = Room.objects.create(name="Test Room", owner=self.founder)
        for i in range(1, 7):
            gamer = self.founder if i == 1 else User.objects.create(email=f"g{i}@test.io")
            slot = self.room.gate_slots.get(slot_number=i)
            slot.gamer = gamer
            slot.status = GateSlot.FILLED
            slot.save()
        self.room.gate_status = Room.OPEN
        self.room.save()

    def test_pick_roles_transitions_room_to_role_select(self):
        self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
        self.room.refresh_from_db()
        self.assertEqual(self.room.table_status, Room.ROLE_SELECT)

    def test_pick_roles_creates_one_table_seat_per_filled_slot(self):
        self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
        self.assertEqual(TableSeat.objects.filter(room=self.room).count(), 6)

    def test_pick_roles_table_seats_carry_gamer_and_slot_number(self):
        self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
        seat = TableSeat.objects.get(room=self.room, slot_number=1)
        self.assertEqual(seat.gamer, self.founder)

    def test_only_open_room_can_start_role_select(self):
        self.room.gate_status = Room.GATHERING
        self.room.save()
        self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
        self.room.refresh_from_db()
        self.assertIsNone(self.room.table_status)

    def test_pick_roles_requires_login(self):
        self.client.logout()
        response = self.client.post(
            reverse("epic:pick_roles", kwargs={"room_id": self.room.id})
        )
        self.assertEqual(response.status_code, 302)
        self.assertIn("/accounts/login/", response.url)

    def test_pick_roles_redirects_to_room(self):
        response = self.client.post(
            reverse("epic:pick_roles", kwargs={"room_id": self.room.id})
        )
        self.assertRedirects(
            response, reverse("epic:room", args=[self.room.id])
        )

    def test_pick_roles_notifies_channel_layer(self):
        with patch("apps.epic.views._notify_role_select_start") as mock_notify:
            self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
            mock_notify.assert_called_once_with(self.room.id)

    def test_pick_roles_idempotent_no_duplicate_seats(self):
        url = reverse("epic:pick_roles", kwargs={"room_id": self.room.id})
        self.client.post(url)
        self.client.post(url)  # second call must be a no-op
        self.assertEqual(TableSeat.objects.filter(room=self.room).count(), 6)


class SelectRoleViewTest(TestCase):
    def setUp(self):
        self.founder = User.objects.create(email="founder@test.io")
        self.room = Room.objects.create(name="Test Room", owner=self.founder)
        self.gamers = [self.founder]
        for i in range(2, 7):
            self.gamers.append(User.objects.create(email=f"g{i}@test.io"))
        for i, gamer in enumerate(self.gamers, start=1):
            slot = self.room.gate_slots.get(slot_number=i)
            slot.gamer = gamer
            slot.status = GateSlot.FILLED
            slot.save()
        self.room.gate_status = Room.OPEN
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        for i, gamer in enumerate(self.gamers, start=1):
            TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
        self.client.force_login(self.founder)

    def test_select_role_records_choice(self):
        self.client.post(
            reverse("epic:select_role", kwargs={"room_id": self.room.id}),
            data={"role": "PC"},
        )
        seat = TableSeat.objects.get(room=self.room, slot_number=1)
        self.assertEqual(seat.role, "PC")

    def test_select_role_wrong_turn_makes_no_change(self):
        self.client.force_login(self.gamers[1])  # slot 2 — not their turn
        self.client.post(
            reverse("epic:select_role", kwargs={"room_id": self.room.id}),
            data={"role": "BC"},
        )
        seat = TableSeat.objects.get(room=self.room, slot_number=2)
        self.assertIsNone(seat.role)

    def test_turn_advances_after_selection(self):
        self.client.post(
            reverse("epic:select_role", kwargs={"room_id": self.room.id}),
            data={"role": "PC"},
        )
        next_active = TableSeat.objects.filter(
            room=self.room, role__isnull=True
        ).order_by("slot_number").first()
        self.assertEqual(next_active.slot_number, 2)

    def test_all_selected_stays_role_select_status(self):
        roles = ["PC", "BC", "SC", "AC", "NC"]
        for i, role in enumerate(roles):
            seat = TableSeat.objects.get(room=self.room, slot_number=i + 1)
            seat.role = role
            seat.save()
        self.client.force_login(self.gamers[5])  # slot 6 — last
        self.client.post(
            reverse("epic:select_role", kwargs={"room_id": self.room.id}),
            data={"role": "EC"},
        )
        self.room.refresh_from_db()
        self.assertEqual(self.room.table_status, Room.ROLE_SELECT)

    def test_select_role_notifies_turn_changed(self):
        with patch("apps.epic.views._notify_turn_changed") as mock_notify:
            self.client.post(
                reverse("epic:select_role", kwargs={"room_id": self.room.id}),
                data={"role": "PC"},
            )
            mock_notify.assert_called_once_with(self.room.id)

    def test_select_role_notifies_all_roles_filled_when_last(self):
        roles = ["PC", "BC", "SC", "AC", "NC"]
        for i, role in enumerate(roles):
            seat = TableSeat.objects.get(room=self.room, slot_number=i + 1)
            seat.role = role
            seat.save()
        self.client.force_login(self.gamers[5])
        with patch("apps.epic.views._notify_all_roles_filled") as mock_notify:
            self.client.post(
                reverse("epic:select_role", kwargs={"room_id": self.room.id}),
                data={"role": "EC"},
            )
            mock_notify.assert_called_once_with(self.room.id)

    def test_select_role_assigns_equipped_deck_to_seat(self):
        earthman = DeckVariant.objects.get(slug="earthman")
        self.founder.equipped_deck = earthman
        self.founder.save(update_fields=["equipped_deck"])
        self.client.post(
            reverse("epic:select_role", kwargs={"room_id": self.room.id}),
            data={"role": "PC"},
        )
        seat = TableSeat.objects.get(room=self.room, slot_number=1)
        self.assertEqual(seat.deck_variant, earthman)

    def test_select_role_no_deck_leaves_deck_variant_null(self):
        self.founder.equipped_deck = None
        self.founder.save(update_fields=["equipped_deck"])
        self.client.post(
            reverse("epic:select_role", kwargs={"room_id": self.room.id}),
            data={"role": "PC"},
        )
        seat = TableSeat.objects.get(room=self.room, slot_number=1)
        self.assertIsNone(seat.deck_variant)

    def test_select_role_unequips_deck_from_user(self):
        earthman = DeckVariant.objects.get(slug="earthman")
        self.client.post(
            reverse("epic:select_role", kwargs={"room_id": self.room.id}),
            data={"role": "PC"},
        )
        self.founder.refresh_from_db()
        self.assertIsNone(self.founder.equipped_deck)

    def test_select_role_requires_login(self):
        self.client.logout()
        response = self.client.post(
            reverse("epic:select_role", kwargs={"room_id": self.room.id}),
            data={"role": "PC"},
        )
        self.assertEqual(response.status_code, 302)
        self.assertIn("/accounts/login/", response.url)

    def test_select_role_returns_ok(self):
        response = self.client.post(
            reverse("epic:select_role", kwargs={"room_id": self.room.id}),
            data={"role": "PC"},
        )
        self.assertEqual(response.status_code, 200)

    def test_select_role_returns_409_for_duplicate_role(self):
        TableSeat.objects.filter(room=self.room, slot_number=2).update(role="BC")
        response = self.client.post(
            reverse("epic:select_role", kwargs={"room_id": self.room.id}),
            data={"role": "BC"},
        )
        self.assertEqual(response.status_code, 409)

    def test_select_role_redirects_when_not_role_select_phase(self):
        self.room.table_status = None
        self.room.save()
        response = self.client.post(
            reverse("epic:select_role", kwargs={"room_id": self.room.id}),
            data={"role": "PC"},
        )
        self.assertRedirects(
            response, reverse("epic:gatekeeper", args=[self.room.id])
        )

    def test_select_role_redirects_for_invalid_role_code(self):
        response = self.client.post(
            reverse("epic:select_role", kwargs={"room_id": self.room.id}),
            data={"role": "BOGUS"},
        )
        self.assertRedirects(
            response, reverse("epic:room", args=[self.room.id])
        )

    def test_same_gamer_cannot_double_pick_sequentially(self):
        """A second POST from the active gamer — after their role has been
        saved — must redirect rather than assign a second role."""
        self.client.post(
            reverse("epic:select_role", kwargs={"room_id": self.room.id}),
            data={"role": "PC"},
        )
        response = self.client.post(
            reverse("epic:select_role", kwargs={"room_id": self.room.id}),
            data={"role": "BC"},
        )
        self.assertRedirects(
            response, reverse("epic:room", args=[self.room.id])
        )
        self.assertEqual(
            TableSeat.objects.filter(room=self.room, role__isnull=False).count(), 1
        )


class SelectRoleMultiSeatTest(TestCase):
    """Carte Blanche multi-seat: second role reuses the deck from the first seat."""

    def setUp(self):
        self.founder = User.objects.create(email="founder@test.io")
        self.client.force_login(self.founder)
        self.room = Room.objects.create(name="Test Room", owner=self.founder)
        self.room.gate_status = Room.OPEN
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        self.earthman = DeckVariant.objects.get(slug="earthman")

    def test_second_role_inherits_deck_from_first_seat_in_room(self):
        # Founder's first seat: PC already taken with deck assigned
        TableSeat.objects.create(
            room=self.room, gamer=self.founder, slot_number=1,
            role="PC", deck_variant=self.earthman,
        )
        # Deck unequipped after first role
        self.founder.equipped_deck = None
        self.founder.save(update_fields=["equipped_deck"])
        # Founder's second seat (Carte Blanche): no role yet
        second_seat = TableSeat.objects.create(
            room=self.room, gamer=self.founder, slot_number=2,
        )
        self.client.post(
            reverse("epic:select_role", kwargs={"room_id": self.room.id}),
            data={"role": "BC"},
        )
        second_seat.refresh_from_db()
        self.assertEqual(second_seat.deck_variant, self.earthman)

    def test_second_role_does_not_unequip_again(self):
        """No-op unequip when deck was already cleared by the first role."""
        TableSeat.objects.create(
            room=self.room, gamer=self.founder, slot_number=1,
            role="PC", deck_variant=self.earthman,
        )
        self.founder.equipped_deck = None
        self.founder.save(update_fields=["equipped_deck"])
        TableSeat.objects.create(room=self.room, gamer=self.founder, slot_number=2)
        self.client.post(
            reverse("epic:select_role", kwargs={"room_id": self.room.id}),
            data={"role": "BC"},
        )
        self.founder.refresh_from_db()
        self.assertIsNone(self.founder.equipped_deck)  # still None, not broken

    # ── Return-trip Role-Select bug — CARTE user navigates away + back ───── #
    #
    # After the FIRST role pick, select_role() clears user.equipped_deck
    # ("deck committed to room"). The room view's role-select context then
    # passes `equipped_deck_id = user.equipped_deck_id` to the template,
    # which sets `data-equipped-deck=""` and JS guards against role-select
    # with "Equip card deck before Role select." → blocks a CARTE user from
    # continuing to pick roles for their remaining seats. Fix: the context's
    # `equipped_deck_id` should also accept the deck_variant of any seat the
    # user already holds in this room (the deck IS in play — it's just
    # committed to existing seats, not to user.equipped_deck).

    def test_role_select_context_recovers_deck_id_from_existing_seat(self):
        """User cleared their equipped_deck after first role pick, but they
        still have a seat in this room w. deck_variant set → context should
        report that deck's id so the guard doesn't fire on return."""
        TableSeat.objects.create(
            room=self.room, gamer=self.founder, slot_number=1,
            role="PC", deck_variant=self.earthman,
        )
        # Slot 2 still needs role (CARTE user's next seat)
        TableSeat.objects.create(room=self.room, gamer=self.founder, slot_number=2)
        self.founder.equipped_deck = None
        self.founder.save(update_fields=["equipped_deck"])
        # Simulate filled gate slots so the room renders in role-select state
        for i in (1, 2):
            slot = self.room.gate_slots.get(slot_number=i)
            slot.gamer = self.founder
            slot.status = GateSlot.FILLED
            slot.save()
        response = self.client.get(
            reverse("epic:room", kwargs={"room_id": self.room.id})
        )
        self.assertEqual(
            response.context["equipped_deck_id"], self.earthman.id,
            "Returning CARTE user should see their in-play deck reflected in "
            "the role-select context so the JS guard doesn't fire.",
        )

    def test_role_select_context_renders_data_equipped_deck_non_empty(self):
        """Template-level check — the rendered `data-equipped-deck` attribute
        should be non-empty so the JS guard at role-select.js:165 lets the
        fan open."""
        TableSeat.objects.create(
            room=self.room, gamer=self.founder, slot_number=1,
            role="PC", deck_variant=self.earthman,
        )
        TableSeat.objects.create(room=self.room, gamer=self.founder, slot_number=2)
        self.founder.equipped_deck = None
        self.founder.save(update_fields=["equipped_deck"])
        for i in (1, 2):
            slot = self.room.gate_slots.get(slot_number=i)
            slot.gamer = self.founder
            slot.status = GateSlot.FILLED
            slot.save()
        response = self.client.get(
            reverse("epic:room", kwargs={"room_id": self.room.id})
        )
        # Should NOT contain the empty-string version that triggers the guard.
        self.assertNotContains(response, 'data-equipped-deck=""')
        self.assertContains(response, f'data-equipped-deck="{self.earthman.id}"')


class RoomViewAllRolesFilledTest(TestCase):
    """Room view in ROLE_SELECT with all seats assigned shows SCAN SIGS button."""
    def setUp(self):
        import lxml.html
        self.lxml = lxml.html
        self.owner = User.objects.create(email="owner@test.io")
        self.room = Room.objects.create(name="Test Room", owner=self.owner)
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        all_roles = ["PC", "BC", "SC", "AC", "NC", "EC"]
        for i, role in enumerate(all_roles, start=1):
            user = User.objects.create(email=f"p{i}@test.io")
            TableSeat.objects.create(room=self.room, gamer=user, slot_number=i, role=role)
        self.client.force_login(self.owner)

    def test_pick_sigs_btn_present_when_all_roles_filled(self):
        response = self.client.get(reverse("epic:room", kwargs={"room_id": self.room.id}))
        parsed = self.lxml.fromstring(response.content)
        [_] = parsed.cssselect("#id_pick_sigs_btn")
        self.assertEqual(parsed.cssselect(".card-stack"), [])

    def test_pick_sigs_btn_hidden_during_role_select(self):
        # Clear one role — still mid-pick, wrap must be hidden
        TableSeat.objects.filter(room=self.room, slot_number=6).update(role=None)
        response = self.client.get(reverse("epic:room", kwargs={"room_id": self.room.id}))
        parsed = self.lxml.fromstring(response.content)
        [wrap] = parsed.cssselect("#id_pick_sigs_wrap")
        self.assertIn("display:none", wrap.get("style", "").replace(" ", ""))


class PickSigsViewTest(TestCase):
    def setUp(self):
        self.owner = User.objects.create(email="owner@test.io")
        self.room = Room.objects.create(name="Test Room", owner=self.owner)
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        all_roles = ["PC", "BC", "SC", "AC", "NC", "EC"]
        for i, role in enumerate(all_roles, start=1):
            user = User.objects.create(email=f"p{i}@test.io")
            TableSeat.objects.create(room=self.room, gamer=user, slot_number=i, role=role)
        self.client.force_login(self.owner)
        self.url = reverse("epic:pick_sigs", kwargs={"room_id": self.room.id})

    def test_pick_sigs_requires_login(self):
        self.client.logout()
        response = self.client.post(self.url)
        self.assertEqual(response.status_code, 302)
        self.assertIn("/accounts/login/", response.url)

    def test_pick_sigs_transitions_to_sig_select(self):
        self.client.post(self.url)
        self.room.refresh_from_db()
        self.assertEqual(self.room.table_status, Room.SIG_SELECT)

    def test_pick_sigs_redirects_to_room(self):
        response = self.client.post(self.url)
        self.assertRedirects(response, reverse("epic:room", args=[self.room.id]))

    def test_pick_sigs_is_noop_if_not_role_select(self):
        self.room.table_status = Room.SIG_SELECT
        self.room.save()
        self.client.post(self.url)
        self.room.refresh_from_db()
        self.assertEqual(self.room.table_status, Room.SIG_SELECT)

    def test_pick_sigs_notifies_sig_select_started(self):
        with patch("apps.epic.views._notify_sig_select_started") as mock_notify:
            self.client.post(self.url)
            mock_notify.assert_called_once_with(self.room.id)


class RoomActionsViewTest(TestCase):
    def setUp(self):
        self.owner = User.objects.create(email="owner@test.io")
        self.gamer = User.objects.create(email="gamer@test.io")
        self.room = Room.objects.create(name="Test Room", owner=self.owner)
        self.slot = self.room.gate_slots.get(slot_number=2)
        self.slot.gamer = self.gamer
        self.slot.status = "FILLED"
        self.slot.save()
        RoomInvite.objects.create(
            room=self.room, inviter=self.owner,
            invitee_email=self.gamer.email
        )

    def test_owner_delete_removes_room(self):
        self.client.force_login(self.owner)
        self.client.post(reverse("epic:delete_room", kwargs={"room_id": self.room.id}))
        self.assertFalse(Room.objects.filter(pk=self.room.pk).exists())

    def test_non_owner_delete_does_not_remove_room(self):
        self.client.force_login(self.gamer)
        self.client.post(reverse("epic:delete_room", kwargs={"room_id": self.room.id}))
        self.assertTrue(Room.objects.filter(pk=self.room.pk).exists())

    def test_delete_redirects_to_gameboard(self):
        self.client.force_login(self.owner)
        response = self.client.post(
            reverse("epic:delete_room", kwargs={"room_id": self.room.id})
        )
        self.assertRedirects(response, "/gameboard/")

    def test_abandon_clears_slot(self):
        self.client.force_login(self.gamer)
        self.client.post(reverse("epic:abandon_room", kwargs={"room_id": self.room.id}))
        self.slot.refresh_from_db()
        self.assertEqual(self.slot.status, "EMPTY")
        self.assertIsNone(self.slot.gamer)

    def test_abandon_deletes_pending_invite(self):
        self.client.force_login(self.gamer)
        self.client.post(reverse("epic:abandon_room", kwargs={"room_id": self.room.id}))
        self.assertFalse(
            RoomInvite.objects.filter(
                room=self.room, invitee_email=self.gamer.email
            ).exists()
        )

    def test_abandon_redirects_to_gameboard(self):
        self.client.force_login(self.gamer)
        response = self.client.post(
            reverse("epic:abandon_room", kwargs={"room_id": self.room.id})
        )
        self.assertRedirects(response, "/gameboard/")


class ReleaseSlotViewTest(TestCase):
    def setUp(self):
        self.gamer = User.objects.create(email="gamer@test.io")
        self.client.force_login(self.gamer)
        owner = User.objects.create(email="owner@test.io")
        self.room = Room.objects.create(name="Test Room", owner=owner)
        self.slot = self.room.gate_slots.get(slot_number=1)
        self.slot.gamer = self.gamer
        self.slot.status = GateSlot.FILLED
        self.slot.debited_token_type = Token.CARTE
        self.slot.save()

    def test_release_slot_downgrades_open_room_to_gathering(self):
        self.room.gate_status = Room.OPEN
        self.room.save()
        self.client.post(
            reverse("epic:release_slot", kwargs={"room_id": self.room.id}),
            data={"slot_number": self.slot.slot_number},
        )
        self.room.refresh_from_db()
        self.assertEqual(self.room.gate_status, Room.GATHERING)


# ── Significator Selection ────────────────────────────────────────────────────

SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]


def _full_sig_setUp(test_case, role_order=None):
    """Populate test_case with a SIG_SELECT room; return (room, gamers, earthman, card_in_deck).
    Uses get_or_create for DeckVariant — migration data persists in TestCase."""
    if role_order is None:
        role_order = SIG_SEAT_ORDER[:]
    earthman, _ = DeckVariant.objects.get_or_create(
        slug="earthman",
        defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
    )
    founder = User.objects.create(email="founder@test.io")
    gamers = [founder]
    for i in range(2, 7):
        gamers.append(User.objects.create(email=f"g{i}@test.io"))
    for gamer in gamers:
        gamer.equipped_deck = earthman
        gamer.save(update_fields=["equipped_deck"])
    room = Room.objects.create(name="Sig Room", owner=founder)
    for i, (gamer, role) in enumerate(zip(gamers, role_order), start=1):
        slot = room.gate_slots.get(slot_number=i)
        slot.gamer = gamer
        slot.status = GateSlot.FILLED
        slot.save()
        TableSeat.objects.create(
            room=room, gamer=gamer, slot_number=i, role=role,
            role_revealed=True, deck_variant=earthman,
        )
    room.gate_status = Room.OPEN
    room.table_status = Room.SIG_SELECT
    room.save()
    card_in_deck = TarotCard.objects.get(
        deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=11
    )
    test_case.client.force_login(founder)
    return room, gamers, earthman, card_in_deck


class SigReserveCarteMultiSeatTest(TestCase):
    """CARTE multi-seat: one gamer owns every seat. He reserves a DISTINCT sig
    per seat (no NVM between switches), readies each, and each polarity room
    fires its own 12s countdown — the same flow as multi-gamer. Reserve no
    longer commits the significator immediately (the solo immediate-commit
    shortcut was demolished 2026-06-05 once the countdown mechanism existed)."""

    def setUp(self):
        from apps.epic.models import DeckVariant
        self.earthman, _ = DeckVariant.objects.get_or_create(
            slug="earthman",
            defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
        )
        self.viewer = User.objects.create(email="disco@test.io", username="disco")
        self.viewer.equipped_deck = self.earthman
        self.viewer.save(update_fields=["equipped_deck"])
        self.room = Room.objects.create(name="Carte Sig", owner=self.viewer)
        for i, role in enumerate(SIG_SEAT_ORDER, start=1):
            slot = self.room.gate_slots.get(slot_number=i)
            slot.gamer = self.viewer
            slot.status = GateSlot.FILLED
            slot.save()
            TableSeat.objects.create(
                room=self.room, gamer=self.viewer, slot_number=i, role=role,
                role_revealed=True, deck_variant=self.earthman,
            )
        self.room.gate_status = Room.OPEN
        self.room.table_status = Room.SIG_SELECT
        self.room.save()
        self.client.force_login(self.viewer)
        self.url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id})
        self.ready_url = reverse("epic:sig_ready", kwargs={"room_id": self.room.id})
        # Three distinct levity court cards. Levity seats = slots 1(PC), 2(NC), 4(SC).
        self.card_a, self.card_b, self.card_c = [
            TarotCard.objects.get(
                deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=n)
            for n in (11, 12, 13)
        ]

    def test_reserve_does_not_commit_significator(self):
        # Demolished solo shortcut: OK creates a provisional reservation; the
        # countdown later commits seat.significator (no instant write).
        self.client.post(self.url + "?seat=1",
                         data={"card_id": self.card_a.id, "action": "reserve"})
        seat1 = TableSeat.objects.get(room=self.room, slot_number=1)
        self.assertIsNone(seat1.significator_id)
        self.assertTrue(SigReservation.objects.filter(
            room=self.room, gamer=self.viewer, seat=seat1).exists())

    def test_reserve_distinct_card_per_seat_without_nvm(self):
        # The reported bug: switching seats and OK-ing a second sig must NOT 409.
        r1 = self.client.post(self.url + "?seat=1",
                              data={"card_id": self.card_a.id, "action": "reserve"})
        r2 = self.client.post(self.url + "?seat=2",
                              data={"card_id": self.card_b.id, "action": "reserve"})
        self.assertEqual(r1.status_code, 200)
        self.assertEqual(r2.status_code, 200)
        self.assertEqual(SigReservation.objects.filter(
            room=self.room, gamer=self.viewer).count(), 2)
        # Neither seat committed yet — that's the countdown's job.
        self.assertFalse(self.room.table_seats.filter(significator__isnull=False).exists())

    def test_reserve_same_seat_different_card_returns_409(self):
        # Per-seat guard intact: a different card for the SAME seat → NVM first.
        self.client.post(self.url + "?seat=1",
                         data={"card_id": self.card_a.id, "action": "reserve"})
        r = self.client.post(self.url + "?seat=1",
                             data={"card_id": self.card_b.id, "action": "reserve"})
        self.assertEqual(r.status_code, 409)

    def test_three_levity_seats_ready_fires_one_countdown(self):
        # Reserve + ready each levity seat (1,2,4). The 3rd ready fires the
        # levity countdown — for a SINGLE gamer owning all three seats.
        for seat_n, card in ((1, self.card_a), (2, self.card_b), (4, self.card_c)):
            self.client.post(self.url + f"?seat={seat_n}",
                             data={"card_id": card.id, "action": "reserve"})
        with patch("apps.epic.views._notify_countdown_start") as mock_notify, \
             patch("apps.epic.tasks.schedule_polarity_confirm") as mock_sched:
            for seat_n in (1, 2, 4):
                self.client.post(self.ready_url + f"?seat={seat_n}",
                                 data={"action": "ready"})
            mock_notify.assert_called_once()
            self.assertIn("levity", mock_notify.call_args[0])
            mock_sched.assert_called_once()


class SigSelectRenderingTest(TestCase):
    """Gate view at SIG_SELECT renders the Significator deck."""

    def setUp(self):
        self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
        self.url = reverse("epic:room", kwargs={"room_id": self.room.id})

    def test_sig_deck_element_present(self):
        response = self.client.get(self.url)
        self.assertContains(response, "id_sig_deck")

    def test_sig_deck_contains_16_sig_cards_by_default(self):
        """Without Note unlocks the deck shows only 16 court cards (no Nomad/Schizo)."""
        response = self.client.get(self.url)
        self.assertEqual(response.content.decode().count('data-card-id='), 16)

    def test_nomad_note_adds_nomad_to_sig_deck(self):
        Note.objects.create(user=self.gamers[0], slug="nomad", earned_at=timezone.now())
        response = self.client.get(self.url)
        self.assertEqual(response.content.decode().count('data-card-id='), 17)

    def test_schizo_note_adds_schizo_to_sig_deck(self):
        Note.objects.create(user=self.gamers[0], slug="schizo", earned_at=timezone.now())
        response = self.client.get(self.url)
        self.assertEqual(response.content.decode().count('data-card-id='), 17)

    def test_super_nomad_note_also_unlocks_nomad(self):
        Note.objects.create(user=self.gamers[0], slug="super-nomad", earned_at=timezone.now())
        response = self.client.get(self.url)
        self.assertEqual(response.content.decode().count('data-card-id='), 17)

    def test_super_schizo_note_also_unlocks_schizo(self):
        Note.objects.create(user=self.gamers[0], slug="super-schizo", earned_at=timezone.now())
        response = self.client.get(self.url)
        self.assertEqual(response.content.decode().count('data-card-id='), 17)

    def test_both_notes_gives_18_sig_cards(self):
        Note.objects.create(user=self.gamers[0], slug="nomad", earned_at=timezone.now())
        Note.objects.create(user=self.gamers[0], slug="schizo", earned_at=timezone.now())
        response = self.client.get(self.url)
        self.assertEqual(response.content.decode().count('data-card-id='), 18)

    def test_seats_rendered_in_pc_nc_ec_sc_ac_bc_order(self):
        response = self.client.get(self.url)
        content = response.content.decode()
        positions = {role: content.find(f'data-role="{role}"') for role in SIG_SEAT_ORDER}
        # Every role must appear
        self.assertTrue(all(pos != -1 for pos in positions.values()))
        # Rendered in canonical sequence
        ordered = sorted(SIG_SEAT_ORDER, key=lambda r: positions[r])
        self.assertEqual(ordered, SIG_SEAT_ORDER)

    def test_sig_deck_not_present_during_role_select(self):
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        response = self.client.get(self.url)
        self.assertNotContains(response, "id_sig_deck")

    def test_sig_cards_render_keyword_data_attributes(self):
        response = self.client.get(self.url)
        content = response.content.decode()
        self.assertIn("data-keywords-upright=", content)
        self.assertIn("data-keywords-reversed=", content)

    def test_sig_stat_block_structure_rendered(self):
        response = self.client.get(self.url)
        self.assertContains(response, "sig-stat-block")
        self.assertContains(response, "spin-btn")
        self.assertContains(response, "stat-face--upright")
        self.assertContains(response, "stat-face--reversed")

    def test_sig_cards_render_energies_operations_data_attributes(self):
        response = self.client.get(self.url)
        self.assertContains(response, "data-energies=")
        self.assertContains(response, "data-operations=")

    def test_sig_info_panel_structure_rendered(self):
        response = self.client.get(self.url)
        self.assertContains(response, "sig-info")
        self.assertContains(response, "fyi-btn")
        self.assertContains(response, "sig-info-effect")
        self.assertContains(response, "sig-info-index")
        self.assertContains(response, "fyi-prev")
        self.assertContains(response, "fyi-next")

    def test_overlay_carries_live_countdown_remaining_on_load(self):
        # Restore-on-load: a gamer landing on his seat view mid-countdown must
        # get the live flashing numeral, not a static WAIT NVM. countdown_start
        # is a one-shot WS event, so the seconds-left are seeded server-side
        # from the timer deadline stored in the cache.
        from django.core.cache import cache
        from apps.epic.tasks import _cache_key
        import time
        founder = self.gamers[0]  # PC → levity
        seat = self.room.table_seats.get(gamer=founder, role="PC")
        card = TarotCard.objects.get(
            deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11)
        SigReservation.objects.create(
            room=self.room, gamer=founder, card=card,
            polarity=SigReservation.LEVITY, seat=seat, ready=True)
        cache.set(
            _cache_key(str(self.room.id), SigReservation.LEVITY),
            {"token": "t", "deadline": time.time() + 10}, 60)
        response = self.client.get(self.url)
        self.assertTrue(8 <= response.context["countdown_remaining"] <= 10)
        self.assertContains(
            response,
            f'data-countdown-remaining="{response.context["countdown_remaining"]}"')

    def test_overlay_countdown_remaining_zero_when_no_countdown(self):
        # No live countdown → context None → attribute renders "0" (JS falls
        # back to WAIT NVM on load).
        response = self.client.get(self.url)
        self.assertIsNone(response.context["countdown_remaining"])
        self.assertContains(response, 'data-countdown-remaining="0"')


class SigSelectUnifiedStageTest(TestCase):
    """Sig Select stage unified with the my_sign card-stage apparatus:
    the shared DRY _stat_face.html stat-block (rank-chip + title + arcana,
    not just keywords), per-card face-image plumbing, and the green
    --duoUser felt that replaces the hex-pane content (my_sea-style) instead
    of the old fixed dark-Gaussian modal. Founder is PC (levity), mid-pick."""

    def setUp(self):
        self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
        self.url = reverse("epic:room", kwargs={"room_id": self.room.id})

    # ── Workstream A — DRY stat-block ───────────────────────────────────────
    def test_stat_block_uses_dry_stat_face_partial(self):
        # The overlay must render the SHARED _stat_face.html (rank-chip +
        # title + arcana), not the old reduced label-only stat-face.
        content = self.client.get(self.url).content.decode()
        self.assertIn("stat-face-title", content)
        self.assertIn("stat-chip-rank", content)
        self.assertIn("stat-face-arcana", content)
        # Keyword <ul> IDs preserved so sig-select.js's selectors still resolve.
        self.assertIn('id="id_stat_keywords_upright"', content)
        self.assertIn('id="id_stat_keywords_reversed"', content)

    # ── Workstream B — per-card face-image plumbing ─────────────────────────
    def test_sig_cards_carry_image_url_and_arcana_key(self):
        content = self.client.get(self.url).content.decode()
        self.assertIn("data-image-url=", content)
        self.assertIn("data-arcana-key=", content)

    def test_stage_card_has_image_slot(self):
        content = self.client.get(self.url).content.decode()
        self.assertIn("sig-stage-card-img", content)

    def test_image_deck_renders_nonempty_face_url(self):
        # Image-equipped seat deck → each sig card carries a real static path.
        self.earthman.has_card_images = True
        self.earthman.save(update_fields=["has_card_images"])
        content = self.client.get(self.url).content.decode()
        self.assertIn('data-image-url="/static', content)

    def test_textonly_deck_renders_empty_face_url(self):
        # Glyph-only deck (Earthman as seeded) → empty data-image-url, glyph
        # fallback. The attribute still renders (plumbing present).
        self.earthman.has_card_images = False
        self.earthman.save(update_fields=["has_card_images"])
        content = self.client.get(self.url).content.decode()
        self.assertIn('data-image-url=""', content)

    # ── Per-card FLIP-to-back (dubbodeck) ───────────────────────────────────
    def test_backable_deck_renders_per_card_back_url_and_flip(self):
        # Non-polarized image deck → each sig card carries its OWN deck back +
        # the stage renders the FLIP affordance (sig_pile_has_backs True), so a
        # mixed pile can FLIP each card to its own deck's back via stage-card.js.
        self.earthman.has_card_images = True
        self.earthman.is_polarized = False
        self.earthman.save(update_fields=["has_card_images", "is_polarized"])
        resp = self.client.get(self.url)
        content = resp.content.decode()
        self.assertIn('data-back-image-url="/static', content)
        self.assertTrue(resp.context["sig_pile_has_backs"])
        self.assertIn("sig-stage-card-back-img", content)
        self.assertIn("sig-flip-btn", content)

    def test_polarized_deck_omits_per_card_back_and_flip(self):
        # Polarized decks don't get a FLIP-to-back (polarity IS their flip) →
        # empty per-card back + no stage back-img/FLIP element.
        self.earthman.has_card_images = True
        self.earthman.is_polarized = True
        self.earthman.save(update_fields=["has_card_images", "is_polarized"])
        resp = self.client.get(self.url)
        content = resp.content.decode()
        self.assertIn('data-back-image-url=""', content)
        self.assertFalse(resp.context["sig_pile_has_backs"])
        self.assertNotIn("sig-stage-card-back-img", content)

    def test_textonly_deck_omits_per_card_back(self):
        # Glyph-only deck → empty per-card back + no FLIP element.
        self.earthman.has_card_images = False
        self.earthman.save(update_fields=["has_card_images"])
        resp = self.client.get(self.url)
        self.assertIn('data-back-image-url=""', resp.content.decode())
        self.assertFalse(resp.context["sig_pile_has_backs"])

    # ── Workstream C — felt replaces hex-pane content (my_sea-style) ────────
    def test_sig_stage_on_felt_in_hex_pane_no_dark_backdrop(self):
        content = self.client.get(self.url).content.decode()
        # Felt modifier on the hex pane; the old dark Gaussian backdrop is gone.
        self.assertIn("has-sig-stage", content)
        self.assertNotIn("sig-backdrop", content)
        # The overlay lives INSIDE the hex pane (before the scroll/views pane).
        hex_pos = content.find("room-hex-pane")
        overlay_pos = content.find("sig-overlay")
        scroll_pos = content.find("room-scroll-pane")
        self.assertNotEqual(overlay_pos, -1)
        self.assertLess(hex_pos, overlay_pos)
        self.assertLess(overlay_pos, scroll_pos)


class SelectSigCardViewTest(TestCase):
    """select_sig view — records choice, enforces turn order, rejects bad input."""

    def setUp(self):
        self.room, self.gamers, self.earthman, self.card = _full_sig_setUp(self)
        # Founder is slot 1, role=PC — active first in canonical order
        self.url = reverse("epic:select_sig", kwargs={"room_id": self.room.id})

    def _post(self, card_id=None, client=None):
        c = client or self.client
        return c.post(self.url, data={"card_id": card_id or self.card.id})

    def test_select_sig_records_choice_on_active_seat(self):
        self._post()
        seat = TableSeat.objects.get(room=self.room, role="PC")
        self.assertEqual(seat.significator, self.card)

    def test_select_sig_returns_200(self):
        response = self._post()
        self.assertEqual(response.status_code, 200)

    def test_select_sig_wrong_turn_makes_no_change(self):
        # Gamer 2 is NC — not their turn yet
        self.client.force_login(self.gamers[1])
        self._post()
        seat = TableSeat.objects.get(room=self.room, role="NC")
        self.assertIsNone(seat.significator)

    def test_select_sig_wrong_turn_returns_403(self):
        self.client.force_login(self.gamers[1])
        response = self._post()
        self.assertEqual(response.status_code, 403)

    def test_select_sig_card_not_in_deck_returns_400(self):
        # Create a pip card (number=5) — not in the sig deck (only court 11–14 + major 0–1)
        other = TarotCard.objects.create(
            deck_variant=self.earthman, arcana="MINOR", suit="BRANDS", number=5,
            name="Five of Brands Test", slug="five-of-brands-test",
            keywords_upright=[], keywords_reversed=[],
        )
        response = self._post(card_id=other.id)
        self.assertEqual(response.status_code, 400)

    def test_select_sig_card_already_taken_returns_409(self):
        # Another seat already holds this card as their significator
        nc_seat = TableSeat.objects.get(room=self.room, role="NC")
        nc_seat.significator = self.card
        nc_seat.save()
        response = self._post()
        self.assertEqual(response.status_code, 409)

    def test_select_sig_advances_active_seat_to_nc(self):
        self._post()
        from apps.epic.models import active_sig_seat
        seat = active_sig_seat(self.room)
        self.assertEqual(seat.role, "NC")

    def test_select_sig_notifies_ws(self):
        with patch("apps.epic.views._notify_sig_selected") as mock_notify:
            self._post()
            mock_notify.assert_called_once()

    def test_select_sig_requires_login(self):
        self.client.logout()
        response = self._post()
        self.assertEqual(response.status_code, 302)
        self.assertIn("/accounts/login/", response.url)

    def test_select_sig_wrong_phase_redirects(self):
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        response = self._post()
        self.assertRedirects(
            response, reverse("epic:room", args=[self.room.id])
        )

    def test_select_sig_last_choice_does_not_advance_to_none(self):
        """After all 6 significators chosen, active_sig_seat() is None —
        no unhandled AttributeError in the view."""
        cards = list(TarotCard.objects.filter(deck_variant=self.earthman, arcana="MINOR"))
        seats_in_order = list(
            TableSeat.objects.filter(room=self.room).order_by("slot_number")
        )
        # Assign all but the last (BC) manually
        for seat, card in zip(seats_in_order[:-1], cards):
            seat.significator = card
            seat.save()
        # BC gamer POSTs the final choice
        bc_seat = TableSeat.objects.get(room=self.room, role="BC")
        self.client.force_login(bc_seat.gamer)
        last_card = TarotCard.objects.filter(
            deck_variant=self.earthman, arcana="MAJOR", number=0
        ).first()
        response = self.client.post(self.url, data={"card_id": last_card.id})
        self.assertIn(response.status_code, (200, 302))


class ConfirmTokenRecordsSlotFilledTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="gamer@test.io")
        self.client.force_login(self.user)
        self.room = Room.objects.create(name="Test Room", owner=self.user)
        self.token = Token.objects.create(user=self.user, token_type=Token.TITHE)
        self.slot = self.room.gate_slots.get(slot_number=1)
        self.slot.gamer = self.user
        self.slot.status = GateSlot.RESERVED
        self.slot.reserved_at = timezone.now()
        self.slot.save()

    def test_confirm_token_records_slot_filled_event(self):
        session = self.client.session
        session["kit_token_id"] = str(self.token.id)
        session.save()
        self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
        event = GameEvent.objects.get(room=self.room, verb=GameEvent.SLOT_FILLED)
        self.assertEqual(event.actor, self.user)
        self.assertEqual(event.data["slot_number"], 1)
        self.assertEqual(event.data["token_type"], Token.TITHE)

    def test_no_event_recorded_if_no_reserved_slot(self):
        self.slot.gamer = None
        self.slot.status = GateSlot.EMPTY
        self.slot.save()
        self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
        self.assertEqual(GameEvent.objects.filter(verb=GameEvent.SLOT_FILLED).count(), 0)


class SelectRoleRecordsRoleSelectedTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="player@test.io")
        self.client.force_login(self.user)
        self.room = Room.objects.create(
            name="Role Room", owner=self.user, table_status=Room.ROLE_SELECT
        )
        self.seat = TableSeat.objects.create(
            room=self.room, gamer=self.user, slot_number=1
        )

    def test_select_role_records_role_selected_event(self):
        self.client.post(
            reverse("epic:select_role", args=[self.room.id]),
            data={"role": "PC"},
        )
        event = GameEvent.objects.get(room=self.room, verb=GameEvent.ROLE_SELECTED)
        self.assertEqual(event.actor, self.user)
        self.assertEqual(event.data["role"], "PC")
        self.assertEqual(event.data["slot_number"], 1)

    def test_no_event_if_role_already_taken(self):
        TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=2, role="PC")
        self.client.post(
            reverse("epic:select_role", args=[self.room.id]),
            data={"role": "PC"},
        )
        self.assertEqual(GameEvent.objects.filter(verb=GameEvent.ROLE_SELECTED).count(), 0)


# ── sig_reserve view ──────────────────────────────────────────────────────────

class SigReserveViewTest(TestCase):
    """sig_reserve — provisional card hold; OK/NVM flow."""

    def setUp(self):
        self.room, self.gamers, self.earthman, self.card = _full_sig_setUp(self)
        # founder (gamers[0]) is PC — levity polarity
        self.client.force_login(self.gamers[0])
        self.url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id})

    def _reserve(self, card_id=None, action="reserve", client=None):
        c = client or self.client
        return c.post(self.url, data={
            "card_id": card_id or self.card.id,
            "action": action,
        })

    # ── happy-path reserve ────────────────────────────────────────────────

    def test_reserve_creates_sig_reservation(self):
        self._reserve()
        self.assertTrue(SigReservation.objects.filter(
            room=self.room, gamer=self.gamers[0], card=self.card
        ).exists())

    def test_reserve_returns_200(self):
        response = self._reserve()
        self.assertEqual(response.status_code, 200)

    def test_reservation_has_correct_polarity(self):
        self._reserve()
        res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
        self.assertEqual(res.polarity, "levity")

    def test_gravity_gamer_reservation_has_gravity_polarity(self):
        # gamers[3] is SC (index 3 → role SC → but _full_sig_setUp uses SIG_SEAT_ORDER
        # which assigns PC→NC→EC→SC→AC→BC, so slot 4 = SC, slot 5 = AC, slot 6 = BC)
        # gamers[5] is BC → gravity
        bc_client = self.client.__class__()
        bc_client.force_login(self.gamers[5])
        self._reserve(client=bc_client)
        res = SigReservation.objects.get(room=self.room, gamer=self.gamers[5])
        self.assertEqual(res.polarity, "gravity")

    # ── conflict handling ─────────────────────────────────────────────────

    def test_reserve_taken_card_same_polarity_returns_409(self):
        # NC (gamers[1]) reserves the same card first — both are levity
        nc_client = self.client.__class__()
        nc_client.force_login(self.gamers[1])
        self._reserve(client=nc_client)
        # Now PC tries to grab the same card — should be blocked
        response = self._reserve()
        self.assertEqual(response.status_code, 409)

    def test_reserve_taken_card_cross_polarity_succeeds(self):
        # BC (gamers[5], gravity) reserves the same card — different polarity, allowed
        bc_client = self.client.__class__()
        bc_client.force_login(self.gamers[5])
        self._reserve(client=bc_client)
        response = self._reserve()  # PC (levity) grabs same card
        self.assertEqual(response.status_code, 200)

    def test_reserve_different_card_while_holding_returns_409(self):
        """Cannot OK a different card while holding one — must NVM first."""
        card_b = TarotCard.objects.filter(
            deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=12
        ).first()
        self._reserve()                          # PC grabs card A → 200
        response = self._reserve(card_id=card_b.id)  # tries card B → 409
        self.assertEqual(response.status_code, 409)
        # Original reservation still intact
        reservations = SigReservation.objects.filter(room=self.room, gamer=self.gamers[0])
        self.assertEqual(reservations.count(), 1)
        self.assertEqual(reservations.first().card, self.card)

    def test_reserve_same_card_again_is_idempotent(self):
        """Re-POSTing the same card while already holding it returns 200 (no-op)."""
        self._reserve()
        response = self._reserve()  # same card again
        self.assertEqual(response.status_code, 200)
        self.assertEqual(
            SigReservation.objects.filter(room=self.room, gamer=self.gamers[0]).count(), 1
        )

    def test_reserve_blocked_then_unblocked_after_release(self):
        """After NVM, a new card can be OK'd."""
        card_b = TarotCard.objects.filter(
            deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=12
        ).first()
        self._reserve()                            # hold card A
        self._reserve(action="release")            # NVM
        response = self._reserve(card_id=card_b.id)  # now card B → 200
        self.assertEqual(response.status_code, 200)
        self.assertTrue(SigReservation.objects.filter(
            room=self.room, gamer=self.gamers[0], card=card_b
        ).exists())

    # ── release ───────────────────────────────────────────────────────────

    def test_release_deletes_reservation(self):
        self._reserve()
        self._reserve(action="release")
        self.assertFalse(SigReservation.objects.filter(
            room=self.room, gamer=self.gamers[0]
        ).exists())

    def test_release_returns_200(self):
        self._reserve()
        response = self._reserve(action="release")
        self.assertEqual(response.status_code, 200)

    def test_release_with_no_reservation_still_200(self):
        """NVM when nothing held is harmless."""
        response = self._reserve(action="release")
        self.assertEqual(response.status_code, 200)

    def test_release_while_ready_records_sig_unready(self):
        """Releasing a ready reservation implicitly acts as WAIT NVM and records SIG_UNREADY."""
        self._reserve()
        res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
        res.ready = True
        res.save()
        self._reserve(action="release")
        self.assertTrue(self.room.events.filter(
            actor=self.gamers[0], verb=GameEvent.SIG_UNREADY
        ).exists())

    # ── guards ────────────────────────────────────────────────────────────

    def test_reserve_non_post_returns_405(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 405)

    def test_reserve_requires_login(self):
        self.client.logout()
        response = self._reserve()
        self.assertEqual(response.status_code, 302)
        self.assertIn("/accounts/login/", response.url)

    def test_reserve_requires_seated_gamer(self):
        outsider = User.objects.create(email="outsider@test.io")
        outsider_client = self.client.__class__()
        outsider_client.force_login(outsider)
        response = self._reserve(client=outsider_client)
        self.assertEqual(response.status_code, 403)

    def test_reserve_wrong_phase_returns_400(self):
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        response = self._reserve()
        self.assertEqual(response.status_code, 400)

    def test_reserve_broadcasts_ws(self):
        with patch("apps.epic.views._notify_sig_reserved") as mock_notify:
            self._reserve()
            mock_notify.assert_called_once()

    def test_release_broadcasts_ws(self):
        self._reserve()
        with patch("apps.epic.views._notify_sig_reserved") as mock_notify:
            self._reserve(action="release")
            mock_notify.assert_called_once()

    def test_release_broadcasts_card_id_so_second_browser_can_clear_it(self):
        """WS release event must include the card_id; otherwise the receiving
        browser can't find the card element to remove .sig-reserved--own."""
        self._reserve()
        with patch("apps.epic.views._notify_sig_reserved") as mock_notify:
            self._reserve(action="release")
            args, kwargs = mock_notify.call_args
            self.assertEqual(args[1], self.card.pk)   # card_id must not be None
            self.assertFalse(kwargs['reserved'])       # reserved=False


# ── sig_ready view ────────────────────────────────────────────────────────────

def _make_levity_reservations(room, gamers, earthman, ready=False):
    """Create SigReservations for the three levity gamers (PC, NC, SC).
    Returns the three reservations in PC→NC→SC order."""
    cards = [
        TarotCard.objects.get(deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=n)
        for n in (11, 12, 13)
    ]
    roles = ["PC", "NC", "SC"]
    # gamers[0]=PC, gamers[1]=NC, gamers[3]=SC
    gamer_indices = [0, 1, 3]
    reservations = []
    for gamer_idx, role, card in zip(gamer_indices, roles, cards):
        seat = TableSeat.objects.get(room=room, role=role)
        res = SigReservation.objects.create(
            room=room, gamer=gamers[gamer_idx], card=card,
            role=role, polarity="levity", seat=seat, ready=ready,
        )
        reservations.append(res)
    return reservations


class SigReadyViewTest(TestCase):
    """sig_ready — toggle ready/unready for the polarity-room countdown."""

    def setUp(self):
        self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
        self.reservations = _make_levity_reservations(self.room, self.gamers, self.earthman)
        self.url = reverse("epic:sig_ready", kwargs={"room_id": self.room.id})

    def _post(self, action="ready", seconds_remaining=None, client=None):
        c = client or self.client
        data = {"action": action}
        if seconds_remaining is not None:
            data["seconds_remaining"] = seconds_remaining
        return c.post(self.url, data=data)

    # ── guards ────────────────────────────────────────────────────────────

    def test_sig_ready_non_post_returns_405(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 405)

    def test_sig_ready_requires_login(self):
        self.client.logout()
        response = self._post()
        self.assertEqual(response.status_code, 302)
        self.assertIn("/accounts/login/", response.url)

    def test_sig_ready_requires_seated_gamer(self):
        outsider = User.objects.create(email="outsider@test.io")
        outsider_client = self.client.__class__()
        outsider_client.force_login(outsider)
        response = self._post(client=outsider_client)
        self.assertEqual(response.status_code, 403)

    def test_sig_ready_wrong_phase_returns_400(self):
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        response = self._post()
        self.assertEqual(response.status_code, 400)

    def test_sig_ready_without_reservation_returns_400(self):
        """Can't go ready without an OK'd card."""
        SigReservation.objects.filter(room=self.room, gamer=self.gamers[0]).delete()
        response = self._post()
        self.assertEqual(response.status_code, 400)

    # ── happy-path ready ──────────────────────────────────────────────────

    def test_sig_ready_sets_ready_true_on_reservation(self):
        self._post(action="ready")
        res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
        self.assertTrue(res.ready)

    def test_sig_ready_returns_200(self):
        response = self._post(action="ready")
        self.assertEqual(response.status_code, 200)

    def test_sig_ready_already_ready_is_idempotent(self):
        """Re-posting ready when already ready returns 200 without re-triggering countdown."""
        self.reservations[0].ready = True
        self.reservations[0].save()
        response = self._post(action="ready")
        self.assertEqual(response.status_code, 200)

    # ── unready ──────────────────────────────────────────────────────────

    def test_sig_unready_sets_ready_false(self):
        self.reservations[0].ready = True
        self.reservations[0].save()
        self._post(action="unready")
        res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
        self.assertFalse(res.ready)

    def test_sig_unready_when_not_ready_is_harmless(self):
        response = self._post(action="unready")
        self.assertEqual(response.status_code, 200)

    # ── countdown mechanics ───────────────────────────────────────────────

    def test_sig_ready_broadcasts_countdown_start_when_all_three_polarity_ready(self):
        """When all three levity gamers are ready, countdown_start broadcasts."""
        # Make NC and SC ready first
        for res in self.reservations[1:]:
            res.ready = True
            res.save()
        # PC (founder) goes ready — triggers all-three condition
        with patch("apps.epic.views._notify_countdown_start") as mock_notify:
            self._post(action="ready")
            mock_notify.assert_called_once()
            args = mock_notify.call_args[0]
            self.assertIn("levity", args)  # polarity in call

    def test_sig_ready_does_not_broadcast_countdown_when_only_two_ready(self):
        self.reservations[1].ready = True
        self.reservations[1].save()
        with patch("apps.epic.views._notify_countdown_start") as mock_notify:
            self._post(action="ready")
            mock_notify.assert_not_called()

    def test_sig_unready_invalid_seconds_defaults_to_12(self):
        """Non-numeric seconds_remaining falls back to 12."""
        self.reservations[0].ready = True
        self.reservations[0].save()
        self._post(action="unready", seconds_remaining="abc")
        self.reservations[0].refresh_from_db()
        self.assertEqual(self.reservations[0].countdown_remaining, 12)

    def test_sig_unready_saves_seconds_remaining_on_all_polarity_reservations(self):
        for res in self.reservations:
            res.ready = True
            res.save()
        self._post(action="unready", seconds_remaining=7)
        for res in self.reservations:
            res.refresh_from_db()
            self.assertEqual(res.countdown_remaining, 7)

    def test_sig_unready_broadcasts_countdown_cancel(self):
        for res in self.reservations:
            res.ready = True
            res.save()
        with patch("apps.epic.views._notify_countdown_cancel") as mock_notify:
            self._post(action="unready", seconds_remaining=7)
            mock_notify.assert_called_once()

    def test_sig_ready_uses_saved_seconds_for_countdown_restart(self):
        """If countdown_remaining is saved (e.g. 7), countdown_start sends 7 not 12."""
        for res in self.reservations:
            res.ready = True
            res.countdown_remaining = 7
            res.save()
        # One unreadied; now goes ready again — all 3 ready → start from 7
        self.reservations[0].ready = False
        self.reservations[0].save()
        with patch("apps.epic.views._notify_countdown_start") as mock_notify:
            self._post(action="ready")
            mock_notify.assert_called_once()
            args, kwargs = mock_notify.call_args
            seconds_sent = kwargs.get("seconds") or args[1]
            self.assertEqual(seconds_sent, 7)


# ── sig_confirm view ──────────────────────────────────────────────────────────

def _make_gravity_reservations(room, gamers, earthman, ready=False):
    """Create SigReservations for the three gravity gamers (EC, AC, BC)."""
    cards = [
        TarotCard.objects.get(deck_variant=earthman, arcana="MIDDLE", suit="GRAILS", number=n)
        for n in (11, 12, 13)
    ]
    roles = ["EC", "AC", "BC"]
    # gamers[2]=EC, gamers[4]=AC, gamers[5]=BC
    gamer_indices = [2, 4, 5]
    reservations = []
    for gamer_idx, role, card in zip(gamer_indices, roles, cards):
        seat = TableSeat.objects.get(room=room, role=role)
        res = SigReservation.objects.create(
            room=room, gamer=gamers[gamer_idx], card=card,
            role=role, polarity="gravity", seat=seat, ready=ready,
        )
        reservations.append(res)
    return reservations


class SigConfirmViewTest(TestCase):
    """sig_confirm — finalize polarity group once countdown reaches zero."""

    def setUp(self):
        self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
        # All three levity gamers are ready
        self.lev_res = _make_levity_reservations(
            self.room, self.gamers, self.earthman, ready=True
        )
        # founder (PC) is already logged in from _full_sig_setUp
        self.url = reverse("epic:sig_confirm", kwargs={"room_id": self.room.id})

    def _post(self, polarity="levity", client=None):
        c = client or self.client
        return c.post(self.url, data={"polarity": polarity})

    # ── guards ────────────────────────────────────────────────────────────

    def test_sig_confirm_non_post_returns_405(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 405)

    def test_sig_confirm_requires_login(self):
        self.client.logout()
        response = self._post()
        self.assertEqual(response.status_code, 302)
        self.assertIn("/accounts/login/", response.url)

    def test_sig_confirm_requires_seated_gamer(self):
        outsider = User.objects.create(email="outsider@test.io")
        outsider_client = self.client.__class__()
        outsider_client.force_login(outsider)
        response = self._post(client=outsider_client)
        self.assertEqual(response.status_code, 403)

    def test_sig_confirm_wrong_phase_returns_400(self):
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        response = self._post()
        self.assertEqual(response.status_code, 400)

    def test_sig_confirm_not_all_polarity_ready_returns_400(self):
        """If any of the three in the polarity group isn't ready, reject."""
        self.lev_res[1].ready = False
        self.lev_res[1].save()
        response = self._post()
        self.assertEqual(response.status_code, 400)

    # ── happy-path ────────────────────────────────────────────────────────

    def test_sig_confirm_sets_significator_on_seats_from_reservations(self):
        self._post()
        for res in self.lev_res:
            seat = TableSeat.objects.get(room=self.room, role=res.role)
            self.assertEqual(seat.significator, res.card)

    def test_sig_confirm_returns_200(self):
        response = self._post()
        self.assertEqual(response.status_code, 200)

    def test_sig_confirm_broadcasts_polarity_room_done(self):
        with patch("apps.epic.views._notify_polarity_room_done") as mock_notify:
            self._post()
            mock_notify.assert_called_once()
            args = mock_notify.call_args[0]
            self.assertIn("levity", args)

    def test_sig_confirm_is_idempotent_if_significators_already_set(self):
        """Second call from another browser returns 200 without re-running logic."""
        self._post()
        response = self._post()
        self.assertEqual(response.status_code, 200)

    # ── both polarities done ──────────────────────────────────────────────

    def test_sig_confirm_broadcasts_pick_sky_available_when_both_polarities_done(self):
        """After both levity and gravity confirm, pick_sky_available fires."""
        # Pre-set gravity seats to already have significators (simulating earlier confirm)
        grav_cards = [
            TarotCard.objects.get(deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=n)
            for n in (11, 12, 13)
        ]
        for role, card in zip(["EC", "AC", "BC"], grav_cards):
            seat = TableSeat.objects.get(room=self.room, role=role)
            seat.significator = card
            seat.save()
        with patch("apps.epic.views._notify_pick_sky_available") as mock_notify:
            self._post(polarity="levity")
            mock_notify.assert_called_once()

    def test_sig_confirm_sets_room_to_sky_select_when_both_polarities_done(self):
        grav_cards = [
            TarotCard.objects.get(deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=n)
            for n in (11, 12, 13)
        ]
        for role, card in zip(["EC", "AC", "BC"], grav_cards):
            seat = TableSeat.objects.get(room=self.room, role=role)
            seat.significator = card
            seat.save()
        self._post(polarity="levity")
        self.room.refresh_from_db()
        self.assertEqual(self.room.table_status, Room.SKY_SELECT)

    def test_sig_confirm_does_not_broadcast_pick_sky_available_when_only_one_polarity_done(self):
        with patch("apps.epic.views._notify_pick_sky_available") as mock_notify:
            self._post(polarity="levity")
            mock_notify.assert_not_called()


# ── SKY_SELECT rendering ──────────────────────────────────────────────────────

class PickSkyRenderingTest(TestCase):
    """Room page at SKY_SELECT renders CAST SKY btn and sig card in tray cell 2."""

    def setUp(self):
        self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
        self.room.table_status = Room.SKY_SELECT
        self.room.save()
        self.sig_card = TarotCard.objects.get(
            deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
        )
        pc_seat = TableSeat.objects.get(room=self.room, role="PC")
        pc_seat.significator = self.sig_card
        pc_seat.save()
        self.url = reverse("epic:room", kwargs={"room_id": self.room.id})

    def test_pick_sky_btn_present_in_sky_select_phase(self):
        response = self.client.get(self.url)
        self.assertContains(response, "id_pick_sky_btn")

    def test_tray_cell_2_contains_sig_card_icon_in_sky_select(self):
        response = self.client.get(self.url)
        self.assertContains(response, "tray-sig-card")

    def test_pick_sky_btn_hidden_during_sig_select(self):
        # Rendered hidden (display:none) so JS can reveal it on pick_sky_available WS event
        self.room.table_status = Room.SIG_SELECT
        self.room.save()
        response = self.client.get(self.url)
        self.assertContains(response, 'id="id_pick_sky_btn"')
        self.assertContains(response, 'style="display:none"')

    def test_sky_delete_clears_seat_character_and_returns_json(self):
        """POST epic:sky_delete clears any Character on the requesting gamer's
        seat — both unconfirmed drafts AND confirmed ones (the latter case is
        why un-saved-via-DEL data was rehydrating on refresh: a SAVE SKY click
        confirms a Character, and only that seat's Character row is the durable
        target the in-room DEL has to purge)."""
        # Seed both a draft & a confirmed Character — DEL must clear them both
        from apps.epic.models import Character
        pc_seat = TableSeat.objects.get(room=self.room, role="PC")
        # Confirmed (the SAVE SKY case)
        confirmed = Character.objects.create(
            seat=pc_seat,
            chart_data={"planets": {"Sun": {"sign": "Gemini"}}},
            confirmed_at=timezone.now(),
        )
        url = reverse("epic:sky_delete", kwargs={"room_id": self.room.id})
        response = self.client.post(url)
        self.assertEqual(response.status_code, 200)
        self.assertJSONEqual(response.content, {"deleted": True})
        self.assertFalse(
            Character.objects.filter(seat=pc_seat, retired_at__isnull=True).exists(),
            "Both draft and confirmed Characters on the seat should be gone",
        )

    def test_sky_delete_405_on_get(self):
        url = reverse("epic:sky_delete", kwargs={"room_id": self.room.id})
        self.assertEqual(self.client.get(url).status_code, 405)

    def test_sky_delete_requires_seat_owner(self):
        """A gamer who isn't seated at this room can't purge another seat."""
        outsider = User.objects.create(email="outsider@test.io")
        self.client.force_login(outsider)
        url = reverse("epic:sky_delete", kwargs={"room_id": self.room.id})
        self.assertEqual(self.client.post(url).status_code, 403)

    def test_sky_delete_does_not_touch_user_model(self):
        """In-room DEL targets the seat's Character, never the User-level
        sky_chart_data. (The Dashsky / My Sky applet DEL is the one that
        clears the user's saved sky.)"""
        founder = self.gamers[0]
        founder.sky_chart_data = {"planets": {"Sun": {"sign": "Gemini"}}}
        founder.sky_birth_tz = "America/New_York"
        founder.save()
        url = reverse("epic:sky_delete", kwargs={"room_id": self.room.id})
        self.client.post(url)
        founder.refresh_from_db()
        self.assertEqual(founder.sky_chart_data, {"planets": {"Sun": {"sign": "Gemini"}}})
        self.assertEqual(founder.sky_birth_tz, "America/New_York")

    def test_no_sky_delete_btn_in_blank_sky_select_modal(self):
        """A fresh CAST SKY modal (no preview wheel rendered yet) must not
        carry the DEL btn — it would otherwise float in the empty wheel area
        suggesting there's something to delete when the user has only seen
        the form. The JS schedulePreview success handler is the contract that
        injects the btn after the wheel paints — so the rendered HTML should
        carry no <button id="id_sky_delete_btn"> markup. (The literal string
        does still appear inside the inline <script> that does the injection,
        so the assertion targets the rendered attribute syntax, not the bare
        identifier.)"""
        response = self.client.get(self.url)
        self.assertNotContains(response, 'id="id_sky_delete_btn"')


class PickSkyUnifiedFeltTest(TestCase):
    """CAST SKY unified with the my_sky apparatus: the dark Gaussian modal
    (.sky-backdrop + .sky-modal-wrap) is replaced by a --duoUser felt .sky-page
    rendered INSIDE .room-hex-pane (my_sea-style, mirroring the Sig Select
    SigSelectUnifiedStageTest precedent). Founder is PC (levity), sky not yet
    confirmed → the felt form is the active surface."""

    def setUp(self):
        self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
        self.room.table_status = Room.SKY_SELECT
        self.room.save()
        self.sig_card = TarotCard.objects.get(
            deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
        )
        pc_seat = TableSeat.objects.get(room=self.room, role="PC")
        pc_seat.significator = self.sig_card
        pc_seat.save()
        self.url = reverse("epic:room", kwargs={"room_id": self.room.id})

    def test_sky_felt_in_hex_pane_no_dark_backdrop(self):
        content = self.client.get(self.url).content.decode()
        # Felt modifier on the hex pane; the old dark Gaussian chrome is gone.
        self.assertIn("has-sky-stage", content)
        self.assertNotIn("sky-backdrop", content)
        self.assertNotIn("sky-modal-wrap", content)
        # The --duoUser felt wrapper (shared .sky-page class) is present.
        self.assertIn("sky-page", content)

    def test_sky_overlay_lives_inside_hex_pane(self):
        content = self.client.get(self.url).content.decode()
        # The overlay sits INSIDE the hex pane, before the scroll/views pane —
        # so it fills the hex (my_sea-style) instead of floating over the page.
        hex_pos = content.find("room-hex-pane")
        overlay_pos = content.find("id_sky_overlay")
        scroll_pos = content.find("room-scroll-pane")
        self.assertNotEqual(overlay_pos, -1)
        self.assertLess(hex_pos, overlay_pos)
        self.assertLess(overlay_pos, scroll_pos)

    def test_sky_felt_absent_once_confirmed(self):
        # A confirmed sky flips the hex to DRAW SEA — the felt form must not
        # render alongside the sea overlay.
        pc_seat = TableSeat.objects.get(room=self.room, role="PC")
        Character.objects.create(seat=pc_seat, confirmed_at=timezone.now())
        content = self.client.get(self.url).content.decode()
        self.assertNotIn("has-sky-stage", content)

    def test_gear_has_sky_nvm_pane_returning_to_hex(self):
        # NVM moved off the felt into the room gear's own sky pane; its NVM
        # returns to the table-hex (epic:room), where the server re-renders
        # DRAW SEA (if saved) or CAST SKY (if not).
        content = self.client.get(self.url).content.decode()
        self.assertIn("room-menu-sky", content)
        hex_url = reverse("epic:room", kwargs={"room_id": self.room.id})
        sky_pane = content[content.find("room-menu-sky"):]
        sky_pane = sky_pane[: sky_pane.find("</div>")]
        self.assertIn(hex_url, sky_pane)
        self.assertIn("NVM", sky_pane)


# ── SEA_SELECT rendering ──────────────────────────────────────────────────────

class PickSeaRenderingTest(TestCase):
    """At SKY_SELECT, a confirmed Character swaps CAST SKY → DRAW SEA + sea overlay."""

    def setUp(self):
        self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
        self.room.table_status = Room.SKY_SELECT
        self.room.save()
        self.sig_card = TarotCard.objects.get(
            deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
        )
        self.pc_seat = TableSeat.objects.get(room=self.room, role="PC")
        self.pc_seat.significator = self.sig_card
        self.pc_seat.save()
        self.url = reverse("epic:room", kwargs={"room_id": self.room.id})

    def _confirm_sky(self, seat=None):
        target = seat or self.pc_seat
        return Character.objects.create(seat=target, confirmed_at=timezone.now())

    def test_sky_confirmed_false_when_no_character(self):
        response = self.client.get(self.url)
        self.assertFalse(response.context["sky_confirmed"])

    def test_sky_confirmed_true_when_character_confirmed(self):
        self._confirm_sky()
        response = self.client.get(self.url)
        self.assertTrue(response.context["sky_confirmed"])

    def test_pick_sea_btn_shown_when_sky_confirmed(self):
        self._confirm_sky()
        response = self.client.get(self.url)
        self.assertContains(response, "id_pick_sea_btn")

    def test_pick_sky_btn_shown_when_sky_not_confirmed(self):
        response = self.client.get(self.url)
        self.assertContains(response, "id_pick_sky_btn")

    def test_sea_overlay_included_when_sky_confirmed(self):
        self._confirm_sky()
        response = self.client.get(self.url)
        self.assertContains(response, "id_sea_overlay")

    # ── Burger Sky sub-btn reopen + glow (Phase 5) ──────────────────────────
    def test_sky_btn_active_and_glow_flag_when_confirmed(self):
        self._confirm_sky()
        response = self.client.get(self.url)
        self.assertTrue(response.context["sky_btn_active"])
        content = response.content.decode()
        # Burger carries the load-pulse flag; the Sky sub-btn goes active.
        self.assertIn("data-sky-glow", content)
        # The felt stays in the DOM (hidden) so the burger can reopen the wheel.
        self.assertIn("id_sky_overlay", content)

    def test_sky_btn_inactive_and_no_glow_when_not_confirmed(self):
        response = self.client.get(self.url)
        self.assertFalse(response.context.get("sky_btn_active"))
        self.assertNotIn("data-sky-glow", response.content.decode())

    def test_saved_sky_json_primes_reopen_when_confirmed(self):
        char = self._confirm_sky()
        char.chart_data = {"planets": {"Sun": {"sign": "Gemini"}}}
        char.save()
        response = self.client.get(self.url)
        # The confirmed chart is handed to the felt so the burger reopen draws
        # the saved wheel without a fresh PySwiss round-trip.
        self.assertIn("Gemini", response.context["saved_sky_json"])

    def test_saved_sky_json_is_null_literal_when_not_confirmed(self):
        # The felt's `const _savedSky = {{ saved_sky_json|...|safe }};` must be
        # valid JS even with no saved sky — a bare `null`, never empty.
        response = self.client.get(self.url)
        self.assertEqual(response.context["saved_sky_json"], "null")

    def test_sea_overlay_select_defaults_to_waite_smith_for_levity(self):
        self._confirm_sky()
        response = self.client.get(self.url)
        # 2-line spread label (split around the comma; "Rider-" prefix added).
        self.assertContains(response, "Celtic Cross,<br>Rider-Waite-Smith")

    def test_sea_overlay_select_defaults_to_escape_velocity_for_gravity(self):
        ec_gamer = self.gamers[2]  # EC — gravity
        self.client.force_login(ec_gamer)
        ec_seat = TableSeat.objects.get(room=self.room, role="EC")
        self._confirm_sky(seat=ec_seat)
        response = self.client.get(self.url)
        self.assertContains(response, "Celtic Cross,<br>Escape Velocity")

    def test_user_polarity_in_context_at_sky_select(self):
        response = self.client.get(self.url)
        self.assertIn("user_polarity", response.context)
        self.assertEqual(response.context["user_polarity"], "levity")  # PC is levity

    def test_my_tray_sig_falls_back_to_seat_when_char_sig_is_none(self):
        """Characters created before the sig-sync fix have significator=None; fall back to seat."""
        Character.objects.create(
            seat=self.pc_seat,
            significator=None,
            confirmed_at=timezone.now(),
        )
        response = self.client.get(self.url)
        self.assertEqual(response.context["my_tray_sig"], self.sig_card)

    def test_my_tray_sig_comes_from_character_significator_when_confirmed(self):
        """When sky_confirmed, my_tray_sig reads from Character.significator (not TableSeat)."""
        char = Character.objects.create(
            seat=self.pc_seat,
            significator=self.sig_card,
            confirmed_at=timezone.now(),
        )
        # Give the seat a *different* sig card so we can distinguish the sources
        other_card = TarotCard.objects.get(
            deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=11
        )
        self.pc_seat.significator = other_card
        self.pc_seat.save()

        response = self.client.get(self.url)
        self.assertEqual(response.context["my_tray_sig"], char.significator)
        self.assertNotEqual(response.context["my_tray_sig"], other_card)


# ── select_role GET redirect ──────────────────────────────────────────────────

class SelectRoleGetRedirectTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="gamer@sr.io")
        self.client.force_login(self.user)
        self.room = Room.objects.create(name="R", owner=self.user)
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()

    def test_get_redirects_to_room(self):
        response = self.client.get(reverse("epic:select_role", kwargs={"room_id": self.room.id}))
        self.assertRedirects(response, reverse("epic:room", kwargs={"room_id": self.room.id}),
                             fetch_redirect_response=False)


# ── sig_reserve / sig_ready / sig_confirm / select_sig helpers ────────────────

def _make_sig_room(owner, *extra_gamers):
    room = Room.objects.create(name="SR", owner=owner)
    seat_map = {}
    gamers = [owner] + list(extra_gamers)
    roles = ["PC", "NC", "EC", "SC", "AC", "BC"]
    for i, (gamer, role) in enumerate(zip(gamers, roles), start=1):
        seat = TableSeat.objects.create(room=room, gamer=gamer, slot_number=i, role=role)
        seat_map[role] = seat
    room.table_status = Room.SIG_SELECT
    room.save()
    return room, seat_map


class SelectSigViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="pc@selsig.io")
        self.client.force_login(self.user)
        self.room, self.seats = _make_sig_room(self.user)

    def test_non_post_redirects(self):
        response = self.client.get(reverse("epic:select_sig", kwargs={"room_id": self.room.id}))
        self.assertEqual(response.status_code, 302)

    def test_nonexistent_card_returns_400(self):
        response = self.client.post(
            reverse("epic:select_sig", kwargs={"room_id": self.room.id}),
            {"card_id": "99999999"},
        )
        self.assertEqual(response.status_code, 400)


# ── sky_preview (epic) ──────────────────────────────────────────────────────

class SkyPreviewViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="pc@sky.io")
        self.client.force_login(self.user)
        self.room, _ = _make_sig_room(self.user)
        self.room.table_status = Room.SKY_SELECT
        self.room.save()
        self.url = reverse("epic:sky_preview", kwargs={"room_id": self.room.id})

    def test_missing_params_returns_400(self):
        response = self.client.get(self.url, {"date": "1990-06-15"})
        self.assertEqual(response.status_code, 400)

    def test_non_numeric_lat_returns_400(self):
        response = self.client.get(self.url, {"date": "1990-06-15", "lat": "abc", "lon": "0"})
        self.assertEqual(response.status_code, 400)

    def test_out_of_range_lat_returns_400(self):
        response = self.client.get(self.url, {"date": "1990-06-15", "lat": "999", "lon": "0"})
        self.assertEqual(response.status_code, 400)

    def test_invalid_tz_string_returns_400(self):
        response = self.client.get(
            self.url,
            {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1", "tz": "Not/Real"},
        )
        self.assertEqual(response.status_code, 400)

    def test_bad_date_format_returns_400(self):
        response = self.client.get(
            self.url,
            {"date": "baddate", "time": "09:00", "lat": "51.5", "lon": "-0.1", "tz": "UTC"},
        )
        self.assertEqual(response.status_code, 400)

    @patch("apps.epic.views.http_requests")
    def test_pyswiss_failure_returns_502(self, mock_requests):
        from unittest.mock import MagicMock
        tz_r = MagicMock()
        tz_r.json.return_value = {"timezone": "UTC"}
        tz_r.raise_for_status = MagicMock()
        chart_r = MagicMock()
        chart_r.raise_for_status.side_effect = Exception("timeout")
        mock_requests.get.side_effect = [tz_r, chart_r]
        response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"})
        self.assertEqual(response.status_code, 502)

    @patch("apps.epic.views.http_requests")
    def test_success_returns_chart_distinctions_timezone(self, mock_requests):
        from unittest.mock import MagicMock
        payload = {
            "planets": {"Sun": {"degree": 84.5}},
            "houses": {"cusps": [0] * 12},
            "elements": {"Earth": 1},
            "house_system": "O",
        }
        tz_r = MagicMock()
        tz_r.json.return_value = {"timezone": "Europe/London"}
        tz_r.raise_for_status = MagicMock()
        ch_r = MagicMock()
        ch_r.json.return_value = payload
        ch_r.raise_for_status = MagicMock()
        mock_requests.get.side_effect = [tz_r, ch_r]
        response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"})
        self.assertEqual(response.status_code, 200)
        data = response.json()
        self.assertIn("distinctions", data)
        self.assertIn("Stone", data["elements"])
        self.assertNotIn("Earth", data["elements"])


# ── tarot_deal ────────────────────────────────────────────────────────────────

class TarotDealViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="dealer@test.io")
        self.client.force_login(self.user)
        self.room, _ = _make_sig_room(self.user)

    def test_non_post_redirects_to_tarot_deck(self):
        response = self.client.get(
            reverse("epic:tarot_deal", kwargs={"room_id": self.room.id})
        )
        self.assertRedirects(
            response,
            reverse("epic:tarot_deck", kwargs={"room_id": self.room.id}),
            fetch_redirect_response=False,
        )


# ── sky_save (epic) ─────────────────────────────────────────────────────────

class SkySaveViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="pc@skysave.io")
        self.client.force_login(self.user)
        self.room, _ = _make_sig_room(self.user)
        self.room.table_status = Room.SKY_SELECT
        self.room.save()
        self.url = reverse("epic:sky_save", kwargs={"room_id": self.room.id})

    def _post(self, payload):
        import json as _json
        return self.client.post(self.url, data=_json.dumps(payload), content_type="application/json")

    def test_get_returns_405(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 405)

    def test_invalid_json_returns_400(self):
        response = self.client.post(self.url, data="not json", content_type="application/json")
        self.assertEqual(response.status_code, 400)

    def test_save_draft_returns_id_and_not_confirmed(self):
        response = self._post({
            "birth_dt": "1990-06-15T09:00:00Z",
            "birth_lat": 51.5,
            "birth_lon": -0.1,
            "birth_place": "London",
            "house_system": "O",
            "chart_data": {},
        })
        self.assertEqual(response.status_code, 200)
        data = response.json()
        self.assertIn("id", data)
        self.assertFalse(data["confirmed"])

    def test_confirm_action_locks_character(self):
        response = self._post({
            "birth_dt": "1990-06-15T09:00:00Z",
            "birth_lat": 51.5,
            "birth_lon": -0.1,
            "birth_place": "",
            "house_system": "O",
            "chart_data": {},
            "action": "confirm",
        })
        self.assertEqual(response.status_code, 200)
        self.assertTrue(response.json()["confirmed"])

    def test_confirm_records_sky_saved_event_with_top_capacitors(self):
        """When action=confirm, log a SKY_SAVED GameEvent w. the highest-count
        capacitor name(s) so the billscroll can render the new prose."""
        from apps.drama.models import GameEvent
        chart = {
            "elements": {
                # Earthman uses 6 elements; canonical names map to capacitors:
                # Fire→Ardor Stone→Ossum Air→Pneuma Water→Humor Time→Tempo Space→Nexus.
                "Fire":  3,
                "Stone": 1,
                "Air":   2,
                "Water": 0,
                "Time":  1,
                "Space": 1,
            }
        }
        self._post({
            "birth_dt": "1990-06-15T09:00:00Z",
            "birth_lat": 51.5, "birth_lon": -0.1,
            "birth_place": "", "house_system": "O",
            "chart_data": chart, "action": "confirm",
        })
        event = GameEvent.objects.get(room=self.room, verb=GameEvent.SKY_SAVED)
        self.assertEqual(event.actor, self.user)
        self.assertEqual(event.data.get("top_capacitors"), ["Ardor"])

    def test_confirm_records_sky_saved_event_with_two_way_tie(self):
        from apps.drama.models import GameEvent
        chart = {
            "elements": {
                "Fire":  3, "Stone": 3,  # tied at top
                "Air":   2, "Water": 0, "Time": 1, "Space": 1,
            }
        }
        self._post({
            "birth_dt": "1990-06-15T09:00:00Z",
            "birth_lat": 51.5, "birth_lon": -0.1,
            "birth_place": "", "house_system": "O",
            "chart_data": chart, "action": "confirm",
        })
        event = GameEvent.objects.get(room=self.room, verb=GameEvent.SKY_SAVED)
        # Order follows the canonical ELEMENT_ORDER (Fire, Stone, Time, Space, Air, Water)
        self.assertEqual(event.data.get("top_capacitors"), ["Ardor", "Ossum"])

    def test_save_without_confirm_does_not_record_sky_saved_event(self):
        from apps.drama.models import GameEvent
        self._post({
            "birth_dt": "1990-06-15T09:00:00Z",
            "birth_lat": 51.5, "birth_lon": -0.1,
            "birth_place": "", "house_system": "O",
            "chart_data": {"elements": {"Fire": 3}},
            # no action=confirm — just a draft save
        })
        self.assertFalse(
            GameEvent.objects.filter(room=self.room, verb=GameEvent.SKY_SAVED).exists()
        )

    def test_confirm_with_dict_shaped_elements_extracts_count(self):
        """Some chart payloads enrich each element to {count, contributors};
        sky_save should read .count rather than treating the dict as a value."""
        from apps.drama.models import GameEvent
        chart = {
            "elements": {
                "Fire":  {"count": 4, "contributors": ["Sun", "Mars", "Jupiter", "Pluto"]},
                "Stone": {"count": 1, "contributors": ["Venus"]},
                "Air":   {"count": 2, "contributors": ["Mercury", "Uranus"]},
                "Water": {"count": 0, "contributors": []},
                "Time":  {"count": 1, "stellia": ["Saturn"]},
                "Space": {"count": 1, "parades": ["Neptune"]},
            }
        }
        self._post({
            "birth_dt": "1990-06-15T09:00:00Z",
            "birth_lat": 51.5, "birth_lon": -0.1,
            "birth_place": "", "house_system": "O",
            "chart_data": chart, "action": "confirm",
        })
        event = GameEvent.objects.get(room=self.room, verb=GameEvent.SKY_SAVED)
        self.assertEqual(event.data.get("top_capacitors"), ["Ardor"])

    def test_confirm_copies_seat_significator_to_character(self):
        """sky_save with action=confirm copies seat.significator onto Character."""
        earthman, _ = DeckVariant.objects.get_or_create(
            slug="earthman", defaults={"name": "Earthman Deck", "card_count": 108}
        )
        sig_card = TarotCard.objects.filter(deck_variant=earthman).first()
        pc_seat = TableSeat.objects.get(room=self.room, role="PC")
        pc_seat.significator = sig_card
        pc_seat.save()

        self._post({
            "birth_dt": "1990-06-15T09:00:00Z",
            "birth_lat": 51.5, "birth_lon": -0.1,
            "birth_place": "", "house_system": "O",
            "chart_data": {}, "action": "confirm",
        })

        char = Character.objects.get(seat=pc_seat)
        self.assertEqual(char.significator, sig_card)


# ── SIG event-retraction branches ────────────────────────────────────────────
# The provenance scrolls use a `data["retracted"] = True` flag to soft-cancel
# prior events when a gamer reverses themselves (WAIT NVM after SAVE SIG, etc).
# These three branches in sig_reserve / sig_ready are the load-bearing ones —
# without them a recanted action stays visible in the billboard scrollback.

class SigEventRetractionTest(TestCase):
    """`data["retracted"] = True` writes on the three reverse-direction paths."""

    def setUp(self):
        self.room, self.gamers, self.earthman, self.card = _full_sig_setUp(self)
        # PC (founder) already logged in; reserve + go ready so subsequent
        # actions have prior SIG_READY events to retract.
        self.reserve_url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id})
        self.ready_url   = reverse("epic:sig_ready",   kwargs={"room_id": self.room.id})

    def _reserve(self):
        return self.client.post(self.reserve_url, data={
            "card_id": self.card.id, "action": "reserve",
        })

    def _ready(self, action="ready"):
        return self.client.post(self.ready_url, data={"action": action})

    def test_sig_unready_retracts_prior_sig_ready_event(self):
        """sig_ready action=unready flips `data["retracted"]=True` on the most
        recent un-retracted SIG_READY event for this actor (views.py L937)."""
        self._reserve()
        self._ready(action="ready")
        prior = self.room.events.filter(
            actor=self.gamers[0], verb=GameEvent.SIG_READY
        ).last()
        self.assertFalse(prior.data.get("retracted"), "precondition: not yet retracted")

        self._ready(action="unready")

        prior.refresh_from_db()
        self.assertTrue(prior.data.get("retracted"))

    def test_sig_ready_retracts_prior_sig_unready_event(self):
        """sig_ready action=ready retracts the most recent un-retracted
        SIG_UNREADY event (views.py L907) — the cancellation is now moot."""
        self._reserve()
        self._ready(action="ready")
        self._ready(action="unready")
        prior_unready = self.room.events.filter(
            actor=self.gamers[0], verb=GameEvent.SIG_UNREADY
        ).last()
        self.assertFalse(prior_unready.data.get("retracted"))

        self._ready(action="ready")

        prior_unready.refresh_from_db()
        self.assertTrue(prior_unready.data.get("retracted"))

    def test_sig_release_while_ready_retracts_prior_sig_ready_event(self):
        """sig_reserve action=release on a ready reservation acts as implicit
        WAIT NVM — retracts the most recent SIG_READY (views.py L823)."""
        self._reserve()
        self._ready(action="ready")
        prior = self.room.events.filter(
            actor=self.gamers[0], verb=GameEvent.SIG_READY
        ).last()
        self.assertFalse(prior.data.get("retracted"))

        self.client.post(self.reserve_url, data={
            "card_id": self.card.id, "action": "release",
        })

        prior.refresh_from_db()
        self.assertTrue(prior.data.get("retracted"))

    def test_sig_release_while_ready_records_sig_unready_event(self):
        """Same release-while-ready path also records a fresh SIG_UNREADY
        (the implicit cancellation event)."""
        self._reserve()
        self._ready(action="ready")
        unready_count_before = self.room.events.filter(
            actor=self.gamers[0], verb=GameEvent.SIG_UNREADY
        ).count()
        self.client.post(self.reserve_url, data={
            "card_id": self.card.id, "action": "release",
        })
        self.assertEqual(
            self.room.events.filter(
                actor=self.gamers[0], verb=GameEvent.SIG_UNREADY
            ).count(),
            unready_count_before + 1,
        )


# ── SIG_RESERVE invalid card-id branch ───────────────────────────────────────

class SigReserveInvalidCardIdTest(TestCase):
    """sig_reserve POSTed with a card_id that doesn't exist returns 400."""

    def setUp(self):
        self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
        self.url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id})

    def test_unknown_card_id_returns_400(self):
        """TarotCard.DoesNotExist branch (views.py L840-841)."""
        response = self.client.post(self.url, data={
            "card_id": 999999, "action": "reserve",
        })
        self.assertEqual(response.status_code, 400)


# ── SIG_SELECT gravity-polarity rendering ────────────────────────────────────

class SigSelectGravityContextTest(TestCase):
    """SIG_SELECT room context for a gravity-polarity gamer.

    Covers the `user_polarity = 'gravity'` branch (views.py L322) and the
    gravity_sig_cards lookup (L357) — both fall through the cracks of the
    default founder-as-PC-levity tests."""

    def setUp(self):
        self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
        # gamers[5] is BC → gravity polarity
        self.bc = self.gamers[5]
        self.client.force_login(self.bc)
        self.url = reverse("epic:room", kwargs={"room_id": self.room.id})

    def test_gravity_gamer_room_context_has_gravity_polarity(self):
        response = self.client.get(self.url)
        self.assertEqual(response.context["user_polarity"], "gravity")

    def test_gravity_gamer_sees_gravity_sig_cards(self):
        """Levity + gravity get the same 16 court cards (filtered by major-arcana
        Note unlocks); this test just asserts the gravity branch was taken."""
        from apps.epic.models import gravity_sig_cards
        response = self.client.get(self.url)
        # Same underlying card set; assertion is that the context was populated
        # (the gravity branch returned, vs falling into the empty `else`).
        self.assertEqual(
            list(response.context["sig_cards"]),
            list(gravity_sig_cards(self.room, self.bc)),
        )

    def test_gravity_gamer_sig_card_set_non_empty(self):
        response = self.client.get(self.url)
        self.assertGreater(len(response.context["sig_cards"]), 0)


# ── SEA_DECK draw view ───────────────────────────────────────────────────────

class SeaDeckViewTest(TestCase):
    """sea_deck — JSON view returning shuffled levity + gravity halves.

    Mirrors the FT in test_game_room_select_sea.py:DRAW SEA — that test walks
    the full UI; this one isolates the JSON contract + filter semantics."""

    def setUp(self):
        self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
        self.room.table_status = Room.SKY_SELECT
        self.room.save()
        # Use PC seat (founder) — already logged in by _full_sig_setUp
        self.url = reverse("epic:sea_deck", kwargs={"room_id": self.room.id})

    def test_returns_403_when_not_seated(self):
        outsider = User.objects.create(email="outsider@test.io")
        self.client.force_login(outsider)
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 403)

    def test_returns_empty_halves_when_seat_has_no_deck_variant(self):
        """sea_deck early-outs to {levity:[],gravity:[]} if the seat hasn't
        committed a deck — guards against null deck_variant FK access."""
        TableSeat.objects.filter(room=self.room).update(deck_variant=None)
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 200)
        data = response.json()
        self.assertEqual(data, {"levity": [], "gravity": []})

    def test_returns_two_halves(self):
        response = self.client.get(self.url)
        data = response.json()
        self.assertIn("levity", data)
        self.assertIn("gravity", data)

    def test_card_count_roughly_split_between_halves(self):
        """Total card pool is split in half — within 1 of perfectly even."""
        response = self.client.get(self.url)
        data = response.json()
        self.assertAlmostEqual(len(data["levity"]), len(data["gravity"]), delta=1)

    def test_card_dict_contains_expected_keys(self):
        response = self.client.get(self.url)
        data = response.json()
        sample = data["levity"][0]
        for key in (
            "id", "name", "arcana", "corner_rank", "suit_icon",
            "name_group", "name_title", "reversed",
            "levity_qualifier", "gravity_qualifier",
        ):
            self.assertIn(key, sample, f"missing key {key!r} in card dict")

    def test_reversed_field_is_boolean(self):
        response = self.client.get(self.url)
        data = response.json()
        for card in data["levity"] + data["gravity"]:
            self.assertIsInstance(card["reversed"], bool)

    def test_excludes_claimed_significators(self):
        """A card already set as a seat.significator must not appear in either
        half — it's been claimed for the game and is out of the sea-draw pool."""
        sig_card = TarotCard.objects.get(
            deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
        )
        pc_seat = TableSeat.objects.get(room=self.room, role="PC")
        pc_seat.significator = sig_card
        pc_seat.save()
        response = self.client.get(self.url)
        data = response.json()
        all_ids = {c["id"] for c in data["levity"]} | {c["id"] for c in data["gravity"]}
        self.assertNotIn(sig_card.id, all_ids)


class RoomBurgerBtnRenderTest(TestCase):
    """Burger btn + fan of 5 sub-btns renders on room.html unconditionally —
    from the gatekeeper state through every table_status. Sub-btns are
    pure scaffolding (no handlers in this sprint)."""

    def setUp(self):
        self.user = User.objects.create(email="founder@test.io")
        self.room = Room.objects.create(name="Burger Room", owner=self.user)
        self.client.force_login(self.user)
        self.url = reverse("epic:room", kwargs={"room_id": self.room.id})

    def test_burger_btn_renders(self):
        response = self.client.get(self.url)
        self.assertContains(response, 'id="id_burger_btn"')
        self.assertContains(response, "fa-burger")

    def test_burger_fan_container_renders(self):
        response = self.client.get(self.url)
        self.assertContains(response, 'id="id_burger_fan"')

    def test_five_fan_sub_btns_render_with_correct_ids(self):
        response = self.client.get(self.url)
        for btn_id in (
            "id_sky_btn", "id_earth_btn", "id_sea_btn",
            "id_voice_btn", "id_text_btn",
        ):
            self.assertContains(response, f'id="{btn_id}"')

    def test_fan_sub_btn_icons_match_spec(self):
        response = self.client.get(self.url)
        for icon in (
            "fa-cloud", "fa-earth-americas", "fa-bridge-water",
            "fa-headset", "fa-keyboard",
        ):
            self.assertContains(response, icon)

    def test_each_sub_btn_renders_dual_icon_for_inactive_flash_swap(self):
        """Sub-btns carry BOTH the real icon (.burger-fan-icon--on) + a
        fa-ban placeholder (.burger-fan-icon--off). CSS keeps the real
        icon visible by default; .flash-inactive swaps to fa-ban during
        the click-while-inactive pulse. fa-ban itself isn't counted
        directly — _table_positions.html also renders fa-ban for
        non-starter seats — but the burger-fan-icon classes are unique
        to the fan + load-bearing for the CSS swap rule."""
        response = self.client.get(self.url)
        body = response.content.decode()
        self.assertEqual(body.count("burger-fan-icon--on"), 5)
        self.assertEqual(body.count("burger-fan-icon--off"), 5)

    def test_burger_btn_script_loaded(self):
        response = self.client.get(self.url)
        self.assertContains(response, "burger-btn.js")

    def test_burger_persists_in_table_status_state(self):
        """Burger renders past the gatekeeper too — confirmed for ROLE_SELECT."""
        self.room.gate_status = Room.OPEN
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        response = self.client.get(self.url)
        self.assertContains(response, 'id="id_burger_btn"')


class RoomGateViewTest(TestCase):
    """Room renewal gate-view (sprint 2026-05-31) — reachable mid-game (the
    gatekeeper redirects to the table once table_status is set; this view
    does not). Shows the viewer's own seat/circle + token time-remaining + a
    RENEW affordance; the gear-menu NVM returns to the table hex."""

    def setUp(self):
        self.owner = User.objects.create(email="owner@test.io", username="owner")
        self.room = Room.objects.create(
            name="Renewal Room", owner=self.owner,
            renewal_period=timedelta(days=7),
            gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
        )
        self.slot = self.room.gate_slots.get(slot_number=1)
        self.slot.gamer = self.owner
        self.slot.status = GateSlot.FILLED
        self.slot.filled_at = timezone.now()
        self.slot.debited_token_type = Token.FREE
        self.slot.save()
        self.client.force_login(self.owner)
        self.url = reverse("epic:room_gate", args=[self.room.id])

    def _lapse(self):
        # Backdate the seat into the renewal-grace window (cost lapsed, seat
        # still FILLED) so the gate-view renders its renew state.
        self.slot.filled_at = timezone.now() - timedelta(days=8)
        self.slot.save()

    def test_renders_200_even_when_table_status_set(self):
        # gatekeeper would 302 to the room; the gate-view must render mid-game.
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 200)

    def test_cost_current_shows_cont_game_to_hex(self):
        response = self.client.get(self.url)
        self.assertContains(response, "id_room_cont_game_btn")
        self.assertContains(response, "CONT")
        self.assertContains(response, reverse("epic:room", args=[self.room.id]))

    def test_cost_current_status_shows_deposited_count(self):
        # One filled slot (the owner's) → "1 Token(s) Deposited" (literal "(s)").
        response = self.client.get(self.url)
        self.assertContains(response, "1 Token(s) Deposited")

    def test_cost_current_has_no_renew_form(self):
        # Rails are static (claimed) while the cost is current — renewing is a
        # lapsed-state affordance only.
        response = self.client.get(self.url)
        self.assertNotContains(
            response, reverse("epic:renew_token", args=[self.room.id]))

    def test_cost_lapsed_shows_please_deposit_and_renew_rails(self):
        self._lapse()
        response = self.client.get(self.url)
        self.assertContains(response, "Please Deposit Token")
        self.assertContains(
            response, reverse("epic:renew_token", args=[self.room.id]))

    def test_cost_lapsed_hides_cont_game(self):
        self._lapse()
        response = self.client.get(self.url)
        self.assertNotContains(response, "id_room_cont_game_btn")

    def test_nvm_returns_to_room_hex(self):
        # NVM lands the gamer back on the table hex at his acting seat (?seat),
        # not owned[0] — so a CARTE multi-seat gamer keeps his switched seat.
        # The owner holds slot 1, so current_slot=1.
        response = self.client.get(self.url)
        self.assertContains(
            response,
            f'href="{reverse("epic:room", args=[self.room.id])}?seat=1"')

    def test_page_class_carries_page_room(self):
        response = self.client.get(self.url)
        self.assertIn("page-room", response.context["page_class"])

    def test_no_seat_circle_or_countdown_rendered(self):
        # Seat circle + countdown removed (user-spec) — the hex .fa-chair + the
        # next-sprint user/seat tooltips carry that info.
        response = self.client.get(self.url)
        self.assertNotContains(response, "room-gate-seat")
        self.assertNotContains(response, "data-cost-until")
        self.assertNotContains(response, "id_room_gate_remaining")


class RoomRenewTokenTest(TestCase):
    """The RENEW endpoint — re-deposit a token into the viewer's own FILLED
    slot, resetting filled_at=now (via debit_token) so the cost-current
    window restarts. Distinct from confirm_token (which needs a RESERVED
    slot). 402 when token-depleted."""

    def setUp(self):
        self.owner = User.objects.create(email="owner@test.io", username="owner")
        self.owner.equipped_trinket = None
        self.owner.save(update_fields=["equipped_trinket"])
        self.owner.tokens.exclude(token_type=Token.FREE).delete()  # keep FREE only
        self.room = Room.objects.create(
            name="Renewal Room", owner=self.owner,
            renewal_period=timedelta(days=7),
            gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
        )
        self.slot = self.room.gate_slots.get(slot_number=1)
        self.slot.gamer = self.owner
        self.slot.status = GateSlot.FILLED
        self.slot.filled_at = timezone.now() - timedelta(days=8)  # in grace
        self.slot.debited_token_type = Token.FREE
        self.slot.save()
        self.client.force_login(self.owner)
        self.url = reverse("epic:renew_token", args=[self.room.id])

    def test_renew_resets_filled_at(self):
        self.client.post(self.url)
        self.slot.refresh_from_db()
        self.assertGreater(self.slot.filled_at, timezone.now() - timedelta(minutes=1))
        self.assertTrue(self.slot.cost_current)

    def test_renew_consumes_free_token(self):
        self.client.post(self.url)
        self.assertFalse(
            self.owner.tokens.filter(token_type=Token.FREE).exists())
        self.slot.refresh_from_db()
        self.assertEqual(self.slot.debited_token_type, Token.FREE)

    def test_renew_records_slot_filled_event(self):
        self.client.post(self.url)
        self.assertTrue(
            self.room.events.filter(
                actor=self.owner, verb=GameEvent.SLOT_FILLED).exists())

    def test_renew_without_filled_slot_redirects(self):
        self.slot.status = GateSlot.EMPTY
        self.slot.gamer = None
        self.slot.save()
        response = self.client.post(self.url)
        self.assertEqual(response.status_code, 302)

    def test_renew_402_when_token_depleted(self):
        self.owner.tokens.all().delete()
        response = self.client.post(self.url)
        self.assertEqual(response.status_code, 402)

    def test_renew_get_redirects(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 302)


class RoomNavbarGateViewTest(TestCase):
    """Navbar swaps CONT GAME → GATE VIEW on room pages (mirror my-sea),
    routing to the room gate-view. The gameboard listing keeps CONT GAME
    (no `page-room` marker)."""

    def setUp(self):
        self.owner = User.objects.create(email="owner@test.io", username="owner")
        self.room = Room.objects.create(
            name="Nav Room", owner=self.owner,
            gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
        )
        self.client.force_login(self.owner)

    def test_room_page_shows_gate_view_not_cont_game(self):
        response = self.client.get(reverse("epic:room", args=[self.room.id]))
        self.assertContains(response, "id_navbar_gate_view_btn")
        self.assertNotContains(response, 'id="id_cont_game"')

    def test_gate_view_btn_links_to_room_gate(self):
        response = self.client.get(reverse("epic:room", args=[self.room.id]))
        self.assertContains(
            response, reverse("epic:room_gate", args=[self.room.id]))

    def test_room_gate_page_also_shows_gate_view(self):
        response = self.client.get(reverse("epic:room_gate", args=[self.room.id]))
        self.assertContains(response, "id_navbar_gate_view_btn")

    def test_room_page_carries_page_room_marker(self):
        response = self.client.get(reverse("epic:room", args=[self.room.id]))
        self.assertIn("page-room", response.context["page_class"])


class RoomCenterSupersessionTest(TestCase):
    """When the viewer's seat token cost lapses (filled_at past the cost-
    current window), GATE VIEW supersedes the center-hex phase buttons —
    SCAN SIGS, CAST SKY, DRAW SEA, the sig overlay — EXCEPT the gamer's own
    ROLE card-stack pick (covered in RoomRoleStackGraceTest)."""

    def setUp(self):
        self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
        self.founder = self.gamers[0]
        self.url = reverse("epic:room", kwargs={"room_id": self.room.id})

    def _lapse_viewer(self):
        slot = self.room.gate_slots.get(slot_number=1)  # founder = slot 1
        slot.filled_at = timezone.now() - timedelta(days=8)  # grace (S=7d)
        slot.save()

    def test_viewer_cost_current_true_by_default(self):
        # _full_sig_setUp leaves filled_at None → never-expires → current.
        self.assertTrue(self.client.get(self.url).context["viewer_cost_current"])

    def test_cost_current_no_gate_view_btn_in_center(self):
        self.assertNotContains(self.client.get(self.url), "id_room_gate_view_btn")

    def test_cost_lapsed_supersedes_sig_overlay(self):
        self._lapse_viewer()
        response = self.client.get(self.url)  # _full_sig_setUp room is SIG_SELECT
        self.assertContains(response, "id_room_gate_view_btn")
        self.assertNotContains(response, "id_sig_deck")

    def test_cost_current_shows_sig_overlay(self):
        response = self.client.get(self.url)
        self.assertContains(response, "id_sig_deck")
        self.assertNotContains(response, "id_room_gate_view_btn")

    def test_cost_lapsed_supersedes_cast_sky(self):
        self.room.table_status = Room.SKY_SELECT
        self.room.save()
        self._lapse_viewer()
        response = self.client.get(self.url)
        self.assertContains(response, "id_room_gate_view_btn")
        self.assertNotContains(response, "id_pick_sky_btn")

    def test_cost_current_shows_cast_sky(self):
        self.room.table_status = Room.SKY_SELECT
        self.room.save()
        response = self.client.get(self.url)
        self.assertContains(response, "id_pick_sky_btn")
        self.assertNotContains(response, "id_room_gate_view_btn")

    def test_cost_lapsed_supersedes_draw_sea(self):
        self.room.table_status = Room.SKY_SELECT
        self.room.save()
        pc = TableSeat.objects.get(room=self.room, gamer=self.founder)
        Character.objects.create(seat=pc, confirmed_at=timezone.now())
        self._lapse_viewer()
        response = self.client.get(self.url)
        self.assertContains(response, "id_room_gate_view_btn")
        self.assertNotContains(response, "id_pick_sea_btn")

    def test_cost_lapsed_supersedes_scan_sigs(self):
        self.room.table_status = Room.ROLE_SELECT  # roles assigned → SCAN SIGS
        self.room.save()
        self._lapse_viewer()
        response = self.client.get(self.url)
        self.assertContains(response, "id_room_gate_view_btn")
        self.assertNotContains(response, "id_pick_sigs_btn")

    def test_cost_current_shows_scan_sigs(self):
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        response = self.client.get(self.url)
        self.assertContains(response, "id_pick_sigs_btn")
        self.assertNotContains(response, "id_room_gate_view_btn")


class RoomRoleStackGraceTest(TestCase):
    """The gamer's own ROLE card-stack pick survives a lapsed token cost
    (deposit-privilege grace) — only SCAN SIGS + later phases get GATE VIEW
    (user-spec 2026-05-31)."""

    def setUp(self):
        self.founder = User.objects.create(email="founder@test.io")
        self.room = Room.objects.create(name="Role Room", owner=self.founder)
        gamers = [self.founder] + [
            User.objects.create(email=f"g{i}@test.io") for i in range(2, 7)
        ]
        for i, gamer in enumerate(gamers, start=1):
            slot = self.room.gate_slots.get(slot_number=i)
            slot.gamer = gamer
            slot.status = GateSlot.FILLED
            slot.save()
            TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
        self.room.gate_status = Room.OPEN
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        self.client.force_login(self.founder)
        self.url = reverse("epic:room", kwargs={"room_id": self.room.id})

    def test_card_stack_kept_when_cost_lapsed(self):
        slot = self.room.gate_slots.get(slot_number=1)
        slot.filled_at = timezone.now() - timedelta(days=8)
        slot.save()
        response = self.client.get(self.url)
        # ROLE pick (the gamer's own turn) stays available within grace…
        self.assertContains(response, "card-stack")
        self.assertContains(response, 'data-state="eligible"')
        # …alongside the GATE VIEW supersession of the non-ROLE affordances.
        self.assertContains(response, "id_room_gate_view_btn")


class ExpireLapsedSeatsTest(TestCase):
    """Auto-BYE: a seat whose token cost lapsed past the renewal grace
    (filled_at + 2*renewal_period) is freed — GateSlot blanked, TableSeat
    blanked (row kept for seat-count integrity), SLOT_RETURNED recorded,
    room flagged RENEWAL_DUE (gamer needed). Lazy on room/gate-view access.
    NULL filled_at never expires (protects fixtures / RESERVED holds)."""

    def setUp(self):
        self.founder = User.objects.create(email="founder@test.io", username="founder")
        self.room = Room.objects.create(
            name="Lapse Room", owner=self.founder,
            renewal_period=timedelta(days=7),
            gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
        )
        self.slot = self.room.gate_slots.get(slot_number=1)
        self.slot.gamer = self.founder
        self.slot.status = GateSlot.FILLED
        self.slot.debited_token_type = Token.FREE
        self.slot.save()
        self.seat = TableSeat.objects.create(
            room=self.room, gamer=self.founder, slot_number=1, role="PC",
        )
        self.client.force_login(self.founder)

    def _set_filled(self, when):
        self.slot.filled_at = when
        self.slot.save()

    def test_frees_gateslot_past_grace(self):
        from apps.epic.views import _expire_lapsed_seats
        self._set_filled(timezone.now() - timedelta(days=15))  # > 2S = 14d
        _expire_lapsed_seats(self.room)
        self.slot.refresh_from_db()
        self.assertEqual(self.slot.status, GateSlot.EMPTY)
        self.assertIsNone(self.slot.gamer)
        self.assertIsNone(self.slot.filled_at)
        self.assertIsNone(self.slot.debited_token_type)

    def test_blanks_table_seat(self):
        from apps.epic.views import _expire_lapsed_seats
        self._set_filled(timezone.now() - timedelta(days=15))
        _expire_lapsed_seats(self.room)
        self.seat.refresh_from_db()
        self.assertIsNone(self.seat.gamer)
        self.assertIsNone(self.seat.role)

    def test_noop_within_grace(self):
        from apps.epic.views import _expire_lapsed_seats
        self._set_filled(timezone.now() - timedelta(days=8))  # in grace [7,14)
        _expire_lapsed_seats(self.room)
        self.slot.refresh_from_db()
        self.assertEqual(self.slot.status, GateSlot.FILLED)

    def test_noop_within_cost_window(self):
        from apps.epic.views import _expire_lapsed_seats
        self._set_filled(timezone.now())
        _expire_lapsed_seats(self.room)
        self.slot.refresh_from_db()
        self.assertEqual(self.slot.status, GateSlot.FILLED)

    def test_ignores_null_filled_at(self):
        from apps.epic.views import _expire_lapsed_seats
        self.slot.filled_at = None
        self.slot.save()
        _expire_lapsed_seats(self.room)
        self.slot.refresh_from_db()
        self.assertEqual(self.slot.status, GateSlot.FILLED)

    def test_sets_renewal_due(self):
        from apps.epic.views import _expire_lapsed_seats
        self._set_filled(timezone.now() - timedelta(days=15))
        _expire_lapsed_seats(self.room)
        self.room.refresh_from_db()
        self.assertEqual(self.room.gate_status, Room.RENEWAL_DUE)

    def test_records_slot_returned(self):
        from apps.epic.views import _expire_lapsed_seats
        self._set_filled(timezone.now() - timedelta(days=15))
        _expire_lapsed_seats(self.room)
        self.assertTrue(
            self.room.events.filter(
                actor=self.founder, verb=GameEvent.SLOT_RETURNED).exists())

    def test_room_view_runs_expiry_on_access(self):
        self._set_filled(timezone.now() - timedelta(days=15))
        self.client.get(reverse("epic:room", args=[self.room.id]))
        self.slot.refresh_from_db()
        self.assertEqual(self.slot.status, GateSlot.EMPTY)

    def test_room_gate_runs_expiry_on_access(self):
        self._set_filled(timezone.now() - timedelta(days=15))
        self.client.get(reverse("epic:room_gate", args=[self.room.id]))
        self.slot.refresh_from_db()
        self.assertEqual(self.slot.status, GateSlot.EMPTY)

    def test_gamer_needed_stub_renders(self):
        self.room.gate_status = Room.RENEWAL_DUE
        self.room.save()
        response = self.client.get(reverse("epic:room", args=[self.room.id]))
        self.assertContains(response, "id_gamer_needed")


class ReleaseLapsedTrinketsTest(TestCase):
    """A deposited trinket binds `Token.current_room` + stamps `in_use_since`;
    once the room's renewal span (7d) elapses the binding is freed so the holder
    can re-deposit / reuse it — WITHOUT ejecting the seat (renewal grace to 14d
    stays `_expire_lapsed_seats`'s job). PASS/BAND never bind (reusable keys),
    and a My-Sea-cooldown COIN (`next_ready_at` set, no `current_room`) is not a
    room binding, so both are untouched. (Staging bug 2026-06-08: a week-old
    CARTE stayed 'In-Use' after its cost lapsed.)"""

    def setUp(self):
        self.user = User.objects.create(email="carte@test.io", username="carteman")
        self.room = Room.objects.create(
            name="Lapse Room", owner=self.user, renewal_period=timedelta(days=7),
        )
        self.slot = self.room.gate_slots.get(slot_number=1)

    def _bind(self, token_type, in_use_since, **extra):
        return Token.objects.create(
            user=self.user, token_type=token_type, current_room=self.room,
            in_use_since=in_use_since, **extra,
        )

    def test_releases_carte_past_renewal_span(self):
        from apps.lyric.models import release_lapsed_trinkets
        carte = self._bind(Token.CARTE, timezone.now() - timedelta(days=8),
                           slots_claimed=6)
        release_lapsed_trinkets(self.user.tokens.all())
        carte.refresh_from_db()
        self.assertIsNone(carte.current_room)
        self.assertIsNone(carte.in_use_since)
        self.assertEqual(carte.slots_claimed, 0)

    def test_releases_coin_past_renewal_span(self):
        from apps.lyric.models import release_lapsed_trinkets
        coin = self._bind(Token.COIN, timezone.now() - timedelta(days=8))
        release_lapsed_trinkets(self.user.tokens.all())
        coin.refresh_from_db()
        self.assertIsNone(coin.current_room)

    def test_noop_within_renewal_span(self):
        from apps.lyric.models import release_lapsed_trinkets
        carte = self._bind(Token.CARTE, timezone.now() - timedelta(days=2))
        release_lapsed_trinkets(self.user.tokens.all())
        carte.refresh_from_db()
        self.assertEqual(carte.current_room, self.room)

    def test_releases_legacy_binding_with_null_in_use_since(self):
        # Pre-clock staging cruft: bound but never stamped → freed on first sweep.
        from apps.lyric.models import release_lapsed_trinkets
        carte = self._bind(Token.CARTE, None, slots_claimed=6)
        release_lapsed_trinkets(self.user.tokens.all())
        carte.refresh_from_db()
        self.assertIsNone(carte.current_room)

    def test_does_not_eject_the_seat(self):
        from apps.lyric.models import release_lapsed_trinkets
        self.slot.gamer = self.user
        self.slot.status = GateSlot.FILLED
        self.slot.filled_at = timezone.now() - timedelta(days=8)
        self.slot.debited_token_type = Token.CARTE
        self.slot.save()
        self._bind(Token.CARTE, timezone.now() - timedelta(days=8), slots_claimed=6)
        release_lapsed_trinkets(self.user.tokens.all())
        self.slot.refresh_from_db()
        self.assertEqual(self.slot.status, GateSlot.FILLED)
        self.assertEqual(self.slot.gamer, self.user)

    def test_my_sea_cooldown_coin_untouched(self):
        # `next_ready_at` (My Sea 24h draw cooldown) without `current_room` is
        # NOT a room binding — the sweep ignores it + leaves next_ready_at intact.
        from apps.lyric.models import release_lapsed_trinkets
        coin = Token.objects.create(
            user=self.user, token_type=Token.COIN,
            next_ready_at=timezone.now() + timedelta(hours=24),
        )
        release_lapsed_trinkets(self.user.tokens.all())
        coin.refresh_from_db()
        self.assertIsNotNone(coin.next_ready_at)
        self.assertIsNone(coin.current_room)


class ExpireLapsedRoomSeatsCommandTest(TestCase):
    """Cron backstop for rooms nobody reopens — `expire_lapsed_room_seats`
    runs the same `_expire_lapsed_seats` sweep the lazy view path uses, for
    mid-game tables left idle past the grace window."""

    def _room_with_filled_slot(self, email, filled_at):
        owner = User.objects.create(email=email)
        room = Room.objects.create(
            name="Cron Room", owner=owner, renewal_period=timedelta(days=7),
            gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
        )
        slot = room.gate_slots.get(slot_number=1)
        slot.gamer = owner
        slot.status = GateSlot.FILLED
        slot.filled_at = filled_at
        slot.debited_token_type = Token.FREE
        slot.save()
        return room, slot

    def test_command_frees_lapsed_seat_and_sets_renewal_due(self):
        from django.core.management import call_command
        room, slot = self._room_with_filled_slot(
            "lapsed@test.io", timezone.now() - timedelta(days=15))
        call_command("expire_lapsed_room_seats")
        slot.refresh_from_db()
        room.refresh_from_db()
        self.assertEqual(slot.status, GateSlot.EMPTY)
        self.assertEqual(room.gate_status, Room.RENEWAL_DUE)

    def test_command_noop_within_grace(self):
        from django.core.management import call_command
        room, slot = self._room_with_filled_slot(
            "ingrace@test.io", timezone.now() - timedelta(days=8))
        call_command("expire_lapsed_room_seats")
        slot.refresh_from_db()
        self.assertEqual(slot.status, GateSlot.FILLED)

    def test_command_releases_lapsed_trinket(self):
        # The cron backstop frees trinkets bound past the 7d in-use window even
        # when the seat is still in renewal grace (< 14d, not yet auto-BYE'd).
        from django.core.management import call_command
        owner = User.objects.create(email="cartecron@test.io")
        room = Room.objects.create(
            name="Carte Cron Room", owner=owner, renewal_period=timedelta(days=7),
        )
        slot = room.gate_slots.get(slot_number=1)
        slot.gamer = owner
        slot.status = GateSlot.FILLED
        slot.filled_at = timezone.now() - timedelta(days=8)  # in grace [7,14)
        slot.debited_token_type = Token.CARTE
        slot.save()
        carte = Token.objects.create(
            user=owner, token_type=Token.CARTE, current_room=room,
            in_use_since=timezone.now() - timedelta(days=8), slots_claimed=6,
        )
        call_command("expire_lapsed_room_seats")
        carte.refresh_from_db()
        slot.refresh_from_db()
        self.assertIsNone(carte.current_room)          # trinket freed
        self.assertEqual(slot.status, GateSlot.FILLED)  # seat still in grace


class RoomScrollOfEventsTest(TestCase):
    """The table-hex aperture gains a 2nd scroll-snap section: scrolling down
    past the hex reveals the room's provenance feed (the same GameEvent stream
    the Billscroll page shows). Available from Role Select onwards — the gate
    phase (no `table_status`) shows no scroll. Mirrors my_sky's wheel<->form
    binary scroll-snap toggle; built to DRY-template onto my_sea next.

    The shared row list comes from `core/_partials/_scroll.html` (the same
    partial the Billscroll page uses), so the feed renders identically here."""

    def setUp(self):
        self.user = User.objects.create(email="founder@test.io", username="disco")
        self.client.force_login(self.user)
        self.room = Room.objects.create(
            name="Whataburgher", owner=self.user,
            gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
        )
        TableSeat.objects.create(
            room=self.room, gamer=self.user, slot_number=1, role="PC",
        )
        # A welcome line (system-authored) + a deposit line — feed content.
        GameEvent.objects.create(room=self.room, verb=GameEvent.ROOM_CREATED)
        GameEvent.objects.create(
            room=self.room, actor=self.user, verb=GameEvent.SLOT_FILLED,
            data={"token_type": "carte", "token_display": "Carte Blanche",
                  "slot_number": 1, "renewal_days": 7},
        )
        self.url = reverse("epic:room", kwargs={"room_id": self.room.id})

    def test_aperture_wraps_hex_and_scroll_panes(self):
        content = self.client.get(self.url).content.decode()
        self.assertIn("room-aperture", content)
        self.assertIn("room-hex-pane", content)
        self.assertIn("room-scroll-pane", content)

    def test_aperture_is_scrollable_from_role_select(self):
        # `is-scrollable` engages the binary scroll-snap (2 panes present).
        content = self.client.get(self.url).content.decode()
        self.assertIn("room-aperture is-scrollable", content)

    def test_scroll_pane_renders_the_event_feed(self):
        content = self.client.get(self.url).content.decode()
        self.assertIn("id_drama_scroll", content)
        self.assertIn("Welcome to Whataburgher!", content)
        self.assertIn("deposits a Carte Blanche for slot 1", content)

    def test_scroll_feed_scoped_to_this_room(self):
        other = Room.objects.create(
            name="Elsewhere", owner=self.user,
            gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
        )
        GameEvent.objects.create(room=other, verb=GameEvent.ROOM_CREATED)
        content = self.client.get(self.url).content.decode()
        self.assertNotIn("Welcome to Elsewhere!", content)

    def test_no_scroll_pane_in_gate_phase(self):
        """The gate phase (table_status unset) shows no scroll — the feed is
        reachable only from Role Select onwards."""
        gate_room = Room.objects.create(name="Gatehouse", owner=self.user)
        content = self.client.get(
            reverse("epic:gatekeeper", args=[gate_room.id])
        ).content.decode()
        self.assertNotIn("room-scroll-pane", content)
        self.assertNotIn("is-scrollable", content)

    def test_scroll_pane_uses_applet_box_card_with_room_name(self):
        """The feed sits in an `.applet-scroll` card (same %applet-box chrome as
        the Billscroll page), with the room name as its rotated title."""
        content = self.client.get(self.url).content.decode()
        self.assertIn("applet-scroll", content)
        # The rotated <h2> title inside the card carries the room name.
        self.assertIn("<h2>Whataburgher</h2>", content)

    def test_scroll_gear_filter_renders_in_table_phase(self):
        """From Role Select onwards the gear menu carries BOTH panes: the
        default NVM/DEL/BYE (`.room-menu-default`) and the Frame/Redact log
        filter (`.room-menu-scroll`), swapped by scroll position client-side."""
        content = self.client.get(self.url).content.decode()
        self.assertIn("room-menu-default", content)
        self.assertIn("room-menu-scroll", content)
        self.assertIn("id_scroll_filter_form", content)
        self.assertIn('value="frame"', content)
        self.assertIn('value="redact"', content)

    def test_scroll_gear_filter_absent_in_gate_phase(self):
        """The gate phase has no scroll, so no Frame/Redact filter pane — the
        gear keeps only its NVM/DEL/BYE menu."""
        gate_room = Room.objects.create(name="Gatehouse", owner=self.user)
        content = self.client.get(
            reverse("epic:gatekeeper", args=[gate_room.id])
        ).content.decode()
        self.assertNotIn("room-menu-scroll", content)
        self.assertNotIn("id_scroll_filter_form", content)

    def test_title_is_room_scroll_reel_in_table_phase(self):
        """The page title is a two-word vertical reel so it can swap GAME ROOM
        ⇄ GAME SCROLL as the aperture snaps to the feed (client-side via
        room-scroll.js's `.is-scroll` toggle). Both words ship in the header;
        CSS slides between them. The window span carries `data-letters-split`
        so base.html's letter-splitter leaves it alone and splits the two
        inner `.gr-word`s instead."""
        content = self.client.get(self.url).content.decode()
        # Match the rendered class attribute, not the bare token — base.html's
        # letter-splitter comment mentions `.gr-swap`/`.gr-word` on every page.
        self.assertIn('class="gr-swap"', content)
        self.assertIn("gr-word gr-word--base", content)
        self.assertIn("gr-word gr-word--scroll", content)
        # The swap-in word reads "scroll" (lowercase in markup; CSS uppercases).
        self.assertIn(">scroll</span>", content)

    def test_title_reel_absent_in_gate_phase(self):
        """The gate phase has no scroll pane to reveal, so the title stays a
        plain GAME ROOM — no reel to slide."""
        gate_room = Room.objects.create(name="Gatehouse", owner=self.user)
        content = self.client.get(
            reverse("epic:gatekeeper", args=[gate_room.id])
        ).content.decode()
        # The reel markup is absent (the bare `gr-swap`/`gr-word` tokens still
        # appear in base.html's splitter comment, so assert on the rendered
        # class attribute + the unique swap-word marker instead).
        self.assertNotIn('class="gr-swap"', content)
        self.assertNotIn("gr-word--scroll", content)


class ScrollStatusViewTest(TestCase):
    """`scroll_status` renders JUST the scroll-of-events feed partial — the
    endpoint room-scroll.js re-fetches on a `scroll_update` WS nudge to refresh
    the SCROLL applet live (no reload). Same feed the room page paints inline."""

    def setUp(self):
        self.user = User.objects.create(email="founder@test.io", username="disco")
        self.client.force_login(self.user)
        self.room = Room.objects.create(
            name="Willawonky", owner=self.user,
            gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
        )
        GameEvent.objects.create(
            room=self.room, actor=self.user, verb=GameEvent.SLOT_FILLED,
            data={"token_type": "carte", "token_display": "Carte Blanche",
                  "slot_number": 1, "renewal_days": 7},
        )
        self.url = reverse("epic:scroll_status", kwargs={"room_id": self.room.id})

    def test_renders_the_scroll_feed_section(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 200)
        self.assertContains(response, "id_drama_scroll")
        self.assertContains(response, "drama-event")

    def test_feed_reflects_latest_events(self):
        # A freshly-recorded event shows up on the next fetch (the live-refresh
        # contract) without re-rendering the whole room page.
        from apps.drama.models import record
        record(self.room, GameEvent.ROLE_SELECTED, actor=self.user, role="PC")
        content = self.client.get(self.url).content.decode()
        # One `.drama-event-body` per row (the bare class appears 3×/row).
        self.assertEqual(content.count("drama-event-body"), 2)

    def test_only_partial_not_full_page(self):
        # It is the bare feed section — no room chrome (navbar / aperture).
        content = self.client.get(self.url).content.decode()
        self.assertNotIn("room-aperture", content)
        self.assertNotIn("navbar", content)


class RoomViewsCarouselTest(TestCase):
    """The scroll pane becomes a horizontal carousel of 5 views (ATLAS /
    SCROLL / POST / CHAT / PULSE — [[project-room-game-views-carousel]]).
    Server contract: the 5 view panes + the root-level icon strip render in
    the table phase; the SCROLL view still wraps the provenance feed; the POST
    view embeds the room thread; the Text sub-btn lights `.active`."""

    def setUp(self):
        self.user = User.objects.create(email="founder@test.io", username="disco")
        self.client.force_login(self.user)
        self.room = Room.objects.create(
            name="Whataburgher", owner=self.user,
            gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
        )
        # Seat the viewer + one bud (TableSeat = committed to a seat). A third
        # gamer fills a gate slot ONLY (transient depositor) — must NOT show as
        # a chat participant. The viewer's seat is what grants POST access.
        self.seatmate = User.objects.create(email="amigo@test.io", username="amigo")
        self.transient = User.objects.create(email="pal@test.io", username="pal")
        TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=1, role="PC")
        TableSeat.objects.create(room=self.room, gamer=self.seatmate, slot_number=2, role="NC")
        slot = self.room.gate_slots.get(slot_number=3)
        slot.gamer = self.transient
        slot.status = GateSlot.FILLED
        slot.save()
        self.url = reverse("epic:room", kwargs={"room_id": self.room.id})

    def test_renders_five_view_panes_in_order(self):
        content = self.client.get(self.url).content.decode()
        self.assertIn("id_room_views", content)
        for view in ("atlas", "scroll", "yarn", "post", "pulse"):
            self.assertIn(f'data-view="{view}"', content)
        # Order: atlas precedes scroll precedes yarn precedes post precedes pulse.
        positions = [content.index(f'room-view--{v}')
                     for v in ("atlas", "scroll", "yarn", "post", "pulse")]
        self.assertEqual(positions, sorted(positions))

    def test_renders_root_level_icon_strip(self):
        content = self.client.get(self.url).content.decode()
        self.assertIn("id_room_views_strip", content)
        for view in ("atlas", "scroll", "yarn", "post", "pulse"):
            self.assertIn(f'data-view="{view}"', content)
        self.assertIn("fa-scroll", content)
        self.assertIn("fa-book-atlas", content)
        self.assertIn("fa-route", content)

    def test_scroll_view_still_wraps_the_provenance_feed(self):
        content = self.client.get(self.url).content.decode()
        self.assertIn("id_drama_scroll", content)

    def test_post_view_embeds_room_thread_composer(self):
        content = self.client.get(self.url).content.decode()
        self.assertIn("id_post_table", content)
        self.assertIn("id_post_line_text", content)
        self.assertIn("id_post_line_btn", content)
        # Composer posts to the room-scoped endpoint, not billboard:view_post.
        self.assertIn(reverse("epic:room_post", args=[self.room.id]), content)

    def test_post_view_renders_existing_thread_lines(self):
        from apps.billboard.models import Line
        post = self.room.get_thread_post()
        Line.objects.create(post=post, author=self.user, text="opening move")
        content = self.client.get(self.url).content.decode()
        self.assertIn("opening move", content)

    def test_yarn_and_pulse_render_stubs(self):
        content = self.client.get(self.url).content.decode()
        self.assertIn("room-view-stub", content)

    def test_post_chat_header_lists_seat_occupants_not_token_depositors(self):
        """The POST chat header hardcodes a 'Gamer Introduction' title and lists
        the gamers OCCUPYING SEATS as recipients — the viewer's seatmate shows;
        a gate-slot-only (transient) depositor does not (he could retract +
        leave). Scoped to the shared-recipients paragraph (the position strip
        carries every gate-slot gamer's handle elsewhere on the page)."""
        import re
        content = self.client.get(self.url).content.decode()
        self.assertIn("Gamer Introduction", content)
        recipients = re.search(
            r'<p class="post-shared-recipients">(.*?)</p>', content, re.S)
        recipients_html = recipients.group(1) if recipients else ""
        self.assertIn("@amigo", recipients_html)        # seated → a participant
        self.assertNotIn("@pal", recipients_html)        # gate-slot-only → not

    def test_atlas_gear_menu_has_source_checkboxes(self):
        """The ATLAS view's gear pane carries a source checkbox per other
        reelhouse view; scroll + post are wired (checked), yarn + pulse have no
        model yet (disabled)."""
        content = self.client.get(self.url).content.decode()
        self.assertIn("room-menu-atlas", content)
        self.assertIn("id_atlas_source_form", content)
        for view in ("scroll", "yarn", "post", "pulse"):
            self.assertIn(f'value="{view}"', content)
        self.assertRegex(content, r'value="yarn"[^>]*disabled')
        self.assertRegex(content, r'value="pulse"[^>]*disabled')

    def test_text_btn_active_on_the_table(self):
        content = self.client.get(self.url).content.decode()
        # The Text sub-btn carries `.active` so the burger fan routes its click
        # to the swipe machine instead of the inactive flash-stub.
        self.assertRegex(content, r'id_text_btn[^>]*class="[^"]*\bactive\b')

    def test_text_btn_inactive_in_gate_phase(self):
        gate_room = Room.objects.create(name="Gatehouse", owner=self.user)
        content = self.client.get(
            reverse("epic:gatekeeper", args=[gate_room.id])
        ).content.decode()
        self.assertNotRegex(content, r'id_text_btn[^>]*class="[^"]*\bactive\b')


class RoomPostEndpointTest(TestCase):
    """`epic:room_post` — the AJAX composer behind the carousel's POST view.
    Appends a Line to the room thread + returns the rendered line partial as
    JSON so room-views.js can splice it in without a reload."""

    def setUp(self):
        self.user = User.objects.create(email="founder@test.io", username="disco")
        self.client.force_login(self.user)
        self.room = Room.objects.create(
            name="Whataburgher", owner=self.user,
            gate_status=Room.OPEN, table_status=Room.ROLE_SELECT,
        )
        # Access is SEAT-based: the poster must occupy a TableSeat, not merely
        # have a filled gate slot.
        TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=1, role="PC")
        self.url = reverse("epic:room_post", args=[self.room.id])

    def test_post_appends_line_and_returns_line_html(self):
        resp = self.client.post(self.url, data={"text": "lets gooo"})
        self.assertEqual(resp.status_code, 200)
        payload = resp.json()
        self.assertTrue(payload["ok"])
        self.assertIn("lets gooo", payload["line_html"])
        self.assertIn("post-line", payload["line_html"])
        post = self.room.get_thread_post()
        self.assertTrue(post.lines.filter(text="lets gooo").exists())

    def test_non_seated_user_forbidden(self):
        outsider = User.objects.create(email="outsider@test.io", username="rando")
        self.client.force_login(outsider)
        resp = self.client.post(self.url, data={"text": "let me in"})
        self.assertEqual(resp.status_code, 403)
        self.assertFalse(self.room.get_thread_post().lines.exists())

    def test_gate_slot_only_depositor_forbidden(self):
        """A token-depositor who filled a gate slot but never took a SEAT can't
        post — they could retract their token + leave, so they must not have
        R/W access to the private chat."""
        transient = User.objects.create(email="pal@test.io", username="pal")
        slot = self.room.gate_slots.get(slot_number=2)
        slot.gamer = transient
        slot.status = GateSlot.FILLED
        slot.save()
        self.client.force_login(transient)
        resp = self.client.post(self.url, data={"text": "let me in"})
        self.assertEqual(resp.status_code, 403)
        self.assertFalse(self.room.get_thread_post().lines.exists())

    def test_duplicate_line_rejected(self):
        self.client.post(self.url, data={"text": "echo"})
        resp = self.client.post(self.url, data={"text": "echo"})
        self.assertEqual(resp.status_code, 400)
        self.assertFalse(resp.json()["ok"])
        self.assertEqual(self.room.get_thread_post().lines.filter(text="echo").count(), 1)

    def test_get_redirects_to_room(self):
        resp = self.client.get(self.url)
        self.assertRedirects(
            resp, reverse("epic:room", args=[self.room.id]),
            fetch_redirect_response=False,
        )


class PickSeaPersistTest(TestCase):
    """Gameroom DRAW SEA persistence — sea_save upserts the Celtic-Cross hand
    onto the seat's confirmed Character.celtic_cross (the gameroom analogue of
    my_sea_lock, but Character-backed: no MySeaDraw quota / Brief machinery).
    sea_delete clears it; the room context exposes saved_by_position +
    saved_sea_spread so a reload re-renders the filled cross."""

    def setUp(self):
        self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
        self.room.table_status = Room.SKY_SELECT
        self.room.save()
        self.pc_seat = TableSeat.objects.get(room=self.room, role="PC")
        self.sig_card = TarotCard.objects.get(
            deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
        )
        self.pc_seat.significator = self.sig_card
        self.pc_seat.save()
        # Confirmed sky → DRAW SEA reachable for this seat.
        self.char = Character.objects.create(
            seat=self.pc_seat,
            significator=self.sig_card,
            chart_data={"planets": {"Sun": {"sign": "Gemini"}}},
            confirmed_at=timezone.now(),
        )
        self.save_url = reverse("epic:sea_save", kwargs={"room_id": self.room.id})
        self.delete_url = reverse("epic:sea_delete", kwargs={"room_id": self.room.id})
        self.room_url = reverse("epic:room", kwargs={"room_id": self.room.id})

    def _hand(self):
        """A 6-card Celtic Cross hand referencing real deck cards."""
        cards = list(
            TarotCard.objects.filter(deck_variant=self.earthman)
            .exclude(id=self.sig_card.id)[:6]
        )
        positions = ["cover", "cross", "crown", "lay", "loom", "leave"]
        return [
            {"position": p, "card_id": c.id, "reversed": False, "polarity": "gravity"}
            for p, c in zip(positions, cards)
        ]

    def test_sea_save_upserts_celtic_cross_onto_confirmed_character(self):
        hand = self._hand()
        resp = self.client.post(
            self.save_url,
            data={"spread": "waite-smith", "hand": hand},
            content_type="application/json",
        )
        self.assertEqual(resp.status_code, 200)
        self.assertTrue(resp.json()["ok"])
        self.char.refresh_from_db()
        self.assertEqual(self.char.celtic_cross["spread"], "waite-smith")
        self.assertEqual(len(self.char.celtic_cross["hand"]), 6)

    def test_sea_save_403_for_non_seated_user(self):
        outsider = User.objects.create(email="outsider@test.io")
        self.client.force_login(outsider)
        resp = self.client.post(
            self.save_url,
            data={"spread": "waite-smith", "hand": self._hand()},
            content_type="application/json",
        )
        self.assertEqual(resp.status_code, 403)

    def test_sea_save_403_when_sky_not_confirmed(self):
        # Drop the confirmed Character → DRAW SEA isn't reachable → 403.
        self.char.delete()
        resp = self.client.post(
            self.save_url,
            data={"spread": "waite-smith", "hand": self._hand()},
            content_type="application/json",
        )
        self.assertEqual(resp.status_code, 403)

    def test_sea_save_400_on_missing_hand(self):
        resp = self.client.post(
            self.save_url,
            data={"spread": "waite-smith"},
            content_type="application/json",
        )
        self.assertEqual(resp.status_code, 400)

    def test_sea_save_405_on_get(self):
        self.assertEqual(self.client.get(self.save_url).status_code, 405)

    def test_sea_delete_clears_celtic_cross(self):
        self.char.celtic_cross = {"spread": "waite-smith", "hand": self._hand()}
        self.char.save(update_fields=["celtic_cross"])
        resp = self.client.post(self.delete_url)
        self.assertEqual(resp.status_code, 200)
        self.char.refresh_from_db()
        self.assertIsNone(self.char.celtic_cross)

    def test_room_context_exposes_saved_by_position_after_save(self):
        hand = self._hand()
        self.char.celtic_cross = {"spread": "waite-smith", "hand": hand}
        self.char.save(update_fields=["celtic_cross"])
        resp = self.client.get(self.room_url)
        self.assertEqual(resp.context["saved_sea_spread"], "waite-smith")
        saved = resp.context["saved_by_position"]
        self.assertIn("cover", saved)
        self.assertEqual(saved["cover"]["card_id"], hand[0]["card_id"])

    # ── Sea Select Scroll-log provenance (affinity / redact / re-publish) ──
    # PC's Role-correlated Celtic position is CROWN (the sixfold map); _hand()
    # places a card at "crown", so the PC gamer's affinity is that card.
    def _post_hand(self, hand=None, spread="waite-smith"):
        return self.client.post(
            self.save_url,
            data={"spread": spread, "hand": self._hand() if hand is None else hand},
            content_type="application/json",
        )

    def _drawn(self, struck=None):
        evs = list(self.room.events.filter(verb=GameEvent.SEA_DRAWN))
        if struck is True:
            return [e for e in evs if e.struck]
        if struck is False:
            return [e for e in evs if not e.struck]
        return evs

    def test_completing_save_publishes_affinity_scroll_log(self):
        hand = self._hand()
        self._post_hand(hand)
        drawn = self._drawn()
        self.assertEqual(len(drawn), 1)
        ev = drawn[0]
        self.assertEqual(ev.data["position"], "crown")   # PC → crown
        crown_id = next(h["card_id"] for h in hand if h["position"] == "crown")
        crown_card = TarotCard.objects.get(id=crown_id)
        self.assertIn(crown_card.name_title, ev.data["card_name"])

    def test_affinity_prose_names_the_waite_smith_position_label(self):
        self._post_hand()
        prose = self._drawn()[0].to_prose()
        self.assertIn("finding affinity with", prose)
        self.assertIn("in the Crown.", prose)

    def test_no_affinity_logged_before_hand_complete(self):
        self._post_hand(self._hand()[:5])   # only 5 of 6 placed
        self.assertEqual(self._drawn(), [])

    def test_reload_resave_does_not_double_publish(self):
        hand = self._hand()
        self._post_hand(hand)
        self._post_hand(hand)               # reload re-POSTs the full 6-card hand
        self.assertEqual(len(self._drawn()), 1)

    def test_del_redacts_affinity_and_leaves_a_relinquishment(self):
        self._post_hand()
        self.client.post(self.delete_url)
        self.assertEqual(len(self._drawn(struck=True)), 1)    # the affinity is struck
        self.assertTrue(
            self.room.events.filter(verb=GameEvent.SEA_RELINQUISHED).exists())

    def test_redraw_redacts_the_relinquishment_and_republishes(self):
        self._post_hand()
        self.client.post(self.delete_url)
        self._post_hand()                   # re-draw completes
        relinquish = self.room.events.filter(verb=GameEvent.SEA_RELINQUISHED).first()
        self.assertTrue(relinquish.struck)  # the standing relinquishment redacted
        self.assertEqual(len(self._drawn(struck=False)), 1)   # one live affinity again

    def test_del_without_a_published_affinity_is_a_noop(self):
        # An incomplete hand never published → DEL records nothing.
        self.char.celtic_cross = {"spread": "waite-smith", "hand": self._hand()[:5]}
        self.char.save(update_fields=["celtic_cross"])
        self.client.post(self.delete_url)
        self.assertEqual(self._drawn(), [])
        self.assertFalse(
            self.room.events.filter(verb=GameEvent.SEA_RELINQUISHED).exists())


class PickSeaUnifiedFeltTest(TestCase):
    """DRAW SEA rebuilt as a --duoUser felt + a Gaussian spread modal, mirroring
    my_sea.html (2026-06-07). The legacy single dark modal (.sea-backdrop +
    .sea-modal-wrap + .sea-modal w. LOCK HAND) is gone; the felt fills the hex
    pane (.sea-page--room) and the modal (#id_sea_spread_modal) carries the
    .sea-select combobox + AUTO DRAW + a mini preview + a corner NVM. The
    combobox offers ONLY the two 6-card Celtic Cross spreads."""

    def setUp(self):
        self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
        self.room.table_status = Room.SKY_SELECT
        self.room.save()
        self.pc_seat = TableSeat.objects.get(room=self.room, role="PC")
        self.sig_card = TarotCard.objects.get(
            deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
        )
        self.pc_seat.significator = self.sig_card
        self.pc_seat.save()
        Character.objects.create(
            seat=self.pc_seat, significator=self.sig_card,
            chart_data={"planets": {"Sun": {"sign": "Gemini"}}},
            confirmed_at=timezone.now(),
        )
        self.url = reverse("epic:room", kwargs={"room_id": self.room.id})

    def test_sea_felt_replaces_dark_modal(self):
        content = self.client.get(self.url).content.decode()
        self.assertIn("has-sea-stage", content)
        self.assertIn("sea-page--room", content)
        # Legacy single-modal chrome is gone.
        self.assertNotIn("sea-backdrop", content)
        self.assertNotIn("sea-modal-wrap", content)

    def test_options_on_felt_no_modal_no_nvm(self):
        """The spread OPTIONS render directly on the felt (.sea-options-col) —
        the combobox + OK + AUTO DRAW + DEL + the mini preview — mirroring Sky
        Select's form-on-felt. The Gaussian modal + corner NVM are gone; OK
        (.btn-confirm) shunts the options down to reveal the cross (user-spec
        2026-06-07)."""
        content = self.client.get(self.url).content.decode()
        self.assertIn("sea-options-col", content)
        self.assertIn("sea-cross-col", content)
        self.assertIn('id="id_sea_confirm_spread"', content)   # OK .btn-confirm
        self.assertIn("sea-cross--preview", content)           # mini preview kept
        self.assertIn('id="id_sea_action_btn"', content)       # AUTO DRAW
        self.assertIn('id="id_sea_del"', content)              # DEL
        # The Gaussian modal + corner NVM are gone.
        self.assertNotIn('id="id_sea_spread_modal"', content)
        self.assertNotIn("my-sea-spread-modal__backdrop", content)
        self.assertNotIn('id="id_sea_cancel"', content)

    def test_auto_draw_replaces_lock_hand(self):
        content = self.client.get(self.url).content.decode()
        self.assertIn('id="id_sea_action_btn"', content)
        self.assertIn("AUTO", content)
        self.assertNotIn("id_sea_lock_hand", content)

    def test_ok_disabled_and_del_enabled_once_a_card_is_drawn(self):
        """Once a card is drawn (saved_by_position), the OK btn gains .btn-disabled
        (the spread is locked) while DEL loses it — inverse states, simultaneous
        with the select locking (user-spec 2026-06-07). Fresh (no draw): OK is
        enabled, DEL disabled."""
        import lxml.html
        # Fresh — OK enabled, DEL disabled.
        parsed = lxml.html.fromstring(self.client.get(self.url).content)
        self.assertNotIn("btn-disabled", parsed.cssselect("#id_sea_confirm_spread")[0].get("class", ""))
        self.assertIn("btn-disabled", parsed.cssselect("#id_sea_del")[0].get("class", ""))
        # One card drawn — flips.
        card_id = TarotCard.objects.filter(deck_variant=self.earthman).first().id
        char = Character.objects.get(seat=self.pc_seat, confirmed_at__isnull=False)
        char.celtic_cross = {"spread": "waite-smith", "hand": [
            {"position": "cover", "card_id": card_id, "reversed": False, "polarity": "gravity"}]}
        char.save(update_fields=["celtic_cross"])
        parsed = lxml.html.fromstring(self.client.get(self.url).content)
        self.assertIn("btn-disabled", parsed.cssselect("#id_sea_confirm_spread")[0].get("class", ""))
        self.assertNotIn("btn-disabled", parsed.cssselect("#id_sea_del")[0].get("class", ""))

    def test_only_two_celtic_cross_spread_options(self):
        content = self.client.get(self.url).content.decode()
        self.assertIn('data-value="waite-smith"', content)
        self.assertIn('data-value="escape-velocity"', content)
        # No 3-card spreads in the gameroom (user-spec 2026-06-07).
        self.assertNotIn('data-value="past-present-future"', content)
        self.assertNotIn('data-value="situation-action-outcome"', content)

    def test_room_gear_has_sea_nvm_pane(self):
        content = self.client.get(self.url).content.decode()
        self.assertIn("room-menu-sea", content)

    def test_sea_stage_renders_back_img_from_seat_deck(self):
        """The sea-stage FLIP reveals the card back. The back-img is sourced from
        the SEAT's contributed deck (NOT request.user.equipped_deck, which
        select_role nulls out → the back silently never rendered + FLIP no-op'd)
        and renders for any image-equipped deck w. a back (user-flagged
        2026-06-07)."""
        minch = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
        self.pc_seat.deck_variant = minch
        self.pc_seat.save(update_fields=["deck_variant"])
        content = self.client.get(self.url).content.decode()
        self.assertIn("sig-stage-card-back-img", content)

    def test_sea_stage_no_back_img_for_text_seat_deck(self):
        """Earthman (text-mode, no card images) → no back-img element (FLIP is a
        no-op for text decks, by design)."""
        content = self.client.get(self.url).content.decode()
        self.assertNotIn("sig-stage-card-back-img", content)

    def test_center_sig_renders_image_for_image_deck_sig(self):
        """The spread's CENTER significator supplies the card-face image when its
        deck has one (mirrors my_sea); the tray sig stays a simple thumbnail. The
        sig's deck_variant is the card's OWN deck — the Sig Select pick comes from
        the Role Select contributed deck, so this is the right source (no
        equipped_deck bug, unlike the FLIP back-img). User-spec 2026-06-07."""
        minch = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
        il_matto = TarotCard.objects.filter(deck_variant=minch).first()
        self.pc_seat.significator = il_matto
        self.pc_seat.save(update_fields=["significator"])
        # my_tray_sig = confirmed_char.significator OR seat.significator → keep both.
        char = Character.objects.get(seat=self.pc_seat, confirmed_at__isnull=False)
        char.significator = il_matto
        char.save(update_fields=["significator"])
        content = self.client.get(self.url).content.decode()
        # Image-mode marker is unique to the felt cross center (the tray + preview
        # sigs stay text-only).
        self.assertIn("sea-sig-card sig-stage-card--image", content)

    def test_always_two_deck_stacks_gravity_and_levity(self):
        """Unlike my_sea / Sig Select, the room Sea Select ALWAYS shows BOTH the
        Gravity + Levity stacks — the gamer draws from either populated half
        (even a CARTE monodeck split across the two), so the polarization-
        conditional single-stack collapse must NOT apply here (user-spec
        2026-06-07)."""
        content = self.client.get(self.url).content.decode()
        self.assertIn("sea-deck-stack--gravity", content)
        self.assertIn("sea-deck-stack--levity", content)
        self.assertNotIn("sea-deck-stack--single", content)
        self.assertNotIn("sea-stacks--single", content)

    def test_seed_map_btn_out_until_hand_complete(self):
        """SEED MAP joins the hex phase stack (the post-completion cascade eases
        it IN, mirroring CAST SKY → DRAW SEA). Pre-completion it renders --out
        while DRAW SEA is in (user-spec 2026-06-07)."""
        import lxml.html
        parsed = lxml.html.fromstring(self.client.get(self.url).content)
        [seed] = parsed.cssselect("#id_seed_map_btn")
        self.assertIn("hex-phase-btn--out", seed.get("class", ""))
        [sea] = parsed.cssselect("#id_pick_sea_btn")
        self.assertNotIn("hex-phase-btn--out", sea.get("class", ""))

    def test_seed_map_in_and_draw_sea_out_when_hand_complete(self):
        """A reload of an already-complete sea lands on SEED MAP server-side:
        SEED MAP in, DRAW SEA --out (the cascade's end-state, no animation)."""
        card_id = TarotCard.objects.filter(deck_variant=self.earthman).first().id
        char = Character.objects.get(seat=self.pc_seat, confirmed_at__isnull=False)
        char.celtic_cross = {"spread": "waite-smith", "hand": [
            {"position": p, "card_id": card_id, "reversed": False, "polarity": "gravity"}
            for p in ["cover", "cross", "crown", "lay", "loom", "leave"]]}
        char.save(update_fields=["celtic_cross"])
        import lxml.html
        parsed = lxml.html.fromstring(self.client.get(self.url).content)
        [seed] = parsed.cssselect("#id_seed_map_btn")
        self.assertNotIn("hex-phase-btn--out", seed.get("class", ""))
        [sea] = parsed.cssselect("#id_pick_sea_btn")
        self.assertIn("hex-phase-btn--out", sea.get("class", ""))

    def test_sea_felt_leaves_sky_btn_lit_and_swaps_cleanly(self):
        """Symmetric with Sky Select (which leaves the sea btn lit while the sky
        felt is up): opening the Sea Select felt must NOT grey the burger Sky
        sub-btn — it stays .active so a revisited Sea Select keeps the completed
        sky one click away (user-spec 2026-06-08). openSea therefore disables
        ONLY id_text_btn (not id_sky_btn). And because both felts are z-5 siblings
        that otherwise STACK (sea paints over sky; neither open path drops the
        other's open-class), clicking the lit sky btn must SWAP felts: openSea
        closes the sky felt + openSky closes the sea felt (both expose their close
        on window). Regression guard for the symmetry + clean-swap fix."""
        content = self.client.get(self.url).content.decode()
        # openSea disables only the Text btn now — the old 2-btn list is gone.
        self.assertIn("['id_text_btn']", content)
        self.assertNotIn("'id_text_btn', 'id_sky_btn'", content)
        # Clean felt-swap wiring both ways (sea exposes + sky calls it; sky
        # exposes + sea calls it).
        self.assertIn("window.closeSeaFelt", content)
        self.assertIn("window.closeSkyFelt", content)

    def test_sea_btn_and_glow_bindings_defer_past_parse_time(self):
        """room.html includes _sea_overlay.html (~line 77) BEFORE _burger.html
        (~line 167, which holds #id_burger_btn + #id_sea_btn). So the overlay's
        inline scripts must NOT bind those btns at parse time — they don't exist
        yet, and a parse-time `getElementById` returns null → the sea-btn REOPEN
        binding + the burger→sea_btn GLOW handoff both silently no-op (sea btn
        dead, burger stuck --priId until refresh). Both bindings must defer to
        DOMContentLoaded. Regression guard for the 2026-06-08 include-order fix."""
        content = self.client.get(self.url).content.decode()
        # Both deferred binders present + gated on DOMContentLoaded.
        self.assertIn("_bindSeaReopen", content)
        self.assertIn("_bindGlowHandoff", content)
        self.assertIn("DOMContentLoaded", content)


class CarteSeatSwitchSkySeaTest(TestCase):
    """A CARTE owner (one gamer owns all 6 seats) must drive EACH seat through
    CAST SKY + DRAW SEA independently. The sky/sea state is per-SEAT
    (Character.seat), so ?seat=N must switch the DISPLAYED + SAVED Character —
    the bug stuck everything on the canonical (PC) seat, so the tray switched but
    the sky wheel / sea spread did not, and saves wrote back to the PC seat
    (user-flagged 2026-06-07)."""

    def setUp(self):
        self.earthman, _ = DeckVariant.objects.get_or_create(
            slug="earthman",
            defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
        )
        self.viewer = User.objects.create(email="disco@test.io", username="disco")
        self.room = Room.objects.create(name="Carte Sky", owner=self.viewer)
        sig = TarotCard.objects.filter(deck_variant=self.earthman, arcana="MAJOR").first()
        self.seats = {}
        for i, role in enumerate(["PC", "NC", "EC", "SC", "AC", "BC"], start=1):
            slot = self.room.gate_slots.get(slot_number=i)
            slot.gamer = self.viewer
            slot.status = GateSlot.FILLED
            slot.save()
            self.seats[i] = TableSeat.objects.create(
                room=self.room, gamer=self.viewer, slot_number=i, role=role,
                role_revealed=True, deck_variant=self.earthman, significator=sig,
            )
        self.room.gate_status = Room.OPEN
        self.room.table_status = Room.SKY_SELECT
        self.room.save()
        self.client.force_login(self.viewer)
        # Slot 1 (PC = canonical) — sky confirmed WITH a sea hand.
        Character.objects.create(
            seat=self.seats[1], significator=sig,
            chart_data={"planets": {}}, confirmed_at=timezone.now(),
            celtic_cross={"spread": "waite-smith", "hand": self._hand()},
        )
        # Slot 3 (EC) — sky confirmed, NO sea hand yet.
        Character.objects.create(
            seat=self.seats[3], significator=sig,
            chart_data={"planets": {}}, confirmed_at=timezone.now(),
        )
        self.room_url = reverse("epic:room", kwargs={"room_id": self.room.id})

    def _hand(self):
        cards = list(TarotCard.objects.filter(deck_variant=self.earthman)[:6])
        positions = ["cover", "cross", "crown", "lay", "loom", "leave"]
        return [{"position": p, "card_id": c.id, "reversed": False, "polarity": "gravity"}
                for p, c in zip(positions, cards)]

    def test_seat_param_switches_displayed_spread(self):
        # ?seat=1 (PC) → its saved hand; ?seat=3 (EC) → empty. The bug showed
        # seat 1's hand for BOTH (the context stuck on the canonical PC seat).
        r1 = self.client.get(self.room_url + "?seat=1")
        self.assertTrue(r1.context["saved_by_position"])
        self.assertEqual(r1.context["saved_sea_spread"], "waite-smith")
        r3 = self.client.get(self.room_url + "?seat=3")
        self.assertEqual(r3.context["saved_by_position"], {})
        self.assertEqual(r3.context["saved_sea_spread"], "")

    def test_sea_save_targets_switched_seat_not_canonical(self):
        save_url = reverse("epic:sea_save", kwargs={"room_id": self.room.id})
        resp = self.client.post(
            save_url + "?seat=3",
            data={"spread": "escape-velocity", "hand": self._hand()},
            content_type="application/json",
        )
        self.assertEqual(resp.status_code, 200)
        # Seat 3 got the new hand; seat 1's (canonical) hand is untouched.
        self.assertEqual(
            Character.objects.get(seat=self.seats[3]).celtic_cross["spread"],
            "escape-velocity",
        )
        self.assertEqual(
            Character.objects.get(seat=self.seats[1]).celtic_cross["spread"],
            "waite-smith",
        )

    def test_sky_save_confirms_switched_seat_independently(self):
        # Slot 5 (AC) — no Character yet. sky_save?seat=5 confirms ITS Character.
        save_url = reverse("epic:sky_save", kwargs={"room_id": self.room.id})
        resp = self.client.post(
            save_url + "?seat=5",
            data={"chart_data": {"planets": {}}, "action": "confirm"},
            content_type="application/json",
        )
        self.assertEqual(resp.status_code, 200)
        self.assertTrue(Character.objects.filter(
            seat=self.seats[5], confirmed_at__isnull=False).exists())
        # The PC seat's Character is NOT touched (still its own confirmed row).
        self.assertEqual(
            Character.objects.filter(seat=self.seats[1], confirmed_at__isnull=False).count(),
            1,
        )

    def test_felt_action_urls_carry_seat_param(self):
        content = self.client.get(self.room_url + "?seat=3").content.decode()
        self.assertIn("/sea/save?seat=3", content)
        self.assertIn("/sky/save?seat=3", content)


class RoomVoiceActivationTest(TestCase):
    """Voice activation for the epic game room. Voice mirrors my_sea's window but
    over the seat's 7d INITIAL cost period (GateSlot.cost_current, NOT the renewal
    grace): live only when the gate is CLOSED (table_status set), the viewer is
    seated, AND MORE THAN ONE distinct gamer holds a token-backed, cost-current
    seat — a sole depositor (incl. a CARTE owner occupying all 6 seats = one
    gamer) has no one to talk to, so voice stays off. The burger fan's
    #id_voice_btn (shared `_burger.html`) lights `.active` + carries `data-room-id`
    = the room UUID, consumed by the already-room-aware RoomVoiceConsumer +
    voice-mesh.js. setUp = 6 distinct depositors (filled_at None → cost-current)."""

    def setUp(self):
        self.founder = User.objects.create(email="vfounder@test.io")
        self.room = Room.objects.create(name="Voice Room", owner=self.founder)
        self.gamers = [self.founder]
        for i in range(2, 7):
            self.gamers.append(User.objects.create(email=f"vg{i}@test.io"))
        for i, gamer in enumerate(self.gamers, start=1):
            slot = self.room.gate_slots.get(slot_number=i)
            slot.gamer = gamer
            slot.status = GateSlot.FILLED
            slot.save()
        self.room.gate_status = Room.OPEN
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        for i, gamer in enumerate(self.gamers, start=1):
            TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
        self.url = reverse("epic:room", kwargs={"room_id": self.room.id})

    def _ctx(self, gamer):
        self.client.force_login(gamer)
        return self.client.get(self.url).context

    def test_voice_active_for_seated_gamer_in_role_select(self):
        self.assertIs(self._ctx(self.founder)["voice_active"], True)

    def test_voice_room_id_is_room_uuid(self):
        self.assertEqual(self._ctx(self.founder)["voice_room_id"], str(self.room.id))

    def test_voice_active_persists_through_sig_select(self):
        self.room.table_status = Room.SIG_SELECT
        self.room.save()
        self.assertIs(self._ctx(self.founder)["voice_active"], True)

    def test_voice_active_persists_through_sky_select(self):
        # SKY_SELECT hosts the in-page DRAW SEA felt — voice spans it too.
        self.room.table_status = Room.SKY_SELECT
        self.room.save()
        self.assertIs(self._ctx(self.founder)["voice_active"], True)

    def test_voice_persists_in_game_with_multiple_depositors(self):
        # The 7d window spans gameplay — voice stays live in IN_GAME as long as
        # >1 cost-current depositor remains (no phase ceiling at SEA_SELECT).
        self.room.table_status = Room.IN_GAME
        self.room.save()
        self.assertIs(self._ctx(self.founder)["voice_active"], True)

    def test_voice_inactive_for_non_seated_viewer(self):
        outsider = User.objects.create(email="outsider@test.io")
        self.assertIs(self._ctx(outsider)["voice_active"], False)

    def test_voice_inactive_with_a_single_depositor(self):
        # CARTE owner of all 6 seats = ONE depositor → no one to talk to.
        self.room.gate_slots.filter(status=GateSlot.FILLED).update(gamer=self.founder)
        self.assertIs(self._ctx(self.founder)["voice_active"], False)

    def test_voice_flips_on_once_a_second_gamer_qualifies(self):
        # Collapse to one depositor (off), then hand a slot to a 2nd gamer (on).
        self.room.gate_slots.filter(status=GateSlot.FILLED).update(gamer=self.founder)
        self.assertIs(self._ctx(self.founder)["voice_active"], False)
        self.room.gate_slots.filter(slot_number=2).update(gamer=self.gamers[1])
        self.assertIs(self._ctx(self.founder)["voice_active"], True)

    def test_voice_inactive_when_cost_period_lapsed_into_grace(self):
        # Filled 8d ago → past the 7d initial period (renewal grace), so the
        # seats aren't cost-current → no active depositors → voice off.
        self.room.gate_slots.filter(status=GateSlot.FILLED).update(
            filled_at=timezone.now() - timedelta(days=8))
        self.assertIs(self._ctx(self.founder)["voice_active"], False)

    def test_voice_inactive_before_the_gate_closes(self):
        # Still gathering (table_status None) → voice off even with depositors.
        self.room.table_status = None
        self.room.save()
        self.assertIs(self._ctx(self.founder)["voice_active"], False)

    def test_voice_muted_at_empty_when_unset(self):
        self.assertEqual(self._ctx(self.founder)["voice_muted_at"], "")

    def test_voice_muted_at_passed_when_set(self):
        self.founder.voice_muted_at = timezone.now()
        self.founder.save()
        ctx = self._ctx(self.founder)
        self.assertEqual(ctx["voice_muted_at"], self.founder.voice_muted_at.isoformat())

    def test_voice_btn_renders_active_with_room_id(self):
        # The burger fan's voice sub-btn lights .active + carries the room UUID
        # (the WebRTC mesh key). NB: .room-page already prints data-room-id, so
        # assert the voice-btn's own active markup, not the bare attribute.
        self.client.force_login(self.founder)
        content = self.client.get(self.url).content.decode()
        self.assertIn('class="burger-fan-btn active" aria-label="Voice"', content)
        self.assertIn(
            f'aria-label="Voice" data-room-id="{self.room.id}"', content)
