import lxml.html
from datetime import timedelta

from django.test import TestCase
from django.urls import reverse
from django.utils import timezone

from apps.applets.models import Applet, UserApplet
from apps.epic.models import DeckVariant, Room, TableSeat
from apps.lyric.models import Token, User


class GameboardViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="gamer@test.io")
        self.client.force_login(self.user)
        Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
        Applet.objects.get_or_create(slug="my-games", defaults={"name": "My Games", "context": "gameboard"})
        Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
        response = self.client.get("/gameboard/")
        self.parsed = lxml.html.fromstring(response.content)

    def test_gameboard_requires_login(self):
        self.client.logout()
        response = self.client.get("/gameboard/")
        self.assertRedirects(
            response, "/?next=/gameboard/", fetch_redirect_response=False
        )
    
    def test_gameboard_renders(self):
        response = self.client.get("/gameboard/")
        self.assertEqual(response.status_code, 200)

    def test_gameboard_shows_my_games_applet(self):
        [_] = self.parsed.cssselect("#id_applet_my_games")

    def test_gameboard_shows_new_game_applet(self):
        [_] = self.parsed.cssselect("#id_applet_new_game")

    def test_gameboard_shows_my_sea_applet(self):
        # Sprint 3 of the My Sea roadmap — applet shell only; sigs/sea/draw
        # flow lands in later sprints. Seeded via migration 0008.
        [_] = self.parsed.cssselect("#id_applet_my_sea")

    def test_my_sea_applet_fires_sign_gate_brief_for_user_without_sig(self):
        # Sprint 4b (refactored 2026-05-22) — user with no significator
        # gets a Look!-formatted Brief banner (`Brief.showBanner` script
        # fired in the applet template) AND the applet body falls through
        # to the empty-state "No draws yet" (no sig → no draws is the only
        # possible state). The Brief itself renders client-side via JS so
        # we assert the script content + the FYI url, not the DOM banner.
        html = self.parsed.text_content() if False else \
            lxml.html.tostring(self.parsed, encoding="unicode")
        self.assertIn("Look!", html)
        self.assertIn("pick your sign before drawing the Sea", html)
        self.assertIn("Brief.showBanner", html)
        # FYI url baked into the Brief script's `post_url`
        self.assertIn("/billboard/my-sign/", html)
        # Old inline gate markup is gone
        self.assertEqual(
            len(self.parsed.cssselect(".my-sea-sign-gate")), 0,
        )
        # Card cells suppressed (no active draw possible without sig)
        self.assertEqual(
            len(self.parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")), 0,
        )

    def test_my_sea_applet_renders_empty_state_for_user_with_sig_no_draws(self):
        # Sig set + no saved draws → the scroll container hosts a single
        # placeholder line ("No draws yet."), no card cells, no gate Brief.
        from apps.epic.models import personal_sig_cards
        sig_pile = personal_sig_cards(self.user)
        self.user.significator = sig_pile[0]
        self.user.save()
        response = self.client.get("/gameboard/")
        parsed = lxml.html.fromstring(response.content)
        [empty] = parsed.cssselect("#id_applet_my_sea .my-sea-empty")
        self.assertIn("No draws yet", empty.text_content())
        self.assertEqual(
            len(parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")), 0,
        )
        # No sign-gate Brief script fires when the user already has a sig
        html = lxml.html.tostring(parsed, encoding="unicode")
        self.assertNotIn("pick your sign before drawing the Sea", html)

    def test_my_sea_applet_header_links_to_my_sea_page(self):
        [link] = self.parsed.cssselect("#id_applet_my_sea h2 a")
        self.assertEqual(link.get("href"), reverse("my_sea"))

    def test_my_sea_applet_renders_drawn_cards_in_draw_order(self):
        """User w. a partial SAO draw — applet renders 3 slots in DRAW_
        ORDER (lay, cover, crown → labelled Beneath/Cover/Crown? no —
        SAO labels are Situation/Action/Outcome). Drawn slot 1 (`lay`)
        carries the card; the un-drawn `cover` + `crown` slots are
        empty placeholders w. their per-spread labels."""
        from apps.epic.models import personal_sig_cards, TarotCard
        from apps.gameboard.models import MySeaDraw
        sig_pile = personal_sig_cards(self.user)
        self.user.significator = sig_pile[0]
        self.user.save()
        card = TarotCard.objects.first()
        MySeaDraw.objects.create(
            user=self.user,
            spread="situation-action-outcome",
            hand=[
                {"position": "lay", "card_id": card.id,
                 "reversed": False, "polarity": "gravity"},
            ],
            significator_id=self.user.significator_id,
        )
        response = self.client.get("/gameboard/")
        parsed = lxml.html.fromstring(response.content)
        # All 3 SAO positions render in DRAW_ORDER (lay, cover, crown).
        wraps = parsed.cssselect("#id_applet_my_sea .my-sea-slot-wrap")
        self.assertEqual(len(wraps), 3,
            "SAO has 3 positions — applet should render 3 slot wraps")
        # Position 1 (`lay`) is filled w. the drawn card.
        filled = parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")
        self.assertEqual(len(filled), 1)
        self.assertEqual(
            filled[0].get("data-position"), "lay",
            "First drawn card should land in the `lay` slug slot",
        )
        self.assertEqual(
            filled[0].get("data-card-id"), str(card.id),
        )
        # Positions 2 + 3 (cover, crown) are empty placeholders.
        empties = parsed.cssselect("#id_applet_my_sea .my-sea-slot--empty")
        self.assertEqual(len(empties), 2)
        empty_positions = {e.get("data-position") for e in empties}
        self.assertEqual(empty_positions, {"cover", "crown"})

    def test_my_sea_applet_slot_renders_image_when_deck_has_card_images(self):
        """Sprint A.7 — when the drawn card belongs to an image-equipped deck
        (Minchiate today), the .my-sea-slot--filled carries `.my-sea-slot--image`
        + renders an <img.sig-stage-card-img> child instead of the text scaffold.
        Shares the contour-stroke + depth shadow SCSS w. my_sign + my-sea
        central sig + my-sign-applet via the comma-list selector in
        `_card-deck.scss`."""
        from apps.epic.models import personal_sig_cards, TarotCard, DeckVariant
        from apps.gameboard.models import MySeaDraw
        sig_pile = personal_sig_cards(self.user)
        self.user.significator = sig_pile[0]
        self.user.save()
        minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
        # Use a Minchiate card for the slot draw (doesn't need to be the sig
        # — the sig is from Earthman; the drawn card is a separate concept).
        card = TarotCard.objects.filter(
            deck_variant=minchiate, slug="il-matto",
        ).first()
        MySeaDraw.objects.create(
            user=self.user,
            spread="situation-action-outcome",
            hand=[{"position": "lay", "card_id": card.id,
                   "reversed": False, "polarity": "gravity"}],
            significator_id=self.user.significator_id,
        )
        response = self.client.get("/gameboard/")
        parsed = lxml.html.fromstring(response.content)
        [filled] = parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")
        self.assertIn("my-sea-slot--image", filled.get("class", ""))
        self.assertEqual(filled.get("data-arcana-key"), "MAJOR")
        [img] = filled.cssselect("img.sig-stage-card-img")
        self.assertIn(
            "minchiate-fiorentine-1860-1890-trumps-00-il-matto.png",
            img.get("src", ""),
        )
        # Text scaffold absent in image mode.
        self.assertEqual(
            len(filled.cssselect(".fan-card-corner")), 0,
            "Image-mode slot must not render the text scaffold",
        )

    def test_my_sea_applet_renders_slots_even_when_user_significator_cleared(self):
        """Regression (user bug 2026-05-25 PM): deleting User.significator
        (via my-sign DEL) must NOT blank the My Sea applet on the gameboard
        when the user has saved MySeaDraw slots. The MySeaDraw row carries
        its own significator_id snapshot at first-draw-time so the saved
        draw is durable even after the user clears their sig. Applet display
        is now decoupled from `request.user.significator_id` — the sig-gate
        Brief banner only fires for users with NO draws AND no sig."""
        from apps.epic.models import personal_sig_cards, TarotCard
        from apps.gameboard.models import MySeaDraw
        sig_pile = personal_sig_cards(self.user)
        snapshot_sig = sig_pile[0]
        card = TarotCard.objects.first()
        MySeaDraw.objects.create(
            user=self.user,
            spread="situation-action-outcome",
            hand=[
                {"position": "lay", "card_id": card.id,
                 "reversed": False, "polarity": "gravity"},
            ],
            significator_id=snapshot_sig.id,
        )
        # User clears their sig AFTER the draw was saved (the bug repro).
        self.user.significator = None
        self.user.significator_reversed = False
        self.user.save(update_fields=["significator", "significator_reversed"])
        self.assertIsNone(self.user.significator_id)

        response = self.client.get("/gameboard/")
        parsed = lxml.html.fromstring(response.content)
        # Slots render despite no current sig — the MySeaDraw row owns the
        # display, not the user's live sig state.
        filled = parsed.cssselect("#id_applet_my_sea .my-sea-slot--filled")
        self.assertEqual(
            len(filled), 1,
            "Filled slot must persist even when User.significator_id is None",
        )
        self.assertEqual(filled[0].get("data-card-id"), str(card.id))
        # And the "No draws yet" empty state must NOT render.
        empties = parsed.cssselect("#id_applet_my_sea .my-sea-empty")
        self.assertEqual(
            len(empties), 0,
            "Applet must not render 'No draws yet' when slots exist",
        )

    def test_my_sea_applet_labels_match_locked_spread(self):
        """SAO label per spec: lay='Situation', cover='Action',
        crown='Outcome'. Empty slots still carry their label so the
        user can see which position is yet to draw."""
        from apps.epic.models import personal_sig_cards, TarotCard
        from apps.gameboard.models import MySeaDraw
        sig_pile = personal_sig_cards(self.user)
        self.user.significator = sig_pile[0]
        self.user.save()
        card = TarotCard.objects.first()
        MySeaDraw.objects.create(
            user=self.user,
            spread="situation-action-outcome",
            hand=[
                {"position": "lay", "card_id": card.id,
                 "reversed": False, "polarity": "gravity"},
            ],
            significator_id=self.user.significator_id,
        )
        response = self.client.get("/gameboard/")
        parsed = lxml.html.fromstring(response.content)
        labels = parsed.cssselect(
            "#id_applet_my_sea .my-sea-slot-wrap .my-sea-slot-label"
        )
        # Labels in DOM order (== DRAW_ORDER): Situation, Action, Outcome
        self.assertEqual(
            [l.text_content().strip() for l in labels],
            ["Situation", "Action", "Outcome"],
        )

    def test_my_sea_applet_waite_smith_labels_post_fix(self):
        """Regression pin for the 2026-05-22 POSITION_LABELS swap fix:
        WS `leave` slot (LEFT) is "Behind", `lay` slot (BOTTOM) is
        "Beneath" — was inverted prior to the fix. Voronoi mapping
        depends on this being right."""
        from apps.epic.models import personal_sig_cards, TarotCard
        from apps.gameboard.models import MySeaDraw
        sig_pile = personal_sig_cards(self.user)
        self.user.significator = sig_pile[0]
        self.user.save()
        card = TarotCard.objects.first()
        # Single-card WS draw — populates `cover` (DRAW_ORDER pos 1).
        MySeaDraw.objects.create(
            user=self.user,
            spread="waite-smith",
            hand=[
                {"position": "cover", "card_id": card.id,
                 "reversed": False, "polarity": "gravity"},
            ],
            significator_id=self.user.significator_id,
        )
        response = self.client.get("/gameboard/")
        parsed = lxml.html.fromstring(response.content)
        wraps = parsed.cssselect("#id_applet_my_sea .my-sea-slot-wrap")
        # WS DRAW_ORDER = [cover, cross, crown, lay, loom, leave]
        # Labels post-fix:                Cover Cross Crown Beneath Before Behind
        labels = [
            w.cssselect(".my-sea-slot-label")[0].text_content().strip()
            for w in wraps
        ]
        self.assertEqual(
            labels,
            ["Cover", "Cross", "Crown", "Beneath", "Before", "Behind"],
        )
    
    def test_my_sea_applet_renders_polarity_qualifier_per_slot(self):
        """Each filled slot carries a `.fan-card-qualifier` whose text is
        the polarity qualifier for the slot's polarity (upright) or the
        reversal_qualifier (reversed). User-reported 2026-05-23: applet
        was rendering only the title, no qualifier."""
        from apps.epic.models import personal_sig_cards, TarotCard
        from apps.gameboard.models import MySeaDraw
        sig_pile = personal_sig_cards(self.user)
        self.user.significator = sig_pile[0]
        self.user.save()
        # Pick a middle court card (Queen of Crowns) — has levity_qualifier
        # "Elevated", gravity_qualifier "Graven", reversal_qualifier "Vacant".
        queen_of_crowns = TarotCard.objects.filter(
            arcana="MIDDLE", suit="CROWNS", number=13,
        ).first()
        MySeaDraw.objects.create(
            user=self.user,
            spread="situation-action-outcome",
            hand=[
                {"position": "lay", "card_id": queen_of_crowns.id,
                 "reversed": False, "polarity": "levity"},
            ],
            significator_id=self.user.significator_id,
        )
        response = self.client.get("/gameboard/")
        parsed = lxml.html.fromstring(response.content)
        quals = parsed.cssselect(
            "#id_applet_my_sea .my-sea-slot--filled .fan-card-qualifier"
        )
        self.assertEqual(len(quals), 1)
        self.assertEqual(quals[0].text_content().strip(), "Elevated")

    def test_my_sea_applet_major_renders_title_comma_qualifier_below(self):
        """Major Arcana w. a qualifier (trump 9 — 'Erasing Personal
        History' + 'Sublimating') renders as 'Title,' / 'Qualifier' per
        the page's stage card convention (`stage-card.js:141-143`). The
        qualifier `<p>` lands AFTER the name `<p>` in DOM order."""
        from apps.epic.models import personal_sig_cards, TarotCard
        from apps.gameboard.models import MySeaDraw
        sig_pile = personal_sig_cards(self.user)
        self.user.significator = sig_pile[0]
        self.user.save()
        trump_9 = TarotCard.objects.filter(
            arcana="MAJOR", number=9, deck_variant__slug="earthman",
        ).first()
        self.assertIsNotNone(trump_9,
            "seed migration 0007 should produce Earthman trump 9 "
            "('Erasing Personal History') w. levity_qualifier='Sublimating'")
        MySeaDraw.objects.create(
            user=self.user,
            spread="situation-action-outcome",
            hand=[
                {"position": "lay", "card_id": trump_9.id,
                 "reversed": False, "polarity": "levity"},
            ],
            significator_id=self.user.significator_id,
        )
        response = self.client.get("/gameboard/")
        parsed = lxml.html.fromstring(response.content)
        face = parsed.cssselect(
            "#id_applet_my_sea .my-sea-slot--filled .fan-card-face"
        )[0]
        # DOM order: name → qualifier (NOT qualifier → name).
        children = [
            el for el in face
            if el.tag == "p"
            and any(
                cls in (el.get("class") or "")
                for cls in ("fan-card-name", "fan-card-qualifier")
            )
        ]
        self.assertEqual(len(children), 2)
        self.assertIn("fan-card-name", children[0].get("class"))
        self.assertIn("fan-card-qualifier", children[1].get("class"))
        # Title carries a trailing comma; qualifier is "Sublimating" (levity).
        self.assertTrue(
            children[0].text_content().strip().endswith(","),
            f"expected trailing comma on Major title, got "
            f"{children[0].text_content()!r}",
        )
        self.assertEqual(children[1].text_content().strip(), "Sublimating")

    def test_my_sea_applet_renders_reversal_qualifier_for_reversed_slot(self):
        from apps.epic.models import personal_sig_cards, TarotCard
        from apps.gameboard.models import MySeaDraw
        sig_pile = personal_sig_cards(self.user)
        self.user.significator = sig_pile[0]
        self.user.save()
        queen_of_crowns = TarotCard.objects.filter(
            arcana="MIDDLE", suit="CROWNS", number=13,
        ).first()
        MySeaDraw.objects.create(
            user=self.user,
            spread="situation-action-outcome",
            hand=[
                {"position": "lay", "card_id": queen_of_crowns.id,
                 "reversed": True, "polarity": "gravity"},
            ],
            significator_id=self.user.significator_id,
        )
        response = self.client.get("/gameboard/")
        parsed = lxml.html.fromstring(response.content)
        quals = parsed.cssselect(
            "#id_applet_my_sea .my-sea-slot--filled .fan-card-qualifier"
        )
        self.assertEqual(quals[0].text_content().strip(), "Vacant")

    def test_gameboard_shows_game_kit(self):
        [_] = self.parsed.cssselect("#id_game_kit")

    def test_gameboard_shows_game_gear(self):
        [_] = self.parsed.cssselect(".gear-btn")

    def test_my_games_has_no_game_items_for_new_user(self):
        game_items = self.parsed.cssselect("#id_applet_my_games .game-item")
        self.assertEqual(len(game_items), 0)

    def test_my_games_row_shows_latest_event_prose_and_ts(self):
        from apps.drama.models import GameEvent, record
        room = Room.objects.create(name="StampedRoom", owner=self.user)
        record(
            room, GameEvent.SLOT_FILLED, actor=self.user,
            slot_number=1, token_type="coin",
            token_display="Coin-on-a-String", renewal_days=7,
        )
        response = self.client.get("/gameboard/")
        body = response.content.decode()
        self.assertIn("StampedRoom", body)
        # Row carries a ts cell from the recorded event
        self.assertRegex(
            body,
            r'#id_applet_my_games|class="[^"]*row-ts'.replace("#", ""),
        )
        # A .row-body cell carries some event prose
        self.assertRegex(body, r'<time[^>]+class="[^"]*row-ts')

    def test_my_games_row_body_includes_actor_display_name(self):
        """Mirror of My Scrolls — the My Games row body must include the
        event actor's display_name so `<actor> deposits a Coin-on-a-String`
        reads as a complete sentence. Scoped to the `.row-body` span (vs.
        a loose substring) so the assertion can't pass on actor renders
        outside the row (no Most Recent Scroll on gameboard, but the
        navbar etc. could shadow a substring match)."""
        from apps.drama.models import GameEvent, record
        actor = User.objects.create(email="stuart@test.io", username="stuart")
        room = Room.objects.create(name="GameRoom", owner=self.user)
        record(
            room, GameEvent.SLOT_FILLED, actor=actor,
            slot_number=2, token_type="coin",
            token_display="Coin-on-a-String", renewal_days=7,
        )
        response = self.client.get("/gameboard/")
        body = response.content.decode()
        self.assertRegex(
            body,
            r'<span class="row-body">[^<]*stuart[^<]*deposits a Coin-on-a-String',
        )

    def test_game_kit_has_coin_on_a_string(self):
        [_] = self.parsed.cssselect("#id_game_kit #id_kit_coin_on_a_string")

    def test_game_kit_has_free_token(self):
        [_] = self.parsed.cssselect("#id_game_kit #id_kit_free_token")

    def test_game_kit_shows_deck_variant_cards(self):
        decks = self.parsed.cssselect("#id_game_kit .deck-variant")
        self.assertGreater(len(decks), 0)
        # Earthman deck (seeded by migration) should have its own card
        [_] = self.parsed.cssselect("#id_game_kit #id_kit_earthman_deck")

    def test_game_kit_has_dice_set_placeholder(self):
        [_] = self.parsed.cssselect("#id_game_kit #id_kit_dice_set")

    def test_deck_token_renders_card_stack_svg_not_fa_id_badge(self):
        """Sprint A.4 — `.token.deck-variant` icon is the inline SVG card-stack
        (`.deck-stack-icon`), not the old `<i class="fa-regular fa-id-badge">`
        placeholder. Stack contains 3 rect children w. `.deck-stack-icon__card`
        classes the CSS keys off for the rest-stack + hover fan-out."""
        deck = self.parsed.cssselect("#id_game_kit #id_kit_earthman_deck")[0]
        # New SVG icon present
        [svg] = deck.cssselect("svg.deck-stack-icon")
        cards = svg.cssselect(".deck-stack-icon__card")
        self.assertEqual(
            len(cards), 3,
            "Card-stack icon must render 3 rect cards (top + 2 fan-out)",
        )
        # Old FA icon removed
        self.assertEqual(
            len(deck.cssselect("i.fa-id-badge")), 0,
            "fa-regular fa-id-badge must be gone from deck-variant token",
        )

    def test_polarized_deck_tooltip_has_x2_decoration(self):
        """Earthman is the only is_polarized=True deck today (per A.0 migration).
        Its tooltip's card-count line should carry a `(×2)` suffix in --terUser
        per [[project-card-deck-icon]]'s `is_polarized` tooltip-decoration rule."""
        deck = self.parsed.cssselect("#id_game_kit #id_kit_earthman_deck")[0]
        tt = deck.cssselect(".tt")[0]
        [x2] = tt.cssselect(".tt-x2")
        self.assertIn("×2", x2.text_content())  # ×2

    def test_nonpolarized_deck_tooltip_lacks_x2_decoration(self):
        """Non-polarized decks (Tarot RWS, future Minchiate) don't get the
        `(×2)` decoration — the suffix signals 'double-polarized = 6 segments
        = fills 2× as many seats' which only applies to polarized decks."""
        from apps.epic.models import DeckVariant
        # Use the migration-renamed RWS deck (formerly fiorentine-minchiate).
        rws = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
        self.user.unlocked_decks.add(rws)
        response = self.client.get("/gameboard/")
        import lxml.html
        parsed = lxml.html.fromstring(response.content)
        deck = parsed.cssselect("#id_game_kit #id_kit_tarot_deck")[0]
        tt = deck.cssselect(".tt")[0]
        self.assertEqual(
            len(tt.cssselect(".tt-x2")), 0,
            "Non-polarized RWS deck must not show (×2) in tooltip",
        )

    def test_image_equipped_deck_icon_uses_back_image_pattern(self):
        """Sprint A.4 follow-up — image-equipped decks (Minchiate today,
        future Earthman) render the SVG card-stack icon w. an inline <pattern>
        referencing the deck's <deck-slug>-back.png + inline style `fill:
        url(#deck-back-<short_key>)` on each rect so the actual card-back
        renders instead of the placeholder `--priUser` solid fill."""
        from apps.epic.models import DeckVariant
        minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
        self.user.unlocked_decks.add(minchiate)
        response = self.client.get("/gameboard/")
        import lxml.html
        parsed = lxml.html.fromstring(response.content)
        deck = parsed.cssselect("#id_game_kit #id_kit_minchiate_deck")[0]
        html = lxml.html.tostring(deck, encoding="unicode")
        self.assertIn("deck-back-minchiate", html,
            "Image-equipped deck's SVG must define a back-image <pattern>")
        self.assertIn("minchiate-fiorentine-1860-1890-back.png", html,
            "Pattern must reference the deck's back-image asset URL")
        self.assertGreaterEqual(
            html.count("fill: url(#deck-back-minchiate)"), 3,
            "Each of the 3 card rects must override its fill via inline style",
        )

    def test_nonimage_deck_icon_has_no_back_pattern(self):
        """Earthman (has_card_images=False until its art lands) renders the
        placeholder fill — NO <pattern> defs, no inline-style fill override,
        rects fall through to the SCSS default `--priUser` solid fill."""
        deck = self.parsed.cssselect("#id_game_kit #id_kit_earthman_deck")[0]
        import lxml.html
        html = lxml.html.tostring(deck, encoding="unicode")
        self.assertNotIn("deck-back-earthman", html,
            "Non-image deck must not define a back-image <pattern>")
        self.assertNotIn("fill: url(#deck-back-", html,
            "Non-image deck rects must use the SCSS default fill")


class GameboardDeckInUseTest(TestCase):
    """Sprint 2: game kit applet renders in-use state for a deck assigned to an active seat."""

    def setUp(self):
        self.user = User.objects.create(email="gamer@test.io")
        self.client.force_login(self.user)
        Applet.objects.get_or_create(slug="game-kit", defaults={"name": "Game Kit", "context": "gameboard"})
        self.earthman = DeckVariant.objects.get(slug="earthman")
        self.room = Room.objects.create(name="Wildfire", owner=self.user)
        self.seat = TableSeat.objects.create(
            room=self.room, gamer=self.user, slot_number=1,
            deck_variant=self.earthman,
        )
        response = self.client.get("/gameboard/")
        self.parsed = lxml.html.fromstring(response.content)

    def test_in_use_deck_don_is_disabled(self):
        [don] = self.parsed.cssselect("#id_kit_earthman_deck .btn-equip")
        self.assertIn("btn-disabled", don.get("class", ""))

    def test_in_use_deck_doff_is_absent(self):
        active_doff = self.parsed.cssselect(
            "#id_kit_earthman_deck .btn-unequip:not(.btn-disabled)"
        )
        self.assertEqual(len(active_doff), 0)

    def test_in_use_deck_carries_room_name_for_mini_portal(self):
        [el] = self.parsed.cssselect("#id_kit_earthman_deck")
        self.assertEqual("Wildfire", el.get("data-in-use-room-name"))

    def test_non_in_use_deck_has_normal_don(self):
        fiorentine = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
        self.user.unlocked_decks.add(fiorentine)
        response = self.client.get("/gameboard/")
        parsed = lxml.html.fromstring(response.content)
        [don] = parsed.cssselect("#id_kit_tarot_deck .btn-equip")
        self.assertNotIn("btn-disabled", don.get("class", ""))


class ToggleGameAppletsViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="gamer@test.io")
        self.client.force_login(self.user)
        self.new_game, _ = Applet.objects.get_or_create(
            slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
        )
        self.my_games, _ = Applet.objects.get_or_create(
            slug="my-games", defaults={"name": "My Games", "context": "gameboard"}
        )
        self.url = reverse("toggle_game_applets")
    
    def test_unauthenticated_user_is_redirected(self):
        self.client.logout()
        response = self.client.post(self.url)
        self.assertRedirects(
            response, f"/?next={self.url}", fetch_redirect_response=False
        )

    def test_unchecked_applet_gets_user_applet_with_visible_false(self):
        self.client.post(self.url, {"applets": ["new-game"]})
        ua = UserApplet.objects.get(user=self.user, applet=self.my_games)
        self.assertFalse(ua.visible)

    def test_redirects_on_normal_post(self):
        response = self.client.post(self.url, {"applets": ["new-game", "my-games"]})
        self.assertRedirects(
            response, reverse("gameboard"), fetch_redirect_response=False
        )

    def test_returns_200_on_htmx_post(self):
        response = self.client.post(
            self.url,
            {"applets": ["new-game", "my-games"]},
            HTTP_HX_REQUEST="true",
        )
        self.assertEqual(response.status_code, 200)

    def test_does_not_affect_dash_applets(self):
        dash_applet, _ = Applet.objects.get_or_create(
            slug="username", defaults={"name": "Username", "context": "dashboard"}
        )
        self.client.post(self.url, {"applets": ["new-game", "my-games"]})
        self.assertFalse(UserApplet.objects.filter(user=self.user, applet=dash_applet).exists())


class EquipDeckViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="gamer@test.io")
        self.client.force_login(self.user)
        self.deck = DeckVariant.objects.first()

    def test_get_returns_405(self):
        response = self.client.get(reverse("equip_deck", kwargs={"deck_id": self.deck.pk}))
        self.assertEqual(response.status_code, 405)

    def test_post_equips_deck(self):
        response = self.client.post(reverse("equip_deck", kwargs={"deck_id": self.deck.pk}))
        self.assertEqual(response.status_code, 204)
        self.user.refresh_from_db()
        self.assertEqual(self.user.equipped_deck, self.deck)


class UnequipDeckViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="gamer@test.io")
        self.deck = DeckVariant.objects.first()
        self.user.equipped_deck = self.deck
        self.user.save(update_fields=["equipped_deck"])
        self.client.force_login(self.user)

    def test_get_returns_405(self):
        response = self.client.get(reverse("unequip_deck", kwargs={"deck_id": self.deck.pk}))
        self.assertEqual(response.status_code, 405)

    def test_post_clears_equipped_deck_when_matches(self):
        response = self.client.post(reverse("unequip_deck", kwargs={"deck_id": self.deck.pk}))
        self.assertEqual(response.status_code, 204)
        self.user.refresh_from_db()
        self.assertIsNone(self.user.equipped_deck)

    def test_post_ignores_non_matching_deck(self):
        other_deck = DeckVariant.objects.exclude(pk=self.deck.pk).first()
        if other_deck is None:
            self.skipTest("Only one deck variant in DB")
        self.client.post(reverse("unequip_deck", kwargs={"deck_id": other_deck.pk}))
        self.user.refresh_from_db()
        self.assertEqual(self.user.equipped_deck, self.deck)


class UnequipTrinketViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="gamer@test.io")
        self.client.force_login(self.user)
        self.token = Token.objects.filter(user=self.user, token_type=Token.COIN).first()

    def test_get_returns_405(self):
        from apps.lyric.models import Token
        token = Token.objects.filter(user=self.user).first()
        if token is None:
            self.skipTest("No token for user")
        response = self.client.get(reverse("unequip_trinket", kwargs={"token_id": token.pk}))
        self.assertEqual(response.status_code, 405)

    def test_post_clears_equipped_trinket_when_matching(self):
        self.user.equipped_trinket = self.token
        self.user.save(update_fields=["equipped_trinket"])
        response = self.client.post(
            reverse("unequip_trinket", kwargs={"token_id": self.token.pk})
        )
        self.assertEqual(response.status_code, 204)
        self.user.refresh_from_db()
        self.assertIsNone(self.user.equipped_trinket)

    def test_post_ignores_non_matching_trinket(self):
        """POSTing a token that's not the currently-equipped one is a 204 no-op
        — equipped_trinket is unchanged. Covers the implicit `else` of the
        `if request.user.equipped_trinket_id == token.pk` branch."""
        other_token = Token.objects.create(user=self.user, token_type=Token.TITHE)
        self.user.equipped_trinket = self.token  # COIN is equipped
        self.user.save(update_fields=["equipped_trinket"])
        response = self.client.post(
            reverse("unequip_trinket", kwargs={"token_id": other_token.pk})
        )
        self.assertEqual(response.status_code, 204)
        self.user.refresh_from_db()
        self.assertEqual(self.user.equipped_trinket, self.token)


class GameKitViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="gamer@test.io")
        self.client.force_login(self.user)
        Applet.objects.get_or_create(slug="gk-trinkets", defaults={"name": "Trinkets", "context": "game-kit"})
        Applet.objects.get_or_create(slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"})
        Applet.objects.get_or_create(slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"})
        Applet.objects.get_or_create(slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"})
        Applet.objects.get_or_create(slug="pronouns", defaults={"name": "Pronouns", "context": "game-kit"})
        response = self.client.get("/gameboard/game-kit/")
        self.parsed = lxml.html.fromstring(response.content)

    def test_game_kit_requires_login(self):
        self.client.logout()
        response = self.client.get("/gameboard/game-kit/")
        self.assertRedirects(response, "/?next=/gameboard/game-kit/", fetch_redirect_response=False)

    def test_game_kit_shows_gear_btn(self):
        [_] = self.parsed.cssselect(".gear-btn")

    def test_game_kit_shows_applet_menu(self):
        [_] = self.parsed.cssselect("#id_game_kit_menu")

    def test_game_kit_applet_menu_has_trinkets_checkbox(self):
        [inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-trinkets']")
        self.assertEqual(inp.get("type"), "checkbox")

    def test_game_kit_applet_menu_has_tokens_checkbox(self):
        [inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-tokens']")
        self.assertEqual(inp.get("type"), "checkbox")

    def test_game_kit_applet_menu_has_decks_checkbox(self):
        [inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-decks']")
        self.assertEqual(inp.get("type"), "checkbox")

    def test_game_kit_applet_menu_has_dice_checkbox(self):
        [inp] = self.parsed.cssselect("#id_game_kit_menu input[value='gk-dice']")
        self.assertEqual(inp.get("type"), "checkbox")

    def test_game_kit_sections_container_present(self):
        [_] = self.parsed.cssselect("#id_gk_sections_container")

    def test_all_sections_visible_by_default(self):
        sections = self.parsed.cssselect("#id_gk_sections_container section")
        # Trinkets, Tokens, Card Decks, Dice Sets, Pronouns
        self.assertEqual(len(sections), 5)

    def test_pronouns_section_renders_five_cards(self):
        [section] = self.parsed.cssselect("#id_gk_pronouns")
        cards = section.cssselect(".gk-pronoun-card")
        self.assertEqual(len(cards), 5)
        slugs = [c.get("data-pronoun") for c in cards]
        self.assertEqual(
            slugs,
            ["pluralism", "bawlmorese", "misogyny", "misandry", "misanthropy"],
        )

    def test_pronouns_section_marks_current_choice_active(self):
        # Default user pronouns = "pluralism" — that card should carry .active.
        [active] = self.parsed.cssselect("#id_gk_pronouns .gk-pronoun-card.active")
        self.assertEqual(active.get("data-pronoun"), "pluralism")

    def test_game_kit_applet_menu_has_pronouns_checkbox(self):
        [inp] = self.parsed.cssselect("#id_game_kit_menu input[value='pronouns']")
        self.assertEqual(inp.get("type"), "checkbox")

    def test_fan_stage_block_renders_rank_suit_chip_per_face(self):
        """Sprint A.7.5 — `#id_fan_stage_block` (the carousel modal's stat
        block) gains the same `.stat-face-header` w. rank+suit chip + the
        `.stat-face-title` + `.stat-face-arcana` slots that the my_sign /
        sea_stage stat blocks have. Previously only the keyword list was
        present (text-mode decks carried text on the card face); for image-
        mode the stat block is the sole home for textual metadata."""
        for face_cls in ("stat-face--upright", "stat-face--reversed"):
            face = self.parsed.cssselect(f"#id_fan_stage_block .{face_cls}")
            self.assertEqual(len(face), 1, f"expected one {face_cls}")
            [header] = face[0].cssselect(".stat-face-header")
            # Polish-4 — rank is a direct child of the header (own row);
            # icon + label live in `.stat-chip-tag` (row-2 inline). No
            # `.stat-face-chip` wrapper.
            [_rank] = header.cssselect(".stat-chip-rank")
            [_tag] = header.cssselect(".stat-chip-tag")
            [_icon] = _tag.cssselect("i.stat-chip-icon")
            [_label] = _tag.cssselect(".stat-face-label")
            [_title] = face[0].cssselect(".stat-face-title")
            [_arcana] = face[0].cssselect(".stat-face-arcana")


class ToggleGameKitSectionsViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="gamer@test.io")
        self.client.force_login(self.user)
        self.trinkets, _ = Applet.objects.get_or_create(
            slug="gk-trinkets", defaults={"name": "Trinkets", "context": "game-kit"}
        )
        self.tokens, _ = Applet.objects.get_or_create(
            slug="gk-tokens", defaults={"name": "Tokens", "context": "game-kit"}
        )
        self.decks, _ = Applet.objects.get_or_create(
            slug="gk-decks", defaults={"name": "Card Decks", "context": "game-kit"}
        )
        self.dice, _ = Applet.objects.get_or_create(
            slug="gk-dice", defaults={"name": "Dice Sets", "context": "game-kit"}
        )
        self.url = reverse("toggle_game_kit_sections")

    def test_unauthenticated_user_is_redirected(self):
        self.client.logout()
        response = self.client.post(self.url)
        self.assertRedirects(response, f"/?next={self.url}", fetch_redirect_response=False)

    def test_unchecked_section_gets_user_applet_with_visible_false(self):
        self.client.post(self.url, {"applets": ["gk-trinkets"]})
        ua = UserApplet.objects.get(user=self.user, applet=self.tokens)
        self.assertFalse(ua.visible)

    def test_redirects_on_normal_post(self):
        response = self.client.post(self.url, {"applets": ["gk-trinkets", "gk-tokens"]})
        self.assertRedirects(response, reverse("game_kit"), fetch_redirect_response=False)

    def test_returns_200_on_htmx_post(self):
        response = self.client.post(
            self.url,
            {"applets": ["gk-trinkets", "gk-tokens"]},
            HTTP_HX_REQUEST="true",
        )
        self.assertEqual(response.status_code, 200)

    def test_does_not_affect_gameboard_applets(self):
        gb_applet, _ = Applet.objects.get_or_create(
            slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
        )
        self.client.post(self.url, {"applets": ["gk-trinkets"]})
        self.assertFalse(UserApplet.objects.filter(user=self.user, applet=gb_applet).exists())

    def test_hidden_section_absent_from_htmx_response(self):
        response = self.client.post(
            self.url,
            {"applets": ["gk-trinkets"]},
            HTTP_HX_REQUEST="true",
        )
        parsed = lxml.html.fromstring(response.content)
        sections = parsed.cssselect("section")
        self.assertEqual(len(sections), 1)


class EquipTrinketViewTest(TestCase):
    def setUp(self):
        self.user = User.objects.create(email="gamer@test.io")
        self.client.force_login(self.user)
        self.token = Token.objects.filter(user=self.user, token_type=Token.COIN).first()

    def test_get_returns_trinket_button_partial(self):
        response = self.client.get(
            reverse("equip_trinket", kwargs={"token_id": self.token.pk})
        )
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "apps/gameboard/_partials/_equip_trinket_btn.html")

    def test_post_equips_trinket_and_returns_204(self):
        response = self.client.post(
            reverse("equip_trinket", kwargs={"token_id": self.token.pk})
        )
        self.assertEqual(response.status_code, 204)
        self.user.refresh_from_db()
        self.assertEqual(self.user.equipped_trinket, self.token)

    def test_post_requires_token_owner(self):
        outsider = User.objects.create(email="outsider@test.io")
        self.client.force_login(outsider)
        response = self.client.post(
            reverse("equip_trinket", kwargs={"token_id": self.token.pk})
        )
        # get_object_or_404 — the token belongs to self.user, not outsider
        self.assertEqual(response.status_code, 404)


class TarotFanViewTest(TestCase):
    def setUp(self):
        from apps.epic.models import DeckVariant
        self.earthman = DeckVariant.objects.get(slug="earthman")
        self.fiorentine = DeckVariant.objects.get(slug="tarot-rider-waite-smith")
        self.user = User.objects.create(email="fan@test.io")
        self.client.force_login(self.user)

    def test_returns_fan_partial_for_unlocked_deck(self):
        response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk}))
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "apps/gameboard/_partials/_tarot_fan.html")

    def test_returns_403_for_locked_deck(self):
        response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.fiorentine.pk}))
        self.assertEqual(response.status_code, 403)

    def test_text_mode_deck_keeps_text_scaffold(self):
        """Sprint A.7.5 — Earthman (has_card_images=False) carousel cards keep
        the existing `.fan-card-corner` + `.fan-card-face` text scaffold and
        lack the `.fan-card--image` marker. Pins the text-mode branch as the
        before-state so the image-mode branch below isn't a regression risk."""
        from apps.epic.models import TarotCard
        # Cap at 5 cards to keep the test focused — the deck has 106 cards.
        TarotCard.objects.filter(deck_variant=self.earthman).exclude(
            pk__in=TarotCard.objects.filter(deck_variant=self.earthman)[:5]
        ).delete()
        response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk}))
        parsed = lxml.html.fromstring(response.content)
        cards = parsed.cssselect(".fan-card")
        self.assertGreater(len(cards), 0)
        for card in cards:
            self.assertNotIn("fan-card--image", card.get("class", ""))
            self.assertEqual(len(card.cssselect("img.sig-stage-card-img")), 0)
            self.assertGreater(len(card.cssselect(".fan-card-corner")), 0)
            self.assertGreater(len(card.cssselect(".fan-card-face")), 0)

    def test_image_mode_deck_renders_img_per_card_and_drops_text_scaffold(self):
        """Sprint A.7.5 — Minchiate (has_card_images=True + non-polarized) cards
        carry `.fan-card--image` + an `<img.sig-stage-card-img>` per card +
        a `<img.sig-stage-card-back-img>` per card (since non-polarized; FLIP
        flips to back). Text scaffold (corners + face) absent server-side."""
        from apps.epic.models import DeckVariant
        minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
        self.user.unlocked_decks.add(minchiate)
        response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": minchiate.pk}))
        self.assertEqual(response.status_code, 200)
        parsed = lxml.html.fromstring(response.content)
        cards = parsed.cssselect(".fan-card")
        # Spot-check the first card; deck has 97 cards.
        self.assertGreater(len(cards), 0)
        first = cards[0]
        self.assertIn("fan-card--image", first.get("class", ""))
        self.assertEqual(first.get("data-arcana-key"), "MAJOR")  # Minchiate trump #0 = Il Matto
        [img] = first.cssselect("img.sig-stage-card-img")
        self.assertIn("minchiate-fiorentine-1860-1890", img.get("src", ""))
        [back_img] = first.cssselect("img.sig-stage-card-back-img")
        self.assertIn(
            "minchiate-fiorentine-1860-1890-back.png", back_img.get("src", "")
        )
        # Text scaffold absent across the WHOLE response — none of the cards
        # in image-mode should render corners/face.
        self.assertEqual(
            len(parsed.cssselect(".fan-card-corner")), 0,
            "image-mode cards must not render the text scaffold",
        )
        self.assertEqual(len(parsed.cssselect(".fan-card-face")), 0)

    def test_image_mode_polarized_deck_omits_back_img(self):
        """Polarized image-equipped deck (none today, but the gate is
        defensive): FLIP retains its polarity-cycle meaning and no back-img
        renders. Earthman flipped to has_card_images=True simulates the
        future state where Earthman art lands."""
        self.earthman.has_card_images = True
        self.earthman.save(update_fields=["has_card_images"])
        response = self.client.get(reverse("tarot_fan", kwargs={"deck_id": self.earthman.pk}))
        parsed = lxml.html.fromstring(response.content)
        cards = parsed.cssselect(".fan-card")
        self.assertGreater(len(cards), 0)
        for card in cards:
            self.assertIn("fan-card--image", card.get("class", ""))
            self.assertEqual(
                len(card.cssselect("img.sig-stage-card-back-img")), 0,
                "Polarized deck must not render the back-image element",
            )


class MySeaViewTest(TestCase):
    """Sprint 3 of the My Sea roadmap — standalone page is a shell only.
    Sigs / sea-select / gatekeeper phase content lands in later sprints."""

    def setUp(self):
        self.user = User.objects.create(email="sea@test.io")
        self.client.force_login(self.user)

    def test_my_sea_requires_login(self):
        self.client.logout()
        response = self.client.get(reverse("my_sea"))
        self.assertRedirects(
            response, "/?next=/gameboard/my-sea/", fetch_redirect_response=False
        )

    def test_my_sea_renders_200(self):
        response = self.client.get(reverse("my_sea"))
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "apps/gameboard/my_sea.html")

    def test_my_sea_uses_gameboard_page_class(self):
        # `page_class` drives the body class for the landscape layout aperture
        # — My Sea inherits the gameboard's aperture (same nav/footer rails).
        response = self.client.get(reverse("my_sea"))
        self.assertIn("page-gameboard", response.content.decode())
        self.assertIn("page-my-sea", response.content.decode())

    def test_sea_stage_stat_block_renders_rank_suit_chip_per_face(self):
        """Sprint A.7.5 — `_sea_stage.html` modal scaffold (included from
        my_sea-picker-phase + the gameroom sea overlay) carries the new
        `.stat-face-header` wrapper w. the rank+suit chip inline w. the
        EMANATION/REVERSAL label. Both upright + reversed faces have their
        own chip; stage-card.js populateStatExtras fills both identically
        on each card focus. Rendered standalone via render_to_string since
        the partial's parent views are phase-gated."""
        from django.template.loader import render_to_string
        html = render_to_string("apps/gameboard/_partials/_sea_stage.html")
        parsed = lxml.html.fromstring(html)
        for face_cls in ("stat-face--upright", "stat-face--reversed"):
            face = parsed.cssselect(f".sea-stat-block .{face_cls}")
            self.assertEqual(len(face), 1, f"expected one {face_cls}")
            [header] = face[0].cssselect(".stat-face-header")
            [_rank] = header.cssselect(".stat-chip-rank")
            [_tag] = header.cssselect(".stat-chip-tag")
            [_icon] = _tag.cssselect("i.stat-chip-icon")
            [_label] = _tag.cssselect(".stat-face-label")


