"""Tests for the shared debug-window lifecycle.

All process/CDP/socket interaction is mocked so tests run hermetically. State
files are redirected under tmp_path via the autouse ``isolate_state_dir``
fixture from conftest.py.
"""

from __future__ import annotations

import json
import os
import signal
import urllib.error
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from a360_mcp.auth._paths import account_key
from a360_mcp.browser import window
from a360_mcp.browser.launch_budget import LaunchBudgetExceeded
from a360_mcp.browser.window import (
    BROWSER_SUBDIR,
    DEBUG_WINDOW_ALWAYS_HEADED,
    ENV_CHROME_BIN,
    READINESS_POLL_INTERVAL_S,
    READINESS_TIMEOUT_S,
    WindowLaunchError,
    WindowState,
    close_window,
    find_window,
    launch_window,
    profile_dir_for,
    window_state_path,
)

URL = "https://cr.example.com"
USER = "svc_mcp"
KEY = account_key(URL, USER)  # "svc_mcp-<hash>" — deterministic per account


# --------------------------------------------------------------------- helpers


def _state_dict(pid: int = 4242, port: int = 9222, **overrides) -> dict:
    payload = {
        "pid": pid,
        "port": port,
        "started_at": 1_000_000.0,
        "control_room_url": URL,
        "username": USER,
        "profile_dir": "/tmp/profile",
    }
    payload.update(overrides)
    return payload


def _state(pid: int = 4242, port: int = 9222, **overrides) -> WindowState:
    return WindowState.from_payload(_state_dict(pid=pid, port=port, **overrides))


def _seed_state_file(payload: dict | None = None) -> None:
    """Write a state file directly under tmp_path/state (where state_dir() points)."""
    payload = payload if payload is not None else _state_dict()
    path = window_state_path(KEY)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload), encoding="utf-8")


class _CdpResponse:
    """urlopen() context-manager mock."""

    def __init__(self, status: int = 200, body: bytes = b'{"Browser": "chrome"}'):
        self.status = status
        self._body = body

    def __enter__(self):
        return self

    def __exit__(self, *exc):
        return False

    def read(self, n: int = -1) -> bytes:
        return self._body


@contextmanager
def _patch_chromium(binary: str = "/usr/bin/chromium", *, popen_pid: int = 9999):
    """Patch the subprocess/socket/urllib stack so launch_window is hermetic.

    Patches:
    - ``A360_BROWSER_BIN`` env → binary
    - ``subprocess.Popen`` → MagicMock with .pid set
    - ``urllib.request.urlopen`` → CDP-alive response
    - ``time.sleep`` → no-op
    """
    with patch.dict(os.environ, {ENV_CHROME_BIN: binary}):
        fake_proc = MagicMock()
        fake_proc.pid = popen_pid
        with (
            patch("a360_mcp.browser.window.subprocess.Popen", return_value=fake_proc) as popen,
            patch(
                "a360_mcp.browser.window.urllib.request.urlopen",
                return_value=_CdpResponse(),
            ) as urlopen,
            patch("a360_mcp.browser.window.time.sleep") as sleep,
            patch("a360_mcp.browser.window.Path.is_file", return_value=True),
        ):
            yield popen, urlopen, sleep


# ---------------------------------------------------------------- dataclass


class TestWindowState:
    def test_to_payload_roundtrip(self):
        s = _state(pid=1234, port=9223)
        again = WindowState.from_payload(s.to_payload())
        assert again is not None
        assert again == s

    def test_from_payload_missing_field(self):
        assert WindowState.from_payload({"pid": 1}) is None

    def test_from_payload_wrong_type(self):
        # port is a string that cannot coerce to int via int() — ValueError.
        assert WindowState.from_payload(_state_dict(port="not-a-port")) is None

    def test_from_payload_not_dict(self):
        assert WindowState.from_payload(["not", "a", "dict"]) is None  # type: ignore[arg-type]

    def test_immutable(self):
        from dataclasses import FrozenInstanceError

        s = _state()
        with pytest.raises(FrozenInstanceError):
            s.pid = 0  # type: ignore[misc]


