import json

from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils import timezone
from django.views.decorators.http import require_POST

from apps.applets.utils import applet_context, apply_applet_toggle
from .models import (
    HAND_SIZE_BY_SPREAD, POSITION_LABELS, MySeaDraw, active_draw_for,
    latest_draw_slots, _select_my_sea_token, debit_my_sea_token,
)


def _annotate_deck_in_use(decks, user):
    """Attach .in_use_room_name to each deck — the name of the active room using it, or None."""
    active = {
        ts.deck_variant_id: ts.room.name
        for ts in TableSeat.objects.filter(
            gamer=user, deck_variant__isnull=False,
        ).select_related("room")
    }
    for deck in decks:
        deck.in_use_room_name = active.get(deck.pk)
    return decks
from apps.epic.models import DeckVariant, Room, TableSeat
from apps.epic.utils import annotate_latest_event, rooms_for_user
from apps.lyric.models import Token


GAMEBOARD_APPLET_ORDER = [
    "new-game",
    "my-games",
    "game-kit",
]


@login_required(login_url="/")
def gameboard(request):
    pass_token = request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None
    band = request.user.tokens.filter(token_type=Token.BAND).first()
    coin = request.user.tokens.filter(token_type=Token.COIN).first()
    carte = request.user.tokens.filter(token_type=Token.CARTE).first()
    free_tokens = list(request.user.tokens.filter(
        token_type=Token.FREE, expires_at__gt=timezone.now()
    ).order_by("expires_at"))
    return render(
        request, "apps/gameboard/gameboard.html", {
            "pass_token": pass_token,
            "band": band,
            "coin": coin,
            "carte": carte,
            "equipped_trinket_id": request.user.equipped_trinket_id,
            "equipped_deck_id": request.user.equipped_deck_id,
            "deck_variants": _annotate_deck_in_use(list(request.user.unlocked_decks.all()), request.user),
            "free_tokens": free_tokens,
            "free_count": len(free_tokens),
            "applets": applet_context(request.user, "gameboard"),
            "page_class": "page-gameboard",
            "my_games": annotate_latest_event(rooms_for_user(request.user)),
            "my_sea_slots": latest_draw_slots(request.user),
        }
    )

@login_required(login_url="/")
def toggle_game_applets(request):
    checked = request.POST.getlist("applets")
    apply_applet_toggle(request.user, "gameboard", checked)
    if request.headers.get("HX-Request"):
        free_tokens = list(request.user.tokens.filter(
            token_type=Token.FREE, expires_at__gt=timezone.now()
        ).order_by("expires_at"))
        return render(request, "apps/gameboard/_partials/_applets.html", {
            "applets": applet_context(request.user, "gameboard"),
            "pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
            "band": request.user.tokens.filter(token_type=Token.BAND).first(),
            "coin": request.user.tokens.filter(token_type=Token.COIN).first(),
            "carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
            "equipped_trinket_id": request.user.equipped_trinket_id,
            "equipped_deck_id": request.user.equipped_deck_id,
            "deck_variants": _annotate_deck_in_use(list(request.user.unlocked_decks.all()), request.user),
            "free_tokens": free_tokens,
            "free_count": len(free_tokens),
            "my_games": annotate_latest_event(rooms_for_user(request.user)),
            "my_sea_slots": latest_draw_slots(request.user),
        })
    return redirect("gameboard")


@login_required(login_url="/")
def equip_trinket(request, token_id):
    token = get_object_or_404(Token, pk=token_id, user=request.user)
    if request.method == "POST":
        request.user.equipped_trinket = token
        request.user.save(update_fields=["equipped_trinket"])
        return HttpResponse(status=204)
    return render(
        request,
        "apps/gameboard/_partials/_equip_trinket_btn.html",
        {"token": token},
    )


@login_required(login_url="/")
def equip_deck(request, deck_id):
    deck = get_object_or_404(DeckVariant, pk=deck_id)
    if request.method == "POST":
        request.user.equipped_deck = deck
        request.user.save(update_fields=["equipped_deck"])
        return HttpResponse(status=204)
    return HttpResponse(status=405)


@login_required(login_url="/")
def unequip_trinket(request, token_id):
    token = get_object_or_404(Token, pk=token_id, user=request.user)
    if request.method == "POST":
        if request.user.equipped_trinket_id == token.pk:
            request.user.equipped_trinket = None
            request.user.save(update_fields=["equipped_trinket"])
        return HttpResponse(status=204)
    return HttpResponse(status=405)


@login_required(login_url="/")
def unequip_deck(request, deck_id):
    get_object_or_404(DeckVariant, pk=deck_id)
    if request.method == "POST":
        if request.user.equipped_deck_id == deck_id:
            request.user.equipped_deck = None
            request.user.save(update_fields=["equipped_deck"])
        return HttpResponse(status=204)
    return HttpResponse(status=405)


def _game_kit_context(user):
    from apps.lyric.models import PRONOUN_CHOICES
    coin = user.tokens.filter(token_type=Token.COIN).first()
    pass_token = user.tokens.filter(token_type=Token.PASS).first() if user.is_staff else None
    band = user.tokens.filter(token_type=Token.BAND).first()
    carte = user.tokens.filter(token_type=Token.CARTE).first()
    free_tokens = list(user.tokens.filter(
        token_type=Token.FREE, expires_at__gt=timezone.now()
    ).order_by("expires_at"))
    tithe_tokens = list(user.tokens.filter(token_type=Token.TITHE))
    pronoun_options = [
        {"key": k, "label": label, "active": (k == user.pronouns)}
        for (k, label) in PRONOUN_CHOICES
    ]
    return {
        "coin": coin,
        "pass_token": pass_token,
        "band": band,
        "carte": carte,
        "free_tokens": free_tokens,
        "tithe_tokens": tithe_tokens,
        # `free_count` / `tithe_count` mirror the gameboard Game Kit applet's
        # context — drive the `(×N)` chip in the .tt-title plus the rendered
        # `.shop-badge` on the icon. Both capped to "99+" at the template
        # level so a runaway count doesn't blow the badge layout.
        "free_count": len(free_tokens),
        "tithe_count": len(tithe_tokens),
        # `deck_variants` is the annotated list (carries .in_use_room_name
        # per `_annotate_deck_in_use`); the gk-decks section uses the same
        # SVG card-stack icon + tt-tooltip pattern as the gameboard's Game
        # Kit applet so the deck row reads identically on both surfaces.
        "deck_variants": _annotate_deck_in_use(list(user.unlocked_decks.all()), user),
        "equipped_deck_id": user.equipped_deck_id,
        # `equipped_trinket_id` powers the gk-trinkets section's DON/DOFF
        # buttons + mini-portal Equipped status (parity w. gameboard applet).
        "equipped_trinket_id": user.equipped_trinket_id,
        "applets": applet_context(user, "game-kit"),
        "pronoun_options": pronoun_options,
        "current_pronouns": user.pronouns,
    }


