import json
import zoneinfo
from datetime import datetime, timedelta

import requests as http_requests
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.db import transaction
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import redirect, render
from django.utils import timezone

from apps.drama.models import GameEvent, record
from django.db.models import Case, IntegerField, Value, When

from apps.epic.models import (
    Character,
    GateSlot, Room, RoomInvite, SIG_SEAT_ORDER, SigReservation, TableSeat,
    TarotCard, TarotDeck,
    active_sig_seat, debit_token, levity_sig_cards, gravity_sig_cards,
    select_token, sig_deck_cards,
)
from apps.epic.utils import _compute_distinctions, _planet_house, card_dict, stack_reversal_probability, top_capacitors
from apps.lyric.models import Token


RESERVE_TIMEOUT = timedelta(seconds=60)


def _retract_prior_event(room, actor, verbs, slot_number=None):
    """Mark the most-recent unretracted GameEvent for `actor` on `room`
    matching one of `verbs` (and optional `slot_number`) as retracted.
    Drives the symmetric redact-pair pattern in the room scroll: every
    state transition (deposit ↔ withdraw, sig-ready ↔ sig-unready) sets
    `data.retracted=True` on its counterpart's prior entry, which the
    scroll template renders strikethrough + Redact-tagged.

    `verbs` is a list/tuple — e.g. when a deposit lands, retract the
    prior SLOT_RETURNED *or* SLOT_RELEASED for the same slot (both
    represent a withdraw of the slot in question). No-op if no matching
    unretracted prior exists. Sprint A.8 user-spec 2026-05-26."""
    qs = room.events.filter(actor=actor, verb__in=verbs)
    if slot_number is not None:
        qs = qs.filter(data__slot_number=slot_number)
    prior = qs.last()
    if prior and not prior.data.get("retracted"):
        prior.data["retracted"] = True
        prior.save(update_fields=["data"])


def _notify_gate_update(room_id):
    async_to_sync(get_channel_layer().group_send)(
        f'room_{room_id}',
        {'type': 'gate_update'},
    )


def _notify_turn_changed(room_id):
    active_seat = TableSeat.objects.filter(
        room_id=room_id, role__isnull=True
    ).order_by("slot_number").first()
    active_slot = active_seat.slot_number if active_seat else None
    starter_roles = list(
        TableSeat.objects.filter(room_id=room_id, role__isnull=False)
        .values_list("role", flat=True)
    )
    async_to_sync(get_channel_layer().group_send)(
        f'room_{room_id}',
        {'type': 'turn_changed', 'active_slot': active_slot, 'starter_roles': starter_roles},
    )


def _notify_all_roles_filled(room_id):
    async_to_sync(get_channel_layer().group_send)(
        f'room_{room_id}',
        {'type': 'all_roles_filled'},
    )


def _notify_sig_select_started(room_id):
    async_to_sync(get_channel_layer().group_send)(
        f'room_{room_id}',
        {'type': 'sig_select_started'},
    )


def _notify_role_select_start(room_id):
    slot_order = list(
        GateSlot.objects.filter(room_id=room_id, status=GateSlot.FILLED)
        .order_by("slot_number")
        .values_list("slot_number", flat=True)
    )
    async_to_sync(get_channel_layer().group_send)(
        f'room_{room_id}',
        {'type': 'role_select_start', 'slot_order': slot_order},
    )


def _notify_sig_selected(room_id, card_id, role, deck_type='levity'):
    async_to_sync(get_channel_layer().group_send)(
        f'room_{room_id}',
        {'type': 'sig_selected', 'card_id': str(card_id), 'role': role, 'deck_type': deck_type},
    )


_LEVITY_ROLES = {'PC', 'NC', 'SC'}
_GRAVITY_ROLES = {'BC', 'EC', 'AC'}


def _notify_sig_reserved(room_id, card_id, role, reserved):
    """Broadcast a sig_reserved event to the matching polarity cursor group."""
    polarity = 'levity' if role in _LEVITY_ROLES else 'gravity'
    async_to_sync(get_channel_layer().group_send)(
        f'cursors_{room_id}_{polarity}',
        {'type': 'sig_reserved', 'card_id': str(card_id) if card_id else None,
         'role': role, 'reserved': reserved},
    )


def _notify_countdown_start(room_id, polarity, *, seconds):
    async_to_sync(get_channel_layer().group_send)(
        f'cursors_{room_id}_{polarity}',
        {'type': 'countdown_start', 'polarity': polarity, 'seconds': seconds},
    )


def _notify_countdown_cancel(room_id, polarity, *, seconds_remaining):
    async_to_sync(get_channel_layer().group_send)(
        f'cursors_{room_id}_{polarity}',
        {'type': 'countdown_cancel', 'polarity': polarity, 'seconds_remaining': seconds_remaining},
    )


def _notify_polarity_room_done(room_id, polarity):
    async_to_sync(get_channel_layer().group_send)(
        f'room_{room_id}',
        {'type': 'polarity_room_done', 'polarity': polarity},
    )


def _notify_pick_sky_available(room_id):
    async_to_sync(get_channel_layer().group_send)(
        f'room_{room_id}',
        {'type': 'pick_sky_available'},
    )


def _notify_sky_confirmed(room_id, seat_role):
    async_to_sync(get_channel_layer().group_send)(
        f'room_{room_id}',
        {'type': 'sky_confirmed', 'seat_role': seat_role},
    )


SLOT_ROLE_LABELS = {1: "PC", 2: "NC", 3: "EC", 4: "SC", 5: "AC", 6: "BC"}

_SIG_SEAT_ORDERING = Case(
    *[When(role=r, then=Value(i)) for i, r in enumerate(SIG_SEAT_ORDER)],
    default=Value(99),
    output_field=IntegerField(),
)


def _canonical_user_seat(room, user):
    """Return the user's seat whose role comes first in PC→NC→EC→SC→AC→BC order.

    In normal play (one user = one seat) this is equivalent to .first().
    For Carte Blanche (one user = all seats) it returns the PC seat, ensuring
    sig-select cursor placement is seat-based, not position/slot-based.
    """
    return room.table_seats.filter(gamer=user).order_by(_SIG_SEAT_ORDERING).first()

_ROLE_SCRAWL_NAMES = {
    "PC": "Player", "NC": "Narrator", "EC": "Economist",
    "SC": "Shepherd", "AC": "Alchemist", "BC": "Builder",
}


def _viewer_current_slot(room, user, seat_param=None):
    """The slot the viewer is currently "acting as": a ?seat=N override when
    they own that slot, else their lowest-numbered owned slot (the canonical
    seat). None for anon / non-seated viewers. Splits the viewer's own
    position circles into me-current (this slot) vs me-also (their other
    CARTE-claimed slots)."""
    if not getattr(user, "is_authenticated", False):
        return None
    owned = sorted(
        room.gate_slots.filter(gamer=user).values_list("slot_number", flat=True)
    )
    if not owned:
        return None
    if seat_param:
        try:
            n = int(seat_param)
        except (TypeError, ValueError):
            n = None
        if n in owned:
            return n
    return owned[0]