# ----------------------------------------------------------------- paths


class TestPaths:
    def test_window_state_path_under_state_dir(self):
        path = window_state_path(KEY)
        # state_dir() is redirectd by autouse fixture, but it must contain the key.
        assert path.name == f"window_{KEY}.json"
        assert "window_" in str(path)

    def test_launch_history_path_returns_str(self):
        from a360_mcp.browser.window import launch_history_path

        path = launch_history_path(KEY)
        assert isinstance(path, str)
        assert KEY in path

    def test_profile_dir_under_browser_subdir(self):
        path = profile_dir_for(KEY)
        assert BROWSER_SUBDIR in path.parts
        assert path.name == f"profile_{KEY}"

    def test_different_accounts_isolate(self):
        a = window_state_path("account_a")
        b = window_state_path("account_b")
        assert a != b


# -------------------------------------------------------------- find_window


class TestFindWindow:
    def test_no_state_returns_none(self):
        assert find_window(control_room_url=URL, username=USER) is None

    def test_live_state_returned(self):
        _seed_state_file()
        with (
            patch("a360_mcp.browser.window._is_pid_alive", return_value=True),
            patch("a360_mcp.browser.window._cdp_alive", return_value=True),
        ):
            state = find_window(control_room_url=URL, username=USER)
        assert state is not None
        assert state.pid == 4242

    def test_clears_stale_when_pid_dead(self):
        _seed_state_file()
        with (
            patch("a360_mcp.browser.window._is_pid_alive", return_value=False),
            patch("a360_mcp.browser.window._clear_state") as clear,
        ):
            assert find_window(control_room_url=URL, username=USER) is None
        clear.assert_called_once_with(KEY)
        # File was actually cleared (find_window calls _clear_state for real by default;
        # we mocked it so the file still exists, but the call site is verified).

    def test_clears_stale_when_cdp_dead(self):
        _seed_state_file()
        with (
            patch("a360_mcp.browser.window._is_pid_alive", return_value=True),
            patch("a360_mcp.browser.window._cdp_alive", return_value=False),
            patch("a360_mcp.browser.window._clear_state") as clear,
        ):
            assert find_window(control_room_url=URL, username=USER) is None
        clear.assert_called_once_with(KEY)

    def test_corrupt_state_returns_none(self):
        path = window_state_path(KEY)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text("not json", encoding="utf-8")
        assert find_window(control_room_url=URL, username=USER) is None


# ------------------------------------------------------------- launch_window