@login_required(login_url="/")
def game_kit(request):
    return render(request, "apps/gameboard/game_kit.html", {
        **_game_kit_context(request.user),
        "page_class": "page-gameboard",
    })


@login_required(login_url="/")
def toggle_game_kit_sections(request):
    checked = request.POST.getlist("applets")
    apply_applet_toggle(request.user, "game-kit", checked)
    if request.headers.get("HX-Request"):
        return render(request, "apps/gameboard/_partials/_game_kit_sections.html",
                      _game_kit_context(request.user))
    return redirect("game_kit")


def _saved_by_position(saved_hand):
    """Build the per-position saved-card lookup the picker cross renders from
    (`_my_sea_slot.html`). Keyed by position slug ("lay", "cover", ...); each
    value carries the pre-resolved card fields so the template never hits the
    DB per slot. Shared by the owner picker (`my_sea`) AND the read-only
    spectator render (`my_sea_visit`) so both draw the IDENTICAL cross."""
    saved = {}
    if not saved_hand:
        return saved
    from apps.epic.models import TarotCard
    ids = [e["card_id"] for e in saved_hand]
    cards_by_id = {c.id: c for c in TarotCard.objects.filter(id__in=ids)}
    for entry in saved_hand:
        c = cards_by_id.get(entry["card_id"])
        saved[entry["position"]] = {
            "card_id": entry["card_id"],
            "reversed": entry.get("reversed", False),
            "polarity": entry.get("polarity", "gravity"),
            "corner_rank": c.corner_rank if c else "",
            "suit_icon": c.suit_icon if c else "",
            "has_card_images": (c.deck_variant.has_card_images
                                if c and c.deck_variant else False),
            "image_url": c.image_url if c else "",
            "arcana": c.arcana if c else "",
            "name": c.name if c else "",
        }
    return saved


@login_required(login_url="/")
def my_sea(request):
    """Shell view for the My Sea standalone page.

    Sprint 5 iter 4c branching — `MySeaDraw` now plays double-duty as
    hand storage AND 24h quota tracker. The row is created on first
    card draw + survives DEL (which only wipes the hand). View states:

    1. No sig → Look!-formatted sign-gate (Sprint 4b).
    2. Active draw, hand non-empty (mid-draw or complete) → picker phase
       w. saved hand state. The DEL btn is server-rendered `.btn-
       disabled` until hand is complete; AUTO DRAW becomes GATE VIEW on
       completion. The draw's sig snapshot is rendered (NOT user.
       significator) so a cleared sig elsewhere doesn't invalidate the
       saved draw.
    3. Active draw, hand empty (post-DEL) → landing w. GATE VIEW (the
       free-draw quota is spent; user must use tokens via the upcoming
       Sprint 6 gatekeeper). Brief banner still surfaces the next-
       free-draw timestamp.
    4. No active draw + sig set → landing w. FREE DRAW (the daily quota
       is available).
       4a. + no equipped deck → also show backup-deck Brief banner.
    """
    active_draw = active_draw_for(request.user)
    sig_card, sig_reversed = _resolve_sig(request.user, active_draw)
    user_has_sig = sig_card is not None
    no_equipped_deck = request.user.equipped_deck_id is None

    if active_draw is not None:
        default_spread = active_draw.spread
        saved_hand = active_draw.hand
        hand_complete = active_draw.is_hand_complete
        hand_empty = active_draw.is_hand_empty
    else:
        default_spread = "situation-action-outcome"
        saved_hand = []
        hand_complete = False
        hand_empty = True
    # Brief banner's "next free draw at" — prefer the User's cooldown
    # anchor (`User.last_free_draw_at + 24h`, set on the first card of
    # the FREE DRAW path; persists across PAID DRAW commits per user-
    # spec 2026-05-23). Falls back to the active row's own
    # `next_free_draw_at` for legacy rows (or test fixtures that bypass
    # `my_sea_lock`).
    next_free_draw_at = (
        request.user.next_free_draw_at
        or (active_draw.next_free_draw_at if active_draw is not None else None)
    )
    # Sprint 6 iter 6b + 2026-05-23 fix — landing center-btn state machine.
    # The user is "in cooldown" iff a `MySeaDraw` row exists (the row was
    # created at first card-draw of the cycle + survives PAID DRAW commit).
    # Within cooldown:
    #
    #   deposit reserved (at gatekeeper)     → PAID DRAW (commits + picker)
    #   paid-through credit set              → PAID DRAW (navigates + picker)
    #   neither                              → GATE VIEW
    #
    # Outside cooldown (no row):             → FREE DRAW
    #
    # The two PAID DRAW states share one button label so the user sees a
    # stable "you're in a paid cycle" cue across navigation — user-
    # reported bug 2026-05-23: PAID DRAW used to revert to FREE DRAW
    # after the row was deleted at commit time.
    deposit_reserved = (
        active_draw is not None and active_draw.deposit_token_id is not None
    )
    paid_through = (
        active_draw is not None and active_draw.paid_through_at is not None
    )
    in_cooldown = active_draw is not None
    show_paid_draw = in_cooldown and (deposit_reserved or paid_through)
    show_gate_view = in_cooldown and not show_paid_draw
    hand_non_empty = active_draw is not None and bool(active_draw.hand)
    # Picker is the active phase iff:
    #   - the user has a non-empty hand in progress / complete, OR
    #   - `?phase=picker` query param is set AND the user is in a paid
    #     cycle (deposit reserved OR paid-through credit set) — covers
    #     the `my_sea_paid_draw` redirect + lets the PAID DRAW landing
    #     button send the user back to the picker via a GET.
    # `?phase=landing` is the explicit ESCAPE HATCH: it forces the
    # landing template even when a non-empty hand exists, so the gear-
    # menu NVM (mid-draw) can dump the user back to the table hex
    # instead of just looping them back into the picker. The landing
    # then renders a CONT DRAW btn that re-enters the picker.
    phase_param = request.GET.get("phase") == "picker"
    force_landing = request.GET.get("phase") == "landing"
    show_picker = (hand_non_empty or (phase_param and show_paid_draw)) \
                  and not force_landing
    show_cont_draw = (
        force_landing
        and active_draw is not None
        and bool(active_draw.hand)            # at least 1 card drawn
        and not active_draw.is_hand_complete  # but not all
    )

    # Per-position lookup for the template — keyed by the position slug
    # ("lay", "cover", ...) so each `.sea-pos-<name>` block can render
    # either its saved card OR an `--empty` slot via a single `{% with
    # entry=saved_by_position.lay %}` block. The card fields (corner_rank,
    # suit_icon) come pre-resolved so the template doesn't need to do a
    # DB lookup per slot.
    # Per-position lookup for `_my_sea_slot.html` — see `_saved_by_position`
    # (shared with the read-only spectator render so both draw the same cross).
    saved_by_position = _saved_by_position(saved_hand)

    # @taxman Brief payloads w. NVM-persistence (user-spec 2026-05-26). The
    # FREE DRAW Brief surfaces ONLY when an active draw exists AND the user
    # hasn't NVM-dismissed since the cycle began. PAID DRAW Brief is
    # independent — surfaces while a PAID DRAW commit has happened in this
    # cycle (paid_through_at OR a prior commit) AND the paid-draw NVM
    # anchor is older than the latest paid-draw brief.
    from django.urls import reverse
    free_draw_brief_payload = None
    paid_draw_brief_payload = None
    if active_draw is not None:
        free_draw_brief_payload = _tax_brief_payload(
            request.user, "FREE DRAW",
            request.user.free_draw_brief_dismissed_at,
            reverse("my_sea_dismiss_free_draw_brief"),
        )
        paid_draw_brief_payload = _tax_brief_payload(
            request.user, "PAID DRAW",
            request.user.paid_draw_brief_dismissed_at,
            reverse("my_sea_dismiss_paid_draw_brief"),
        )

    # Phase C — the owner's voice btn lights up while any present invitee
    # has an open 24h voice window. Room key is mysea-<owner_id>.
    from .models import SeaInvite
    voice_active = SeaInvite.objects.filter(
        owner=request.user, status=SeaInvite.ACCEPTED,
        token_deposited_at__isnull=False, left_at__isnull=True,
        voice_until__gt=timezone.now(),
    ).exists()

    return render(request, "apps/gameboard/my_sea.html", {
        "user_has_sig": user_has_sig,
        "voice_active": voice_active,
        "voice_room_id": f"mysea-{request.user.id}",
        "voice_muted_at": (request.user.voice_muted_at.isoformat()
                           if request.user.voice_muted_at else ""),
        "no_equipped_deck": no_equipped_deck,
        "show_backup_intro_banner": (
            user_has_sig and no_equipped_deck and active_draw is None
        ),
        "significator": sig_card,
        "significator_reversed": sig_reversed,
        "default_spread": default_spread,
        "reversals_pct": 25,
        "sea_deck_data": (
            _my_sea_deck_data(request.user, exclude_id=sig_card.id if sig_card else None)
            if user_has_sig else {"levity": [], "gravity": []}
        ),
        # Iter 4b / 4c
        "active_draw": active_draw,
        "saved_hand": saved_hand,
        "saved_by_position": saved_by_position,
        "next_free_draw_at": next_free_draw_at,
        "hand_complete": hand_complete,
        # The owner's landing hex shows present visitors too (2C-6C), not just
        # 1C — the same `_my_sea_seats` ring the spectator + broadcast use.
        # 1C seating is driven entirely by this ring's `seat.present`; the dead
        # `seat1_seated` flag was removed 2026-05-31 (the template never read
        # it — `_my_sea_seats` already seats 1C for the full 24h window).
        "seats": _my_sea_seats(request.user),
        "show_picker": show_picker,
        "show_cont_draw": show_cont_draw,
        # Sub-btn .active flag for the burger fan — Sea sub-btn lights up
        # for the whole picker phase + STAYS active even after hand_complete
        # so the user can still open the modal to reach DEL + the GATE VIEW
        # btn (both live inside the modal). Earlier iteration tied this to
        # `show_picker and not hand_complete`; that locked DEL + GATE VIEW
        # behind an inactive btn once all cards landed.
        "sea_btn_active": show_picker,
        # Phase 3 of the Sea sub-btn rollout — pre-first-draw glow handoff.
        # True when picker phase is active + hand still empty (paid-draw
        # entry, or page reload of an empty picker). The fresh DRAW SEA
        # → picker transition happens client-side w. show_picker=False on
        # the rendered template, so the FREE-DRAW JS handler ALSO sets the
        # glow on burger in that path.
        "sea_first_draw_pending": show_picker and not hand_non_empty,
        "show_paid_draw": show_paid_draw,
        "show_gate_view": show_gate_view,
        "deposit_reserved": deposit_reserved,
        "paid_through": paid_through,
        "hand_non_empty": hand_non_empty,
        # TAX_LEDGER brief payloads (None when not eligible to show)
        "free_draw_brief_payload": free_draw_brief_payload,
        "paid_draw_brief_payload": paid_draw_brief_payload,
        "page_class": "page-gameboard page-my-sea",
    })


