from datetime import timedelta
from django.db.models import Q
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone

from django.db import IntegrityError

from apps.lyric.models import Token, User
from apps.epic.models import (
    AspectType, Character, DeckVariant, GateSlot, HouseLabel, Planet, Room, RoomInvite,
    SigReservation, Sign, TableSeat, TarotCard,
    debit_token, select_token, sig_deck_cards, levity_sig_cards, gravity_sig_cards,
    sig_seat_order, active_sig_seat,
)


class RoomCreationTest(TestCase):
    def test_creating_a_room_generates_six_gate_slots(self):
        owner = User.objects.create(email="founder@example.com")
        room = Room.objects.create(name="Test Room", owner=owner)
        self.assertEqual(GateSlot.objects.filter(room=room).count(), 6)


class DebitTokenTest(TestCase):
    def setUp(self):
        self.owner = User.objects.create(email="founder@example.com")
        self.room = Room.objects.create(
            name="Test Room",
            owner=self.owner,
            renewal_period=timedelta(days=7)
        )
        self.slot = self.room.gate_slots.get(slot_number=1)

    def test_debit_free_token_consumes_token_and_fills_slot(self):
        free_token = Token.objects.get(user=self.owner, token_type=Token.FREE)
        debit_token(self.owner, self.slot, free_token)
        self.assertFalse(Token.objects.filter(pk=free_token.pk).exists())
        self.slot.refresh_from_db()
        self.assertEqual(self.slot.status, GateSlot.FILLED)
        self.assertEqual(self.slot.gamer, self.owner)

    def test_debit_coin_does_not_consume_token(self):
        coin_token = Token.objects.get(user=self.owner, token_type=Token.COIN)
        debit_token(self.owner, self.slot, coin_token)
        self.assertTrue(Token.objects.filter(pk=coin_token.pk).exists())
        self.slot.refresh_from_db()
        self.assertEqual(self.slot.status, GateSlot.FILLED)
        self.assertEqual(self.slot.gamer, self.owner)

    def test_debit_band_does_not_consume_or_unequip(self):
        """BAND mirrors PASS — fills the slot, but never deleted, never
        gets `current_room` set, and stays equipped (debit_token's PASS
        branch is the model). The wallet should keep showing the BAND
        after the user enters a gate w. it."""
        band = Token.objects.create(user=self.owner, token_type=Token.BAND)
        self.owner.equipped_trinket = band
        self.owner.save(update_fields=["equipped_trinket"])
        debit_token(self.owner, self.slot, band)
        self.assertTrue(Token.objects.filter(pk=band.pk).exists())
        band.refresh_from_db()
        self.assertIsNone(band.current_room_id)
        self.owner.refresh_from_db()
        self.assertEqual(self.owner.equipped_trinket_id, band.pk)
        self.slot.refresh_from_db()
        self.assertEqual(self.slot.status, GateSlot.FILLED)
        self.assertEqual(self.slot.gamer, self.owner)
        self.assertEqual(self.slot.debited_token_type, Token.BAND)

    def test_debit_fills_last_slot_and_opens_gate(self):
        for i in range(2, 7):
            gamer = 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()
        free_token = Token.objects.get(user=self.owner, token_type=Token.FREE)
        debit_token(self.owner, self.slot, free_token)
        self.room.refresh_from_db()
        self.assertEqual(self.room.gate_status, Room.OPEN)


class CoinTokenInUseTest(TestCase):
    def setUp(self):
        self.owner = User.objects.create(email="founder@example.com")
        self.room = Room.objects.create(
            name="Dragon's Den",
            owner=self.owner,
            renewal_period=timedelta(days=7),
        )
        self.slot = self.room.gate_slots.get(slot_number=1)
        self.coin = Token.objects.get(user=self.owner, token_type=Token.COIN)
        debit_token(self.owner, self.slot, self.coin)
        self.coin.refresh_from_db()

    def test_coin_tooltip_expiry_shows_next_ready_date(self):
        expected_date = self.coin.next_ready_at.strftime("%Y-%m-%d")
        self.assertIn(expected_date, self.coin.tooltip_expiry())

    def test_coin_tooltip_room_html_contains_anchor(self):
        room_url = reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
        html = self.coin.tooltip_room_html()
        self.assertIn(f'href="{room_url}"', html)
        self.assertIn(self.room.name, html)


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

    def test_returns_coin_when_available(self):
        token = select_token(self.user)
        self.assertEqual(token.token_type, Token.COIN)

    def test_returns_free_token_when_coin_in_use(self):
        self.coin.current_room = self.other_room
        self.coin.save()
        token = select_token(self.user)
        self.assertEqual(token.token_type, Token.FREE)

    def test_free_token_selection_is_fefo(self):
        self.coin.current_room = self.other_room
        self.coin.save()
        Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
        soon = Token.objects.create(
            user=self.user, token_type=Token.FREE,
            expires_at=timezone.now() + timedelta(days=2),
        )
        Token.objects.create(
            user=self.user, token_type=Token.FREE,
            expires_at=timezone.now() + timedelta(days=6),
        )
        token = select_token(self.user)
        self.assertEqual(token.pk, soon.pk)

    def test_returns_tithe_when_coin_in_use_and_no_free_tokens(self):
        self.coin.current_room = self.other_room
        self.coin.save()
        Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
        tithe = Token.objects.create(user=self.user, token_type=Token.TITHE)
        token = select_token(self.user)
        self.assertEqual(token.pk, tithe.pk)

    def test_returns_none_when_all_depleted(self):
        self.coin.current_room = self.other_room
        self.coin.save()
        Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
        token = select_token(self.user)
        self.assertIsNone(token)

    def test_returns_pass_for_staff(self):
        """PASS must be equipped to be picked — DON-ing it is the user's
        opt-in to trinket use (parity w. COIN's auto-equip default)."""
        self.user.is_staff = True
        self.user.save()
        pass_token = Token.objects.create(user=self.user, token_type=Token.PASS)
        self.user.equipped_trinket = pass_token
        self.user.save(update_fields=["equipped_trinket"])
        token = select_token(self.user)
        self.assertEqual(token.token_type, Token.PASS)

    def test_returns_band_when_equipped(self):
        """BAND, like PASS, must be equipped to be picked. Awarded-but-
        DOFFed BAND stays in the wallet but doesn't auto-fire."""
        band = Token.objects.create(user=self.user, token_type=Token.BAND)
        self.user.equipped_trinket = band
        self.user.save(update_fields=["equipped_trinket"])
        token = select_token(self.user)
        self.assertEqual(token.pk, band.pk)

    def test_pass_wins_when_equipped_over_band(self):
        """Equipped slot is the only trinket the picker considers — whichever
        the user has DON-ed is the one that fires."""
        self.user.is_staff = True
        self.user.save()
        pass_token = Token.objects.create(user=self.user, token_type=Token.PASS)
        Token.objects.create(user=self.user, token_type=Token.BAND)
        self.user.equipped_trinket = pass_token
        self.user.save(update_fields=["equipped_trinket"])
        token = select_token(self.user)
        self.assertEqual(token.pk, pass_token.pk)


