"""ITs for the my-sea spectator (invitee) views — Phase B of
[[my-sea-invite-voice-blueprint]].

An ACCEPTED invitee can VISIT the owner's my-sea as a read-only spectator,
deposit a token at a visitor gate to occupy seat 2C (+ open a 24h voice
window), and BYE out (freeing the seat). All access is gated on an ACCEPTED
SeaInvite for (owner, request.user).
"""

from datetime import timedelta

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

from apps.gameboard.models import MySeaDraw, SeaInvite
from apps.lyric.models import Token, User


def _owner_with_sig(email="owner@test.io", username="discoman"):
    owner = User.objects.create(email=email, username=username)
    # A sig id is required to build a MySeaDraw; any int is fine for the
    # spectator read-only path (no card lookup asserted here).
    return owner


class MySeaVisitGuardTest(TestCase):
    def setUp(self):
        self.owner = _owner_with_sig()
        self.bud = User.objects.create(email="bud@test.io", username="budster")
        self.invite = SeaInvite.objects.create(
            owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
            status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
        )
        self.url = reverse("my_sea_visit", args=[self.owner.id])

    def test_accepted_invitee_can_visit(self):
        self.client.force_login(self.bud)
        self.assertEqual(self.client.get(self.url).status_code, 200)

    def test_non_invitee_is_forbidden(self):
        stranger = User.objects.create(email="x@test.io", username="x")
        self.client.force_login(stranger)
        self.assertEqual(self.client.get(self.url).status_code, 403)

    def test_pending_invitee_is_auto_accepted_on_visit(self):
        # Accept-on-GET (user-spec 2026-05-29): following the bud-page sea-btn
        # cascade — or the @mailman post-attribution anchor — to the spectator
        # table implicitly accepts a still-pending invite, so the page renders
        # instead of 403ing the legitimately-invited user.
        self.invite.status = SeaInvite.PENDING
        self.invite.accepted_at = None
        self.invite.save()
        self.client.force_login(self.bud)
        self.assertEqual(self.client.get(self.url).status_code, 200)
        self.invite.refresh_from_db()
        self.assertEqual(self.invite.status, SeaInvite.ACCEPTED)
        self.assertIsNotNone(self.invite.accepted_at)

    def test_expired_pending_invitee_is_forbidden(self):
        # A lapsed PENDING invite (no deposit, past 24h) is NOT auto-accepted.
        self.invite.status = SeaInvite.PENDING
        self.invite.accepted_at = None
        self.invite.save()
        SeaInvite.objects.filter(pk=self.invite.pk).update(
            created_at=timezone.now() - timedelta(hours=48),
        )
        self.client.force_login(self.bud)
        self.assertEqual(self.client.get(self.url).status_code, 403)

    def test_left_invitee_is_forbidden(self):
        self.invite.status = SeaInvite.LEFT
        self.invite.left_at = timezone.now()
        self.invite.save()
        self.client.force_login(self.bud)
        self.assertEqual(self.client.get(self.url).status_code, 403)

    def test_owner_visiting_own_sea_redirects_to_my_sea(self):
        self.client.force_login(self.owner)
        resp = self.client.get(self.url)
        self.assertRedirects(resp, reverse("my_sea"), fetch_redirect_response=False)