class MySeaDrawSeaLandingViewTest(TestCase):
    """Sprint 5 iter 1 — view context for the DRAW SEA landing UX. Pins
    `no_equipped_deck` + `show_backup_intro_banner` context keys + the
    presence of the new landing template elements when user passes the
    Sprint 4b sign-gate."""

    def setUp(self):
        from apps.epic.models import personal_sig_cards
        self.user = User.objects.create(email="draw@test.io")
        self.client.force_login(self.user)
        # Assign a sig so the view's landing branch (not the gate) renders.
        self.user.significator = personal_sig_cards(self.user)[0]
        self.user.save(update_fields=["significator"])

    def test_context_no_equipped_deck_false_when_user_has_deck(self):
        # post_save auto-equips Earthman; `no_equipped_deck` should be False.
        response = self.client.get(reverse("my_sea"))
        self.assertFalse(response.context["no_equipped_deck"])

    def test_context_no_equipped_deck_true_when_user_cleared_deck(self):
        self.user.equipped_deck = None
        self.user.save(update_fields=["equipped_deck"])
        response = self.client.get(reverse("my_sea"))
        self.assertTrue(response.context["no_equipped_deck"])

    def test_context_show_backup_intro_banner_when_no_deck_and_has_sig(self):
        # Brief banner fires when user has a sig AND no deck — they're on the
        # landing UX (gate passed) but headed for the backup-deck draw path.
        self.user.equipped_deck = None
        self.user.save(update_fields=["equipped_deck"])
        response = self.client.get(reverse("my_sea"))
        self.assertTrue(response.context["show_backup_intro_banner"])

    def test_context_show_backup_intro_banner_false_when_deck_equipped(self):
        response = self.client.get(reverse("my_sea"))
        self.assertFalse(response.context["show_backup_intro_banner"])

    def test_landing_renders_free_draw_btn_when_sig_set(self):
        # Element ID `id_draw_sea_btn` describes intent (draw entry point);
        # visible label is "FREE DRAW" for the daily-free quota draw.
        response = self.client.get(reverse("my_sea"))
        self.assertContains(response, 'id="id_draw_sea_btn"')
        self.assertContains(response, "FREE")
        self.assertContains(response, "DRAW")

    def test_landing_renders_six_chair_seats_with_C_suffix(self):
        response = self.client.get(reverse("my_sea"))
        html = response.content.decode()
        for n in range(1, 7):
            with self.subTest(slot=n):
                self.assertIn(f'data-slot="{n}"', html)
                self.assertIn(f"{n}C", html)

    def test_landing_renders_position_status_ban_icon_on_each_seat(self):
        # Each chair seat starts empty (red `.fa-ban` status icon). The
        # FREE DRAW click handler swaps seat 1C's icon to .fa-circle-check
        # client-side; this IT only pins the initial render state. Class
        # substrings ("position-status-icon", "fa-ban") ALSO appear in the
        # inline JS handler (classList.remove arg, querySelector arg) — so
        # counts are asserted on the full class-attribute string only.
        response = self.client.get(reverse("my_sea"))
        html = response.content.decode()
        self.assertEqual(html.count('class="position-status-icon fa-solid fa-ban"'), 6)
        self.assertEqual(html.count('class="seat-position-label"'), 6)

    def test_landing_not_rendered_when_user_has_no_sig(self):
        # Sprint 4b gate still wins precedence — FREE DRAW must not render
        # when significator is None.
        self.user.significator = None
        self.user.save(update_fields=["significator"])
        response = self.client.get(reverse("my_sea"))
        self.assertNotContains(response, 'id="id_draw_sea_btn"')


class MySeaPickerPhaseTemplateTest(TestCase):
    """Sprint 5 iter 2 — picker-phase template render contract: the
    three-card cross (sig in core + cover/leave/loom drop zones) is
    server-rendered (hidden until JS swaps data-phase after FREE DRAW).
    Crown / lay / cross from the gameroom's 6-position Celtic Cross are
    deliberately forsaken in the solo flow."""

    def setUp(self):
        from apps.epic.models import personal_sig_cards
        self.user = User.objects.create(email="picker@test.io")
        self.client.force_login(self.user)
        self.target = personal_sig_cards(self.user)[0]
        self.user.significator = self.target
        self.user.save(update_fields=["significator"])

    def test_picker_renders_significator_in_core_cell(self):
        response = self.client.get(reverse("my_sea"))
        html = response.content.decode()
        # Sig card carries the user's significator id so iter 3's draw
        # flow can target it for SPIN / FLIP / FYI without re-fetching.
        self.assertIn('sea-pos-core', html)
        self.assertIn('sea-sig-card', html)
        self.assertIn(f'data-card-id="{self.target.id}"', html)

    def test_picker_renders_cover_leave_loom_positions(self):
        response = self.client.get(reverse("my_sea"))
        self.assertContains(response, "sea-pos-cover")
        self.assertContains(response, "sea-pos-leave")
        self.assertContains(response, "sea-pos-loom")

    def test_sea_sig_card_renders_image_when_deck_has_card_images(self):
        """Sprint A.5 — central sig card on /gameboard/my-sea/ carries the
        `.sig-stage-card--image` marker class + an <img.sig-stage-card-img>
        child pointing at the deck's image asset when the user's equipped
        deck is image-equipped (Minchiate today). Mirrors A.3's my_sign.html
        image-mode treatment so the central sig + the Sea Stage modal render
        with consistent visual identity."""
        from apps.epic.models import DeckVariant, TarotCard
        minchiate = DeckVariant.objects.get(slug="minchiate-fiorentine-1860-1890")
        # Override the auto-equipped Earthman w. Minchiate + pick Il Matto as
        # the user's sig (it's MAJOR rank 0 → permitted by personal_sig_cards
        # IF user has the super-nomad Note unlock; superuser auto-gets it).
        self.user.is_superuser = True
        self.user.save()
        # Re-run the post_save Note grants for the now-superuser by manually
        # granting (signal only fires on initial create).
        from apps.drama.models import Note
        Note.grant_if_new(self.user, "super-nomad")
        Note.grant_if_new(self.user, "super-schizo")
        self.user.unlocked_decks.add(minchiate)
        self.user.equipped_deck = minchiate
        il_matto = TarotCard.objects.get(deck_variant=minchiate, slug="il-matto")
        self.user.significator = il_matto
        self.user.save(update_fields=["equipped_deck", "significator"])

        import lxml.html
        response = self.client.get(reverse("my_sea"))
        parsed = lxml.html.fromstring(response.content)
        [sig_card] = parsed.cssselect(".sea-sig-card")
        self.assertIn(
            "sig-stage-card--image", sig_card.get("class", ""),
            "Sig card must carry --image marker class for Minchiate-equipped user",
        )
        [img] = sig_card.cssselect("img.sig-stage-card-img")
        self.assertIn(
            "minchiate-fiorentine-1860-1890-trumps-00-il-matto.png",
            img.get("src", ""),
            "Image src must point at the v2-convention Il Matto asset",
        )

    def test_sea_sig_card_renders_text_when_deck_has_no_images(self):
        """Earthman (has_card_images=False) keeps the existing corner-rank +
        suit-icon text render — the image branch only applies to image-decks."""
        # Default setUp leaves the user on auto-equipped Earthman.
        import lxml.html
        response = self.client.get(reverse("my_sea"))
        parsed = lxml.html.fromstring(response.content)
        [sig_card] = parsed.cssselect(".sea-sig-card")
        self.assertNotIn("sig-stage-card--image", sig_card.get("class", ""))
        self.assertEqual(
            len(sig_card.cssselect("img.sig-stage-card-img")), 0,
            "Non-image deck must not render the <img> in the sig card",
        )
        self.assertEqual(
            len(sig_card.cssselect(".fan-corner-rank")), 1,
            "Non-image deck falls through to corner-rank text render",
        )

    def test_picker_renders_six_card_only_positions_for_spread_switch(self):
        # Crown / lay / cross sit in the DOM unconditionally so iter 3's
        # SPREAD dropdown can reveal them via CSS attribute swap (data-
        # spread-shape="six-card") without re-rendering. Default 3-card
        # spread hides them via `.my-sea-cross[data-spread-shape=
        # "three-card"]` rules in _gameboard.scss — FT pins the hidden
        # state visually.
        response = self.client.get(reverse("my_sea"))
        self.assertContains(response, "sea-pos-crown")
        self.assertContains(response, "sea-pos-lay")
        self.assertContains(response, "sea-pos-cross")

    def test_picker_not_rendered_when_user_has_no_sig(self):
        # 4b gate wins; picker has no business rendering without a sig.
        self.user.significator = None
        self.user.save(update_fields=["significator"])
        response = self.client.get(reverse("my_sea"))
        self.assertNotContains(response, "my-sea-picker")


class MySeaPolarityMatchesMySignTest(TestCase):
    """Bug 2026-05-21 (user-reported): a levity sig chosen on /billboard/
    my-sign/ rendered as gravity-styled on /gameboard/my-sea/ (priUser bg
    + secUser text) — and vice versa for gravity sigs (which then collided
    w. the hardcoded --secUser corner-rank color in `.sea-sig-card`,
    making the rank + suit-icon invisible against a --secUser bg).

    Root cause was a polarity inversion in `my_sea.html:10` —
    `{% if significator_reversed %}gravity{% else %}levity{% endif %}` —
    opposite of `my_sign.html:22` + its JS `_polarity()`. This test pins
    the two surfaces to agree on the same `User.significator_reversed`
    → `data-polarity` mapping, so any future drift gets caught."""

    def setUp(self):
        from apps.epic.models import personal_sig_cards
        self.user = User.objects.create(email="polarity@test.io")
        self.client.force_login(self.user)
        target = personal_sig_cards(self.user)[0]
        self.user.significator = target
        self.user.save(update_fields=["significator"])

    def test_unreversed_sig_renders_gravity_on_both_surfaces(self):
        """`significator_reversed=False` (the on-creation default) renders
        data-polarity="gravity" on BOTH my_sign + my_sea — the convention
        established by my_sign's Sprint 4a picker + its `_polarity()` JS."""
        self.user.significator_reversed = False
        self.user.save(update_fields=["significator_reversed"])
        sea = self.client.get(reverse("my_sea")).content.decode()
        sign = self.client.get(reverse("billboard:my_sign")).content.decode()
        self.assertIn('data-polarity="gravity"', sea)
        self.assertIn('data-polarity="gravity"', sign)

    def test_reversed_sig_renders_levity_on_both_surfaces(self):
        """`significator_reversed=True` (FLIP-toggled on my_sign) renders
        data-polarity="levity" on BOTH surfaces."""
        self.user.significator_reversed = True
        self.user.save(update_fields=["significator_reversed"])
        sea = self.client.get(reverse("my_sea")).content.decode()
        sign = self.client.get(reverse("billboard:my_sign")).content.decode()
        self.assertIn('data-polarity="levity"', sea)
        self.assertIn('data-polarity="levity"', sign)


class MySeaSpreadFormTemplateTest(TestCase):
    """Sprint 5 iter 3 — form col + SPREAD dropdown structure + default-
    spread context + cross's `data-spread-shape` attribute. Iter 3 spec
    locks `Situation, Action, Outcome` as the default spread (a 3-card
    variant); the 6 spreads sit under 2 section dividers (3-card / 6-
    card)."""

    SPREAD_OPTIONS = [
        ("past-present-future", "Past, Present, Future"),
        ("situation-action-outcome", "Situation, Action, Outcome"),
        ("mind-body-spirit", "Mind, Body, Spirit"),
        ("desire-obstacle-solution", "Desire, Obstacle, Solution"),
        ("waite-smith", "Celtic Cross, Waite-Smith"),
        ("escape-velocity", "Celtic Cross, Escape Velocity"),
    ]

    def setUp(self):
        from apps.epic.models import personal_sig_cards
        self.user = User.objects.create(email="spread@test.io")
        self.client.force_login(self.user)
        self.target = personal_sig_cards(self.user)[0]
        self.user.significator = self.target
        self.user.save(update_fields=["significator"])

    def test_context_default_spread_is_situation_action_outcome(self):
        response = self.client.get(reverse("my_sea"))
        self.assertEqual(
            response.context["default_spread"], "situation-action-outcome",
        )

    def test_context_reversals_pct_defaults_to_25(self):
        response = self.client.get(reverse("my_sea"))
        self.assertEqual(response.context["reversals_pct"], 25)

    def test_template_renders_all_six_spread_options(self):
        response = self.client.get(reverse("my_sea"))
        html = response.content.decode()
        for value, label in self.SPREAD_OPTIONS:
            with self.subTest(spread=value):
                self.assertIn(f'data-value="{value}"', html)
                self.assertIn(label, html)

    def test_template_renders_three_card_and_six_card_section_dividers(self):
        response = self.client.get(reverse("my_sea"))
        html = response.content.decode()
        self.assertEqual(html.count("sea-select-divider"), 2)
        self.assertIn("3-card spreads", html)
        self.assertIn("6-card spreads", html)

    def test_template_marks_situation_action_outcome_aria_selected(self):
        response = self.client.get(reverse("my_sea"))
        html = response.content.decode()
        # The default option carries aria-selected="true"; the others false.
        self.assertIn(
            'data-value="situation-action-outcome" aria-selected="true"', html,
        )

    def test_cross_carries_initial_data_spread_sao(self):
        # `.my-sea-cross[data-spread]` is the per-spread visibility key;
        # default-spread context value renders into the attribute. SCSS
        # rules in _gameboard.scss hide the inactive positions per spread.
        response = self.client.get(reverse("my_sea"))
        self.assertContains(response, 'data-spread="situation-action-outcome"')

    def test_template_renders_sao_position_labels_on_default(self):
        # Server-renders the SAO position labels into the empty drop-zone
        # `.sea-pos-label` spans so the page is correct before JS boots.
        # JS swaps labels on spread change.
        response = self.client.get(reverse("my_sea"))
        html = response.content.decode()
        self.assertIn('data-position="lay">Situation</span>', html)
        self.assertIn('data-position="cover">Action</span>', html)
        self.assertIn('data-position="crown">Outcome</span>', html)
        # Inactive-for-SAO positions render their span but w. empty
        # textContent (JS fills them on spread switch).
        self.assertIn('data-position="leave"></span>', html)
        self.assertIn('data-position="loom"></span>', html)
        self.assertIn('data-position="cross"></span>', html)

    def test_form_col_renders_decks_action_btn_del_and_reversal_hint(self):
        # Iter 4c — LOCK HAND replaced by AUTO DRAW (mid-draw) which JS
        # transitions to GATE VIEW on completion. ID `id_sea_action_btn`
        # is the single slot housing both states (label + `data-state`
        # toggled by JS). User w. no active draw → AUTO DRAW label.
        response = self.client.get(reverse("my_sea"))
        html = response.content.decode()
        self.assertIn("sea-deck-stack--gravity", html)
        self.assertIn("sea-deck-stack--levity", html)
        self.assertIn('id="id_sea_action_btn"', html)
        self.assertIn('data-state="auto-draw"', html)
        self.assertIn("AUTO", html)
        self.assertIn('id="id_sea_del"', html)
        self.assertIn("sea-reversal-hint", html)
        self.assertIn("25% reversals", html)


