"""FTs for the My Sea standalone page sign-gate.

Sprint 4b of [[project-my-sea-roadmap]]. The /gameboard/my-sea/ page is
gated behind sig selection — when `user.significator` is None, render a
Look!-formatted Brief-style line w. FYI (→ /billboard/my-sign/) + NVM
(→ /gameboard/) instead of the draw UX. The My Sea applet on /gameboard/
mirrors the gate hint in its empty-state slot.
"""
from datetime import timedelta

from django.utils import timezone
from selenium.webdriver.common.by import By

from .base import FunctionalTest
from .sig_page import _assign_sig, _seed_earthman_sig_pile
from apps.applets.models import Applet
from apps.epic.models import personal_sig_cards
from apps.lyric.models import User


def _count_filled_slots(picker):
    """Count `.sea-card-slot--filled` elements inside a picker container.
    Module-level so multiple test classes can use it without inheriting."""
    return len(
        picker.find_elements(By.CSS_SELECTOR, ".sea-card-slot--filled")
    )


def _open_spread_modal(test):
    """Open #id_sea_spread_modal so subsequent interactions w. in-modal
    selectors (`.sea-select*`, `#id_sea_action_btn`, `#id_sea_del`) reach
    a visible target. Sprint 2026-05-26 wrapped the sea-form-col in a
    modal triggered by the burger fan's Sea sub-btn; tests that touched
    those selectors directly now have to open the modal first.

    Module-level so MySeaSpreadFormTest + MySeaCardDrawTest +
    MySeaLockHandTest can all call it w/o duplicating the helper.

    JS .click() is used (not Selenium .click) because the fan sub-btns
    spend ~0.25s mid-animation stacked at the burger centre — Selenium
    would hit a click-intercept by whichever sub-btn is z-topmost during
    the transform. execute_script bypasses the visual hit-test."""
    burger_btn = test.browser.find_element(By.ID, "id_burger_btn")
    test.browser.execute_script("arguments[0].click()", burger_btn)
    sea_btn = test.wait_for(
        lambda: test.browser.find_element(By.ID, "id_sea_btn")
    )
    test.browser.execute_script("arguments[0].click()", sea_btn)
    test.wait_for(
        lambda: test.assertIsNone(
            test.browser.find_element(By.ID, "id_sea_spread_modal")
                .get_attribute("hidden")
        )
    )


def _seed_gameboard_applets():
    """My Sea + the rest of the gameboard applets so /gameboard/ renders
    without missing-applet errors during the applet-side assertions.
    Mirrors the migration seed (0003 + 0008) — every slug must have a
    matching _applet-<slug>.html partial under apps/gameboard/_partials/."""
    for slug, name, cols, rows, ctx in [
        ("my-sea", "My Sea", 12, 4, "gameboard"),
        ("game-kit", "Game Kit", 4, 3, "gameboard"),
        ("new-game", "New Game", 4, 3, "gameboard"),
        ("my-games", "My Games", 4, 4, "gameboard"),
    ]:
        Applet.objects.get_or_create(
            slug=slug,
            defaults={"name": name, "context": ctx,
                      "default_visible": True, "grid_cols": cols, "grid_rows": rows},
        )


class MySeaSignGateTest(FunctionalTest):
    """Sign-gate UX on the standalone /gameboard/my-sea/ page + the
    /gameboard/ My Sea applet. User without a saved sig sees a Look!-
    formatted nudge w. FYI to the picker + NVM to the gameboard."""

    def setUp(self):
        super().setUp()
        _seed_earthman_sig_pile()
        _seed_gameboard_applets()
        self.email = "sea@test.io"
        self.gamer = User.objects.create(email=self.email)
        sig_pile = personal_sig_cards(self.gamer)
        self.target_card = sig_pile[0] if sig_pile else None
        self.assertIsNotNone(
            self.target_card,
            "personal_sig_cards(user) returned no cards — check Earthman seed",
        )

    # ── Test 1 ───────────────────────────────────────────────────────────────

    def test_no_sig_renders_lookline_gate_on_standalone_page(self):
        """User without significator → /gameboard/my-sea/ fires the Look!-
        formatted sign-gate Brief banner w. FYI + NVM controls (refactored
        2026-05-22 from inline `.my-sea-sign-gate` div to a `.note-banner`
        portaled via `Brief.showBanner`)."""
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        banner = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".note-banner.my-sea-sign-gate-brief"
            )
        )
        text = banner.text
        self.assertIn("Look!", text)
        self.assertIn("pick your sign", text.lower())
        self.assertIn("drawing the Sea", text)
        # FYI + NVM action buttons live inside the Brief shell (built-in).
        fyi = banner.find_element(By.CSS_SELECTOR, ".note-banner__fyi")
        self.assertTrue(fyi.is_displayed())
        nvm = banner.find_element(By.CSS_SELECTOR, ".note-banner__nvm")
        self.assertTrue(nvm.is_displayed())

    # ── Test 2 ───────────────────────────────────────────────────────────────

    def test_gate_fyi_links_to_my_sign_picker(self):
        """FYI button on the Brief is an `<a href>` pointing at /billboard/
        my-sign/. Carries the standard `.note-banner__fyi` class."""
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        fyi = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-sign-gate-brief .note-banner__fyi"
            )
        )
        href = fyi.get_attribute("href") or ""
        self.assertTrue(
            href.endswith("/billboard/my-sign/"),
            f"FYI should link to /billboard/my-sign/, got {href!r}",
        )

    # ── Test 3 ───────────────────────────────────────────────────────────────

    def test_gate_nvm_dismisses_brief(self):
        """NVM button on the Brief dismisses the banner (built into
        `Brief.showBanner`'s nvm handler — `banner.remove()`)."""
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        nvm = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-sign-gate-brief .note-banner__nvm"
            )
        )
        nvm.click()
        self.wait_for(
            lambda: self.assertEqual(
                len(self.browser.find_elements(
                    By.CSS_SELECTOR, ".my-sea-sign-gate-brief"
                )),
                0,
                "NVM should remove the gate Brief from the DOM",
            )
        )

    # ── Test 4 ───────────────────────────────────────────────────────────────

    def test_with_sig_skips_gate_and_renders_draw_shell(self):
        """User w. saved significator → no sign-gate Brief on the page;
        draw shell renders normally."""
        _assign_sig(self.gamer, self.target_card)
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, ".my-sea-page")
        )
        self.assertEqual(
            len(self.browser.find_elements(
                By.CSS_SELECTOR, ".my-sea-sign-gate-brief")),
            0,
            "Gate Brief should not render when user has a saved significator",
        )

    # ── Test 5 ───────────────────────────────────────────────────────────────

    def test_no_sig_applet_mirrors_gate_with_fyi_link(self):
        """On /gameboard/, the My Sea applet fires the same sign-gate
        Brief w. FYI link to /billboard/my-sign/ when the user has no
        significator. Provides a consistent UX across surfaces."""
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/")
        banner = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".note-banner.my-sea-sign-gate-brief"
            )
        )
        self.assertIn("Look!", banner.text)
        fyi = banner.find_element(By.CSS_SELECTOR, ".note-banner__fyi")
        href = fyi.get_attribute("href") or ""
        self.assertTrue(href.endswith("/billboard/my-sign/"))

    # ── Test 6 ───────────────────────────────────────────────────────────────

    def test_with_sig_applet_renders_default_empty_state(self):
        """Applet w. saved sig → no gate Brief, empty-state placeholder
        ("No draws yet.") inside the applet body."""
        _assign_sig(self.gamer, self.target_card)
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/")
        self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_applet_my_sea")
        )
        self.assertEqual(
            len(self.browser.find_elements(
                By.CSS_SELECTOR, ".my-sea-sign-gate-brief"
            )),
            0,
        )


