"""FT spec for the #id_bud_btn slide-out — handshake button shared across:

  • post.html (bottom-left)        — async POST to billboard:share_post
  • gatekeeper room.html (upper-right footer) — async POST to epic:invite_gamer
  • my_buds.html                    — async POST to billboard:add_bud
                                       (covered by functional_tests.test_my_buds)

This file covers the post-share + gatekeeper variants; My Buds lives in its
own FT module. All three drive the same `bindBudBtn(...)` skeleton in
apps/billboard/static/apps/billboard/bud-btn.js.

The original test_bud_btn.py was written red BEFORE implementation as a
TDD handoff; test_gatekeeper_bud_btn.py was the parallel spec for the
gatekeeper port (2026-05-09 sprint). The two merged 2026-05-12 since they
exercise the same UI component in two contexts.

Run:

    python src/manage.py test functional_tests.test_core_bud_btn

────────────────────────────────────────────────────────────────────────────
SPEC SUMMARY (original post.html sprint — preserved for historical context)
────────────────────────────────────────────────────────────────────────────

  • A new #id_bud_btn (<i class="fa-solid fa-handshake">) sits bottom-left
    of the viewport — mirror of #id_kit_btn (bottom-right). Shares the same
    fixed/circular/secUser-bordered look + .active-state styling.
  • Lives in a partial template, e.g. apps/billboard/_partials/_bud_panel.html,
    included only by post.html (NOT the global base.html — bud is post-only).
  • Replaces the inline share form on post.html: typing the recipient now
    happens in a slide-out under the bud btn.
  • Click #id_bud_btn → recipient field grows L→R under it, spanning
    `100vw - 3rem` (1.5rem padding each side). The field is vertically
    centred on the bud btn's centre, w. healthy left padding so the typed
    text + placeholder don't overlap the btn glyph.
  • An OK btn (.btn.btn-confirm) is tacked on the trailing edge of the
    field (replaces the legacy big SHARE .btn-primary).
  • While the recipient field is open: html.bud-open is set; #id_kit_btn
    quickly eases to opacity 0. Symmetric: when html.kit-open is set,
    #id_bud_btn eases to opacity 0.
  • Click OK → POST share-post async (existing C3.b endpoint), clears the
    field, closes the slide-out, slide-down Brief banner appears.
  • Click outside the field/btn → closes the slide-out, clears the field
    (no Brief). Reopening shows the placeholder, not the prior typed value.
  • Escape key closes the slide-out (same as kit btn pattern).
  • post.html + my_posts.html should be aperture-styled (body.page-billboard
    or new body.page-post if needed; the user noted post.html isn't
    currently in the base/applet scss aperture group).

────────────────────────────────────────────────────────────────────────────
IMPLEMENTATION CHECKLIST (post-compaction)
────────────────────────────────────────────────────────────────────────────

  1. Create templates/apps/billboard/_partials/_bud_panel.html w. the btn
     + slide-out form + inline JS.
  2. Edit templates/apps/billboard/post.html:
     - Drop the inline `<form id=id_share_form>` + #id_recipient + SHARE btn
       block (the share JS moves into _bud_panel.html).
     - Add `{% include "apps/billboard/_partials/_bud_panel.html" %}`.
  3. Edit billboard.views.view_post (or my_posts) context:
     - "page_class": "page-billboard" (or new page-post) so the body class
       picks up the aperture SCSS.
  4. SCSS — add to _game-kit.scss neighbour or new _bud.scss:
     - #id_bud_btn: position fixed bottom-left, mirror of #id_kit_btn
       (3rem circle, secUser border, .active state, etc.)
     - #id_bud_panel (the slide-out wrapper): position fixed,
       left: 1.5rem, right: 1.5rem (or 1.5rem + #id_kit_btn width when
       kit btn visible — but mutual-exclusion makes that moot), bottom-
       aligned w. the btn centre, transition transform/width L→R.
     - html.bud-open #id_kit_btn { opacity: 0; transition: opacity 0.15s; }
     - html.kit-open    #id_bud_btn { opacity: 0; transition: opacity 0.15s; }
     - The OK .btn-confirm gets normal btn-pad styling; flex-shrink:0 on
       the trailing edge of the slide-out.
  5. JS in _bud_panel.html:
     - Mirror game-kit.js click/escape/click-outside pattern.
     - Toggle html.bud-open + #id_bud_btn.active.
     - On submit/OK: fetch POST share-post w. Accept:application/json,
       reuse the C3.b response handling (line append, banner via
       Brief.showBanner, recipient_display chip append).
     - On dismiss-without-OK: clear input.value.
  6. post.html + my_posts.html: add the body class hook so the aperture
     SCSS engages (probably page-billboard already, just need to confirm).
  7. Update functional_tests.post_page.PostPage.share_post_with() to
     drive the bud-btn flow (click btn → type → click OK → wait for chip).
  8. Re-run test_sharing.SharingTest — should still pass once the page-
     object mirrors the new flow.

────────────────────────────────────────────────────────────────────────────
KNOWN AT TIME OF WRITING
────────────────────────────────────────────────────────────────────────────
  - #id_kit_btn lives in templates/core/base.html (line ~55), styled in
    static_src/scss/_game-kit.scss; toggled by apps/dashboard/static/.../game-kit.js.
  - The C3.b share-post async endpoint accepts Accept: application/json,
    returns {brief, line_text, recipient_display}; intercepted by the
    inline JS in post.html's existing #id_share_form. That JS moves into
    _bud_panel.html.
  - body.page-billboard class is set by billboard:billboard view; post.html
    needs it (or its own class) added in billboard.views.view_post.
"""