class MySeaVisitContextTest(TestCase):
    def setUp(self):
        self.owner = _owner_with_sig()
        self.bud = User.objects.create(email="bud@test.io", username="budster")
        self.invite = SeaInvite.objects.create(
            owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
            status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
        )
        # Owner has drawn a hand → seat 1C is occupied + cards exist to view.
        MySeaDraw.objects.create(
            user=self.owner, spread="situation-action-outcome",
            significator_id=1, hand=[
                {"position": "lay", "card_id": 1, "reversed": False,
                 "polarity": "gravity"},
            ],
        )
        self.client.force_login(self.bud)
        self.url = reverse("my_sea_visit", args=[self.owner.id])

    def test_spectator_context_flags(self):
        ctx = self.client.get(self.url).context
        self.assertTrue(ctx["spectator"])
        self.assertFalse(ctx["is_owner"])
        self.assertEqual(ctx["owner"], self.owner)
        self.assertEqual(ctx["sea_invite"], self.invite)
        # Owner controls forced off on the spectator surface.
        self.assertFalse(ctx["sea_btn_active"])

    def test_not_present_shows_gate_view(self):
        content = self.client.get(self.url).content.decode()
        self.assertIn("GATE", content)
        self.assertIn(reverse("my_sea_visit_gate", args=[self.owner.id]), content)

    def test_present_shows_view_draw_and_seat2(self):
        self.invite.token_deposited_at = timezone.now()
        self.invite.voice_until = timezone.now() + timedelta(hours=24)
        self.invite.save()
        ctx = self.client.get(self.url).context
        self.assertTrue(ctx["sea_invite"].is_present)
        self.assertTrue(ctx["seat2_present"])
        self.assertIn("VIEW", self.client.get(self.url).content.decode())

    def test_present_renders_owner_draw_as_interactive_cross_stage(self):
        """Phase 1 (2026-05-29): the visitor's VIEW DRAW renders the IDENTICAL
        `.my-sea-cross` picker + `_sea_stage` the owner sees (click→stage,
        hover) off the owner's draw — NOT the old flat `.my-sea-scroll` strip
        that blew one card up to fill the viewport."""
        self.invite.token_deposited_at = timezone.now()
        self.invite.voice_until = timezone.now() + timedelta(hours=24)
        self.invite.save()
        resp = self.client.get(self.url)
        ctx = resp.context
        # Cross-stage parity context is present + sourced from the owner.
        self.assertEqual(ctx["default_spread"], "situation-action-outcome")
        self.assertIn("lay", ctx["saved_by_position"])
        self.assertEqual(ctx["saved_by_position"]["lay"]["card_id"], 1)
        self.assertIn("lay", ctx["label_by_position"])
        content = resp.content.decode()
        # The interactive cross + stage modal render; the old scroll is gone.
        self.assertIn('id="id_sea_overlay"', content)
        self.assertIn("sea-cross", content)
        self.assertIn('id="id_sea_stage"', content)
        self.assertIn('id="id_my_sea_deck"', content)        # deck JSON for stage
        self.assertIn('data-card-id="1"', content)           # owner's drawn card slot
        self.assertNotIn("my-sea-scroll", content)

    def test_present_renders_owner_deck_stack_top_left_flip_disabled(self):
        """The deck stack mirrors the OWNER's deck (everyone plays the owner's
        deck), pinned top-left (--visit) with FLIP always disabled (read-only
        spectator can't deal). A polarized owner deck → a dubbodeck."""
        from apps.epic.models import DeckVariant
        deck = DeckVariant.objects.filter(is_polarized=True).first()
        self.assertIsNotNone(deck, "expected a polarized (dubbo) deck seeded")
        self.owner.equipped_deck = deck      # the OWNER's deck, not the viewer's
        self.owner.save(update_fields=["equipped_deck"])
        self.invite.token_deposited_at = timezone.now()
        self.invite.voice_until = timezone.now() + timedelta(hours=24)
        self.invite.save()
        html = self.client.get(self.url).content.decode()
        self.assertIn("my-sea-stacks-wrap--visit", html)
        # Dubbodeck → two named stacks.
        self.assertIn("sea-deck-stack--gravity", html)
        self.assertIn("sea-deck-stack--levity", html)
        # FLIP is the disabled (×) state; never an enabled FLIP for a visitor.
        self.assertIn("sea-stack-ok btn-disabled", html)
        self.assertNotIn(
            'class="btn btn-reveal sea-stack-ok" type="button">FLIP', html)

    def test_stack_keys_on_owner_deck_not_viewer_deck(self):
        # Viewer has no deck, owner has one → the stack still renders (it's the
        # owner's deck in play).
        from apps.epic.models import DeckVariant
        self.bud.equipped_deck = None
        self.bud.save(update_fields=["equipped_deck"])
        self.owner.equipped_deck = DeckVariant.objects.first()
        self.owner.save(update_fields=["equipped_deck"])
        self.invite.token_deposited_at = timezone.now()
        self.invite.voice_until = timezone.now() + timedelta(hours=24)
        self.invite.save()
        html = self.client.get(self.url).content.decode()
        self.assertIn("my-sea-stacks-wrap--visit", html)

    def test_no_stack_when_owner_has_no_deck(self):
        self.owner.equipped_deck = None
        self.owner.save(update_fields=["equipped_deck"])
        self.invite.token_deposited_at = timezone.now()
        self.invite.voice_until = timezone.now() + timedelta(hours=24)
        self.invite.save()
        html = self.client.get(self.url).content.decode()
        self.assertNotIn("my-sea-stacks-wrap--visit", html)

    def test_not_present_does_not_render_cross_or_deck_json(self):
        # Before deposit (gate view) there's no draw surface to seed.
        content = self.client.get(self.url).content.decode()
        self.assertNotIn('id="id_sea_overlay"', content)
        self.assertNotIn('id="id_my_sea_deck"', content)

    def test_both_seats_render_seated_simultaneously(self):
        # Phase 2 (2026-05-29): with the owner drawn (1C) AND the visitor
        # deposited (2C), BOTH chairs render `.seated` + `.fa-circle-check`
        # at once — neither reverts to the semi-opaque .fa-ban default.
        self.invite.token_deposited_at = timezone.now()
        self.invite.voice_until = timezone.now() + timedelta(hours=24)
        self.invite.save()
        ctx = self.client.get(self.url).context
        self.assertTrue(ctx["seat1_present"])   # owner drawn
        self.assertTrue(ctx["seat2_present"])   # visitor present
        html = self.client.get(self.url).content.decode()
        self.assertIn('class="table-seat seated" data-slot="1"', html)        # owner
        # The viewer's own seat (2C) carries the --self marker.
        self.assertIn('table-seat--self" data-slot="2"', html)
        self.assertEqual(
            html.count('class="position-status-icon fa-solid fa-circle-check"'), 2)