def _resolve_sig(user, active_draw):
    """When an active draw exists, render its sig snapshot — even if
    user.significator has since been cleared (per user spec, preserve the
    old sig on the saved draw). Otherwise use user.significator."""
    if active_draw is not None:
        from apps.epic.models import TarotCard
        sig = TarotCard.objects.filter(id=active_draw.significator_id).first()
        return sig, active_draw.significator_reversed
    return user.significator, user.significator_reversed


def _tax_brief_payload(user, slug_marker, dismissed_at, dismiss_url):
    """Return `Brief.to_banner_dict()` (w. `dismiss_url` populated) for the
    user's latest TAX_LEDGER Brief whose Line text contains `slug_marker`
    (e.g. "FREE DRAW" / "PAID DRAW"), IF the user's `dismissed_at` anchor
    is None OR strictly less than the brief's `created_at`. Returns None
    otherwise — used by the my_sea view to drive conditional Brief render
    in the template w. NVM-persistence semantics per user-spec 2026-05-26.

    Slug-marker filtering on Line text keeps the Brief model schema flat
    (no sub_kind discriminator); FREE/PAID DRAW are the only two TAX_
    LEDGER markers today + their wordings are non-overlapping."""
    from apps.billboard.models import Brief
    brief = (Brief.objects
             .filter(owner=user, kind=Brief.KIND_TAX_LEDGER,
                     line__text__contains=slug_marker)
             .order_by("-created_at").first())
    if brief is None:
        return None
    if dismissed_at is not None and dismissed_at >= brief.created_at:
        return None
    payload = brief.to_banner_dict()
    payload["dismiss_url"] = dismiss_url
    return payload


def _notify_sea_draw(owner_id, hand, spread):
    """Best-effort push of the owner's current hand to watching invitees (the
    my-sea spectate WS, `mysea_<owner_id>`) so they witness each card land
    without refreshing. The `spread` rides along so a spectator who loaded under
    a different spread re-syncs its cross layout + captions before filling the
    hand — otherwise a post-DEL spread switch lands the new cards into the old
    spread's cells (user-reported asymmetry 2026-05-30). Guarded — a missing/
    down channel layer must never break the solo draw, since the spectate is an
    enhancement, not a hard dependency."""
    try:
        from asgiref.sync import async_to_sync
        from channels.layers import get_channel_layer
        layer = get_channel_layer()
        if layer is None:
            return
        async_to_sync(layer.group_send)(
            f"mysea_{owner_id}",
            {"type": "sea_draw", "hand": hand, "spread": spread},
        )
    except Exception:
        pass