class MySeaDrawSeaLandingTest(FunctionalTest):
    """Sprint 5 iter 1 — FREE DRAW landing on /gameboard/my-sea/ for a
    user w. a saved sig (past the [[sprint-my-sea-sign-gate-may19]] gate).

    Landing renders a DRY table hex (parameterized from the room) w. 6
    chair seats labeled 1C-6C (placeholders for the eventual friend-
    invite feature per [[project-my-sea-roadmap]] architectural anchor
    "Six chairs retained even in solo") + a central FREE DRAW `.btn-
    primary` mirroring SCAN SIGN on /billboard/my-sign/. Each chair seat
    renders w. a red `.fa-ban` status icon (empty slot).

    Click flow: FREE DRAW → seat 1C transitions to `.seated` state
    (chair `--terUser` + drop-shadow glow + `.fa-ban` swap to `.fa-
    circle-check` green) → after a brief delay so the user sees the
    animation, `data-phase` swaps to `picker` (picker content lands in
    iter 2). The 'C' = "Chair" (user-locked vocabulary); no role
    semantics in this solo flow.

    "FREE DRAW" is the label for the 1/24h free quota draw — a future
    sprint will conditionally swap the label to "DRAW SEA" once the
    free has been used, w. the DRAW SEA btn calling the room
    gatekeeper partial for token-deposit.

    The same Brief "Default deck warning" copy from my-sign fires when
    the user has no equipped deck."""

    def setUp(self):
        super().setUp()
        _seed_earthman_sig_pile()
        _seed_gameboard_applets()
        self.email = "draw@test.io"
        self.gamer = User.objects.create(email=self.email)
        # Assign a sig so the page passes the Sprint 4b gate + lands on
        # the new DRAW SEA UX rather than the Look!-line gate.
        self.target_card = _assign_sig(self.gamer)

    # ── Test 1 ───────────────────────────────────────────────────────────────

    def test_landing_renders_hex_with_free_draw_btn(self):
        """User w. sig → /gameboard/my-sea/ shows the DRY table hex (re-
        used from my-sign / the room shell) w. a central FREE DRAW btn.
        Element ID `id_draw_sea_btn` describes intent (the draw entry
        point); the hex-center btn is a 3-way state machine:
        - FREE DRAW (`#id_draw_sea_btn`) — fresh user (no active row, no
          deposit). Pinned here.
        - PAID DRAW (`#id_my_sea_paid_draw_btn`) — deposit reserved.
          Pinned by `MySeaLandingPaidDrawTest`.
        - GATE VIEW (`#id_my_sea_gate_view_btn`) — quota spent, no
          deposit (post-DEL). Pinned by
          `MySeaDeleteDrawAndGuardPortalTest` (the DEL-confirm flow).

        The three IDs are mutually exclusive — only one renders at a
        time. This test pins the fresh-user side of that exclusion
        (FREE DRAW present, the other two absent); the deposit + post-
        DEL sides have their own dedicated FTs that pin the same
        invariant from the other directions."""
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        # data-phase=landing on the page wrapper
        page = self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']")
        )
        # Hex shell present
        page.find_element(By.CSS_SELECTOR, ".room-shell .table-hex")
        # FREE DRAW btn in hex center
        btn = page.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
        self.assertTrue(btn.is_displayed())
        self.assertIn("FREE", btn.text.upper())
        self.assertIn("DRAW", btn.text.upper())
        self.assertIn("btn-primary", btn.get_attribute("class"))
        # Mutual exclusion: PAID DRAW + GATE VIEW must NOT also render.
        self.assertEqual(
            len(page.find_elements(By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn")),
            0,
            "PAID DRAW btn must not render when no deposit is reserved",
        )
        self.assertEqual(
            len(page.find_elements(By.CSS_SELECTOR, "#id_my_sea_gate_view_btn")),
            0,
            "GATE VIEW btn must not render when no active draw row exists",
        )

    # ── Test 2 ───────────────────────────────────────────────────────────────

    def test_landing_renders_six_chair_seats_labeled_1C_to_6C(self):
        """All 6 chair positions render w. labels 1C-6C (placeholder for
        friend-invite). CSS class `.table-seat` is preserved so the SCSS
        positioning rules (data-slot=N) carry over from the room shell.
        Each seat starts w. a red `.fa-ban` status icon (empty)."""
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        seats = self.wait_for(
            lambda: self._six_seats()
        )
        self.assertEqual(len(seats), 6)
        for n, seat in enumerate(seats, start=1):
            with self.subTest(slot=n):
                label = "".join(seat.text.upper().split())
                self.assertIn(f"{n}C", label)
                # Each seat carries the red ban status icon initially.
                seat.find_element(
                    By.CSS_SELECTOR, ".position-status-icon.fa-ban"
                )

    def _six_seats(self):
        seats = self.browser.find_elements(
            By.CSS_SELECTOR, ".my-sea-page[data-phase='landing'] .table-seat"
        )
        if len(seats) != 6:
            raise AssertionError(f"expected 6 seats, got {len(seats)}")
        return seats

    # ── Test 3 ───────────────────────────────────────────────────────────────

    def test_free_draw_click_seats_user_in_1C_then_swaps_phase(self):
        """Click FREE DRAW → seat 1C transitions to `.seated` w. fa-ban
        swapped for fa-circle-check (visible to the user during the
        ~800ms animation delay); other seats remain empty; then the
        page's data-phase swaps to 'picker' so iter 2's content can
        take over. Single-user instance for now → user always gets the
        lowest-numeral seat (1C)."""
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        btn = self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
        )
        btn.click()
        # Seat 1C goes seated + icon swaps. Other seats unchanged.
        seat1 = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".table-seat[data-slot='1'].seated"
            )
        )
        seat1.find_element(By.CSS_SELECTOR, ".position-status-icon.fa-circle-check")
        # Seats 2-6 retain the .fa-ban icon (still empty).
        for n in range(2, 7):
            with self.subTest(slot=n):
                other = self.browser.find_element(
                    By.CSS_SELECTOR, f".table-seat[data-slot='{n}']"
                )
                self.assertNotIn("seated", other.get_attribute("class"))
                other.find_element(By.CSS_SELECTOR, ".position-status-icon.fa-ban")
        # After the seat animation, data-phase swaps to picker + landing hides.
        self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
            )
        )
        landing = self.browser.find_element(By.CSS_SELECTOR, ".my-sea-landing")
        self.assertFalse(landing.is_displayed())

    # ── Test 4 ───────────────────────────────────────────────────────────────

    def test_brief_banner_renders_when_no_deck_equipped(self):
        """No equipped deck → the same 'Default deck warning' Brief
        banner from my-sign fires (lifted verbatim). Tagged w. a my-sea-
        specific class so FTs can disambiguate from any other Briefs."""
        self.gamer.equipped_deck = None
        self.gamer.save(update_fields=["equipped_deck"])
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        banner = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-intro-banner"
            )
        )
        self.assertIn("Default deck warning", banner.text)
        self.assertIn("no deck is equipped", banner.text)
        self.assertIn("Shabby Cardstock", banner.text)

    # ── Test 5 ───────────────────────────────────────────────────────────────

    def test_no_brief_banner_when_deck_equipped(self):
        """User w. an equipped deck → no Default-deck-warning Brief on
        landing. Auto-equip via the User post_save signal handles this
        for fresh users; assertion guards against accidental render of
        the banner when the condition shouldn't fire."""
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
        )
        self.assertEqual(
            len(self.browser.find_elements(By.CSS_SELECTOR, ".my-sea-intro-banner")),
            0,
        )


class MySeaPickerPhaseTest(FunctionalTest):
    """Sprint 5 iter 2 — picker phase content on /gameboard/my-sea/ after
    FREE DRAW click swaps `data-phase` to `picker`. Three-card spread:
    user's saved significator pinned in the center (`.sea-pos-core`) +
    three drawn-card positions surrounding it — cover (overlaid on sig),
    leave (left of center), loom (right of center). Crown / lay / cross
    from the gameroom's 6-position Celtic Cross are deliberately omitted
    (user-locked spec). Empty drop-zones are visible — actual card-draw
    wiring lands in iter 3 alongside the form col (spread dropdown /
    decks / LOCK HAND / DEL)."""

    def setUp(self):
        super().setUp()
        _seed_earthman_sig_pile()
        _seed_gameboard_applets()
        self.email = "picker@test.io"
        self.gamer = User.objects.create(email=self.email)
        self.target_card = _assign_sig(self.gamer)

    def _enter_picker_phase(self):
        """Common nav: load /gameboard/my-sea/, click FREE DRAW, wait for
        the page wrapper's data-phase to swap to `picker` (which happens
        ~800ms after click per the seat-1C animation delay in the inline
        JS)."""
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        btn = self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
        )
        btn.click()
        return self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
            )
        )

    # ── Test 1 ───────────────────────────────────────────────────────────────

    def test_picker_renders_significator_card_in_core_cell(self):
        """User's saved significator pins the `.sea-pos-core` cell — the
        center of the three-card cross. Card data attribute reflects the
        actual TarotCard.id so future iters can wire FYI / SPIN onto it."""
        self._enter_picker_phase()
        core = self.browser.find_element(
            By.CSS_SELECTOR, ".my-sea-picker .sea-pos-core .sea-sig-card"
        )
        self.assertEqual(
            core.get_attribute("data-card-id"), str(self.target_card.id)
        )

    # ── Test 2 ───────────────────────────────────────────────────────────────

    def test_picker_renders_cover_leave_loom_positions(self):
        """The three drawn-card positions (cover/leave/loom) render as
        empty `.sea-card-slot--empty` drop zones. Cover is overlaid on
        the sig card via `.sea-pos-core > .sea-pos-cover` nesting; leave
        + loom sit in their own grid cells flanking core."""
        picker = self._enter_picker_phase()
        # Cover lives nested inside .sea-pos-core (overlaid on sig)
        picker.find_element(
            By.CSS_SELECTOR, ".sea-pos-core .sea-pos-cover .sea-card-slot--empty"
        )
        picker.find_element(
            By.CSS_SELECTOR, ".sea-pos-leave .sea-card-slot--empty"
        )
        picker.find_element(
            By.CSS_SELECTOR, ".sea-pos-loom .sea-card-slot--empty"
        )

    # ── Test 3 ───────────────────────────────────────────────────────────────

    def test_picker_renders_sao_default_position_subset(self):
        """Default spread = Situation/Action/Outcome (SAO) → only lay
        (Situation) + cover (Action) + crown (Outcome) visible from the
        6 surrounding positions; leave / loom / cross hidden. All 6
        cells render in DOM so spread-switching never re-mutates the
        cross structure — per-spread visibility lives in SCSS via
        `.my-sea-cross[data-spread="..."]` rules."""
        picker = self._enter_picker_phase()
        visible = {".sea-pos-lay", ".sea-pos-cover", ".sea-pos-crown"}
        hidden  = {".sea-pos-leave", ".sea-pos-loom", ".sea-pos-cross"}
        for pos in visible | hidden:
            with self.subTest(position=pos):
                elements = picker.find_elements(By.CSS_SELECTOR, pos)
                self.assertEqual(len(elements), 1, f"{pos} should render in DOM")
                expected_visible = pos in visible
                self.assertEqual(
                    elements[0].is_displayed(), expected_visible,
                    f"{pos} visibility wrong for SAO default; expected {expected_visible}",
                )


