"""Game-views carousel — RED outside-in FTs (planned 2026-06-02, build next).

The room's 2nd vertical scroll-snap pane (the Game Scroll) becomes a HORIZONTAL
carousel of five views, reached by scrolling DOWN from the table-hex:

    ATLAS  ◀  SCROLL  ▶  POST  ▶  CHAT  ▶  PULSE
    (book-atlas) (scroll) (keyboard) (comments) (chart-pie)

- A root-level ICON STRIP sits centred at the aperture bottom (above the scroll
  card's fade mask), shown only while the views pane is in view. The active
  view's icon carries the --ninUser/--terUser glow; the others are --secUser @0.6.
  SCROLL is the 2nd icon and the default landing view.
- Nav: native horizontal scroll-snap + horizontal mouse-wheel (and shift+wheel);
  vertical wheel stays reserved for scrolling each feed. Icon-click also snaps.
- The h2 title reel gains a horizontal axis: the 2nd word cycles
  ROOM ⇄ SCROLL/POST/ATLAS/CHAT/PULSE (built on the existing .gr-swap reel).
- The burger fan's #id_text_btn leads to POST: from the hex it swipes DOWN to
  the views pane then RIGHT to the Post view (the "swipe machine").
- POST is a room-scoped thread reusing post.html's #id_post_table + the
  #id_post_line_text composer. ATLAS aggregates GameEvents + the room's posts,
  time-merged, each source styled distinctly. CHAT + PULSE are stubs this sprint.

These tests fail until the feature lands — they ARE the contract for the build.
FT bucket: game_room. IT/UT coverage (view context, room↔Post link, aggregate
merge) is written beneath these as the build proceeds.
"""
from selenium.webdriver.common.by import By

from .base import FunctionalTest
from .room_page import _equip_earthman_deck, _fill_room_via_orm
from apps.drama.models import GameEvent
from apps.epic.models import Room, TableSeat
from apps.lyric.models import User

VIEW_ORDER = ["atlas", "scroll", "yarn", "post", "pulse"]