class SelectTokenEquipGatedTest(TestCase):
    """The trinket slot is the user's opt-in to trinket-as-token use. A DOFFed
    trinket (PASS/BAND/COIN) stays in the wallet but is invisible to the gate
    picker — clicking the rails falls back to FREE (FEFO) → TITHE → None.
    CARTE is never auto-picked even when equipped: it's opt-in via the kit-
    bag click flow, which routes through `drop_token`'s explicit `token_id`
    POST param (not `select_token`).

    Bug 2026-05-21 (user-reported): no equipped trinket + only FREE/TITHE
    available → "free for all" rails admit because the old flat-priority
    chain still grabbed an owned-but-DOFFed COIN, advanced its current_room
    silently, and never decremented anything visible. New semantics: the
    equip slot gates trinket use entirely."""

    def setUp(self):
        self.user = User.objects.create(email="equip@test.io")
        # Wipe auto-COIN + auto-FREE + the auto-equip; tests seed precisely.
        self.user.tokens.all().delete()
        self.user.refresh_from_db()  # SET_NULL on equipped_trinket fired

    def test_skips_unequipped_coin_and_returns_free(self):
        Token.objects.create(user=self.user, token_type=Token.COIN)
        free = Token.objects.create(
            user=self.user, token_type=Token.FREE,
            expires_at=timezone.now() + timedelta(days=7),
        )
        self.assertEqual(select_token(self.user).pk, free.pk)

    def test_skips_unequipped_coin_and_returns_tithe_when_no_free(self):
        Token.objects.create(user=self.user, token_type=Token.COIN)
        tithe = Token.objects.create(user=self.user, token_type=Token.TITHE)
        self.assertEqual(select_token(self.user).pk, tithe.pk)

    def test_returns_none_when_no_equip_and_no_consumables(self):
        Token.objects.create(user=self.user, token_type=Token.COIN)
        Token.objects.create(user=self.user, token_type=Token.BAND)
        self.assertIsNone(select_token(self.user))

    def test_carte_equipped_falls_through_to_free(self):
        """CARTE is opt-in via kit-bag's explicit click — never auto-picked
        by select_token even when equipped (the rails fallback for an idle
        CARTE-holder is FREE/TITHE, not CARTE itself)."""
        carte = Token.objects.create(user=self.user, token_type=Token.CARTE)
        free = Token.objects.create(
            user=self.user, token_type=Token.FREE,
            expires_at=timezone.now() + timedelta(days=7),
        )
        self.user.equipped_trinket = carte
        self.user.save(update_fields=["equipped_trinket"])
        self.assertEqual(select_token(self.user).pk, free.pk)

    def test_equipped_coin_wins_over_unequipped_band(self):
        """Equip slot is exclusive — the DON-ed trinket is the only one the
        picker considers among trinkets, regardless of priority rank."""
        coin = Token.objects.create(user=self.user, token_type=Token.COIN)
        Token.objects.create(user=self.user, token_type=Token.BAND)
        self.user.equipped_trinket = coin
        self.user.save(update_fields=["equipped_trinket"])
        self.assertEqual(select_token(self.user).pk, coin.pk)

    def test_equipped_coin_in_use_elsewhere_falls_through_to_free(self):
        """Defensive: equipped + in-use COIN shouldn't occur (debit_token
        auto-unequips on consumption) but if it does, treat as no-equip."""
        other_room = Room.objects.create(name="Elsewhere", owner=self.user)
        coin = Token.objects.create(
            user=self.user, token_type=Token.COIN, current_room=other_room,
        )
        free = Token.objects.create(
            user=self.user, token_type=Token.FREE,
            expires_at=timezone.now() + timedelta(days=7),
        )
        self.user.equipped_trinket = coin
        self.user.save(update_fields=["equipped_trinket"])
        self.assertEqual(select_token(self.user).pk, free.pk)

    def test_staff_with_unequipped_pass_falls_through_to_free(self):
        """Even staff must DON the PASS — the auto-equip on user creation
        is the convenience default, NOT a special-case bypass of the rule."""
        self.user.is_staff = True
        self.user.save(update_fields=["is_staff"])
        Token.objects.create(user=self.user, token_type=Token.PASS)
        free = Token.objects.create(
            user=self.user, token_type=Token.FREE,
            expires_at=timezone.now() + timedelta(days=7),
        )
        self.assertEqual(select_token(self.user).pk, free.pk)


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

    def test_table_status_defaults_to_blank(self):
        self.room.refresh_from_db()
        self.assertFalse(self.room.table_status)

    def test_room_has_role_select_constant(self):
        self.assertEqual(Room.ROLE_SELECT, "ROLE_SELECT")

    def test_room_has_sig_select_constant(self):
        self.assertEqual(Room.SIG_SELECT, "SIG_SELECT")

    def test_room_has_in_game_constant(self):
        self.assertEqual(Room.IN_GAME, "IN_GAME")

    def test_table_status_accepts_role_select(self):
        self.room.table_status = Room.ROLE_SELECT
        self.room.save()
        self.room.refresh_from_db()
        self.assertEqual(self.room.table_status, Room.ROLE_SELECT)


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

    def test_table_seat_can_be_created(self):
        seat = TableSeat.objects.create(
            room=self.room,
            gamer=self.owner,
            slot_number=1,
        )
        self.assertEqual(seat.slot_number, 1)
        self.assertIsNone(seat.role)
        self.assertFalse(seat.role_revealed)
        self.assertIsNone(seat.seat_position)

    def test_table_seat_role_choices_cover_all_six(self):
        role_codes = [c[0] for c in TableSeat.ROLE_CHOICES]
        for code in ["PC", "BC", "SC", "AC", "NC", "EC"]:
            self.assertIn(code, role_codes)

    def test_partner_map_pairs_are_mutual(self):
        for a, b in [(TableSeat.PC, TableSeat.BC), (TableSeat.SC, TableSeat.AC), (TableSeat.NC, TableSeat.EC)]:
            self.assertEqual(TableSeat.PARTNER_MAP[a], b)
            self.assertEqual(TableSeat.PARTNER_MAP[b], a)

    def test_room_table_seats_reverse_relation(self):
        TableSeat.objects.create(room=self.room, gamer=self.owner, slot_number=1)
        self.assertEqual(self.room.table_seats.count(), 1)


