"""ITs for the gatekeeper token deposit↔withdraw redact-pair pattern —
sprint A.8 user-spec 2026-05-26.

Mirrors the sig embody/disembody redact mechanism (already wired in
`sig_ready`): every state transition emits a new GameEvent AND marks
the most-recent unretracted counterpart as `data.retracted=True` so the
room scroll renders the prior entry strikethrough + Redact-tagged.

Pairs covered:
- confirm_token (SLOT_FILLED) ↔ return_token / release_slot (SLOT_RETURNED / SLOT_RELEASED)

All paths exercised:
- Non-CARTE deposit + return single slot
- CARTE per-slot deposit + per-slot release
- CARTE full-token return (multi-slot withdraw — one entry per slot)
- Re-deposit after withdraw — retracts the prior withdraw
"""
from datetime import timedelta

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

from apps.drama.models import GameEvent
from apps.epic.models import GateSlot, Room
from apps.lyric.models import Token, User


def _events(room, actor=None):
    qs = room.events.all()
    if actor is not None:
        qs = qs.filter(actor=actor)
    return qs


class TokenWithdrawRedactPairTest(TestCase):
    """Single-slot deposit/withdraw cycle on a non-CARTE token."""

    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)
        # Seed a FILLED slot w. a Coin debited (skipping the confirm_token
        # path so we control the SLOT_FILLED event payload directly).
        self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
        self.coin.current_room = self.room
        self.coin.save()
        self.slot.gamer = self.gamer
        self.slot.status = GateSlot.FILLED
        self.slot.debited_token_type = Token.COIN
        self.slot.debited_token_expires_at = timezone.now() + timedelta(days=7)
        self.slot.save()
        # Seed the matching SLOT_FILLED event (what confirm_token would emit).
        from apps.drama.models import record
        record(
            self.room, GameEvent.SLOT_FILLED, actor=self.gamer,
            slot_number=1, token_type=Token.COIN,
            token_display="Coin-on-a-String", renewal_days=7,
        )

    def test_return_emits_slot_returned_event_for_actor(self):
        self.client.post(reverse("epic:return_token", kwargs={"room_id": self.room.id}))
        returned = _events(self.room, self.gamer).filter(
            verb=GameEvent.SLOT_RETURNED,
        )
        self.assertEqual(returned.count(), 1)
        ev = returned.first()
        self.assertEqual(ev.data["slot_number"], 1)
        self.assertEqual(ev.data["token_type"], Token.COIN)

    def test_return_retracts_prior_slot_filled_for_same_slot(self):
        self.client.post(reverse("epic:return_token", kwargs={"room_id": self.room.id}))
        prior = _events(self.room, self.gamer).filter(
            verb=GameEvent.SLOT_FILLED,
        ).first()
        self.assertIsNotNone(prior)
        self.assertTrue(prior.struck, "Prior SLOT_FILLED must be marked retracted")

    def test_returned_event_renders_unified_withdraw_prose(self):
        self.client.post(reverse("epic:return_token", kwargs={"room_id": self.room.id}))
        ev = _events(self.room, self.gamer).filter(
            verb=GameEvent.SLOT_RETURNED,
        ).first()
        prose = ev.to_prose()
        self.assertIn("withdraws", prose)
        self.assertIn("Coin-on-a-String", prose)
        self.assertIn("slot 1", prose)

    def test_reserved_only_return_does_not_emit_withdraw_event(self):
        # If the slot is only RESERVED (no SLOT_FILLED ever recorded by
        # the matching deposit), the return path clears state but emits
        # no withdraw entry — there's no deposit to mirror.
        self.slot.status = GateSlot.RESERVED
        self.slot.debited_token_type = None
        self.slot.debited_token_expires_at = None
        self.slot.save()
        self.room.events.filter(verb=GameEvent.SLOT_FILLED).delete()
        self.client.post(reverse("epic:return_token", kwargs={"room_id": self.room.id}))
        self.assertEqual(
            _events(self.room, self.gamer).filter(verb=GameEvent.SLOT_RETURNED).count(),
            0,
        )