from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys

from apps.applets.models import Applet
from apps.billboard.models import Brief, Line, Post
from apps.epic.models import GateSlot, Room, RoomInvite
from apps.lyric.models import User

from .base import FunctionalTest


def _seed_a_post(user):
    """Create a Post w. one Line so view_post renders w/o redirect."""
    p = Post.objects.create(owner=user, title="seed line")
    Line.objects.create(post=p, text="seed line", author=user)
    return p


class BudBtnPresenceTest(FunctionalTest):
    """The bud btn is post-only — present on post.html, absent elsewhere."""

    def setUp(self):
        super().setUp()
        Applet.objects.get_or_create(
            slug="my-posts",
            defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"},
        )
        self.gamer = User.objects.create(email="bud@test.io")
        self.post = _seed_a_post(self.gamer)
        self.create_pre_authenticated_session("bud@test.io")

    # ── B1 ──────────────────────────────────────────────────────────────────

    def test_bud_btn_renders_on_post_html(self):
        self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
        btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
        icon = btn.find_element(By.CSS_SELECTOR, "i.fa-solid.fa-handshake")
        self.assertIsNotNone(icon)

    # ── B2 ──────────────────────────────────────────────────────────────────

    def test_bud_btn_absent_on_dashboard(self):
        self.browser.get(self.live_server_url + "/")
        # Allow page to settle
        self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn"))
        self.assertFalse(self.browser.find_elements(By.ID, "id_bud_btn"))

    # ── B3 ──────────────────────────────────────────────────────────────────

    def test_bud_btn_absent_on_billboard_index(self):
        self.browser.get(self.live_server_url + "/billboard/")
        self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn"))
        self.assertFalse(self.browser.find_elements(By.ID, "id_bud_btn"))


class BudBtnPositionTest(FunctionalTest):
    """The btn sits bottom-left, mirror of #id_kit_btn's bottom-right."""

    def setUp(self):
        super().setUp()
        self.gamer = User.objects.create(email="bud@test.io")
        self.post = _seed_a_post(self.gamer)
        self.create_pre_authenticated_session("bud@test.io")
        self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")

    def test_bud_btn_is_fixed_bottom_left(self):
        btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
        cs = self.browser.execute_script(
            "var s = getComputedStyle(arguments[0]); "
            "return {position: s.position, bottom: s.bottom, left: s.left, right: s.right};",
            btn,
        )
        self.assertEqual(cs["position"], "fixed")
        # bottom + left are non-auto/0; right is auto (or large) so the btn
        # hugs the bottom-left corner.
        self.assertNotEqual(cs["bottom"], "auto")
        self.assertNotEqual(cs["left"], "auto")

    def test_bud_btn_size_matches_kit_btn(self):
        """Same circular-3rem look — visually a mirror pair."""
        btn = self.browser.find_element(By.ID, "id_bud_btn")
        kit = self.browser.find_element(By.ID, "id_kit_btn")
        b_box = btn.size
        k_box = kit.size
        self.assertEqual(b_box["width"], k_box["width"])
        self.assertEqual(b_box["height"], k_box["height"])