class TestLaunchWindow:
    def test_happy_path_writes_state_and_records_launch(self):
        with _patch_chromium(popen_pid=12345) as (popen, urlopen, sleep):
            state = launch_window(control_room_url=URL, username=USER, now=2_000_000.0)
        assert state.pid == 12345
        # State file written.
        written = window_state_path(KEY)
        assert written.is_file()
        # Launch history recorded.
        from a360_mcp.browser.window import launch_history_path

        history = json.loads(Path(launch_history_path(KEY)).read_text())
        assert history == [2_000_000.0]
        # Profile dir was created.
        assert profile_dir_for(KEY).is_dir()
        # One subprocess.Popen call, with start_new_session=True.
        popen.assert_called_once()
        _, kwargs = popen.call_args
        assert kwargs["start_new_session"] is True
        # CDP was probed.
        urlopen.assert_called()

    def test_missing_binary_raises(self):
        # No A360_BROWSER_BIN, no PATH candidates → WindowLaunchError.
        with (
            patch.dict(os.environ, {}, clear=True),
            (
                patch("a360_mcp.browser.window.shutil.which", return_value=None)
                if False
                else patch("shutil.which", return_value=None)
            ),
            patch("a360_mcp.browser.window.Path.is_file", return_value=False),
            patch("a360_mcp.browser.window.check_launch_allowed"),
        ):
            with pytest.raises(WindowLaunchError, match="Chromium binary not found"):
                launch_window(control_room_url=URL, username=USER)

    def test_popen_failure_raises_window_launch_error(self):
        with (
            patch.dict(os.environ, {ENV_CHROME_BIN: "/usr/bin/chromium"}),
            patch("a360_mcp.browser.window.Path.is_file", return_value=True),
            patch(
                "a360_mcp.browser.window.subprocess.Popen",
                side_effect=OSError("permission denied"),
            ),
            patch("a360_mcp.browser.window.check_launch_allowed"),
        ):
            with pytest.raises(WindowLaunchError, match="Failed to start Chromium"):
                launch_window(control_room_url=URL, username=USER)

    def test_too_soon_relaunch_blocked(self):
        # Seed launch history with a very recent timestamp.
        from a360_mcp.browser.window import launch_history_path

        Path(launch_history_path(KEY)).parent.mkdir(parents=True, exist_ok=True)
        Path(launch_history_path(KEY)).write_text("[1999999.0]")
        with pytest.raises(LaunchBudgetExceeded, match="already gone"):
            launch_window(control_room_url=URL, username=USER, now=2_000_000.0)

    def test_process_death_during_readiness_raises(self):
        with (
            patch.dict(os.environ, {ENV_CHROME_BIN: "/usr/bin/chromium"}),
            patch("a360_mcp.browser.window.Path.is_file", return_value=True),
            patch("a360_mcp.browser.window.subprocess.Popen") as popen,
            patch(
                "a360_mcp.browser.window.urllib.request.urlopen",
                side_effect=urllib.error.URLError("nope"),
            ),
            patch("a360_mcp.browser.window.time.sleep"),
            patch("a360_mcp.browser.window._wait_for_cdp", return_value=False),
            patch("a360_mcp.browser.window._is_pid_alive", return_value=False),
            patch("a360_mcp.browser.window.check_launch_allowed"),
        ):
            fake = MagicMock(pid=777)
            popen.return_value = fake
            with pytest.raises(WindowLaunchError, match="exited within"):
                launch_window(control_room_url=URL, username=USER)

    def test_cdp_never_binds_terminates_and_raises(self):
        with (
            patch.dict(os.environ, {ENV_CHROME_BIN: "/usr/bin/chromium"}),
            patch("a360_mcp.browser.window.Path.is_file", return_value=True),
            patch("a360_mcp.browser.window.subprocess.Popen") as popen,
            patch("a360_mcp.browser.window._wait_for_cdp", return_value=False),
            patch("a360_mcp.browser.window._is_pid_alive", return_value=True),
            patch("a360_mcp.browser.window._terminate") as terminate,
            patch("a360_mcp.browser.window.check_launch_allowed"),
        ):
            popen.return_value = MagicMock(pid=888)
            with pytest.raises(WindowLaunchError, match="did not expose its CDP port"):
                launch_window(control_room_url=URL, username=USER)
        terminate.assert_called_once_with(888)

    def test_chromium_args_include_cdp_and_profile(self):
        with _patch_chromium(popen_pid=1) as (popen, _, _):
            launch_window(
                control_room_url=URL, username=USER, start_url="https://start.example.com"
            )
        args = popen.call_args[0][0]
        assert args[0] == "/usr/bin/chromium"
        assert any(a.startswith("--user-data-dir=") for a in args)
        assert "--remote-debugging-address=127.0.0.1" in args
        assert "--no-first-run" in args
        assert "--no-default-browser-check" in args
        assert "--disable-features=Translate" in args
        assert args[-1] == "https://start.example.com"


# ------------------------------------------------------------- close_window


class TestCloseWindow:
    def test_no_window_returns_false(self):
        assert close_window(control_room_url=URL, username=USER) is False

    def test_live_window_terminated_and_state_cleared(self):
        _seed_state_file()
        with (
            patch("a360_mcp.browser.window._is_pid_alive", return_value=True),
            patch("a360_mcp.browser.window._cdp_alive", return_value=True),
            patch("a360_mcp.browser.window._terminate") as terminate,
        ):
            assert close_window(control_room_url=URL, username=USER) is True
        terminate.assert_called_once_with(4242)
        assert not window_state_path(KEY).exists()