def _notify_sea_spread(owner_id, spread):
    """Best-effort push of JUST the owner's chosen spread (no hand) to watching
    invitees, so the spectator's cross re-lays-out + re-captions the moment the
    owner closes her SPREAD modal — before any card is drawn (user-spec
    2026-05-30). Previously the spread only reached spectators piggy-backed on
    the first `sea_draw`, so the visitor sat on a stale layout until a card
    landed. Guarded, same as `_notify_sea_draw`."""
    try:
        from asgiref.sync import async_to_sync
        from channels.layers import get_channel_layer
        layer = get_channel_layer()
        if layer is None:
            return
        async_to_sync(layer.group_send)(
            f"mysea_{owner_id}",
            {"type": "sea_spread", "spread": spread},
        )
    except Exception:
        pass


def _my_sea_seats(owner):
    """Table-ring seat list for `owner`'s sea — owner 1C + present invitees
    2C-6C by deposit order (capped at MY_SEA_MAX_VISITORS). Each entry is
    {n, label, present, token, invitee_id?}; the per-viewer `is_self` is layered
    on by the caller. Shared by the spectator render (my_sea_visit) AND the live
    seat broadcast (`sea_seats`)."""
    from .models import SeaInvite, MY_SEA_MAX_VISITORS
    owner_draw = active_draw_for(owner)
    # Seated in 1C for the FULL 24h window — as long as a draw row exists
    # (free or paid; PAID DRAW resets created_at so the window runs from the
    # later of the two). Decoupled from hand/deposit/paid state per user-spec
    # 2026-05-31: a DEL'd row (empty hand, no paid credit) keeps the owner
    # seated until the row expires at 24h, not unseated the instant she DELs.
    owner_seated = owner_draw is not None
    owner_token = (f"owner-{owner.id}-{owner_draw.id}"
                   if owner_draw is not None else "")
    seats = [{"n": 1, "label": "1C", "present": owner_seated, "token": owner_token}]
    present = (
        SeaInvite.objects
        .filter(owner=owner, status=SeaInvite.ACCEPTED,
                token_deposited_at__isnull=False, left_at__isnull=True)
        .order_by("token_deposited_at")[:MY_SEA_MAX_VISITORS]
    )
    for idx, inv in enumerate(present):
        seats.append({"n": idx + 2, "label": f"{idx + 2}C", "present": True,
                      "token": f"visit-{inv.id}",
                      "invitee_id": str(inv.invitee_id)})
    while len(seats) < 6:
        n = len(seats) + 1
        seats.append({"n": n, "label": f"{n}C", "present": False, "token": ""})
    return seats


def _notify_sea_seats(owner_id):
    """Best-effort push of the seat ring to watching invitees when presence
    changes (a deposit takes a seat / a BYE frees one) so the hex updates live.
    Guarded, same as `_notify_sea_draw`."""
    try:
        from asgiref.sync import async_to_sync
        from channels.layers import get_channel_layer
        from apps.lyric.models import User
        layer = get_channel_layer()
        if layer is None:
            return
        owner = User.objects.filter(id=owner_id).first()
        if owner is None:
            return
        async_to_sync(layer.group_send)(
            f"mysea_{owner_id}",
            {"type": "sea_seats", "seats": _my_sea_seats(owner)},
        )
    except Exception:
        pass


@login_required(login_url="/")
@require_POST
def my_sea_lock(request):
    """Upsert the user's draw hand state. Sprint 5 iter 4c refactor —
    fires on every card placement (manual FLIP or AUTO DRAW completion)
    rather than only on a discrete LOCK HAND action.

    Body: JSON `{"spread": "<slug>", "hand": [{position, card_id, reversed,
    polarity}, ...]}` — `hand` is the current FULL state (partial OK
    for mid-draw; sized to HAND_SIZE_BY_SPREAD for complete).

    Returns:
      200 `{ok, next_free_draw_at, hand_complete}` on success
      400 malformed payload or no sig
      409 spread differs from the user's already-active draw's spread
          (the spread is locked at first-card moment; can't switch mid-
          draw via a sneaky POST)
    """
    try:
        payload = json.loads(request.body.decode("utf-8") or "{}")
    except json.JSONDecodeError:
        return JsonResponse({"error": "invalid_json"}, status=400)

    spread = payload.get("spread")
    hand = payload.get("hand")
    if not spread or not isinstance(hand, list) or not hand:
        return JsonResponse({"error": "spread_and_hand_required"}, status=400)
    if spread not in HAND_SIZE_BY_SPREAD:
        return JsonResponse({"error": "unknown_spread"}, status=400)

    existing = active_draw_for(request.user)
    if existing is not None:
        # Mid-draw upsert OR post-DEL re-draw (which Sprint 6 will route
        # through the gatekeeper but the endpoint stays permissive here).
        # Spread-switch policy (refined 2026-05-25 PM per user bug report —
        # AUTO DRAW failing on non-SAO spreads with 409): the spread is
        # committed at first-card moment for the duration of the active
        # NON-EMPTY draw. Once the user DELs (clears hand to []) the row
        # stays alive for its 24h quota window but the spread lock lifts —
        # the user can pick a fresh spread for the next draw without losing
        # the cooldown clock. Mid-non-empty-draw spread switches still get
        # 409 to prevent the "sneaky POST" of a different spread without an
        # explicit DEL gesture.
        spread_changed = existing.spread != spread
        if spread_changed and existing.hand:
            return JsonResponse({"error": "spread_mismatch"}, status=409)
        # If this row carried a paid-through credit (set by `my_sea_paid_
        # draw` at commit time) AND we're transitioning empty→non-empty,
        # the credit is being consumed by this draw — clear it so the
        # next attempt requires a fresh gatekeeper deposit (user-spec
        # 2026-05-23: "each redraw needs a new token").
        was_empty = not existing.hand
        existing.hand = hand
        update_fields = ["hand"]
        if spread_changed:
            existing.spread = spread
            update_fields.append("spread")
        if (was_empty and hand
                and existing.paid_through_at is not None):
            existing.paid_through_at = None
            update_fields.append("paid_through_at")
        existing.save(update_fields=update_fields)
        # live to spectators — hand + spread (a post-DEL re-draw may switch it)
        _notify_sea_draw(request.user.id, existing.hand, existing.spread)
        return JsonResponse({
            "ok": True,
            "next_free_draw_at": (
                request.user.next_free_draw_at.isoformat()
                if request.user.next_free_draw_at else None
            ),
            "hand_complete": existing.is_hand_complete,
        })

    # First card draw of a fresh cycle (no row exists). If the user's
    # free-draw cooldown isn't active, this is a FREE DRAW — anchor the
    # 24h cooldown to the User now (NOT to the row's created_at, per
    # user-spec 2026-05-23: the cooldown stays put even across PAID
    # DRAWs in the same cycle).
    sig_id = request.user.significator_id
    if sig_id is None:
        return JsonResponse({"error": "no_significator"}, status=400)

    if not request.user.free_draw_cooldown_active:
        request.user.last_free_draw_at = timezone.now()
        # Fresh FREE DRAW cycle → clear the NVM-dismissal anchor so the
        # newly-emitted Brief surfaces on the next page load. User-spec
        # 2026-05-26: NVM dismissal persists ONLY until the next FREE
        # DRAW spend; once that lands, the new ledger entry re-opens the
        # Brief surface for the new cycle.
        request.user.free_draw_brief_dismissed_at = None
        request.user.save(update_fields=[
            "last_free_draw_at", "free_draw_brief_dismissed_at",
        ])

    draw = MySeaDraw.objects.create(
        user=request.user,
        spread=spread,
        hand=hand,
        significator_id=sig_id,
        significator_reversed=request.user.significator_reversed,
    )
    _notify_sea_draw(request.user.id, draw.hand, draw.spread)   # live to spectators
    # Append the @taxman ledger entry + spawn the Brief. Response carries
    # the Brief payload so the picker IIFE can surface the banner in-place
    # w.o. a page reload — same affordance the prior in-template
    # `_showFreeDrawLockedBrief` provided, just w. server-authored copy +
    # NVM-persistence via `dismiss_url`.
    from django.urls import reverse
    from apps.billboard.tax import log_tax_debit
    _, _, brief = log_tax_debit(request.user, "free_draw_locked")
    brief_payload = brief.to_banner_dict()
    brief_payload["dismiss_url"] = reverse("my_sea_dismiss_free_draw_brief")
    return JsonResponse({
        "ok": True,
        "next_free_draw_at": (
            request.user.next_free_draw_at.isoformat()
            if request.user.next_free_draw_at else None
        ),
        "hand_complete": draw.is_hand_complete,
        "free_draw_brief_payload": brief_payload,
    })


