import random
import uuid

from datetime import timedelta
from django.db import models
from django.db.models import UniqueConstraint
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.conf import settings
from django.utils import timezone

from apps.lyric.models import Token


class Room(models.Model):
    GATHERING = "GATHERING"
    OPEN = "OPEN"
    RENEWAL_DUE = "RENEWAL_DUE"
    GATE_STATUS_CHOICES = [
        (GATHERING, "GATHERING GAMERS"),
        (OPEN, "Open"),
        (RENEWAL_DUE, "Renewal Due"),
    ]

    PRIVATE = "PRIVATE"
    PUBLIC = "PUBLIC"
    INVITE_ONLY = "INVITE ONLY"
    VISIBILITY_CHOICES = [
        (PRIVATE, "Private"),
        (PUBLIC, "Public"),
        (INVITE_ONLY, "Invite Only"),
    ]

    ROLE_SELECT = "ROLE_SELECT"
    SIG_SELECT = "SIG_SELECT"
    SKY_SELECT = "SKY_SELECT"
    IN_GAME = "IN_GAME"
    TABLE_STATUS_CHOICES = [
        (ROLE_SELECT, "Role Select"),
        (SIG_SELECT, "Significator Select"),
        (SKY_SELECT, "Sky Select"),
        (IN_GAME, "In Game"),
    ]

    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
    name = models.CharField(max_length=200)
    owner = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="owned_rooms"
    )
    visibility = models.CharField(max_length=20, choices=VISIBILITY_CHOICES, default=PRIVATE)
    gate_status = models.CharField(max_length=20, choices=GATE_STATUS_CHOICES, default=GATHERING)
    table_status = models.CharField(
        max_length=20, choices=TABLE_STATUS_CHOICES, null=True, blank=True
    )
    sig_select_started_at = models.DateTimeField(null=True, blank=True)
    renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7))
    created_at = models.DateTimeField(auto_now_add=True)
    board_state = models.JSONField(default=dict)
    seed_count = models.IntegerField(default=12)


class GateSlot(models.Model):
    EMPTY = "EMPTY"
    RESERVED = "RESERVED"
    FILLED = "FILLED"
    STATUS_CHOICES = [
        (EMPTY, "Empty"),
        (RESERVED, "Reserved"),
        (FILLED, "Filled"),
    ]

    room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="gate_slots")
    slot_number = models.IntegerField()
    gamer = models.ForeignKey(
        settings.AUTH_USER_MODEL, null=True, blank=True,
        on_delete=models.SET_NULL, related_name="gate_slots"
    )
    funded_by = models.ForeignKey(
        settings.AUTH_USER_MODEL, null=True, blank=True,
        on_delete=models.SET_NULL, related_name="funded_slots"
    )
    status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=EMPTY)
    reserved_at = models.DateTimeField(null=True, blank=True)
    filled_at = models.DateTimeField(null=True, blank=True)
    debited_token_type = models.CharField(max_length=8, null=True, blank=True)
    debited_token_expires_at = models.DateTimeField(null=True, blank=True)


class RoomInvite(models.Model):
    PENDING = "PENDING"
    ACCEPTED = "ACCEPTED"
    DECLINED = "DECLINED"
    STATUS_CHOICES = [
        (PENDING, "Pending"),
        (ACCEPTED, "Accepted"),
        (DECLINED, "Declined"),
    ]

    room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="invites")
    inviter = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="sent_invites"
    )
    invitee_email = models.EmailField()
    status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=PENDING)
    created_at = models.DateTimeField(auto_now_add=True)


@receiver(post_save, sender=Room)
def create_gate_slots(sender, instance, created, **kwargs):
    if created:
        for i in range(1, 7):
            GateSlot.objects.create(room=instance, slot_number=i)


