"""Lifecycle of the shared debug window (a detached Chromium + CDP port).

The window is launched as a plain OS process rather than a Playwright-managed
browser so it outlives the tool call that opened it. State (pid + port +
profile) lives in a small JSON file keyed by account, mirroring how the auth
layer keys its token and session files — so a dev window and a test window
never collide with each other, nor with the login profile.

Hard rules inherited from the handoff (Phase 9 spec):

- **Headed only.** The window exists so a human can look at it together with
  the model. A headless shared screen is a contradiction, so the headless
  setting is deliberately not consulted here.
- **No clicks.** This module launches the process and exposes its CDP port.
  Driving the page (forms, buttons, navigation) is the human's job. The
  ``inspect_*`` helpers read state over CDP without ever synthesising input.
- **Per-account profile.** Each account gets its own ``--user-data-dir`` so
  cookies, extensions and the remembered-device login do not bleed across
  accounts on the same machine.
"""

from __future__ import annotations

import json
import logging
import os
import socket
import subprocess
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path

from ..auth._paths import account_key, state_dir
from .launch_budget import (
    check_launch_allowed,
    record_launch,
)

logger = logging.getLogger(__name__)

# The debug window is ALWAYS visible, regardless of any headless setting. A
# headless shared screen contradicts the purpose of "let's look at it together".
DEBUG_WINDOW_ALWAYS_HEADED = True

# How long to wait for Chromium to bind its CDP port before giving up.
READINESS_TIMEOUT_S = 30.0
READINESS_POLL_INTERVAL_S = 0.5

# Subdirectory under state_dir() holding per-account browser profiles + state.
BROWSER_SUBDIR = "browser"
WINDOW_STATE_FILENAME = "window_{account_key}.json"
LAUNCH_HISTORY_FILENAME = "launch_history_{account_key}.json"

# Environment override for the Chromium binary path. When unset we try common
# install locations. Auto-install is deliberately NOT done — a Chromium that
# appears without consent is a bad default for a debug tool.
ENV_CHROME_BIN = "A360_BROWSER_BIN"

# Common Chromium binary names/paths. Order matters: env override wins, then
# these are tried top-down.
_CANDIDATE_BINARIES: tuple[str, ...] = (
    "google-chrome",
    "google-chrome-stable",
    "chromium",
    "chromium-browser",
    "chrome",
    # macOS
    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
    # Windows (common install locations)
    r"C:\Program Files\Google\Chrome\Application\chrome.exe",
    r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
)


@dataclass(frozen=True)
class WindowState:
    """Snapshot of one running debug window. Immutable; written as JSON."""

    pid: int
    port: int
    started_at: float
    control_room_url: str
    username: str
    profile_dir: str

    def to_payload(self) -> dict:
        return {
            "pid": self.pid,
            "port": self.port,
            "started_at": self.started_at,
            "control_room_url": self.control_room_url,
            "username": self.username,
            "profile_dir": self.profile_dir,
        }

    @classmethod
    def from_payload(cls, payload: dict) -> WindowState | None:
        try:
            return cls(
                pid=int(payload["pid"]),
                port=int(payload["port"]),
                started_at=float(payload["started_at"]),
                control_room_url=str(payload["control_room_url"]),
                username=str(payload["username"]),
                profile_dir=str(payload["profile_dir"]),
            )
        except (KeyError, TypeError, ValueError):
            return None


class WindowLaunchError(RuntimeError):
    """Chromium could not be started or did not become ready in time."""


def window_state_path(account_key_value: str) -> Path:
    return state_dir() / WINDOW_STATE_FILENAME.format(account_key=account_key_value)


def launch_history_path(account_key_value: str) -> str:
    return str(state_dir() / LAUNCH_HISTORY_FILENAME.format(account_key=account_key_value))


def profile_dir_for(account_key_value: str) -> Path:
    return state_dir() / BROWSER_SUBDIR / f"profile_{account_key_value}"