@login_required(login_url="/")
@require_POST
def my_sea_spread(request):
    """Broadcast the owner's chosen spread to watching spectators the moment she
    closes the SPREAD modal — before any card is drawn (user-spec 2026-05-30).

    Body: JSON `{"spread": "<slug>"}`. No hand is touched. If an active draw row
    exists with an EMPTY hand (fresh / post-DEL), the spread is persisted onto it
    so a spectator who loads fresh ALSO lands on the right layout — this stays
    within the existing "spread locks at first card" policy (a non-empty hand
    never reaches here from the client, and we never overwrite a drawn spread).

    Returns 200 `{ok}` on success; 400 for a malformed / unknown spread."""
    try:
        payload = json.loads(request.body.decode("utf-8") or "{}")
    except json.JSONDecodeError:
        return JsonResponse({"error": "invalid_json"}, status=400)
    spread = payload.get("spread")
    if spread not in HAND_SIZE_BY_SPREAD:
        return JsonResponse({"error": "unknown_spread"}, status=400)
    draw = active_draw_for(request.user)
    if draw is not None and not draw.hand and draw.spread != spread:
        draw.spread = spread
        draw.save(update_fields=["spread"])
    _notify_sea_spread(request.user.id, spread)
    return JsonResponse({"ok": True})


@login_required(login_url="/")
@require_POST
def my_sea_delete(request):
    """Clear the user's active draw hand — preserves the `MySeaDraw` row
    so the 24h quota window keeps running. Per user spec (2026-05-20):
    DEL doesn't refund the daily free-draw; the row stays as a quota
    tracker until 24h elapse, after which `active_draw_for`'s lazy
    cleanup reaps it (or the `delete_stale_my_sea_draws` mgmt cmd does).

    Idempotent: re-firing on a row w. already-empty hand is a no-op."""
    draw = active_draw_for(request.user)
    if draw is not None:
        draw.hand = []
        draw.save(update_fields=["hand"])
        # clear the spectators' cross (spread preserved on the row → keep layout)
        _notify_sea_draw(request.user.id, [], draw.spread)
    return HttpResponse(status=204)


@login_required(login_url="/")
def my_sea_gate(request):
    """Sprint 6 iter 6a — solo my-sea gatekeeper. Mirrors the room's
    `_gatekeeper.html` structure (coin-slot rails + refund affordance)
    adapted for 1-user, 1-token-per-redraw semantics. The user spends a
    token (PASS/COIN/FREE/TITHE — CARTE excluded) to acquire a fresh
    24h quota cycle after their daily free draw is spent.

    Branches on `MySeaDraw.deposit_token_id`:
      - None (no deposit yet) → INSERT TOKEN TO PLAY rails are active.
      - non-None (deposit reserved) → refund affordance + PAID DRAW btn.
    """
    active_draw = active_draw_for(request.user)
    sig_card, sig_reversed = _resolve_sig(request.user, active_draw)
    deposit_reserved = (
        active_draw is not None and active_draw.deposit_token_id is not None
    )
    hand_non_empty = active_draw is not None and bool(active_draw.hand)
    return render(request, "apps/gameboard/my_sea_gate.html", {
        "user_has_sig": sig_card is not None,
        "significator": sig_card,
        "significator_reversed": sig_reversed,
        "active_draw": active_draw,
        "deposit_reserved": deposit_reserved,
        "hand_non_empty": hand_non_empty,
        "page_class": "page-gameboard page-my-sea page-my-sea-gate",
    })


@login_required(login_url="/")
@require_POST
def my_sea_insert_token(request):
    """Reserve the user's next-priority token on their MySeaDraw row.
    Idempotent w.r.t. an already-reserved deposit — re-posting is a no-op
    rather than double-debit. Creates the row if none exists (so a fresh
    user can hit the gatekeeper without first using their free draw)."""
    active_draw = active_draw_for(request.user)
    if active_draw is None:
        # No active row yet — create a quota tracker row w. empty hand
        # so the deposit has something to attach to. This also commits
        # the user's free-draw quota for the day (since `active_draw_
        # for` will now return this row).
        sig_id = request.user.significator_id
        if sig_id is None:
            return redirect("my_sea_gate")
        active_draw = MySeaDraw.objects.create(
            user=request.user,
            spread="situation-action-outcome",
            hand=[],
            significator_id=sig_id,
            significator_reversed=request.user.significator_reversed,
        )
    if active_draw.deposit_token_id is not None:
        return redirect("my_sea_gate")
    token = _select_my_sea_token(request.user)
    if token is None:
        return redirect("my_sea_gate")
    active_draw.deposit_token_id = token.pk
    active_draw.deposit_reserved_at = timezone.now()
    active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
    return redirect("my_sea_gate")