class MySeaDeckDataViewTest(TestCase):
    """Sprint 5 iter 4a — view-level deck-data contract. `my_sea` view
    embeds a shuffled deck (levity + gravity halves, current user's
    significator excluded, reversal pre-rolled at ~25%) as JSON via
    the `sea_deck_data` context key + `{{ ...|json_script }}` filter
    in the template."""

    def setUp(self):
        from apps.epic.models import personal_sig_cards
        self.user = User.objects.create(email="deck@test.io")
        self.client.force_login(self.user)
        self.target = personal_sig_cards(self.user)[0]
        self.user.significator = self.target
        self.user.save(update_fields=["significator"])

    def test_context_sea_deck_data_has_two_polarity_halves(self):
        response = self.client.get(reverse("my_sea"))
        deck = response.context["sea_deck_data"]
        self.assertIn("levity", deck)
        self.assertIn("gravity", deck)
        self.assertIsInstance(deck["levity"], list)
        self.assertIsInstance(deck["gravity"], list)

    def test_deck_data_excludes_user_significator(self):
        response = self.client.get(reverse("my_sea"))
        deck = response.context["sea_deck_data"]
        all_ids = (
            {c["id"] for c in deck["levity"]}
            | {c["id"] for c in deck["gravity"]}
        )
        self.assertNotIn(self.target.id, all_ids)

    def test_deck_data_halves_are_disjoint(self):
        response = self.client.get(reverse("my_sea"))
        deck = response.context["sea_deck_data"]
        levity_ids = {c["id"] for c in deck["levity"]}
        gravity_ids = {c["id"] for c in deck["gravity"]}
        self.assertEqual(levity_ids & gravity_ids, set())

    def test_deck_data_cards_carry_corner_rank_suit_icon_and_reversed(self):
        # Card dict shape mirrors the gameroom `sea_deck` endpoint so
        # iter 4b's persistence/render path can reuse the JSON contract.
        response = self.client.get(reverse("my_sea"))
        deck = response.context["sea_deck_data"]
        any_card = (deck["levity"] + deck["gravity"])[0]
        for key in ("id", "corner_rank", "suit_icon", "reversed"):
            with self.subTest(key=key):
                self.assertIn(key, any_card)
        self.assertIsInstance(any_card["reversed"], bool)

    def test_template_embeds_deck_as_json_script(self):
        # Embed mechanism: `{{ sea_deck_data|json_script:"id_my_sea_deck" }}`
        # gives a `<script type="application/json" id="id_my_sea_deck">`.
        response = self.client.get(reverse("my_sea"))
        self.assertContains(
            response,
            '<script id="id_my_sea_deck" type="application/json">',
        )

    def test_deck_data_empty_when_user_has_no_equipped_deck(self):
        # Backup-deck branch: per [[sprint-my-sign-picker-may18h]] follow-
        # up, no-deck users still proceed via Earthman. So deck_data falls
        # back to Earthman, NOT empty. (Earthman seed is migration-loaded
        # in this TestCase context.)
        self.user.equipped_deck = None
        self.user.save(update_fields=["equipped_deck"])
        response = self.client.get(reverse("my_sea"))
        deck = response.context["sea_deck_data"]
        self.assertGreater(len(deck["levity"]) + len(deck["gravity"]), 0)


# ── Sprint 5 iter 4b — server persistence: MySeaDraw + lock + delete ──────────


class MySeaDrawModelTest(TestCase):
    """Sprint 5 iter 4b — `MySeaDraw` model + `active_draw_for` helper."""

    def setUp(self):
        from apps.epic.models import personal_sig_cards
        self.user = User.objects.create(email="model@test.io")
        self.target = personal_sig_cards(self.user)[0]
        self.user.significator = self.target
        self.user.save(update_fields=["significator"])

    def _build_hand(self):
        from apps.epic.models import TarotCard
        cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
        return [
            {"position": "lay",   "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
            {"position": "cover", "card_id": cards[1].id, "reversed": True,  "polarity": "levity"},
            {"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
        ]

    def test_create_round_trips_hand_and_sig_snapshot(self):
        from apps.gameboard.models import MySeaDraw
        hand = self._build_hand()
        draw = MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            hand=hand, significator_id=self.target.id, significator_reversed=False,
        )
        draw.refresh_from_db()
        self.assertEqual(draw.hand, hand)
        self.assertEqual(draw.significator_id, self.target.id)

    def test_next_free_draw_at_is_created_at_plus_24h(self):
        from datetime import timedelta
        from apps.gameboard.models import MySeaDraw, FREE_DRAW_COOLDOWN_HOURS
        draw = MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            hand=self._build_hand(), significator_id=self.target.id,
        )
        delta = draw.next_free_draw_at - draw.created_at
        self.assertEqual(delta, timedelta(hours=FREE_DRAW_COOLDOWN_HOURS))

    def test_is_within_quota_window_true_when_fresh(self):
        from apps.gameboard.models import MySeaDraw
        draw = MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            hand=self._build_hand(), significator_id=self.target.id,
        )
        self.assertTrue(draw.is_within_quota_window)

    def test_is_within_quota_window_false_when_older_than_24h(self):
        from datetime import timedelta
        from django.utils import timezone
        from apps.gameboard.models import MySeaDraw
        draw = MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            hand=self._build_hand(), significator_id=self.target.id,
            created_at=timezone.now() - timedelta(hours=25),
        )
        self.assertFalse(draw.is_within_quota_window)

    def test_active_draw_for_returns_recent_draw(self):
        from apps.gameboard.models import MySeaDraw, active_draw_for
        draw = MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            hand=self._build_hand(), significator_id=self.target.id,
        )
        self.assertEqual(active_draw_for(self.user), draw)

    def test_active_draw_for_returns_none_when_no_draws(self):
        from apps.gameboard.models import active_draw_for
        self.assertIsNone(active_draw_for(self.user))

    def test_active_draw_for_returns_none_when_only_stale_draws(self):
        from datetime import timedelta
        from django.utils import timezone
        from apps.gameboard.models import MySeaDraw, active_draw_for
        MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            hand=self._build_hand(), significator_id=self.target.id,
            created_at=timezone.now() - timedelta(hours=25),
        )
        self.assertIsNone(active_draw_for(self.user))

    def test_active_draw_for_scopes_to_user(self):
        from apps.gameboard.models import MySeaDraw, active_draw_for
        other = User.objects.create(email="other@test.io")
        MySeaDraw.objects.create(
            user=other, spread="situation-action-outcome",
            hand=self._build_hand(), significator_id=self.target.id,
        )
        self.assertIsNone(active_draw_for(self.user))


