"""Tests for the read-only browser inspection layer.

All inspect_* helpers:
- Accept a WindowState (or None) and surface NoDebugWindow when None.
- Connect to Chromium over CDP via Playwright (mocked here).
- Never synthesise input, never navigate, never launch a window.

Mocking strategy: patch `require_playwright()` to return a mock
`sync_playwright` whose call chain yields controllable page/browser objects.
"""

from __future__ import annotations

import base64
from pathlib import Path
from unittest.mock import MagicMock, patch

import orjson
import pytest

from a360_mcp.browser import inspect as inspect_mod
from a360_mcp.browser.inspect import (
    DEFAULT_INSPECT_DURATION_S,
    MAX_INSPECT_DURATION_S,
    BrowserUnavailable,
    ConsoleEntry,
    InspectError,
    NetworkEntry,
    NoDebugWindow,
    _cap,
    _clamp_duration,
    get_computed_style,
    inspect_console,
    inspect_network,
    screenshot_debug,
)
from a360_mcp.browser.window import WindowState
from a360_mcp.utils.response_budget import get_response_budget

_STATE = WindowState(
    pid=1234,
    port=9222,
    started_at=1000.0,
    control_room_url="https://cr.example.com",
    username="svc_mcp",
    profile_dir="/tmp/profile",
)


def _make_chain(
    *,
    page=None,
    contexts_count=1,
    pages_count=1,
    connect_side_effect=None,
):
    real_page = page or MagicMock()
    real_context = MagicMock()
    real_context.pages = [real_page] if pages_count else []
    real_browser = MagicMock()
    real_browser.contexts = [real_context] * contexts_count if contexts_count else []
    if connect_side_effect:
        real_browser = connect_side_effect

    pw_instance = MagicMock()
    pw_instance.chromium.connect_over_cdp.return_value = real_browser

    pw_cm = MagicMock()
    pw_cm.start.return_value = pw_instance

    sync_playwright = MagicMock(return_value=pw_cm)
    return sync_playwright, pw_instance, real_browser, real_page


@pytest.fixture(autouse=True)
def _no_sleep(monkeypatch):
    monkeypatch.setattr(inspect_mod.time, "sleep", lambda s: None)


@pytest.fixture(autouse=True)
def _no_time_progress(monkeypatch):
    # Fix time.time so _sleep's deadline loop exits immediately.
    monkeypatch.setattr(inspect_mod.time, "time", lambda: 5000.0)


class TestNoneStateRaises:
    def test_inspect_console_raises(self):
        with pytest.raises(NoDebugWindow):
            inspect_console(None)

    def test_inspect_network_raises(self):
        with pytest.raises(NoDebugWindow):
            inspect_network(None)

    def test_screenshot_raises(self):
        with pytest.raises(NoDebugWindow):
            screenshot_debug(None)

    def test_computed_style_raises(self):
        with pytest.raises(NoDebugWindow):
            get_computed_style(None, selector="div")


class TestCap:
    def test_short_text_unchanged(self):
        assert _cap("hello") == "hello"

    def test_long_text_truncated(self):
        text = "x" * 600
        capped = _cap(text)
        assert len(capped) < len(text)
        assert "600 chars" in capped

    def test_exact_boundary_unchanged(self):
        from a360_mcp.browser.inspect import ENTRY_TEXT_CAP

        assert _cap("x" * ENTRY_TEXT_CAP) == "x" * ENTRY_TEXT_CAP


class TestClampDuration:
    def test_none_uses_default(self):
        assert _clamp_duration(None) == DEFAULT_INSPECT_DURATION_S

    def test_negative_clamped_to_zero(self):
        assert _clamp_duration(-5.0) == 0.0

    def test_over_max_clamped(self):
        assert _clamp_duration(999.0) == MAX_INSPECT_DURATION_S

    def test_in_range_passthrough(self):
        assert _clamp_duration(10.0) == 10.0

    def test_zero_passthrough(self):
        assert _clamp_duration(0.0) == 0.0