class MySeaSpreadFormTest(FunctionalTest):
    """Sprint 5 iter 3 — form col on the picker phase: SPREAD dropdown
    (custom combobox w. 6 options + 2 horizontal section dividers for
    "3-card spreads" / "6-card spreads"), reversal-rate caption, two
    DECKS swatches (GRAVITY + LEVITY), LOCK HAND + DEL btns. Selecting
    a 6-card spread (Celtic Cross variants) swaps `.my-sea-cross[data-
    spread-shape]` from `three-card` to `six-card`, revealing the
    crown / lay / cross positions hidden by default.

    Card-draw mechanics — clicking a deck swatch to deposit a card into
    the next empty slot — defers to iter 4."""

    def setUp(self):
        super().setUp()
        _seed_earthman_sig_pile()
        _seed_gameboard_applets()
        self.email = "spread@test.io"
        self.gamer = User.objects.create(email=self.email)
        self.target_card = _assign_sig(self.gamer)

    def _enter_picker_phase(self):
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        btn = self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
        )
        btn.click()
        return self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
            )
        )

    # ── Test 1 ───────────────────────────────────────────────────────────────

    def test_spread_dropdown_renders_six_options_and_two_dividers(self):
        """SPREAD combobox has 4 three-card options + 2 six-card
        options + 2 horizontal section dividers labelled "3-card
        spreads" / "6-card spreads". Dividers are `role=presentation`
        + `.sea-select-divider` so combobox.js skips them.

        The dropdown is closed (`aria-expanded='false'`) on initial
        render so the <li>s aren't displayed; use textContent rather
        than `.text` (which returns "" for hidden elements)."""
        picker = self._enter_picker_phase()
        options = picker.find_elements(
            By.CSS_SELECTOR, ".sea-select-list [role='option']"
        )
        self.assertEqual(len(options), 6)
        option_labels = [
            o.get_attribute("textContent").strip() for o in options
        ]
        # Three-card variants — labels per [[project-my-sea-roadmap]]
        # iter 3 spec lock.
        self.assertIn("Past, Present, Future", option_labels)
        self.assertIn("Situation, Action, Outcome", option_labels)
        self.assertIn("Mind, Body, Spirit", option_labels)
        self.assertIn("Desire, Obstacle, Solution", option_labels)
        # Six-card variants
        self.assertIn("Celtic Cross, Waite-Smith", option_labels)
        self.assertIn("Celtic Cross, Escape Velocity", option_labels)
        # Two horizontal dividers
        dividers = picker.find_elements(By.CSS_SELECTOR, ".sea-select-divider")
        self.assertEqual(len(dividers), 2)
        divider_text = "|".join(
            d.get_attribute("textContent").upper().strip() for d in dividers
        )
        self.assertIn("3-CARD SPREADS", divider_text)
        self.assertIn("6-CARD SPREADS", divider_text)

    # ── Test 2 ───────────────────────────────────────────────────────────────

    def test_default_spread_is_situation_action_outcome(self):
        """Per the spec, `Situation, Action, Outcome` is the default
        spread on landing — selected in the combobox + reflected in
        the hidden `<input id="id_sea_spread">` initial value + on
        `.my-sea-cross[data-spread]`."""
        picker = self._enter_picker_phase()
        _open_spread_modal(self)  # .sea-select-current lives in the spread modal
        hidden = picker.find_element(By.CSS_SELECTOR, "#id_sea_spread")
        self.assertEqual(
            hidden.get_attribute("value"), "situation-action-outcome",
        )
        selected = picker.find_element(
            By.CSS_SELECTOR, ".sea-select-list [role='option'][aria-selected='true']"
        )
        self.assertEqual(
            selected.get_attribute("textContent").strip(),
            "Situation, Action, Outcome",
        )
        current = picker.find_element(By.CSS_SELECTOR, ".sea-select-current")
        self.assertEqual(current.text.strip(), "Situation, Action, Outcome")
        cross = picker.find_element(By.CSS_SELECTOR, ".my-sea-cross")
        self.assertEqual(
            cross.get_attribute("data-spread"), "situation-action-outcome",
        )

    # ── Test 3 ───────────────────────────────────────────────────────────────

    def test_picking_spread_swaps_data_spread_and_position_visibility(self):
        """Each spread reveals its own position subset (user-locked
        2026-05-19):
            PPF → leave + cover + loom visible
            SAO → lay   + cover + crown
            MBS → crown + lay   + loom
            DOS → loom  + cross + cover
            CC variants → all 6 surrounding positions.
        `.my-sea-cross[data-spread]` swaps on combobox change; SCSS
        rules toggle the inactive positions to `display: none`."""
        ALL_POSITIONS = {"crown", "leave", "cover", "cross", "loom", "lay"}
        SPREAD_POSITIONS = {
            "past-present-future":      {"leave", "cover", "loom"},
            "situation-action-outcome": {"lay",   "cover", "crown"},
            "mind-body-spirit":         {"crown", "lay",   "loom"},
            "desire-obstacle-solution": {"loom",  "cross", "crown"},
            "waite-smith":              ALL_POSITIONS,
            "escape-velocity":          ALL_POSITIONS,
        }
        picker = self._enter_picker_phase()
        _open_spread_modal(self)  # combobox lives in the spread modal
        cross  = picker.find_element(By.CSS_SELECTOR, ".my-sea-cross")
        combo  = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")

        def _pick(value):
            # Combobox click outside an open dropdown opens it; click on
            # an option inside selects + closes. Re-opening for each pick
            # keeps the test deterministic.
            if combo.get_attribute("aria-expanded") != "true":
                combo.click()
            opt = picker.find_element(
                By.CSS_SELECTOR,
                f".sea-select-list [role='option'][data-value='{value}']",
            )
            opt.click()

        for value, expected_visible in SPREAD_POSITIONS.items():
            with self.subTest(spread=value):
                _pick(value)
                self.wait_for(
                    lambda v=value: self.assertEqual(
                        cross.get_attribute("data-spread"), v
                    )
                )
                for pos in ALL_POSITIONS:
                    element = picker.find_element(
                        By.CSS_SELECTOR, f".sea-pos-{pos}"
                    )
                    should_show = pos in expected_visible
                    self.assertEqual(
                        element.is_displayed(), should_show,
                        f"spread={value} pos={pos}: expected is_displayed={should_show}",
                    )

    # ── Test 4 ───────────────────────────────────────────────────────────────

    def test_per_spread_position_labels_render_and_update(self):
        """Each visible empty slot carries a `.sea-pos-label` caption
        whose text matches the spread's per-position label map (e.g.
        SAO default: lay='Situation', cover='Action', crown='Outcome').
        JS updates labels on spread change. Reappropriates the
        GRAVITY/LEVITY (`.sea-stack-name`) caption styling."""
        SPREAD_LABELS = {
            "situation-action-outcome": {"lay": "Situation", "cover": "Action",  "crown": "Outcome"},
            "past-present-future":      {"leave": "Past",    "cover": "Present", "loom": "Future"},
            "mind-body-spirit":         {"crown": "Mind",    "lay":   "Body",    "loom": "Spirit"},
            "desire-obstacle-solution": {"loom":  "Desire",  "cross": "Obstacle","crown":"Solution"},
            "waite-smith":              {"crown": "Crown",   "leave": "Behind",  "cover": "Cover",
                                         "cross": "Cross",   "loom":  "Before",  "lay":   "Beneath"},
        }
        picker = self._enter_picker_phase()
        _open_spread_modal(self)  # combobox lives in the spread modal
        combo  = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")

        def _pick(value):
            if combo.get_attribute("aria-expanded") != "true":
                combo.click()
            picker.find_element(
                By.CSS_SELECTOR,
                f".sea-select-list [role='option'][data-value='{value}']",
            ).click()

        # SAO default — assert labels via the server-rendered initial state.
        for pos, expected_label in SPREAD_LABELS["situation-action-outcome"].items():
            with self.subTest(spread="situation-action-outcome", position=pos):
                label_el = picker.find_element(
                    By.CSS_SELECTOR,
                    f".sea-pos-label[data-position='{pos}']",
                )
                self.assertEqual(
                    label_el.get_attribute("textContent").strip(),
                    expected_label,
                )

        # Switch to each other spread + verify the labels update.
        for spread, position_to_label in SPREAD_LABELS.items():
            if spread == "situation-action-outcome":
                continue
            _pick(spread)
            for pos, expected_label in position_to_label.items():
                with self.subTest(spread=spread, position=pos):
                    label_el = self.wait_for(
                        lambda p=pos, lbl=expected_label: self._wait_label(p, lbl, picker)
                    )
                    self.assertEqual(
                        label_el.get_attribute("textContent").strip(),
                        expected_label,
                    )

    def _wait_label(self, position, expected_label, picker):
        el = picker.find_element(
            By.CSS_SELECTOR, f".sea-pos-label[data-position='{position}']"
        )
        if el.get_attribute("textContent").strip() != expected_label:
            raise AssertionError(
                f"label@{position}: got "
                f"{el.get_attribute('textContent')!r}, want {expected_label!r}"
            )
        return el