class MySeaLockHandViewTest(TestCase):
    """Sprint 5 iter 4b — POST `/gameboard/my-sea/lock` persists a hand."""

    def setUp(self):
        from apps.epic.models import personal_sig_cards
        self.user = User.objects.create(email="lock@test.io")
        self.client.force_login(self.user)
        self.target = personal_sig_cards(self.user)[0]
        self.user.significator = self.target
        self.user.save(update_fields=["significator"])
        self.url = reverse("my_sea_lock")

    def _build_payload(self, spread="situation-action-outcome", hand=None):
        from apps.epic.models import TarotCard
        if hand is None:
            cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
            hand = [
                {"position": "lay",   "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
                {"position": "cover", "card_id": cards[1].id, "reversed": True,  "polarity": "levity"},
                {"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
            ]
        return {"spread": spread, "hand": hand}

    def test_lock_requires_login(self):
        import json
        self.client.logout()
        response = self.client.post(
            self.url, data=json.dumps(self._build_payload()),
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 302)

    def test_lock_get_returns_405(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 405)

    def test_lock_post_creates_my_sea_draw_for_user(self):
        import json
        from apps.gameboard.models import MySeaDraw
        response = self.client.post(
            self.url, data=json.dumps(self._build_payload()),
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 200)
        draw = MySeaDraw.objects.get(user=self.user)
        self.assertEqual(draw.spread, "situation-action-outcome")
        self.assertEqual(len(draw.hand), 3)
        self.assertEqual(draw.significator_id, self.target.id)

    def test_lock_post_response_includes_next_free_draw_iso_timestamp(self):
        import json
        from datetime import datetime
        response = self.client.post(
            self.url, data=json.dumps(self._build_payload()),
            content_type="application/json",
        )
        body = response.json()
        self.assertIn("next_free_draw_at", body)
        # Round-trip parse — the server is expected to send an ISO 8601 string.
        parsed = datetime.fromisoformat(body["next_free_draw_at"])
        self.assertIsNotNone(parsed)

    def test_lock_post_first_card_sets_user_last_free_draw_at(self):
        # User-spec 2026-05-23: free-draw cooldown is anchored to User.
        # last_free_draw_at, set on the first-card-of-cycle lock so the
        # next FREE DRAW unlocks 24h later regardless of any intervening
        # PAID DRAWs. Fresh user (no prior cooldown) → SET.
        import json
        before = timezone.now()
        self.assertIsNone(self.user.last_free_draw_at,
            "precondition: fresh user has no prior free draw")
        response = self.client.post(
            self.url, data=json.dumps(self._build_payload()),
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 200)
        self.user.refresh_from_db()
        self.assertIsNotNone(self.user.last_free_draw_at)
        self.assertGreaterEqual(self.user.last_free_draw_at, before)

    def test_lock_post_during_cooldown_does_not_reset_last_free_draw_at(self):
        # User is mid-cooldown (last_free_draw_at set to 6h ago). A
        # subsequent /lock POST (e.g. a paid draw committing its first
        # card) must NOT bump last_free_draw_at — the cooldown stays
        # anchored to the original FREE DRAW per user-spec 2026-05-23.
        import json
        from datetime import timedelta
        from apps.gameboard.models import MySeaDraw
        original_anchor = timezone.now() - timedelta(hours=6)
        self.user.last_free_draw_at = original_anchor
        self.user.save(update_fields=["last_free_draw_at"])
        # Seed an existing row in a paid-through state (no hand yet).
        MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            significator_id=self.target.id, hand=[],
            paid_through_at=timezone.now(),
        )
        # Now lock the first paid card.
        self.client.post(
            self.url, data=json.dumps(self._build_payload(hand=[{
                "position": "lay", "card_id": self.target.id,
                "reversed": False, "polarity": "gravity",
            }])),
            content_type="application/json",
        )
        self.user.refresh_from_db()
        # last_free_draw_at unchanged — within 1s of original anchor.
        delta = abs((self.user.last_free_draw_at - original_anchor).total_seconds())
        self.assertLess(delta, 1.0)

    def test_lock_post_first_paid_card_consumes_paid_through_credit(self):
        # User-spec 2026-05-23: paid_through credit is one-shot. The
        # first card drawn after PAID DRAW commit clears `paid_through_
        # at` so the next redraw requires a fresh gatekeeper deposit.
        import json
        from apps.gameboard.models import MySeaDraw
        self.user.last_free_draw_at = timezone.now() - timezone.timedelta(hours=6)
        self.user.save(update_fields=["last_free_draw_at"])
        row = MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            significator_id=self.target.id, hand=[],
            paid_through_at=timezone.now(),
        )
        self.client.post(
            self.url, data=json.dumps(self._build_payload(hand=[{
                "position": "lay", "card_id": self.target.id,
                "reversed": False, "polarity": "gravity",
            }])),
            content_type="application/json",
        )
        row.refresh_from_db()
        self.assertIsNone(row.paid_through_at,
            "first card of paid session must consume the paid_through credit")
        self.assertEqual(len(row.hand), 1)

    def test_lock_post_within_quota_upserts_same_row(self):
        # Iter 4c — `/lock` is now an upsert (per-placement POST cadence).
        # Second POST w. same spread updates the existing row's hand
        # rather than 409'ing. Only one row exists per user per 24h.
        import json
        from apps.gameboard.models import MySeaDraw
        from apps.epic.models import TarotCard
        # First POST: 1-card partial hand
        cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
        partial = {
            "spread": "situation-action-outcome",
            "hand": [{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"}],
        }
        r1 = self.client.post(
            self.url, data=json.dumps(partial),
            content_type="application/json",
        )
        self.assertEqual(r1.status_code, 200)
        # Second POST: full 3-card hand (the SAME draw progressing).
        full = self._build_payload()
        r2 = self.client.post(
            self.url, data=json.dumps(full),
            content_type="application/json",
        )
        self.assertEqual(r2.status_code, 200)
        # Exactly one MySeaDraw row exists; hand is the latest full one.
        rows = MySeaDraw.objects.filter(user=self.user)
        self.assertEqual(rows.count(), 1)
        self.assertEqual(len(rows.first().hand), 3)

    def test_lock_post_spread_mismatch_within_quota_returns_409(self):
        # Spread is committed at first-card moment for the duration of an
        # ACTIVE non-empty draw. Switching spread mid-non-empty-draw → 409.
        import json
        from apps.epic.models import TarotCard
        cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
        self.client.post(
            self.url, data=json.dumps({
                "spread": "situation-action-outcome",
                "hand": [{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"}],
            }),
            content_type="application/json",
        )
        response = self.client.post(
            self.url, data=json.dumps({
                "spread": "waite-smith",
                "hand": [{"position": "crown", "card_id": cards[1].id, "reversed": False, "polarity": "levity"}],
            }),
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 409)

    def test_lock_post_spread_switch_after_del_succeeds(self):
        """Sprint A.7-polish-3 — once the user DELs (clears hand to []), the
        spread lock lifts: a subsequent POST with a different spread
        UPDATES the existing row's spread + populates the new hand. The 24h
        quota window (created_at + paid_through_at) is preserved — only the
        spread field changes. Fixes the AUTO-DRAW-only-works-on-SAO user bug
        report (2026-05-25 PM): users couldn't switch spreads even after
        DELing because the row's spread stayed locked for the full 24h."""
        import json
        from apps.epic.models import TarotCard
        from apps.gameboard.models import MySeaDraw
        cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
        # First draw under SAO — creates row w. spread=SAO + 1 card.
        self.client.post(
            self.url, data=json.dumps({
                "spread": "situation-action-outcome",
                "hand": [{"position": "lay", "card_id": cards[0].id,
                          "reversed": False, "polarity": "gravity"}],
            }),
            content_type="application/json",
        )
        row = MySeaDraw.objects.get(user=self.user)
        self.assertEqual(row.spread, "situation-action-outcome")
        original_created_at = row.created_at
        # DEL clears hand to [] but preserves the row.
        self.client.post(reverse("my_sea_delete"))
        row.refresh_from_db()
        self.assertEqual(row.hand, [])
        self.assertEqual(row.spread, "situation-action-outcome")
        # Now POST a draw under a DIFFERENT spread. Should succeed +
        # update the row's spread.
        response = self.client.post(
            self.url, data=json.dumps({
                "spread": "waite-smith",
                "hand": [{"position": "crown", "card_id": cards[1].id,
                          "reversed": False, "polarity": "levity"}],
            }),
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 200)
        row.refresh_from_db()
        self.assertEqual(row.spread, "waite-smith")
        self.assertEqual(len(row.hand), 1)
        # The 24h quota window is preserved — created_at unchanged.
        self.assertEqual(row.created_at, original_created_at)

    def test_lock_post_returns_hand_complete_flag(self):
        # Body includes `hand_complete` so the JS can decide whether to
        # transition the picker into post-completion state (DEL enable,
        # FLIPs disable, AUTO DRAW → GATE VIEW).
        import json
        partial = {
            "spread": "situation-action-outcome",
            "hand": self._build_payload()["hand"][:1],
        }
        r1 = self.client.post(
            self.url, data=json.dumps(partial),
            content_type="application/json",
        )
        self.assertFalse(r1.json()["hand_complete"])
        r2 = self.client.post(
            self.url, data=json.dumps(self._build_payload()),
            content_type="application/json",
        )
        self.assertTrue(r2.json()["hand_complete"])

    def test_lock_post_empty_hand_returns_400(self):
        import json
        response = self.client.post(
            self.url, data=json.dumps({"spread": "situation-action-outcome", "hand": []}),
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 400)

    def test_lock_post_missing_spread_returns_400(self):
        import json
        payload = self._build_payload()
        payload.pop("spread")
        response = self.client.post(
            self.url, data=json.dumps(payload),
            content_type="application/json",
        )
        self.assertEqual(response.status_code, 400)

    def test_lock_post_snapshots_user_significator(self):
        import json
        from apps.gameboard.models import MySeaDraw
        self.client.post(
            self.url, data=json.dumps(self._build_payload()),
            content_type="application/json",
        )
        draw = MySeaDraw.objects.get(user=self.user)
        # Sig snapshot persists even after user clears their sig.
        self.user.significator = None
        self.user.save(update_fields=["significator"])
        draw.refresh_from_db()
        self.assertEqual(draw.significator_id, self.target.id)


class MySeaDeleteDrawViewTest(TestCase):
    """Sprint 5 iter 4c — POST `/gameboard/my-sea/delete` clears the HAND
    but preserves the row so the 24h quota window keeps running."""

    def setUp(self):
        from apps.epic.models import personal_sig_cards, TarotCard
        from apps.gameboard.models import MySeaDraw
        self.user = User.objects.create(email="del@test.io")
        self.client.force_login(self.user)
        self.target = personal_sig_cards(self.user)[0]
        self.user.significator = self.target
        self.user.save(update_fields=["significator"])
        cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
        self.draw = MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            significator_id=self.target.id,
            hand=[
                {"position": "lay",   "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
                {"position": "cover", "card_id": cards[1].id, "reversed": True,  "polarity": "levity"},
                {"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
            ],
        )
        self.url = reverse("my_sea_delete")

    def test_delete_requires_login(self):
        self.client.logout()
        response = self.client.post(self.url)
        self.assertEqual(response.status_code, 302)

    def test_delete_get_returns_405(self):
        response = self.client.get(self.url)
        self.assertEqual(response.status_code, 405)

    def test_delete_post_clears_hand_but_preserves_row(self):
        # Iter 4c — DEL no longer deletes; the row stays as a 24h quota
        # tracker. Hand JSON gets wiped + `created_at` preserved (so the
        # landing renders GATE VIEW, not FREE DRAW, until the row expires).
        from apps.gameboard.models import MySeaDraw
        original_created_at = self.draw.created_at
        response = self.client.post(self.url)
        self.assertIn(response.status_code, (200, 204, 302))
        self.draw.refresh_from_db()
        self.assertEqual(self.draw.hand, [])
        self.assertEqual(self.draw.created_at, original_created_at)
        self.assertTrue(MySeaDraw.objects.filter(user=self.user).exists())

    def test_delete_post_scoped_to_user_does_not_touch_others(self):
        from apps.gameboard.models import MySeaDraw
        other = User.objects.create(email="other-del@test.io")
        other_draw = MySeaDraw.objects.create(
            user=other, spread="situation-action-outcome",
            hand=self.draw.hand, significator_id=self.target.id,
        )
        self.client.post(self.url)
        other_draw.refresh_from_db()
        self.assertEqual(len(other_draw.hand), 3)  # untouched

    def test_delete_post_idempotent_when_no_active_draw(self):
        # User deletes twice in a row — second call is a no-op, not a 500.
        # First DEL clears hand. Second DEL finds the row w. empty hand
        # already; just no-ops.
        self.client.post(self.url)
        response = self.client.post(self.url)
        self.assertIn(response.status_code, (200, 204, 302))


class MySeaViewWithSavedDrawTest(TestCase):
    """Sprint 5 iter 4b — `my_sea` view branches when an active draw exists.

    Active draw bypasses the sign-gate (sig snapshot on the draw is used
    even if `user.significator` is None), bypasses the landing phase (the
    saved hand IS what the user came to see), and adds a Brief banner +
    next-free-draw timestamp to the context."""

    def setUp(self):
        from apps.epic.models import personal_sig_cards, TarotCard
        from apps.gameboard.models import MySeaDraw
        self.user = User.objects.create(email="saved@test.io")
        self.client.force_login(self.user)
        self.target = personal_sig_cards(self.user)[0]
        self.user.significator = self.target
        self.user.save(update_fields=["significator"])
        cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
        self.draw = MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            significator_id=self.target.id,
            hand=[
                {"position": "lay",   "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
                {"position": "cover", "card_id": cards[1].id, "reversed": True,  "polarity": "levity"},
                {"position": "crown", "card_id": cards[2].id, "reversed": False, "polarity": "gravity"},
            ],
        )

    def test_context_carries_active_draw(self):
        response = self.client.get(reverse("my_sea"))
        self.assertEqual(response.context["active_draw"], self.draw)

    def test_context_default_spread_is_saved_spread(self):
        response = self.client.get(reverse("my_sea"))
        self.assertEqual(response.context["default_spread"], self.draw.spread)

    def test_context_carries_next_free_draw_iso(self):
        from datetime import datetime
        response = self.client.get(reverse("my_sea"))
        ts = response.context["next_free_draw_at"]
        # Either a datetime instance or an ISO string the template renders.
        if isinstance(ts, str):
            self.assertIsNotNone(datetime.fromisoformat(ts))
        else:
            self.assertIsNotNone(ts.isoformat())

    def test_saved_draw_bypasses_sign_gate_even_when_user_sig_cleared(self):
        # User-spec'd: a cleared sig doesn't invalidate the saved draw.
        # The view must still render the picker phase (NOT the sign-gate)
        # by falling back to the draw's snapshotted sig.
        self.user.significator = None
        self.user.save(update_fields=["significator"])
        response = self.client.get(reverse("my_sea"))
        html = response.content.decode()
        self.assertNotIn("my-sea-sign-gate", html)
        self.assertIn('data-phase="picker"', html)

    def test_view_triggers_brief_banner_when_active_draw_exists(self):
        # Brief is rendered client-side via Brief.showBanner (standard
        # `.note-banner` w. Gaussian-glass bg, portaled atop the h2 —
        # same UX as my-notes / my-sign default-deck-warning Briefs).
        # Server emits a `window._showFreeDrawLockedBrief("<iso>")` call
        # gated on active_draw; ISO timestamp (`|date:'c'`) is re-used
        # as both `created_at` AND the source for the human-formatted
        # display string note.js renders in the `.note-banner__timestamp`
        # slot — single source of truth, no "Invalid Date" on bad input.
        response = self.client.get(reverse("my_sea"))
        # Match the call form w. opening quote — the bare token
        # `_showFreeDrawLockedBrief(` also appears in the function
        # definition emitted unconditionally inside the picker IIFE.
        self.assertContains(response, 'window._showFreeDrawLockedBrief("')
        # The ISO format produced by Django's `|date:'c'` starts with the
        # full year + ISO-style T separator — pin a representative token.
        from django.utils import timezone
        from datetime import timedelta
        expected_year = (timezone.now() + timedelta(hours=24)).strftime("%Y")
        self.assertContains(response, '_showFreeDrawLockedBrief("' + expected_year)

    def test_view_does_not_trigger_brief_banner_without_active_draw(self):
        # Definition of `_showFreeDrawLockedBrief` is always emitted;
        # only the CALL is gated on active_draw. Pin the call form.
        from apps.gameboard.models import MySeaDraw
        MySeaDraw.objects.all().delete()
        response = self.client.get(reverse("my_sea"))
        self.assertNotContains(response, 'window._showFreeDrawLockedBrief("')

    def test_view_wires_del_button_to_shared_guard_portal_when_active_draw(self):
        # No my-sea-specific guard markup — the picker IIFE calls
        # `window.showGuard(delBtn, "Are you sure?", confirmFn)` which
        # targets the shared #id_guard_portal from base.html (same
        # tooltip the room gear-menu uses; standard OK/NVM button pair).
        # Server emits the call site; we pin the call form + the delete
        # URL it POSTs to.
        response = self.client.get(reverse("my_sea"))
        self.assertContains(response, "window.showGuard(")
        self.assertContains(response, reverse("my_sea_delete"))

    def test_saved_hand_renders_as_filled_slots_in_picker(self):
        # Each saved position's slot is server-rendered as `--filled` w.
        # the snapshotted card id + polarity. JS-init then layers any
        # post-load behaviours (label re-rendering, stage-card lookups).
        response = self.client.get(reverse("my_sea"))
        html = response.content.decode()
        for entry in self.draw.hand:
            self.assertIn(f'data-card-id="{entry["card_id"]}"', html)
            self.assertIn(f"sea-card-slot--{entry['polarity']}", html)

    def test_landing_phase_suppressed_when_active_draw_exists(self):
        response = self.client.get(reverse("my_sea"))
        self.assertNotContains(response, 'id="id_draw_sea_btn"')
        self.assertNotContains(response, 'data-phase="landing"')

    def test_complete_hand_renders_action_btn_as_gate_view(self):
        # Iter 4c — server pre-renders the action btn label + data-state
        # based on `hand_complete`. With the setUp's 3-card SAO hand,
        # hand is complete → btn label is GATE VIEW.
        response = self.client.get(reverse("my_sea"))
        self.assertContains(response, 'data-state="gate-view"')
        self.assertContains(response, "GATE")
        self.assertContains(response, "VIEW")

    def test_complete_hand_picker_carries_locked_class(self):
        # `.my-sea-picker--locked` is server-rendered for completed hands
        # so the JS init seeds `_locked=true` w.o. waiting for the post-
        # placement state transition (matters for hot reloads, bfcache).
        response = self.client.get(reverse("my_sea"))
        self.assertContains(response, "my-sea-picker--locked")

    def test_complete_hand_del_btn_is_not_disabled(self):
        # DEL is `.btn-disabled` only when hand is INCOMPLETE. Complete
        # hand → DEL renders w.o. the disabled class (clicking opens the
        # guard portal).
        import re
        response = self.client.get(reverse("my_sea"))
        html = response.content.decode()
        m = re.search(
            r'<button[^>]*id="id_sea_del"[^>]*class="([^"]*)"', html,
        )
        self.assertIsNotNone(m)
        self.assertNotIn("btn-disabled", m.group(1))


class MySeaViewWithEmptyHandTest(TestCase):
    """Sprint 5 iter 4c — view branch for an active draw w. empty hand
    (the post-DEL state, where the quota row stays as a 24h tracker but
    the user's hand has been wiped). Landing renders w. GATE VIEW (NOT
    FREE DRAW) as the primary nav."""

    def setUp(self):
        from apps.epic.models import personal_sig_cards
        from apps.gameboard.models import MySeaDraw
        self.user = User.objects.create(email="empty@test.io")
        self.client.force_login(self.user)
        self.target = personal_sig_cards(self.user)[0]
        self.user.significator = self.target
        self.user.save(update_fields=["significator"])
        # Active draw row but hand is empty — simulates the post-DEL state.
        self.draw = MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            significator_id=self.target.id, hand=[],
        )

    def test_empty_hand_renders_landing_phase_not_picker(self):
        response = self.client.get(reverse("my_sea"))
        self.assertContains(response, 'data-phase="landing"')

    def test_empty_hand_landing_renders_gate_view_btn_not_free_draw(self):
        response = self.client.get(reverse("my_sea"))
        self.assertContains(response, 'id="id_my_sea_gate_view_btn"')
        self.assertNotContains(response, 'id="id_draw_sea_btn"')

    def test_empty_hand_gate_view_btn_links_to_gate_url(self):
        response = self.client.get(reverse("my_sea"))
        self.assertContains(response, reverse("my_sea_gate"))

    def test_empty_hand_brief_banner_still_triggered(self):
        # Quota's still committed (row exists, 24h clock still running) →
        # the Brief banner is part of the saved-draw context, regardless
        # of hand state. Informs the user when the next free draw is.
        response = self.client.get(reverse("my_sea"))
        self.assertContains(response, 'window._showFreeDrawLockedBrief("')


class MySeaViewWithPartialHandTest(TestCase):
    """Sprint 5 iter 4c — view branch for an active draw w. mid-progress
    hand (some slots filled, not yet complete). Picker renders w. the
    partial slots + AUTO DRAW btn (not GATE VIEW); DEL stays disabled."""

    def setUp(self):
        from apps.epic.models import personal_sig_cards, TarotCard
        from apps.gameboard.models import MySeaDraw
        self.user = User.objects.create(email="partial@test.io")
        self.client.force_login(self.user)
        self.target = personal_sig_cards(self.user)[0]
        self.user.significator = self.target
        self.user.save(update_fields=["significator"])
        cards = list(TarotCard.objects.exclude(id=self.target.id)[:2])
        # SAO is a 3-position spread; partial = 2 cards drawn.
        self.draw = MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            significator_id=self.target.id,
            hand=[
                {"position": "lay",   "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
                {"position": "cover", "card_id": cards[1].id, "reversed": True,  "polarity": "levity"},
            ],
        )

    def test_partial_hand_renders_picker_phase(self):
        response = self.client.get(reverse("my_sea"))
        self.assertContains(response, 'data-phase="picker"')

    def test_partial_hand_action_btn_is_auto_draw_not_gate_view(self):
        response = self.client.get(reverse("my_sea"))
        self.assertContains(response, 'data-state="auto-draw"')
        self.assertContains(response, "AUTO")

    def test_partial_hand_del_btn_carries_btn_disabled(self):
        import re
        response = self.client.get(reverse("my_sea"))
        html = response.content.decode()
        m = re.search(
            r'<button[^>]*id="id_sea_del"[^>]*class="([^"]*)"', html,
        )
        self.assertIsNotNone(m)
        self.assertIn("btn-disabled", m.group(1))

    def test_partial_hand_picker_does_NOT_carry_locked_class(self):
        # Hand is mid-progress; locked class only applies on completion.
        import re
        response = self.client.get(reverse("my_sea"))
        html = response.content.decode()
        m = re.search(r'<div class="my-sea-picker([^"]*)"', html)
        self.assertIsNotNone(m)
        self.assertNotIn("my-sea-picker--locked", m.group(1))


class MySeaGateViewTest(TestCase):
    """Sprint 6 iter 6a — `my_sea_gate` renders the solo gatekeeper UI.
    Replaces the iter-4c 404 stub. Branches on whether a deposit is
    already reserved on the user's MySeaDraw row."""

    def setUp(self):
        from apps.epic.models import personal_sig_cards
        from datetime import timedelta
        from apps.lyric.models import Token
        self.user = User.objects.create(email="gate@test.io")
        self.client.force_login(self.user)
        self.target = personal_sig_cards(self.user)[0]
        self.user.significator = self.target
        self.user.save(update_fields=["significator"])
        # Seed a FREE token so deposit attempts have something to pick.
        Token.objects.create(
            user=self.user, token_type=Token.FREE,
            expires_at=timezone.now() + timedelta(days=30),
        )

    def test_gate_view_requires_login(self):
        self.client.logout()
        response = self.client.get(reverse("my_sea_gate"))
        self.assertEqual(response.status_code, 302)

    def test_gate_view_renders_200(self):
        response = self.client.get(reverse("my_sea_gate"))
        self.assertEqual(response.status_code, 200)
        self.assertTemplateUsed(response, "apps/gameboard/my_sea_gate.html")

    def test_gate_view_shows_insert_token_form_when_no_deposit(self):
        from apps.gameboard.models import MySeaDraw
        MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            significator_id=self.target.id, hand=[],
        )
        response = self.client.get(reverse("my_sea_gate"))
        self.assertContains(response, reverse("my_sea_insert_token"))
        self.assertNotContains(response, "id_my_sea_paid_draw_btn")

    def test_gate_view_shows_paid_draw_btn_when_deposit_reserved(self):
        from apps.gameboard.models import MySeaDraw
        from apps.lyric.models import Token
        free_tok = Token.objects.filter(user=self.user, token_type=Token.FREE).first()
        MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            significator_id=self.target.id, hand=[],
            deposit_token_id=free_tok.pk,
            deposit_reserved_at=timezone.now(),
        )
        response = self.client.get(reverse("my_sea_gate"))
        self.assertContains(response, "id_my_sea_paid_draw_btn")
        self.assertContains(response, reverse("my_sea_refund_token"))


class MySeaInsertTokenViewTest(TestCase):
    """Sprint 6 iter 6a — POST `/gameboard/my-sea/insert` reserves the
    user's next-priority token on their MySeaDraw row (creates the row
    if missing). Idempotent w.r.t. an already-reserved deposit."""

    def setUp(self):
        from apps.epic.models import personal_sig_cards
        self.user = User.objects.create(email="insert@test.io")
        # Wipe auto-tokens from User post_save signal (COIN + FREE).
        self.user.tokens.all().delete()
        self.client.force_login(self.user)
        self.target = personal_sig_cards(self.user)[0]
        self.user.significator = self.target
        self.user.save(update_fields=["significator"])
        self.free_tok = Token.objects.create(
            user=self.user, token_type=Token.FREE,
            expires_at=timezone.now() + timedelta(days=30),
        )
        self.url = reverse("my_sea_insert_token")

    def test_insert_get_returns_405(self):
        self.assertEqual(self.client.get(self.url).status_code, 405)

    def test_insert_creates_row_and_reserves_token(self):
        from apps.gameboard.models import MySeaDraw
        self.client.post(self.url)
        draw = MySeaDraw.objects.get(user=self.user)
        self.assertEqual(draw.deposit_token_id, self.free_tok.pk)
        self.assertIsNotNone(draw.deposit_reserved_at)

    def test_insert_uses_existing_row_when_one_exists(self):
        from apps.gameboard.models import MySeaDraw
        draw = MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            significator_id=self.target.id, hand=[],
        )
        self.client.post(self.url)
        draw.refresh_from_db()
        self.assertEqual(draw.deposit_token_id, self.free_tok.pk)
        self.assertEqual(MySeaDraw.objects.filter(user=self.user).count(), 1)

    def test_insert_idempotent_when_deposit_already_reserved(self):
        from apps.gameboard.models import MySeaDraw
        from apps.lyric.models import Token
        other_tok = Token.objects.create(
            user=self.user, token_type=Token.FREE,
            expires_at=timezone.now() + timedelta(days=30),
        )
        draw = MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            significator_id=self.target.id, hand=[],
            deposit_token_id=other_tok.pk,
            deposit_reserved_at=timezone.now(),
        )
        self.client.post(self.url)
        draw.refresh_from_db()
        # Still pointed at the original token; no double-reserve.
        self.assertEqual(draw.deposit_token_id, other_tok.pk)