def find_window(*, control_room_url: str, username: str) -> WindowState | None:
    """Return the live window for this account, or None when none is running.

    A window is "live" when its recorded PID is still alive AND its CDP port
    still answers. A stale state file (process gone) is cleared so the next
    caller can launch a fresh window.
    """
    key = account_key(control_room_url, username)
    state = _read_state(key)
    if state is None:
        return None
    if not _is_pid_alive(state.pid) or not _cdp_alive(state.port):
        logger.debug(
            "Clearing stale window state for %s (pid=%s port=%s not live)",
            key,
            state.pid,
            state.port,
        )
        _clear_state(key)
        return None
    return state


def launch_window(
    *,
    control_room_url: str,
    username: str,
    start_url: str | None = None,
    now: float | None = None,
) -> WindowState:
    """Launch the shared debug window for this account.

    Raises :class:`LaunchBudgetExceeded` when the budget refuses another launch,
    or :class:`WindowLaunchError` when Chromium cannot be started or fails to
    bind its CDP port within :data:`READINESS_TIMEOUT_S` seconds.
    """
    key = account_key(control_room_url, username)
    history = launch_history_path(key)
    check_launch_allowed(history, now=now)

    binary = _resolve_chromium_binary()
    if binary is None:
        raise WindowLaunchError(
            "Chromium binary not found. Set " + ENV_CHROME_BIN + " to the absolute path of "
            "Chrome/Chromium, or install Google Chrome / Chromium."
        )

    port = _pick_free_port()
    profile = profile_dir_for(key)
    profile.mkdir(parents=True, exist_ok=True)

    args = _build_chromium_args(binary, port, profile, start_url or control_room_url)
    logger.info("Launching debug window: %s", " ".join(args))

    try:
        proc = subprocess.Popen(
            args,
            stdin=subprocess.DEVNULL,
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
            close_fds=True,
            start_new_session=True,
        )
    except OSError as exc:
        raise WindowLaunchError(f"Failed to start Chromium ({binary}): {exc}") from exc

    state = WindowState(
        pid=proc.pid,
        port=port,
        started_at=time.time(),
        control_room_url=control_room_url,
        username=username,
        profile_dir=str(profile),
    )

    if not _wait_for_cdp(port, timeout=READINESS_TIMEOUT_S):
        # The window vanished inside the readiness window — almost certainly a
        # crash. Poll process liveness so we can give a precise error.
        if not _is_pid_alive(proc.pid):
            raise WindowLaunchError(
                f"Chromium (pid={proc.pid}) exited within {READINESS_TIMEOUT_S:.0f}s of launch "
                "without binding its debug port. Check the Chromium install and the profile "
                f"directory at {profile}."
            )
        # Process alive but port never answered: kill it so we don't leak.
        _terminate(proc.pid)
        raise WindowLaunchError(
            f"Chromium (pid={proc.pid}) did not expose its CDP port within "
            f"{READINESS_TIMEOUT_S:.0f}s. The build may disable remote debugging."
        )

    _write_state(key, state)
    record_launch(history, now=now)
    logger.info("Debug window ready: pid=%s port=%s profile=%s", state.pid, state.port, profile)
    return state


def close_window(*, control_room_url: str, username: str) -> bool:
    """Terminate the live window for this account. Returns True if one was closed."""
    state = find_window(control_room_url=control_room_url, username=username)
    if state is None:
        return False
    _terminate(state.pid)
    _clear_state(account_key(control_room_url, username))
    return True


def _build_chromium_args(binary: str, port: int, profile: Path, start_url: str) -> list[str]:
    # Flags chosen for a debug window: visible, isolated profile, CDP on, and
    # the workarounds required to run Chromium detached under recent desktop
    # environments (no sandbox is unsafe for arbitrary web browsing but this
    # window is pointing at a known internal host and is owned by the user).
    return [
        binary,
        "--user-data-dir=" + str(profile),
        f"--remote-debugging-port={port}",
        # Bind to loopback only so other machines on the LAN cannot drive the
        # window. (Mirrors Chrome's own default since ~M87.)
        "--remote-debugging-address=127.0.0.1",
        "--no-first-run",
        "--no-default-browser-check",
        "--disable-features=Translate",
        start_url,
    ]


