"""A scripted stand-in for the Marionette session.

Faked at the Marionette level, not at the seat level, so `BrowserSeat.turn` runs the
real `chat.py` path — surface steering, effort setting, typing, send, block
extraction. A fake seat would only test itself.
"""

from __future__ import annotations

import re

import chat
import solwebd


class FakeMarionette:
    """Answers the exact scripts `chat.py` and `solwebd.py` send, by source identity."""

    def __init__(self, replies: list[dict]):
        self.replies = list(replies)
        self.typed: list[str] = []
        self.efforts: list[str] = []
        self.navigations: list[str] = []
        self.cap: dict | None = None
        self.turns = 0
        self.user_turns = 0
        self.clicks = 0
        self.drop_clicks = 0  # sends the app swallows before it accepts one
        self._pending = ""
        self._delivered = False  # a click has actually landed for the current prompt
        self._accept_reads = 0  # ACCEPT_STATE polls since that delivery
        self._marked: str | None = None
        self._reply: dict = {"blocks": [], "text": ""}
        # Live-event surface: real reasoning/answer text and a resolvable turn history,
        # scripted independently of `replies` so event tests can drive them explicitly.
        self.no_meta = False  # simulate the "url never became /c/<uuid>" protocol case
        self.thinking_text = ""
        self.thinking_present = False
        self.thinking_readable = True
        self.thinking_expand_calls = 0
        self.hydrate_turns: list[dict] = []
        # Copy-button/clipboard surface (chat.copy_reply_text): a reply dict's "clip"
        # key is the source markdown the button would copy, defaulting to its "text"
        # when absent. Knobs let a test simulate an older UI (no button) or a denied/
        # empty clipboard, both of which must fall back to the HARVEST innerText text.
        self.copy_button_present = True
        self.clipboard_empty = False
        self.clipboard_source = ""
        # Virtualization simulation: ChatGPT unmounts older turns as the view
        # scrolls to a new one, so a rendered count can sit below (or bounce
        # under) any pre-send baseline for the whole turn. Set either override to
        # pin what USER_COUNT/PROGRESS.turns report regardless of the real
        # self.user_turns/self.turns a delivery bumps.
        self.user_count_override: int | None = None
        self.progress_turns_override: int | None = None
        # PROGRESS.stop lifecycle: popped once per PROGRESS call, pinned at the
        # last entry once exhausted. Empty means "never observed" (stop=False
        # always) — the fast-reply-before-first-poll case.
        self.stop_sequence: list[bool] = []
        self.account = "owner@example.com"
        self.document_title = "ChatGPT"

    # --- Marionette surface -------------------------------------------------
    def navigate(self, url: str) -> None:
        self.navigations.append(url)

    def url(self) -> str:
        if self.no_meta:
            return "https://chatgpt.com/"
        for nav in reversed(self.navigations):
            if "/c/" in nav:
                return nav
        n = len(self.navigations)
        return f"https://chatgpt.com/c/00000000-0000-4000-8000-{n:012x}"

    def press(self, key: str = "") -> None:
        self._marked = None

    def click(self, selector: str) -> bool:
        if selector == chat.SEND:
            self.clicks += 1
            if self.drop_clicks > 0:
                self.drop_clicks -= 1
                return True
            self._deliver()
        return True

    def js_click(self, selector: str) -> bool:
        if selector == "[data-gptbridge]":
            if self._marked and self._marked in chat.EFFORT_LABELS.values():
                self.efforts.append(self._marked)
            return True
        return self.click(selector)

    def send_keys(self, selector: str, text: str) -> bool:
        return True

    def screenshot(self, dest):
        return dest

    def sync_script(self, source: str, args: list | None = None):
        args = args or []
        if source == chat.CAP_SCAN:
            if not self.cap:
                return None
            words = re.compile(args[1], re.I)
            return {"text": self.cap["text"]} if words.search(self.cap["text"]) else None
        if source == chat.SURFACE:
            return {"found": True, "switched": False}
        if source == chat.MARK_RE:
            self._marked = "__settings__"
            return "medium"
        if source == chat.MARK:
            self._marked = str(args[0]).lower()
            return self._marked
        if source == chat.MENU_OPTIONS:
            return list(chat.EFFORT_LABELS.values())
        if source == chat.TYPE:
            self._pending = str(args[1])
            self._delivered = False  # a fresh prompt is not yet accepted
            return True
        if source == chat.USER_COUNT:
            if self.user_count_override is not None:
                return self.user_count_override
            return self.user_turns
        if source == chat.TURN_COUNT:
            return self.turns
        if source == chat.PROGRESS:
            turns = (self.progress_turns_override if self.progress_turns_override is not None
                     else self.turns)
            if self.stop_sequence:
                stop = self.stop_sequence.pop(0) if len(self.stop_sequence) > 1 \
                    else self.stop_sequence[0]
            else:
                stop = False
            return {"turns": turns, "imgs": 0, "text": len(self._reply["text"]), "stop": stop}
        if source == chat.ACCEPT_STATE:
            # No streaming is modeled here: a reply is complete the instant it is
            # delivered. The send button is "gone" only for the one poll right
            # after delivery (the real transient stop-button window); every later
            # poll sees it already back, same as a reply that finished streaming.
            gone = self._delivered and self._accept_reads == 0
            self._accept_reads += 1
            return {"sendGone": gone, "composerEmpty": self._delivered}
        if source == chat.HARVEST:
            return {"text": self._reply["text"], "images": [], "url": self.url()}
        if source == chat.COPY_BUTTON:
            return bool(self.copy_button_present)
        if source == solwebd.REPLY_BLOCKS:
            return {"blocks": list(self._reply["blocks"]), "text": self._reply["text"],
                    "url": self.url()}
        if source == chat.LIVE_STATE:
            return {"url": self.url(), "text": self._reply["text"], "turns": self.turns}
        if source == chat.THINKING:
            if not self.thinking_present:
                return {"present": False}
            expand_flag = args[3] if len(args) > 3 else ""
            if not self.thinking_readable:
                self.thinking_expand_calls += 1
                return {"present": True, "expanding": expand_flag != "1"}
            if not self.thinking_text:
                return {"present": True, "expanding": False, "text": ""}
            if expand_flag != "1":
                self.thinking_expand_calls += 1
                return {"present": True, "expanding": True}
            return {"present": True, "expanding": False, "text": self.thinking_text}
        if source == chat.HYDRATE:
            return list(self.hydrate_turns)
        if source == chat.DOCUMENT_TITLE:
            return self.document_title
        if "document.querySelector(arguments[0])" in source:  # wait_ready
            return {"t": "ChatGPT", "c": True}
        raise AssertionError(f"unscripted call: {source[:60]!r}")

    def script(self, source: str, args: list | None = None):
        """The async-script surface (`WebDriver:ExecuteAsyncScript`) — only ever the
        clipboard read."""
        if source == chat.AUTH_SESSION:
            return {"user": {"email": self.account}}
        if source == chat.CLIPBOARD_READ:
            return "" if self.clipboard_empty else self.clipboard_source
        raise AssertionError(f"unscripted async call: {source[:60]!r}")

    # --- scripting ----------------------------------------------------------
    def _deliver(self) -> None:
        self.typed.append(self._pending)
        self._pending = ""
        self._delivered = True
        self._accept_reads = 0
        self.turns += 1
        self.user_turns += 1
        self._reply = self.replies.pop(0) if self.replies else {"blocks": [], "text": ""}
        self.clipboard_source = self._reply.get("clip", self._reply.get("text", ""))


class FakeSession:
    """Context-manager shape `BrowserSeat` expects from `browser.Session`."""

    def __init__(self, marionette: FakeMarionette):
        self.m = marionette

    def __enter__(self):
        return self.m

    def __exit__(self, *exc):
        return False


def seat_for(replies: list[dict]) -> tuple[solwebd.BrowserSeat, FakeMarionette]:
    marionette = FakeMarionette(replies)
    seat = solwebd.BrowserSeat(session_factory=lambda: FakeSession(marionette))
    return seat, marionette


def pool_for(replies_by_seat: list[list[dict]]) -> tuple[solwebd.SeatPool, list[FakeMarionette]]:
    """A SeatPool with one independently-scripted FakeMarionette per seat, so a test
    can tell which physical seat a request actually landed on."""
    marionettes = [FakeMarionette(replies) for replies in replies_by_seat]

    def factory(slot: int) -> FakeSession:
        return FakeSession(marionettes[slot - 1])

    pool = solwebd.SeatPool(max_seats=len(marionettes), session_factory=factory)
    return pool, marionettes


def block(payload: str) -> dict:
    return {"blocks": [payload], "text": payload}