class MySeaRefundTokenViewTest(TestCase):
    """Sprint 6 iter 6a — POST `/gameboard/my-sea/refund` clears the
    deposit fields. Token wasn't actually consumed at INSERT (refund-
    aware design), so no inventory side effects."""

    def setUp(self):
        from apps.epic.models import personal_sig_cards
        from datetime import timedelta
        from apps.gameboard.models import MySeaDraw
        from apps.lyric.models import Token
        self.user = User.objects.create(email="refund@test.io")
        self.client.force_login(self.user)
        self.target = personal_sig_cards(self.user)[0]
        self.user.significator = self.target
        self.user.save(update_fields=["significator"])
        self.tok = Token.objects.create(
            user=self.user, token_type=Token.FREE,
            expires_at=timezone.now() + timedelta(days=30),
        )
        self.draw = MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            significator_id=self.target.id, hand=[],
            deposit_token_id=self.tok.pk,
            deposit_reserved_at=timezone.now(),
        )
        self.url = reverse("my_sea_refund_token")

    def test_refund_clears_deposit_fields(self):
        self.client.post(self.url)
        self.draw.refresh_from_db()
        self.assertIsNone(self.draw.deposit_token_id)
        self.assertIsNone(self.draw.deposit_reserved_at)

    def test_refund_does_not_consume_token(self):
        from apps.lyric.models import Token
        self.client.post(self.url)
        self.assertTrue(Token.objects.filter(pk=self.tok.pk).exists())

    def test_refund_idempotent_when_no_deposit(self):
        from apps.gameboard.models import MySeaDraw
        MySeaDraw.objects.all().delete()
        response = self.client.post(self.url)
        self.assertIn(response.status_code, (200, 204, 302))


class MySeaPaidDrawViewTest(TestCase):
    """Sprint 6 iter 6a — POST `/gameboard/my-sea/paid-draw` commits the
    deposited token + resets the row for a fresh 24h quota cycle. Per-
    token-type debit rules apply (FREE/TITHE consumed, COIN cooldown,
    PASS no-op, CARTE not reachable via `_select_my_sea_token`)."""

    def setUp(self):
        from apps.epic.models import personal_sig_cards
        from datetime import timedelta
        from apps.gameboard.models import MySeaDraw
        from apps.lyric.models import Token
        self.user = User.objects.create(email="paid@test.io")
        self.client.force_login(self.user)
        self.target = personal_sig_cards(self.user)[0]
        self.user.significator = self.target
        self.user.save(update_fields=["significator"])
        self.free_tok = Token.objects.create(
            user=self.user, token_type=Token.FREE,
            expires_at=timezone.now() + timedelta(days=30),
        )
        self.draw = MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            significator_id=self.target.id, hand=[],
            deposit_token_id=self.free_tok.pk,
            deposit_reserved_at=timezone.now(),
        )
        self.url = reverse("my_sea_paid_draw")

    def test_paid_draw_consumes_free_token(self):
        from apps.lyric.models import Token
        self.client.post(self.url)
        self.assertFalse(Token.objects.filter(pk=self.free_tok.pk).exists())

    def test_paid_draw_preserves_row_and_sets_paid_through_at(self):
        # User-spec 2026-05-23 (replaces the 2026-05-20 "delete row"
        # spec): PAID DRAW preserves the row + sets `paid_through_at`
        # so the landing PAID DRAW button stays visible across navigation
        # cycles. Without this, the user who pays but doesn't immediately
        # draw cards sees the button revert to FREE DRAW on next page
        # load (the reported regression). `deposit_token_id` /
        # `deposit_reserved_at` clear (token spent, no longer reserved);
        # `hand` clears (fresh start per user-confirmed semantics).
        from apps.gameboard.models import MySeaDraw
        before = timezone.now()
        self.client.post(self.url)
        self.draw.refresh_from_db()
        self.assertTrue(MySeaDraw.objects.filter(pk=self.draw.pk).exists(),
            "PAID DRAW must preserve the row (was previously deleted)")
        self.assertIsNone(self.draw.deposit_token_id)
        self.assertIsNone(self.draw.deposit_reserved_at)
        self.assertIsNotNone(self.draw.paid_through_at,
            "PAID DRAW must stamp paid_through_at on commit")
        self.assertGreaterEqual(self.draw.paid_through_at, before)
        self.assertEqual(self.draw.hand, [],
            "PAID DRAW must clear the hand (fresh paid session)")

    def test_paid_draw_redirects_to_my_sea_with_phase_picker(self):
        # User-spec 2026-05-20: drop the user directly into the picker
        # after PAID DRAW (no intermediate FREE-DRAW click). Encoded via
        # `?phase=picker` query param so the my_sea view can short-
        # circuit `show_picker` even when active_draw is now None.
        response = self.client.post(self.url)
        self.assertEqual(response.status_code, 302)
        self.assertIn("phase=picker", response["Location"])

    def test_paid_draw_with_coin_sets_24h_cooldown_and_unequips(self):
        from datetime import timedelta
        from apps.lyric.models import Token
        coin = Token.objects.create(user=self.user, token_type=Token.COIN)
        self.user.equipped_trinket = coin
        self.user.save(update_fields=["equipped_trinket"])
        self.draw.deposit_token_id = coin.pk
        self.draw.save(update_fields=["deposit_token_id"])
        before = timezone.now()
        self.client.post(self.url)
        coin.refresh_from_db()
        self.assertTrue(coin.next_ready_at >= before + timedelta(hours=23, minutes=58))
        self.assertTrue(coin.next_ready_at <= before + timedelta(hours=24, minutes=2))
        self.user.refresh_from_db()
        self.assertIsNone(self.user.equipped_trinket_id)

    def test_paid_draw_with_pass_does_not_consume(self):
        from apps.lyric.models import Token
        self.user.is_staff = True
        self.user.save(update_fields=["is_staff"])
        pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS)
        self.draw.deposit_token_id = pass_tok.pk
        self.draw.save(update_fields=["deposit_token_id"])
        self.client.post(self.url)
        self.assertTrue(Token.objects.filter(pk=pass_tok.pk).exists())

    def test_paid_draw_no_deposit_redirects_to_my_sea(self):
        self.draw.deposit_token_id = None
        self.draw.save(update_fields=["deposit_token_id"])
        response = self.client.post(self.url)
        self.assertEqual(response.status_code, 302)


class MySeaCooldownAnchoredToFreeDrawTest(TestCase):
    """User-spec 2026-05-23: the 24h free-draw cooldown is anchored to the
    user's last TOKENLESS first-card-draw (`User.last_free_draw_at`), NOT
    to any subsequent paid draws. A paid draw in the middle of the cycle
    must NOT push the cooldown forward — the next FREE DRAW unlocks at
    free-draw + 24h regardless of any interim paid activity.

    Also pins the "sticky PAID DRAW button" UX: after PAID DRAW commits,
    the row carries a `paid_through_at` credit until the first card of
    the paid session lands. During that window, any navigation back to
    /gameboard/my-sea/ keeps the landing button labelled PAID DRAW (it
    used to revert to FREE DRAW because the row was deleted at commit
    time — the user-reported bug)."""

    def setUp(self):
        from apps.epic.models import personal_sig_cards
        from apps.lyric.models import Token
        self.user = User.objects.create(email="anchor@test.io")
        self.user.tokens.all().delete()
        self.client.force_login(self.user)
        self.target = personal_sig_cards(self.user)[0]
        self.user.significator = self.target
        self.user.save(update_fields=["significator"])
        # FREE token for the paid-draw step.
        self.free_tok = Token.objects.create(
            user=self.user, token_type=Token.FREE,
            expires_at=timezone.now() + timedelta(days=30),
        )

    def _seed_used_free_draw(self, when=None):
        """Simulate a completed FREE DRAW + DEL: row exists w. hand=[],
        no deposit, `User.last_free_draw_at` anchored at `when`."""
        from apps.gameboard.models import MySeaDraw
        when = when or timezone.now()
        self.user.last_free_draw_at = when
        self.user.save(update_fields=["last_free_draw_at"])
        return MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            significator_id=self.target.id, hand=[],
            created_at=when,
        )

    def test_paid_draw_does_not_reset_user_last_free_draw_at(self):
        original_anchor = timezone.now() - timedelta(hours=6)
        draw = self._seed_used_free_draw(when=original_anchor)
        # Deposit + commit a token via the PAID DRAW endpoint.
        draw.deposit_token_id = self.free_tok.pk
        draw.deposit_reserved_at = timezone.now()
        draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
        self.client.post(reverse("my_sea_paid_draw"))
        self.user.refresh_from_db()
        # User.last_free_draw_at stays at the original anchor
        # (within 1s tolerance for timestamp wobble).
        delta = abs(
            (self.user.last_free_draw_at - original_anchor).total_seconds()
        )
        self.assertLess(delta, 1.0,
            "PAID DRAW must NOT touch User.last_free_draw_at — the "
            "cooldown stays anchored to the original FREE DRAW per "
            "user-spec 2026-05-23")

    def test_brief_next_free_draw_at_uses_user_anchor_not_paid_row(self):
        # The view passes `next_free_draw_at` to the template as ISO
        # — the Brief script in my_sea.html surfaces this directly.
        # Anchor: user's last_free_draw_at + 24h, NOT row.created_at
        # + 24h (which after PAID DRAW would point 24h past the paid
        # commit, not 24h past the free draw).
        original_anchor = timezone.now() - timedelta(hours=6)
        self._seed_used_free_draw(when=original_anchor)
        # The active row's `created_at` matches the seed time (free
        # draw moment). Now simulate a "row created LATER than anchor"
        # state by bumping created_at forward — this is what would
        # happen if the row were re-created at PAID DRAW time under
        # the old buggy delete-on-commit semantics.
        from apps.gameboard.models import MySeaDraw
        row = MySeaDraw.objects.get(user=self.user)
        row.created_at = timezone.now()  # "PAID DRAW just created me"
        row.save(update_fields=["created_at"])
        response = self.client.get(reverse("my_sea"))
        # The user's next_free_draw_at = anchor + 24h, NOT row.created_at
        # + 24h. Differs by ~6h; check that the rendered ISO matches the
        # user-level anchor (truncated to date+hour for stability).
        expected_user_iso = (
            original_anchor + timedelta(hours=24)
        ).isoformat()[:13]  # "YYYY-MM-DDTHH" — date + hour
        self.assertIn(expected_user_iso, response.content.decode())

    def test_paid_draw_commit_makes_landing_show_paid_draw_btn(self):
        # End-to-end of the user-reported bug: deposit → PAID DRAW commit
        # → navigate to /gameboard/my-sea/ → landing must show PAID DRAW
        # (NOT FREE DRAW, NOT GATE VIEW). Pre-fix: row was deleted at
        # commit time + landing fell through to FREE DRAW.
        from apps.gameboard.models import MySeaDraw
        draw = self._seed_used_free_draw()
        draw.deposit_token_id = self.free_tok.pk
        draw.deposit_reserved_at = timezone.now()
        draw.save(update_fields=["deposit_token_id", "deposit_reserved_at"])
        # Commit the paid draw.
        self.client.post(reverse("my_sea_paid_draw"))
        # Simulate user navigating away + back: re-render my_sea.
        response = self.client.get(reverse("my_sea"))
        self.assertContains(response, 'id="id_my_sea_paid_draw_btn"',
            msg_prefix="post-PAID-DRAW navigation must keep PAID DRAW btn")
        self.assertNotContains(response, 'id="id_draw_sea_btn"',
            msg_prefix="FREE DRAW btn must NOT show after PAID DRAW commit")
        self.assertNotContains(response, 'id="id_my_sea_gate_view_btn"',
            msg_prefix="GATE VIEW btn must NOT show while paid-through is set")

    def test_paid_draw_btn_post_with_paid_through_redirects_to_picker(self):
        # After commit, the PAID DRAW button on the landing should
        # route the user back to the picker (via ?phase=picker) without
        # consuming another token.
        from apps.gameboard.models import MySeaDraw
        draw = self._seed_used_free_draw()
        draw.paid_through_at = timezone.now()
        draw.save(update_fields=["paid_through_at"])
        free_count_before = self.user.tokens.filter(
            token_type=self.free_tok.token_type
        ).count()
        response = self.client.post(reverse("my_sea_paid_draw"))
        self.assertEqual(response.status_code, 302)
        self.assertIn("phase=picker", response["Location"])
        # No token consumed — the paid-through credit covers this.
        self.assertEqual(
            self.user.tokens.filter(
                token_type=self.free_tok.token_type
            ).count(),
            free_count_before,
        )

    def test_first_card_after_paid_draw_consumes_paid_through_credit(self):
        # User-spec 2026-05-23 follow-up: paid-through is one-shot. Once
        # the user draws their first card of the paid session, the
        # credit is consumed → next redraw needs a fresh deposit.
        import json
        from apps.gameboard.models import MySeaDraw
        from apps.epic.models import TarotCard
        draw = self._seed_used_free_draw()
        draw.paid_through_at = timezone.now()
        draw.save(update_fields=["paid_through_at"])
        card = TarotCard.objects.exclude(id=self.target.id).first()
        self.client.post(
            reverse("my_sea_lock"),
            data=json.dumps({
                "spread": "situation-action-outcome",
                "hand": [{
                    "position": "lay", "card_id": card.id,
                    "reversed": False, "polarity": "gravity",
                }],
            }),
            content_type="application/json",
        )
        draw.refresh_from_db()
        self.assertIsNone(draw.paid_through_at,
            "first card of paid session consumes the paid-through credit")