class GameViewsCarouselTest(FunctionalTest):
    def setUp(self):
        super().setUp()
        self.viewer = User.objects.create(email="disco@test.io", username="disco")
        _equip_earthman_deck(self.viewer)
        self.room = Room.objects.create(name="Whataburgher", owner=self.viewer)
        _fill_room_via_orm(
            self.room,
            ["disco@test.io", "amigo@test.io", "bud@test.io",
             "pal@test.io", "dude@test.io", "bro@test.io"],
        )
        # Seat disco + amigo + bud (a TableSeat = committed to a seat). pal/
        # dude/bro stay gate-slot-only (transient depositors) → they must NOT
        # appear as chat participants nor be able to post to the thread.
        for slot_n, email, role in [(1, "disco@test.io", "PC"),
                                    (2, "amigo@test.io", "NC"),
                                    (3, "bud@test.io", "EC")]:
            TableSeat.objects.create(
                room=self.room, gamer=User.objects.get(email=email),
                slot_number=slot_n, role=role,
            )
        # A provenance line so the Scroll + Atlas feeds have content.
        GameEvent.objects.create(
            room=self.room, actor=self.viewer, verb=GameEvent.SLOT_FILLED,
            data={"token_type": "carte", "token_display": "Carte Blanche",
                  "slot_number": 1, "renewal_days": 7},
        )
        # A retracted SIG_READY (struck → data-label="redact") so the Atlas can
        # be checked for SCROLL-style strikethrough + redact-filter coupling.
        GameEvent.objects.create(
            room=self.room, actor=self.viewer, verb=GameEvent.SIG_READY,
            data={"retracted": True, "card_name": "The Nomad"},
        )
        self.room.table_status = Room.ROLE_SELECT
        self.room.gate_status = Room.OPEN
        self.room.save()
        self.url = self.live_server_url + f"/gameboard/room/{self.room.id}/"

    # ── helpers ──────────────────────────────────────────────────────────
    def _open(self, size=(820, 600)):
        self.create_pre_authenticated_session("disco@test.io")
        self.browser.set_window_size(*size)
        self.browser.get(self.url)
        return self.wait_for(
            lambda: self.browser.find_element(By.CSS_SELECTOR, ".room-aperture"))

    def _scroll_to_views(self):
        # Drop the aperture onto its 2nd vertical snap pane (the views carousel).
        self.browser.execute_script(
            "document.querySelector('.room-aperture').scrollTop = 99999;")
        self.wait_for(lambda: self.assertTrue(
            self.browser.find_element(By.ID, "id_room_views_strip").is_displayed()))

    def _in_viewport(self, css):
        return self.browser.execute_script(
            "var e=document.querySelector(arguments[0]); if(!e) return false;"
            "var r=e.getBoundingClientRect();"
            "return r.left < window.innerWidth && r.right > 0"
            " && r.top < window.innerHeight && r.bottom > 0;", css)

    def _active_view(self):
        return self.browser.find_element(
            By.CSS_SELECTOR, ".room-view-icon.is-active").get_attribute("data-view")

    def _click_icon(self, view):
        self.browser.execute_script(
            "document.querySelector(arguments[0]).click();",
            f".room-view-icon[data-view='{view}']")

    # ── tests ────────────────────────────────────────────────────────────
    def test_icon_strip_hidden_at_hex_shown_in_views_with_scroll_active(self):
        """At the table-hex the strip is hidden; scrolling down reveals it with
        five icons in order [atlas, scroll, yarn, post, pulse] and SCROLL (2nd)
        active + glowing."""
        self._open()
        self.assertFalse(
            self.browser.find_element(By.ID, "id_room_views_strip").is_displayed())

        self._scroll_to_views()
        icons = self.browser.find_elements(
            By.CSS_SELECTOR, "#id_room_views_strip .room-view-icon")
        self.assertEqual([i.get_attribute("data-view") for i in icons], VIEW_ORDER)
        # SCROLL is the default landing view + carries the active glow class.
        self.assertEqual(self._active_view(), "scroll")
        scroll_icon = self.browser.find_element(
            By.CSS_SELECTOR, ".room-view-icon[data-view='scroll']")
        self.assertIn("fa-scroll", scroll_icon.get_attribute("innerHTML"))

    def test_clicking_icon_snaps_view_moves_glow_and_title(self):
        """Clicking the POST icon snaps to the Post view, hands the glow to the
        post icon, and advances the title reel to GAME POST."""
        self._open()
        self._scroll_to_views()
        self.assertTrue(self._in_viewport(".room-view[data-view='scroll']"))

        self._click_icon("post")
        self.wait_for(lambda: self.assertTrue(
            self._in_viewport(".room-view[data-view='post']")))
        self.assertEqual(self._active_view(), "post")
        # Glow handoff: scroll icon is no longer active.
        self.assertNotIn("is-active", self.browser.find_element(
            By.CSS_SELECTOR, ".room-view-icon[data-view='scroll']"
        ).get_attribute("class"))
        # Title reel reveals the active view's name.
        self.assertEqual(self.browser.find_element(
            By.CSS_SELECTOR, ".row .col-lg-6 h2").get_attribute("data-active-view"),
            "post")

    def test_horizontal_wheel_advances_to_next_view(self):
        """A horizontal wheel tick (deltaX) advances to the next view in line —
        the desktop-mouse equivalent of a swipe. Vertical wheel must NOT change
        the view (it scrolls the feed)."""
        self._open()
        self._scroll_to_views()
        self.assertEqual(self._active_view(), "scroll")
        # Horizontal wheel right → next view in line (yarn, between scroll & post).
        self.browser.execute_script(
            "document.querySelector('#id_room_views').dispatchEvent("
            "new WheelEvent('wheel', {deltaX: 120, deltaY: 0, bubbles: true}));")
        self.wait_for(lambda: self.assertEqual(self._active_view(), "yarn"))

    def test_text_btn_from_hex_swipes_down_to_the_reelhouse(self):
        """The burger fan's #id_text_btn (fa-keyboard) is active on the table;
        clicking it from the hex runs the swipe machine — first DOWN to the
        reelhouse (the views pane), which brings the icon strip on screen.

        The follow-on OVER-to-POST beat is a delayed (post-descent + 0.5s hold)
        programmatic scroll that headless Firefox drops, so that nav DECISION is
        pinned in the Jasmine swipe-machine spec instead; here we assert the
        reliable descent beat + that the swipe was even triggered."""
        self._open()
        # At the hex the strip is hidden.
        self.assertFalse(
            self.browser.find_element(By.ID, "id_room_views_strip").is_displayed())
        # Burger fan: open it, then click the active Text sub-btn.
        self.browser.execute_script(
            "document.getElementById('id_burger_btn').click();")
        text_btn = self.browser.find_element(By.ID, "id_text_btn")
        self.assertIn("active", text_btn.get_attribute("class"))
        self.browser.execute_script("arguments[0].click();", text_btn)

        # Swipe DOWN: the reelhouse comes on screen → the icon strip reveals.
        self.wait_for(lambda: self.assertTrue(
            self.browser.find_element(By.ID, "id_room_views_strip").is_displayed()))
        self.assertTrue(self._in_viewport("#id_room_views"))

    def test_post_view_is_room_thread_with_working_composer(self):
        """The Post view embeds the room-scoped post thread: the #id_post_line_text
        composer appends a line to #id_post_table that everyone at the table reads."""
        self._open()
        self._scroll_to_views()
        self._click_icon("post")
        self.wait_for(lambda: self.assertTrue(
            self._in_viewport(".room-view[data-view='post'] #id_post_line_text")))

        box = self.browser.find_element(
            By.CSS_SELECTOR, ".room-view[data-view='post'] #id_post_line_text")
        box.send_keys("lets gooo")
        self.browser.find_element(
            By.CSS_SELECTOR, ".room-view[data-view='post'] #id_post_line_btn").click()
        self.wait_for(lambda: self.assertIn(
            "lets gooo",
            self.browser.find_element(
                By.CSS_SELECTOR, ".room-view[data-view='post'] #id_post_table").text))

    def test_atlas_aggregates_provenance_and_posts(self):
        """The Atlas view merges the room's GameEvents AND its post-lines into one
        time-ordered feed, each source carrying a distinct data-source so per-type
        styling can land at the end of the sprint."""
        self._open()
        self._scroll_to_views()
        # Post a line first so Atlas has a post-source row to merge.
        self._click_icon("post")
        self.wait_for(lambda: self.assertTrue(
            self._in_viewport(".room-view[data-view='post'] #id_post_line_text")))
        box = self.browser.find_element(
            By.CSS_SELECTOR, ".room-view[data-view='post'] #id_post_line_text")
        box.send_keys("door creaks open")
        self.browser.find_element(
            By.CSS_SELECTOR, ".room-view[data-view='post'] #id_post_line_btn").click()

        self._click_icon("atlas")
        self.wait_for(lambda: self.assertTrue(
            self._in_viewport(".room-view[data-view='atlas']")))
        atlas = self.browser.find_element(
            By.CSS_SELECTOR, ".room-view[data-view='atlas']")
        # Both a provenance row and a post row are present, distinctly sourced.
        self.assertIn("deposits a Carte Blanche", atlas.text)
        self.assertIn("door creaks open", atlas.text)
        self.assertTrue(atlas.find_elements(
            By.CSS_SELECTOR, "[data-source='provenance']"))
        self.assertTrue(atlas.find_elements(
            By.CSS_SELECTOR, "[data-source='post']"))

    def test_atlas_provenance_rows_carry_scroll_strikethrough(self):
        """A retracted/redacted SCROLL log reads the same strikethrough in the
        merged ATLAS as it does in SCROLL (`.atlas-row-body.struck`)."""
        self._open()
        self._scroll_to_views()
        self._click_icon("atlas")
        self.wait_for(lambda: self.assertTrue(
            self._in_viewport(".room-view[data-view='atlas']")))
        atlas = self.browser.find_element(
            By.CSS_SELECTOR, ".room-view[data-view='atlas']")
        self.assertTrue(atlas.find_elements(
            By.CSS_SELECTOR, ".atlas-row-body.struck"))

    def test_atlas_omits_redact_logs_filtered_out_on_scroll(self):
        """Unchecking Redact in the SCROLL gear filter hides the struck logs in
        the feed — and they must not surface in the merged ATLAS either."""
        self._open()
        self._scroll_to_views()   # lands on SCROLL → gear shows the log filter
        gear = self.browser.find_element(
            By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_room_menu']")
        self.browser.execute_script("arguments[0].click();", gear)
        self.wait_for(lambda: self.assertTrue(
            self.browser.find_element(By.ID, "id_scroll_filter_form").is_displayed()))
        redact = self.browser.find_element(
            By.CSS_SELECTOR, "#id_scroll_filter_form input[value='redact']")
        if redact.is_selected():
            self.browser.execute_script("arguments[0].click();", redact)
        self.browser.execute_script("arguments[0].click();", self.browser.find_element(
            By.CSS_SELECTOR, "#id_scroll_filter_form button[type='submit']"))

        self._click_icon("atlas")
        self.wait_for(lambda: self.assertTrue(
            self._in_viewport(".room-view[data-view='atlas']")))
        atlas = self.browser.find_element(
            By.CSS_SELECTOR, ".room-view[data-view='atlas']")
        # Redact logs (the only struck rows) were filtered out → none in ATLAS.
        self.assertFalse(atlas.find_elements(
            By.CSS_SELECTOR, ".atlas-row-body.struck"))

    def test_gear_menu_swaps_per_view_and_disables_on_stubs(self):
        """Each reelhouse view has its own gear: SCROLL → the Frame/Redact log
        filter, ATLAS → its source checkboxes; YARN/POST/PULSE → disabled (an
        active click does not open a menu)."""
        self._open()
        self._scroll_to_views()   # lands on SCROLL, on the reelhouse
        gear = self.browser.find_element(
            By.CSS_SELECTOR, ".gear-btn[data-menu-target='id_room_menu']")
        menu = self.browser.find_element(By.ID, "id_room_menu")

        # SCROLL view → the Frame/Redact filter.
        self.browser.execute_script("arguments[0].click();", gear)
        self.wait_for(lambda: self.assertTrue(
            self.browser.find_element(By.ID, "id_scroll_filter_form").is_displayed()))

        # ATLAS view → the source checkboxes (the icon-click closes the menu).
        self._click_icon("atlas")
        self.wait_for(lambda: self.assertTrue(
            self._in_viewport(".room-view[data-view='atlas']")))
        self.browser.execute_script("arguments[0].click();", gear)
        self.wait_for(lambda: self.assertTrue(
            self.browser.find_element(By.ID, "id_atlas_source_form").is_displayed()))

        # YARN view → gear disabled; an active click does NOT open the menu.
        self._click_icon("yarn")
        self.wait_for(lambda: self.assertIn(
            "gear-disabled", gear.get_attribute("class")))
        self.browser.execute_script("arguments[0].click();", gear)
        self.assertFalse(menu.is_displayed())

    def test_yarn_and_pulse_render_as_stubs(self):
        """YARN (fa-route) + PULSE (fa-chart-pie) are stub views this sprint —
        each renders a placeholder, no backing model yet. The watermark icon
        rests ABOVE the [Feature forthcoming] label (the shared partial centres
        itself absolutely, so without the stub override it would overlap the
        icon dead-centre)."""
        self._open()
        self._scroll_to_views()

        def _rect(css):
            return self.browser.execute_script(
                "return document.querySelector(arguments[0]).getBoundingClientRect();", css)

        for view in ("yarn", "pulse"):
            self._click_icon(view)
            self.wait_for(lambda v=view: self.assertTrue(
                self._in_viewport(f".room-view[data-view='{v}']")))
            stub_sel = f".room-view[data-view='{view}'] .room-view-stub"
            self.assertTrue(
                self.browser.find_element(By.CSS_SELECTOR, stub_sel).is_displayed())
            # Icon's bottom edge clears the label's top edge — stacked, no clip.
            icon = _rect(f"{stub_sel} i")
            label = _rect(f"{stub_sel} .forthcoming")
            self.assertLessEqual(
                icon["bottom"], label["top"],
                f"{view} watermark icon overlaps the forthcoming label")

    def test_icon_strip_clears_the_aperture_mask_in_landscape(self):
        """In landscape the strip sits at the aperture bottom, above the scroll
        card's fade mask (not obscured) — its box stays within the viewport."""
        self._open(size=(940, 520))
        self._scroll_to_views()
        strip = self.browser.find_element(By.ID, "id_room_views_strip")
        self.assertTrue(strip.is_displayed())
        within = self.browser.execute_script(
            "var r=arguments[0].getBoundingClientRect();"
            "return r.bottom <= window.innerHeight && r.top >= 0"
            " && r.right <= window.innerWidth && r.left >= 0;", strip)
        self.assertTrue(within)