class RoomInviteTest(TestCase):
    def setUp(self):
        self.founder = User.objects.create(email="founder@example.com")
        self.room = Room.objects.create(name="Dragon's Den", owner=self.founder)

    def test_founder_can_invite_by_email(self):
        invite = RoomInvite.objects.create(
            room=self.room,
            inviter=self.founder,
            invitee_email="friend@example.com",
        )
        self.assertEqual(invite.status, RoomInvite.PENDING)

    def test_invited_room_appears_in_my_games_queryset(self):
        friend = User.objects.create(email="friend@example.com")
        RoomInvite.objects.create(
            room=self.room,
            inviter=self.founder,
            invitee_email=friend.email,
        )
        rooms = Room.objects.filter(
            Q(owner=friend) |
            Q(gate_slots__gamer=friend) |
            Q(invites__invitee_email=friend.email, invites__status=RoomInvite.PENDING)
        ).distinct()
        self.assertIn(self.room, rooms)


# ── Significator deck helpers ─────────────────────────────────────────────────

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


def _full_sig_room(name="Sig Room", role_order=None):
    """Return (room, gamers, earthman) with all 6 seats filled, roles assigned,
    table_status=SIG_SELECT, and every gamer's equipped_deck set to Earthman.
    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},
    )
    owner = User.objects.create(email="founder@sig.io")
    gamers = [owner]
    for i in range(2, 7):
        gamers.append(User.objects.create(email=f"g{i}@sig.io"))
    for gamer in gamers:
        gamer.equipped_deck = earthman
        gamer.save(update_fields=["equipped_deck"])
    room = Room.objects.create(name=name, owner=owner)
    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,
        )
    room.table_status = Room.SIG_SELECT
    room.save()
    return room, gamers, earthman


class SigDeckCompositionTest(TestCase):
    """sig_deck_cards(room) returns exactly 36 cards with correct suit/arcana split."""

    def setUp(self):
        self.room, self.gamers, self.earthman = _full_sig_room()

    def test_sig_deck_returns_36_cards(self):
        cards = sig_deck_cards(self.room)
        self.assertEqual(len(cards), 36)

    def test_sc_ac_contribute_court_cards_of_blades_and_grails(self):
        cards = sig_deck_cards(self.room)
        sc_ac = [c for c in cards if c.suit in ("BLADES", "GRAILS")]
        # M/J/Q/K × 2 suits × 2 roles = 16
        self.assertEqual(len(sc_ac), 16)
        self.assertTrue(all(c.number in (11, 12, 13, 14) for c in sc_ac))

    def test_pc_bc_contribute_court_cards_of_brands_and_crowns(self):
        cards = sig_deck_cards(self.room)
        pc_bc = [c for c in cards if c.suit in ("BRANDS", "CROWNS")]
        self.assertEqual(len(pc_bc), 16)
        self.assertTrue(all(c.number in (11, 12, 13, 14) for c in pc_bc))

    def test_nc_ec_contribute_schiz_and_chancellor(self):
        cards = sig_deck_cards(self.room)
        major = [c for c in cards if c.arcana == "MAJOR"]
        self.assertEqual(len(major), 4)
        self.assertEqual(sorted(c.number for c in major), [0, 0, 1, 1])

    def test_each_card_appears_twice_once_per_pile(self):
        """18 unique card specs × 2 (levity + gravity) = 36 total."""
        cards = sig_deck_cards(self.room)
        slugs = [c.slug for c in cards]
        unique_slugs = set(slugs)
        self.assertEqual(len(unique_slugs), 18)
        self.assertTrue(all(slugs.count(s) == 2 for s in unique_slugs))


class SigSeatOrderTest(TestCase):
    """sig_seat_order() and active_sig_seat() return seats in PC→NC→EC→SC→AC→BC order."""

    def setUp(self):
        # Assign roles in reverse of canonical order to prove reordering works
        self.room, self.gamers, _ = _full_sig_room(
            name="Order Room",
            role_order=["BC", "AC", "SC", "EC", "NC", "PC"],
        )

    def test_sig_seat_order_returns_canonical_role_sequence(self):
        seats = sig_seat_order(self.room)
        self.assertEqual([s.role for s in seats], SIG_SEAT_ORDER)

    def test_active_sig_seat_is_first_seat_without_significator(self):
        seat = active_sig_seat(self.room)
        self.assertEqual(seat.role, "PC")

    def test_active_sig_seat_advances_after_significator_set(self):
        pc_seat = TableSeat.objects.get(room=self.room, role="PC")
        earthman = DeckVariant.objects.get(slug="earthman")
        card = TarotCard.objects.filter(deck_variant=earthman, arcana="MINOR").first()
        pc_seat.significator = card
        pc_seat.save()
        seat = active_sig_seat(self.room)
        self.assertEqual(seat.role, "NC")

    def test_active_sig_seat_is_none_when_all_chosen(self):
        earthman = DeckVariant.objects.get(slug="earthman")
        cards = list(TarotCard.objects.filter(deck_variant=earthman))
        for i, seat in enumerate(TableSeat.objects.filter(room=self.room)):
            seat.significator = cards[i]
            seat.save()
        self.assertIsNone(active_sig_seat(self.room))


class SigCardFieldTest(TestCase):
    """TableSeat.significator FK to TarotCard — default null, assignable."""

    def setUp(self):
        earthman, _ = DeckVariant.objects.get_or_create(
            slug="earthman",
            defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
        )
        self.card = TarotCard.objects.get(
            deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=11,
        )
        owner = User.objects.create(email="owner@test.io")
        room = Room.objects.create(name="Field Test", owner=owner)
        self.seat = TableSeat.objects.create(room=room, gamer=owner, slot_number=1, role="PC")

    def test_significator_defaults_to_none(self):
        self.assertIsNone(self.seat.significator)

    def test_significator_can_be_assigned(self):
        self.seat.significator = self.card
        self.seat.save()
        self.seat.refresh_from_db()
        self.assertEqual(self.seat.significator, self.card)

    def test_significator_nullable_on_delete(self):
        self.seat.significator = self.card
        self.seat.save()
        self.card.delete()
        self.seat.refresh_from_db()
        self.assertIsNone(self.seat.significator)


# ── SigReservation model ──────────────────────────────────────────────────────

def _make_sig_card(deck_variant, suit, number):
    name_map = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
    card, _ = TarotCard.objects.get_or_create(
        deck_variant=deck_variant,
        slug=f"{name_map[number].lower()}-of-{suit.lower()}-em",
        defaults={
            "arcana": "MINOR", "suit": suit, "number": number,
            "name": f"{name_map[number]} of {suit.capitalize()}",
        },
    )
    return card


class SigReservationModelTest(TestCase):
    def setUp(self):
        self.earthman, _ = DeckVariant.objects.get_or_create(
            slug="earthman",
            defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
        )
        self.owner = User.objects.create(email="founder@test.io")
        self.room = Room.objects.create(name="Sig Room", owner=self.owner)
        self.card = _make_sig_card(self.earthman, "BRANDS", 14)
        self.seat = TableSeat.objects.create(
            room=self.room, gamer=self.owner, slot_number=1, role="PC"
        )

    def test_can_create_sig_reservation(self):
        res = SigReservation.objects.create(
            room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
        )
        self.assertEqual(res.role, "PC")
        self.assertEqual(res.polarity, "levity")
        self.assertIsNotNone(res.reserved_at)

    def test_one_reservation_per_gamer_per_room(self):
        SigReservation.objects.create(
            room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
        )
        card2 = _make_sig_card(self.earthman, "GRAILS", 13)
        with self.assertRaises(IntegrityError):
            SigReservation.objects.create(
                room=self.room, gamer=self.owner, card=card2, role="PC", polarity="levity"
            )

    def test_same_card_blocked_within_same_polarity(self):
        gamer2 = User.objects.create(email="nc@test.io")
        TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="NC")
        SigReservation.objects.create(
            room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
        )
        with self.assertRaises(IntegrityError):
            SigReservation.objects.create(
                room=self.room, gamer=gamer2, card=self.card, role="NC", polarity="levity"
            )

    def test_same_card_allowed_across_polarity(self):
        """A gravity gamer may reserve the same card instance as a levity gamer
        — each polarity has its own independent pile."""
        gamer2 = User.objects.create(email="bc@test.io")
        TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="BC")
        SigReservation.objects.create(
            room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
        )
        res2 = SigReservation.objects.create(
            room=self.room, gamer=gamer2, card=self.card, role="BC", polarity="gravity"
        )
        self.assertIsNotNone(res2.pk)

    def test_deleting_reservation_clears_slot(self):
        res = SigReservation.objects.create(
            room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
        )
        res.delete()
        self.assertFalse(SigReservation.objects.filter(room=self.room, gamer=self.owner).exists())


class SigCardHelperTest(TestCase):
    """levity_sig_cards() and gravity_sig_cards() return 16 courts by default;
    Nomad/Schizo added when the user has the matching Note unlock.
    Relies on the Earthman deck seeded by migrations (no manual card creation).
    """

    def setUp(self):
        from django.utils import timezone
        self.earthman = DeckVariant.objects.get(slug="earthman")
        self.owner = User.objects.create(email="founder@test.io")
        TableSeat.objects.create(
            room=Room.objects.create(name="Card Test", owner=self.owner),
            gamer=self.owner, slot_number=1, role="PC",
            deck_variant=self.earthman,
        )
        self.room = self.owner.table_seats.first().room
        self._tz = timezone

    def test_levity_sig_cards_returns_16_without_notes(self):
        cards = levity_sig_cards(self.room, self.owner)
        self.assertEqual(len(cards), 16)

    def test_gravity_sig_cards_returns_16_without_notes(self):
        cards = gravity_sig_cards(self.room, self.owner)
        self.assertEqual(len(cards), 16)

    def test_nomad_note_includes_nomad(self):
        from apps.drama.models import Note
        Note.objects.create(user=self.owner, slug="nomad", earned_at=self._tz.now())
        cards = levity_sig_cards(self.room, self.owner)
        self.assertEqual(len(cards), 17)
        self.assertTrue(any(c.number == 0 and c.arcana == "MAJOR" for c in cards))

    def test_schizo_note_includes_schizo(self):
        from apps.drama.models import Note
        Note.objects.create(user=self.owner, slug="schizo", earned_at=self._tz.now())
        cards = levity_sig_cards(self.room, self.owner)
        self.assertEqual(len(cards), 17)
        self.assertTrue(any(c.number == 1 and c.arcana == "MAJOR" for c in cards))

    def test_levity_and_gravity_share_same_card_objects(self):
        """Both piles draw from the same 18 TarotCard instances — visual distinction
        comes from CSS polarity class, not separate card model records."""
        levity = levity_sig_cards(self.room)
        gravity = gravity_sig_cards(self.room)
        self.assertEqual(
            sorted(c.pk for c in levity),
            sorted(c.pk for c in gravity),
        )

    def test_returns_empty_when_no_deck_on_seats_or_owner(self):
        """Falls back to empty list when neither seats nor owner have a deck."""
        self.room.table_seats.update(deck_variant=None)
        self.owner.equipped_deck = None
        self.owner.save()
        self.assertEqual(levity_sig_cards(self.room, self.owner), [])
        self.assertEqual(gravity_sig_cards(self.room, self.owner), [])


class PersonalSigCardsTest(TestCase):
    """personal_sig_cards(user) — solo (room-less) sig pile sourced from
    User.equipped_deck. Same 18-card pile + Note-unlock filtering as
    levity_sig_cards / gravity_sig_cards (which route through a room)."""

    def test_fresh_user_gets_16_cards_via_auto_equipped_earthman(self):
        from apps.epic.models import personal_sig_cards
        user = User.objects.create(email="solo@test.io")
        # post_save signal auto-equips Earthman; no Schizo/Nomad notes yet,
        # so Majors 0 and 1 are filtered out by _filter_major_unlocks.
        cards = personal_sig_cards(user)
        self.assertEqual(len(cards), 16)

    def test_falls_back_to_earthman_when_no_equipped_deck(self):
        """Sprint 4a-follow contract: instead of returning an empty pile when
        the user has no equipped_deck (e.g. their deck is in-use as a
        TableSeat.deck_variant in an active room), personal_sig_cards falls
        back to the Earthman deck. The picker labels this "Earthman [Shabby
        Paperboard]" via a Brief banner at the view layer."""
        from apps.epic.models import personal_sig_cards
        user = User.objects.create(email="dekless@test.io")
        user.equipped_deck = None
        user.save(update_fields=["equipped_deck"])
        cards = personal_sig_cards(user)
        self.assertEqual(len(cards), 16)
        # All cards should belong to the Earthman deck (the fallback)
        self.assertTrue(all(c.deck_variant.slug == "earthman" for c in cards))

    def test_schizo_note_unlocks_major_1(self):
        from apps.drama.models import Note
        from apps.epic.models import personal_sig_cards
        from django.utils import timezone
        user = User.objects.create(email="schizo@test.io")
        Note.objects.create(user=user, slug="schizo", earned_at=timezone.now())
        cards = personal_sig_cards(user)
        self.assertEqual(len(cards), 17)
        self.assertTrue(any(c.number == 1 and c.arcana == "MAJOR" for c in cards))