class BudBtnSlideOutTest(FunctionalTest):
    """Click the bud btn → recipient field + OK btn slide out under it."""

    def setUp(self):
        super().setUp()
        self.gamer = User.objects.create(email="bud@test.io")
        self.post = _seed_a_post(self.gamer)
        self.create_pre_authenticated_session("bud@test.io")
        self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")

    def test_recipient_field_hidden_until_click(self):
        """Pre-click: the field is in DOM but visually closed (e.g. width 0
        or transform scaleX(0)) — assertion checks it doesn't take its full
        viewport-spanning width yet."""
        btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
        # The field+OK panel is rendered (so JS can transition it) but should
        # be in a closed state — assert the panel container exists and the
        # input is not displayed at full width (a CSS-driven slide-out).
        panel = self.browser.find_element(By.ID, "id_bud_panel")
        # Before click, panel visible-width should be < viewport / 2 (closed)
        viewport_w = self.browser.execute_script("return window.innerWidth;")
        self.assertLess(panel.size["width"], viewport_w / 2)

    def test_click_bud_btn_reveals_recipient_field_and_ok_btn(self):
        btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
        btn.click()

        recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
        self.assertTrue(recipient.is_displayed())

        # OK btn is .btn.btn-confirm tacked onto the panel — not a big SHARE
        ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm")
        self.assertEqual(ok.text.strip().upper(), "OK")
        # Bud btn picks up .active when open (mirror kit-btn pattern)
        self.assertIn("active", btn.get_attribute("class"))

    def test_panel_spans_almost_full_viewport_when_open(self):
        """When open, the panel spans 100vw - 3rem (1.5rem each side)."""
        btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
        btn.click()
        panel = self.browser.find_element(By.ID, "id_bud_panel")
        # Wait for the slide-out transition to settle
        self.wait_for(lambda: self.assertGreater(
            panel.size["width"],
            self.browser.execute_script("return window.innerWidth;") * 0.7,
        ))

    def test_recipient_input_has_left_padding_so_glyph_doesnt_overlap(self):
        btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
        btn.click()
        recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
        pad = self.browser.execute_script(
            "return parseFloat(getComputedStyle(arguments[0]).paddingLeft);",
            recipient,
        )
        # At least 2.5rem (40px-ish) so the bud glyph (3rem circle) doesn't
        # overlap the placeholder/typed text.
        self.assertGreaterEqual(pad, 32)


class BudKitMutualExclusionTest(FunctionalTest):
    """When kit btn is active, bud btn fades to 0 — and vice-versa."""

    def setUp(self):
        super().setUp()
        self.gamer = User.objects.create(email="bud@test.io")
        self.post = _seed_a_post(self.gamer)
        self.create_pre_authenticated_session("bud@test.io")
        self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")

    def test_bud_active_fades_kit_btn(self):
        bud = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
        kit = self.browser.find_element(By.ID, "id_kit_btn")
        bud.click()
        self.wait_for(lambda: self.assertEqual(
            self.browser.execute_script(
                "return parseFloat(getComputedStyle(arguments[0]).opacity);",
                kit,
            ),
            0.0,
        ))

    def test_kit_active_fades_bud_btn(self):
        kit = self.wait_for(lambda: self.browser.find_element(By.ID, "id_kit_btn"))
        bud = self.browser.find_element(By.ID, "id_bud_btn")
        kit.click()
        self.wait_for(lambda: self.assertEqual(
            self.browser.execute_script(
                "return parseFloat(getComputedStyle(arguments[0]).opacity);",
                bud,
            ),
            0.0,
        ))


class BudBtnDismissTest(FunctionalTest):
    """Click outside / Escape closes the panel; field is cleared; reopening
    shows the placeholder, not the previously-typed value."""

    def setUp(self):
        super().setUp()
        self.gamer = User.objects.create(email="bud@test.io")
        self.post = _seed_a_post(self.gamer)
        self.create_pre_authenticated_session("bud@test.io")
        self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")

    def _open_and_type(self, text):
        btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
        btn.click()
        recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
        recipient.send_keys(text)
        return btn, recipient

    def test_click_outside_dismisses_and_clears(self):
        btn, recipient = self._open_and_type("alice@test.io")
        # Click somewhere far from the panel + btn
        body = self.browser.find_element(By.TAG_NAME, "body")
        self.browser.execute_script(
            "var e = new MouseEvent('click', {bubbles:true, clientX: 100, clientY: 100});"
            "document.querySelector('h2').dispatchEvent(e);"
        )
        self.wait_for(lambda: self.assertNotIn("active", btn.get_attribute("class")))
        # Reopening: input value should be cleared
        btn.click()
        recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
        self.assertEqual(recipient.get_attribute("value"), "")

    def test_escape_dismisses_and_clears(self):
        btn, recipient = self._open_and_type("alice@test.io")
        recipient.send_keys(Keys.ESCAPE)
        self.wait_for(lambda: self.assertNotIn("active", btn.get_attribute("class")))
        btn.click()
        recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
        self.assertEqual(recipient.get_attribute("value"), "")