class MySeaCardDrawTest(FunctionalTest):
    """Sprint 5 iter 4a — client-side card-draw mechanics on the picker
    phase. Server embeds the deck (gravity + levity halves, user's sig
    excluded) as JSON; clicking GRAVITY/LEVITY swatch shows FLIP; FLIP
    deposits the next card into the next DRAW_ORDER slot for the active
    spread. DEL fully resets the in-progress hand. LOCK HAND enables
    when the hand is complete + click locks down further interaction.
    Switching spreads also resets the hand (the position-subset changes).

    Server-side persistence (committing the locked hand to a MySeaDraw
    model) defers to iter 4b."""

    def setUp(self):
        super().setUp()
        _seed_earthman_sig_pile()
        _seed_gameboard_applets()
        self.email = "draw@test.io"
        self.gamer = User.objects.create(email=self.email)
        self.target_card = _assign_sig(self.gamer)

    def _enter_picker_phase(self):
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        btn = self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_draw_sea_btn")
        )
        btn.click()
        return self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
            )
        )

    def _draw_open_modal(self, picker, polarity):
        """Click a polarity swatch + the FLIP btn that appears → opens
        the SeaDeal stage modal. Returns the stage element so callers
        can assert on it before dismissing."""
        stack = picker.find_element(
            By.CSS_SELECTOR, f".sea-deck-stack--{polarity}"
        )
        stack.click()
        flip = self.wait_for(
            lambda: stack.find_element(By.CSS_SELECTOR, ".sea-stack-ok")
        )
        self.wait_for(lambda: self.assertTrue(flip.is_displayed()))
        flip.click()
        # SeaDeal.openStage shows #id_sea_stage. Wait for the modal.
        return self.wait_for(
            lambda: self._stage_visible()
        )

    def _stage_visible(self):
        stage = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage")
        if not stage.is_displayed():
            raise AssertionError("sea-stage not visible after FLIP click")
        return stage

    def _dismiss_modal(self):
        """Click the stage backdrop → SeaDeal._hideStage → modal hides +
        slot gains `.--visible` (thumbnail fades in).

        Uses `execute_script` to dispatch the click rather than a native
        Selenium `.click()` — `.sea-stage-content` overlays the backdrop
        visually (centered card + stat block), so Selenium reports
        ElementClickInterceptedException for a direct click. This is
        the documented Selenium-limitation exception per the TDD skill;
        the actual backdrop-click → close behaviour is Jasmine-tested
        in [[SeaDealSpec.js]] / "Backdrop click closes the stage"."""
        self.browser.execute_script(
            "document.querySelector('#id_sea_stage .sea-stage-backdrop').click();"
        )
        self.wait_for(
            lambda: self.assertFalse(
                self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage").is_displayed()
            )
        )

    def _draw_one(self, picker, polarity):
        """Full single-draw cycle: open modal + dismiss it. Used by FTs
        that need to deposit multiple cards in sequence (the stage
        backdrop blocks subsequent deck-stack clicks)."""
        self._draw_open_modal(picker, polarity)
        self._dismiss_modal()

    # ── Test 1 ───────────────────────────────────────────────────────────────

    def test_deck_data_embedded_with_two_polarity_halves(self):
        """Server-side renders the shuffled deck (levity + gravity
        halves, sig excluded) inside `<script type="application/json"
        id="id_my_sea_deck">`. Client-side JS reads on init."""
        import json as _json
        picker = self._enter_picker_phase()
        data_el = picker.find_element(By.CSS_SELECTOR, "#id_my_sea_deck")
        deck = _json.loads(data_el.get_attribute("textContent"))
        self.assertIn("levity", deck)
        self.assertIn("gravity", deck)
        self.assertIsInstance(deck["levity"], list)
        self.assertIsInstance(deck["gravity"], list)
        # Both halves should be non-empty (16 court cards in the seed,
        # minus 1 sig → 15 cards split ~7/8).
        self.assertGreater(len(deck["levity"]) + len(deck["gravity"]), 0)
        # No card appears in both halves.
        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())

    # ── Test 2 ───────────────────────────────────────────────────────────────

    def test_user_significator_excluded_from_drawn_deck(self):
        """The user's significator (pinned in `.sea-pos-core`) must NOT
        appear in the gravity or levity deck halves — would otherwise
        let the same card show up twice in the layout."""
        import json as _json
        picker = self._enter_picker_phase()
        data_el = picker.find_element(By.CSS_SELECTOR, "#id_my_sea_deck")
        deck = _json.loads(data_el.get_attribute("textContent"))
        all_ids = {c["id"] for c in deck["levity"]} | {c["id"] for c in deck["gravity"]}
        self.assertNotIn(self.target_card.id, all_ids)

    # ── Test 3 ───────────────────────────────────────────────────────────────

    def test_levity_click_then_flip_deposits_card_into_first_sao_slot(self):
        """Default spread = SAO; first slot = `.sea-pos-lay` per the
        DRAW_ORDER spec. Clicking LEVITY → FLIP → the first drawn card
        lands in lay's `.sea-card-slot` w. `--filled` + `--levity`
        classes + corner_rank text content from the deck card."""
        picker = self._enter_picker_phase()
        self._draw_one(picker, "levity")
        slot = self.wait_for(
            lambda: picker.find_element(
                By.CSS_SELECTOR,
                ".sea-pos-lay .sea-card-slot.sea-card-slot--filled",
            )
        )
        self.assertIn("sea-card-slot--levity", slot.get_attribute("class"))
        # Card has a corner-rank rendered inside.
        slot.find_element(By.CSS_SELECTOR, ".fan-corner-rank")
        # Slot has a data-card-id attribute set to the deposited card's id.
        self.assertTrue(slot.get_attribute("data-card-id"))

    # ── Test 4 ───────────────────────────────────────────────────────────────

    def test_two_draws_fill_first_two_slots_in_draw_order(self):
        """SAO draw order = lay → cover → crown. Second draw lands in
        `.sea-pos-cover` regardless of polarity. Polarity of each
        slot reflects which swatch was clicked."""
        picker = self._enter_picker_phase()
        self._draw_one(picker, "levity")
        self._draw_one(picker, "gravity")
        # First slot (lay) — levity
        lay = picker.find_element(
            By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
        )
        self.assertIn("sea-card-slot--levity", lay.get_attribute("class"))
        # Second slot (cover) — gravity
        cover = picker.find_element(
            By.CSS_SELECTOR, ".sea-pos-cover .sea-card-slot.sea-card-slot--filled"
        )
        self.assertIn("sea-card-slot--gravity", cover.get_attribute("class"))

    # ── Test 5 ───────────────────────────────────────────────────────────────

    def test_action_btn_transitions_to_gate_view_on_hand_complete(self):
        """Iter-4c — the action btn (`#id_sea_action_btn`) starts as AUTO
        DRAW (`data-state="auto-draw"`); when the final card lands, JS
        transitions it to GATE VIEW (`data-state="gate-view"`, label =
        "GATE VIEW")."""
        picker = self._enter_picker_phase()
        _open_spread_modal(self)  # action btn lives in the spread modal
        action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
        self.assertEqual(action_btn.get_attribute("data-state"), "auto-draw")
        # innerText (via JS) works on hidden elements; selenium .text would
        # return "" once we close the modal below.
        self.assertIn("AUTO", self.browser.execute_script(
            "return arguments[0].innerText", action_btn).upper())
        # Close the modal so the deck-stack FLIP clicks below aren't
        # intercepted by the modal backdrop (which covers the whole
        # viewport while open).
        self.browser.execute_script(
            "document.getElementById('id_sea_spread_modal').setAttribute('hidden', '')"
        )
        self._draw_one(picker, "levity")
        self._draw_one(picker, "levity")
        self._draw_one(picker, "gravity")
        # Third draw completes the SAO hand — action btn becomes GATE VIEW.
        # data-state attribute is readable even while the modal is hidden.
        self.wait_for(
            lambda: self.assertEqual(action_btn.get_attribute("data-state"), "gate-view")
        )
        self.assertIn("GATE", self.browser.execute_script(
            "return arguments[0].innerText", action_btn).upper())

    # ── Test 5b — AUTO DRAW slot click reopens preview modal ────────────────

    def test_auto_drawn_slots_can_reopen_stage_modal_on_click(self):
        """Iter-6c bug-fix (user-reported 2026-05-21): cards placed by
        AUTO DRAW were silently un-clickable — the inline `_fillSlot`
        bypassed `SeaDeal.openStage` so SeaDeal's `_seaHand` dict was
        never populated for those slots, and the overlay click handler
        short-circuits on `if (!_seaHand[pos]) return;` → no modal.

        Reproduce: draw one card manually (sets `_seaHand[lay]`), AUTO
        DRAW the rest (sets `_seaHand[lay]` still, but NOT cover/crown
        if the bug exists), then click an auto-drawn slot — should
        re-open the stage modal w. that card's preview.

        Fix landed: `SeaDeal.register(card, posSelector, isLevity)`
        public method populates `_seaHand` + delegates to SeaDeal's
        internal `_fillSlot` (so `dataset.posKey` stays consistent w.
        the manual-FLIP path). AUTO DRAW now calls register, not the
        inline shim."""
        picker = self._enter_picker_phase()
        # Manual draw: lay (first position in SAO order).
        self._draw_one(picker, "levity")
        self.assertEqual(_count_filled_slots(picker), 1)

        # AUTO DRAW the remaining 2 (cover + crown). Reopen modal —
        # the first draw above closed it via AUTO DRAW's modal-close
        # capture handler... actually no, manual FLIP doesn't close the
        # modal. But the modal may have been auto-dismissed by some
        # later interaction; opening defensively keeps the test robust.
        _open_spread_modal(self)
        action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
        action_btn.click()
        confirm = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, "#id_guard_portal.active .guard-yes"
            )
        )
        confirm.click()
        # Wait for hand-complete transition (action btn → GATE VIEW).
        self.wait_for(
            lambda: self.assertEqual(action_btn.get_attribute("data-state"), "gate-view")
        )
        # All 3 slots filled.
        self.wait_for(lambda: self.assertEqual(_count_filled_slots(picker), 3))

        # Click an AUTO-drawn slot (cover — second in SAO draw order, so
        # placed by AUTO DRAW). Two-tap pattern: first click focuses,
        # second click opens the modal (per SeaDeal's overlay handler).
        cover_slot = picker.find_element(
            By.CSS_SELECTOR, ".sea-pos-cover .sea-card-slot--filled"
        )
        cover_slot.click()
        cover_slot.click()
        # Stage modal must open w. the card preview.
        stage = self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage")
        )
        self.wait_for(lambda: self.assertTrue(stage.is_displayed()))

    # ── Test 5c — manual draw survives refresh ──────────────────────────────

    def test_manual_draw_persists_on_refresh(self):
        """User-reported 2026-05-21: manually-drawn cards disappeared
        after a refresh. Root cause: SeaDeal's `_fillSlot` stamps
        `slot.dataset.posKey` w. the selector form (".sea-pos-cover"),
        but the inline `_collectHandFromDom` looks up by raw name from
        `_currentOrder()` ("cover"). The key mismatch means manual
        draws are dropped from the POST body — server stores nothing
        → refresh shows empty state.

        Fix: standardize on raw position names for `dataset.posKey`."""
        from apps.gameboard.models import MySeaDraw
        picker = self._enter_picker_phase()
        self._draw_one(picker, "levity")
        # POST is async — wait for server commit by polling the DB.
        self.wait_for(
            lambda: self.assertEqual(
                len(MySeaDraw.objects.get(user=self.gamer).hand), 1,
            )
        )
        self.browser.refresh()
        picker = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
            )
        )
        self.assertEqual(_count_filled_slots(picker), 1)

    # ── Test 5d — reloaded slot re-opens stage modal on click ───────────────

    def test_reloaded_slot_can_reopen_stage_modal_on_click(self):
        """After refresh, server-rendered filled slots must remain
        clickable to re-open the stage modal. Requires SeaDeal's init
        to seed `_seaHand` from the embedded deck JSON + the saved
        slots in the DOM. User-reported 2026-05-21."""
        from apps.gameboard.models import MySeaDraw
        picker = self._enter_picker_phase()
        self._draw_one(picker, "levity")
        self.wait_for(
            lambda: self.assertEqual(
                len(MySeaDraw.objects.get(user=self.gamer).hand), 1,
            )
        )
        self.browser.refresh()
        slot = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot--filled"
            )
        )
        slot.click()  # first tap — focus
        slot.click()  # second tap — open modal
        stage = self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stage")
        )
        self.wait_for(lambda: self.assertTrue(stage.is_displayed()))

    # ── Test 6 ───────────────────────────────────────────────────────────────

    def test_del_btn_is_disabled_until_first_draw(self):
        """User spec 2026-05-26: DEL btn renders `.btn-disabled` only
        UNTIL the first card lands (was: until hand complete). The 24h
        free-draw quota is still committed at first-card-draw + can't be
        refunded by an early DEL, but the user can DEL the in-progress
        hand to start over within the cycle. JS `_setHasDrawn(true)` fires
        on the first deposit + at the AUTO DRAW POST-commit moment."""
        picker = self._enter_picker_phase()
        delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
        # Pre-draw — disabled.
        self.assertIn("btn-disabled", delbtn.get_attribute("class"))
        self._draw_one(picker, "levity")
        # First card landed — DEL un-disables immediately.
        self.wait_for(
            lambda: self.assertNotIn("btn-disabled", delbtn.get_attribute("class"))
        )
        # Subsequent draws + completion — DEL stays enabled.
        self._draw_one(picker, "levity")
        self._draw_one(picker, "gravity")
        self.assertNotIn("btn-disabled", delbtn.get_attribute("class"))

    # ── Test 7 ───────────────────────────────────────────────────────────────

    def test_hand_completion_locks_picker_state(self):
        """Iter-4c — when the final card lands (manual or AUTO DRAW),
        the picker gains `.my-sea-picker--locked`; further deck-stack
        clicks still SHOW the FLIP btn (so the user can see why no
        further drawing is allowed) but the FLIP carries `.btn-disabled`
        + cards no longer fire on its click. No discrete LOCK HAND
        action; the transition is automatic on hand-completion."""
        picker = self._enter_picker_phase()
        self._draw_one(picker, "levity")
        self._draw_one(picker, "levity")
        self._draw_one(picker, "gravity")
        self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-picker.my-sea-picker--locked"
            )
        )

    # ── Test 8 ───────────────────────────────────────────────────────────────

    def test_first_draw_locks_spread_combobox(self):
        """Iter-4c — once the first card lands, the SPREAD combobox
        carries `.sea-select--locked` for the rest of the quota window.
        The spread is committed at first-card moment (server-side too:
        any later POST w. a different spread → 409); no client-side
        unlock path. (Iter-4a had DEL release the lock; iter-4c made DEL
        `.btn-disabled` pre-completion → no reset pathway.)"""
        picker = self._enter_picker_phase()
        self._draw_one(picker, "levity")
        combo = picker.find_element(By.CSS_SELECTOR, "[data-combobox]")
        self.wait_for(
            lambda: self.assertIn("sea-select--locked", combo.get_attribute("class"))
        )

    # ── Test 4 ───────────────────────────────────────────────────────────────

    def test_form_col_renders_decks_lock_hand_del_and_reversal_pct(self):
        """Form col carries the DECKS swatches (GRAVITY + LEVITY), the
        LOCK HAND `.btn-primary`, the DEL `.btn-danger`, and the
        reversal-percentage caption (default 25%)."""
        picker = self._enter_picker_phase()
        # DECKS — two stacks (stay on page, OUTSIDE the spread modal).
        stacks = picker.find_elements(By.CSS_SELECTOR, ".sea-deck-stack")
        self.assertEqual(len(stacks), 2)
        names = "|".join(
            s.find_element(By.CSS_SELECTOR, ".sea-stack-name").text.upper()
            for s in stacks
        )
        self.assertIn("GRAVITY", names)
        self.assertIn("LEVITY", names)
        # Action btn + DEL + reversal hint live INSIDE the spread modal —
        # open it before reading their .text (hidden elements return "").
        _open_spread_modal(self)
        # Iter-4c — action btn (AUTO DRAW / GATE VIEW slot) + DEL.
        action_btn = picker.find_element(By.CSS_SELECTOR, "#id_sea_action_btn")
        self.assertIn("AUTO", action_btn.text.upper())
        self.assertIn("btn-primary", action_btn.get_attribute("class"))
        self.assertEqual(action_btn.get_attribute("data-state"), "auto-draw")
        delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
        # DEL renders w. `.btn-disabled` pre-completion + inner text "×"
        # (case-by-case rendering per game-kit tooltip convention; user-
        # spec 2026-05-20 dropped the universal pseudo-element ×).
        self.assertIn("btn-danger", delbtn.get_attribute("class"))
        self.assertIn("btn-disabled", delbtn.get_attribute("class"))
        self.assertEqual(delbtn.text, "×")
        # Reversal % caption — default 25
        hint = picker.find_element(By.CSS_SELECTOR, ".sea-reversal-hint")
        self.assertIn("25", hint.text)
        self.assertIn("reversal", hint.text.lower())

    # ── Test (modal bug fix) ────────────────────────────────────────────────

    def test_flip_click_opens_portaled_stage_modal(self):
        """Bug fix (2026-05-19): the user-reported missing modal. After
        clicking the deck stack + the FLIP btn that appears, SeaDeal.
        openStage should fire — showing `#id_sea_stage` (position-fixed
        full-viewport portal) above everything else. Before the fix the
        slot got filled directly at opacity 0 → 'thumbnail summarily
        disappears'. Now: modal opens; slot stays at `--filled` but
        `--visible` is NOT added yet (waits for backdrop dismiss)."""
        picker = self._enter_picker_phase()
        stage = self._draw_open_modal(picker, "levity")
        # Stage card carries the drawn card's data — non-empty corner rank.
        rank = stage.find_element(
            By.CSS_SELECTOR, ".sea-stage-card .fan-card-corner--tl .fan-corner-rank"
        )
        self.assertTrue(rank.text.strip(), "stage card should display the drawn card's corner rank")
        # Slot in the cross is in `.--filled` state but the thumbnail is
        # invisible until the modal dismisses (the bug we're guarding).
        slot = picker.find_element(
            By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
        )
        self.assertNotIn(
            "sea-card-slot--visible", slot.get_attribute("class"),
            "slot should still be in pre-reveal opacity-0 state while modal is open",
        )

    # ── Test (modal bug fix, dismiss reveal) ───────────────────────────────

    def test_backdrop_click_dismisses_modal_and_reveals_thumbnail(self):
        """Bug fix part 2: clicking the `.sea-stage-backdrop` closes the
        modal AND adds `.sea-card-slot--visible` to the deposited slot,
        making the thumbnail fade in. Confirms the user-reported 'card
        appears where the slot was' behavior post-dismiss."""
        picker = self._enter_picker_phase()
        self._draw_open_modal(picker, "levity")
        self._dismiss_modal()
        slot = picker.find_element(
            By.CSS_SELECTOR, ".sea-pos-lay .sea-card-slot.sea-card-slot--filled"
        )
        self.assertIn(
            "sea-card-slot--visible", slot.get_attribute("class"),
            "post-dismiss, the slot should fade in via `.--visible`",
        )

    # ── Test (modal bug fix, stat block populates) ─────────────────────────

    def test_modal_stage_renders_stat_block_dom_contract(self):
        """SeaDeal._populate populates the stat-block keyword `<ul>`s
        via `#id_sea_stat_upright` / `#id_sea_stat_reversed`. The DOM
        contract — these IDs exist inside the stage — is what this FT
        pins; the actual stat content (keyword text, qualifier render)
        is exercised by [[SeaDealSpec.js]]. Earthman seed cards in the
        iter-4a FT pile carry empty keyword arrays so we can't assert
        text content here without enriching the seed."""
        picker = self._enter_picker_phase()
        self._draw_open_modal(picker, "levity")
        # Stat-block UL elements exist inside the visible stage.
        upright = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stat_upright")
        reversed_ul = self.browser.find_element(By.CSS_SELECTOR, "#id_sea_stat_reversed")
        self.assertIsNotNone(upright)
        self.assertIsNotNone(reversed_ul)
        # The sea stat block is inside the visible stage modal.
        stat_block = self.browser.find_element(
            By.CSS_SELECTOR, "#id_sea_stage .sea-stat-block"
        )
        self.assertIsNotNone(stat_block)