class MySeaVisitEmptyHandLabelsTest(TestCase):
    """Regression (2026-05-30): the spread captions (CROWN/COVER/…) must show
    on the spectator cross even before the owner has drawn a single card —
    they key off the SPREAD, not the drawn hand. Previously `label_by_position`
    was built from `latest_draw_slots`, which returns [] for an empty hand, so
    an owner who'd placed only a significator left every caption blank while her
    OWN my_sea (whose JS seeds labels from the POSITION_LABELS constant) showed
    them. Now the view pulls captions straight from POSITION_LABELS[spread]."""

    def setUp(self):
        self.owner = _owner_with_sig()
        self.bud = User.objects.create(email="bud@test.io", username="budster")
        self.invite = SeaInvite.objects.create(
            owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
            status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
            token_deposited_at=timezone.now(),
            voice_until=timezone.now() + timedelta(hours=24),
        )
        # Owner has chosen a spread + placed a significator but drawn NO cards.
        MySeaDraw.objects.create(
            user=self.owner, spread="escape-velocity",
            significator_id=1, hand=[],
        )
        self.client.force_login(self.bud)
        self.url = reverse("my_sea_visit", args=[self.owner.id])

    def test_captions_present_for_empty_hand(self):
        from apps.gameboard.models import POSITION_LABELS
        ctx = self.client.get(self.url).context
        self.assertEqual(ctx["default_spread"], "escape-velocity")
        # All six escape-velocity captions present despite the empty hand.
        self.assertEqual(
            ctx["label_by_position"], POSITION_LABELS["escape-velocity"])

    def test_caption_text_rendered_in_cross(self):
        html = self.client.get(self.url).content.decode()
        for caption in ("Crown", "Leave", "Cover", "Cross", "Loom", "Lay"):
            self.assertIn(
                f'data-position="{caption.lower()}">{caption}</span>', html)