class UserFreeDrawCooldownPropertyTest(TestCase):
    """`User.free_draw_cooldown_active` + `User.next_free_draw_at`
    helpers. The cooldown is sticky from `last_free_draw_at` (set on the
    user's last tokenless first-card-draw) for FREE_DRAW_COOLDOWN_HOURS."""

    def setUp(self):
        self.user = User.objects.create(email="cooldown@test.io")

    def test_no_last_free_draw_at_returns_false(self):
        self.assertIsNone(self.user.last_free_draw_at)
        self.assertFalse(self.user.free_draw_cooldown_active)
        self.assertIsNone(self.user.next_free_draw_at)

    def test_recent_last_free_draw_at_returns_true(self):
        self.user.last_free_draw_at = timezone.now() - timedelta(hours=6)
        self.user.save()
        self.assertTrue(self.user.free_draw_cooldown_active)

    def test_old_last_free_draw_at_returns_false(self):
        self.user.last_free_draw_at = timezone.now() - timedelta(hours=25)
        self.user.save()
        self.assertFalse(self.user.free_draw_cooldown_active)

    def test_next_free_draw_at_is_last_plus_24h(self):
        anchor = timezone.now() - timedelta(hours=6)
        self.user.last_free_draw_at = anchor
        self.user.save()
        self.assertEqual(
            self.user.next_free_draw_at,
            anchor + timedelta(hours=24),
        )


class MySeaPhasePickerQueryParamTest(TestCase):
    """`?phase=picker` query param forces picker phase when the user is
    in a paid cycle (post-PAID-DRAW commit, hand still empty). Updated
    2026-05-23 — previously the param worked w. no active row (the old
    "delete row at PAID DRAW" semantics). Under the new "preserve row +
    set paid_through_at" semantics, the row is present + paid_through_at
    is set; the picker shows via the param + the paid-through state."""

    def setUp(self):
        from apps.epic.models import personal_sig_cards
        self.user = User.objects.create(email="phase@test.io")
        self.client.force_login(self.user)
        self.target = personal_sig_cards(self.user)[0]
        self.user.significator = self.target
        self.user.save(update_fields=["significator"])

    def test_no_param_lands_on_free_draw(self):
        response = self.client.get(reverse("my_sea"))
        self.assertContains(response, 'data-phase="landing"')
        self.assertContains(response, 'id="id_draw_sea_btn"')

    def test_phase_picker_param_forces_picker_when_paid_through(self):
        # Simulate the just-after-PAID-DRAW state: row exists w. hand=[]
        # + paid_through_at set. The ?phase=picker param drops the user
        # into the picker rather than the landing.
        from apps.gameboard.models import MySeaDraw
        MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            significator_id=self.target.id, hand=[],
            paid_through_at=timezone.now(),
        )
        response = self.client.get(reverse("my_sea") + "?phase=picker")
        self.assertContains(response, 'data-phase="picker"')
        self.assertNotContains(response, 'id="id_sea_overlay"' + ' style="display:none"')

    def test_phase_picker_param_ignored_when_active_draw_with_empty_hand(self):
        # Post-DEL state: active row w. empty hand + NO paid-through
        # credit → the user still needs to gatekeeper. Landing wins;
        # GATE VIEW button shown.
        from apps.gameboard.models import MySeaDraw
        MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            significator_id=self.target.id, hand=[],
        )
        response = self.client.get(reverse("my_sea") + "?phase=picker")
        self.assertContains(response, 'data-phase="landing"')
        self.assertContains(response, 'id="id_my_sea_gate_view_btn"')

    def test_paid_through_with_empty_hand_renders_paid_draw_btn_not_free(self):
        """User-reported bug 2026-05-23 — after PAID DRAW commit, if the
        user navigates away without drawing, the landing button must
        stay as PAID DRAW (not revert to FREE DRAW). Preserved-row +
        `paid_through_at` is the regression-pinning state."""
        from apps.gameboard.models import MySeaDraw
        MySeaDraw.objects.create(
            user=self.user, spread="situation-action-outcome",
            significator_id=self.target.id, hand=[],
            paid_through_at=timezone.now(),
        )
        response = self.client.get(reverse("my_sea"))
        self.assertContains(response, 'data-phase="landing"')
        self.assertContains(response, 'id="id_my_sea_paid_draw_btn"')
        self.assertNotContains(response, 'id="id_draw_sea_btn"')
        self.assertNotContains(response, 'id="id_my_sea_gate_view_btn"')


class SelectMySeaTokenTest(TestCase):
    """Sprint 6 iter 6a — `_select_my_sea_token` priority chain w. CARTE
    excluded + COIN cooldown-respecting."""

    def setUp(self):
        self.user = User.objects.create(email="selecttok@test.io")
        # New-user post_save signal auto-creates COIN + FREE tokens
        # (`apps.lyric.models`). Wipe them so each test only sees the
        # tokens it explicitly seeds. Refresh so equipped_trinket_id
        # picks up the cascade SET_NULL (otherwise the Python object
        # stays stale + select_token's defensive fresh-DB-query finds
        # an SQLite-reused-pk Token that "happens" to match).
        self.user.tokens.all().delete()
        self.user.refresh_from_db()

    def test_carte_is_excluded(self):
        from apps.gameboard.models import _select_my_sea_token
        Token.objects.create(user=self.user, token_type=Token.CARTE)
        self.assertIsNone(_select_my_sea_token(self.user))

    def test_cooldown_coin_is_excluded(self):
        from apps.gameboard.models import _select_my_sea_token
        Token.objects.create(
            user=self.user, token_type=Token.COIN,
            next_ready_at=timezone.now() + timedelta(hours=12),
        )
        self.assertIsNone(_select_my_sea_token(self.user))

    def test_pass_wins_priority_for_staff(self):
        """PASS must be EQUIPPED to be picked — DON-ing it is the user's
        opt-in to trinket use. Owned-but-DOFFed PASS stays invisible to
        the picker (parity w. all other trinkets under the equip-gated
        semantics — see [[feedback-equip-slot-gates-trinket-use]])."""
        from apps.lyric.models import Token
        from apps.gameboard.models import _select_my_sea_token
        self.user.is_staff = True
        self.user.save(update_fields=["is_staff"])
        pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS)
        Token.objects.create(user=self.user, token_type=Token.COIN)
        self.user.equipped_trinket = pass_tok
        self.user.save(update_fields=["equipped_trinket"])
        self.assertEqual(_select_my_sea_token(self.user), pass_tok)


class SelectMySeaTokenEquipGatedTest(TestCase):
    """My-sea variant of `SelectTokenEquipGatedTest`. Same equip-gated
    semantics: trinkets must be DON-ed to fire; CARTE never auto-picked;
    fall back to FREE → TITHE → None. PLUS my-sea's cooldown check on
    COIN (24h after debit_my_sea_token consumes one)."""

    def setUp(self):
        self.user = User.objects.create(email="myseaequip@test.io")
        self.user.tokens.all().delete()
        self.user.refresh_from_db()

    def test_skips_unequipped_coin_and_returns_free(self):
        from apps.lyric.models import Token
        from apps.gameboard.models import _select_my_sea_token
        Token.objects.create(user=self.user, token_type=Token.COIN)
        free = Token.objects.create(
            user=self.user, token_type=Token.FREE,
            expires_at=timezone.now() + timedelta(days=7),
        )
        self.assertEqual(_select_my_sea_token(self.user).pk, free.pk)

    def test_skips_unequipped_band_and_returns_tithe(self):
        from apps.lyric.models import Token
        from apps.gameboard.models import _select_my_sea_token
        Token.objects.create(user=self.user, token_type=Token.BAND)
        tithe = Token.objects.create(user=self.user, token_type=Token.TITHE)
        self.assertEqual(_select_my_sea_token(self.user).pk, tithe.pk)

    def test_returns_none_when_no_equip_and_no_consumables(self):
        from apps.lyric.models import Token
        from apps.gameboard.models import _select_my_sea_token
        Token.objects.create(user=self.user, token_type=Token.COIN)
        self.assertIsNone(_select_my_sea_token(self.user))

    def test_equipped_band_returns_band(self):
        from apps.lyric.models import Token
        from apps.gameboard.models import _select_my_sea_token
        band = Token.objects.create(user=self.user, token_type=Token.BAND)
        self.user.equipped_trinket = band
        self.user.save(update_fields=["equipped_trinket"])
        self.assertEqual(_select_my_sea_token(self.user).pk, band.pk)

    def test_equipped_cooldown_coin_falls_through_to_free(self):
        """Equipped COIN on 24h cooldown shouldn't occur (debit_my_sea_
        token auto-unequips), but defensive: if it does, the picker
        should respect the cooldown + fall through, not return a stale
        COIN that debit_my_sea_token would refuse."""
        from apps.lyric.models import Token
        from apps.gameboard.models import _select_my_sea_token
        coin = Token.objects.create(
            user=self.user, token_type=Token.COIN,
            next_ready_at=timezone.now() + timedelta(hours=12),
        )
        free = Token.objects.create(
            user=self.user, token_type=Token.FREE,
            expires_at=timezone.now() + timedelta(days=7),
        )
        self.user.equipped_trinket = coin
        self.user.save(update_fields=["equipped_trinket"])
        self.assertEqual(_select_my_sea_token(self.user).pk, free.pk)

    def test_carte_equipped_falls_through_to_free(self):
        """CARTE is fully excluded from my-sea (debit_my_sea_token raises
        ValueError for CARTE). Even equipped, it's never auto-picked here."""
        from apps.lyric.models import Token
        from apps.gameboard.models import _select_my_sea_token
        carte = Token.objects.create(user=self.user, token_type=Token.CARTE)
        free = Token.objects.create(
            user=self.user, token_type=Token.FREE,
            expires_at=timezone.now() + timedelta(days=7),
        )
        self.user.equipped_trinket = carte
        self.user.save(update_fields=["equipped_trinket"])
        self.assertEqual(_select_my_sea_token(self.user).pk, free.pk)


class DebitMySeaTokenTest(TestCase):
    """Sprint 6 iter 6a — `debit_my_sea_token` per-type semantics."""

    def setUp(self):
        self.user = User.objects.create(email="debittok@test.io")

    def test_carte_raises_value_error(self):
        from apps.lyric.models import Token
        from apps.gameboard.models import debit_my_sea_token
        carte = Token.objects.create(user=self.user, token_type=Token.CARTE)
        with self.assertRaises(ValueError):
            debit_my_sea_token(self.user, carte)

    def test_free_token_is_consumed(self):
        from datetime import timedelta
        from apps.lyric.models import Token
        from apps.gameboard.models import debit_my_sea_token
        free = Token.objects.create(
            user=self.user, token_type=Token.FREE,
            expires_at=timezone.now() + timedelta(days=30),
        )
        debit_my_sea_token(self.user, free)
        self.assertFalse(Token.objects.filter(pk=free.pk).exists())

    def test_tithe_token_is_consumed(self):
        from apps.lyric.models import Token
        from apps.gameboard.models import debit_my_sea_token
        tithe = Token.objects.create(user=self.user, token_type=Token.TITHE)
        debit_my_sea_token(self.user, tithe)
        self.assertFalse(Token.objects.filter(pk=tithe.pk).exists())

    def test_pass_token_is_not_consumed(self):
        from apps.lyric.models import Token
        from apps.gameboard.models import debit_my_sea_token
        self.user.is_staff = True
        self.user.save(update_fields=["is_staff"])
        pass_tok = Token.objects.create(user=self.user, token_type=Token.PASS)
        debit_my_sea_token(self.user, pass_tok)
        self.assertTrue(Token.objects.filter(pk=pass_tok.pk).exists())