class MySeaLockHandTest(FunctionalTest):
    """Sprint 5 iter 4b — server persistence + DEL guard.

    Iter 4a left the locked hand purely client-side; this iter persists
    it via a `MySeaDraw` model so:
      - reload restores the locked hand (picker renders w. all positions
        already filled + locked)
      - a 24-hour free-draw quota applies (user gets 1 draw per 24h
        irrespective of spread type)
      - the landing phase is bypassed when a saved draw exists
      - DEL on a locked hand opens a uniform guard portal (CONFIRM/NVM)
      - a Brief banner accompanies the picker post-lock w. the next
        free-draw timestamp + NVM to dismiss

    Per-modal interactivity (NVM dismiss UX, button-enabled state on
    saved-hand init) defers to Jasmine — this FT pins only the
    integration paths the server is responsible for.
    """

    def setUp(self):
        super().setUp()
        _seed_earthman_sig_pile()
        _seed_gameboard_applets()
        self.email = "lock@test.io"
        self.gamer = User.objects.create(email=self.email)
        self.target_card = _assign_sig(self.gamer)

    def _save_draw_for_user(self, hand=None):
        """Persist a MySeaDraw row for self.gamer directly, bypassing the
        LOCK HAND UI. Returns the saved draw. Used by tests that pin the
        post-lock UX without re-walking the 3-card draw flow each time."""
        from apps.gameboard.models import MySeaDraw
        if hand is None:
            # Pick three cards from the user's deck (excluding sig)
            from apps.epic.models import TarotCard
            cards = list(TarotCard.objects.exclude(
                id=self.target_card.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 MySeaDraw.objects.create(
            user=self.gamer,
            spread="situation-action-outcome",
            hand=hand,
            significator_id=self.target_card.id,
            significator_reversed=False,
        )

    # ── Test 1 ───────────────────────────────────────────────────────────────

    def test_saved_draw_bypasses_landing_renders_picker_phase_directly(self):
        """User with a MySeaDraw row lands directly on [data-phase='picker']
        — the landing (FREE DRAW + 6-chair hex) is skipped, since the
        free quota is already spent and the locked hand is what the user
        should see."""
        self._save_draw_for_user()
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        page = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
            )
        )
        self.assertIsNotNone(page)
        # FREE DRAW landing chair-hex should not be visible.
        landings = self.browser.find_elements(
            By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']"
        )
        self.assertEqual(landings, [])

    # ── Test 2 ───────────────────────────────────────────────────────────────

    def test_saved_draw_renders_saved_hand_in_picker_slots(self):
        """The picker phase renders each saved position's slot as
        `--filled` + carries the saved card's id in `data-card-id` +
        the saved polarity class (`--gravity` / `--levity`)."""
        draw = self._save_draw_for_user()
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
            )
        )
        for entry in draw.hand:
            slot = self.browser.find_element(
                By.CSS_SELECTOR,
                f".sea-pos-{entry['position']} .sea-card-slot.sea-card-slot--filled",
            )
            self.assertEqual(
                slot.get_attribute("data-card-id"), str(entry["card_id"]),
                f"slot for position {entry['position']} should carry the saved card id",
            )
            self.assertIn(
                f"sea-card-slot--{entry['polarity']}",
                slot.get_attribute("class"),
            )

    # ── Test 3 ───────────────────────────────────────────────────────────────

    def test_saved_draw_renders_brief_banner_with_next_free_draw_timestamp(self):
        """Post-lock UX: a Look!-formatted Brief banner appears atop the
        h2 (standard portaled `.note-banner` w. Gaussian-glass bg, same
        styling as my-notes / my-sign default-deck-warning Briefs). The
        spend-moment timestamp lives in the dedicated `.note-banner__
        timestamp` `<time>` slot (note.js's standard datetime element),
        formatted by JS to `D, M j @ g:i A` shape — e.g. "Wed, May 20 @
        11:57 PM". Tagged `.my-sea-locked-banner` so this FT disambiguates
        from any other Briefs that may stack on the page.

        Sprint 2026-05-26 (@taxman ledger) update — the Brief banner is
        now server-driven via the `free_draw_brief_payload` context var,
        which surfaces ONLY when a TAX_LEDGER Brief exists for the user.
        `my_sea_lock` emits one automatically on the first card of a
        cycle; this FT bypasses `my_sea_lock` by ORM-creating the
        MySeaDraw row, so it must ALSO seed the matching tax-debit
        Brief explicitly (mirrors what `my_sea_lock` would have done)."""
        self._save_draw_for_user()
        # Seed the TAX_LEDGER Brief the server-driven my_sea.html template
        # gates on. Without this, `free_draw_brief_payload` is None in
        # context + no banner renders. (Real `my_sea_lock` calls log_tax_
        # debit on the first-card-of-cycle branch — this FT bypasses that
        # path by ORM-creating MySeaDraw, so the seed has to be manual.)
        from apps.billboard.tax import log_tax_debit
        log_tax_debit(self.gamer, "free_draw_locked")
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        brief = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".note-banner.my-sea-locked-banner"
            )
        )
        text = brief.text
        self.assertIn("Look!", text)
        self.assertIn("free draw", text.lower())
        # Timestamp slot owns the next-free-draw datetime. The "@" token
        # in the `D, M j @ g:i A` format is a stable assertion target;
        # also pin the year to confirm the source ISO parsed correctly
        # (would render "Invalid Date" if note.js got an empty string).
        ts = brief.find_element(By.CSS_SELECTOR, ".note-banner__timestamp")
        ts_text = ts.text
        self.assertIn("@", ts_text)
        self.assertNotIn("Invalid", ts_text)
        # NVM dismiss button is wired by note.js itself.
        brief.find_element(By.CSS_SELECTOR, ".note-banner__nvm")

    # ── Test 4 ───────────────────────────────────────────────────────────────

    def test_del_click_opens_shared_guard_portal(self):
        """DEL on a locked hand opens the shared `#id_guard_portal` from
        base.html (same Gaussian-glass tooltip the room gear-menu uses)
        w. uniform 'Are you sure?' copy + the standard `.btn-confirm OK`
        + `.btn-cancel NVM` button pair. The Brief banner above carries
        the quota-specific info, so the portal stays text-free of
        conditional wording."""
        self._save_draw_for_user()
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        picker = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
            )
        )
        _open_spread_modal(self)  # del btn lives in the spread modal
        delbtn = picker.find_element(By.CSS_SELECTOR, "#id_sea_del")
        delbtn.click()
        portal = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, "#id_guard_portal.active"
            )
        )
        self.wait_for(lambda: self.assertTrue(portal.is_displayed()))
        self.assertIn("sure", portal.text.lower())
        portal.find_element(By.CSS_SELECTOR, ".guard-yes")
        portal.find_element(By.CSS_SELECTOR, ".guard-no")

    # ── Test 5 ───────────────────────────────────────────────────────────────

    def test_del_confirm_clears_hand_and_returns_to_gate_view_landing(self):
        """Iter-4c semantics: clicking the portal's OK (`.guard-yes`)
        POSTs to the delete endpoint → server CLEARS the hand JSON but
        preserves the MySeaDraw row (quota tracker stays running for the
        24h window). Reload lands on the table-hex landing — but the
        primary nav btn is GATE VIEW (`#id_my_sea_gate_view_btn`), NOT
        FREE DRAW, since the quota's spent until the row expires."""
        from apps.gameboard.models import MySeaDraw
        self._save_draw_for_user()
        self.assertEqual(MySeaDraw.objects.filter(user=self.gamer).count(), 1)
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        picker = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-page[data-phase='picker']"
            )
        )
        _open_spread_modal(self)  # del btn lives in the spread modal
        picker.find_element(By.CSS_SELECTOR, "#id_sea_del").click()
        confirm = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, "#id_guard_portal.active .guard-yes"
            )
        )
        confirm.click()
        self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-page[data-phase='landing']"
            )
        )
        # Row preserved as quota tracker; hand wiped.
        rows = MySeaDraw.objects.filter(user=self.gamer)
        self.assertEqual(rows.count(), 1)
        self.assertEqual(rows.first().hand, [])
        # Landing renders GATE VIEW (not FREE DRAW) per iter-4c spec.
        self.browser.find_element(By.CSS_SELECTOR, "#id_my_sea_gate_view_btn")
        self.assertEqual(
            len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")),
            0,
        )