def select_token(user):
    """Pick a token for `drop_token`'s rails-click flow (no explicit
    kit-bag choice). Equip-gated: trinkets (PASS/BAND/COIN) must be DON-ed
    to fire; CARTE is opt-in only (kit-bag click sets a `token_id` POST
    param that bypasses this picker). No equipped trinket OR equipped
    trinket invalid for this gate → fall back to FREE (FEFO) → TITHE → None.

    Bug 2026-05-21 fix: previous flat-priority chain (PASS → BAND → COIN
    → FREE → TITHE, regardless of equip state) silently consumed a DOFFed
    COIN — user saw nothing change in the wallet ("free for all" symptom).
    Equip slot now gates trinket use entirely. See [[feedback-equip-slot-
    gates-trinket-use]] for the rationale.
    """
    # Query the trinket fresh from the user's tokens (not via the cached
    # FK descriptor) — defensive against stale Token state from earlier
    # in the request lifecycle + cheap filter on the owned-set so a
    # dangling FK to a deleted token resolves to None instead of crashing.
    if user.equipped_trinket_id is not None:
        trinket = user.tokens.filter(pk=user.equipped_trinket_id).first()
    else:
        trinket = None
    if trinket is not None:
        if trinket.token_type == Token.PASS and user.is_staff:
            return trinket
        if trinket.token_type == Token.BAND:
            return trinket
        if trinket.token_type == Token.COIN and trinket.current_room_id is None:
            return trinket
        # CARTE excluded — opt-in via explicit kit-bag click; idle CARTE-
        # holders get FREE/TITHE fallback.
    free = user.tokens.filter(
        token_type=Token.FREE,
        expires_at__gt=timezone.now(),
    ).order_by("expires_at").first()
    if free:
        return free
    return user.tokens.filter(token_type=Token.TITHE).first()


def debit_token(user, slot, token):
    slot.debited_token_type = token.token_type
    if token.token_type == Token.COIN:
        token.current_room = slot.room
        period = slot.room.renewal_period or timedelta(days=7)
        token.next_ready_at = timezone.now() + period
        token.save()
        # Parity w. CARTE's drop_token unequip: a deposited COIN is committed
        # elsewhere & can't be re-used as the active trinket until the deposit
        # is released, so clear `equipped_trinket` to drop it out of the Kit
        # Bag's Trinket slot. PASS stays equipped (auto-admits, never deposits).
        if user.equipped_trinket_id == token.pk:
            user.equipped_trinket = None
            user.save(update_fields=["equipped_trinket"])
    elif token.token_type == Token.CARTE:
        pass  # current_room already set in drop_token; token not consumed
    elif token.token_type not in (Token.PASS, Token.BAND):
        slot.debited_token_expires_at = token.expires_at
        token.delete()
    slot.gamer = user
    slot.status = GateSlot.FILLED
    slot.filled_at = timezone.now()
    slot.save()

    room = slot.room
    if not room.gate_slots.filter(status=GateSlot.EMPTY).exists():
        room.gate_status = Room.OPEN
        room.save()


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


class TableSeat(models.Model):
    PC = "PC"
    BC = "BC"
    SC = "SC"
    AC = "AC"
    NC = "NC"
    EC = "EC"
    ROLE_CHOICES = [
        (PC, "Player"),
        (BC, "Builder"),
        (SC, "Shepherd"),
        (AC, "Alchemist"),
        (NC, "Narrator"),
        (EC, "Economist"),
    ]
    PARTNER_MAP = {PC: BC, BC: PC, SC: AC, AC: SC, NC: EC, EC: NC}

    room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="table_seats")
    gamer = models.ForeignKey(
        settings.AUTH_USER_MODEL, null=True, blank=True,
        on_delete=models.SET_NULL, related_name="table_seats"
    )
    slot_number = models.IntegerField()
    role = models.CharField(max_length=2, choices=ROLE_CHOICES, null=True, blank=True)
    role_revealed = models.BooleanField(default=False)
    seat_position = models.IntegerField(null=True, blank=True)
    significator = models.ForeignKey(
        "TarotCard", null=True, blank=True,
        on_delete=models.SET_NULL, related_name="significator_seats",
    )
    deck_variant = models.ForeignKey(
        "DeckVariant", null=True, blank=True,
        on_delete=models.SET_NULL, related_name="active_seats",
    )