class MySeaVisitNavTest(TestCase):
    """NVM + GATE VIEW routing on the spectator surface (user-spec 2026-05-30):
    the navbar GATE VIEW opens THIS owner's visitor gate (not the viewer's own);
    the gear NVM is phase-aware (mySeaVisitNvm) — table hex → the bud's page,
    draw phase → back to the hex; the leave-the-sea guard confirms with OK."""

    def setUp(self):
        self.owner = _owner_with_sig()
        self.bud = User.objects.create(email="bud@test.io", username="budster")
        SeaInvite.objects.create(
            owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
            status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
            token_deposited_at=timezone.now(),
            voice_until=timezone.now() + timedelta(hours=24),
        )
        self.client.force_login(self.bud)
        self.html = self.client.get(
            reverse("my_sea_visit", args=[self.owner.id])).content.decode()

    def test_navbar_gate_view_targets_the_visit_gate_not_own(self):
        visit_gate = reverse("my_sea_visit_gate", args=[self.owner.id])
        self.assertIn(f"window.location.href='{visit_gate}'", self.html)
        # The viewer's OWN sea gate must NOT be the navbar target here.
        self.assertNotIn(
            f"window.location.href='{reverse('my_sea_gate')}'", self.html)

    def test_gear_nvm_uses_the_phase_aware_visit_handler(self):
        self.assertIn('onclick="mySeaVisitNvm(event)"', self.html)

    def test_visit_nvm_leaves_to_the_bud_page_from_the_hex(self):
        bud_url = reverse("billboard:bud_page", args=[self.owner.id])
        self.assertIn(f"BUD_URL = '{bud_url}'", self.html)

    def test_leave_guard_confirms_with_OK(self):
        self.assertIn("yesLabel: 'OK'", self.html)
        self.assertNotIn("yesLabel: 'NVM'", self.html)


class MySeaOwnerNavbarGateUnaffectedTest(TestCase):
    """Regression — the OWNER's own my_sea navbar GATE VIEW still routes to the
    user's own gate (the visit-gate branch must not leak onto owner pages)."""

    def setUp(self):
        from apps.epic.models import personal_sig_cards
        self.user = User.objects.create(email="owner@test.io", username="discoself")
        self.user.significator = personal_sig_cards(self.user)[0]
        self.user.save(update_fields=["significator"])
        self.client.force_login(self.user)

    def test_owner_navbar_gate_targets_own_gate(self):
        html = self.client.get(reverse("my_sea")).content.decode()
        self.assertIn(
            f"window.location.href='{reverse('my_sea_gate')}'", html)


class MySeaVisitOwnerSeatedTest(TestCase):
    """Phase 2 (window rule 2026-05-31) — the owner shows seated in 1C on the
    spectator hex for the FULL 24h window after her most recent free/paid
    draw, i.e. as long as `active_draw_for` returns a row. Seating tracks the
    row's existence, not the hand/deposit/paid state — so a DEL'd row keeps
    the owner seated on the visitor's view too."""

    def setUp(self):
        self.owner = _owner_with_sig()
        self.bud = User.objects.create(email="bud@test.io", username="budster")
        self.invite = SeaInvite.objects.create(
            owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
            status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
        )
        self.client.force_login(self.bud)
        self.url = reverse("my_sea_visit", args=[self.owner.id])

    def test_owner_seated_when_paid_with_empty_hand(self):
        MySeaDraw.objects.create(
            user=self.owner, spread="situation-action-outcome",
            significator_id=1, hand=[], deposit_token_id=99,
            deposit_reserved_at=timezone.now(),
        )
        self.assertTrue(self.client.get(self.url).context["seat1_present"])

    def test_owner_seated_with_empty_hand_no_payment(self):
        # DEL'd row (empty hand, no deposit/paid) — still seated for the 24h
        # window on the spectator's view (user-spec 2026-05-31).
        MySeaDraw.objects.create(
            user=self.owner, spread="situation-action-outcome",
            significator_id=1, hand=[],
        )
        self.assertTrue(self.client.get(self.url).context["seat1_present"])

    def test_owner_not_seated_without_draw_or_payment(self):
        self.assertFalse(self.client.get(self.url).context["seat1_present"])