class TarotCardCautionsTest(TestCase):
    """TarotCard.cautions JSONField — field existence and Schizo seed data."""

    def setUp(self):
        self.earthman = DeckVariant.objects.get(slug="earthman")

    def test_cautions_field_saves_and_retrieves_list(self):
        card = TarotCard.objects.create(
            deck_variant=self.earthman,
            arcana="MINOR",
            suit="CROWNS",
            number=99,
            name="Test Card",
            slug="test-card-cautions",
            cautions=["First caution.", "Second caution."],
        )
        card.refresh_from_db()
        self.assertEqual(card.cautions, ["First caution.", "Second caution."])

    def test_cautions_defaults_to_empty_list(self):
        card = TarotCard.objects.create(
            deck_variant=self.earthman,
            arcana="MINOR",
            suit="CROWNS",
            number=98,
            name="Default Cautions Card",
            slug="default-cautions-card",
        )
        self.assertEqual(card.cautions, [])

    def test_schizo_has_4_cautions(self):
        schizo = TarotCard.objects.get(
            deck_variant=self.earthman, arcana="MAJOR", number=1
        )
        self.assertEqual(len(schizo.cautions), 4)

    def test_schizo_caution_references_the_pervert(self):
        schizo = TarotCard.objects.get(
            deck_variant=self.earthman, arcana="MAJOR", number=1
        )
        self.assertIn("The Pervert", schizo.cautions[0])

    def test_schizo_cautions_use_reverse_language(self):
        schizo = TarotCard.objects.get(
            deck_variant=self.earthman, arcana="MAJOR", number=1
        )
        for caution in schizo.cautions:
            self.assertIn("reverse", caution)
            self.assertNotIn("transform", caution)