class DeckVariant(models.Model):
    """A named deck variant, e.g. Earthman or Tarot (Rider-Waite-Smith)."""

    EARTHMAN = "earthman"
    ITALIAN = "italian"
    ENGLISH = "english"
    PLAYING = "playing"
    FAMILY_CHOICES = [
        (EARTHMAN, "Earthman"),
        (ITALIAN, "Italian / Minchiate"),
        (ENGLISH, "English Tarot"),
        (PLAYING, "Playing card"),
    ]

    # Per-family translation tables: canonical SUIT enum (Earthman vocab) →
    # family-authentic display slug used in image filenames + UI labels.
    # See [[reference-card-image-naming-convention]] v2.
    _SUIT_SLUG_BY_FAMILY = {
        EARTHMAN: {"BRANDS": "brands", "CROWNS": "crowns", "GRAILS": "grails", "BLADES": "blades"},
        ITALIAN:  {"BRANDS": "batons", "CROWNS": "coins",  "GRAILS": "cups",   "BLADES": "swords"},
        ENGLISH:  {"BRANDS": "wands",  "CROWNS": "pentacles", "GRAILS": "cups", "BLADES": "swords"},
        PLAYING:  {"BRANDS": "clubs",  "CROWNS": "diamonds", "GRAILS": "hearts", "BLADES": "spades"},
    }
    _TRUMP_CATEGORY_BY_FAMILY = {
        EARTHMAN: "trumps",
        ITALIAN:  "trumps",
        ENGLISH:  "majors",
        PLAYING:  None,  # 52-card decks: no trump category (jokers handled separately)
    }

    name = models.CharField(max_length=100, unique=True)
    slug = models.SlugField(unique=True)
    card_count = models.IntegerField()
    description = models.TextField(blank=True)
    is_default = models.BooleanField(default=False)
    family = models.CharField(max_length=10, choices=FAMILY_CHOICES, default=EARTHMAN)
    has_card_images = models.BooleanField(default=True)
    is_polarized = models.BooleanField(default=False)

    @property
    def back_image_url(self):
        """Full static-asset URL for this deck's card-back image, or empty
        string if the deck has no images (legacy text-only mode). Sprint A.4
        — consumed by the card-stack icon SVG to render the actual deck back
        as the visible card-stack rect-fills instead of the placeholder
        `--priUser` solid color."""
        if not self.has_card_images:
            return ""
        from django.templatetags.static import static
        return static(
            f"apps/epic/images/cards-faces/{self.slug}/{self.slug}-back.png"
        )

    def suit_slug(self, canonical_suit):
        """Map canonical SUIT enum → family-authentic filename slug.
        e.g. ('italian', 'BRANDS') → 'batons'."""
        return self._SUIT_SLUG_BY_FAMILY[self.family][canonical_suit]

    def suit_display(self, canonical_suit):
        """User-facing capitalized suit label, e.g. ('italian', 'BRANDS') → 'Batons'."""
        return self.suit_slug(canonical_suit).capitalize()

    @property
    def trump_category(self):
        """Filename-slug category for trump cards in this family."""
        return self._TRUMP_CATEGORY_BY_FAMILY[self.family]

    @property
    def short_key(self):
        """First dash-separated word of slug — used as an HTML id component."""
        return self.slug.split('-')[0]

    def __str__(self):
        return f"{self.name} ({self.card_count} cards)"