def _gate_positions(room, user=None, current_slot=None):
    """Return list of per-circle dicts for _table_positions.html.

    Carries the legacy keys (slot, role_label, role_assigned) PLUS the rich
    tooltip payload (sprint 2026-06-02): a `.tt-pos-*` state class classifying
    the occupant relative to `user`, the deposited-token count, the seat-clock
    expiry, the seat significator rank/suit, and bud shoptalk. `@handle` +
    title are read off `pos.slot.gamer` in the template (at_handle /
    active_title_display). `current_slot` is the viewer's acting seat
    (`_viewer_current_slot`) — it splits the viewer's own circles me-current
    vs me-also."""
    # Circles disappear in turn order (slot 1 first, slot 2 second, …) regardless
    # of which role each gamer chose — so use count, not role matching.
    assigned_count = room.table_seats.exclude(role__isnull=True).count()
    seats_by_slot = {s.slot_number: s for s in room.table_seats.all()}
    authed = getattr(user, "is_authenticated", False)
    bud_ids = set(user.buds.values_list("id", flat=True)) if authed else set()
    shoptalk_map = {}
    if authed:
        from apps.billboard.models import BudshipNote
        shoptalk_map = {
            bn.bud_id: bn.shoptalk
            for bn in BudshipNote.objects.filter(user=user)
        }
    positions = []
    for slot in room.gate_slots.order_by("slot_number"):
        gamer = slot.gamer
        seat = seats_by_slot.get(slot.slot_number)
        is_self = authed and gamer is not None and gamer.id == user.id
        is_bud = (
            authed and gamer is not None and not is_self
            and gamer.id in bud_ids
        )
        is_me_also = is_self and slot.slot_number != current_slot
        if gamer is None:
            state_class = "tt-pos-empty"
        elif is_self:
            state_class = "tt-pos-me-also" if is_me_also else "tt-pos-me-current"
        elif is_bud:
            state_class = "tt-pos-gamer tt-pos-bud"
        else:
            state_class = "tt-pos-gamer"
        # Deposited-token count — CARTE claims many slots on one token.
        tokens = 1
        if gamer is not None and slot.debited_token_type == Token.CARTE:
            carte = gamer.tokens.filter(
                token_type=Token.CARTE, current_room=room
            ).first()
            tokens = carte.slots_claimed if carte else 1
        sig = seat.significator if seat else None
        positions.append({
            "slot": slot,
            "role_label": SLOT_ROLE_LABELS.get(slot.slot_number, ""),
            "role_assigned": slot.slot_number <= assigned_count,
            "state_class": state_class,
            "is_self": is_self,
            "is_bud": is_bud,
            "is_me_also": is_me_also,
            "shoptalk": shoptalk_map.get(gamer.id, "") if is_bud else "",
            "tokens": tokens,
            "expiry": slot.cost_current_until,
            "sign_rank": sig.corner_rank if sig else "",
            "sign_suit_icon": sig.suit_icon if sig else "",
        })
    return positions


def _expire_reserved_slots(room):
    cutoff = timezone.now() - RESERVE_TIMEOUT
    room.gate_slots.filter(
        status=GateSlot.RESERVED,
        reserved_at__lt=cutoff,
    ).update(status=GateSlot.EMPTY, gamer=None, reserved_at=None)


def _expire_lapsed_seats(room):
    """Auto-BYE gamers whose seat token cost lapsed past the renewal grace
    (filled_at + 2*renewal_period). For each lapsed FILLED slot: blank the
    GateSlot, blank the matching TableSeat (keep the row — `_gate_positions`
    + sig logic count seat rows), record a SLOT_RETURNED (+ retract the
    prior SLOT_FILLED so the scroll's deposit↔withdraw redact-pair stays
    symmetric), then flag the room RENEWAL_DUE ("gamer needed" stub). Lazy —
    called on room + gate-view access, mirroring `_expire_reserved_slots`.
    NULL filled_at slots are never expired (RESERVED holds / ORM fixtures /
    auto-admit trinkets whose seat clock simply hasn't started)."""
    span = room.renewal_period or timedelta(days=7)
    cutoff = timezone.now() - 2 * span
    lapsed = list(
        room.gate_slots.filter(
            status=GateSlot.FILLED,
            filled_at__isnull=False,
            filled_at__lt=cutoff,
        )
    )
    if not lapsed:
        return
    for slot in lapsed:
        gamer = slot.gamer
        token_type = slot.debited_token_type
        slot_number = slot.slot_number
        if gamer is not None:
            # Blank the seat row (vacate the position circle) — keep the row.
            room.table_seats.filter(gamer=gamer).update(
                gamer=None, role=None, role_revealed=False,
                seat_position=None, significator=None, deck_variant=None,
            )
        slot.gamer = None
        slot.status = GateSlot.EMPTY
        slot.filled_at = None
        slot.debited_token_type = None
        slot.debited_token_expires_at = None
        slot.save()
        if gamer is not None and token_type:
            _retract_prior_event(
                room, gamer, (GameEvent.SLOT_FILLED,), slot_number=slot_number,
            )
            record(room, GameEvent.SLOT_RETURNED, actor=gamer,
                   slot_number=slot_number, token_type=token_type,
                   token_display=dict(Token.TOKEN_TYPE_CHOICES).get(
                       token_type, token_type))
    room.gate_status = Room.RENEWAL_DUE
    room.save(update_fields=["gate_status"])


def _gate_context(room, user, seat_param=None):
    _expire_reserved_slots(room)
    slots = room.gate_slots.order_by("slot_number")
    pending_slot = slots.filter(status=GateSlot.RESERVED).first()
    user_reserved_slot = None
    user_filled_slot = None
    carte_token = None
    carte_slots_claimed = 0
    carte_nvm_slot_number = None
    carte_next_slot_number = None
    if user.is_authenticated:
        user_reserved_slot = slots.filter(gamer=user, status=GateSlot.RESERVED).first()
        user_filled_slot = slots.filter(gamer=user, status=GateSlot.FILLED).first()
        carte_token = user.tokens.filter(
            token_type=Token.CARTE, current_room=room
        ).first()
        if carte_token:
            carte_slots_claimed = carte_token.slots_claimed
            # NVM shown on the highest-numbered slot this user filled via CARTE
            nvm_slot = slots.filter(
                debited_token_type=Token.CARTE, gamer=user, status=GateSlot.FILLED
            ).order_by("-slot_number").first()
            if nvm_slot:
                carte_nvm_slot_number = nvm_slot.slot_number
            # Only the very next empty slot gets an OK button
            next_slot = slots.filter(status=GateSlot.EMPTY).order_by("slot_number").first()
            if next_slot:
                carte_next_slot_number = next_slot.slot_number
    carte_active = carte_token is not None
    eligible = (
        user.is_authenticated
        and pending_slot is None
        and user_reserved_slot is None
        and user_filled_slot is None
        and not carte_active
    )
    token_depleted = eligible and select_token(user) is None
    can_drop = eligible and not token_depleted
    is_last_slot = (
        user_reserved_slot is not None
        and slots.filter(status=GateSlot.EMPTY).count() == 0
    )
    user_can_reject = user_reserved_slot is not None or user_filled_slot is not None or carte_active
    return {
        "slots": slots,
        "pending_slot": pending_slot,
        "user_reserved_slot": user_reserved_slot,
        "user_filled_slot": user_filled_slot,
        "can_drop": can_drop,
        "token_depleted": token_depleted,
        "is_last_slot": is_last_slot,
        "user_can_reject": user_can_reject,
        "carte_active": carte_active,
        "carte_slots_claimed": carte_slots_claimed,
        "carte_nvm_slot_number": carte_nvm_slot_number,
        "carte_next_slot_number": carte_next_slot_number,
        "gate_positions": _gate_positions(
            room, user, _viewer_current_slot(room, user, seat_param)
        ),
        "starter_roles": [],
    }