class MySeaVisitInsertTokenTest(TestCase):
    def setUp(self):
        self.owner = _owner_with_sig()
        self.bud = User.objects.create(email="bud@test.io", username="budster")
        self.invite = SeaInvite.objects.create(
            owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
            status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
        )
        self.url = reverse("my_sea_visit_insert_token", args=[self.owner.id])

    def test_deposit_marks_present_and_opens_voice_window(self):
        self.client.force_login(self.bud)
        resp = self.client.post(self.url)
        self.invite.refresh_from_db()
        self.assertIsNotNone(self.invite.token_deposited_at)
        self.assertIsNotNone(self.invite.voice_until)
        self.assertTrue(self.invite.voice_active)
        self.assertTrue(self.invite.is_present)
        self.assertRedirects(
            resp, reverse("my_sea_visit", args=[self.owner.id]),
            fetch_redirect_response=False,
        )

    def test_non_invitee_cannot_deposit(self):
        stranger = User.objects.create(email="x@test.io", username="x")
        self.client.force_login(stranger)
        self.assertEqual(self.client.post(self.url).status_code, 403)

    def test_no_token_does_not_mark_present(self):
        # Strip the visitor of every usable token.
        self.bud.equipped_trinket = None
        self.bud.save(update_fields=["equipped_trinket"])
        Token.objects.filter(user=self.bud).delete()
        self.client.force_login(self.bud)
        self.client.post(self.url)
        self.invite.refresh_from_db()
        self.assertIsNone(self.invite.token_deposited_at)
        self.assertFalse(self.invite.is_present)

    def test_deposit_broadcasts_the_seat_ring(self):
        from unittest.mock import patch
        self.client.force_login(self.bud)
        with patch("apps.gameboard.views._notify_sea_seats") as mock:
            self.client.post(self.url)
        mock.assert_called_once_with(self.owner.id)


class MySeaVisitLeaveTest(TestCase):
    def setUp(self):
        self.owner = _owner_with_sig()
        self.bud = User.objects.create(email="bud@test.io", username="budster")
        self.invite = SeaInvite.objects.create(
            owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
            status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
            token_deposited_at=timezone.now(),
            voice_until=timezone.now() + timedelta(hours=24),
        )
        self.url = reverse("my_sea_visit_leave", args=[self.owner.id])

    def test_leave_sets_left_and_kills_voice(self):
        self.client.force_login(self.bud)
        resp = self.client.post(self.url)
        self.invite.refresh_from_db()
        self.assertEqual(self.invite.status, SeaInvite.LEFT)
        self.assertIsNotNone(self.invite.left_at)
        self.assertIsNone(self.invite.voice_until)
        self.assertFalse(self.invite.is_present)
        self.assertRedirects(
            resp, reverse("gameboard"), fetch_redirect_response=False,
        )

    def test_leave_broadcasts_the_seat_ring(self):
        from unittest.mock import patch
        self.client.force_login(self.bud)
        with patch("apps.gameboard.views._notify_sea_seats") as mock:
            self.client.post(self.url)
        mock.assert_called_once_with(self.owner.id)

    def test_non_invitee_cannot_leave(self):
        stranger = User.objects.create(email="x@test.io", username="x")
        self.client.force_login(stranger)
        self.assertEqual(self.client.post(self.url).status_code, 403)