class BudBtnOkSubmitsAsyncShareTest(FunctionalTest):
    """OK → POST share-post (Accept:application/json) → Brief banner +
    recipient chip appended; field clears; panel closes."""

    def setUp(self):
        super().setUp()
        self.sharer = User.objects.create(email="bud@test.io")
        self.recipient = User.objects.create(email="alice@test.io")
        self.post = _seed_a_post(self.sharer)
        self.create_pre_authenticated_session("bud@test.io")
        self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")

    def test_ok_creates_brief_appends_line_and_chip(self):
        btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
        btn.click()
        recipient_input = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
        recipient_input.send_keys("alice@test.io")
        ok = self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm")
        ok.click()

        # 1. Brief is created server-side
        self.wait_for(lambda: self.assertEqual(
            Brief.objects.filter(owner=self.sharer, kind=Brief.KIND_SHARE_INVITE).count(),
            1,
        ))
        # 2. Banner appears
        self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner"))
        # 3. .post-recipient chip appended
        self.wait_for(lambda: self.assertTrue(
            self.browser.find_elements(By.CSS_SELECTOR, ".post-recipient")
        ))
        # 4. Field cleared
        recipient_input = self.browser.find_element(By.ID, "id_recipient")
        self.assertEqual(recipient_input.get_attribute("value"), "")
        # 5. Panel closed (btn no longer .active)
        self.wait_for(lambda: self.assertNotIn("active", btn.get_attribute("class")))


class PostHtmlAperturePageClassTest(FunctionalTest):
    """post.html and my_posts.html should pick up the aperture body class
    so they share the navbar/footer scroll-lock pattern w. sky.html etc."""

    def setUp(self):
        super().setUp()
        Applet.objects.get_or_create(
            slug="my-posts",
            defaults={"name": "My Posts", "grid_cols": 4, "grid_rows": 3, "context": "billboard"},
        )
        self.gamer = User.objects.create(email="bud@test.io")
        self.post = _seed_a_post(self.gamer)
        self.create_pre_authenticated_session("bud@test.io")

    def test_post_html_body_carries_billboard_or_post_page_class(self):
        self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")
        body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body"))
        cls = body.get_attribute("class")
        self.assertTrue(
            "page-billboard" in cls or "page-billpost" in cls,
            f"post.html body class missing aperture marker: {cls!r}",
        )

    def test_my_posts_html_body_carries_billboard_or_post_page_class(self):
        self.browser.get(self.live_server_url + f"/billboard/users/{self.gamer.id}/")
        body = self.wait_for(lambda: self.browser.find_element(By.TAG_NAME, "body"))
        cls = body.get_attribute("class")
        self.assertTrue(
            "page-billboard" in cls or "page-billpost" in cls,
            f"my_posts.html body class missing aperture marker: {cls!r}",
        )