# ── Sprint 6 — my-sea gatekeeper (token-deposit-to-redraw within 24h) ─────────
# FT skeleton written TDD-first per user spec 2026-05-20. See
# [[sprint-my-sea-iter-6-plan]] for the full spec; iters 6a/6b/6c break
# the work into three commits but the FTs describe the user-facing
# behavior end-to-end so the impl can converge against them.


class MySeaGatekeeperPageTest(FunctionalTest):
    """Sprint 6 iter 6a — `/gameboard/my-sea/gate/` renders the solo
    gatekeeper UI. Coin-slot rails (INSERT TOKEN TO PLAY) + 6-chair hex
    (seat 1 always reserved for owner, others banned). One-token-per-
    draw, refundable until PAID DRAW commits."""

    def setUp(self):
        super().setUp()
        _seed_earthman_sig_pile()
        _seed_gameboard_applets()
        self.email = "gate@test.io"
        self.gamer = User.objects.create(email=self.email)
        _assign_sig(self.gamer)
        # The User post_save signal auto-creates COIN + FREE tokens
        # (apps.lyric.models). Clear them so the deposit-priority test
        # below picks only the FREE we seed explicitly — otherwise
        # COIN wins (PASS > COIN > FREE > TITHE) and the FREE-count
        # assertion fails (per IT-trap memo, 2026-05-19).
        self.gamer.tokens.all().delete()
        # Seed a FREE token so the gatekeeper has something to deposit.
        from datetime import timedelta
        from django.utils import timezone as dj_tz
        from apps.lyric.models import Token
        Token.objects.create(
            user=self.gamer, token_type=Token.FREE,
            expires_at=dj_tz.now() + timedelta(days=30),
        )

    def _save_empty_hand_draw(self):
        """Quota-spent state: an active MySeaDraw row w. empty hand
        (post-DEL or post-completion-DEL). This is the canonical state
        where the gatekeeper is meaningful."""
        from apps.gameboard.models import MySeaDraw
        return MySeaDraw.objects.create(
            user=self.gamer, spread="situation-action-outcome",
            significator_id=self.gamer.significator_id, hand=[],
        )

    def test_gatekeeper_page_renders_token_rails_in_empty_state(self):
        """No deposit yet → coin-slot shows INSERT TOKEN TO PLAY + the
        rails are active (click-target). No refund btn yet."""
        self._save_empty_hand_draw()
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
        rails_form = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, "form[action$='/my-sea/insert']"
            )
        )
        rails_form.find_element(By.CSS_SELECTOR, "button.token-rails")
        # No refund btn or PAID DRAW yet.
        self.assertEqual(
            len(self.browser.find_elements(
                By.CSS_SELECTOR, "form[action$='/my-sea/refund']"
            )),
            0,
        )
        self.assertEqual(
            len(self.browser.find_elements(
                By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
            )),
            0,
        )

    def test_gatekeeper_renders_no_hex_modal_only(self):
        """Per user spec 2026-05-20, the gatekeeper is a transient in-flight
        UI — modal-over-`--duoUser` bg, NO hex / chair-seats. Seats live on
        the my-sea picker page itself; the gatekeeper just offers the
        coin-slot + (post-deposit) PAID DRAW affordance."""
        self._save_empty_hand_draw()
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
        self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-gate-modal"
            )
        )
        self.assertEqual(
            len(self.browser.find_elements(By.CSS_SELECTOR, ".table-seat")),
            0,
        )
        self.assertEqual(
            len(self.browser.find_elements(By.CSS_SELECTOR, ".table-hex")),
            0,
        )

    def test_insert_token_reserves_deposit_and_reveals_paid_draw_btn(self):
        """Click INSERT TOKEN → server reserves the user's next-priority
        token on the MySeaDraw row; gatekeeper re-renders w. refund btn
        + `#id_my_sea_paid_draw_btn` visible."""
        self._save_empty_hand_draw()
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
        rails = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR,
                "form[action$='/my-sea/insert'] button.token-rails",
            )
        )
        rails.click()
        self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
            )
        )
        # Refund affordance is now present.
        self.browser.find_element(
            By.CSS_SELECTOR, "form[action$='/my-sea/refund']"
        )
        # Server-side: MySeaDraw row has deposit_token_id set.
        from apps.gameboard.models import MySeaDraw
        draw = MySeaDraw.objects.get(user=self.gamer)
        self.assertIsNotNone(draw.deposit_token_id)

    def test_refund_clears_deposit_and_returns_to_empty_state(self):
        from apps.gameboard.models import MySeaDraw
        self._save_empty_hand_draw()
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
        self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR,
                "form[action$='/my-sea/insert'] button.token-rails",
            )
        ).click()
        refund = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR,
                "form[action$='/my-sea/refund'] button",
            )
        )
        refund.click()
        # After refund, INSERT TOKEN form is back; PAID DRAW gone.
        self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR,
                "form[action$='/my-sea/insert']",
            )
        )
        self.assertEqual(
            len(self.browser.find_elements(
                By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
            )),
            0,
        )
        draw = MySeaDraw.objects.get(user=self.gamer)
        self.assertIsNone(draw.deposit_token_id)

    def test_paid_draw_commits_token_and_redirects_to_picker(self):
        """PAID DRAW commits the deposited token (FREE token consumed)
        + redirects to /gameboard/my-sea/?phase=picker so the user lands
        directly in the picker. Under the 2026-05-23 spec update, the
        row is PRESERVED w. `paid_through_at` set (NOT deleted as
        iter-6c originally did) — preservation keeps the PAID DRAW btn
        on the landing across navigation cycles until the first card is
        drawn. See [[sprint-paid-draw-persistence-may23]] for the
        rationale + the user-reported bug it fixes."""
        from apps.gameboard.models import MySeaDraw
        from apps.lyric.models import Token
        draw = self._save_empty_hand_draw()
        free_count_before = Token.objects.filter(
            user=self.gamer, token_type=Token.FREE,
        ).count()
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
        self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR,
                "form[action$='/my-sea/insert'] button.token-rails",
            )
        ).click()
        paid_draw = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
            )
        )
        paid_draw.click()
        self.wait_for(
            lambda: self.assertIn(
                "/gameboard/my-sea/?phase=picker", self.browser.current_url
            )
        )
        # FREE token consumed.
        self.assertEqual(
            Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(),
            free_count_before - 1,
        )
        # Row preserved + paid_through_at stamped (sticky PAID DRAW state).
        draw.refresh_from_db()
        self.assertIsNotNone(draw.paid_through_at,
            "PAID DRAW commit must set paid_through_at (preserves PAID "
            "DRAW btn on subsequent landing renders — user-reported "
            "regression 2026-05-23)")
        self.assertIsNone(draw.deposit_token_id)
        self.assertEqual(draw.hand, [])

    def test_paid_draw_btn_persists_after_navigation_without_card_draw(self):
        """User-reported bug 2026-05-23: after PAID DRAW commits, if the
        user navigates away without drawing any cards, the landing
        button reverts to FREE DRAW (under the old delete-at-commit
        semantics) even though the free draw is still on cooldown. With
        `paid_through_at` preserved on the row, the landing PAID DRAW
        button stays visible across navigation cycles until the first
        card of the paid session lands."""
        from apps.gameboard.models import MySeaDraw
        from django.utils import timezone as dj_tz
        self._save_empty_hand_draw()
        # Set cooldown anchor explicitly so the user-level state matches
        # the row's "cycle is live" indication.
        self.gamer.last_free_draw_at = dj_tz.now()
        self.gamer.save(update_fields=["last_free_draw_at"])
        self.create_pre_authenticated_session(self.email)
        # Deposit + commit via the gatekeeper.
        self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
        self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR,
                "form[action$='/my-sea/insert'] button.token-rails",
            )
        ).click()
        self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
            )
        ).click()
        # Land in picker (?phase=picker). Now navigate AWAY without
        # drawing any cards — to /gameboard/ — then back to /gameboard/
        # my-sea/. The landing must still show PAID DRAW (not FREE DRAW
        # nor GATE VIEW).
        self.wait_for(
            lambda: self.assertIn("phase=picker", self.browser.current_url)
        )
        self.browser.get(self.live_server_url + "/gameboard/")
        self.wait_for(
            lambda: self.browser.find_element(By.ID, "id_applet_new_game")
        )
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        # PAID DRAW button still rendered on the landing.
        self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
            )
        )
        # FREE DRAW + GATE VIEW NOT shown.
        self.assertEqual(
            len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")),
            0,
            "FREE DRAW btn must NOT show after PAID DRAW commit (regression)"
        )
        self.assertEqual(
            len(self.browser.find_elements(
                By.CSS_SELECTOR, "#id_my_sea_gate_view_btn"
            )),
            0,
            "GATE VIEW btn must NOT show while paid-through credit is set"
        )


