"""Tests for the six browser MCP tools.

Tools are thin orchestration layers over ``a360_mcp.browser`` functions. We
mock the browser functions (not their internals) and verify:

- ``open_debug_window`` — already-open short-circuit; budget/launch-error paths
  release the slot; success returns the window payload.
- ``close_debug_window`` — close + release + closed-flag.
- ``inspect_console`` / ``inspect_network`` / ``screenshot_debug`` /
  ``get_computed_style`` — no-window error dict; success merged dict;
  InspectError handling.
- The 6 tools are registered; mutating classification for open/close.
"""

from __future__ import annotations

import importlib
from contextlib import contextmanager
from unittest.mock import MagicMock

import pytest

from a360_mcp.tools import browser_tools
from a360_mcp.tools.browser_tools import (
    CloseDebugWindowParams,
    GetComputedStyleParams,
    InspectConsoleParams,
    InspectNetworkParams,
    OpenDebugWindowParams,
    ScreenshotDebugParams,
)
from a360_mcp.utils.registry import get_registry

URL = "https://cr.example.com"
USER = "svc_mcp"

_STATE = MagicMock(
    pid=1234,
    port=9222,
    started_at=1_000_000.0,
    control_room_url=URL,
    username=USER,
    profile_dir="/tmp/p",
)
_STATE.to_payload.return_value = {"pid": 1234, "port": 9222}


@pytest.fixture
def cfg(server_config):
    """server_config from conftest already matches URL/USER."""
    return server_config


@pytest.fixture
def auth():
    return MagicMock(name="auth_manager")


@contextmanager
def _patch_browser(
    *,
    find=None,
    launch=None,
    close=None,
    claim=None,
    release=None,
    bind=None,
    assert_safe=None,
):
    """Patch the browser functions imported by browser_tools at call time.

    browser_tools imports them lazily inside each tool, so patching the public
    entry points on ``a360_mcp.browser`` is sufficient.
    """
    import a360_mcp.browser as browser_pkg

    originals = {
        "find_window": browser_pkg.find_window,
        "launch_window": browser_pkg.launch_window,
        "close_window": browser_pkg.close_window,
        "claim_browser_slot": browser_pkg.claim_browser_slot,
        "release_browser_slot": browser_pkg.release_browser_slot,
        "bind_browser_slot": browser_pkg.bind_browser_slot,
    }
    if find is not None:
        browser_pkg.find_window = find
    if launch is not None:
        browser_pkg.launch_window = launch
    if close is not None:
        browser_pkg.close_window = close
    if claim is not None:
        browser_pkg.claim_browser_slot = claim
    if release is not None:
        browser_pkg.release_browser_slot = release
    if bind is not None:
        browser_pkg.bind_browser_slot = bind
    try:
        yield
    finally:
        browser_pkg.find_window = originals["find_window"]
        browser_pkg.launch_window = originals["launch_window"]
        browser_pkg.close_window = originals["close_window"]
        browser_pkg.claim_browser_slot = originals["claim_browser_slot"]
        browser_pkg.release_browser_slot = originals["release_browser_slot"]
        browser_pkg.bind_browser_slot = originals["bind_browser_slot"]


@contextmanager
def _patch_unsaved_guard(*, assert_safe_side_effect=None):
    """Patch ``assert_safe_to_disrupt`` on the unsaved_guard module."""
    import a360_mcp.browser.unsaved_guard as guard

    original = guard.assert_safe_to_disrupt
    if assert_safe_side_effect is not None:
        guard.assert_safe_to_disrupt = MagicMock(side_effect=assert_safe_side_effect)
    else:
        guard.assert_safe_to_disrupt = MagicMock()
    try:
        yield guard.assert_safe_to_disrupt
    finally:
        guard.assert_safe_to_disrupt = original


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


def _result_success(d: dict) -> bool:
    return d.get("success") is True


def _result_error(d: dict) -> bool:
    return d.get("success") is False and "error_type" in d


# --------------------------------------------------------- open_debug_window


