"""A single browser session pinned to one thread.

Playwright has thread affinity: every call on a browser must happen on the
thread that created it. This class owns one ``BrowserTools`` instance on a
dedicated thread and marshals method calls onto it through a queue.

It is a faithful extraction of ``MainController._browser_call`` /
``_loop`` / ``_teardown_browser_thread`` / ``_is_browser_session_dead`` — same
call-timeout teardown (so a hung op can't head-of-line-block the thread) and
same dead-session rebuild (so a Selenium session that died while a human was
approving gets transparently rebuilt) — but without Flask or MainController, and
with the ``BrowserTools`` factory injected so it is testable with a fake and
needs no real Chromium.

The iframe handling (``_handle_iframe``, used only by ``open_page``) is
deliberately left out: it is editor-context coupled and lands with open_page.
"""

import queue
import threading
from typing import Any, Callable, List, Optional, Tuple

# A hung browser op must not pin the thread forever.
DEFAULT_CALL_TIMEOUT_SECONDS = 180.0
# Bounded wait for the old thread to close its browser/context on teardown.
DEFAULT_TEARDOWN_JOIN_SECONDS = 10.0

_SESSION_DEAD_MARKERS = ("Browser has closed", "session may have expired")

BrowserToolsFactory = Callable[[], Any]

_STOP = None  # sentinel put on the queue to break the loop


def is_session_dead(error: BaseException) -> bool:
    """True when an error means the whole browser session is gone and a fresh
    BrowserTools instance is needed (vs. a normal per-page error)."""
    message = str(error)
    return any(marker in message for marker in _SESSION_DEAD_MARKERS)


class SessionCallTimeout(TimeoutError):
    """Raised when a single browser op exceeds the call timeout; the session has
    been torn down and must be rebuilt (re-open the page and retry)."""


class BrowserSessionThread:
    """Owns a ``BrowserTools`` instance on a dedicated thread and proxies calls
    to it. One of these per live browser session in the pool."""

    def __init__(
        self,
        factory: BrowserToolsFactory,
        *,
        call_timeout_seconds: float = DEFAULT_CALL_TIMEOUT_SECONDS,
        teardown_join_seconds: float = DEFAULT_TEARDOWN_JOIN_SECONDS,
    ) -> None:
        self._factory = factory
        self._call_timeout = call_timeout_seconds
        self._teardown_join = teardown_join_seconds
        self._call_queue: "queue.Queue" = queue.Queue()
        self._thread: Optional[threading.Thread] = None

    @property
    def alive(self) -> bool:
        return self._thread is not None and self._thread.is_alive()

    def _ensure_thread(self) -> None:
        if self.alive:
            return
        ready = threading.Event()
        init_error: List[BaseException] = []
        # Each thread reads from its OWN queue, captured as a closure local — NOT
        # from self._call_queue. If a thread outlives a teardown (its op hung
        # past the join timeout), then on waking it must read the queue it was
        # born with (where teardown put its _STOP), not whatever queue a newer
        # thread has since installed on self._call_queue. Reading the shared
        # attribute is how it would become a zombie that steals commands and runs
        # them against its dead browser.
        q: "queue.Queue" = queue.Queue()
        self._call_queue = q

        def _loop() -> None:
            try:
                bt = self._factory()
            except BaseException as e:  # noqa: BLE001 - surfaced to caller
                init_error.append(e)
                ready.set()
                return
            ready.set()
            try:
                while True:
                    item = q.get()
                    if item is _STOP:
                        break
                    name, args, kwargs, result_q = item
                    try:
                        result_q.put(("ok", getattr(bt, name)(*args, **kwargs)))
                    except BaseException as e:  # noqa: BLE001 - forwarded to caller
                        result_q.put(("error", e))
            finally:
                try:
                    bt.close()
                except Exception:  # noqa: BLE001 - best-effort teardown
                    pass

        t = threading.Thread(target=_loop, name="browser-session", daemon=True)
        t.start()
        ready.wait()
        if init_error:
            self._thread = None
            raise init_error[0]
        self._thread = t

    def teardown(self) -> None:
        """Stop the worker thread so the next call rebuilds a fresh session."""
        t, self._thread = self._thread, None
        if t is None:
            return
        try:
            self._call_queue.put(_STOP)  # breaks the loop; the thread closes bt
        except Exception:  # noqa: BLE001
            pass
        if t.is_alive():
            t.join(timeout=self._teardown_join)

    close = teardown  # alias: closing a session is tearing its thread down

    def call(
        self,
        method_name: str,
        *args: Any,
        _allow_session_retry: bool = True,
        **kwargs: Any,
    ) -> Any:
        """Run ``BrowserTools.<method_name>(*args, **kwargs)`` on the session
        thread and return its result. Rebuilds + retries once on a dead session;
        tears down + raises on a hung op."""
        self._ensure_thread()
        result_q: "queue.Queue[Tuple[str, Any]]" = queue.Queue()
        self._call_queue.put((method_name, args, kwargs, result_q))
        try:
            status, value = result_q.get(timeout=self._call_timeout)
        except queue.Empty:
            # The op is stuck on the single browser thread. Tear it down so the
            # next call gets a fresh session instead of queueing behind it.
            self.teardown()
            raise SessionCallTimeout(
                f"Browser operation '{method_name}' timed out after "
                f"{self._call_timeout}s; the session was reset. "
                "Re-open the page and try again."
            )
        if status == "error":
            if _allow_session_retry and is_session_dead(value):
                # The cached remote browser session died (e.g. the Selenium pod
                # scaled to zero during a human-approval wait). Rebuild + retry
                # exactly once.
                self.teardown()
                return self.call(
                    method_name, *args, _allow_session_retry=False, **kwargs
                )
            raise value
        return value