# --------------------------------------------------------- _resolve_chromium


class TestResolveChromiumBinary:
    def test_env_override_returns_path(self):
        with patch.dict(os.environ, {ENV_CHROME_BIN: "/custom/chrome"}):
            with patch("a360_mcp.browser.window.Path.is_file", return_value=True):
                assert window._resolve_chromium_binary() == "/custom/chrome"

    def test_env_override_via_path_lookup(self):
        with patch.dict(os.environ, {ENV_CHROME_BIN: "chrome"}):
            with patch("shutil.which", return_value="/usr/bin/chrome") as which:
                assert window._resolve_chromium_binary() == "/usr/bin/chrome"
                which.assert_called()

    def test_env_override_not_found_returns_none(self):
        with patch.dict(os.environ, {ENV_CHROME_BIN: "/nope"}):
            with patch("a360_mcp.browser.window.Path.is_file", return_value=False):
                with patch("shutil.which", return_value=None):
                    assert window._resolve_chromium_binary() is None

    def test_candidate_lookup_via_which(self):
        with patch.dict(os.environ, {}, clear=True):
            with patch("shutil.which", return_value="/usr/bin/google-chrome"):
                assert window._resolve_chromium_binary() == "/usr/bin/google-chrome"

    def test_no_candidate_found_returns_none(self):
        with patch.dict(os.environ, {}, clear=True):
            with patch("shutil.which", return_value=None):
                with patch("a360_mcp.browser.window.Path.is_file", return_value=False):
                    assert window._resolve_chromium_binary() is None


# ----------------------------------------------------------------- _cdp_alive


class TestCdpAlive:
    def test_returns_true_on_browser_marker(self):
        resp = _CdpResponse(200, b'{"Browser": "chrome"}')
        with patch("a360_mcp.browser.window.urllib.request.urlopen", return_value=resp):
            assert window._cdp_alive(9222) is True

    def test_returns_true_on_websocket_marker(self):
        resp = _CdpResponse(200, b'{"webSocketDebuggerUrl": "ws://x"}')
        with patch("a360_mcp.browser.window.urllib.request.urlopen", return_value=resp):
            assert window._cdp_alive(9222) is True

    def test_returns_false_on_wrong_status(self):
        resp = _CdpResponse(404, b"")
        with patch("a360_mcp.browser.window.urllib.request.urlopen", return_value=resp):
            assert window._cdp_alive(9222) is False

    def test_returns_false_on_url_error(self):
        with patch(
            "a360_mcp.browser.window.urllib.request.urlopen",
            side_effect=urllib.error.URLError("nope"),
        ):
            assert window._cdp_alive(9222) is False

    def test_returns_false_on_oserror(self):
        with patch(
            "a360_mcp.browser.window.urllib.request.urlopen",
            side_effect=OSError("nope"),
        ):
            assert window._cdp_alive(9222) is False


# ------------------------------------------------------------- _is_pid_alive


class TestIsPidAlive:
    def test_zero_or_negative_returns_false(self):
        assert window._is_pid_alive(0) is False
        assert window._is_pid_alive(-1) is False

    def test_process_lookup_returns_false(self):
        with patch("a360_mcp.browser.window.os.kill", side_effect=ProcessLookupError()):
            assert window._is_pid_alive(99999) is False

    def test_permission_returns_true(self):
        # Skip on win32 semantics — the test environment is Linux.
        with patch("a360_mcp.browser.window.os.kill", side_effect=PermissionError()):
            assert window._is_pid_alive(99999) is True

    def test_oserror_returns_true(self):
        with patch("a360_mcp.browser.window.os.kill", side_effect=OSError()):
            assert window._is_pid_alive(99999) is True

    def test_success_returns_true(self):
        with patch("a360_mcp.browser.window.os.kill"):
            assert window._is_pid_alive(1234) is True