class MySeaVisitCapacityTest(TestCase):
    """Phase 5 (2026-05-29) — at most 5 visitors seat at a bud's my-sea
    (owner 1C + 2C–6C). A full table bounces a fresh deposit; a visitor who
    leaves frees their seat for someone else."""

    def setUp(self):
        from apps.gameboard.models import MY_SEA_MAX_VISITORS
        self.MAX = MY_SEA_MAX_VISITORS
        self.owner = _owner_with_sig()

    def _accepted_invitee(self, n, present=False):
        u = User.objects.create(email=f"v{n}@test.io", username=f"v{n}")
        inv = SeaInvite.objects.create(
            owner=self.owner, invitee=u, invitee_email=u.email,
            status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
        )
        if present:
            inv.token_deposited_at = timezone.now()
            inv.voice_until = timezone.now() + timedelta(hours=24)
            inv.save()
        return u, inv

    def _fill_table(self):
        # MAX present visitors → the table is full.
        for n in range(self.MAX):
            self._accepted_invitee(n, present=True)

    def test_present_count_and_table_has_room(self):
        self._fill_table()
        self.assertEqual(SeaInvite.present_count(self.owner), self.MAX)
        self.assertFalse(SeaInvite.table_has_room(self.owner))

    def test_full_table_bounces_a_fresh_deposit(self):
        self._fill_table()
        latecomer, inv = self._accepted_invitee(99, present=False)
        self.client.force_login(latecomer)
        resp = self.client.post(
            reverse("my_sea_visit_insert_token", args=[self.owner.id]))
        inv.refresh_from_db()
        self.assertIsNone(inv.token_deposited_at)   # no seat taken
        self.assertFalse(inv.is_present)
        self.assertIn("full=1", resp["Location"])

    def test_leaving_frees_a_seat_for_the_next_visitor(self):
        self._fill_table()
        # One present visitor leaves → a seat opens.
        leaver = SeaInvite.objects.filter(
            owner=self.owner, token_deposited_at__isnull=False).first()
        self.client.force_login(leaver.invitee)
        self.client.post(reverse("my_sea_visit_leave", args=[self.owner.id]))
        self.assertTrue(SeaInvite.table_has_room(self.owner))
        # The latecomer can now deposit into the freed seat.
        latecomer, inv = self._accepted_invitee(99, present=False)
        self.client.force_login(latecomer)
        self.client.post(
            reverse("my_sea_visit_insert_token", args=[self.owner.id]))
        inv.refresh_from_db()
        self.assertTrue(inv.is_present)

    def test_gate_flags_table_full_for_a_non_present_visitor(self):
        self._fill_table()
        latecomer, _ = self._accepted_invitee(99, present=False)
        self.client.force_login(latecomer)
        ctx = self.client.get(
            reverse("my_sea_visit_gate", args=[self.owner.id])).context
        self.assertTrue(ctx["table_full"])

    def test_other_present_visitors_render_in_3c_onward(self):
        # The viewer + 2 other present visitors → owner 1C, viewer 2C,
        # the two others 3C/4C; the viewer's seat is the --self one.
        viewer, vinv = self._accepted_invitee(0, present=True)
        self._accepted_invitee(1, present=True)
        self._accepted_invitee(2, present=True)
        self.client.force_login(viewer)
        resp = self.client.get(reverse("my_sea_visit", args=[self.owner.id]))
        seats = resp.context["seats"]
        # Owner has no draw here (1C empty); 3 present visitors fill 2C–4C.
        self.assertEqual(sum(1 for s in seats if s["present"]), 3)
        # Visitors fill 2C, 3C, 4C; exactly one is the viewer (is_self).
        selves = [s for s in seats if s.get("is_self")]
        self.assertEqual(len(selves), 1)
        html = resp.content.decode()
        self.assertIn('data-slot="3"', html)
        self.assertIn("table-seat--self", html)
        # Owner isn't seated (no draw) in this fixture → 1C is the empty seat.
        self.assertFalse(seats[0]["present"])

    def test_already_present_visitor_is_not_blocked_by_a_full_table(self):
        # The 5th present visitor re-POSTing insert is a no-op (already seated),
        # never a "full" bounce.
        self._fill_table()
        seated = SeaInvite.objects.filter(
            owner=self.owner, token_deposited_at__isnull=False).first()
        self.client.force_login(seated.invitee)
        ctx = self.client.get(
            reverse("my_sea_visit_gate", args=[self.owner.id])).context
        self.assertFalse(ctx["table_full"])