@login_required(login_url="/")
@require_POST
def my_sea_refund_token(request):
    """Clear the user's deposit reservation. Token wasn't actually
    debited at INSERT (refund-aware design), so this is purely a row
    update — no side effects on the user's inventory."""
    active_draw = active_draw_for(request.user)
    if active_draw is not None and active_draw.deposit_token_id is not None:
        active_draw.deposit_token_id = None
        active_draw.deposit_reserved_at = None
        active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
    return redirect("my_sea_gate")


@login_required(login_url="/")
@require_POST
def my_sea_paid_draw(request):
    """Commit the deposited token + mark the row as paid-through so the
    PAID DRAW button label persists if the user navigates away before
    drawing cards (user-reported bug 2026-05-23: PAID DRAW was reverting
    to FREE DRAW after one navigation cycle because the row was deleted
    at commit time, wiping the cooldown state).

    Semantics:
      - debit_my_sea_token consumes the deposited token (FREE/TITHE
        deleted; COIN: 24h cooldown + unequip; PASS/BAND: no-op).
      - `deposit_token_id` + `deposit_reserved_at` cleared (token spent,
        no longer reserved).
      - `paid_through_at = now` — sticky credit marker. Drives the
        landing-button logic in `my_sea` (PAID DRAW button stays so the
        user can re-enter the picker without another gatekeeper visit
        as long as `hand` stays empty).
      - `hand = []` — fresh start per user-spec 2026-05-23 ("clear hand
        on PAID DRAW commit").
      - `User.last_free_draw_at` is NOT touched. The 24h cooldown stays
        anchored to the original FREE DRAW moment (NOT the paid draw).

    Redirects to /gameboard/my-sea/?phase=picker so the user lands
    directly in the picker after the commit.
    """
    from django.urls import reverse
    from apps.lyric.models import Token
    active_draw = active_draw_for(request.user)
    if active_draw is None:
        return redirect("my_sea")
    # Paid-through credit already set (no deposit currently reserved) —
    # this is the user clicking PAID DRAW on the landing AFTER an earlier
    # commit, to re-enter the picker. No token debit, just route to the
    # picker (the `paid_through_at` credit stays until the first card
    # lock consumes it in `my_sea_lock`).
    if active_draw.deposit_token_id is None:
        if active_draw.paid_through_at is not None:
            return redirect(reverse("my_sea") + "?phase=picker")
        return redirect("my_sea")
    token = Token.objects.filter(
        pk=active_draw.deposit_token_id, user=request.user,
    ).first()
    if token is None:
        # Token vanished between reserve + commit (unlikely w. solo
        # flow but defensive). Clear deposit + bounce to my-sea.
        active_draw.deposit_token_id = None
        active_draw.deposit_reserved_at = None
        active_draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
        return redirect("my_sea")
    debit_my_sea_token(request.user, token)
    active_draw.deposit_token_id = None
    active_draw.deposit_reserved_at = None
    active_draw.paid_through_at = timezone.now()
    active_draw.hand = []
    active_draw.save(update_fields=[
        "deposit_token_id", "deposit_reserved_at",
        "paid_through_at", "hand",
    ])
    # Fresh PAID DRAW commit → clear the PAID DRAW NVM-dismissal anchor +
    # append the @taxman ledger entry / spawn the Brief. Per user-spec
    # 2026-05-26 the PAID DRAW Brief NVM-persistence is independent from
    # FREE DRAW's; each cycle's dismissal lifts on its own next-spend.
    request.user.paid_draw_brief_dismissed_at = None
    request.user.save(update_fields=["paid_draw_brief_dismissed_at"])
    from apps.billboard.tax import log_tax_debit
    log_tax_debit(request.user, "paid_draw_locked")
    return redirect(reverse("my_sea") + "?phase=picker")


@login_required(login_url="/")
@require_POST
def my_sea_dismiss_free_draw_brief(request):
    """Stamp `User.free_draw_brief_dismissed_at` so the FREE DRAW Brief
    stays suppressed on subsequent page loads until the next FREE DRAW is
    spent (`my_sea_lock` clears the anchor on a fresh-cycle commit, re-
    opening the Brief surface for the new cycle).

    User-spec 2026-05-26: NVM-persistence for the FREE DRAW Brief. Fire-
    and-forget POST from `note.js`'s NVM click handler; returns 204."""
    request.user.free_draw_brief_dismissed_at = timezone.now()
    request.user.save(update_fields=["free_draw_brief_dismissed_at"])
    return HttpResponse(status=204)


@login_required(login_url="/")
@require_POST
def my_sea_dismiss_paid_draw_brief(request):
    """Stamp `User.paid_draw_brief_dismissed_at` so the PAID DRAW Brief
    stays suppressed on subsequent page loads until the next PAID DRAW
    commit. Mirror of `my_sea_dismiss_free_draw_brief`."""
    request.user.paid_draw_brief_dismissed_at = timezone.now()
    request.user.save(update_fields=["paid_draw_brief_dismissed_at"])
    return HttpResponse(status=204)


@login_required(login_url="/")
@require_POST
def my_sea_invite(request):
    """Invite a bud to the owner's my-sea table (Phase A of
    [[my-sea-invite-voice-blueprint]] — replaces the iter-6c "coming soon"
    stub). Resolves the recipient (email OR username) to a registered User
    when possible, dedups against an outstanding PENDING/ACCEPTED invite for
    the same (owner, invitee_email), creates a SeaInvite(PENDING), and logs
    the @mailman "Acceptances & rejections" Line + invitee Brief via
    `apps.billboard.mail.log_sea_invite`.

    Returns JSON `{brief, recipient_display}` for the inviter's sent-
    confirmation banner (bud-btn.js `onSuccess` renders it). The inviter's
    confirmation is a transient banner; the invitee's notification IS
    persisted (log_sea_invite spawns the Brief)."""
    from django.urls import reverse
    from apps.billboard.mail import log_sea_invite
    from apps.billboard.views import _resolve_recipient
    from .models import SeaInvite

    raw = (request.POST.get("recipient") or "").strip()
    if not raw:
        return JsonResponse({"brief": None, "recipient_display": None})

    candidate = _resolve_recipient(raw)
    if candidate is not None and candidate == request.user:
        return JsonResponse({"brief": None, "recipient_display": None})  # no self-invite

    invitee_email = candidate.email if candidate else raw
    recipient_display = (
        (candidate.username or candidate.email) if candidate else raw
    )

    # Dedup: an outstanding (PENDING/ACCEPTED) invite for this recipient is
    # "already invited". Terminal-state invites (DECLINED/EXPIRED/LEFT) don't
    # block a fresh re-invite.
    already = SeaInvite.objects.filter(
        owner=request.user,
        invitee_email=invitee_email,
        status__in=[SeaInvite.PENDING, SeaInvite.ACCEPTED],
    ).exists()
    if already:
        return JsonResponse({
            "brief": {
                "title": "Already invited",
                "line_text": f"Look!—{recipient_display} is already invited to your Sea.",
                "post_url": reverse("my_sea"),
                "created_at": "",
                "kind": "NUDGE",
            },
            "recipient_display": recipient_display,
        })

    invite = SeaInvite.objects.create(
        owner=request.user,
        invitee=candidate,
        invitee_email=invitee_email,
        status=SeaInvite.PENDING,
    )
    log_sea_invite(invite)

    return JsonResponse({
        "brief": {
            "title": "Invite sent",
            "line_text": f"Look!—your invite is on its way to {recipient_display}.",
            "post_url": reverse("my_sea"),
            "created_at": "",
            "kind": "NUDGE",
        },
        "recipient_display": recipient_display,
    })