def _role_select_context(room, user, seat_param=None):
    current_slot = _viewer_current_slot(room, user, seat_param)
    user_seat = None
    active_seat = None
    unassigned = room.table_seats.filter(role__isnull=True).order_by("slot_number")
    if unassigned.exists():
        # Normal path — TableSeats present
        active_seat = unassigned.first()
        user_seat = None
        if user.is_authenticated:
            user_seat = room.table_seats.filter(gamer=user, role__isnull=True).order_by("slot_number").first()
        if user_seat and user_seat.slot_number == active_seat.slot_number:
            card_stack_state = "eligible"
        else:
            card_stack_state = "ineligible"
    else:
        # Fallback — no TableSeats yet; use GateSlot drop order
        active_slot = room.gate_slots.filter(
            status=GateSlot.FILLED
        ).order_by("slot_number").first()
        if active_slot is None:
            card_stack_state = None
        elif user.is_authenticated and active_slot.gamer == user:
            card_stack_state = "eligible"
        else:
            card_stack_state = "ineligible"
    starter_roles = list(
        room.table_seats.exclude(role__isnull=True).values_list("role", flat=True)
    )
    if len(starter_roles) == 6:
        card_stack_state = None
    _action_order = {r: i for i, r in enumerate(["PC", "NC", "EC", "SC", "AC", "BC"])}
    assigned_seats = (
        sorted(
            room.table_seats.filter(gamer=user, role__isnull=False),
            key=lambda s: _action_order.get(s.role, 99),
        )
        if user.is_authenticated else []
    )
    active_slot = active_seat.slot_number if active_seat else None
    # CARTE seat-switch (?seat=N): a multi-seat gamer previews a specific owned
    # seat. The card-stack's active slot becomes that seat; it stays "eligible"
    # (role-pickable now) only when the previewed seat is also the table's
    # current turn (the lowest unassigned seat), else it shows the ineligible
    # .fa-ban. No-op for the normal flow — a one-seat gamer never passes ?seat,
    # and an unowned/garbage param is ignored.
    if seat_param and user.is_authenticated and card_stack_state is not None:
        try:
            _seat_n = int(seat_param)
        except (TypeError, ValueError):
            _seat_n = None
        if _seat_n is not None and room.table_seats.filter(
            gamer=user, slot_number=_seat_n
        ).exists():
            active_slot = _seat_n
            _turn = unassigned.first() if unassigned.exists() else None
            card_stack_state = (
                "eligible"
                if _turn is not None and _turn.slot_number == _seat_n
                else "ineligible"
            )
    _my_role = assigned_seats[0].role if assigned_seats else None
    # `equipped_deck_id` gates the JS role-select guard (role-select.js L165).
    # Falls back to ANY of the user's seats in this room w. deck_variant set
    # — covers the CARTE multi-seat return path where the first role-pick
    # cleared `user.equipped_deck` (the deck is committed to seats, but still
    # "in play"). Without this, a CARTE user navigating away + back gets
    # "Equip card deck before Role select" wrongly fired. See [[sprint-carte-
    # role-select-return-may18]] / commit-TBD for the bug trail.
    role_select_deck_id = (
        user.equipped_deck_id if user.is_authenticated else None
    )
    if user.is_authenticated and not role_select_deck_id:
        seat_w_deck = room.table_seats.filter(
            gamer=user, deck_variant__isnull=False,
        ).order_by("slot_number").first()
        if seat_w_deck:
            role_select_deck_id = seat_w_deck.deck_variant_id
    ctx = {
        "card_stack_state": card_stack_state,
        "equipped_deck_id": role_select_deck_id,
        "starter_roles": starter_roles,
        "assigned_seats": assigned_seats,
        "my_tray_role": _my_role,
        "my_tray_role_tooltip": (
            {
                "title": _ROLE_SCRAWL_NAMES.get(_my_role, ""),
                "description": "[Placeholder description]",
            }
            if _my_role else None
        ),
        "my_tray_scrawl_static_path": (
            f"apps/epic/icons/cards-roles/starter-role-{_ROLE_SCRAWL_NAMES[_my_role]}.svg"
            if _my_role else None
        ),
        "user_seat": user_seat,
        "user_slots": list(
            room.table_seats.filter(gamer=user, role__isnull=True)
            .order_by("slot_number")
            .values_list("slot_number", flat=True)
        ) if user.is_authenticated else [],
        "active_slot": active_slot,
        "gate_positions": _gate_positions(room, user, current_slot),
        "slots": room.gate_slots.order_by("slot_number"),
    }
    # Viewer's seat token-cost state — drives the center-hex GATE VIEW
    # supersession (room.html). When the viewer's FILLED slot's cost has
    # lapsed (filled_at past the cost-current window), GATE VIEW replaces
    # SCAN SIGS / CAST SKY / DRAW SEA / the sig overlay; the gamer's own
    # ROLE card-stack pick survives the renewal grace. No filled slot →
    # treated as current (defensive — non-seated viewers see the normal UI).
    viewer_slot = (
        room.gate_slots.filter(gamer=user, status=GateSlot.FILLED).first()
        if user.is_authenticated else None
    )
    ctx["viewer_cost_current"] = viewer_slot.cost_current if viewer_slot else True
    ctx["viewer_in_grace"] = viewer_slot.in_renewal_grace if viewer_slot else False

    # Tray cell 2: sig card (set once polarity group confirms)
    _canonical_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
    ctx["my_tray_sig"] = _canonical_seat.significator if _canonical_seat else None

    if room.table_status == Room.SIG_SELECT:
        user_seat = _canonical_user_seat(room, user) if user.is_authenticated else None
        # CARTE seat-switch (?seat=N): a multi-seat owner picks a sig per seat,
        # so the overlay must reflect the SELECTED seat (its role/polarity/deck),
        # not the canonical PC seat. `current_slot` is the ?seat-resolved owned
        # slot (falls back to canonical for a one-seat gamer, who never switches).
        if user.is_authenticated and current_slot is not None:
            _seat_override = room.table_seats.filter(
                gamer=user, slot_number=current_slot
            ).first()
            if _seat_override:
                user_seat = _seat_override
        user_role = user_seat.role if user_seat else None
        user_polarity = None
        if user_role in _LEVITY_ROLES:
            user_polarity = 'levity'
        elif user_role in _GRAVITY_ROLES:
            user_polarity = 'gravity'

        user_reservation = SigReservation.objects.filter(
            room=room, gamer=user
        ).first() if user.is_authenticated else None
        ctx["user_seat"] = user_seat
        ctx["user_polarity"] = user_polarity
        ctx["user_ready"] = bool(user_reservation and user_reservation.ready)
        # Carry the active seat on the reserve URL so sig_reserve targets THIS
        # seat (per-seat sig), not the canonical one.
        ctx["sig_reserve_url"] = f"/gameboard/room/{room.id}/sig-reserve"
        if user_seat:
            ctx["sig_reserve_url"] += f"?seat={user_seat.slot_number}"

        # Has this gamer's polarity already had significators assigned?
        # (Other polarity still in progress — stay in SIG_SELECT but skip the overlay.)
        if user_polarity:
            _polarity_roles = _LEVITY_ROLES if user_polarity == 'levity' else _GRAVITY_ROLES
            ctx["polarity_done"] = not room.table_seats.filter(
                role__in=_polarity_roles, significator__isnull=True
            ).exists()
        else:
            ctx["polarity_done"] = False

        # Pre-load existing reservations for this polarity so JS can restore
        # grabbed state on page load/refresh.  Keyed by str(card_id) → role.
        if user_polarity:
            polarity_const = SigReservation.LEVITY if user_polarity == 'levity' else SigReservation.GRAVITY
            reservations = {
                str(res.card_id): res.role
                for res in room.sig_reservations.filter(polarity=polarity_const)
            }
        else:
            reservations = {}
        ctx["sig_reservations_json"] = json.dumps(reservations)

        if user_polarity == 'levity':
            ctx["sig_cards"] = levity_sig_cards(room, user)
        elif user_polarity == 'gravity':
            ctx["sig_cards"] = gravity_sig_cards(room, user)
        else:
            ctx["sig_cards"] = []

    if room.table_status == Room.SKY_SELECT:
        user_role = _canonical_seat.role if _canonical_seat else None
        user_polarity = None
        if user_role in _LEVITY_ROLES:
            user_polarity = 'levity'
        elif user_role in _GRAVITY_ROLES:
            user_polarity = 'gravity'
        ctx["user_polarity"] = user_polarity
        confirmed_char = (
            Character.objects.filter(
                seat=_canonical_seat,
                confirmed_at__isnull=False,
                retired_at__isnull=True,
            ).first()
            if _canonical_seat else None
        )
        sky_confirmed = confirmed_char is not None
        ctx["sky_confirmed"] = sky_confirmed
        ctx["user_seat_role"] = _canonical_seat.role if _canonical_seat else ''
        if sky_confirmed:
            # Fall back to seat.significator for Characters created before the sync was added
            ctx["my_tray_sig"] = confirmed_char.significator or _canonical_seat.significator

    return ctx


@login_required
def create_room(request):
    if request.method == "POST":
        name = request.POST.get("name", "").strip()
        if name:
            room = Room.objects.create(name=name, owner=request.user)
            # System-authored welcome (actor=None) — first scroll log
            # on every room. Renders via GameEvent.to_prose as
            # "Welcome to <name>!" with no actor prefix.
            record(room, GameEvent.ROOM_CREATED)
            return redirect("epic:gatekeeper", room_id=room.id)
    return redirect("/gameboard/")