class TarotCard(models.Model):
    MAJOR = "MAJOR"
    MINOR = "MINOR"            # pip cards (numbers 1-10)
    MIDDLE = "MIDDLE"          # Earthman court cards (M/J/Q/K, numbers 11-14)
    ARCANA_CHOICES = [
        (MAJOR,  "Major Arcana"),
        (MINOR,  "Minor Arcana"),
        (MIDDLE, "Middle Arcana"),
    ]

    # Canonical SUIT_CHOICES = Earthman vocabulary (2026-05-25 lock).
    # Per-family display + filename slug mapping lives in image_filename /
    # display_suit_name properties driven by DeckVariant.family.
    BRANDS = "BRANDS"
    CROWNS = "CROWNS"
    GRAILS = "GRAILS"
    BLADES = "BLADES"
    SUIT_CHOICES = [
        (BRANDS, "Brands"),
        (CROWNS, "Crowns"),
        (GRAILS, "Grails"),
        (BLADES, "Blades"),
    ]

    deck_variant = models.ForeignKey(
        DeckVariant, null=True, blank=True,
        on_delete=models.CASCADE, related_name="cards",
    )
    name = models.CharField(max_length=200)
    arcana = models.CharField(max_length=6, choices=ARCANA_CHOICES)
    suit = models.CharField(max_length=10, choices=SUIT_CHOICES, null=True, blank=True)
    icon = models.CharField(max_length=50, blank=True, default='')  # FA icon override (e.g. major arcana)
    number = models.IntegerField()  # 0–21 major (Fiorentine); 0–49 major (Earthman); 1–14 minor
    slug = models.SlugField(max_length=120)
    correspondence = models.CharField(max_length=200, blank=True)  # Tarot / Minchiate equivalent
    group = models.CharField(max_length=100, blank=True)           # Earthman major grouping
    reversal_qualifier = models.CharField(max_length=200, blank=True, default='') # polysemous (cf [[feedback-reversal-qualifier-dual-role]]): on non-Majors w. no polarity qualifier it's the reversal-face qualifier (e.g. "Vacant"); on Majors w. polarity qualifiers it's the NAME-SWAP for the reversal face (e.g. "Patrilineage" for card 34). `applet_face()` routes on `arcana`.
    reversal_drops_qualifier = models.BooleanField(default=False)  # Pattern B' cards (16-18): reversal face shows the name swap ALONE, no qualifier. Pattern B (default False): polarity qualifier persists on the reversal face.
    levity_qualifier = models.CharField(max_length=100, blank=True, default='')
    gravity_qualifier = models.CharField(max_length=100, blank=True, default='')
    levity_emanation = models.CharField(max_length=200, blank=True, default='')   # polarity-split upright (cards 48-49)
    gravity_emanation = models.CharField(max_length=200, blank=True, default='')
    levity_reversal = models.CharField(max_length=200, blank=True, default='')    # polarity-split reversal (card 48)
    gravity_reversal = models.CharField(max_length=200, blank=True, default='')
    italic_word = models.CharField(max_length=50, blank=True, default='')         # word(s) inside any title slot to wrap in <em> at render time (e.g. "Stalking" for trumps 19-21)
    energies = models.JSONField(default=list)      # list of {type, effect} dicts — Energy interactions
    operations = models.JSONField(default=list)    # list of {type, effect} dicts — Operation interactions
    keywords_upright = models.JSONField(default=list)
    keywords_reversed = models.JSONField(default=list)
    cautions = models.JSONField(default=list)

    class Meta:
        ordering = ["deck_variant", "arcana", "suit", "number"]
        unique_together = [("deck_variant", "slug")]

    @staticmethod
    def _to_roman(n):
        if n == 0:
            return '0'
        val  = [50, 40, 10,  9,  5,  4,  1]
        syms = ['L','XL','X','IX','V','IV','I']
        result = ''
        for v, s in zip(val, syms):
            while n >= v:
                result += s
                n -= v
        return result

    @property
    def corner_rank(self):
        if self.arcana == self.MAJOR:
            return self._to_roman(self.number)
        court = {11: 'M', 12: 'J', 13: 'Q', 14: 'K'}
        if self.number in court:
            return court[self.number]
        return 'A' if self.number == 1 else str(self.number)

    def emanation_for(self, polarity):
        """Return the upright title for a given polarity ('levity' or 'gravity').
        Falls back to name_title (group prefix stripped) for cards without a
        polarity split."""
        if polarity == 'levity' and self.levity_emanation:
            return self.levity_emanation
        if polarity == 'gravity' and self.gravity_emanation:
            return self.gravity_emanation
        return self.name_title

    def reversal_for(self, polarity):
        """Return the reversed title for a given polarity.
        Falls back to reversal_qualifier (blank = same as emanation_for)."""
        if polarity == 'levity' and self.levity_reversal:
            return self.levity_reversal
        if polarity == 'gravity' and self.gravity_reversal:
            return self.gravity_reversal
        return self.reversal_qualifier or self.emanation_for(polarity)

    def applet_face(self, polarity='gravity', reversed=False):
        """Return the rendering payload for a card face in the My Sign /
        My Sea applets — mirrors `populateCard` in `stage-card.js`. Four
        patterns:

        - **Polarity-split FULL title** (cards 19-21, 48-49): single-line
          title from `emanation_for` / `reversal_for`; qualifier blank.
        - **Pattern B — Major w. polarity qualifier + reversal name-swap**
          (cards 2-5, 10-15, 22-35, 41): `reversal_qualifier` carries the
          REVERSAL-face NAME (e.g. "Patrilineage" for card 34). Polarity
          qualifier persists across both faces. Renders: `<reversal_qual>,`
          / `<polarity_qualifier>` on the reversal face.
        - **Pattern B' — Major w. name-swap that DROPS qualifier on
          reversal** (cards 16-18 — Realms): same as Pattern B but the
          reversal face renders only the name (e.g. "Shame"), no
          qualifier. Marked via `reversal_drops_qualifier=True`.
        - **Non-Major (middle / minor)**: qualifier ABOVE title; reversal
          face uses `reversal_qualifier` as the QUALIFIER (NOT a name
          swap) — e.g. "Queen of Crowns" stays as the title, "Vacant"
          renders as the reversal qualifier.

        Returns a 3-key dict:
            {
                "title":           str,  # title (w. trailing comma for Major+qual)
                "qualifier":       str,  # qualifier text (may be blank)
                "qualifier_first": bool, # True ⇒ qualifier above title; False ⇒ below
            }
        """
        is_major = (self.arcana == self.MAJOR)
        if reversed:
            override = (self.levity_reversal if polarity == 'levity'
                        else self.gravity_reversal)
            if override:
                return {"title": override, "qualifier": "", "qualifier_first": False}
            polarity_qualifier = (
                self.levity_qualifier if polarity == 'levity'
                else self.gravity_qualifier
            )
            # Pattern B / B' — Major w. both polarity qualifier + reversal
            # name-swap. `reversal_qualifier` is the SWAPPED NAME (not a
            # qualifier) for these Majors. See `reversal_qualifier` field
            # docstring + [[feedback-reversal-qualifier-dual-role]].
            if is_major and self.reversal_qualifier and polarity_qualifier:
                if self.reversal_drops_qualifier:
                    # Pattern B' (16-18): single-line reversal name.
                    return {"title": self.reversal_qualifier,
                            "qualifier": "", "qualifier_first": False}
                # Pattern B (2-5, 10-15, 22-35, 41): swapped name + polarity
                # qualifier carried across both faces.
                return {"title": self.reversal_qualifier + ",",
                        "qualifier": polarity_qualifier,
                        "qualifier_first": False}
            # Non-Major OR Major-without-polarity-qualifier: reversal_
            # qualifier is the qualifier (Pattern A / fallback).
            qualifier = self.reversal_qualifier or polarity_qualifier
        else:
            override = (self.levity_emanation if polarity == 'levity'
                        else self.gravity_emanation)
            if override:
                return {"title": override, "qualifier": "", "qualifier_first": False}
            qualifier = (self.levity_qualifier if polarity == 'levity'
                         else self.gravity_qualifier)

        title = self.name_title
        if is_major and qualifier:
            return {"title": title + ",", "qualifier": qualifier,
                    "qualifier_first": False}
        return {"title": title, "qualifier": qualifier, "qualifier_first": True}

    @property
    def name_group(self):
        """Returns 'Group N:' prefix if the name contains ': ', else ''."""
        if ': ' in self.name:
            return self.name.split(': ', 1)[0] + ':'
        return ''

    @property
    def name_title(self):
        """Returns the title after 'Group N: ', or the full name if no colon."""
        if ': ' in self.name:
            return self.name.split(': ', 1)[1]
        return self.name

    @property
    def title_squeeze_class(self):
        """No-op kept for template compatibility. Title fit is now handled by
        a smaller base `font-size` on `.fan-card-name`/`.fan-card-reversal-*`
        plus `text-wrap: balance` (see `_card-deck.scss`) — every long-title
        card fits naturally without per-card CSS hacks."""
        return ''

    @property
    def suit_icon(self):
        if self.icon:
            return self.icon
        if self.arcana == self.MAJOR:
            # Sprint A.7.5 — trumps default to fa-hand-dots so the chip (and
            # any text-mode corner) always has a symbol below the rank. Per-
            # card overrides still win via the `self.icon` branch above (the
            # Earthman seed sets `icon="fa-hand-dots"` explicitly for trumps
            # 2+, which was the only place this fallback used to live; trumps
            # 0/1 + every Minchiate trump now pick it up for free).
            return 'fa-hand-dots'
        return {
            self.BRANDS: 'fa-wand-sparkles',
            self.CROWNS: 'fa-crown',
            self.GRAILS: 'fa-trophy',
            self.BLADES: 'fa-gun',
        }.get(self.suit, '')

    # Tarot-family courts: rank 11=page, 12=knight, 13=queen, 14=king. Playing
    # family (3 courts: jack/queen/king at ranks 11-13) handled separately when
    # a playing deck is seeded — Sprint A.2 covers tarot families only.
    _COURT_NAME_BY_RANK = {11: "page", 12: "knight", 13: "queen", 14: "king"}

    @property
    def image_filename(self):
        """v2-convention filename per [[reference-card-image-naming-convention]].
        Always derives a path; the template decides whether to actually render
        an <img> based on `deck_variant.has_card_images`."""
        deck = self.deck_variant
        if self.arcana == self.MAJOR:
            return f"{deck.slug}-{deck.trump_category}-{self.number:02d}-{self.slug}.png"
        # MINOR or MIDDLE: <deck-slug>-<suit-slug>-<NN>[-<court>].png
        suit_slug = deck.suit_slug(self.suit)
        rank = f"{self.number:02d}"
        court = self._COURT_NAME_BY_RANK.get(self.number)
        if court:
            return f"{deck.slug}-{suit_slug}-{rank}-{court}.png"
        return f"{deck.slug}-{suit_slug}-{rank}.png"

    @property
    def display_suit_name(self):
        """Family-authentic capitalized suit label (e.g. 'Batons' for italian
        BRANDS, 'Pentacles' for english CROWNS). Empty for major arcana."""
        if not self.suit:
            return ""
        return self.deck_variant.suit_display(self.suit)

    @property
    def image_url(self):
        """Full static-asset URL for the card image, or empty string if the
        deck has no images (legacy text-only mode). Constructed via Django's
        `static` helper so STATIC_URL prefix + manifest-versioning (when
        WhiteNoise compressed manifest is active) flow through."""
        if not self.deck_variant.has_card_images:
            return ""
        from django.templatetags.static import static
        return static(
            f"apps/epic/images/cards-faces/{self.deck_variant.slug}/{self.image_filename}"
        )

    @property
    def cautions_json(self):
        import json
        return json.dumps(self.cautions)

    @property
    def energies_json(self):
        import json
        return json.dumps(self.energies)

    @property
    def operations_json(self):
        import json
        return json.dumps(self.operations)

    def __str__(self):
        return self.name