# Explicit accept/decline endpoints (the @mailman Post's OK/BYE forms +
# `_invite_actions.html`) were removed 2026-05-29: acceptance is now implicit
# — clicking the bud-page sea sub-btn lands on `my_sea_visit`, which accepts a
# still-pending invite on GET. There is no decline surface; an un-clicked
# invite simply lapses after 24h (`SeaInvite.is_expired`).


# ── Phase B — my-sea spectator (invitee) surfaces ───────────────────────────
def _accepted_visit_invite(owner, user):
    """The requester's ACCEPTED invite to spectate `owner`'s my-sea, or None.
    The single access gate for every spectator surface — a token deposit
    keeps the row ACCEPTED (presence derives from `token_deposited_at`); a
    BYE flips it to LEFT, which closes the gate."""
    from .models import SeaInvite
    return SeaInvite.objects.filter(
        owner=owner, invitee=user, status=SeaInvite.ACCEPTED,
    ).order_by("-created_at").first()


@login_required(login_url="/")
def my_sea_visit(request, owner_id):
    """Spectator view — an ACCEPTED invitee watches the owner's my-sea read-
    only (Phase B of [[my-sea-invite-voice-blueprint]]). 403 unless an
    ACCEPTED SeaInvite(owner, request.user) exists; the owner is bounced to
    their own my_sea. Renders the table hex (seat 1C = owner-drawn, seat 2C =
    this visitor once present) + the owner's draw read-only via
    `latest_draw_slots`. No AUTO DRAW / DEL / FLIP-to-deposit on the owner's
    hand — `sea_btn_active` forced False.

    Accept-on-GET (user-spec 2026-05-29): a still-pending, non-expired invite
    from `owner` to this user is accepted implicitly on arrival — the bud-page
    sea-btn cascade (+ the @mailman post-attribution anchor) both land here,
    and the click IS the acceptance. A stranger with no invite still 403s."""
    from apps.lyric.models import User
    from .models import SeaInvite, MY_SEA_MAX_VISITORS
    owner = get_object_or_404(User, id=owner_id)
    if owner == request.user:
        return redirect("my_sea")
    pending = (
        SeaInvite.objects
        .filter(owner=owner, invitee=request.user, status=SeaInvite.PENDING)
        .order_by("-created_at").first()
    )
    if pending is not None and not pending.is_expired:
        pending.status = SeaInvite.ACCEPTED
        pending.accepted_at = timezone.now()
        pending.save(update_fields=["status", "accepted_at"])
    invite = _accepted_visit_invite(owner, request.user)
    if invite is None:
        return HttpResponseForbidden()
    owner_draw = active_draw_for(owner)
    sig_card, sig_reversed = _resolve_sig(owner, owner_draw)
    owner_hand_non_empty = owner_draw is not None and bool(owner_draw.hand)
    # Owner seated in 1C (as the visitor sees it) for the FULL 24h window —
    # as long as a draw row exists (user-spec 2026-05-31), not only once a
    # card lands or a deposit/paid credit is set. Mirrors `_my_sea_seats`'
    # `owner_seated` so the spectator hex and the owner's own landing agree.
    owner_seated = owner_draw is not None
    # Multi-seat hex (2026-05-29): every present member shows on the ring —
    # owner 1C + present invitees 2C-6C by deposit order (the same seats
    # everyone sees), built by the shared `_my_sea_seats` helper that the live
    # `sea_seats` broadcast also uses. Layer the per-viewer `is_self` on here.
    seats = _my_sea_seats(owner)
    for seat in seats:
        seat["is_self"] = seat.get("invitee_id") == str(request.user.id)
    # Read-only spectator render parity (Phase 1, 2026-05-29): the visitor's
    # VIEW DRAW renders the SAME `.my-sea-cross` picker + `_sea_stage` the
    # owner sees, populated from the owner's draw. `saved_by_position` fills
    # the slots; `label_by_position` captions them per the owner's spread;
    # `sea_deck_data` is the OWNER's deck so sea.js can resolve each clicked
    # slot's full card face for the magnified stage.
    owner_slots = latest_draw_slots(owner)
    owner_spread = (owner_draw.spread if owner_draw is not None
                    else "situation-action-outcome")
    return render(request, "apps/gameboard/my_sea_visit.html", {
        "spectator": True,
        "is_owner": False,
        "read_only": True,
        "owner": owner,
        "sea_invite": invite,
        "seat1_present": owner_seated,
        "seat2_present": invite.is_present,
        "seats": seats,
        "owner_draw_id": owner_draw.id if owner_draw is not None else "",
        "voice_active": invite.voice_active,
        "voice_room_id": f"mysea-{owner.id}",
        "voice_muted_at": (request.user.voice_muted_at.isoformat()
                           if request.user.voice_muted_at else ""),
        "significator": sig_card,
        "significator_reversed": sig_reversed,
        "my_sea_slots": owner_slots,
        "owner_hand_non_empty": owner_hand_non_empty,
        # Read-only cross-stage parity payload.
        "default_spread": owner_spread,
        "saved_by_position": _saved_by_position(
            owner_draw.hand if owner_draw is not None else []),
        # Captions key off the SPREAD, not the drawn cards — the owner page's
        # syncLabels() sets them from the POSITION_LABELS JS constant the moment
        # a spread is chosen, so an owner who's placed only a significator (empty
        # hand) still shows CROWN/COVER/… On the spectator side `latest_draw_slots`
        # returns [] for an empty hand, so deriving labels from it left every
        # caption blank (2026-05-30). Pull straight from POSITION_LABELS instead.
        "label_by_position": POSITION_LABELS.get(owner_spread, {}),
        # Full per-spread caption map — lets the spectate WS re-caption the
        # cross live if the owner switches spreads (post-DEL) after page load.
        "position_labels": POSITION_LABELS,
        "sea_deck_data": (
            _my_sea_deck_data(owner, exclude_id=sig_card.id if sig_card else None)
            if sig_card is not None else {"levity": [], "gravity": []}
        ),
        # Owner-only controls forced off on the spectator surface.
        "sea_btn_active": False,
        "sea_first_draw_pending": False,
        "page_class": "page-gameboard page-my-sea page-my-sea-visit",
    })


