import threading
import time
import unittest
from typing import Any, List

from abstra_internals.cloud.browser_service.session import (
    BrowserSessionThread,
    SessionCallTimeout,
    is_session_dead,
)


class FakeBrowserTools:
    """Stands in for abstra-lib's BrowserTools: records which thread each call
    ran on, can be scripted to raise, and tracks close()."""

    def __init__(self) -> None:
        self.calls: List[str] = []
        self.call_threads: List[int] = []
        self.closed = False

    def navigate_to_url(self, url: str) -> dict:
        self.calls.append(f"navigate_to_url:{url}")
        self.call_threads.append(threading.get_ident())
        return {"tab_id": "t1", "url": url, "title": "T"}

    def get_text(self, page_id: str, selector: str) -> str:
        self.call_threads.append(threading.get_ident())
        return f"text:{selector}"

    def boom(self) -> None:
        raise ValueError("a normal per-page error")

    def dead(self) -> None:
        raise RuntimeError("Browser has closed unexpectedly")

    def slow(self) -> None:
        time.sleep(5)

    def close(self) -> None:
        self.closed = True


class BrowserSessionThreadTest(unittest.TestCase):
    def test_marshals_calls_onto_a_single_dedicated_thread(self) -> None:
        made: List[FakeBrowserTools] = []

        def factory() -> FakeBrowserTools:
            bt = FakeBrowserTools()
            made.append(bt)
            return bt

        s = BrowserSessionThread(factory)
        r1 = s.call("navigate_to_url", "https://x")
        r2 = s.call("get_text", "t1", "h1")

        self.assertEqual(r1, {"tab_id": "t1", "url": "https://x", "title": "T"})
        self.assertEqual(r2, "text:h1")
        # Factory ran once (thread reused across calls).
        self.assertEqual(len(made), 1)
        # Both calls ran on the same thread, and NOT the test thread.
        bt = made[0]
        self.assertEqual(len(set(bt.call_threads)), 1)
        self.assertNotIn(threading.get_ident(), bt.call_threads)
        s.close()

    def test_propagates_a_normal_error_without_rebuilding(self) -> None:
        made: List[FakeBrowserTools] = []
        s = BrowserSessionThread(lambda: made.append(FakeBrowserTools()) or made[-1])
        with self.assertRaises(ValueError):
            s.call("boom")
        # Normal per-page error must not tear the session down.
        self.assertEqual(len(made), 1)
        self.assertTrue(s.alive)
        s.close()

    def test_dead_session_error_rebuilds_and_retries_once(self) -> None:
        made: List[FakeBrowserTools] = []
        attempts = {"dead": 0}

        class Flaky(FakeBrowserTools):
            def dead_then_ok(self) -> str:
                attempts["dead"] += 1
                if attempts["dead"] == 1:
                    raise RuntimeError("session may have expired")
                return "recovered"

        def factory() -> Flaky:
            bt = Flaky()
            made.append(bt)
            return bt

        s = BrowserSessionThread(factory)
        result = s.call("dead_then_ok")
        self.assertEqual(result, "recovered")
        # First (dead) session torn down, a second built for the retry.
        self.assertEqual(len(made), 2)
        self.assertTrue(made[0].closed)  # old session was closed on teardown
        s.close()

    def test_dead_session_does_not_retry_forever(self) -> None:
        made: List[FakeBrowserTools] = []
        s = BrowserSessionThread(lambda: made.append(FakeBrowserTools()) or made[-1])
        with self.assertRaises(RuntimeError):
            s.call("dead")  # always dead
        # Exactly one rebuild+retry: original + one rebuild = 2 instances.
        self.assertEqual(len(made), 2)
        s.close()

    def test_hung_op_times_out_and_tears_down(self) -> None:
        made: List[FakeBrowserTools] = []
        s = BrowserSessionThread(
            lambda: made.append(FakeBrowserTools()) or made[-1],
            call_timeout_seconds=0.3,
        )
        with self.assertRaises(SessionCallTimeout):
            s.call("slow")
        self.assertFalse(s.alive)  # torn down after the hang
        # A subsequent call rebuilds a fresh session and works.
        self.assertEqual(s.call("get_text", "t1", "h1"), "text:h1")
        self.assertEqual(len(made), 2)
        s.close()

    def test_close_stops_the_thread_and_closes_browsertools(self) -> None:
        made: List[FakeBrowserTools] = []
        s = BrowserSessionThread(lambda: made.append(FakeBrowserTools()) or made[-1])
        s.call("navigate_to_url", "https://x")
        self.assertTrue(s.alive)
        s.close()
        self.assertFalse(s.alive)
        # Give the daemon loop a moment to run its finally: bt.close().
        for _ in range(50):
            if made[0].closed:
                break
            time.sleep(0.01)
        self.assertTrue(made[0].closed)

    def test_init_failure_is_surfaced_to_the_caller(self) -> None:
        def factory() -> Any:
            raise RuntimeError("launch failed: no Chromium")

        s = BrowserSessionThread(factory)
        with self.assertRaises(RuntimeError):
            s.call("navigate_to_url", "https://x")
        self.assertFalse(s.alive)

    def test_hung_thread_that_outlives_teardown_does_not_become_a_zombie(self) -> None:
        # Regression: a call whose op hangs past the timeout leaves its thread
        # alive. When that op finally returns, the thread must read its OWN
        # queue (see teardown's _STOP and exit), NOT the queue a rebuilt session
        # has since installed — otherwise it steals commands and runs them on
        # its dead browser.
        gate = threading.Event()
        made: List["FakeBrowserTools"] = []

        class Gated(FakeBrowserTools):
            def hang(self) -> str:
                self.call_threads.append(threading.get_ident())
                gate.wait(2.0)  # released by the test (2s is a safety cap)
                return "hung-done"

        def factory() -> "FakeBrowserTools":
            bt = Gated()
            made.append(bt)
            return bt

        s = BrowserSessionThread(
            factory, call_timeout_seconds=0.2, teardown_join_seconds=0.2
        )

        # 1) the hung op times out; b1's thread stays blocked in hang().
        with self.assertRaises(SessionCallTimeout):
            s.call("hang")
        self.assertFalse(s.alive)

        # 2) a fresh call rebuilds the session on b2 (repointing self._call_queue).
        self.assertEqual(s.call("get_text", "t", "h1"), "text:h1")
        self.assertEqual(len(made), 2)

        # 3) release b1's op. With the bug it wakes, reads the NEW queue, and
        #    lingers as a zombie; with the fix it reads its own queue, gets its
        #    _STOP, and closes b1.
        gate.set()
        for _ in range(200):
            if made[0].closed:
                break
            time.sleep(0.01)
        self.assertTrue(made[0].closed, "the hung (b1) thread never shut down cleanly")

        # 4) the live session is still b2 and keeps working.
        self.assertEqual(s.call("get_text", "t", "h2"), "text:h2")
        s.close()

    def test_is_session_dead_predicate(self) -> None:
        self.assertTrue(is_session_dead(RuntimeError("Browser has closed")))
        self.assertTrue(is_session_dead(Exception("the session may have expired")))
        self.assertFalse(is_session_dead(ValueError("Selector '#x' not found")))


if __name__ == "__main__":
    unittest.main()