class TestInspectConsole:
    def test_captures_console_events_wiring(self):
        page = MagicMock()
        sp, _, _, _ = _make_chain(page=page)
        with patch.object(inspect_mod, "require_playwright", return_value=sp):
            result = inspect_console(_STATE, duration_seconds=0.0)
        page.on.assert_any_call("console", page.on.call_args_list[0][0][1])
        page.on.assert_any_call("pageerror", page.on.call_args_list[1][0][1])
        assert result["count"] == 0
        assert result["listened_seconds"] == 0.0
        assert result["window"] == {"pid": 1234, "port": 9222}

    def test_connect_error_raises_inspect_error(self):
        sp, pw_instance, _, _ = _make_chain()
        pw_instance.chromium.connect_over_cdp.side_effect = RuntimeError("port closed")
        with patch.object(inspect_mod, "require_playwright", return_value=sp):
            with pytest.raises(InspectError, match="Could not connect"):
                inspect_console(_STATE, duration_seconds=0.0)

    def test_no_contexts_raises(self):
        sp, _, _, _ = _make_chain(contexts_count=0)
        with patch.object(inspect_mod, "require_playwright", return_value=sp):
            with pytest.raises(InspectError, match="no browser context"):
                inspect_console(_STATE, duration_seconds=0.0)

    def test_no_pages_raises(self):
        sp, _, _, _ = _make_chain(pages_count=0)
        with patch.object(inspect_mod, "require_playwright", return_value=sp):
            with pytest.raises(InspectError, match="no open tab"):
                inspect_console(_STATE, duration_seconds=0.0)


class TestInspectNetwork:
    def test_wires_requestfinished_and_failed(self):
        page = MagicMock()
        sp, _, _, _ = _make_chain(page=page)
        with patch.object(inspect_mod, "require_playwright", return_value=sp):
            result = inspect_network(_STATE, duration_seconds=0.0)
        events = [c.args[0] for c in page.on.call_args_list]
        assert "requestfinished" in events
        assert "requestfailed" in events
        assert result["count"] == 0


class TestScreenshotDebug:
    def test_inline_base64(self):
        page = MagicMock()
        page.screenshot.return_value = b"\x89PNG fake"
        sp, _, _, _ = _make_chain(page=page)
        with patch.object(inspect_mod, "require_playwright", return_value=sp):
            result = screenshot_debug(_STATE)
        assert result["saved"] is False
        assert base64.b64decode(result["base64_png"]) == b"\x89PNG fake"
        assert result["bytes"] == len(b"\x89PNG fake")

    def test_save_to_file(self, tmp_path):
        page = MagicMock()
        page.screenshot.return_value = b"\x89PNG data"
        sp, _, _, _ = _make_chain(page=page)
        out = tmp_path / "shot.png"
        with patch.object(inspect_mod, "require_playwright", return_value=sp):
            result = screenshot_debug(_STATE, output_path=str(out))
        assert result["saved"] is True
        assert result["path"] == str(out.resolve())
        assert out.read_bytes() == b"\x89PNG data"

    def test_screenshot_failure_raises(self):
        page = MagicMock()
        page.screenshot.side_effect = RuntimeError("crashed")
        sp, _, _, _ = _make_chain(page=page)
        with patch.object(inspect_mod, "require_playwright", return_value=sp):
            with pytest.raises(InspectError, match="screenshot failed"):
                screenshot_debug(_STATE)