# ── SigReservation ready gate ─────────────────────────────────────────────────

class SigReservationReadyGateTest(TestCase):
    """SigReservation.ready and countdown_remaining fields."""

    def setUp(self):
        self.earthman = DeckVariant.objects.get(slug="earthman")
        owner = User.objects.create(email="owner@test.io")
        room = Room.objects.create(name="R", owner=owner)
        card = TarotCard.objects.get(
            deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
        )
        self.res = SigReservation.objects.create(
            room=room, gamer=owner, card=card, role="PC", polarity="levity"
        )

    def test_ready_defaults_to_false(self):
        self.assertFalse(self.res.ready)

    def test_countdown_remaining_defaults_to_none(self):
        self.assertIsNone(self.res.countdown_remaining)

    def test_ready_can_be_set_true(self):
        self.res.ready = True
        self.res.save()
        self.res.refresh_from_db()
        self.assertTrue(self.res.ready)

    def test_countdown_remaining_can_be_saved(self):
        self.res.countdown_remaining = 7
        self.res.save()
        self.res.refresh_from_db()
        self.assertEqual(self.res.countdown_remaining, 7)


# ── Room SKY_SELECT status ────────────────────────────────────────────────────

class RoomSkySelectStatusTest(TestCase):
    """Room.SKY_SELECT constant and sig_select_started_at field."""

    def setUp(self):
        owner = User.objects.create(email="owner@test.io")
        self.room = Room.objects.create(name="R", owner=owner)

    def test_sky_select_constant_value(self):
        self.assertEqual(Room.SKY_SELECT, "SKY_SELECT")

    def test_sky_select_is_valid_table_status_choice(self):
        choices = [c[0] for c in Room.TABLE_STATUS_CHOICES]
        self.assertIn(Room.SKY_SELECT, choices)

    def test_sig_select_started_at_defaults_to_none(self):
        self.assertIsNone(self.room.sig_select_started_at)

    def test_sig_select_started_at_can_be_set(self):
        from django.utils import timezone
        now = timezone.now()
        self.room.sig_select_started_at = now
        self.room.save()
        self.room.refresh_from_db()
        self.assertIsNotNone(self.room.sig_select_started_at)


# ── TarotDeck.draw / shuffle ──────────────────────────────────────────────────

class TarotDeckDrawTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="dealer@test.io")
        self.room = Room.objects.create(name="R", owner=self.user)

    def test_draw_raises_value_error_when_too_few_cards_remain(self):
        from apps.epic.models import TarotDeck
        deck_variant = DeckVariant.objects.first()
        all_ids = list(TarotCard.objects.filter(deck_variant=deck_variant).values_list('id', flat=True))
        td = TarotDeck.objects.create(
            room=self.room,
            deck_variant=deck_variant,
            drawn_card_ids=all_ids,
        )
        with self.assertRaises(ValueError):
            td.draw(1)

    def test_shuffle_resets_drawn_card_ids(self):
        from apps.epic.models import TarotDeck
        deck_variant = DeckVariant.objects.first()
        some_ids = list(TarotCard.objects.filter(deck_variant=deck_variant).values_list('id', flat=True)[:3])
        td = TarotDeck.objects.create(
            room=self.room,
            deck_variant=deck_variant,
            drawn_card_ids=some_ids,
        )
        td.shuffle()
        td.refresh_from_db()
        self.assertEqual(td.drawn_card_ids, [])

    def test_remaining_count_subtracts_drawn_from_total(self):
        from apps.epic.models import TarotDeck
        deck_variant = DeckVariant.objects.first()
        td = TarotDeck.objects.create(
            room=self.room,
            deck_variant=deck_variant,
            drawn_card_ids=[],
        )
        self.assertEqual(td.remaining_count, deck_variant.card_count)
        td.drawn_card_ids = list(
            TarotCard.objects.filter(deck_variant=deck_variant).values_list('id', flat=True)[:5]
        )
        td.save()
        self.assertEqual(td.remaining_count, deck_variant.card_count - 5)

    def test_remaining_count_zero_when_no_deck_variant(self):
        from apps.epic.models import TarotDeck
        td = TarotDeck.objects.create(room=self.room, deck_variant=None)
        self.assertEqual(td.remaining_count, 0)

    def test_draw_returns_n_tuples_of_card_and_bool(self):
        from apps.epic.models import TarotDeck
        deck_variant = DeckVariant.objects.first()
        td = TarotDeck.objects.create(room=self.room, deck_variant=deck_variant)
        drawn = td.draw(3)
        self.assertEqual(len(drawn), 3)
        for card, is_reversed in drawn:
            self.assertIsInstance(card, TarotCard)
            self.assertIsInstance(is_reversed, bool)

    def test_draw_appends_card_ids_to_drawn_card_ids(self):
        from apps.epic.models import TarotDeck
        deck_variant = DeckVariant.objects.first()
        td = TarotDeck.objects.create(room=self.room, deck_variant=deck_variant)
        drawn = td.draw(4)
        td.refresh_from_db()
        self.assertEqual(len(td.drawn_card_ids), 4)
        for card, _ in drawn:
            self.assertIn(card.id, td.drawn_card_ids)

    def test_draw_excludes_already_drawn_cards(self):
        """Subsequent draws never repeat cards from the existing drawn_card_ids."""
        from apps.epic.models import TarotDeck
        deck_variant = DeckVariant.objects.first()
        td = TarotDeck.objects.create(room=self.room, deck_variant=deck_variant)
        first = td.draw(5)
        first_ids = {card.id for card, _ in first}
        second = td.draw(5)
        second_ids = {card.id for card, _ in second}
        self.assertFalse(first_ids & second_ids)


# ── sig_deck_cards with no equipped deck ─────────────────────────────────────

class SigDeckCardsNoEquippedDeckTest(TestCase):
    def test_returns_empty_list_when_owner_has_no_equipped_deck(self):
        user = User.objects.create(email="nodeck@test.io")
        user.equipped_deck = None
        user.save(update_fields=["equipped_deck"])
        room = Room.objects.create(name="R", owner=user)
        self.assertEqual(sig_deck_cards(room), [])


# ── Astrology model __str__ methods ──────────────────────────────────────────

class AstrologyModelStrTest(TestCase):
    def test_zodiac_sign_str(self):
        sign = Sign.objects.first()
        if sign is None:
            self.skipTest("No Sign rows")
        self.assertEqual(str(sign), sign.name)

    def test_planet_str(self):
        planet = Planet.objects.first()
        if planet is None:
            self.skipTest("No Planet rows")
        self.assertEqual(str(planet), planet.name)

    def test_aspect_type_str(self):
        aspect = AspectType.objects.first()
        if aspect is None:
            self.skipTest("No AspectType rows")
        self.assertEqual(str(aspect), aspect.name)

    def test_house_label_str(self):
        label = HouseLabel.objects.first()
        if label is None:
            self.skipTest("No HouseLabel rows")
        self.assertIn(str(label.number), str(label))


# ── Character model ───────────────────────────────────────────────────────────

class CharacterModelTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="char@test.io")
        self.room = Room.objects.create(name="R", owner=self.user)
        self.seat = TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=1, role="PC")

    def test_draft_str(self):
        char = Character.objects.create(seat=self.seat)
        self.assertIn("draft", str(char))

    def test_confirmed_str(self):
        char = Character.objects.create(seat=self.seat, confirmed_at=timezone.now())
        self.assertIn("confirmed", str(char))

    def test_is_confirmed_false_for_draft(self):
        char = Character.objects.create(seat=self.seat)
        self.assertFalse(char.is_confirmed)

    def test_is_confirmed_true_when_confirmed_at_set(self):
        char = Character.objects.create(seat=self.seat, confirmed_at=timezone.now())
        self.assertTrue(char.is_confirmed)

    def test_is_active_true_when_confirmed_and_not_retired(self):
        char = Character.objects.create(seat=self.seat, confirmed_at=timezone.now())
        self.assertTrue(char.is_active)

    def test_is_active_false_when_retired(self):
        char = Character.objects.create(
            seat=self.seat,
            confirmed_at=timezone.now(),
            retired_at=timezone.now(),
        )
        self.assertFalse(char.is_active)


class DeckSchemaA0Test(TestCase):
    """Sprint A.0 — DeckVariant gains has_card_images, family, is_polarized;
    SUIT_CHOICES collapses to canonical Earthman vocab; existing
    fiorentine-minchiate seed (which was actually RWS Tarot in disguise)
    gets renamed to tarot-rider-waite-smith with revocabbed suits."""

    def test_deck_variant_has_card_images_field(self):
        f = DeckVariant._meta.get_field("has_card_images")
        self.assertEqual(f.get_internal_type(), "BooleanField")

    def test_deck_variant_family_field_has_expected_choices(self):
        f = DeckVariant._meta.get_field("family")
        choice_values = {c[0] for c in f.choices}
        self.assertEqual(choice_values, {"earthman", "italian", "english", "playing"})

    def test_deck_variant_is_polarized_field(self):
        f = DeckVariant._meta.get_field("is_polarized")
        self.assertEqual(f.get_internal_type(), "BooleanField")

    def test_earthman_is_polarized(self):
        earthman = DeckVariant.objects.get(slug="earthman")
        self.assertTrue(earthman.is_polarized)

    def test_earthman_family_is_earthman(self):
        earthman = DeckVariant.objects.get(slug="earthman")
        self.assertEqual(earthman.family, "earthman")

    def test_earthman_has_card_images_false_until_artwork_painted(self):
        earthman = DeckVariant.objects.get(slug="earthman")
        self.assertFalse(earthman.has_card_images)

    def test_rws_deck_renamed_from_fiorentine_minchiate(self):
        self.assertFalse(
            DeckVariant.objects.filter(slug="fiorentine-minchiate").exists(),
            "fiorentine-minchiate slug should have been renamed to tarot-rider-waite-smith",
        )
        self.assertTrue(
            DeckVariant.objects.filter(slug="tarot-rider-waite-smith").exists(),
        )

    def test_rws_deck_name_is_canonical(self):
        rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
        self.assertEqual(rws.name, "Tarot (Rider-Waite-Smith)")

    def test_rws_family_is_english(self):
        rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
        self.assertEqual(rws.family, "english")

    def test_rws_has_card_images_false(self):
        rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
        self.assertFalse(rws.has_card_images)

    def test_rws_is_polarized_false(self):
        rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
        self.assertFalse(rws.is_polarized)

    def test_rws_cards_use_canonical_earthman_suits(self):
        rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
        suit_values = set(
            TarotCard.objects.filter(deck_variant=rws, arcana="MINOR")
            .values_list("suit", flat=True)
            .distinct()
        )
        self.assertEqual(
            suit_values,
            {"BRANDS", "GRAILS", "BLADES", "CROWNS"},
            "RWS suits should have been revocabbed: WANDS→BRANDS, CUPS→GRAILS, "
            "SWORDS→BLADES, PENTACLES→CROWNS",
        )

    def test_rws_minor_card_count_preserved(self):
        """Revocab is a string swap on the suit field — count is invariant."""
        rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
        for canonical_suit in ("BRANDS", "GRAILS", "BLADES", "CROWNS"):
            self.assertEqual(
                TarotCard.objects.filter(
                    deck_variant=rws, arcana="MINOR", suit=canonical_suit
                ).count(),
                14,
                f"{canonical_suit} should have 14 cards in RWS",
            )

    def test_suit_choices_dropped_english_vocab(self):
        choice_values = {c[0] for c in TarotCard.SUIT_CHOICES}
        self.assertEqual(choice_values, {"BRANDS", "GRAILS", "BLADES", "CROWNS"})
        self.assertNotIn("WANDS", choice_values)
        self.assertNotIn("CUPS", choice_values)
        self.assertNotIn("SWORDS", choice_values)
        self.assertNotIn("PENTACLES", choice_values)