def _resolve_chromium_binary() -> str | None:
    override = os.environ.get(ENV_CHROME_BIN)
    if override:
        if Path(override).is_file():
            return override
        # Honour PATH lookup of the override too.
        from shutil import which

        resolved = which(override)
        if resolved:
            return resolved
        return None
    from shutil import which

    for candidate in _CANDIDATE_BINARIES:
        if os.path.isabs(candidate):
            if Path(candidate).is_file():
                return candidate
        else:
            resolved = which(candidate)
            if resolved:
                return resolved
    return None


def _pick_free_port() -> int:
    # Bind port 0 lets the OS choose; we then close the socket and reuse the
    # port. There is a small race window before Chromium rebinds, but for a
    # per-account single-window tool this is acceptable and far simpler than
    # coordinating a port range.
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    try:
        sock.bind(("127.0.0.1", 0))
        return sock.getsockname()[1]
    finally:
        sock.close()


def _wait_for_cdp(port: int, *, timeout: float) -> bool:
    deadline = time.time() + timeout
    while time.time() < deadline:
        if _cdp_alive(port):
            return True
        time.sleep(READINESS_POLL_INTERVAL_S)
    return False


def _cdp_alive(port: int) -> bool:
    url = f"http://127.0.0.1:{port}/json/version"
    try:
        with urllib.request.urlopen(url, timeout=2.0) as resp:
            if resp.status != 200:
                return False
            body = resp.read(4096)
            return b"Browser" in body or b"webSocket" in body
    except (urllib.error.URLError, OSError):
        return False


def _is_pid_alive(pid: int) -> bool:
    if pid <= 0:
        return False
    if sys.platform == "win32":
        try:
            os.kill(pid, 0)
        except (ProcessLookupError, PermissionError, OSError):
            return False
        return True
    try:
        os.kill(pid, 0)
    except ProcessLookupError:
        return False
    except PermissionError:
        return True  # exists, owned by someone else
    except OSError:
        return True  # unknown -> assume alive; never free state on a guess
    return True


def _terminate(pid: int) -> None:
    if pid <= 0:
        return
    try:
        if sys.platform == "win32":
            subprocess.run(
                ["taskkill", "/PID", str(pid), "/T", "/F"],
                check=False,
                stdout=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                timeout=5.0,
            )
        else:
            import signal

            os.kill(pid, signal.SIGTERM)
    except (ProcessLookupError, PermissionError, OSError):
        # Already gone or not ours — both are fine outcomes for a close.
        pass


def _read_state(key: str) -> WindowState | None:
    path = window_state_path(key)
    try:
        with open(path, encoding="utf-8") as handle:
            raw = json.load(handle)
    except (FileNotFoundError, OSError, json.JSONDecodeError):
        return None
    if not isinstance(raw, dict):
        return None
    return WindowState.from_payload(raw)


def _write_state(key: str, state: WindowState) -> None:
    path = window_state_path(key)
    path.parent.mkdir(parents=True, exist_ok=True)
    tmp = path.with_suffix(path.suffix + ".tmp")
    with open(tmp, "w", encoding="utf-8") as handle:
        json.dump(state.to_payload(), handle)
    os.replace(tmp, path)


def _clear_state(key: str) -> None:
    try:
        window_state_path(key).unlink()
    except FileNotFoundError:
        pass
    except OSError as exc:  # pragma: no cover - best effort
        logger.debug("Could not clear window state for %s: %s", key, exc)


__all__ = [
    "BROWSER_SUBDIR",
    "DEBUG_WINDOW_ALWAYS_HEADED",
    "ENV_CHROME_BIN",
    "READINESS_TIMEOUT_S",
    "WindowLaunchError",
    "WindowState",
    "close_window",
    "find_window",
    "launch_history_path",
    "launch_window",
    "profile_dir_for",
    "window_state_path",
]
