"""Read-only inspection over the live debug window via Playwright-over-CDP.

The inspect_* helpers are **structurally unable to open a window**. They accept
only a running :class:`WindowState` and surface a clear "call open_debug_window
first" error when none exists. They never call :func:`launch_window`, never
synthesise input, and never navigate — they read what is already on screen.

Connection model: Playwright's ``connect_over_cdp`` attaches to the Chromium
that ``window.launch_window`` started, without managing its lifecycle. Closing
the Playwright connection leaves the browser running — only the inspection
session ends. This is what makes "open in the morning, inspect many times"
work without relaunching.

Playwright is a SOFT dependency: it lives behind the ``[browser]`` extra so the
core MCP server stays installable without it. Missing Playwright surfaces as a
:class:`BrowserUnavailable` error the tool layer turns into a structured dict.
"""

from __future__ import annotations

import base64
import logging
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any

from ..auth._paths import account_key, state_dir
from ..utils.response_budget import get_response_budget
from .window import WindowState

logger = logging.getLogger(__name__)

# How long to listen for console/network events when no explicit duration is
# given. Long enough to catch a page's idle-time XHRs, short enough not to make
# the tool call feel hung.
DEFAULT_INSPECT_DURATION_S = 3.0
MAX_INSPECT_DURATION_S = 30.0

# Cap each captured entry so a runaway page cannot blow the response budget.
# Long values are truncated with a marker; the full text would be in the
# browser's devtools anyway.
ENTRY_TEXT_CAP = 500
MAX_ENTRIES_PER_KIND = 200

# Largest PNG we will base64 into a tool result. A base64 string carries no
# A360 identity, so `enforce_response_budget` cannot stub it — the blob would
# reach the client whole and be abridged into an opaque scratchpad file. Rather
# than build a payload the budget cannot fix, oversized captures spill to disk.
#
# base64 inflates by 4/3, so the cap is set against the *encoded* size: a
# quarter of the byte budget, leaving room for the rest of the envelope.
INLINE_SCREENSHOT_BUDGET_FRACTION = 4
MAX_INLINE_SCREENSHOT_BYTES = get_response_budget() // INLINE_SCREENSHOT_BUDGET_FRACTION

# Filename used when an oversized capture spills into the per-account state dir.
SPILLED_SCREENSHOT_FILENAME = "debug_screenshot.png"


class BrowserUnavailable(RuntimeError):
    """Playwright is not installed — surface the [browser] extra to the user."""


class NoDebugWindow(RuntimeError):
    """No live debug window — direct the caller to open_debug_window."""


class InspectError(RuntimeError):
    """A Playwright operation failed mid-inspection."""


def require_playwright() -> Any:
    """Import Playwright lazily, or raise :class:`BrowserUnavailable`."""
    try:
        from playwright.sync_api import sync_playwright
    except ImportError as exc:
        raise BrowserUnavailable(
            "Playwright is not installed. Install the browser extra: "
            "pip install 'a360-mcp[browser]' && playwright install chromium"
        ) from exc
    return sync_playwright


@dataclass(frozen=True)
class ConsoleEntry:
    kind: str  # "log", "error", "warning", "info", "pageerror"
    text: str

    def to_dict(self) -> dict:
        return {"kind": self.kind, "text": self.text}


@dataclass(frozen=True)
class NetworkEntry:
    method: str
    url: str
    status: int | None
    resource_type: str
    failed: bool

    def to_dict(self) -> dict:
        return {
            "method": self.method,
            "url": self.url,
            "status": self.status,
            "resource_type": self.resource_type,
            "failed": self.failed,
        }


def _cap(text: str) -> str:
    if len(text) <= ENTRY_TEXT_CAP:
        return text
    return text[:ENTRY_TEXT_CAP] + f" …<{len(text)} chars>"


def _connect(state: WindowState) -> Any:
    """Open a Playwright CDP connection + first page. Caller owns closing."""
    sync_playwright = require_playwright()
    try:
        pw = sync_playwright().start()
    except Exception as exc:  # playwright raises its own RuntimeError subclasses
        raise InspectError(f"Could not start Playwright: {exc}") from exc
    try:
        browser = pw.chromium.connect_over_cdp(f"http://127.0.0.1:{state.port}")
    except Exception as exc:
        pw.stop()
        raise InspectError(
            f"Could not connect to debug window CDP at port {state.port}: {exc}"
        ) from exc
    contexts = browser.contexts
    if not contexts:
        browser.close()
        pw.stop()
        raise InspectError("Debug window has no browser context; relaunch it.")
    pages = contexts[0].pages
    if not pages:
        browser.close()
        pw.stop()
        raise InspectError("Debug window has no open tab; open a tab first.")
    return pw, browser, pages[0]