class MinchiateFiorentine1860SeedTest(TestCase):
    """Sprint A.1 — seed the actual Minchiate Fiorentine 1860-1890 deck.
    97 cards: 40 numbered trumps + Il Matto (rank 0) + 56 minors (4 suits × 14)."""

    DECK_SLUG = "minchiate-fiorentine-1860-1890"

    def setUp(self):
        self.deck = DeckVariant.objects.get(slug=self.DECK_SLUG)

    def test_deck_exists_w_canonical_name(self):
        self.assertEqual(self.deck.name, "Minchiate Fiorentine (1860–1890)")

    def test_deck_family_is_italian(self):
        self.assertEqual(self.deck.family, "italian")

    def test_deck_has_card_images_true(self):
        self.assertTrue(self.deck.has_card_images)

    def test_deck_is_polarized_false(self):
        self.assertFalse(self.deck.is_polarized)

    def test_deck_card_count_97(self):
        self.assertEqual(self.deck.card_count, 97)

    def test_total_card_rows_match_declared_count(self):
        self.assertEqual(
            TarotCard.objects.filter(deck_variant=self.deck).count(), 97
        )

    def test_trump_count_is_41(self):
        """40 numbered (1-40) + Il Matto (rank 0) = 41 trumps."""
        self.assertEqual(
            TarotCard.objects.filter(deck_variant=self.deck, arcana="MAJOR").count(),
            41,
        )

    def test_il_matto_at_rank_0(self):
        il_matto = TarotCard.objects.get(deck_variant=self.deck, slug="il-matto")
        self.assertEqual(il_matto.arcana, "MAJOR")
        self.assertEqual(il_matto.number, 0)
        self.assertEqual(il_matto.name, "Il Matto")

    def test_il_gobbo_at_rank_11_w_hermit_correspondence(self):
        il_gobbo = TarotCard.objects.get(deck_variant=self.deck, slug="il-gobbo")
        self.assertEqual(il_gobbo.number, 11)
        self.assertEqual(il_gobbo.correspondence, "The Hermit")

    def test_trump_40_is_le_trombe(self):
        le_trombe = TarotCard.objects.get(
            deck_variant=self.deck, arcana="MAJOR", number=40,
        )
        self.assertEqual(le_trombe.name, "Le Trombe")

    def test_papa_uno_through_cinque_are_majors_1_through_5(self):
        papas = list(
            TarotCard.objects.filter(deck_variant=self.deck, arcana="MAJOR")
            .filter(number__in=[1, 2, 3, 4, 5])
            .order_by("number")
            .values_list("name", flat=True)
        )
        self.assertEqual(papas, ["Papa Uno", "Papa Due", "Papa Tre", "Papa Quattro", "Papa Cinque"])

    def test_minors_count_56_w_canonical_suits_only(self):
        minors_per_suit = {
            suit: TarotCard.objects.filter(
                deck_variant=self.deck, arcana="MINOR", suit=suit,
            ).count()
            for suit in ("BRANDS", "GRAILS", "BLADES", "CROWNS")
        }
        self.assertEqual(minors_per_suit, {"BRANDS": 14, "GRAILS": 14, "BLADES": 14, "CROWNS": 14})

    def test_no_minor_uses_dropped_english_suit(self):
        bad_suits = set(
            TarotCard.objects.filter(deck_variant=self.deck, arcana="MINOR")
            .values_list("suit", flat=True)
            .distinct()
        ) - {"BRANDS", "GRAILS", "BLADES", "CROWNS"}
        self.assertEqual(bad_suits, set())

    def test_minor_court_ranks_11_through_14(self):
        """Page=11, Knight=12, Queen=13, King=14 per [[reference-card-image-naming-convention]]."""
        for suit in ("BRANDS", "GRAILS", "BLADES", "CROWNS"):
            court_numbers = set(
                TarotCard.objects.filter(
                    deck_variant=self.deck, arcana="MINOR", suit=suit, number__gte=11,
                ).values_list("number", flat=True)
            )
            self.assertEqual(court_numbers, {11, 12, 13, 14}, f"{suit} courts")

    def test_page_of_batons_exists(self):
        """BRANDS is the canonical enum; Italian display 'Batons' lives in the card name."""
        card = TarotCard.objects.get(
            deck_variant=self.deck, suit="BRANDS", number=11,
        )
        self.assertEqual(card.name, "Page of Batons")
        self.assertEqual(card.slug, "page-of-batons")

    def test_king_of_coins_exists(self):
        """CROWNS canonical enum, 'Coins' Italian-family display."""
        card = TarotCard.objects.get(
            deck_variant=self.deck, suit="CROWNS", number=14,
        )
        self.assertEqual(card.name, "King of Coins")

    def test_no_middle_arcana(self):
        """Minchiate has only MAJOR + MINOR; MIDDLE is Earthman-only."""
        self.assertEqual(
            TarotCard.objects.filter(deck_variant=self.deck, arcana="MIDDLE").count(),
            0,
        )


