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.urls import reverse
from django.template.loader import render_to_string
from django.utils import timezone

from apps.billboard.forms import ExistingPostLineForm
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"}

# Sea Select affinity — each Role is correlated with one Celtic-Cross position
# (the "universal slot term"), keyed by the sixfold chair index (user-spec
# 2026-06-08; roles rotate each round, but a seat's CURRENT role drives this).
# The card drawn into that position is the gamer's affinity (the SEA_DRAWN
# Scroll log). All spread label-sets (incl. Waite-Smith's Behind/Before/Beneath)
# key to the SAME position keys — only the displayed label differs per spread.
ROLE_POSITION_MAP = {
    "PC": "crown", "NC": "leave", "EC": "loom",
    "SC": "cover", "AC": "cross", "BC": "lay",
}
# Per-spread display label for each position key (mirrors POSITION_LABELS in
# _sea_overlay.html). The position KEY is the canonical index; the label is what
# that spread calls it.
SEA_POSITION_LABELS = {
    "waite-smith":     {"crown": "Crown", "leave": "Behind", "cover": "Cover",
                        "cross": "Cross", "loom": "Before", "lay": "Beneath"},
    "escape-velocity": {"crown": "Crown", "leave": "Leave", "cover": "Cover",
                        "cross": "Cross", "loom": "Loom", "lay": "Lay"},
}

_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()


def _acting_seat(room, user, seat_param):
    """The seat an action targets: the `?seat=N` override when N is one of the
    user's owned slots, else the canonical seat. Lets a CARTE multi-seat owner
    act per-seat — a distinct sig reservation in SIG_SELECT, a distinct sky/sea
    Character in SKY_SELECT — across all six owned seats."""
    seat = _canonical_user_seat(room, user)
    if seat_param:
        try:
            n = int(seat_param)
        except (TypeError, ValueError):
            n = None
        if n is not None:
            override = room.table_seats.filter(gamer=user, slot_number=n).first()
            if override:
                seat = override
    return seat