class BudBtnDuplicateShareErrorTest(FunctionalTest):
    """Re-sharing a post w. someone already in post.shared_with triggers
    the error Brief titled `@<username> is already present`. FYI on the
    Brief dismisses + adds .bud-duplicate-flash to the existing
    .post-recipient[data-user-id=…] chip."""

    def setUp(self):
        super().setUp()
        self.sharer = User.objects.create(email="bud@test.io", username="bud")
        self.alice = User.objects.create(email="alice@test.io", username="alice")
        self.post = _seed_a_post(self.sharer)
        self.post.shared_with.add(self.alice)
        self.create_pre_authenticated_session("bud@test.io")
        self.browser.get(self.live_server_url + f"/billboard/post/{self.post.id}/")

    def test_duplicate_share_shows_error_brief_and_fyi_flashes_chip(self):
        btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
        btn.click()
        recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
        recipient.send_keys("alice@test.io")
        self.browser.find_element(By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm").click()

        title = self.wait_for(lambda: self.browser.find_element(
            By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__title"
        ))
        self.assertEqual(title.text, "@alice is already present")

        # Existing chip carries data-user-id so the FYI highlight can find it
        chip = self.browser.find_element(
            By.CSS_SELECTOR, f".post-recipient[data-user-id='{self.alice.id}']"
        )
        self.assertNotIn("bud-duplicate-flash", chip.get_attribute("class") or "")

        # FYI dismisses + applies flash class
        self.wait_for(lambda: self.browser.execute_script(
            "arguments[0].click()",
            self.browser.find_element(
                By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__fyi"
            ),
        ))
        self.wait_for(lambda: self.assertEqual(
            self.browser.find_elements(By.CSS_SELECTOR, ".note-banner--duplicate"),
            [],
        ))
        self.wait_for(lambda: self.assertIn(
            "bud-duplicate-flash", chip.get_attribute("class") or ""
        ))


# ─────────────────────────────────────────────────────────────────────────────
#
# Gatekeeper variant — bud-btn upper-right (footer in landscape) on room.html.
# Replaces the legacy inline `<form action="invite_gamer">` panel inside the
# gatekeeper modal. The bud-btn slide-out hosts the email/username field +
# OK btn; submit fires async POST to epic:invite_gamer w. Accept:
# application/json — server returns {brief, recipient_display, ...}; JS shows
# the slide-down Brief banner.
#
# ─────────────────────────────────────────────────────────────────────────────


class GatekeeperBudBtnPresenceTest(FunctionalTest):
    """The bud-btn renders for the room owner during gate phase, and is
    absent for non-owners (friend invites are owner-only)."""

    def setUp(self):
        super().setUp()
        self.owner = User.objects.create(email="owner@test.io", username="owner")
        self.gamer = User.objects.create(email="gamer@test.io", username="gamer")
        self.room = Room.objects.create(name="Bingobango", owner=self.owner)
        self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"

    def test_bud_btn_renders_for_owner(self):
        self.create_pre_authenticated_session("owner@test.io")
        self.browser.get(self.room_url)
        self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))

    def test_bud_btn_absent_for_non_owner(self):
        # A registered non-owner viewer doesn't see the invite affordance.
        self.create_pre_authenticated_session("gamer@test.io")
        self.browser.get(self.room_url)
        # Gatekeeper-specific element confirms page rendered
        self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal"))
        self.assertFalse(self.browser.find_elements(By.ID, "id_bud_btn"))

    def test_legacy_invite_email_input_is_gone(self):
        """Sanity: the old inline form has been removed."""
        self.create_pre_authenticated_session("owner@test.io")
        self.browser.get(self.room_url)
        self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".gate-modal"))
        self.assertFalse(self.browser.find_elements(By.ID, "id_invite_email"))


class GatekeeperBudBtnAsyncInviteTest(FunctionalTest):
    """OK on the bud-btn slide-out fires the async invite — RoomInvite
    persisted, Brief w/ kind=GAME_INVITE created, slide-down banner shown."""

    def setUp(self):
        super().setUp()
        self.owner = User.objects.create(email="owner@test.io", username="owner")
        self.alice = User.objects.create(email="alice@test.io", username="alice")
        self.room = Room.objects.create(name="Bingobango", owner=self.owner)
        self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
        self.create_pre_authenticated_session("owner@test.io")
        self.browser.get(self.room_url)

    def _open_panel_and_invite(self, recipient):
        bud_btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
        bud_btn.click()
        recipient_input = self.wait_for(
            lambda: self.browser.find_element(By.ID, "id_recipient")
        )
        recipient_input.send_keys(recipient)
        # wait_for + execute_script bypasses Selenium's scroll-into-view
        # gate — under CI contention the post-send_keys autocomplete dropdown
        # can briefly overlap the OK button + Firefox refuses the click w.
        # ElementNotInteractableException (pipeline #304). Same shape as
        # confirm_guard in base.py.
        self.wait_for(lambda: self.browser.execute_script(
            "arguments[0].click()",
            self.browser.find_element(
                By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
            ),
        ))
        return bud_btn

    def test_invite_creates_room_invite(self):
        self._open_panel_and_invite("alice@test.io")
        self.wait_for(lambda: self.assertEqual(
            RoomInvite.objects.filter(
                room=self.room, invitee_email="alice@test.io"
            ).count(),
            1,
        ))

    def test_invite_spawns_game_invite_brief(self):
        self._open_panel_and_invite("alice@test.io")
        self.wait_for(lambda: self.assertEqual(
            Brief.objects.filter(
                owner=self.owner, kind=Brief.KIND_GAME_INVITE,
            ).count(),
            1,
        ))

    def test_invite_renders_slide_down_banner(self):
        self._open_panel_and_invite("alice@test.io")
        self.wait_for(lambda: self.browser.find_element(By.CSS_SELECTOR, ".note-banner"))

    def test_invite_closes_panel_after_success(self):
        bud_btn = self._open_panel_and_invite("alice@test.io")
        self.wait_for(lambda: self.assertNotIn("active", bud_btn.get_attribute("class")))

    def test_invite_username_resolves_to_user_email(self):
        """Username-typed invite stores the resolved User's email."""
        self._open_panel_and_invite("alice")
        self.wait_for(lambda: self.assertEqual(
            RoomInvite.objects.filter(
                room=self.room, invitee_email="alice@test.io"
            ).count(),
            1,
        ))

    def test_invite_auto_adds_recipient_to_owner_buds(self):
        self._open_panel_and_invite("alice@test.io")
        self.wait_for(lambda: self.assertIn(
            self.alice, list(self.owner.buds.all())
        ))