class TarotDeck(models.Model):
    """One shuffled deck per room, scoped to the founder's chosen DeckVariant."""

    room = models.OneToOneField(Room, on_delete=models.CASCADE, related_name="tarot_deck")
    deck_variant = models.ForeignKey(
        DeckVariant, null=True, blank=True,
        on_delete=models.SET_NULL, related_name="active_decks",
    )
    drawn_card_ids = models.JSONField(default=list)
    created_at = models.DateTimeField(auto_now_add=True)

    @property
    def remaining_count(self):
        total = self.deck_variant.card_count if self.deck_variant else 0
        return total - len(self.drawn_card_ids)

    def draw(self, n=1):
        """Draw n cards at random. Returns list of (TarotCard, reversed: bool) tuples."""
        available = list(
            TarotCard.objects.filter(deck_variant=self.deck_variant)
            .exclude(id__in=self.drawn_card_ids)
        )
        if len(available) < n:
            raise ValueError(
                f"Not enough cards remaining: {len(available)} available, {n} requested"
            )
        drawn = random.sample(available, n)
        self.drawn_card_ids = self.drawn_card_ids + [card.id for card in drawn]
        self.save(update_fields=["drawn_card_ids"])
        return [(card, random.choice([True, False])) for card in drawn]

    def shuffle(self):
        """Reset the deck so all variant cards are available again."""
        self.drawn_card_ids = []
        self.save(update_fields=["drawn_card_ids"])