_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.select_related("significator").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)
        }
    # value → display ("carte" → "Carte Blanche", "Free" → "Free Token") for
    # the per-slot deposited-token `<ul>`.
    token_display = dict(Token.TOKEN_TYPE_CHOICES)
    positions = []
    for slot in room.gate_slots.select_related("gamer").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"
        # Ordered display names of the token(s) deposited in THIS slot — one
        # today (each slot costs exactly 1 token; a CARTE covers each seat at
        # cost 1, so a CARTE seat reads "Carte Blanche"). Becomes a multi-entry
        # list when the rising-game-cost feature lands.
        token_types = (
            [token_display.get(slot.debited_token_type, slot.debited_token_type)]
            if gamer is not None and slot.debited_token_type else []
        )
        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_me_also": is_me_also,
            "shoptalk": shoptalk_map.get(gamer.id, "") if is_bud else "",
            # Per-slot expenditure count (GateSlot.token_cost) — 1 normally.
            "tokens": slot.token_cost,
            "token_types": token_types,
            "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)
    current_slot = _viewer_current_slot(room, user, seat_param)
    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,
        # Acting seat — exposed so the gate page's own GATE VIEW re-click
        # re-carries ?seat (keeps the seat-switch context across reloads).
        "current_slot": current_slot,
        "gate_positions": _gate_positions(room, user, current_slot),
        "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"
            )
    # The tray mirrors the seat the viewer is ACTING AS — the seat occupying
    # current_slot (the ?seat-selected pos-circle, else their lowest owned) —
    # NOT the role-canonical PC seat. So a multi-seat (CARTE) gamer's tray
    # follows whichever circle they switched to. A single-seat gamer's lone
    # slot IS current_slot, so selected_seat == their canonical seat (no-op).
    selected_seat = (
        room.table_seats.filter(gamer=user, slot_number=current_slot).first()
        if user.is_authenticated and current_slot is not None else None
    )
    _my_role = selected_seat.role if selected_seat 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,
        # The viewer's acting seat (?seat slot, else lowest owned). Threaded
        # onto the GATE VIEW nav so the gate view's current matches the table
        # — without it the gate view falls back to owned[0] (pos 1), locking
        # pos 1 as a never-switchable me-current circle.
        "current_slot": current_slot,
        "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
    # Sig cell follows the acting seat too (see selected_seat above), so a CARTE
    # gamer sees the sig of the circle they switched to. SKY_SELECT overrides
    # this below from the confirmed Character.
    ctx["my_tray_sig"] = selected_seat.significator if selected_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. Gate on an EXPLICIT ?seat — with no param,
        # `current_slot` is merely the lowest owned GATE slot, which need not be
        # the canonical PC seat (roles aren't slot-ordered); keep the canonical
        # seat in that case so every SIG_SELECT surface (incl. my_tray_sig) agrees.
        if seat_param and 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'

        # Per-seat: data-ready reflects the ACTING seat's reservation, so a
        # CARTE owner's WAIT-NVM state restores correctly per switched seat.
        user_reservation = SigReservation.objects.filter(
            room=room, gamer=user, seat=user_seat
        ).first() if (user.is_authenticated and user_seat) 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)
            }
            # Seconds left on a live polarity countdown, so a gamer landing on
            # this seat view mid-countdown restores the flashing numeral instead
            # of a static WAIT NVM (countdown_start is a one-shot WS broadcast).
            # None when no countdown is running → template renders 0.
            from apps.epic.tasks import countdown_remaining
            ctx["countdown_remaining"] = countdown_remaining(room.id, polarity_const)
        else:
            reservations = {}
            ctx["countdown_remaining"] = None
        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"] = []
        # Per-card FLIP-to-back (dubbodeck): the stage back-img + FLIP affordance
        # render when ANY card in the (possibly mixed-deck) pile has a
        # non-polarized image-deck back; stage-card.js then points the back-img at
        # the FOCUSED card's OWN deck. Was gated on the viewer's seat deck only —
        # wrong once a pile mixes decks (an RWS card in a PC-earthman viewer's pile
        # could never FLIP). select_related on the assembly keeps this N+1-free.
        ctx["sig_pile_has_backs"] = any(
            c.deck_variant_id and c.deck_variant.has_card_images
            and not c.deck_variant.is_polarized
            for c in ctx["sig_cards"]
        )

    if room.table_status == Room.SKY_SELECT:
        # CARTE seat-switch: the sky/sea state is per-SEAT (Character.seat), so key
        # this whole block off the seat the viewer switched to (`selected_seat` =
        # ?seat-aware), NOT the fixed canonical seat — without it the tray switched
        # but the sky wheel / sea spread stuck on the canonical seat, so a CARTE
        # owner couldn't drive his other 5 seats thru CAST SKY / DRAW SEA
        # (user-flagged 2026-06-07). Falls back to canonical if unset.
        _sky_seat = selected_seat or _canonical_seat
        user_role = _sky_seat.role if _sky_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=_sky_seat,
                confirmed_at__isnull=False,
                retired_at__isnull=True,
            ).first()
            if _sky_seat else None
        )
        sky_confirmed = confirmed_char is not None
        ctx["sky_confirmed"] = sky_confirmed
        ctx["user_seat_role"] = _sky_seat.role if _sky_seat else ''
        # Burger-fan Sky sub-btn: ACTIVE once the sky is saved (confirmed) — the
        # burger then pulses thrice (--priTk) on load + the sub-btn re-opens the
        # saved wheel. `saved_sky_json` primes that reopen so the felt draws the
        # confirmed chart without a fresh PySwiss round-trip (mirrors My Sky).
        ctx["sky_btn_active"] = sky_confirmed
        ctx["saved_sky_json"] = (
            json.dumps(confirmed_char.chart_data)
            if (sky_confirmed and confirmed_char.chart_data)
            else "null"
        )
        if sky_confirmed:
            # Fall back to seat.significator for Characters created before the sync was added
            ctx["my_tray_sig"] = confirmed_char.significator or _sky_seat.significator
            # DRAW SEA persistence — pre-render the saved Celtic-Cross hand from
            # the seat's Character.celtic_cross so a reload lands the filled
            # cross + the right spread (mirrors my-sea's saved_by_position, but
            # Character-backed). Empty dict / "" when no sea drawn yet. The
            # `_saved_by_position` helper lives in gameboard.views; deferred
            # import dodges the module-load cycle (gameboard.views imports epic).
            from apps.gameboard.views import _saved_by_position
            _cc = confirmed_char.celtic_cross or {}
            _sea_hand = _cc.get("hand") or []
            ctx["saved_by_position"] = _saved_by_position(_sea_hand)
            ctx["saved_sea_spread"] = _cc.get("spread") or ""
            # DRAW SEA offers ONLY the two 6-card Celtic Cross spreads (user-spec
            # 2026-06-07); default to the saved one, else the polarity-matched CC.
            ctx["sea_default_spread"] = (
                ctx["saved_sea_spread"]
                or ("waite-smith" if user_polarity == "levity" else "escape-velocity")
            )
            # 6-card Celtic Cross is complete at 6 placed cards.
            ctx["hand_complete"] = len(_sea_hand) >= 6
            # Burger Sea sub-btn = the post-completion REOPEN affordance (active
            # only once the spread is complete, like the Sky sub-btn after a save
            # — NOT during drawing). So a reload of a complete sea renders it
            # active; the live cascade activates it client-side otherwise.
            ctx["sea_btn_active"] = ctx["hand_complete"]
            # Sea-stage FLIP card-back — sourced from the SEAT's contributed deck,
            # NOT request.user.equipped_deck (which `select_role` nulls out once
            # the deck is contributed to the room → the back-img silently never
            # rendered in the gameroom + FLIP no-op'd). User-flagged 2026-06-07.
            _back_deck = _sky_seat.deck_variant if _sky_seat else None
            ctx["sea_back_image_url"] = (
                _back_deck.back_image_url
                if (_back_deck and _back_deck.has_card_images) else ""
            )

    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)
    # CARTE seat alignment: a multi-seat owner arriving at SIG_SELECT with no
    # ?seat gets the sig overlay + reserve URL locked to the canonical PC seat,
    # while the TRAY follows owned[0] (often a different polarity). A sig reserved
    # from that view files against the WRONG seat → the seat the owner thought he
    # covered stays empty → that polarity never reaches 3-ready → its countdown
    # runs to 0 but _fire bails at `len(ready) < 3`, so it never advances. Redirect
    # to ?seat=<owned[0]> so every surface (tray, overlay, reserve URL, WS cursor
    # group) resolves to ONE seat via the proven ?seat path — the same realignment
    # a GATE-VIEW switch performs. SIG_SELECT-only: SKY_SELECT already keys its
    # overlay off selected_seat (~L639); single-seat gamers / non-owners fail the
    # owner test (gate-slot ownership, matching _viewer_current_slot). Staging bug
    # 2026-06-08, trace-confirmed (the misfiled-reservation chain).
    if (
        request.GET.get("seat") is None
        and room.table_status == Room.SIG_SELECT
        and request.user.is_authenticated
        and room.gate_slots.filter(gamer=request.user).count() > 1
    ):
        current_slot = _viewer_current_slot(room, request.user, None)
        if current_slot is not None:
            return redirect(
                f"{reverse('epic:room', kwargs={'room_id': room.id})}"
                f"?seat={current_slot}"
            )
    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))
    # Scroll-of-events — the table-hex aperture's 2nd snap section. Same query
    # + shared `core/_partials/_scroll.html` partial as the Billscroll page
    # (`billboard.views.scroll`), so the feed renders identically. Unfiltered
    # (Meta-ordered by timestamp) — the `struck`/redact rendering is the
    # partial's job. Only reached from Role Select onwards (the template gates
    # the scroll pane on `room.table_status`).
    ctx["events"] = room.events.select_related("actor").all()
    ctx["viewer"] = request.user
    ctx["scroll_position"] = 0
    # Game-views carousel (POST view) — the room's single game-table thread.
    # Lazy-created on first table-page load; its Lines render inline in the
    # POST view (same `_post_line.html` partial as post.html) and the composer
    # appends via `epic:room_post`. `text_btn_active` lights the burger fan's
    # Text sub-btn (the swipe-machine entry to the Post view) on the table only
    # — it is unset on the gatekeeper / other _burger.html surfaces, which keep
    # the sub-btn in its inactive flash-stub state.
    room_post = room.get_thread_post()
    ctx["room_post"] = room_post
    ctx["room_post_lines"] = room_post.lines.select_related("author").all()
    ctx["text_btn_active"] = True
    # The POST chat's participants are the gamers OCCUPYING SEATS (a TableSeat
    # with their gamer) — NOT mere gate-slot/token depositors, who can retract
    # their token + leave without ever committing to the game. `seated_others`
    # excludes the viewer (who is the "& me" line); deduped, slot-ordered.
    seen_seated = set()
    seated_others = []
    for seat in (room.table_seats.filter(gamer__isnull=False)
                 .exclude(gamer=request.user)
                 .select_related("gamer").order_by("slot_number")):
        if seat.gamer_id not in seen_seated:
            seen_seated.add(seat.gamer_id)
            seated_others.append(seat.gamer)
    ctx["seated_others"] = seated_others
    # Voice (Phase C, room path) — the burger fan's #id_voice_btn. Mirrors my_sea's
    # voice window, but over the seat's 7d INITIAL cost period (GateSlot.cost_current
    # — NOT the renewal grace, mirroring "lasts a day, off in grace" at 7d scale).
    # It needs MORE THAN ONE distinct gamer holding a token-backed, cost-current
    # seat: a SOLE depositor — including a CARTE owner occupying all 6 seats (one
    # gamer) — has no one to talk to, so voice stays OFF; a 2nd qualifying gamer
    # flips it on, and it toggles back off once the cost period lapses into grace.
    # Gated on the gate being CLOSED (table_status set = ROLE_SELECT onset) + the
    # viewer being seated. The room UUID is the WebRTC mesh key —
    # RoomVoiceConsumer._can_join already gates an epic room on TableSeat
    # membership (no consumer change); voice_muted_at carries the persisted mute so
    # an in-period nav/refresh rejoins MUTED. [[project-my-sea-invite-voice-blueprint]]
    active_depositors = {
        slot.gamer_id
        for slot in room.gate_slots.filter(
            status=GateSlot.FILLED, gamer__isnull=False)
        if slot.cost_current
    }
    is_seated = room.table_seats.filter(gamer=request.user).exists()
    ctx["voice_active"] = (
        room.table_status is not None
        and is_seated
        and len(active_depositors) > 1
    )
    ctx["voice_room_id"] = str(room.id)
    ctx["voice_muted_at"] = (
        request.user.voice_muted_at.isoformat()
        if getattr(request.user, "voice_muted_at", None) else ""
    )
    return render(request, "apps/gameboard/room.html", ctx)