def gatekeeper(request, room_id):
    room = Room.objects.get(id=room_id)
    _expire_lapsed_seats(room)
    if room.table_status:
        return redirect("epic:room", room_id=room_id)
    ctx = _gate_context(room, request.user)
    ctx["room"] = room
    ctx["page_class"] = "page-gameboard"
    return render(request, "apps/gameboard/room.html", ctx)


def room_view(request, room_id):
    room = Room.objects.get(id=room_id)
    _expire_lapsed_seats(room)
    ctx = _role_select_context(room, request.user, request.GET.get("seat"))
    ctx["room"] = room
    # `page-room` drives the navbar GATE VIEW swap (mirrors my-sea's
    # `page-my-sea`) so the table page reaches the renewal gate-view instead
    # of a self-referential CONT GAME. The bare gameboard listing stays
    # `page-gameboard` (no page-room) → keeps CONT GAME.
    ctx["page_class"] = "page-gameboard page-room"
    # Reversal-rate hint label under DRAW SEA's SPREAD select — same helper as
    # sea_partial so the value tracks any future per-user override automatically.
    ctx["stack_reversal_pct"] = int(round(stack_reversal_probability(request.user, room) * 100))
    return render(request, "apps/gameboard/room.html", ctx)


@login_required
def room_gate(request, room_id):
    """Room renewal gate-view — reachable mid-game (unlike `gatekeeper`,
    which redirects to the table once `table_status` is set). GATE VIEW
    (navbar + center supersession) routes here. Reuses the gatekeeper's
    token-slot modal: when the viewer's seat cost is current the roles
    panel shows CONT GAME (→ table hex, same target as the gear NVM) and
    the status reads "<n> Token(s) Deposited"; when the cost has lapsed the
    rails go active to RENEW and the status reads "Please Deposit Token"
    (no CONT GAME until the cost is satisfied again). The seat circle +
    time-remaining live on the table hex / next-sprint user-seat tooltips,
    so they're intentionally absent here (user-spec 2026-05-31)."""
    room = Room.objects.get(id=room_id)
    _expire_lapsed_seats(room)
    user_slot = room.gate_slots.filter(
        gamer=request.user, status=GateSlot.FILLED
    ).first()
    # Merge the gatekeeper gate-context so the position circles
    # (_table_positions.html) render here too — it supplies gate_positions
    # (now rich w. the .tt-pos-* tooltip payload) + every carte_* key the
    # partial's slot conditionals read. The renewal modal's own keys override
    # below.
    ctx = _gate_context(room, request.user, request.GET.get("seat"))
    ctx.update({
        "room": room,
        "cost_current": user_slot.cost_current if user_slot else True,
        "deposited_count": room.gate_slots.filter(status=GateSlot.FILLED).count(),
        "page_class": "page-gameboard page-room page-room-gate",
    })
    return render(request, "apps/gameboard/room_gate.html", ctx)


@login_required
def renew_token(request, room_id):
    """Renew the viewer's seat — re-deposit a token into their already-FILLED
    slot, resetting `filled_at=now` (via `debit_token`) so the cost-current
    window restarts. Distinct from `confirm_token` (which transitions a
    RESERVED slot); renewal is a FILLED→FILLED refresh of an occupied seat.
    402 when the user is token-depleted; no-op redirect when the user holds
    no filled slot (e.g. already auto-BYE'd out of the room)."""
    if request.method != "POST":
        return redirect("epic:room_gate", room_id=room_id)
    room = Room.objects.get(id=room_id)
    slot = room.gate_slots.filter(
        gamer=request.user, status=GateSlot.FILLED
    ).first()
    if slot is None:
        return redirect("epic:room_gate", room_id=room_id)
    token_id = request.POST.get("token_id")
    token = (request.user.tokens.filter(id=token_id).first()
             if token_id else select_token(request.user))
    if token is None:
        return HttpResponse(status=402)
    debit_token(request.user, slot, token)   # resets filled_at=now → A=now
    record(room, GameEvent.SLOT_FILLED, actor=request.user,
           slot_number=slot.slot_number, token_type=token.token_type,
           token_display=token.get_token_type_display(),
           renewal_days=(room.renewal_period.days if room.renewal_period else 7))
    _notify_gate_update(room_id)
    return redirect("epic:room_gate", room_id=room_id)


@login_required
def drop_token(request, room_id):
    if request.method == "POST":
        room = Room.objects.get(id=room_id)
        token_id = request.POST.get("token_id")
        if token_id:
            token = request.user.tokens.filter(id=token_id).first()
        else:
            token = select_token(request.user)
        if token is None:
            return HttpResponse(status=402)
        if token.token_type == Token.CARTE:
            # CARTE enters the machine without reserving a slot — all slots
            # become individually claimable via .drop-token-btn
            if token.current_room_id and token.current_room_id != room.id:
                return HttpResponse(status=409)
            token.current_room = room
            token.save()
            if request.user.equipped_trinket_id == token.pk:
                request.user.equipped_trinket = None
                request.user.save(update_fields=["equipped_trinket"])
            request.session["kit_token_id"] = str(token.id)
            _notify_gate_update(room_id)
            return redirect("epic:gatekeeper", room_id=room_id)
        if room.gate_slots.filter(status=GateSlot.RESERVED).exists():
            return redirect("epic:gatekeeper", room_id=room_id)
        if room.gate_slots.filter(gamer=request.user, status=GateSlot.FILLED).exists():
            return redirect("epic:gatekeeper", room_id=room_id)
        slot = room.gate_slots.filter(
            status=GateSlot.EMPTY
        ).order_by("slot_number").first()
        if slot:
            slot.gamer = request.user
            slot.status = GateSlot.RESERVED
            slot.reserved_at = timezone.now()
            slot.save()
            request.session["kit_token_id"] = str(token.id)
            _notify_gate_update(room_id)
    return redirect("epic:gatekeeper", room_id=room_id)


@login_required
def confirm_token(request, room_id):
    if request.method == "POST":
        room = Room.objects.get(id=room_id)
        slot_number = request.POST.get("slot_number")
        if slot_number:
            # CARTE per-slot fill: directly fill the requested slot
            carte = request.user.tokens.filter(
                token_type=Token.CARTE, current_room=room
            ).first()
            if carte:
                slot = room.gate_slots.filter(
                    slot_number=slot_number, status=GateSlot.EMPTY
                ).first()
                if slot:
                    debit_token(request.user, slot, carte)
                    # slots_claimed is the high-water mark — advance if beyond current
                    if int(slot_number) > carte.slots_claimed:
                        carte.slots_claimed = int(slot_number)
                        carte.save()
                    # Redact-pair: a re-deposit on this slot strikes the most-
                    # recent unretracted withdraw entry for this slot (user-
                    # spec 2026-05-26 — symmetric mirror of the sig embody/
                    # disembody pattern).
                    _retract_prior_event(
                        room, request.user,
                        (GameEvent.SLOT_RETURNED, GameEvent.SLOT_RELEASED),
                        slot_number=int(slot_number),
                    )
                    record(room, GameEvent.SLOT_FILLED, actor=request.user,
                           slot_number=int(slot_number), token_type=Token.CARTE,
                           token_display=carte.get_token_type_display(),
                           renewal_days=(room.renewal_period.days if room.renewal_period else 7))
                    _notify_gate_update(room_id)
        else:
            slot = room.gate_slots.filter(
                gamer=request.user, status=GateSlot.RESERVED
            ).first()
            if slot:
                token_id = request.session.pop("kit_token_id", None)
                token = None
                if token_id:
                    token = request.user.tokens.filter(id=token_id).first()
                if not token:
                    token = select_token(request.user)
                if token:
                    debit_token(request.user, slot, token)
                    # Redact-pair: re-deposit on this slot strikes the prior
                    # unretracted withdraw entry for this slot (sprint A.8).
                    _retract_prior_event(
                        room, request.user,
                        (GameEvent.SLOT_RETURNED, GameEvent.SLOT_RELEASED),
                        slot_number=slot.slot_number,
                    )
                    record(room, GameEvent.SLOT_FILLED, actor=request.user,
                           slot_number=slot.slot_number, token_type=token.token_type,
                           token_display=token.get_token_type_display(),
                           renewal_days=(room.renewal_period.days if room.renewal_period else 7))
                    _notify_gate_update(room_id)
    return redirect("epic:gatekeeper", room_id=room_id)