class TestScreenshotStaysInsideTheResponseBudget:
    """A base64 PNG has no A360 identity, so `enforce_response_budget` cannot
    stub it — a full-page Control Room capture is routinely 1-5 MB against a
    75 KB ceiling, and the whole blob would reach the client whole. The fix is
    to never build the oversized payload: spill to disk and return the path.
    """

    def _big_png(self) -> bytes:
        return b"\x89PNG" + b"x" * (inspect_mod.MAX_INLINE_SCREENSHOT_BYTES + 1)

    def test_oversized_capture_spills_to_disk(self, tmp_path, monkeypatch):
        monkeypatch.setenv("A360_STATE_DIR", str(tmp_path))
        page = MagicMock()
        page.screenshot.return_value = self._big_png()
        sp, _, _, _ = _make_chain(page=page)
        with patch.object(inspect_mod, "require_playwright", return_value=sp):
            result = screenshot_debug(_STATE)
        assert result["saved"] is True
        assert "base64_png" not in result
        assert Path(result["path"]).read_bytes() == self._big_png()
        assert result["spilled_to_disk"] is True
        # Honest about what happened, per the response-budget contract.
        assert result["note"]

    def test_spilled_result_fits_the_budget(self, tmp_path, monkeypatch):
        monkeypatch.setenv("A360_STATE_DIR", str(tmp_path))
        page = MagicMock()
        page.screenshot.return_value = self._big_png()
        sp, _, _, _ = _make_chain(page=page)
        with patch.object(inspect_mod, "require_playwright", return_value=sp):
            result = screenshot_debug(_STATE)
        assert len(orjson.dumps(result)) < get_response_budget()

    def test_small_capture_still_returns_inline_base64(self):
        page = MagicMock()
        page.screenshot.return_value = b"\x89PNG small"
        sp, _, _, _ = _make_chain(page=page)
        with patch.object(inspect_mod, "require_playwright", return_value=sp):
            result = screenshot_debug(_STATE)
        assert result["saved"] is False
        assert base64.b64decode(result["base64_png"]) == b"\x89PNG small"

    def test_explicit_output_path_wins_over_the_spill_path(self, tmp_path):
        page = MagicMock()
        page.screenshot.return_value = self._big_png()
        sp, _, _, _ = _make_chain(page=page)
        out = tmp_path / "mine.png"
        with patch.object(inspect_mod, "require_playwright", return_value=sp):
            result = screenshot_debug(_STATE, output_path=str(out))
        assert result["path"] == str(out.resolve())
        assert result.get("spilled_to_disk") is not True


class TestGetComputedStyle:
    def test_returns_styles(self):
        page = MagicMock()
        page.evaluate.return_value = {
            "matched": True,
            "tag": "div",
            "styles": {"display": "block"},
        }
        sp, _, _, _ = _make_chain(page=page)
        with patch.object(inspect_mod, "require_playwright", return_value=sp):
            result = get_computed_style(_STATE, selector="#main")
        assert result["result"]["matched"] is True
        assert result["selector"] == "#main"

    def test_empty_selector_raises(self):
        with pytest.raises(InspectError, match="selector is required"):
            get_computed_style(_STATE, selector="")

    def test_evaluate_failure_raises(self):
        page = MagicMock()
        page.evaluate.side_effect = RuntimeError("JS error")
        sp, _, _, _ = _make_chain(page=page)
        with patch.object(inspect_mod, "require_playwright", return_value=sp):
            with pytest.raises(InspectError, match="computed style read failed"):
                get_computed_style(_STATE, selector="div")


class TestRequirePlaywrightMissing:
    def test_raises_browser_unavailable(self, monkeypatch):
        import sys

        monkeypatch.setitem(sys.modules, "playwright", None)
        monkeypatch.setitem(sys.modules, "playwright.sync_api", None)
        with pytest.raises(BrowserUnavailable, match="pip install"):
            from a360_mcp.browser.inspect import require_playwright

            require_playwright()


class TestDataclasses:
    def test_console_entry_to_dict(self):
        entry = ConsoleEntry(kind="error", text="boom")
        assert entry.to_dict() == {"kind": "error", "text": "boom"}

    def test_network_entry_to_dict(self):
        entry = NetworkEntry("GET", "https://x", 200, "xhr", False)
        d = entry.to_dict()
        assert d["method"] == "GET"
        assert d["status"] == 200

    def test_network_entry_none_status(self):
        entry = NetworkEntry("POST", "https://x", None, "fetch", True)
        assert entry.to_dict()["status"] is None
        assert entry.to_dict()["failed"] is True