def scroll_status(request, room_id):
    """Render JUST the room scroll-of-events feed (`core/_partials/_scroll.html`)
    — fetched by room-scroll.js on a `scroll_update` WS nudge so the SCROLL
    applet refreshes live without a page reload. Same query + context keys as
    the `events`/`viewer`/`scroll_position` block in `room_view`, so the swapped
    feed renders identically to the initial paint (the redact filter + buffer
    dots are re-applied client-side after the swap)."""
    room = Room.objects.get(id=room_id)
    return render(request, "core/_partials/_scroll.html", {
        "events": room.events.select_related("actor").all(),
        "viewer": request.user,
        "scroll_position": 0,
    })


@login_required
def room_post(request, room_id):
    """Append a Line to the room's game-table thread (the POST view of the
    game-views carousel) and return the rendered line partial as JSON, so
    room-views.js can splice it into `#id_post_table` without a page reload
    (the carousel must stay put on the POST view). Only seated gamers may
    speak at the table; the validation mirrors post.html's composer
    (non-empty + no duplicate line text per thread). GET is a no-op redirect
    back to the room."""
    room = Room.objects.get(id=room_id)
    if request.method != "POST":
        return redirect("epic:room", room_id=room_id)
    # Only gamers OCCUPYING A SEAT (a TableSeat with their gamer) may speak in
    # the chat — NOT gate-slot/token depositors who haven't committed to a seat
    # (they can retract + leave, so they must not have R/W access to the chat).
    if not room.table_seats.filter(gamer=request.user).exists():
        return HttpResponseForbidden()
    post = room.get_thread_post()
    form = ExistingPostLineForm(for_post=post, data=request.POST)
    if not form.is_valid():
        return JsonResponse({"ok": False, "errors": form.errors}, status=400)
    line = form.save(author=request.user)
    line_html = render_to_string(
        "apps/billboard/_partials/_post_line.html",
        {"line": line},
        request=request,
    )
    return JsonResponse({"ok": True, "line_html": line_html})