@login_required(login_url="/")
def my_sea_visit_gate(request, owner_id):
    """Visitor gate — single-step token deposit to occupy seat 2C + open the
    voice window. Reuses my_sea_gate.html with spectator=True (INSERT TOKEN
    only; no refund / PAID-DRAW two-step)."""
    from apps.lyric.models import User
    from .models import SeaInvite
    owner = get_object_or_404(User, id=owner_id)
    invite = _accepted_visit_invite(owner, request.user)
    if invite is None:
        return HttpResponseForbidden()
    sig_card, sig_reversed = _resolve_sig(owner, active_draw_for(owner))
    # Seat cap — a not-yet-present visitor can't deposit into a full table
    # (owner + 5). The gate renders a "table full" notice instead of the rails.
    table_full = not invite.is_present and not SeaInvite.table_has_room(owner)
    return render(request, "apps/gameboard/my_sea_gate.html", {
        "spectator": True,
        "owner": owner,
        "sea_invite": invite,
        "visit_owner_id": owner.id,
        "user_has_sig": sig_card is not None,
        "significator": sig_card,
        "significator_reversed": sig_reversed,
        # Visitor gate is single-step — no reserve/commit, so the refund +
        # PAID DRAW blocks (gated on `deposit_reserved`) never render.
        "deposit_reserved": False,
        "hand_non_empty": False,
        "table_full": table_full,
        "page_class": "page-gameboard page-my-sea page-my-sea-gate page-my-sea-visit-gate",
    })


@login_required(login_url="/")
@require_POST
def my_sea_visit_insert_token(request, owner_id):
    """Single-step visitor token deposit. Selects + debits the visitor's next-
    priority token (same priority chain as the owner gate), then records
    `token_deposited_at` + a 24h `voice_until` on the SeaInvite (NOT on the
    owner's MySeaDraw), marking seat 2C present + opening the voice window."""
    from datetime import timedelta
    from django.urls import reverse
    from apps.lyric.models import User
    from .models import SeaInvite
    owner = get_object_or_404(User, id=owner_id)
    invite = _accepted_visit_invite(owner, request.user)
    if invite is None:
        return HttpResponseForbidden()
    if invite.token_deposited_at is None:
        # Seat cap (user-spec 2026-05-29): owner (1C) + up to 5 visitors. A
        # fresh deposit can only take a seat while one is free; a full table
        # bounces back w. ?full=1 so the gate can say so. A visitor who LEFT
        # frees their seat for someone else (is_present → False).
        if not SeaInvite.table_has_room(owner):
            return redirect(
                reverse("my_sea_visit", args=[owner.id]) + "?full=1")
        token = _select_my_sea_token(request.user)
        if token is not None:
            debit_my_sea_token(request.user, token)
            now = timezone.now()
            invite.token_deposited_at = now
            invite.voice_until = now + timedelta(hours=24)
            invite.save(update_fields=["token_deposited_at", "voice_until"])
            _notify_sea_seats(owner.id)   # live: this visitor takes a seat
    return redirect("my_sea_visit", owner_id=owner.id)


@login_required(login_url="/")
@require_POST
def my_sea_visit_leave(request, owner_id):
    """Spectator BYE — drop presence: status=LEFT, left_at=now, voice killed
    (frees seat 2C + ends the voice window). Matches the requester's latest
    non-DECLINED invite for this owner."""
    from apps.lyric.models import User
    from .models import SeaInvite
    owner = get_object_or_404(User, id=owner_id)
    invite = (SeaInvite.objects
              .filter(owner=owner, invitee=request.user)
              .exclude(status=SeaInvite.DECLINED)
              .order_by("-created_at").first())
    if invite is None:
        return HttpResponseForbidden()
    invite.status = SeaInvite.LEFT
    invite.left_at = timezone.now()
    invite.voice_until = None
    invite.save(update_fields=["status", "left_at", "voice_until"])
    _notify_sea_seats(owner.id)   # live: this visitor frees their seat
    return redirect("gameboard")


def _my_sea_deck_data(user, exclude_id=None):
    """Build the shuffled deck (levity + gravity halves) for the my-sea
    picker's card-draw mechanic. Card payload shape is whatever
    `apps.epic.utils.card_dict` defines (single source of truth shared
    w. the gameroom `sea_deck` endpoint).

    Differences from the room version:
    - No `room` context — exclude only the sig card (no other seated
      gamers to worry about). `exclude_id` defaults to `user.significator_id`
      but callers can pass a draw's snapshotted sig id when the saved-
      draw branch is rendering.
    - Backup-deck fallthrough: if the user's `equipped_deck` is None,
      fall back to Earthman (mirrors `personal_sig_cards`).
    """
    import random
    from apps.epic.models import DeckVariant, TarotCard
    from apps.epic.utils import card_dict, stack_reversal_probability
    deck = user.equipped_deck or DeckVariant.objects.filter(slug="earthman").first()
    if not deck:
        return {"levity": [], "gravity": []}
    if exclude_id is None:
        exclude_id = user.significator_id
    available = list(TarotCard.objects.filter(deck_variant=deck))
    if exclude_id:
        available = [c for c in available if c.id != exclude_id]
    random.shuffle(available)
    mid = len(available) // 2
    reversal_prob = stack_reversal_probability(user)
    return {
        "levity": [card_dict(c, reversal_prob) for c in available[:mid]],
        "gravity": [card_dict(c, reversal_prob) for c in available[mid:]],
    }


@login_required(login_url="/")
def tarot_fan(request, deck_id):
    from apps.epic.models import TarotCard
    deck = get_object_or_404(DeckVariant, pk=deck_id)
    if not request.user.unlocked_decks.filter(pk=deck_id).exists():
        return HttpResponse(status=403)
    _suit_order = {"BRANDS": 0, "GRAILS": 1, "BLADES": 2, "CROWNS": 3}
    cards = sorted(
        TarotCard.objects.filter(deck_variant=deck),
        key=lambda c: (0 if c.arcana == "MAJOR" else 1, _suit_order.get(c.suit or "", 9), c.number),
    )
    return render(request, "apps/gameboard/_partials/_tarot_fan.html", {
        "deck": deck,
        "cards": cards,
    })