@login_required
def return_token(request, room_id):
    if request.method == "POST":
        room = Room.objects.get(id=room_id)
        # CARTE full return: reset token + all CARTE-debited slots. Snapshot
        # the slot numbers BEFORE the bulk update so we can emit a per-slot
        # withdraw + redact pair (one entry per slot was deposited, so one
        # entry per slot is withdrawn — symmetric mirror per user-spec
        # 2026-05-26 sprint A.8).
        carte = request.user.tokens.filter(
            token_type=Token.CARTE, current_room=room
        ).first()
        if carte:
            carte_slot_numbers = list(
                room.gate_slots.filter(
                    debited_token_type=Token.CARTE, gamer=request.user,
                ).values_list("slot_number", flat=True)
            )
            room.gate_slots.filter(
                debited_token_type=Token.CARTE, gamer=request.user
            ).update(
                gamer=None, status=GateSlot.EMPTY, filled_at=None,
                debited_token_type=None, debited_token_expires_at=None,
            )
            carte.current_room = None
            carte.slots_claimed = 0
            carte.save()
            request.session.pop("kit_token_id", None)
            for n in carte_slot_numbers:
                _retract_prior_event(
                    room, request.user, (GameEvent.SLOT_FILLED,), slot_number=n,
                )
                record(room, GameEvent.SLOT_RETURNED, actor=request.user,
                       slot_number=n, token_type=Token.CARTE,
                       token_display=carte.get_token_type_display())
            _notify_gate_update(room_id)
            return redirect("epic:gatekeeper", room_id=room_id)
        slot = room.gate_slots.filter(
            gamer=request.user,
            status__in=[GateSlot.RESERVED, GateSlot.FILLED],
        ).first()
        if slot:
            # Snapshot token-type + slot-number BEFORE the slot reset so the
            # log entry carries the right payload.
            withdraw_token_type = slot.debited_token_type
            withdraw_slot_number = slot.slot_number
            if slot.status == GateSlot.FILLED:
                if slot.debited_token_type == Token.COIN:
                    coin = request.user.tokens.filter(
                        token_type=Token.COIN, current_room=room
                    ).first()
                    if coin:
                        coin.current_room = None
                        coin.next_ready_at = None
                        coin.save()
                elif slot.debited_token_type in (Token.FREE, Token.TITHE):
                    Token.objects.create(
                        user=request.user,
                        token_type=slot.debited_token_type,
                        expires_at=slot.debited_token_expires_at,
                    )
            request.session.pop("kit_token_id", None)
            was_filled = slot.status == GateSlot.FILLED
            slot.gamer = None
            slot.status = GateSlot.EMPTY
            slot.reserved_at = None
            slot.filled_at = None
            slot.debited_token_type = None
            slot.debited_token_expires_at = None
            slot.save()
            # Only emit a withdraw entry when a deposit was actually undone
            # (FILLED → EMPTY). RESERVED → EMPTY is a pre-confirm cancel
            # that never recorded a SLOT_FILLED, so no redact-pair fires.
            if was_filled and withdraw_token_type:
                _retract_prior_event(
                    room, request.user, (GameEvent.SLOT_FILLED,),
                    slot_number=withdraw_slot_number,
                )
                token_display = dict(Token.TOKEN_TYPE_CHOICES).get(
                    withdraw_token_type, withdraw_token_type,
                )
                record(room, GameEvent.SLOT_RETURNED, actor=request.user,
                       slot_number=withdraw_slot_number,
                       token_type=withdraw_token_type,
                       token_display=token_display)
            _notify_gate_update(room_id)
    return redirect("epic:gatekeeper", room_id=room_id)


@login_required
def release_slot(request, room_id):
    """Un-fill a single CARTE-claimed slot without returning the CARTE itself.

    Emits a SLOT_RELEASED event (renders w. the unified withdraw prose,
    shape-matched to the deposit) and retracts the corresponding prior
    SLOT_FILLED so the room scroll renders the redact-pair per user-spec
    2026-05-26 (sprint A.8).
    """
    if request.method == "POST":
        room = Room.objects.get(id=room_id)
        slot_number = request.POST.get("slot_number")
        if slot_number:
            slot = room.gate_slots.filter(
                slot_number=slot_number,
                debited_token_type=Token.CARTE,
                gamer=request.user,
                status=GateSlot.FILLED,
            ).first()
            if slot:
                released_slot_number = slot.slot_number
                slot.gamer = None
                slot.status = GateSlot.EMPTY
                slot.filled_at = None
                slot.debited_token_type = None
                slot.debited_token_expires_at = None
                slot.save()
                if room.gate_status == Room.OPEN:
                    room.gate_status = Room.GATHERING
                    room.save()
                _retract_prior_event(
                    room, request.user, (GameEvent.SLOT_FILLED,),
                    slot_number=released_slot_number,
                )
                record(room, GameEvent.SLOT_RELEASED, actor=request.user,
                       slot_number=released_slot_number, token_type=Token.CARTE,
                       token_display=dict(Token.TOKEN_TYPE_CHOICES).get(
                           Token.CARTE, "Carte Blanche"))
                _notify_gate_update(room_id)
    return redirect("epic:gatekeeper", room_id=room_id)


@login_required
def select_role(request, room_id):
    if request.method == "POST":
        room = Room.objects.get(id=room_id)
        if room.table_status != Room.ROLE_SELECT:
            return redirect(
                "epic:room" if room.table_status else "epic:gatekeeper",
                room_id=room_id,
            )
        role = request.POST.get("role")
        valid_roles = [code for code, _ in TableSeat.ROLE_CHOICES]
        if not role or role not in valid_roles:
            return redirect("epic:room", room_id=room_id)
        existing = None
        with transaction.atomic():
            active_seat = room.table_seats.select_for_update().filter(
                role__isnull=True
            ).order_by("slot_number").first()
            if not active_seat or active_seat.gamer != request.user:
                return redirect("epic:room", room_id=room_id)
            if room.table_seats.filter(role=role).exists():
                return HttpResponse(status=409)
            active_seat.role = role
            existing = room.table_seats.filter(
                gamer=request.user, deck_variant__isnull=False,
            ).exclude(pk=active_seat.pk).order_by("slot_number").first()
            active_seat.deck_variant = (
                existing.deck_variant if existing else request.user.equipped_deck
            )
            active_seat.save()
        if not existing and request.user.equipped_deck:
            request.user.equipped_deck = None
            request.user.save(update_fields=["equipped_deck"])
        record(room, GameEvent.ROLE_SELECTED, actor=request.user,
               role=role, slot_number=active_seat.slot_number,
               role_display=dict(TableSeat.ROLE_CHOICES).get(role, role))
        if room.table_seats.filter(role__isnull=True).exists():
            _notify_turn_changed(room_id)
        else:
            _notify_all_roles_filled(room_id)
        return HttpResponse(status=200)
    return redirect("epic:room", room_id=room_id)


@login_required
def pick_sigs(request, room_id):
    if request.method == "POST":
        room = Room.objects.get(id=room_id)
        if room.table_status == Room.ROLE_SELECT:
            room.table_status = Room.SIG_SELECT
            room.save()
            _notify_sig_select_started(room_id)
    return redirect("epic:room", room_id=room_id)


@login_required
def pick_roles(request, room_id):
    if request.method == "POST":
        room = Room.objects.get(id=room_id)
        if room.gate_status == Room.OPEN and room.table_status is None:
            room.table_status = Room.ROLE_SELECT
            room.save()
            for slot in room.gate_slots.filter(status=GateSlot.FILLED).order_by("slot_number"):
                TableSeat.objects.create(
                    room=room,
                    gamer=slot.gamer,
                    slot_number=slot.slot_number,
                )
            _notify_role_select_start(room_id)
    return redirect("epic:room", room_id=room_id)


