"""UT for the `SeaInvite` status machine + derived properties — Phase A1 of
[[my-sea-invite-voice-blueprint]].

`SeaInvite` is the single source of truth for the my-sea bud-invite
relationship. These tests pin the four derived properties the OK/BYE render,
seat-2 glow, spectator guard, and voice window all read from:

    expires_at    — created_at + 24h
    is_expired    — PENDING only, past expiry, AND no token deposited
    voice_active  — voice_until set + in the future
    is_present    — ACCEPTED + token deposited + not yet LEFT

All assertions run on unsaved instances (no FK / DB touched), so this is a
`SimpleTestCase` — the properties are pure functions of the row's own fields.
"""

from datetime import timedelta

from django.test import SimpleTestCase
from django.utils import timezone

from apps.gameboard.models import SeaInvite


class SeaInviteStatusMachineTest(SimpleTestCase):
    def _invite(self, **kwargs):
        """Build an unsaved SeaInvite with PENDING/now defaults overridable."""
        defaults = {"status": SeaInvite.PENDING, "created_at": timezone.now()}
        defaults.update(kwargs)
        return SeaInvite(**defaults)

    # ── status default ───────────────────────────────────────────────────
    def test_default_status_is_pending(self):
        self.assertEqual(SeaInvite().status, SeaInvite.PENDING)

    # ── expires_at ───────────────────────────────────────────────────────
    def test_expires_at_is_24h_after_created(self):
        created = timezone.now()
        inv = self._invite(created_at=created)
        self.assertEqual(inv.expires_at, created + timedelta(hours=24))

    # ── is_expired ───────────────────────────────────────────────────────
    def test_is_expired_true_for_stale_pending_without_deposit(self):
        inv = self._invite(created_at=timezone.now() - timedelta(hours=25))
        self.assertTrue(inv.is_expired)

    def test_is_expired_false_within_window(self):
        inv = self._invite(created_at=timezone.now() - timedelta(hours=1))
        self.assertFalse(inv.is_expired)

    def test_deposit_makes_invite_non_expiring(self):
        # Per user-spec: once a token is deposited the invite never expires,
        # even though it's well past the 24h PENDING window.
        inv = self._invite(
            created_at=timezone.now() - timedelta(hours=25),
            token_deposited_at=timezone.now() - timedelta(hours=20),
        )
        self.assertFalse(inv.is_expired)

    def test_only_pending_invites_can_expire(self):
        inv = self._invite(
            status=SeaInvite.ACCEPTED,
            created_at=timezone.now() - timedelta(hours=25),
        )
        self.assertFalse(inv.is_expired)

    # ── voice_active ─────────────────────────────────────────────────────
    def test_voice_active_true_when_voice_until_in_future(self):
        inv = self._invite(voice_until=timezone.now() + timedelta(hours=1))
        self.assertTrue(inv.voice_active)

    def test_voice_active_false_when_none(self):
        self.assertFalse(self._invite().voice_active)

    def test_voice_active_false_when_past(self):
        inv = self._invite(voice_until=timezone.now() - timedelta(minutes=1))
        self.assertFalse(inv.voice_active)

    # ── is_present ───────────────────────────────────────────────────────
    def test_is_present_true_when_accepted_deposited_not_left(self):
        inv = self._invite(
            status=SeaInvite.ACCEPTED,
            token_deposited_at=timezone.now(),
        )
        self.assertTrue(inv.is_present)

    def test_is_present_false_without_deposit(self):
        inv = self._invite(status=SeaInvite.ACCEPTED)
        self.assertFalse(inv.is_present)

    def test_is_present_false_after_left(self):
        # left_at set (status still ACCEPTED here) isolates the `left_at is
        # None` clause of the guard.
        inv = self._invite(
            status=SeaInvite.ACCEPTED,
            token_deposited_at=timezone.now() - timedelta(hours=1),
            left_at=timezone.now(),
        )
        self.assertFalse(inv.is_present)

    # ── invitee_access_open ──────────────────────────────────────────────
    # The invitee-facing access window: lights the bud-page sea sub-btn AND
    # keeps the owner's spectator my-sea reachable. Open for a non-terminal
    # invite within 24h of being proffered OR within 24h of the invitee's
    # last gate token deposit (user-spec 2026-05-29).
    def test_access_open_for_fresh_pending(self):
        inv = self._invite(created_at=timezone.now() - timedelta(hours=1))
        self.assertTrue(inv.invitee_access_open)

    def test_access_open_for_accepted_within_invite_window(self):
        # The key fix: an ACCEPTED invite still inside its 24h window keeps
        # the sea btn lit (the old design went dark the moment it accepted).
        inv = self._invite(
            status=SeaInvite.ACCEPTED,
            created_at=timezone.now() - timedelta(hours=5),
        )
        self.assertTrue(inv.invitee_access_open)

    def test_access_closed_for_stale_pending_without_deposit(self):
        inv = self._invite(created_at=timezone.now() - timedelta(hours=25))
        self.assertFalse(inv.invitee_access_open)

    def test_access_closed_for_stale_accepted_without_deposit(self):
        inv = self._invite(
            status=SeaInvite.ACCEPTED,
            created_at=timezone.now() - timedelta(hours=25),
        )
        self.assertFalse(inv.invitee_access_open)

    def test_access_open_within_24h_of_last_deposit_past_invite_window(self):
        # Invite proffered 3 days ago, but the invitee deposited a token at
        # the owner's gate 5h ago — the deposit clause re-arms the window.
        inv = self._invite(
            status=SeaInvite.ACCEPTED,
            created_at=timezone.now() - timedelta(hours=72),
            token_deposited_at=timezone.now() - timedelta(hours=5),
        )
        self.assertTrue(inv.invitee_access_open)

    def test_access_closed_when_both_windows_lapsed(self):
        inv = self._invite(
            status=SeaInvite.ACCEPTED,
            created_at=timezone.now() - timedelta(hours=72),
            token_deposited_at=timezone.now() - timedelta(hours=25),
        )
        self.assertFalse(inv.invitee_access_open)

    def test_access_closed_for_declined(self):
        inv = self._invite(
            status=SeaInvite.DECLINED,
            created_at=timezone.now() - timedelta(hours=1),
        )
        self.assertFalse(inv.invitee_access_open)

    def test_access_closed_for_left_even_with_recent_deposit(self):
        # A BYE is a deliberate exit — the door stays shut even though the
        # token deposit is recent.
        inv = self._invite(
            status=SeaInvite.LEFT,
            created_at=timezone.now() - timedelta(hours=1),
            token_deposited_at=timezone.now() - timedelta(hours=1),
            left_at=timezone.now(),
        )
        self.assertFalse(inv.invitee_access_open)