# ── SigReservation — provisional card hold during SIG_SELECT ──────────────────

class SigReservation(models.Model):
    LEVITY = 'levity'
    GRAVITY = 'gravity'
    POLARITY_CHOICES = [(LEVITY, 'Levity'), (GRAVITY, 'Gravity')]

    room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name='sig_reservations')
    gamer = models.ForeignKey(
        settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sig_reservations'
    )
    seat = models.ForeignKey(
        'TableSeat', null=True, blank=True,
        on_delete=models.SET_NULL, related_name='sig_reservation',
    )
    card = models.ForeignKey(
        'TarotCard', on_delete=models.CASCADE, related_name='sig_reservations'
    )
    role = models.CharField(max_length=2)
    polarity = models.CharField(max_length=7, choices=POLARITY_CHOICES)
    reserved_at = models.DateTimeField(auto_now_add=True)
    ready = models.BooleanField(default=False)
    countdown_remaining = models.IntegerField(null=True, blank=True)

    class Meta:
        constraints = [
            UniqueConstraint(
                fields=['room', 'gamer'],
                name='one_sig_reservation_per_gamer_per_room',
            ),
            UniqueConstraint(
                fields=['room', 'card', 'polarity'],
                name='one_reservation_per_card_per_polarity_per_room',
            ),
        ]


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

def _room_deck_variant(room):
    """Return the DeckVariant in use for this room.

    Looks up the deck committed to any TableSeat in the room (all seats share the
    same deck per game).  Falls back to the room owner's equipped_deck for rooms
    created before deck contribution was wired.
    """
    seat = room.table_seats.filter(deck_variant__isnull=False).first()
    if seat:
        return seat.deck_variant
    return room.owner.equipped_deck


def sig_deck_cards(room):
    """Return 36 TarotCard objects forming the Significator deck (18 unique × 2).

    PC/BC pair → BRANDS + CROWNS Middle Arcana court cards (11–14): 8 unique
    SC/AC pair → BLADES + GRAILS Middle Arcana court cards (11–14): 8 unique
    NC/EC pair → MAJOR arcana numbers 0 and 1:                      2 unique
    Total: 18 unique × 2 (levity + gravity piles) = 36 cards.
    """
    unique_cards = _sig_unique_cards_for_deck(_room_deck_variant(room))
    return unique_cards + unique_cards  # × 2 = 36