@login_required
def invite_gamer(request, room_id):
    """Gatekeeper invite flow. Backwards-compatible w. the legacy
    `invitee_email` form-submit (still POSTs from any old caller); also
    serves the new bud-btn slide-out which sends `recipient` (email OR
    username) + Accept: application/json. Bud-btn flow:
      • Resolves recipient via _resolve_recipient (registered → User; else None).
      • Stores RoomInvite using the resolved email (or raw input if unregistered).
      • Auto-adds inviter ↔ recipient to each others' buds (symmetric, per
        share_post precedent — registered recipients only).
      • Spawns a Brief w. kind=GAME_INVITE + room=room (post=null).
      • Returns JSON {brief, recipient_display} when Accept matches; else
        redirects to gatekeeper as before."""
    if request.method != "POST":
        return redirect("epic:gatekeeper", room_id=room_id)

    from apps.billboard.models import Brief
    from apps.billboard.views import _resolve_recipient

    room = Room.objects.get(id=room_id)
    is_ajax = "application/json" in request.headers.get("Accept", "")

    # New bud-btn field name is `recipient`; legacy form uses `invitee_email`.
    raw = (
        request.POST.get("recipient")
        or request.POST.get("invitee_email")
        or ""
    ).strip()

    if not raw:
        if is_ajax:
            return JsonResponse({"brief": None, "recipient_display": None})
        return redirect("epic:gatekeeper", room_id=room_id)

    candidate = _resolve_recipient(raw)
    is_self = candidate is not None and candidate == request.user
    if is_self:
        if is_ajax:
            return JsonResponse({"brief": None, "recipient_display": None})
        return redirect("epic:gatekeeper", room_id=room_id)

    # RoomInvite uses the resolved User's email when available (so a
    # username-typed invite doesn't store the raw username as if it were
    # an email); falls back to the raw input for unregistered addresses.
    invitee_email = candidate.email if candidate else raw

    # Duplicate-invite guard: "already present" = recipient is either
    # already seated in the room OR has a (pending/accepted) RoomInvite.
    # During gatekeeper phase the visible `.gate-slot.filled` cells are
    # GateSlot-driven (TableSeats spin up later at SIG SELECT), so check
    # both — GateSlot.FILLED catches the in-phase case, TableSeat catches
    # the post-phase case. Seated recipients carry recipient_user_id so
    # the client can find the .gate-slot.filled[data-user-id="X"]
    # highlight target; pending invitees have no visible slot, so
    # recipient_user_id stays null.
    already_seated = candidate is not None and (
        GateSlot.objects.filter(
            room=room, gamer=candidate, status=GateSlot.FILLED,
        ).exists()
        or TableSeat.objects.filter(room=room, gamer=candidate).exists()
    )
    already_invited = RoomInvite.objects.filter(
        room=room, invitee_email=invitee_email,
    ).exists()
    already_present = already_seated or already_invited

    brief = None
    if not already_present:
        RoomInvite.objects.create(
            room=room,
            inviter=request.user,
            invitee_email=invitee_email,
            status=RoomInvite.PENDING,
        )
        # Buds graph: symmetric auto-add on registered recipients (mirrors
        # share_post). Idempotent on M2M; no-op on unregistered recipients.
        if candidate is not None:
            request.user.buds.add(candidate)
            candidate.buds.add(request.user)
        # Brief: confirmation banner for the inviter. Brief.post stays
        # null; banner FYI navigates to the room's gatekeeper page via
        # Brief.room.
        brief = Brief.objects.create(
            owner=request.user,
            post=None,
            room=room,
            kind=Brief.KIND_GAME_INVITE,
            title="Invite sent",
        )

    recipient_user_id = str(candidate.id) if already_seated else None

    if is_ajax:
        recipient_display = None
        if candidate is not None:
            recipient_display = candidate.username or candidate.email
        return JsonResponse({
            "brief":             brief.to_banner_dict() if brief is not None else None,
            "recipient_display": recipient_display,
            "recipient_user_id": recipient_user_id,
            "already_present":   already_present,
        })

    return redirect("epic:gatekeeper", room_id=room_id)


@login_required
def delete_room(request, room_id):
    if request.method == "POST":
        room = Room.objects.get(id=room_id)
        if request.user == room.owner:
            room.delete()
    return redirect("/gameboard/")


@login_required
def abandon_room(request, room_id):
    if request.method == "POST":
        room = Room.objects.get(id=room_id)
        room.gate_slots.filter(gamer=request.user).update(
            gamer=None, status="EMPTY", filled_at=None
        )
        room.invites.filter(
            invitee_email=request.user.email,
            status=RoomInvite.PENDING
        ).delete()
    return redirect("/gameboard/")


def gate_status(request, room_id):
    room = Room.objects.get(id=room_id)
    ctx = _gate_context(room, request.user)
    ctx["room"] = room
    return render(request, "apps/gameboard/_partials/_gatekeeper.html", ctx)


@login_required
def sig_reserve(request, room_id):
    """Provisional card hold (OK / NVM) during SIG_SELECT.
    POST body: card_id=<uuid>, action=reserve|release
    """
    if request.method != "POST":
        return HttpResponse(status=405)
    room = Room.objects.get(id=room_id)
    if room.table_status != Room.SIG_SELECT:
        return HttpResponse(status=400)

    user_seat = _canonical_user_seat(room, request.user)
    # CARTE per-seat sig: honor a ?seat=N override (carried on the reserve URL)
    # so the hold targets the SELECTED owned seat, not the canonical PC one.
    seat_param = request.GET.get("seat")
    if seat_param:
        try:
            _seat_n = int(seat_param)
        except (TypeError, ValueError):
            _seat_n = None
        if _seat_n is not None:
            _override = room.table_seats.filter(
                gamer=request.user, slot_number=_seat_n
            ).first()
            if _override:
                user_seat = _override
    if not user_seat or not user_seat.role:
        return HttpResponse(status=403)

    action = request.POST.get("action", "reserve")

    if action == "release":
        existing = SigReservation.objects.filter(room=room, gamer=request.user).first()
        released_card_id = existing.card_id if existing else None
        if existing and existing.ready:
            # Gamer released while ready — treat as an implicit WAIT NVM
            prior = room.events.filter(
                actor=request.user, verb=GameEvent.SIG_READY
            ).last()
            if prior and not prior.data.get("retracted"):
                prior.data["retracted"] = True
                prior.save(update_fields=["data"])
            record(room, GameEvent.SIG_UNREADY, actor=request.user)
            polarity = existing.polarity
            all_ready = SigReservation.objects.filter(
                room=room, polarity=polarity, ready=True
            ).count() == 3
            if all_ready:
                _notify_countdown_cancel(room_id, polarity, seconds_remaining=12)
        SigReservation.objects.filter(room=room, gamer=request.user).delete()
        _notify_sig_reserved(room_id, released_card_id, user_seat.role, reserved=False)
        return HttpResponse(status=200)

    # Reserve action
    card_id = request.POST.get("card_id")
    try:
        card = TarotCard.objects.get(pk=card_id)
    except TarotCard.DoesNotExist:
        return HttpResponse(status=400)

    polarity = SigReservation.LEVITY if user_seat.role in _LEVITY_ROLES else SigReservation.GRAVITY

    # Block if another gamer in the same polarity already holds this card
    if SigReservation.objects.filter(
        room=room, card=card, polarity=polarity
    ).exclude(gamer=request.user).exists():
        return HttpResponse(status=409)

    # Block if this gamer already holds a *different* card — must NVM first
    existing = SigReservation.objects.filter(room=room, gamer=request.user).first()
    if existing and existing.card != card:
        return HttpResponse(status=409)

    # Idempotent: already holding the same card
    if existing:
        return HttpResponse(status=200)

    SigReservation.objects.create(
        room=room, gamer=request.user, card=card,
        seat=user_seat, role=user_seat.role, polarity=polarity,
    )
    _notify_sig_reserved(room_id, card.pk, user_seat.role, reserved=True)

    # Solo polarity group (CARTE — the viewer owns EVERY seat in this polarity,
    # so there is no co-gamer to ready/countdown-sync against). Commit the sig
    # to the active seat right away; the 3-ready countdown can never complete
    # solo. The provisional row stays (a NVM frees it for the next seat, and
    # seat.significator persists through release). Strictly gated to the solo
    # case so the multi-gamer reserve→ready→countdown→confirm contract — and
    # its channels tests — are untouched. [[project-position-circle-tooltips]]
    polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
    solo_group = not room.table_seats.filter(
        role__in=polarity_roles
    ).exclude(gamer=request.user).exists()
    if solo_group:
        user_seat.significator = card
        user_seat.save(update_fields=["significator"])
        # Solo player has committed every seat's sig → advance to SKY_SELECT
        # (mirrors sig_confirm's tail; no countdown ever fires solo).
        if not room.table_seats.filter(significator__isnull=True).exists():
            Room.objects.filter(id=room_id).update(table_status=Room.SKY_SELECT)
            _notify_pick_sky_available(room_id)
    return HttpResponse(status=200)