class MySeaLandingPaidDrawTest(FunctionalTest):
    """Sprint 6 iter 6b — landing center-btn state machine extended w.
    PAID DRAW. After depositing in the gatekeeper, refresh / navigate
    back to /gameboard/my-sea/ → landing renders PAID DRAW (not GATE
    VIEW) at the hex center."""

    def setUp(self):
        super().setUp()
        _seed_earthman_sig_pile()
        _seed_gameboard_applets()
        self.email = "landpaid@test.io"
        self.gamer = User.objects.create(email=self.email)
        _assign_sig(self.gamer)

    def test_landing_shows_paid_draw_btn_when_deposit_reserved(self):
        from datetime import timedelta
        from django.utils import timezone as dj_tz
        from apps.gameboard.models import MySeaDraw
        from apps.lyric.models import Token
        free_tok = Token.objects.create(
            user=self.gamer, token_type=Token.FREE,
            expires_at=dj_tz.now() + timedelta(days=30),
        )
        MySeaDraw.objects.create(
            user=self.gamer, spread="situation-action-outcome",
            significator_id=self.gamer.significator_id, hand=[],
            deposit_token_id=free_tok.pk,
            deposit_reserved_at=dj_tz.now(),
        )
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, "#id_my_sea_paid_draw_btn"
            )
        )
        # FREE DRAW + GATE VIEW are NOT shown when a deposit is reserved.
        self.assertEqual(
            len(self.browser.find_elements(By.CSS_SELECTOR, "#id_draw_sea_btn")),
            0,
        )
        self.assertEqual(
            len(self.browser.find_elements(By.CSS_SELECTOR, "#id_my_sea_gate_view_btn")),
            0,
        )


class MySeaNavbarGateViewTest(FunctionalTest):
    """Sprint 6 iter 6b — navbar CONT GAME swaps to GATE VIEW whenever
    the user is on `body.page-my-sea`. Always reachable, regardless of
    quota state — clicking takes the user to /gameboard/my-sea/gate/."""

    def setUp(self):
        super().setUp()
        _seed_earthman_sig_pile()
        _seed_gameboard_applets()
        self.email = "navgate@test.io"
        self.gamer = User.objects.create(email=self.email)
        _assign_sig(self.gamer)

    def test_navbar_renders_gate_view_btn_on_my_sea_page(self):
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        nav = self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, ".navbar")
        )
        # GATE VIEW btn present, CONT GAME btn not.
        nav.find_element(By.CSS_SELECTOR, "#id_navbar_gate_view_btn")
        self.assertEqual(
            len(nav.find_elements(By.CSS_SELECTOR, "#id_navbar_cont_game_btn")),
            0,
        )


class MySeaSeatOnePersistenceTest(FunctionalTest):
    """Sprint 6 iter 6b (window rule 2026-05-31) — seat 1 (the owner's
    reserved chair) renders `.seated` for the full 24h window after her most
    recent draw, i.e. as long as a `MySeaDraw` row exists. DEL empties the
    hand but keeps the row → seat 1 STAYS seated; only a fresh user with no
    row (or a fully-expired window) shows `.fa-ban`."""

    def setUp(self):
        super().setUp()
        _seed_earthman_sig_pile()
        _seed_gameboard_applets()
        self.email = "seat1@test.io"
        self.gamer = User.objects.create(email=self.email)
        _assign_sig(self.gamer)

    def test_seat_1_banned_for_fresh_user_no_quota(self):
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        seat1 = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".table-seat[data-slot='1']"
            )
        )
        self.assertNotIn("seated", seat1.get_attribute("class"))
        seat1.find_element(By.CSS_SELECTOR, ".fa-ban")

    def test_seat_1_seated_when_active_draw_has_empty_hand(self):
        """DEL leaves the row but wipes the hand; seat 1 STAYS seated for
        the rest of the 24h window (user-spec 2026-05-31: seating tied to
        `active_draw` row existence, NOT to the hand being non-empty)."""
        from apps.gameboard.models import MySeaDraw
        MySeaDraw.objects.create(
            user=self.gamer, spread="situation-action-outcome",
            significator_id=self.gamer.significator_id, hand=[],
        )
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        seat1 = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".table-seat[data-slot='1']"
            )
        )
        self.assertIn("seated", seat1.get_attribute("class"))
        seat1.find_element(By.CSS_SELECTOR, ".fa-circle-check")


class MySeaBudBtnInviteTest(FunctionalTest):
    """bud-btn invite panel on the my-sea gatekeeper. Panel opens +
    autocomplete works (reuses billboard:search_buds); the OK btn now sends a
    REAL invite (Phase A of [[my-sea-invite-voice-blueprint]]) — POSTs create
    a SeaInvite + return an "invite sent" Brief banner. (Was a coming-soon
    stub through iter 6c.)"""

    def setUp(self):
        super().setUp()
        _seed_earthman_sig_pile()
        _seed_gameboard_applets()
        self.email = "bud@test.io"
        self.gamer = User.objects.create(email=self.email)
        _assign_sig(self.gamer)
        # Seed a quota row so the gatekeeper has context.
        from apps.gameboard.models import MySeaDraw
        MySeaDraw.objects.create(
            user=self.gamer, spread="situation-action-outcome",
            significator_id=self.gamer.significator_id, hand=[],
        )

    def test_bud_btn_panel_opens_on_gatekeeper(self):
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
        bud_btn = self.wait_for(
            lambda: self.browser.find_element(By.ID, "id_bud_btn")
        )
        bud_btn.click()
        # Panel opens — html.bud-open class is added.
        self.wait_for(
            lambda: self.assertIn(
                "bud-open",
                self.browser.find_element(By.TAG_NAME, "html").get_attribute("class"),
            )
        )
        self.browser.find_element(By.ID, "id_recipient")

    def test_bud_btn_ok_sends_real_invite(self):
        from apps.lyric.models import User as _U
        from apps.gameboard.models import SeaInvite
        # Seed a friend so the OK click has a recipient to invite.
        _U.objects.create(email="friend@test.io")
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
        bud_btn = self.wait_for(
            lambda: self.browser.find_element(By.ID, "id_bud_btn")
        )
        bud_btn.click()
        recipient = self.wait_for(
            lambda: self.browser.find_element(By.ID, "id_recipient")
        )
        # Wait for slide-out animation to settle (per
        # [[feedback-css-transition-selenium-click-race]]).
        self.wait_for(
            lambda: self.assertGreater(
                self.browser.execute_script(
                    "return document.getElementById('id_bud_panel').getBoundingClientRect().width;"
                ),
                100,
            )
        )
        recipient.send_keys("friend@test.io")
        self.browser.find_element(
            By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
        ).click()
        # Brief banner confirms the invite is on its way.
        brief = self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner")
        )
        self.assertIn("on its way", brief.text.lower())
        # A real SeaInvite row now exists for the invited friend.
        self.assertTrue(
            SeaInvite.objects.filter(invitee_email="friend@test.io").exists()
        )


class MySeaSpectatorFlowTest(FunctionalTest):
    """Phase B of [[my-sea-invite-voice-blueprint]] — an ACCEPTED invitee
    visits the owner's my-sea, deposits a token at the visitor gate, and
    thereby occupies seat 2C (the center btn flips GATE VIEW → VIEW DRAW)."""

    def setUp(self):
        super().setUp()
        self.browser.set_window_size(800, 1200)  # portrait — center-hex clicks
        _seed_gameboard_applets()
        from apps.gameboard.models import MySeaDraw, SeaInvite
        self.owner = User.objects.create(email="owner@test.io", username="discoman")
        # Owner has drawn → seat 1C is occupied + there's a draw to view.
        MySeaDraw.objects.create(
            user=self.owner, spread="situation-action-outcome",
            significator_id=1,
            hand=[{"position": "lay", "card_id": 1, "reversed": False,
                   "polarity": "gravity"}],
        )
        self.email = "bud@test.io"
        self.bud = User.objects.create(email=self.email, username="budster")
        self.invite = SeaInvite.objects.create(
            owner=self.owner, invitee=self.bud, invitee_email=self.email,
            status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
        )

    def test_spectator_deposits_token_to_occupy_seat_2c(self):
        from django.urls import reverse
        self.create_pre_authenticated_session(self.email)
        self.browser.get(
            self.live_server_url + reverse("my_sea_visit", args=[self.owner.id])
        )
        # Before deposit: GATE VIEW shown, seat 2C not yet seated.
        gate_btn = self.wait_for(
            lambda: self.browser.find_element(By.ID, "id_my_sea_gate_view_btn")
        )
        seat2 = self.browser.find_element(
            By.CSS_SELECTOR, ".table-seat[data-slot='2']"
        )
        self.assertNotIn("seated", seat2.get_attribute("class"))
        # GATE VIEW → visitor gate → INSERT TOKEN (single-step deposit).
        gate_btn.click()
        insert = self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, ".token-rails")
        )
        insert.click()
        # Back on the visit page: VIEW DRAW now shown, seat 2C seated.
        self.wait_for(
            lambda: self.browser.find_element(By.ID, "id_my_sea_view_draw_btn")
        )
        seat2 = self.browser.find_element(
            By.CSS_SELECTOR, ".table-seat[data-slot='2']"
        )
        self.assertIn("seated", seat2.get_attribute("class"))


class MySeaVoiceBtnTest(FunctionalTest):
    """Phase C — with a 24h voice window open, the burger fan's #id_voice_btn
    renders .active + data-room-id on the present invitee's visit page."""

    def setUp(self):
        super().setUp()
        self.browser.set_window_size(800, 1200)
        _seed_gameboard_applets()
        from apps.gameboard.models import SeaInvite
        self.owner = User.objects.create(email="owner@test.io", username="discoman")
        self.email = "bud@test.io"
        self.bud = User.objects.create(email=self.email, username="budster")
        SeaInvite.objects.create(
            owner=self.owner, invitee=self.bud, invitee_email=self.email,
            status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
            token_deposited_at=timezone.now(),
            voice_until=timezone.now() + timedelta(hours=24),
        )

    def test_voice_btn_active_for_present_invitee(self):
        from django.urls import reverse
        self.create_pre_authenticated_session(self.email)
        self.browser.get(
            self.live_server_url + reverse("my_sea_visit", args=[self.owner.id])
        )
        voice = self.wait_for(
            lambda: self.browser.find_element(By.ID, "id_voice_btn")
        )
        self.assertIn("active", voice.get_attribute("class"))
        self.assertEqual(
            voice.get_attribute("data-room-id"), f"mysea-{self.owner.id}"
        )