@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"))
    # CONT GAME + the gear NVM both return to the table hex — but they must land
    # the gamer on his ACTING seat (the ?seat-selected pos-circle), not owned[0].
    # Without the ?seat a CARTE multi-seat gamer acting at pos 4 got shuttled
    # back to pos 1. Mirrors the GATE VIEW nav buttons (bedc489).
    table_url = reverse("epic:room", args=[room.id])
    if ctx.get("current_slot"):
        table_url += f"?seat={ctx['current_slot']}"
    ctx.update({
        "room": room,
        "table_url": table_url,
        "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",
        # The renewal gate-view shows the circles for their rich hover tooltips
        # ONLY — it is not a gather surface. Zero the CARTE drop/release form
        # triggers so _table_positions renders no OK/NVM/PICK ROLES buttons here
        # (renewal happens via the modal's own token rails).
        "carte_next_slot_number": None,
        "carte_nvm_slot_number": None,
        "is_last_slot": False,
    })
    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.in_use_since = timezone.now()  # room in-use clock (7d release)
            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.in_use_since = 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.in_use_since = 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)

    # 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.
    user_seat = _acting_seat(room, request.user, request.GET.get("seat"))
    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, seat=user_seat
        ).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, seat=user_seat
        ).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 SEAT already holds a *different* card — must NVM this seat
    # first. Per-seat (not per-gamer): a CARTE multi-seat owner holds one
    # reservation per owned seat, so the guard is scoped to user_seat.
    existing = SigReservation.objects.filter(
        room=room, gamer=request.user, seat=user_seat
    ).first()
    if existing and existing.card != card:
        return HttpResponse(status=409)

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

    # Block re-using a card already held for one of this gamer's OTHER seats
    # in the same polarity (each seat needs a distinct sig). Normally the grid
    # locks taken cards; this guards the API + avoids the (room, card, polarity)
    # IntegrityError on a same-card cross-seat reserve.
    if SigReservation.objects.filter(
        room=room, card=card, polarity=polarity, gamer=request.user
    ).exclude(seat=user_seat).exists():
        return HttpResponse(status=409)

    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)

    # No immediate commit. A CARTE multi-seat owner reserves one sig PER SEAT,
    # readies each, and each polarity room runs the real 12s countdown (3 ready
    # → confirm), exactly like the multi-gamer flow. `sig_confirm` / the
    # countdown timer commit `seat.significator` and advance to SKY_SELECT. The
    # old solo immediate-commit shortcut was demolished 2026-06-05 — it predated
    # the countdown mechanism and blocked picking more than one sig per gamer.
    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)
    # Per-seat ready: a CARTE multi-seat owner readies each owned seat's sig
    # independently (the ?seat=N rides the ready URL, like the reserve URL).
    user_seat = _acting_seat(room, request.user, request.GET.get("seat"))
    if user_seat is None:
        return HttpResponse(status=403)

    action = request.POST.get("action", "ready")
    reservation = SigReservation.objects.filter(
        room=room, gamer=request.user, seat=user_seat
    ).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 = _acting_seat(room, request.user, request.GET.get("seat"))
    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 = _acting_seat(room, request.user, request.GET.get("seat"))
    if seat is None:
        return HttpResponseForbidden()
    Character.objects.filter(seat=seat, retired_at__isnull=True).delete()
    return JsonResponse({'deleted': True})