@login_required
def sig_ready(request, room_id):
    """Toggle ready/unready for the polarity-room countdown.
    POST body: action=ready|unready [, seconds_remaining=<int>]
    """
    if request.method != "POST":
        return HttpResponse(status=405)
    room = Room.objects.get(id=room_id)
    if room.table_status != Room.SIG_SELECT:
        return HttpResponse(status=400)
    user_seat = _canonical_user_seat(room, request.user)
    if user_seat is None:
        return HttpResponse(status=403)

    action = request.POST.get("action", "ready")
    reservation = SigReservation.objects.filter(room=room, gamer=request.user).first()

    if action == "ready":
        if reservation is None:
            return HttpResponse(status=400)
        if reservation.ready:
            return HttpResponse(status=200)  # idempotent — already ready, don't re-trigger countdown
        reservation.ready = True
        reservation.save(update_fields=["ready"])
        card = reservation.card
        if card:
            _qual = card.levity_qualifier if reservation.polarity == SigReservation.LEVITY else card.gravity_qualifier
            _card_display = f"{_qual} {card.name_title}" if _qual else card.name_title
        else:
            _card_display = "a card"
        record(room, GameEvent.SIG_READY, actor=request.user,
               card_name=_card_display,
               corner_rank=card.corner_rank if card else "",
               suit_icon=card.suit_icon if card else "")
        # Retract the most recent un-retracted SIG_UNREADY (cancellation is now moot)
        prior_unready = room.events.filter(
            actor=request.user, verb=GameEvent.SIG_UNREADY
        ).last()
        if prior_unready and not prior_unready.data.get("retracted"):
            prior_unready.data["retracted"] = True
            prior_unready.save(update_fields=["data"])

        # Check if all three in this polarity are now ready
        polarity = reservation.polarity
        polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
        ready_count = SigReservation.objects.filter(
            room=room, polarity=polarity, ready=True
        ).count()
        if ready_count == 3:
            from apps.epic.tasks import schedule_polarity_confirm
            # Use saved countdown_remaining if a pause was recorded, else 12
            saved = SigReservation.objects.filter(
                room=room, polarity=polarity
            ).exclude(countdown_remaining__isnull=True).values_list(
                "countdown_remaining", flat=True
            ).first()
            seconds = saved if saved is not None else 12
            schedule_polarity_confirm(str(room_id), polarity, seconds)
            _notify_countdown_start(room_id, polarity, seconds=seconds)

    else:  # unready
        if reservation is not None:
            reservation.ready = False
            reservation.save(update_fields=["ready"])
            # Mark the most recent un-retracted SIG_READY event for this actor
            prior = room.events.filter(
                actor=request.user, verb=GameEvent.SIG_READY
            ).last()
            if prior and not prior.data.get("retracted"):
                prior.data["retracted"] = True
                prior.save(update_fields=["data"])
            record(room, GameEvent.SIG_UNREADY, actor=request.user)
            polarity = reservation.polarity

            # Save remaining seconds on all polarity reservations
            try:
                seconds_remaining = int(request.POST.get("seconds_remaining", 12))
            except (TypeError, ValueError):
                seconds_remaining = 12
            SigReservation.objects.filter(room=room, polarity=polarity).update(
                countdown_remaining=seconds_remaining
            )
            _notify_countdown_cancel(room_id, polarity, seconds_remaining=seconds_remaining)
            from apps.epic.tasks import cancel_polarity_confirm
            cancel_polarity_confirm(str(room_id), polarity)

    return HttpResponse(status=200)


@login_required
def sig_confirm(request, room_id):
    """Finalise polarity group once the countdown fires.
    POST body: polarity=levity|gravity
    """
    if request.method != "POST":
        return HttpResponse(status=405)
    room = Room.objects.get(id=room_id)
    if room.table_status != Room.SIG_SELECT:
        return HttpResponse(status=400)
    user_seat = _canonical_user_seat(room, request.user)
    if user_seat is None:
        return HttpResponse(status=403)

    polarity = request.POST.get("polarity", SigReservation.LEVITY)
    polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES

    # Idempotency: seats already have significators
    if not room.table_seats.filter(role__in=polarity_roles, significator__isnull=True).exists():
        return HttpResponse(status=200)

    # All three in the polarity group must be ready
    ready_count = SigReservation.objects.filter(room=room, polarity=polarity, ready=True).count()
    if ready_count < 3:
        return HttpResponse(status=400)

    # Assign significators from reservations
    reservations = list(
        SigReservation.objects.filter(room=room, polarity=polarity, ready=True)
        .select_related('seat', 'card')
    )
    for res in reservations:
        if res.seat:
            res.seat.significator = res.card
            res.seat.save(update_fields=['significator'])
    SigReservation.objects.filter(room=room, polarity=polarity).update(countdown_remaining=None)

    _notify_polarity_room_done(room_id, polarity)

    # If both polarities are now done, advance to SKY_SELECT
    if not room.table_seats.filter(significator__isnull=True).exists():
        Room.objects.filter(id=room_id).update(table_status=Room.SKY_SELECT)
        _notify_pick_sky_available(room_id)

    return HttpResponse(status=200)


@login_required
def select_sig(request, room_id):
    if request.method != "POST":
        return redirect("epic:gatekeeper", room_id=room_id)
    room = Room.objects.get(id=room_id)
    if room.table_status != Room.SIG_SELECT:
        return redirect(
            "epic:room" if room.table_status else "epic:gatekeeper",
            room_id=room_id,
        )
    active_seat = active_sig_seat(room)
    if active_seat is None or active_seat.gamer != request.user:
        return HttpResponse(status=403)
    card_id = request.POST.get("card_id")
    try:
        card = TarotCard.objects.get(pk=card_id)
    except TarotCard.DoesNotExist:
        return HttpResponse(status=400)
    sig_card_ids = {c.pk for c in sig_deck_cards(room)}
    if card.pk not in sig_card_ids:
        return HttpResponse(status=400)
    if room.table_seats.filter(significator=card).exists():
        return HttpResponse(status=409)
    active_seat.significator = card
    active_seat.save()
    deck_type = request.POST.get('deck_type', 'levity')
    _notify_sig_selected(room_id, card.pk, active_seat.role, deck_type)
    return HttpResponse(status=200)


@login_required
def tarot_deck(request, room_id):
    room = Room.objects.get(id=room_id)
    deck_variant = request.user.equipped_deck
    deck, _ = TarotDeck.objects.get_or_create(
        room=room,
        defaults={"deck_variant": deck_variant},
    )
    return render(request, "apps/gameboard/tarot_deck.html", {
        "room": room,
        "deck": deck,
        "remaining": deck.remaining_count,
    })