class TestOpenDebugWindow:
    def test_already_open_short_circuits(self, cfg, auth):
        find = MagicMock(return_value=_STATE)
        with _patch_browser(find=find):
            result = browser_tools.open_debug_window(cfg, auth, OpenDebugWindowParams())
        assert _result_success(result)
        assert result["already_open"] is True
        assert result["window"] == _STATE.to_payload.return_value

    def test_launch_when_no_existing(self, cfg, auth):
        find = MagicMock(return_value=None)
        launch = MagicMock(return_value=_STATE)
        claim = MagicMock()
        release = MagicMock()
        with _patch_browser(find=find, launch=launch, claim=claim, release=release):
            with _patch_unsaved_guard():
                result = browser_tools.open_debug_window(cfg, auth, OpenDebugWindowParams())
        assert _result_success(result)
        assert result["already_open"] is False
        launch.assert_called_once()
        claim.assert_called_once()
        release.assert_not_called()  # success path does not release

    def test_open_advertises_no_unsaved_guard(self, cfg, auth):
        # Opening cannot disrupt unsaved work: either a window already exists
        # (short-circuit, nothing is touched) or there is none. The tool must
        # not advertise a `force` escape hatch for an interlock it never runs.
        assert "force" not in OpenDebugWindowParams.model_fields

    def test_open_does_not_invoke_the_unsaved_guard(self, cfg, auth):
        find = MagicMock(return_value=None)
        launch = MagicMock(return_value=_STATE)
        claim = MagicMock()
        release = MagicMock()
        with _patch_browser(find=find, launch=launch, claim=claim, release=release):
            with _patch_unsaved_guard() as guard:
                result = browser_tools.open_debug_window(cfg, auth, OpenDebugWindowParams())
        assert _result_success(result)
        guard.assert_not_called()

    def test_session_budget_exceeded_returns_error(self, cfg, auth):
        from a360_mcp.auth._session_budget import SessionBudgetExceeded

        find = MagicMock(return_value=None)
        claim = MagicMock(side_effect=SessionBudgetExceeded("no slots"))
        launch = MagicMock()
        release = MagicMock()
        with _patch_browser(find=find, claim=claim, launch=launch, release=release):
            with _patch_unsaved_guard():
                result = browser_tools.open_debug_window(cfg, auth, OpenDebugWindowParams())
        assert _result_error(result)
        assert result["error_type"] == "session_budget_exceeded"
        launch.assert_not_called()  # did not get past the slot claim

    def test_launch_budget_exceeded_releases_slot(self, cfg, auth):
        from a360_mcp.browser.launch_budget import LaunchBudgetExceeded

        find = MagicMock(return_value=None)
        claim = MagicMock()
        launch = MagicMock(side_effect=LaunchBudgetExceeded("too soon"))
        release = MagicMock()
        with _patch_browser(find=find, claim=claim, launch=launch, release=release):
            with _patch_unsaved_guard():
                result = browser_tools.open_debug_window(cfg, auth, OpenDebugWindowParams())
        assert _result_error(result)
        assert result["error_type"] == "launch_budget_exceeded"
        release.assert_called_once()

    def test_window_launch_error_releases_slot(self, cfg, auth):
        from a360_mcp.browser.window import WindowLaunchError

        find = MagicMock(return_value=None)
        claim = MagicMock()
        launch = MagicMock(side_effect=WindowLaunchError("no binary"))
        release = MagicMock()
        with _patch_browser(find=find, claim=claim, launch=launch, release=release):
            with _patch_unsaved_guard():
                result = browser_tools.open_debug_window(cfg, auth, OpenDebugWindowParams())
        assert _result_error(result)
        assert result["error_type"] == "window_launch_error"
        release.assert_called_once()

    def test_binds_the_slot_to_the_launched_window_pid(self, cfg, auth):
        find = MagicMock(return_value=None)
        launch = MagicMock(return_value=_STATE)
        claim = MagicMock()
        bind = MagicMock()
        with _patch_browser(find=find, launch=launch, claim=claim, bind=bind):
            result = browser_tools.open_debug_window(cfg, auth, OpenDebugWindowParams())
        assert _result_success(result)
        bind.assert_called_once()
        assert bind.call_args.kwargs["pid"] == _STATE.pid