def _disconnect(pw: Any, browser: Any) -> None:
    # Closing a connected-over-CDP browser disconnects WITHOUT killing the
    # underlying Chromium — that's the behaviour we depend on.
    try:
        browser.close()
    except Exception as exc:  # pragma: no cover - best effort
        logger.debug("browser.close failed during disconnect: %s", exc)
    try:
        pw.stop()
    except Exception as exc:  # pragma: no cover - best effort
        logger.debug("playwright.stop failed during disconnect: %s", exc)


def inspect_console(state: WindowState | None, *, duration_seconds: float | None = None) -> dict:
    """Capture console messages and page errors for ``duration_seconds``.

    Returns ``{count, entries, listened_seconds, window}``. Raises
    :class:`NoDebugWindow` when ``state`` is None — the caller must surface a
    "call open_debug_window first" message.
    """
    if state is None:
        raise NoDebugWindow(
            "No live debug window. Call open_debug_window first — inspect_console only "
            "reads a window that is already open."
        )
    duration = _clamp_duration(duration_seconds)
    entries: list[ConsoleEntry] = []

    pw, browser, page = _connect(state)
    try:

        def _on_console(msg: Any) -> None:
            if len(entries) >= MAX_ENTRIES_PER_KIND:
                return
            entries.append(ConsoleEntry(kind=msg.type, text=_cap(msg.text)))

        def _on_pageerror(err: Any) -> None:
            if len(entries) >= MAX_ENTRIES_PER_KIND:
                return
            entries.append(ConsoleEntry(kind="pageerror", text=_cap(str(err))))

        page.on("console", _on_console)
        page.on("pageerror", _on_pageerror)
        _sleep(duration)
    finally:
        _disconnect(pw, browser)

    return {
        "count": len(entries),
        "entries": [e.to_dict() for e in entries],
        "listened_seconds": duration,
        "window": {"pid": state.pid, "port": state.port},
    }


def inspect_network(state: WindowState | None, *, duration_seconds: float | None = None) -> dict:
    """Capture XHR/fetch network traffic for ``duration_seconds``.

    Returns ``{count, requests, listened_seconds, window}``. Only XHR and
    fetch resources are reported (documents/stylesheets/images clutter the
    trace without adding diagnostic value for bot-failure work).
    """
    if state is None:
        raise NoDebugWindow(
            "No live debug window. Call open_debug_window first — inspect_network only "
            "reads a window that is already open."
        )
    duration = _clamp_duration(duration_seconds)
    entries: list[NetworkEntry] = []

    pw, browser, page = _connect(state)
    try:

        def _on_request_finished(request: Any) -> None:
            if len(entries) >= MAX_ENTRIES_PER_KIND:
                return
            if request.resource_type not in ("xhr", "fetch"):
                return
            try:
                resp = request.response()
                status = resp.status if resp is not None else None
            except Exception:
                status = None
            entries.append(
                NetworkEntry(
                    method=request.method,
                    url=request.url,
                    status=status,
                    resource_type=request.resource_type,
                    failed=status is None,
                )
            )

        def _on_request_failed(request: Any) -> None:
            if len(entries) >= MAX_ENTRIES_PER_KIND:
                return
            if request.resource_type not in ("xhr", "fetch"):
                return
            entries.append(
                NetworkEntry(
                    method=request.method,
                    url=request.url,
                    status=None,
                    resource_type=request.resource_type,
                    failed=True,
                )
            )

        page.on("requestfinished", _on_request_finished)
        page.on("requestfailed", _on_request_failed)
        _sleep(duration)
    finally:
        _disconnect(pw, browser)

    return {
        "count": len(entries),
        "requests": [e.to_dict() for e in entries],
        "listened_seconds": duration,
        "window": {"pid": state.pid, "port": state.port},
    }