def _confirmed_character_for(seat):
    """The seat's active (sky-confirmed, un-retired) Character, or None. DRAW
    SEA only follows a confirmed sky, so the Celtic-Cross hand attaches here."""
    if seat is None:
        return None
    return Character.objects.filter(
        seat=seat, confirmed_at__isnull=False, retired_at__isnull=True,
    ).first()


def _sea_affinity_for(seat, spread, hand):
    """The affinity payload for the card drawn into this seat's Role-correlated
    Celtic position (ROLE_POSITION_MAP), or None when the role has no mapped
    position / the position isn't in the hand / the card is missing. Mirrors
    SIG_READY's display: a polarity-qualified `name_title` + corner abbrev.
    `hand` = [{position, card_id, reversed, polarity}, ...]."""
    position = ROLE_POSITION_MAP.get(seat.role)
    if position is None:
        return None
    entry = next((h for h in hand if h.get("position") == position), None)
    if entry is None:
        return None
    card = TarotCard.objects.filter(id=entry.get("card_id")).first()
    if card is None:
        return None
    qual = (card.levity_qualifier if entry.get("polarity") == "levity"
            else card.gravity_qualifier)
    card_display = f"{qual} {card.name_title}" if qual else card.name_title
    label = SEA_POSITION_LABELS.get(
        spread, SEA_POSITION_LABELS["waite-smith"]).get(position, position.title())
    return {
        "position": position, "position_label": label,
        "card_name": card_display, "corner_rank": card.corner_rank,
        "suit_icon": card.suit_icon,
    }