# --------------------------------------------------------- close_debug_window


class TestCloseDebugWindow:
    def test_close_with_window_releases_slot(self, cfg, auth):
        close = MagicMock(return_value=True)
        release = MagicMock()
        with _patch_browser(close=close, release=release):
            result = browser_tools.close_debug_window(cfg, auth, CloseDebugWindowParams())
        assert _result_success(result)
        assert result["closed"] is True
        close.assert_called_once()
        release.assert_called_once()

    def test_close_when_no_window_still_releases(self, cfg, auth):
        close = MagicMock(return_value=False)
        release = MagicMock()
        with _patch_browser(close=close, release=release):
            result = browser_tools.close_debug_window(cfg, auth, CloseDebugWindowParams())
        assert _result_success(result)
        assert result["closed"] is False
        release.assert_called_once()


class TestCloseDebugWindowUnsavedGuard:
    """Closing is the disruptive action — it destroys whatever is on screen.

    ``open_debug_window`` cannot disrupt anything (it short-circuits when a
    window exists), so the guard belongs here, where a live window is actually
    being torn down.
    """

    def test_guard_runs_against_the_live_window(self, cfg, auth):
        find = MagicMock(return_value=_STATE)
        close = MagicMock(return_value=True)
        release = MagicMock()
        with _patch_browser(find=find, close=close, release=release):
            with _patch_unsaved_guard() as guard:
                browser_tools.close_debug_window(cfg, auth, CloseDebugWindowParams())
        guard.assert_called_once()
        assert guard.call_args.args[0] is _STATE
        assert guard.call_args.kwargs.get("action") == "close_debug_window"

    def test_unsaved_input_blocks_the_close(self, cfg, auth):
        from a360_mcp.browser.unsaved_guard import UnsavedInputBlocked

        find = MagicMock(return_value=_STATE)
        close = MagicMock(return_value=True)
        release = MagicMock()
        with _patch_browser(find=find, close=close, release=release):
            with _patch_unsaved_guard(
                assert_safe_side_effect=UnsavedInputBlocked("2 unsaved controls")
            ):
                result = browser_tools.close_debug_window(cfg, auth, CloseDebugWindowParams())
        assert _result_error(result)
        assert result["error_type"] == "unsaved_input_blocked"
        # The refusal must not have half-torn-down the window.
        close.assert_not_called()
        release.assert_not_called()
        assert result["action_required"]

    def test_force_bypasses_the_guard(self, cfg, auth):
        from a360_mcp.browser.unsaved_guard import UnsavedInputBlocked

        find = MagicMock(return_value=_STATE)
        close = MagicMock(return_value=True)
        release = MagicMock()
        with _patch_browser(find=find, close=close, release=release):
            with _patch_unsaved_guard(
                assert_safe_side_effect=UnsavedInputBlocked("2 unsaved controls")
            ) as guard:
                result = browser_tools.close_debug_window(
                    cfg, auth, CloseDebugWindowParams(force=True)
                )
        assert _result_success(result)
        guard.assert_not_called()
        close.assert_called_once()

    def test_no_window_skips_the_guard_entirely(self, cfg, auth):
        find = MagicMock(return_value=None)
        close = MagicMock(return_value=False)
        release = MagicMock()
        with _patch_browser(find=find, close=close, release=release):
            with _patch_unsaved_guard() as guard:
                result = browser_tools.close_debug_window(cfg, auth, CloseDebugWindowParams())
        assert _result_success(result)
        guard.assert_not_called()
        release.assert_called_once()


# ----------------------------------------------------------- inspect tools