def _sig_unique_cards_for_deck(deck_variant):
    """Return the 18 unique TarotCards forming one sig pile for the given
    deck variant. Shared between room sig-select (called via _sig_unique_cards
    after room → deck_variant lookup) and the solo My Sign picker (called
    via personal_sig_cards from User.equipped_deck).

    "Court cards" are recognized by rank (11=Page, 12=Knight, 13=Queen,
    14=King) regardless of arcana classification: Earthman classifies its
    courts as MIDDLE arcana, but other tarot families (Minchiate Fiorentine,
    RWS) classify them as MINOR. Including both classifications gives every
    deck the symmetric 18-card pile (16 courts × 4 suits + 2 majors at
    numbers 0/1) instead of letting non-Earthman decks fall to 2 cards just
    because they don't use the MIDDLE classification. Cross-deck eligibility
    is NOT segment-limited — all 4 suits' courts qualify per user spec
    2026-05-25.
    """
    if deck_variant is None:
        return []
    courts = list(TarotCard.objects.filter(
        deck_variant=deck_variant,
        arcana__in=[TarotCard.MIDDLE, TarotCard.MINOR],
        suit__in=[TarotCard.BRANDS, TarotCard.CROWNS, TarotCard.BLADES, TarotCard.GRAILS],
        number__in=[11, 12, 13, 14],
    ))
    major = list(TarotCard.objects.filter(
        deck_variant=deck_variant,
        arcana=TarotCard.MAJOR,
        number__in=[0, 1],
    ))
    return courts + major


def _sig_unique_cards(room):
    """Return the 18 unique TarotCard objects that form one sig pile."""
    return _sig_unique_cards_for_deck(_room_deck_variant(room))


def personal_sig_cards(user):
    """Solo equivalent of levity_sig_cards / gravity_sig_cards — uses
    User.equipped_deck instead of room.deck_variant. For the Game Sign
    picker at /billboard/my-sign/. Same 18-card pile (16 middle arcana +
    Major 0 + 1), filtered by the user's Note unlocks (Schizo/Nomad lines).

    Fallback: if the user has no equipped_deck (e.g. their only deck is
    in-use as a TableSeat.deck_variant in an active room), fall back to
    the Earthman deck. The picker UI labels this "Earthman [Shabby
    Paperboard]" via a Brief banner — the cards are identical, the deck
    identity is just a UX framing for "temporary, doesn't belong to your
    Game Kit inventory"."""
    deck = user.equipped_deck or DeckVariant.objects.filter(slug="earthman").first()
    return _filter_major_unlocks(_sig_unique_cards_for_deck(deck), user)


def _filter_major_unlocks(cards, user):
    """Remove Nomad (0) and Schizo (1) unless the user has the matching Note unlock."""
    if user is None or not user.is_authenticated:
        return [c for c in cards if c.arcana != TarotCard.MAJOR]
    earned = set(user.notes.values_list("slug", flat=True))
    return [
        c for c in cards
        if c.arcana != TarotCard.MAJOR
        or (c.number == 0 and earned & {"nomad",  "super-nomad"})
        or (c.number == 1 and earned & {"schizo", "super-schizo"})
    ]


def levity_sig_cards(room, user=None):
    """Cards available to the levity group (PC/NC/SC), filtered by user's Note unlocks."""
    return _filter_major_unlocks(_sig_unique_cards(room), user)


def gravity_sig_cards(room, user=None):
    """Cards available to the gravity group (BC/EC/AC), filtered by user's Note unlocks."""
    return _filter_major_unlocks(_sig_unique_cards(room), user)


def sig_seat_order(room):
    """Return TableSeats in canonical PC→NC→EC→SC→AC→BC order."""
    _order = {r: i for i, r in enumerate(SIG_SEAT_ORDER)}
    seats = list(room.table_seats.all())
    return sorted(seats, key=lambda s: _order.get(s.role, 99))


def active_sig_seat(room):
    """Return the first seat without a significator in canonical order, or None."""
    for seat in sig_seat_order(room):
        if seat.significator_id is None:
            return seat
    return None


# ── Astrological reference tables (seeded, never user-edited) ─────────────────