# ------------------------------------------------------------------ _terminate


class TestTerminate:
    def test_zero_pid_is_noop(self):
        window._terminate(0)  # no exception

    def test_negative_pid_is_noop(self):
        window._terminate(-5)

    def test_sends_sigterm_on_unix(self):
        with patch("a360_mcp.browser.window.os.kill") as kill:
            window._terminate(1234)
        kill.assert_called_once_with(1234, signal.SIGTERM)

    def test_process_lookup_swallowed(self):
        with patch("a360_mcp.browser.window.os.kill", side_effect=ProcessLookupError()):
            window._terminate(1234)  # no raise

    def test_permission_swallowed(self):
        with patch("a360_mcp.browser.window.os.kill", side_effect=PermissionError()):
            window._terminate(1234)

    def test_oserror_swallowed(self):
        with patch("a360_mcp.browser.window.os.kill", side_effect=OSError()):
            window._terminate(1234)


# ------------------------------------------------------- _pick_free_port


class TestPickFreePort:
    def test_returns_port_from_socket(self):
        fake_sock = MagicMock()
        fake_sock.getsockname.return_value = ("127.0.0.1", 5555)
        with patch("a360_mcp.browser.window.socket.socket", return_value=fake_sock):
            assert window._pick_free_port() == 5555
        fake_sock.close.assert_called_once()


# ----------------------------------------------------- _read/write/clear


class TestStateIo:
    def test_read_state_missing_returns_none(self):
        assert window._read_state(KEY) is None

    def test_read_state_corrupt_returns_none(self):
        path = window_state_path(KEY)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text("garbage")
        assert window._read_state(KEY) is None

    def test_read_state_non_dict_returns_none(self):
        path = window_state_path(KEY)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text("[1,2,3]")
        assert window._read_state(KEY) is None

    def test_write_state_atomic(self):
        s = _state(pid=11, port=22)
        window._write_state(KEY, s)
        path = window_state_path(KEY)
        assert path.is_file()
        loaded = json.loads(path.read_text())
        assert loaded["pid"] == 11

    def test_clear_state_missing_is_noop(self):
        window._clear_state(KEY)  # no raise

    def test_clear_state_removes_file(self):
        path = window_state_path(KEY)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text("{}")
        window._clear_state(KEY)
        assert not path.exists()


# ------------------------------------------------------------- _wait_for_cdp


class TestWaitForCdp:
    def test_returns_true_when_cdp_alive_immediately(self):
        with patch("a360_mcp.browser.window._cdp_alive", return_value=True):
            assert window._wait_for_cdp(9222, timeout=1.0) is True

    def test_returns_false_after_deadline(self):
        # Make time.time() always below a fixed deadline so the loop spins forever,
        # then ensure _cdp_alive stays False and the function eventually returns False.
        # We force the deadline to pass by returning a sequence that crosses it.
        times = iter([100.0, 100.0, 200.0])  # start, poll, deadline-exceeded
        sleeps = []
        with (
            patch("a360_mcp.browser.window.time.time", side_effect=lambda: next(times)),
            patch("a360_mcp.browser.window.time.sleep", side_effect=lambda s: sleeps.append(s)),
            patch("a360_mcp.browser.window._cdp_alive", return_value=False),
        ):
            assert window._wait_for_cdp(9222, timeout=30.0) is False
        assert sleeps  # at least one poll happened


# -------------------------------------------------------------- constants


class TestConstants:
    def test_always_headed(self):
        assert DEBUG_WINDOW_ALWAYS_HEADED is True

    def test_readiness_constants_positive(self):
        assert READINESS_TIMEOUT_S > 0
        assert READINESS_POLL_INTERVAL_S > 0

    def test_env_chrome_bin_name(self):
        assert ENV_CHROME_BIN == "A360_BROWSER_BIN"