class TokenRedepositAfterWithdrawTest(TestCase):
    """A re-deposit after a withdraw redacts the prior withdraw entry —
    closes the loop on the symmetric pair (deposit ↔ withdraw ↔ deposit)."""

    def setUp(self):
        self.gamer = User.objects.create(email="reuser@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)
        # Seed a prior SLOT_RETURNED event (as though the user had just
        # withdrawn from slot 1). The next confirm_token should retract it.
        from apps.drama.models import record
        record(
            self.room, GameEvent.SLOT_RETURNED, actor=self.gamer,
            slot_number=1, token_type=Token.COIN,
            token_display="Coin-on-a-String",
        )
        # Now set up the slot for confirm_token to land — RESERVED w. the
        # coin available in the user's kit.
        self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
        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_redeposit_retracts_prior_withdraw_event(self):
        self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
        prior = _events(self.room, self.gamer).filter(
            verb=GameEvent.SLOT_RETURNED,
        ).first()
        self.assertIsNotNone(prior)
        self.assertTrue(prior.struck, "Prior SLOT_RETURNED must be retracted on redeposit")

    def test_redeposit_emits_new_slot_filled_entry(self):
        self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
        filled = _events(self.room, self.gamer).filter(verb=GameEvent.SLOT_FILLED)
        self.assertEqual(filled.count(), 1)
        ev = filled.first()
        self.assertEqual(ev.data["slot_number"], 1)
        self.assertFalse(ev.struck, "New deposit event starts unretracted")


class CarteFullReturnPerSlotRedactPairTest(TestCase):
    """CARTE full token return emits one SLOT_RETURNED entry per slot it
    was claiming + retracts the matching SLOT_FILLED for each slot."""

    def setUp(self):
        self.gamer = User.objects.create(email="cartegamer@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.carte = Token.objects.create(
            user=self.gamer, token_type=Token.CARTE, current_room=self.room,
            slots_claimed=3,
        )
        # CARTE has claimed slots 1, 2, 3.
        from apps.drama.models import record
        for n in (1, 2, 3):
            slot = self.room.gate_slots.get(slot_number=n)
            slot.gamer = self.gamer
            slot.status = GateSlot.FILLED
            slot.debited_token_type = Token.CARTE
            slot.debited_token_expires_at = timezone.now() + timedelta(days=7)
            slot.save()
            record(
                self.room, GameEvent.SLOT_FILLED, actor=self.gamer,
                slot_number=n, token_type=Token.CARTE,
                token_display="Carte Blanche", renewal_days=7,
            )

    def test_carte_full_return_emits_one_withdraw_per_claimed_slot(self):
        self.client.post(reverse("epic:return_token", kwargs={"room_id": self.room.id}))
        returned = _events(self.room, self.gamer).filter(
            verb=GameEvent.SLOT_RETURNED,
        )
        self.assertEqual(returned.count(), 3)
        slot_numbers = sorted(ev.data["slot_number"] for ev in returned)
        self.assertEqual(slot_numbers, [1, 2, 3])

    def test_carte_full_return_retracts_each_slots_prior_deposit(self):
        self.client.post(reverse("epic:return_token", kwargs={"room_id": self.room.id}))
        for n in (1, 2, 3):
            deposit = _events(self.room, self.gamer).filter(
                verb=GameEvent.SLOT_FILLED, data__slot_number=n,
            ).first()
            self.assertIsNotNone(deposit, f"slot {n} deposit must exist")
            self.assertTrue(
                deposit.struck,
                f"slot {n} deposit must be retracted after CARTE full return",
            )


class ReleaseSlotRedactPairTest(TestCase):
    """release_slot (per-slot CARTE release) emits SLOT_RELEASED + retracts
    that slot's prior SLOT_FILLED. The CARTE token itself stays in play."""

    def setUp(self):
        self.gamer = User.objects.create(email="rel@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.carte = Token.objects.create(
            user=self.gamer, token_type=Token.CARTE, current_room=self.room,
            slots_claimed=2,
        )
        # CARTE-claimed slot 2 — the one we'll release.
        slot = self.room.gate_slots.get(slot_number=2)
        slot.gamer = self.gamer
        slot.status = GateSlot.FILLED
        slot.debited_token_type = Token.CARTE
        slot.debited_token_expires_at = timezone.now() + timedelta(days=7)
        slot.save()
        from apps.drama.models import record
        record(
            self.room, GameEvent.SLOT_FILLED, actor=self.gamer,
            slot_number=2, token_type=Token.CARTE,
            token_display="Carte Blanche", renewal_days=7,
        )

    def test_release_emits_slot_released_event_for_that_slot(self):
        self.client.post(
            reverse("epic:release_slot", kwargs={"room_id": self.room.id}),
            data={"slot_number": "2"},
        )
        released = _events(self.room, self.gamer).filter(
            verb=GameEvent.SLOT_RELEASED,
        )
        self.assertEqual(released.count(), 1)
        self.assertEqual(released.first().data["slot_number"], 2)

    def test_release_retracts_prior_deposit_on_that_slot(self):
        self.client.post(
            reverse("epic:release_slot", kwargs={"room_id": self.room.id}),
            data={"slot_number": "2"},
        )
        deposit = _events(self.room, self.gamer).filter(
            verb=GameEvent.SLOT_FILLED, data__slot_number=2,
        ).first()
        self.assertIsNotNone(deposit)
        self.assertTrue(deposit.struck)