class GatekeeperBudBtnDuplicateInviteErrorTest(FunctionalTest):
    """Re-inviting a recipient already seated in the room triggers the
    error Brief titled `@<username> is already present`. FYI on the Brief
    dismisses + adds .bud-duplicate-flash to the existing
    .gate-slot.filled[data-user-id=…] element. Pending-but-unseated
    duplicates also surface the Brief but FYI has no slot to highlight."""

    def setUp(self):
        super().setUp()
        self.owner = User.objects.create(email="owner@test.io", username="owner")
        self.alice = User.objects.create(email="alice@test.io", username="alice")
        self.room = Room.objects.create(name="Dup Room", owner=self.owner)
        # Seat alice via a GateSlot — _gate_positions renders .gate-slot.filled
        # cells from GateSlot records (TableSeat spins up later at SIG SELECT),
        # so the duplicate-highlight target lives there during gatekeeper phase.
        GateSlot.objects.create(
            room=self.room, gamer=self.alice, slot_number=1,
            status=GateSlot.FILLED,
        )
        self.room_url = f"{self.live_server_url}/gameboard/room/{self.room.id}/gate/"
        self.create_pre_authenticated_session("owner@test.io")
        self.browser.get(self.room_url)

    def test_duplicate_invite_shows_error_brief_and_fyi_flashes_slot(self):
        btn = self.wait_for(lambda: self.browser.find_element(By.ID, "id_bud_btn"))
        btn.click()
        recipient = self.wait_for(lambda: self.browser.find_element(By.ID, "id_recipient"))
        recipient.send_keys("alice@test.io")
        # See _open_panel_and_invite (above) for the bypass rationale.
        self.wait_for(lambda: self.browser.execute_script(
            "arguments[0].click()",
            self.browser.find_element(
                By.CSS_SELECTOR, "#id_bud_panel .btn.btn-confirm"
            ),
        ))

        title = self.wait_for(lambda: self.browser.find_element(
            By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__title"
        ))
        self.assertEqual(title.text, "@alice is already present")

        # No new RoomInvite or Brief persisted server-side on duplicate
        self.assertFalse(RoomInvite.objects.filter(
            room=self.room, invitee_email="alice@test.io",
        ).exists())
        self.assertEqual(
            Brief.objects.filter(owner=self.owner, kind=Brief.KIND_GAME_INVITE).count(),
            0,
        )

        slot = self.browser.find_element(
            By.CSS_SELECTOR, f".gate-slot.filled[data-user-id='{self.alice.id}']"
        )
        self.assertNotIn("bud-duplicate-flash", slot.get_attribute("class") or "")

        self.wait_for(lambda: self.browser.execute_script(
            "arguments[0].click()",
            self.browser.find_element(
                By.CSS_SELECTOR, ".note-banner--duplicate .note-banner__fyi"
            ),
        ))
        self.wait_for(lambda: self.assertEqual(
            self.browser.find_elements(By.CSS_SELECTOR, ".note-banner--duplicate"),
            [],
        ))
        self.wait_for(lambda: self.assertIn(
            "bud-duplicate-flash", slot.get_attribute("class") or ""
        ))
