import json
import unittest
from typing import List, Optional, Tuple

from abstra_internals.cloud.browser_service.pool import BrowserSessionPool


class FakeBrowserTools:
    def __init__(self) -> None:
        self.closed = False

    def navigate_to_url(self, url: str) -> dict:
        return {"tab_id": "t1", "url": url, "title": "T"}

    def get_text(self, page_id: str, selector: str) -> str:
        return f"text:{selector}"

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


class FakeClock:
    def __init__(self) -> None:
        self.t = 0.0

    def __call__(self) -> float:
        return self.t


class BrowserSessionPoolTest(unittest.TestCase):
    def _pool(self, clock=None, ttl=300.0):
        made: List[Tuple[str, Optional[str], FakeBrowserTools]] = []

        def factory(project_id: str, editor_token: Optional[str]) -> FakeBrowserTools:
            bt = FakeBrowserTools()
            made.append((project_id, editor_token, bt))
            return bt

        pool = BrowserSessionPool(
            factory,
            idle_ttl_seconds=ttl,
            clock=clock or __import__("time").monotonic,
        )
        return pool, made

    def test_end_to_end_dispatch_through_session_and_serialization(self) -> None:
        pool, made = self._pool()
        out = pool.handle(
            "s1", "proj1", "tok", "browser_navigate", {"url": "https://x"}
        )
        self.assertEqual(
            out,
            json.dumps({"tab_id": "t1", "url": "https://x", "title": "T"}, indent=2),
        )
        # factory received the project + token
        self.assertEqual(made[0][0], "proj1")
        self.assertEqual(made[0][1], "tok")
        pool.close_all()

    def test_session_is_reused_across_calls_and_keyed_by_session_id(self) -> None:
        pool, made = self._pool()
        pool.handle(
            "s1", "proj1", "tok", "browser_get_text", {"tab_id": "t", "selector": "h1"}
        )
        pool.handle(
            "s1", "proj1", "tok", "browser_get_text", {"tab_id": "t", "selector": "h2"}
        )
        self.assertEqual(len(made), 1)  # one session reused
        self.assertEqual(pool.active_sessions, 1)

        pool.handle(
            "s2", "proj2", None, "browser_get_text", {"tab_id": "t", "selector": "h1"}
        )
        self.assertEqual(len(made), 2)  # distinct session for a new id
        self.assertEqual(pool.active_sessions, 2)
        self.assertEqual(made[1][0], "proj2")
        pool.close_all()

    def test_same_session_id_across_projects_gets_separate_browsers(self) -> None:
        # The pool is shared, so a colliding session_id from a different project
        # must NOT bind to another project's live browser — it's a different key.
        pool, made = self._pool()
        a = pool.handle("dup", "projA", "tok", "browser_navigate", {"url": "a"})
        b = pool.handle("dup", "projB", "tok", "browser_navigate", {"url": "b"})
        self.assertEqual(len(made), 2)  # two distinct browsers, not one shared
        self.assertEqual(pool.active_sessions, 2)
        self.assertEqual(made[0][0], "projA")
        self.assertEqual(made[1][0], "projB")
        self.assertNotEqual(a, b)  # each navigated its own url
        # Closing one project's session leaves the other's intact.
        pool.close("dup", "projA")
        self.assertEqual(pool.active_sessions, 1)
        self.assertTrue(_eventually(lambda: made[0][2].closed))
        self.assertFalse(made[1][2].closed)
        pool.close_all()

    def test_same_replica_reopen_reuses_browser_and_stale_close_is_noop(self) -> None:
        # A re-open (epoch 2) landing on the same replica must reuse the browser
        # (tabs survive), and the superseded epoch-1 holder's close must NOT
        # destroy the browser epoch 2 is now using.
        pool, made = self._pool()
        pool.handle("s", "p", "tok", "browser_navigate", {"url": "u"}, epoch=1)
        b1 = made[0][2]
        pool.handle("s", "p", "tok", "browser_navigate", {"url": "u2"}, epoch=2)
        self.assertEqual(len(made), 1)  # same browser reused across the re-open
        self.assertEqual(pool.active_sessions, 1)

        # Stale epoch-1 close (the old holder tearing down) is a no-op.
        pool.close("s", "p", epoch=1)
        self.assertEqual(pool.active_sessions, 1)
        self.assertFalse(b1.closed)

        # The current epoch's close reclaims it.
        pool.close("s", "p", epoch=2)
        self.assertEqual(pool.active_sessions, 0)
        self.assertTrue(_eventually(lambda: b1.closed))

    def test_close_tears_down_and_next_call_recreates(self) -> None:
        pool, made = self._pool()
        pool.handle("s1", "proj1", "tok", "browser_navigate", {"url": "u"})
        first_bt = made[0][2]
        pool.close("s1", "proj1")
        self.assertEqual(pool.active_sessions, 0)
        self.assertTrue(_eventually(lambda: first_bt.closed))

        pool.handle("s1", "proj1", "tok", "browser_navigate", {"url": "u"})
        self.assertEqual(len(made), 2)  # recreated
        pool.close_all()

    def test_evict_idle_closes_only_stale_sessions(self) -> None:
        clock = FakeClock()
        pool, made = self._pool(clock=clock, ttl=100.0)

        pool.handle(
            "s1", "proj1", "tok", "browser_navigate", {"url": "u"}
        )  # used at t=0
        clock.t = 50.0
        pool.handle(
            "s2", "proj2", "tok", "browser_navigate", {"url": "u"}
        )  # used at t=50

        clock.t = 120.0  # s1 idle 120s (>100), s2 idle 70s (<100)
        evicted = pool.evict_idle()
        self.assertEqual(evicted, 1)
        self.assertEqual(pool.active_sessions, 1)
        self.assertTrue(_eventually(lambda: made[0][2].closed))  # s1 closed
        self.assertFalse(made[1][2].closed)  # s2 alive
        pool.close_all()


def _eventually(pred, tries: int = 100) -> bool:
    import time

    for _ in range(tries):
        if pred():
            return True
        time.sleep(0.01)
    return pred()


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