class MySeaGearBtnTest(FunctionalTest):
    """Sprint 6 iter 6c — `.gear-btn` on every my-sea page state
    (landing / picker / gatekeeper). Opens a NVM-only menu (DEL/BYE
    deliberately omitted — gear is for "back out without committing");
    NVM nav-backs to /gameboard/ mirroring the room's gear-menu
    convention. Same partial included on both /gameboard/my-sea/ and
    /gameboard/my-sea/gate/ so visual + behavior is identical."""

    def setUp(self):
        super().setUp()
        _seed_earthman_sig_pile()
        _seed_gameboard_applets()
        self.email = "gear@test.io"
        self.gamer = User.objects.create(email=self.email)
        _assign_sig(self.gamer)
        # Seed a quota row so the gatekeeper has an active_draw context.
        from apps.gameboard.models import MySeaDraw
        MySeaDraw.objects.create(
            user=self.gamer, spread="situation-action-outcome",
            significator_id=self.gamer.significator_id, hand=[],
        )

    def test_gear_btn_renders_on_gatekeeper(self):
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
        gear = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-page .gear-btn"
            )
        )
        # Menu wired via data-menu-target.
        self.assertEqual(
            gear.get_attribute("data-menu-target"),
            "id_my_sea_menu",
        )

    def test_gear_btn_renders_on_landing_without_active_draw(self):
        """User direction 2026-05-20 — gear stays on /gameboard/my-sea/
        even when no gatekeeper / no active_draw row exists. Fresh user
        with a sig + no draw lands here + still sees the gear."""
        from apps.gameboard.models import MySeaDraw
        MySeaDraw.objects.filter(user=self.gamer).delete()
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-page .gear-btn"
            )
        )

    def test_gear_btn_opens_menu_with_nvm_only(self):
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
        gear = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-page .gear-btn"
            )
        )
        gear.click()
        menu = self.wait_for(
            lambda: self.browser.find_element(By.ID, "id_my_sea_menu")
        )
        self.assertEqual(menu.value_of_css_property("display"), "block")
        # NVM btn present, DEL + BYE deliberately absent. NVM is a `<button
        # onclick="location.href=...">` (NOT an `<a class="btn">`) per 2026-
        # 05-26 fix — anchors inherit body's serif font, buttons stay sans-
        # serif. Check the onclick attr for the nav target instead of href.
        nvm = menu.find_element(By.CSS_SELECTOR, ".btn-cancel")
        self.assertIn("/gameboard/", nvm.get_attribute("onclick") or "")
        self.assertEqual(
            len(menu.find_elements(By.CSS_SELECTOR, ".btn-danger")), 0,
        )
        self.assertEqual(
            len(menu.find_elements(By.CSS_SELECTOR, ".btn-abandon")), 0,
        )

    def test_nvm_navigates_back_to_my_sea_hex(self):
        # NVM on the my-sea gatekeeper navigates back to the my-sea table hex
        # (/gameboard/my-sea/), NOT out to /gameboard/ — changed 5cade51
        # (gatekeeper + picker NVM → table hex; only the landing + sign-gate
        # phases eject to /gameboard/). Sibling test_gear_btn_opens_menu_with_
        # nvm_only still passes since it only checks the onclick *contains*
        # /gameboard/.
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/gate/")
        gear = self.wait_for(
            lambda: self.browser.find_element(
                By.CSS_SELECTOR, ".my-sea-page .gear-btn"
            )
        )
        gear.click()
        self.wait_for(
            lambda: self.browser.find_element(By.ID, "id_my_sea_menu")
        )
        self.browser.find_element(
            By.CSS_SELECTOR, "#id_my_sea_menu .btn-cancel"
        ).click()
        self.wait_for(
            lambda: self.assertRegex(
                self.browser.current_url, r"/gameboard/my-sea/$"
            )
        )


class MySeaOwnerSeatingTest(FunctionalTest):
    """Phase 2 (2026-05-29) — the owner's 1C chair stays persistently seated
    (`.seated` + green `.fa-circle-check`, NOT the semi-opaque `.fa-ban`
    default) whenever she's committed to a draw cycle, including a PAID DRAW
    landing before any card lands. Previously only a non-empty hand seated 1C,
    so a paid-but-undrawn owner showed the empty-seat default."""

    def setUp(self):
        super().setUp()
        _seed_earthman_sig_pile()
        _seed_gameboard_applets()
        self.email = "seated@test.io"
        self.gamer = User.objects.create(email=self.email)
        _assign_sig(self.gamer)

    def _paid_empty_draw(self):
        from apps.gameboard.models import MySeaDraw
        return MySeaDraw.objects.create(
            user=self.gamer, spread="situation-action-outcome",
            significator_id=self.gamer.significator_id, hand=[],
            deposit_token_id=99, deposit_reserved_at=timezone.now(),
        )

    def test_paid_draw_landing_seats_owner_in_1c_persistently(self):
        self._paid_empty_draw()
        self.create_pre_authenticated_session(self.email)
        self.browser.get(self.live_server_url + "/gameboard/my-sea/")
        seat1 = self.wait_for(lambda: self.browser.find_element(
            By.CSS_SELECTOR, ".table-seat[data-slot='1']"
        ))
        # Seated (not the empty default) + the green check — and STAYS so
        # after the 2s flare settles (the steady state is server-rendered).
        self.assertIn("seated", (seat1.get_attribute("class") or "").split())
        self.assertTrue(seat1.find_elements(
            By.CSS_SELECTOR, ".position-status-icon.fa-circle-check"
        ))
        self.assertFalse(seat1.find_elements(
            By.CSS_SELECTOR, ".position-status-icon.fa-ban"
        ))


class MySeaVisitSpectatorCrossTest(FunctionalTest):
    """Phase 1 (2026-05-29) — the visitor's VIEW DRAW renders the owner's draw
    as the SAME interactive cross stage the owner sees (`.my-sea-cross` +
    `_sea_stage`), NOT the old flat scroll that blew a single card up to fill
    the viewport."""

    def setUp(self):
        super().setUp()
        _seed_earthman_sig_pile()
        _seed_gameboard_applets()
        self.owner = User.objects.create(email="owner@test.io", username="owner")
        _assign_sig(self.owner)
        self.visitor_email = "viz@test.io"
        self.visitor = User.objects.create(
            email=self.visitor_email, username="viz")
        from apps.epic.models import TarotCard
        from apps.gameboard.models import MySeaDraw, SeaInvite
        card = TarotCard.objects.exclude(id=self.owner.significator_id).first()
        MySeaDraw.objects.create(
            user=self.owner, spread="situation-action-outcome",
            significator_id=self.owner.significator_id,
            hand=[{"position": "lay", "card_id": card.id, "reversed": False,
                   "polarity": "gravity"}],
        )
        SeaInvite.objects.create(
            owner=self.owner, invitee=self.visitor,
            invitee_email=self.visitor_email,
            status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
            token_deposited_at=timezone.now(),
            voice_until=timezone.now() + timedelta(hours=24),
        )

    def test_view_draw_reveals_interactive_cross_with_filled_slot(self):
        self.create_pre_authenticated_session(self.visitor_email)
        self.browser.get(
            self.live_server_url + f"/gameboard/my-sea/visit/{self.owner.id}/")
        view_btn = self.wait_for(lambda: self.browser.find_element(
            By.ID, "id_my_sea_view_draw_btn"))
        self.browser.execute_script("arguments[0].click()", view_btn)
        cross = self.wait_for(lambda: self.browser.find_element(
            By.CSS_SELECTOR, ".my-sea-cross"))
        self.assertTrue(cross.is_displayed())
        self.assertTrue(self.browser.find_elements(
            By.CSS_SELECTOR, ".sea-card-slot--filled"))
        self.assertTrue(self.browser.find_elements(By.ID, "id_sea_stage"))
        self.assertFalse(self.browser.find_elements(
            By.CSS_SELECTOR, ".my-sea-scroll"))
        # The cross rides the owner's shared picker felt: the page flips to
        # data-phase="picker" (--duoUser bg) while showing the draw, and the
        # display:contents wrapper lets .my-sea-picker fill the page.
        page = self.browser.find_element(By.CSS_SELECTOR, ".my-sea-page")
        self.assertEqual(page.get_attribute("data-phase"), "picker")


class MySeaVisitVoiceGuardTest(FunctionalTest):
    """Phase 4 (2026-05-29) — when voice is LIVE, the gear-menu NVM that leaves
    my_sea warns via the shared guard portal before disconnecting; with no live
    mic it navigates straight through. (Mesh stubbed — no real WebRTC.)"""

    def setUp(self):
        super().setUp()
        _seed_earthman_sig_pile()
        _seed_gameboard_applets()
        self.owner = User.objects.create(email="owner@test.io", username="owner")
        _assign_sig(self.owner)
        self.visitor_email = "viz@test.io"
        self.visitor = User.objects.create(
            email=self.visitor_email, username="viz")
        from apps.gameboard.models import SeaInvite
        SeaInvite.objects.create(
            owner=self.owner, invitee=self.visitor,
            invitee_email=self.visitor_email,
            status=SeaInvite.ACCEPTED, accepted_at=timezone.now(),
            token_deposited_at=timezone.now(),
            voice_until=timezone.now() + timedelta(hours=24),
        )

    def _open_nvm(self):
        gear = self.wait_for(lambda: self.browser.find_element(
            By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_my_sea_menu']"))
        self.browser.execute_script("arguments[0].click()", gear)
        return self.wait_for(lambda: self.browser.find_element(
            By.CSS_SELECTOR, "#id_my_sea_menu .btn-cancel"))

    def test_nvm_warns_before_disconnecting_when_voice_live(self):
        self.create_pre_authenticated_session(self.visitor_email)
        self.browser.get(
            self.live_server_url + f"/gameboard/my-sea/visit/{self.owner.id}/")
        self.browser.execute_script("window.VoiceRoom = {localStream: {}};")
        self._open_nvm().click()
        portal = self.wait_for(lambda: self.browser.find_element(
            By.CSS_SELECTOR, "#id_guard_portal.active"))
        self.assertIn(
            "disconnect from voice",
            portal.find_element(By.CSS_SELECTOR, ".guard-message").text.lower())
        self.assertIn(f"/my-sea/visit/{self.owner.id}/", self.browser.current_url)

    def test_nvm_navigates_directly_when_no_live_voice(self):
        self.create_pre_authenticated_session(self.visitor_email)
        self.browser.get(
            self.live_server_url + f"/gameboard/my-sea/visit/{self.owner.id}/")
        self.browser.execute_script("window.VoiceRoom = {localStream: null};")
        self._open_nvm().click()
        self.wait_for(lambda: self.assertNotIn(
            "/my-sea/visit/", self.browser.current_url))