class CardImageFilenameA2Test(TestCase):
    """Sprint A.2 — `TarotCard.image_filename` + `display_suit_name` properties.

    `image_filename` derives a v2-convention-compliant filename string per
    [[reference-card-image-naming-convention]] using `deck_variant.family`
    to translate canonical Earthman SUIT enum (BRANDS/CROWNS/GRAILS/BLADES)
    into the family-authentic display slug (batons/coins/cups/swords for
    italian; wands/pentacles/cups/swords for english; brands/crowns/grails/blades
    for earthman). `display_suit_name` returns the capitalized version for
    user-facing labels (tooltip, stat-block, card title).
    """

    def setUp(self):
        self.minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
        self.rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
        self.earthman = DeckVariant.objects.get(slug="earthman")

    # ── image_filename: Minchiate (italian family) trumps ───────────────────
    def test_filename_il_matto(self):
        card = TarotCard.objects.get(deck_variant=self.minchiate, slug="il-matto")
        self.assertEqual(
            card.image_filename,
            "minchiate-fiorentine-1860-1890-trumps-00-il-matto.png",
        )

    def test_filename_il_gobbo(self):
        card = TarotCard.objects.get(deck_variant=self.minchiate, slug="il-gobbo")
        self.assertEqual(
            card.image_filename,
            "minchiate-fiorentine-1860-1890-trumps-11-il-gobbo.png",
        )

    def test_filename_le_trombe(self):
        card = TarotCard.objects.get(deck_variant=self.minchiate, slug="le-trombe")
        self.assertEqual(
            card.image_filename,
            "minchiate-fiorentine-1860-1890-trumps-40-le-trombe.png",
        )

    def test_filename_l_acqua_w_apostrophe_restored(self):
        card = TarotCard.objects.get(deck_variant=self.minchiate, slug="l-acqua")
        self.assertEqual(
            card.image_filename,
            "minchiate-fiorentine-1860-1890-trumps-21-l-acqua.png",
        )

    # ── image_filename: Minchiate (italian family) minors ───────────────────
    def test_filename_ace_of_batons(self):
        card = TarotCard.objects.get(
            deck_variant=self.minchiate, suit="BRANDS", number=1,
        )
        self.assertEqual(
            card.image_filename,
            "minchiate-fiorentine-1860-1890-batons-01.png",
        )

    def test_filename_ten_of_coins(self):
        card = TarotCard.objects.get(
            deck_variant=self.minchiate, suit="CROWNS", number=10,
        )
        self.assertEqual(
            card.image_filename,
            "minchiate-fiorentine-1860-1890-coins-10.png",
        )

    def test_filename_page_of_cups_has_court_suffix(self):
        card = TarotCard.objects.get(
            deck_variant=self.minchiate, suit="GRAILS", number=11,
        )
        self.assertEqual(
            card.image_filename,
            "minchiate-fiorentine-1860-1890-cups-11-page.png",
        )

    def test_filename_king_of_swords_has_court_suffix(self):
        card = TarotCard.objects.get(
            deck_variant=self.minchiate, suit="BLADES", number=14,
        )
        self.assertEqual(
            card.image_filename,
            "minchiate-fiorentine-1860-1890-swords-14-king.png",
        )

    # ── image_filename: RWS (english family) — derived path even when
    # has_card_images=False (template decides whether to render the <img>) ──
    def test_filename_rws_ace_of_cups_uses_english_suit_slug(self):
        """Post-revocab, RWS Ace of Cups has suit=GRAILS but english-family
        display slug 'cups'."""
        card = TarotCard.objects.get(deck_variant=self.rws, slug="ace-of-cups")
        self.assertEqual(
            card.image_filename,
            "tarot-rider-waite-smith-cups-01.png",
        )

    def test_filename_rws_the_fool_uses_majors_category(self):
        """English Tarot family uses 'majors' as the trump-category slug,
        not 'trumps' (which is the Italian-Minchiate convention)."""
        card = TarotCard.objects.get(deck_variant=self.rws, slug="the-fool")
        self.assertEqual(
            card.image_filename,
            "tarot-rider-waite-smith-majors-00-the-fool.png",
        )

    def test_filename_rws_king_of_pentacles_uses_pentacles_slug(self):
        """CROWNS canonical, english-family display 'pentacles'."""
        card = TarotCard.objects.get(deck_variant=self.rws, slug="king-of-pentacles")
        self.assertEqual(
            card.image_filename,
            "tarot-rider-waite-smith-pentacles-14-king.png",
        )

    # ── image_filename: Earthman (earthman family) ──────────────────────────
    def test_filename_earthman_uses_earthman_suit_slug(self):
        """Earthman family is identity-mapped: BRANDS→brands etc. Returns
        a path even though Earthman.has_card_images=False — the template
        decides whether to USE the path."""
        card = TarotCard.objects.filter(
            deck_variant=self.earthman, suit="BRANDS",
        ).first()
        self.assertTrue(card.image_filename.startswith("earthman-brands-"))

    # ── display_suit_name ───────────────────────────────────────────────────
    def test_display_suit_name_italian_brands_is_batons(self):
        card = TarotCard.objects.get(
            deck_variant=self.minchiate, suit="BRANDS", number=1,
        )
        self.assertEqual(card.display_suit_name, "Batons")

    def test_display_suit_name_italian_crowns_is_coins(self):
        card = TarotCard.objects.get(
            deck_variant=self.minchiate, suit="CROWNS", number=1,
        )
        self.assertEqual(card.display_suit_name, "Coins")

    def test_display_suit_name_english_crowns_is_pentacles(self):
        card = TarotCard.objects.get(deck_variant=self.rws, slug="ace-of-pentacles")
        self.assertEqual(card.display_suit_name, "Pentacles")

    def test_display_suit_name_earthman_brands_is_brands(self):
        card = TarotCard.objects.filter(
            deck_variant=self.earthman, suit="BRANDS",
        ).first()
        self.assertEqual(card.display_suit_name, "Brands")

    def test_display_suit_name_empty_for_major_arcana(self):
        card = TarotCard.objects.get(deck_variant=self.minchiate, slug="il-matto")
        self.assertEqual(card.display_suit_name, "")