def _redact_standing_sea_event(room, gamer, slot_number, verb):
    """Mark the most recent non-struck `verb` Scroll event for this seat
    `retracted` (the strikethrough redact); return its card_name (for the mirror
    entry) or ''. The `not retracted` test runs in PYTHON — `.exclude(
    data__retracted=True)` silently drops rows missing the key on SQLite
    [[feedback-jsonfield-exclude-sqlite-null]]."""
    candidates = [
        e for e in room.events.filter(verb=verb, actor=gamer)
        if e.data.get("slot_number") == slot_number and not e.data.get("retracted")
    ]
    if not candidates:
        return ""
    target = candidates[-1]   # events Meta-ordered by timestamp → last = newest
    target.data["retracted"] = True
    target.save(update_fields=["data"])
    return target.data.get("card_name", "")


@login_required
def sea_save(request, room_id):
    """Upsert the seat's Celtic-Cross spread hand onto its confirmed
    Character.celtic_cross. The gameroom analogue of my_sea_lock, but
    Character-backed — NONE of the solo MySeaDraw quota / FREE-PAID-DRAW /
    Brief machinery (that's my-sea-only). Fires on each card placement
    (manual FLIP or AUTO DRAW) so a navigate-away mid-draw still persists.

    POST body (JSON): {"spread": "<slug>", "hand": [{position, card_id,
    reversed, polarity}, ...]} — the FULL current hand (partial OK mid-draw).
    Returns {ok} 200 · 400 malformed · 403 not own seat / sky not confirmed.
    """
    if request.method != 'POST':
        return HttpResponse(status=405)
    room = Room.objects.get(id=room_id)
    seat = _acting_seat(room, request.user, request.GET.get("seat"))
    if seat is None:
        return HttpResponse(status=403)
    try:
        body = json.loads(request.body or b'{}')
    except json.JSONDecodeError:
        return HttpResponse(status=400)
    spread = body.get('spread')
    hand = body.get('hand')
    if not spread or not isinstance(hand, list) or not hand:
        return JsonResponse({'error': 'spread_and_hand_required'}, status=400)
    char = _confirmed_character_for(seat)
    if char is None:
        return HttpResponse(status=403)
    prior_len = len((char.celtic_cross or {}).get('hand') or [])
    char.celtic_cross = {'spread': spread, 'hand': hand}
    char.save(update_fields=['celtic_cross'])
    # Publish the affinity Scroll log on the COMPLETING save — the <6→6
    # transition only (so a reload that re-POSTs the full 6-card hand doesn't
    # double-publish). A re-draw retracts the standing relinquishment DEL left
    # behind, then publishes anew (step 3 of the lifecycle).
    if len(hand) >= 6 and prior_len < 6:
        affinity = _sea_affinity_for(seat, spread, hand)
        if affinity is not None:
            _redact_standing_sea_event(
                room, request.user, seat.slot_number, GameEvent.SEA_RELINQUISHED)
            record(room, GameEvent.SEA_DRAWN, actor=request.user,
                   role=seat.role, slot_number=seat.slot_number, **affinity)
    return JsonResponse({'ok': True})


@login_required
def sea_delete(request, room_id):
    """Clear the seat's Celtic-Cross hand (celtic_cross=None). The DRAW SEA DEL
    targets this; the Character + its confirmed sky stay intact (only the sea
    spread is dropped, so the gamer can re-draw)."""
    if request.method != 'POST':
        return HttpResponse(status=405)
    room = Room.objects.get(id=room_id)
    seat = _acting_seat(room, request.user, request.GET.get("seat"))
    if seat is None:
        return HttpResponseForbidden()
    char = _confirmed_character_for(seat)
    if char is not None and char.celtic_cross is not None:
        char.celtic_cross = None
        char.save(update_fields=['celtic_cross'])
        # Redact the published affinity (strikethrough) + leave a relinquishment
        # entry in its wake (the redact-pair). No-op when nothing was published
        # (an incomplete hand never reached the SEA_DRAWN publish).
        card_name = _redact_standing_sea_event(
            room, request.user, seat.slot_number, GameEvent.SEA_DRAWN)
        if card_name:
            record(room, GameEvent.SEA_RELINQUISHED, actor=request.user,
                   role=seat.role, slot_number=seat.slot_number, card_name=card_name)
    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 = _acting_seat(room, request.user, request.GET.get("seat"))
    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)
    # ?seat threads through so the injected felt (CARTE seat-switch) reflects the
    # selected seat's spread + carries the right seat onto its own action URLs.
    ctx = _role_select_context(room, request.user, request.GET.get("seat"))
    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)