class Sign(models.Model):
    FIRE  = 'Fire'
    EARTH = 'Earth'
    AIR   = 'Air'
    WATER = 'Water'
    ELEMENT_CHOICES = [(e, e) for e in (FIRE, EARTH, AIR, WATER)]

    CARDINAL = 'Cardinal'
    FIXED    = 'Fixed'
    MUTABLE  = 'Mutable'
    MODALITY_CHOICES = [(m, m) for m in (CARDINAL, FIXED, MUTABLE)]

    name         = models.CharField(max_length=20, unique=True)
    symbol       = models.CharField(max_length=5)   # ♈ ♉ … ♓
    element      = models.CharField(max_length=5,  choices=ELEMENT_CHOICES)
    modality     = models.CharField(max_length=8,  choices=MODALITY_CHOICES)
    order        = models.PositiveSmallIntegerField(unique=True)  # 0–11, Aries first
    start_degree = models.FloatField()                            # 0, 30, 60 … 330

    class Meta:
        ordering = ['order']

    def __str__(self):
        return self.name


class Planet(models.Model):
    name   = models.CharField(max_length=20, unique=True)
    symbol = models.CharField(max_length=5)   # ☉ ☽ ☿ ♀ ♂ ♃ ♄ ♅ ♆ ♇
    order  = models.PositiveSmallIntegerField(unique=True)  # 0–9, Sun first

    class Meta:
        ordering = ['order']

    def __str__(self):
        return self.name


class AspectType(models.Model):
    name   = models.CharField(max_length=20, unique=True)
    symbol = models.CharField(max_length=5)   # ☌ ⚹ □ △ ☍
    angle  = models.PositiveSmallIntegerField()  # 0, 60, 90, 120, 180
    orb    = models.FloatField()                 # max allowed orb in degrees

    class Meta:
        ordering = ['angle']

    def __str__(self):
        return self.name


class HouseLabel(models.Model):
    """Life-area label for each of the 12 astrological houses (distinctions)."""

    number   = models.PositiveSmallIntegerField(unique=True)  # 1–12
    name     = models.CharField(max_length=30)
    keywords = models.CharField(max_length=100, blank=True)

    class Meta:
        ordering = ['number']

    def __str__(self):
        return f"{self.number}: {self.name}"


# ── Character ─────────────────────────────────────────────────────────────────

class Character(models.Model):
    """A gamer's player-character for one seat in one game session.

    Lifecycle:
      - Created (draft) when gamer opens CAST SKY overlay.
      - confirmed_at set on confirm → locked.
      - retired_at set on retirement → archived (seat may hold a new Character).

    Active character for a seat: confirmed_at__isnull=False, retired_at__isnull=True.
    """

    PORPHYRY  = 'O'
    PLACIDUS  = 'P'
    KOCH      = 'K'
    WHOLE     = 'W'
    HOUSE_SYSTEM_CHOICES = [
        (PORPHYRY, 'Porphyry'),
        (PLACIDUS, 'Placidus'),
        (KOCH,     'Koch'),
        (WHOLE,    'Whole Sign'),
    ]

    # ── seat relationship ─────────────────────────────────────────────────
    seat = models.ForeignKey(
        TableSeat, on_delete=models.CASCADE, related_name='characters',
    )

    # ── significator (set at CAST SKY) ────────────────────────────────────
    significator = models.ForeignKey(
        TarotCard, null=True, blank=True,
        on_delete=models.SET_NULL, related_name='character_significators',
    )

    # ── sky input (what the gamer entered) ─────────────────────────────
    birth_dt    = models.DateTimeField(null=True, blank=True)   # UTC
    birth_lat   = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
    birth_lon   = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
    birth_place = models.CharField(max_length=200, blank=True)  # display string only
    house_system = models.CharField(
        max_length=1, choices=HOUSE_SYSTEM_CHOICES, default=PORPHYRY,
    )

    # ── computed sky snapshot (full PySwiss response) ───────────────────
    chart_data  = models.JSONField(null=True, blank=True)

    # ── celtic cross spread (added at DRAW SEA) ───────────────────────────
    celtic_cross = models.JSONField(null=True, blank=True)

    # ── lifecycle ─────────────────────────────────────────────────────────
    created_at   = models.DateTimeField(auto_now_add=True)
    confirmed_at = models.DateTimeField(null=True, blank=True)
    retired_at   = models.DateTimeField(null=True, blank=True)

    class Meta:
        ordering = ['-created_at']

    def __str__(self):
        status = 'confirmed' if self.confirmed_at else 'draft'
        return f"Character(seat={self.seat_id}, {status})"

    @property
    def is_confirmed(self):
        return self.confirmed_at is not None

    @property
    def is_active(self):
        return self.confirmed_at is not None and self.retired_at is None