def _patch_inspect_module(*, fn_name, return_value=None, side_effect=None):
    """Patch the named inspect function on the inspect module."""
    import a360_mcp.browser.inspect as inspect_mod

    original = getattr(inspect_mod, fn_name)
    mock = MagicMock()
    if side_effect is not None:
        mock.side_effect = side_effect
    else:
        mock.return_value = return_value
    setattr(inspect_mod, fn_name, mock)

    def restore():
        setattr(inspect_mod, fn_name, original)

    return mock, restore


class TestInspectConsole:
    def test_no_window_returns_error(self, cfg, auth):
        with _patch_browser(find=MagicMock(return_value=None)):
            result = browser_tools.inspect_console(cfg, auth, InspectConsoleParams())
        assert _result_error(result)
        assert result["error_type"] == "no_debug_window"

    def test_success_merges_dict(self, cfg, auth):
        captured = {"count": 2, "entries": [], "listened_seconds": 3.0}
        mock, restore = _patch_inspect_module(fn_name="inspect_console", return_value=captured)
        try:
            with _patch_browser(find=MagicMock(return_value=_STATE)):
                result = browser_tools.inspect_console(cfg, auth, InspectConsoleParams())
        finally:
            restore()
        assert _result_success(result)
        assert result["count"] == 2
        mock.assert_called_once()

    def test_inspect_error_returns_error_dict(self, cfg, auth):
        from a360_mcp.browser.inspect import InspectError

        mock, restore = _patch_inspect_module(
            fn_name="inspect_console", side_effect=InspectError("boom")
        )
        try:
            with _patch_browser(find=MagicMock(return_value=_STATE)):
                result = browser_tools.inspect_console(cfg, auth, InspectConsoleParams())
        finally:
            restore()
        assert _result_error(result)
        assert result["error_type"] == "inspect_error"


class TestInspectNetwork:
    def test_no_window_returns_error(self, cfg, auth):
        with _patch_browser(find=MagicMock(return_value=None)):
            result = browser_tools.inspect_network(cfg, auth, InspectNetworkParams())
        assert _result_error(result)
        assert result["error_type"] == "no_debug_window"

    def test_success(self, cfg, auth):
        captured = {"count": 1, "requests": [], "listened_seconds": 3.0}
        mock, restore = _patch_inspect_module(fn_name="inspect_network", return_value=captured)
        try:
            with _patch_browser(find=MagicMock(return_value=_STATE)):
                result = browser_tools.inspect_network(cfg, auth, InspectNetworkParams())
        finally:
            restore()
        assert _result_success(result)
        assert result["count"] == 1


class TestScreenshotDebug:
    def test_no_window_returns_error(self, cfg, auth):
        with _patch_browser(find=MagicMock(return_value=None)):
            result = browser_tools.screenshot_debug(cfg, auth, ScreenshotDebugParams())
        assert _result_error(result)
        assert result["error_type"] == "no_debug_window"

    def test_success_inline(self, cfg, auth):
        captured = {"saved": False, "base64_png": "AAAA", "bytes": 4}
        mock, restore = _patch_inspect_module(fn_name="screenshot_debug", return_value=captured)
        try:
            with _patch_browser(find=MagicMock(return_value=_STATE)):
                result = browser_tools.screenshot_debug(cfg, auth, ScreenshotDebugParams())
        finally:
            restore()
        assert _result_success(result)
        assert result["base64_png"] == "AAAA"

    def test_success_to_file(self, cfg, auth):
        captured = {"saved": True, "path": "/tmp/s.png", "bytes": 99}
        mock, restore = _patch_inspect_module(fn_name="screenshot_debug", return_value=captured)
        try:
            with _patch_browser(find=MagicMock(return_value=_STATE)):
                result = browser_tools.screenshot_debug(
                    cfg, auth, ScreenshotDebugParams(output_path="/tmp/s.png")
                )
        finally:
            restore()
        assert _result_success(result)
        assert result["saved"] is True