@login_required
def tarot_deal(request, room_id):
    if request.method != "POST":
        return redirect("epic:tarot_deck", room_id=room_id)
    room = Room.objects.get(id=room_id)
    deck = TarotDeck.objects.get(room=room)
    drawn = deck.draw(6)   # Celtic Cross: 6 cross positions; 4 staff filled via gameplay
    positions = [
        {
            "card": card,
            "reversed": is_reversed,
            "orientation": "Reversed" if is_reversed else "Upright",
            "position": i + 1,
        }
        for i, (card, is_reversed) in enumerate(drawn)
    ]
    return render(request, "apps/gameboard/tarot_deck.html", {
        "room": room,
        "deck": deck,
        "remaining": deck.remaining_count,
        "positions": positions,
    })


# ── Sky (natal chart) ───────────────────────────────────────────────────────

@login_required
def sky_preview(request, room_id):
    """Proxy GET to PySwiss /api/chart/ and augment with distinction counts.

    Query params:
      date  — YYYY-MM-DD  (local birth date)
      time  — HH:MM       (local birth time, default 12:00)
      tz    — IANA timezone string (optional; auto-resolved from lat/lon if absent)
      lat   — float
      lon   — float

    If tz is absent or blank, calls PySwiss /api/tz/ to resolve it from the
    coordinates before converting the local datetime to UTC.

    Response includes a 'timezone' key (resolved or supplied) so the client
    can back-fill the timezone field after the first wheel render.

    No database writes — safe for debounced real-time calls.
    """
    seat = _canonical_user_seat(Room.objects.get(id=room_id), request.user)
    if seat is None:
        return HttpResponse(status=403)

    date_str = request.GET.get('date')
    time_str = request.GET.get('time', '12:00')
    tz_str   = request.GET.get('tz', '').strip()
    lat_str  = request.GET.get('lat')
    lon_str  = request.GET.get('lon')

    if not date_str or lat_str is None or lon_str is None:
        return HttpResponse(status=400)

    try:
        lat = float(lat_str)
        lon = float(lon_str)
    except ValueError:
        return HttpResponse(status=400)

    if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
        return HttpResponse(status=400)

    # Resolve timezone from coordinates if not supplied
    if not tz_str:
        try:
            tz_resp = http_requests.get(
                settings.PYSWISS_URL + '/api/tz/',
                params={'lat': lat_str, 'lon': lon_str},
                timeout=5,
            )
            tz_resp.raise_for_status()
            tz_str = tz_resp.json().get('timezone') or 'UTC'
        except Exception:
            tz_str = 'UTC'

    try:
        tz = zoneinfo.ZoneInfo(tz_str)
    except (zoneinfo.ZoneInfoNotFoundError, KeyError):
        return HttpResponse(status=400)

    try:
        local_dt = datetime.strptime(f'{date_str} {time_str}', '%Y-%m-%d %H:%M')
        local_dt = local_dt.replace(tzinfo=tz)
        utc_dt   = local_dt.astimezone(zoneinfo.ZoneInfo('UTC'))
        dt_iso   = utc_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
    except ValueError:
        return HttpResponse(status=400)

    try:
        resp = http_requests.get(
            settings.PYSWISS_URL + '/api/chart/',
            params={'dt': dt_iso, 'lat': lat_str, 'lon': lon_str},
            timeout=5,
        )
        resp.raise_for_status()
    except Exception:
        return HttpResponse(status=502)

    data = resp.json()
    # PySwiss uses "Earth"; the wheel and SCSS use "Stone".
    if 'elements' in data and 'Earth' in data['elements']:
        data['elements']['Stone'] = data['elements'].pop('Earth')
    data['distinctions'] = _compute_distinctions(data['planets'], data['houses'])
    data['timezone'] = tz_str
    return JsonResponse(data)


@login_required
def sky_save(request, room_id):
    """Create or update the draft Character for the requesting gamer's seat.

    POST body (JSON):
      birth_dt     — ISO 8601 UTC datetime
      birth_lat    — float
      birth_lon    — float
      birth_place  — display string (optional)
      house_system — single char, default 'O'
      chart_data   — full PySwiss response dict (incl. distinctions)
      action       — 'save' (default) or 'confirm'

    On 'confirm': sets confirmed_at, locking the Character.
    Returns: {id, confirmed}
    """
    if request.method != 'POST':
        return HttpResponse(status=405)

    room = Room.objects.get(id=room_id)
    seat = _canonical_user_seat(room, request.user)
    if seat is None:
        return HttpResponse(status=403)

    try:
        body = json.loads(request.body)
    except json.JSONDecodeError:
        return HttpResponse(status=400)

    # Find or create the active draft (unconfirmed, unretired) for this seat
    char = Character.objects.filter(
        seat=seat, confirmed_at__isnull=True, retired_at__isnull=True,
    ).first()
    if char is None:
        char = Character(seat=seat)

    char.birth_dt    = body.get('birth_dt')
    char.birth_lat   = body.get('birth_lat')
    char.birth_lon   = body.get('birth_lon')
    char.birth_place = body.get('birth_place', '')
    char.house_system = body.get('house_system', Character.PORPHYRY)
    char.chart_data  = body.get('chart_data')

    char.significator = seat.significator

    if body.get('action') == 'confirm':
        char.confirmed_at = timezone.now()

    char.save()

    if char.is_confirmed:
        from apps.drama.models import GameEvent, record
        caps = top_capacitors((char.chart_data or {}).get('elements'))
        record(
            room, GameEvent.SKY_SAVED, actor=request.user,
            top_capacitors=caps,
        )
        _notify_sky_confirmed(room_id, seat.role)

    return JsonResponse({'id': char.id, 'confirmed': char.is_confirmed})


@login_required
def sky_delete(request, room_id):
    """Purge the requesting gamer's Character on this seat — both unconfirmed
    drafts AND confirmed rows. The in-room CAST SKY DEL targets this so SAVE
    SKY → DEL → refresh truly drops the saved sky for the seat. The User
    model's sky_chart_data is intentionally untouched (Dashsky / My Sky
    applet's DEL handles that separately)."""
    if request.method != 'POST':
        return HttpResponse(status=405)
    room = Room.objects.get(id=room_id)
    seat = _canonical_user_seat(room, request.user)
    if seat is None:
        return HttpResponseForbidden()
    Character.objects.filter(seat=seat, retired_at__isnull=True).delete()
    return JsonResponse({'deleted': True})


@login_required
def sea_deck(request, room_id):
    """Shuffled deck lists (levity + gravity halves) for DRAW SEA draw.

    Excludes all Significators already claimed by seated gamers.
    Returns {levity: [{id, name, arcana, suit, number, levity_qualifier,
             gravity_qualifier}], gravity: [...]}
    """
    import random as _random
    room = Room.objects.get(id=room_id)
    seat = _canonical_user_seat(room, request.user)
    if seat is None:
        return HttpResponse(status=403)

    deck = seat.deck_variant
    if not deck:
        return JsonResponse({'levity': [], 'gravity': []})

    sig_ids = set(
        room.table_seats.exclude(significator__isnull=True)
        .values_list('significator_id', flat=True)
    )

    # Roll reversal eagerly during the shuffle — the deck order is fully
    # determined at phase start, so the reversal axis should be too. Future
    # per-user-profile config rides this same helper.
    reversal_prob = stack_reversal_probability(request.user, room)

    available = list(
        TarotCard.objects.filter(deck_variant=deck).exclude(id__in=sig_ids)
    )
    _random.shuffle(available)
    mid = len(available) // 2
    return JsonResponse({
        'levity': [card_dict(c, reversal_prob) for c in available[:mid]],
        'gravity': [card_dict(c, reversal_prob) for c in available[mid:]],
    })


@login_required
def sea_partial(request, room_id):
    """Return the rendered sea overlay partial for in-page injection after sky confirm."""
    room = Room.objects.get(id=room_id)
    ctx = _role_select_context(room, request.user)
    if not ctx.get('sky_confirmed'):
        return HttpResponse(status=403)
    ctx['room'] = room
    # Reversal-rate hint label under SPREAD — both the percentage AND the raw
    # probability flow from the same helper, so when per-user config lands we
    # only swap the helper body and every render picks it up.
    _prob = stack_reversal_probability(request.user, room)
    ctx['stack_reversal_pct'] = int(round(_prob * 100))
    return render(request, 'apps/gameboard/_partials/_sea_overlay.html', ctx)

