"""Block disruptive actions when the user has unsaved form input on screen.

The debug window exists so a human and the model can look at the same page.
A navigation the model triggers — ``open_debug_window`` reloading to a fresh
URL, or a refresh mid-edit — silently throws away whatever the user was typing.
There is no Control Room API for "is there unsaved work", so this module
checks the page directly over CDP.

Detection is heuristic and DELIBERATELY CONSERVATIVE — false positives block
the user from their own workflow; false negatives only lose typed text. We
prefer the latter. The check counts form controls whose current value differs
from their default, plus any element marked ``contenteditable`` with non-empty
text. Counting (not enumerating) keeps the result small and the heuristic
app-agnostic: it works on the Control Room's React forms and on any other
internal tool the window happens to be pointing at.
"""

from __future__ import annotations

import logging

from .window import WindowState

logger = logging.getLogger(__name__)

# How many dirty controls warrant blocking. A single stray checkbox the user
# toggled by accident is noise; three is signal. Tuned low because the cost
# of a false block is a single confirmation click, while the cost of a false
# pass is losing whatever the user was typing.
DIRTY_THRESHOLD = 1

# JS that counts dirty controls in the active tab. Returns
# {dirty_inputs, dirty_selects, editable_with_text, total_dirty, sample}.
# Capped output keeps the response budget happy on long forms.
_DETECT_JS = """
() => {
    const cap = 5;
    const sample = [];
    const inputs = Array.from(document.querySelectorAll('input, textarea'));
    let dirty_inputs = 0;
    for (const el of inputs) {
        const type = (el.type || 'text').toLowerCase();
        if (type === 'submit' || type === 'button' || type === 'reset' || type === 'image' || type === 'hidden') continue;
        const defaultValue = (el.defaultValue !== undefined) ? el.defaultValue : '';
        if (el.value !== defaultValue) {
            dirty_inputs += 1;
            if (sample.length < cap) sample.push({tag: el.tagName.toLowerCase(), type: type, name: el.name || null, id: el.id || null});
        }
    }
    const selects = Array.from(document.querySelectorAll('select'));
    let dirty_selects = 0;
    for (const el of selects) {
        const changed = Array.from(el.options).some(o => o.selected !== o.defaultSelected);
        if (changed) {
            dirty_selects += 1;
            if (sample.length < cap) sample.push({tag: 'select', name: el.name || null, id: el.id || null});
        }
    }
    const editables = Array.from(document.querySelectorAll('[contenteditable=""], [contenteditable="true"]'));
    let editable_with_text = 0;
    for (const el of editables) {
        if ((el.innerText || '').trim().length > 0) {
            editable_with_text += 1;
            if (sample.length < cap) sample.push({tag: 'contenteditable', id: el.id || null});
        }
    }
    const total = dirty_inputs + dirty_selects + editable_with_text;
    return {dirty_inputs, dirty_selects, editable_with_text, total_dirty: total, sample};
}
"""


class UnsavedInputBlocked(RuntimeError):
    """The page has unsaved input — refuse the disruptive action."""


class GuardCheckError(RuntimeError):
    """Could not run the unsaved-input check (e.g. window died mid-check)."""


def check_unsaved_input(
    state: WindowState | None,
    *,
    threshold: int = DIRTY_THRESHOLD,
) -> dict:
    """Inspect the active tab for unsaved form input.

    Returns ``{has_unsaved: bool, dirty_count, threshold, sample, window}``.
    Callers that would disrupt the page (navigate, reload) should raise
    :class:`UnsavedInputBlocked` when ``has_unsaved`` is True.

    A missing window (state is None) is treated as "no unsaved input" — there
    is nothing to disrupt. A failed check (window unreachable) raises
    :class:`GuardCheckError` so the caller can decide whether to proceed; by
    default we treat the unreachable window as absent.
    """
    if state is None:
        return {
            "has_unsaved": False,
            "dirty_count": 0,
            "threshold": threshold,
            "sample": [],
            "window": None,
        }

    # Lazy import so the unsaved guard module loads even without Playwright
    # installed; the check only needs it at call time.
    try:
        from .inspect import _connect, _disconnect
    except ImportError as exc:
        raise GuardCheckError(f"Playwright not available: {exc}") from exc

    try:
        pw, browser, page = _connect(state)
    except Exception as exc:
        raise GuardCheckError(
            f"Could not connect to debug window to check for unsaved input: {exc}"
        ) from exc

    try:
        result = page.evaluate(_DETECT_JS)
    except Exception as exc:
        raise GuardCheckError(f"unsaved-input probe failed: {exc}") from exc
    finally:
        _disconnect(pw, browser)

    if not isinstance(result, dict):
        result = {}
    dirty = int(result.get("total_dirty", 0) or 0)
    return {
        "has_unsaved": dirty >= threshold,
        "dirty_count": dirty,
        "threshold": threshold,
        "sample": result.get("sample", []) or [],
        "window": {"pid": state.pid, "port": state.port},
    }


def assert_safe_to_disrupt(state: WindowState | None, *, action: str) -> None:
    """Raise :class:`UnsavedInputBlocked` when the page has unsaved input.

    ``action`` is included in the message so the user knows what was blocked
    (e.g. "open_debug_window", "reload"). When the check itself fails we let
    the action proceed — losing typed text is bad, but blocking every action
    because the probe had a transient error is worse. The failure is logged.
    """
    try:
        report = check_unsaved_input(state)
    except GuardCheckError as exc:
        logger.warning("unsaved-input check failed for %s; proceeding: %s", action, exc)
        return
    if report["has_unsaved"]:
        raise UnsavedInputBlocked(
            f"The debug window has {report['dirty_count']} unsaved form control(s) "
            f"(threshold {report['threshold']}). {action} would discard them — "
            "save or dismiss the form first, then retry. "
            f"Sample: {report['sample']}"
        )


__all__ = [
    "DIRTY_THRESHOLD",
    "GuardCheckError",
    "UnsavedInputBlocked",
    "assert_safe_to_disrupt",
    "check_unsaved_input",
]