class TestGetComputedStyle:
    def test_no_window_returns_error(self, cfg, auth):
        with _patch_browser(find=MagicMock(return_value=None)):
            result = browser_tools.get_computed_style(
                cfg, auth, GetComputedStyleParams(selector="div")
            )
        assert _result_error(result)
        assert result["error_type"] == "no_debug_window"

    def test_success(self, cfg, auth):
        captured = {"properties": {"color": "rgb(0,0,0)"}, "rect": {}}
        mock, restore = _patch_inspect_module(fn_name="get_computed_style", return_value=captured)
        try:
            with _patch_browser(find=MagicMock(return_value=_STATE)):
                result = browser_tools.get_computed_style(
                    cfg, auth, GetComputedStyleParams(selector=".btn")
                )
        finally:
            restore()
        assert _result_success(result)
        mock.assert_called_once()
        # Verify selector is forwarded.
        _, kwargs = mock.call_args
        assert kwargs["selector"] == ".btn"


# ---------------------------------------------------------------- _resolve_window


class TestResolveWindow:
    def test_returns_state_when_window_live(self, cfg):
        with _patch_browser(find=MagicMock(return_value=_STATE)):
            state, err = browser_tools._resolve_window(cfg)
        assert state is _STATE
        assert err is None

    def test_returns_error_when_no_window(self, cfg):
        with _patch_browser(find=MagicMock(return_value=None)):
            state, err = browser_tools._resolve_window(cfg)
        assert state is None
        assert err["error_type"] == "no_debug_window"


# ----------------------------------------------------------------- _error


class TestErrorHelper:
    def test_error_payload_basic(self):
        d = browser_tools._error(tool="t", error="msg", error_type="x")
        assert d == {"success": False, "tool": "t", "error": "msg", "error_type": "x"}

    def test_error_payload_with_extra(self):
        d = browser_tools._error(tool="t", error="msg", error_type="x", action_required="retry")
        assert d["action_required"] == "retry"


# ----------------------------------------------------------- registration


class TestRegistration:
    def test_six_tools_registered(self):
        # browser_tools imports at module load and registers via @register_tool.
        # If the registry was cleared by another test module, force re-registration.
        importlib.reload(browser_tools)
        registry = get_registry()
        names = {
            "open_debug_window",
            "close_debug_window",
            "inspect_console",
            "inspect_network",
            "screenshot_debug",
            "get_computed_style",
        }
        assert names.issubset(registry.keys())

    def test_param_models_correct(self):
        importlib.reload(browser_tools)
        registry = get_registry()
        assert registry["open_debug_window"][1] is browser_tools.OpenDebugWindowParams
        assert registry["close_debug_window"][1] is browser_tools.CloseDebugWindowParams
        assert registry["inspect_console"][1] is browser_tools.InspectConsoleParams
        assert registry["inspect_network"][1] is browser_tools.InspectNetworkParams
        assert registry["screenshot_debug"][1] is browser_tools.ScreenshotDebugParams
        assert registry["get_computed_style"][1] is browser_tools.GetComputedStyleParams


# ------------------------------------------------------------- classification


class TestClassification:
    def test_open_and_close_are_mutating(self):
        from a360_mcp.policies.write_guards import MUTATING_TOOL_NAMES, is_mutating_tool

        assert "open_debug_window" in MUTATING_TOOL_NAMES
        assert "close_debug_window" in MUTATING_TOOL_NAMES
        assert is_mutating_tool("open_debug_window") is True
        assert is_mutating_tool("close_debug_window") is True

    def test_inspect_tools_are_read_only(self):
        from a360_mcp.policies.write_guards import is_mutating_tool

        for name in (
            "inspect_console",
            "inspect_network",
            "screenshot_debug",
            "get_computed_style",
        ):
            assert is_mutating_tool(name) is False, name

    def test_browser_tools_not_publish_grade(self):
        from a360_mcp.policies.write_guards import is_publish_grade_tool

        # No browser tool starts with deploy_/publish_, so none are publish-grade.
        for name in ("open_debug_window", "close_debug_window", "inspect_console"):
            assert is_publish_grade_tool(name) is False, name