class MySeaVoiceContextTest(TestCase):
    """Phase C — the #id_voice_btn lights up (voice_active) for both the
    owner and the present invitee while the 24h voice window is open, keyed on
    mysea-<owner_id>."""

    def setUp(self):
        self.owner = _owner_with_sig()
        self.bud = User.objects.create(email="bud@test.io", username="budster")
        self.room_id = f"mysea-{self.owner.id}"

    def _present_invite(self, voice_future=True):
        return SeaInvite.objects.create(
            owner=self.owner, invitee=self.bud, invitee_email=self.bud.email,
            status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
            token_deposited_at=timezone.now(),
            voice_until=timezone.now() + (
                timedelta(hours=24) if voice_future else timedelta(hours=-1)
            ),
        )

    def test_spectator_voice_active_in_context(self):
        self._present_invite()
        self.client.force_login(self.bud)
        ctx = self.client.get(
            reverse("my_sea_visit", args=[self.owner.id])
        ).context
        self.assertTrue(ctx["voice_active"])
        self.assertEqual(ctx["voice_room_id"], self.room_id)

    def test_owner_voice_active_when_present_invitee_has_window(self):
        self._present_invite()
        self.client.force_login(self.owner)
        ctx = self.client.get(reverse("my_sea")).context
        self.assertTrue(ctx["voice_active"])
        self.assertEqual(ctx["voice_room_id"], self.room_id)

    def test_owner_voice_inactive_without_present_invitee(self):
        self.client.force_login(self.owner)
        ctx = self.client.get(reverse("my_sea")).context
        self.assertFalse(ctx["voice_active"])

    def test_owner_voice_inactive_when_window_lapsed(self):
        self._present_invite(voice_future=False)
        self.client.force_login(self.owner)
        ctx = self.client.get(reverse("my_sea")).context
        self.assertFalse(ctx["voice_active"])


class MySeaSlotRankLongTest(TestCase):
    """The 5+-char Roman-numeral squeeze (`--rank-long`) is now rendered
    server-side in `_my_sea_slot.html`, matching sea.js's `_fillSlot` length>=5
    rule — so a saved hand keeps the narrowed numeral across a refresh instead
    of stretching back out (user-reported 2026-05-29)."""

    def _render(self, corner_rank):
        from django.template.loader import render_to_string
        return render_to_string("apps/gameboard/_partials/_my_sea_slot.html", {
            "position": "lay",
            "crossing": False,
            "saved": {
                "card_id": 1, "polarity": "gravity", "reversed": False,
                "has_card_images": False, "corner_rank": corner_rank,
                "suit_icon": "", "arcana": "", "image_url": "", "name": "",
            },
        })

    def test_long_numeral_gets_rank_long(self):
        self.assertIn("sea-card-slot--rank-long", self._render("XLVIII"))

    def test_short_numeral_has_no_rank_long(self):
        self.assertNotIn("sea-card-slot--rank-long", self._render("IV"))

    def test_exactly_five_chars_gets_rank_long(self):
        # Boundary matches the JS (length >= 5).
        self.assertIn("sea-card-slot--rank-long", self._render("XVIII"))