def screenshot_debug(
    state: WindowState | None,
    *,
    output_path: str | None = None,
    full_page: bool = True,
) -> dict:
    """Take a PNG screenshot of the active tab.

    When ``output_path`` is given the file is written there and its absolute
    path is returned. Otherwise the PNG comes back base64-encoded inline —
    unless it exceeds :data:`MAX_INLINE_SCREENSHOT_BYTES`, in which case it
    spills to the per-account state directory and the path is returned instead.

    The spill is not an optimization. A base64 string has no A360 identity, so
    the response budget cannot stub it; an inline multi-megabyte capture would
    reach the client whole and be abridged into an opaque file.
    """
    if state is None:
        raise NoDebugWindow(
            "No live debug window. Call open_debug_window first — screenshot_debug only "
            "captures a window that is already open."
        )

    pw, browser, page = _connect(state)
    try:
        png_bytes = page.screenshot(full_page=full_page, type="png")
    except Exception as exc:
        raise InspectError(f"screenshot failed: {exc}") from exc
    finally:
        _disconnect(pw, browser)

    window = {"pid": state.pid, "port": state.port}

    if output_path:
        out = _write_png(Path(output_path).expanduser().resolve(), png_bytes)
        return {"saved": True, "path": str(out), "bytes": len(png_bytes), "window": window}

    encoded = base64.b64encode(png_bytes).decode("ascii")
    if len(encoded) <= MAX_INLINE_SCREENSHOT_BYTES:
        return {
            "saved": False,
            "base64_png": encoded,
            "bytes": len(png_bytes),
            "window": window,
        }

    out = _write_png(_spilled_screenshot_path(state), png_bytes)
    return {
        "saved": True,
        "path": str(out),
        "bytes": len(png_bytes),
        "spilled_to_disk": True,
        "window": window,
        "note": (
            f"Screenshot is {len(encoded)} base64 bytes, over the "
            f"{MAX_INLINE_SCREENSHOT_BYTES}-byte inline limit, so it was written to disk "
            "instead of returned inline. Open the path to view it, or pass output_path "
            "to choose the location."
        ),
    }


def _write_png(path: Path, png_bytes: bytes) -> Path:
    """Write ``png_bytes`` to ``path``, creating parent directories."""
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_bytes(png_bytes)
    return path


def _spilled_screenshot_path(state: WindowState) -> Path:
    """Where an oversized capture lands: under the per-account state dir.

    Keeping it beside the snapshots (rather than the CWD) means a screenshot
    never lands in whatever directory the MCP host happened to start in.
    """
    key = account_key(state.control_room_url, state.username)
    return state_dir() / "screenshots" / key / SPILLED_SCREENSHOT_FILENAME


def get_computed_style(
    state: WindowState | None, *, selector: str, properties: list[str] | None = None
) -> dict:
    """Read computed CSS values for the first element matching ``selector``.

    When ``properties`` is None, a compact summary (display, visibility,
    opacity, position, color, background-color, font-size) is returned. This
    is read-only: it evaluates ``getComputedStyle`` and returns the result,
    never writes to the DOM.
    """
    if state is None:
        raise NoDebugWindow(
            "No live debug window. Call open_debug_window first — get_computed_style only "
            "reads a window that is already open."
        )
    if not selector or not selector.strip():
        raise InspectError("selector is required")

    default_props = [
        "display",
        "visibility",
        "opacity",
        "position",
        "color",
        "background-color",
        "font-size",
    ]
    props = properties or default_props

    pw, browser, page = _connect(state)
    try:
        result = page.evaluate(
            """(args) => {
                const [selector, properties] = args;
                const el = document.querySelector(selector);
                if (!el) return {matched: false};
                const style = getComputedStyle(el);
                const picked = {};
                for (const p of properties) picked[p] = style.getPropertyValue(p);
                const rect = el.getBoundingClientRect();
                return {
                    matched: true,
                    tag: el.tagName.toLowerCase(),
                    id: el.id || null,
                    classes: el.className || null,
                    styles: picked,
                    rect: {x: rect.x, y: rect.y, w: rect.width, h: rect.height},
                };
            }""",
            [selector, props],
        )
    except Exception as exc:
        raise InspectError(f"computed style read failed: {exc}") from exc
    finally:
        _disconnect(pw, browser)

    return {
        "selector": selector,
        "result": result,
        "window": {"pid": state.pid, "port": state.port},
    }


def _clamp_duration(seconds: float | None) -> float:
    if seconds is None:
        return DEFAULT_INSPECT_DURATION_S
    if seconds < 0:
        return 0.0
    if seconds > MAX_INSPECT_DURATION_S:
        return MAX_INSPECT_DURATION_S
    return seconds


def _sleep(seconds: float) -> None:
    if seconds <= 0:
        return
    deadline = time.time() + seconds
    while time.time() < deadline:
        time.sleep(min(0.2, deadline - time.time()))


__all__ = [
    "BrowserUnavailable",
    "DEFAULT_INSPECT_DURATION_S",
    "InspectError",
    "MAX_INSPECT_DURATION_S",
    "NoDebugWindow",
    "get_computed_style",
    "inspect_console",
    "inspect_network",
    "require_playwright",
    "screenshot_debug",
]
