"""Shared helpers and constants for mcp_vm_blackbox tool modules."""

from __future__ import annotations

import importlib
import json
import os
import shlex
import shutil
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any

import asyncssh
from fastmcp.exceptions import ToolError

from mcp_vm_blackbox.targets import TargetConfig, resolve_target

if TYPE_CHECKING:
    from mcp_vm_blackbox.vbox_dispatch import VBoxDispatch

# ---------------------------------------------------------------------------
# Path and environment constants
# ---------------------------------------------------------------------------


def discover_repo_root_from_cwd(max_depth: int = 10) -> Path | None:
    """Walk parents from :func:`Path.cwd` looking for ``.git`` or ``.mcp-vm-blackbox``.

    Returns:
        The directory that contains the marker, or ``None`` if none found within
        *max_depth* steps (or at filesystem root).
    """
    current = Path.cwd().resolve()
    for _ in range(max_depth):
        if (current / ".git").exists() or (current / ".mcp-vm-blackbox").exists():
            return current
        if current.parent == current:
            return None
        current = current.parent
    return None


def default_blackbox_data_root() -> Path:
    """Absolute path to the ``.mcp-vm-blackbox`` data directory.

    Unlike ``Path('.mcp-vm-blackbox')``, this does not depend on the current
    working directory at read/write time: resolution happens once when the
    path is first computed (typically at process startup).

    Resolution order:

    1. :envvar:`MCP_VM_BLACKBOX_DATA_ROOT` — path to the data directory (the
       ``.mcp-vm-blackbox`` folder itself).
    2. Walk upward from :func:`Path.cwd` for ``.git`` or ``.mcp-vm-blackbox``,
       then ``<discovered-repo>/.mcp-vm-blackbox``.
    3. ``Path.home() / '.mcp-vm-blackbox'`` — same global default pattern as
       :func:`effective_root` when no repo is found.

    Returns:
        Resolved, absolute path.
    """
    env = os.environ.get("MCP_VM_BLACKBOX_DATA_ROOT")
    if env:
        return Path(env).expanduser().resolve()
    repo = discover_repo_root_from_cwd()
    if repo is not None:
        return (repo / ".mcp-vm-blackbox").resolve()
    return (Path.home() / ".mcp-vm-blackbox").resolve()


_SCRATCH_DIR = Path(
    os.environ.get("MCP_VM_BLACKBOX_SCRATCH_DIR", str(Path.home() / ".mcp-vm-blackbox" / "scratch" / "screenshots"))
)
_TOML_PATH = Path(
    os.environ.get("MCP_VM_BLACKBOX_TARGETS_TOML", str(Path.home() / ".mcp-vm-blackbox" / "infra_targets.toml"))
)

# ---------------------------------------------------------------------------
# Named constants
# ---------------------------------------------------------------------------

_SSH_BIN: str = shutil.which("ssh") or "ssh"
_SCP_BIN: str = shutil.which("scp") or "scp"
_CI_CHECK_STAT_LINE_COUNT: int = 3
_BUILD_TERMINAL_MARKERS: tuple[str, ...] = ("Build successful", "FAILED", "Error:", "exited with")
_LOG_POLL_INTERVAL_S: int = 2

#: Map of human-friendly key names to VBoxDispatch key names.
#: Used by vm_key tool for validation and mapping.
KEY_MAP: dict[str, str] = {
    # Legacy names (backward-compatible)
    "enter": "Return",
    "tab": "Tab",
    "escape": "Escape",
    "space": "space",
    "backspace": "BackSpace",
    # Letters
    "a": "a",
    "b": "b",
    "c": "c",
    "d": "d",
    "e": "e",
    "f": "f",
    "g": "g",
    "h": "h",
    "i": "i",
    "j": "j",
    "k": "k",
    "l": "l",
    "m": "m",
    "n": "n",
    "o": "o",
    "p": "p",
    "q": "q",
    "r": "r",
    "s": "s",
    "t": "t",
    "u": "u",
    "v": "v",
    "w": "w",
    "x": "x",
    "y": "y",
    "z": "z",
    # Numbers
    "0": "0",
    "1": "1",
    "2": "2",
    "3": "3",
    "4": "4",
    "5": "5",
    "6": "6",
    "7": "7",
    "8": "8",
    "9": "9",
    # Function keys
    "f1": "f1",
    "f2": "f2",
    "f3": "f3",
    "f4": "f4",
    "f5": "f5",
    "f6": "f6",
    "f7": "f7",
    "f8": "f8",
    "f9": "f9",
    "f10": "f10",
    "f11": "f11",
    "f12": "f12",
    # Navigation keys
    "up": "up",
    "down": "down",
    "left": "left",
    "right": "right",
    "home": "home",
    "end": "end",
    "pageup": "pageup",
    "pagedown": "pagedown",
    "insert": "insert",
    "delete": "delete",
    # Other special keys
    "printscreen": "printscreen",
}

#: Map of build alias -> script path. Populate via MCP_VM_BLACKBOX_BUILD_SCRIPTS
#: env var as JSON, or extend in your project's server configuration.
BUILD_SCRIPTS: dict[str, str] = {}

#: Minimum required versions for CI preflight.
VERSION_REQUIREMENTS: dict[str, str] = {"VBoxManage": "7.0", "vagrant": "2.3", "packer": "1.10"}

# ---------------------------------------------------------------------------
# Public exports for tool modules
# ---------------------------------------------------------------------------

SCRATCH_DIR = _SCRATCH_DIR
TOML_PATH = _TOML_PATH
SSH_BIN = _SSH_BIN
SCP_BIN = _SCP_BIN
CI_CHECK_STAT_LINE_COUNT = _CI_CHECK_STAT_LINE_COUNT
BUILD_TERMINAL_MARKERS = _BUILD_TERMINAL_MARKERS
LOG_POLL_INTERVAL_S = _LOG_POLL_INTERVAL_S


def _resolve(target: str) -> TargetConfig:
    """Resolve a target string to a TargetConfig.

    Args:
        target: Named target or raw ``user@host`` string.

    Returns:
        Resolved TargetConfig.
    """
    return resolve_target(target, _TOML_PATH)


def _vbox(cfg: TargetConfig, vm_name: str) -> VBoxDispatch:
    """Construct a VBoxDispatch for local or remote dispatch.

    Args:
        cfg: Resolved target configuration.
        vm_name: VirtualBox VM name.

    Returns:
        Configured VBoxDispatch instance.
    """
    from mcp_vm_blackbox.vbox_dispatch import VBoxDispatch  # noqa: PLC0415

    return VBoxDispatch(vm_name=vm_name, cfg=cfg)


@dataclass
class _SshRunResult:
    """Result of _ssh_run with stdout, stderr, returncode for caller compatibility."""

    stdout: str
    stderr: str
    returncode: int


def _to_str(val: str | bytes | None) -> str:
    """Convert stdout/stderr to str."""
    if val is None:
        return ""
    return val.decode("utf-8", errors="replace") if isinstance(val, bytes) else val


async def _ssh_run(cfg: TargetConfig, args: list[str]) -> _SshRunResult:
    """Run a command via asyncssh — remote host or localhost (no subprocess).

    For remote targets uses asyncssh to the configured host.
    For local targets uses asyncssh to localhost so no subprocess is used.

    Args:
        cfg: Resolved target configuration.
        args: Command and arguments to execute.

    Returns:
        Result with stdout, stderr, returncode.
    """
    if not cfg.is_remote:
        cmd = shlex.join(args)
        try:
            async with asyncssh.connect("localhost", known_hosts=None) as conn:
                result = await conn.run(cmd, check=False)
                return _SshRunResult(
                    stdout=_to_str(result.stdout),
                    stderr=_to_str(result.stderr),
                    returncode=result.exit_status if result.exit_status is not None else -1,
                )
        except asyncssh.Error as exc:
            return _SshRunResult(stdout="", stderr=str(exc), returncode=-1)

    if "@" in cfg.ssh_host:
        username, host = cfg.ssh_host.rsplit("@", 1)
    else:
        host = cfg.ssh_host
        username = cfg.ssh_user or ""

    conn_kwargs: dict[str, Any] = {"host": host, "port": cfg.ssh_port or 22, "known_hosts": None}
    if username:
        conn_kwargs["username"] = username
    if cfg.ssh_identity_file:
        conn_kwargs["client_keys"] = [cfg.ssh_identity_file]

    cmd = shlex.join(args)
    async with asyncssh.connect(**conn_kwargs) as conn:
        result = await conn.run(cmd, check=False)
        return _SshRunResult(
            stdout=_to_str(result.stdout),
            stderr=_to_str(result.stderr),
            returncode=result.exit_status if result.exit_status is not None else -1,
        )


async def _ssh_get_file(cfg: TargetConfig, remote_path: str, local_path: str) -> None:
    """Copy a file from a remote host to the local filesystem via SFTP.

    No-op when cfg is local (is_remote=False). Caller must not invoke for
    local targets.

    Args:
        cfg: Resolved target configuration (must have is_remote=True).
        remote_path: Path on the remote host.
        local_path: Path on the local filesystem.

    Raises:
        asyncssh.Error: When the SFTP transfer fails.
    """
    if not cfg.is_remote:
        return
    if "@" in cfg.ssh_host:
        username, host = cfg.ssh_host.rsplit("@", 1)
    else:
        host = cfg.ssh_host
        username = cfg.ssh_user or ""
    conn_kwargs: dict[str, Any] = {"host": host, "port": cfg.ssh_port or 22, "known_hosts": None}
    if username:
        conn_kwargs["username"] = username
    if cfg.ssh_identity_file:
        conn_kwargs["client_keys"] = [cfg.ssh_identity_file]
    async with asyncssh.connect(**conn_kwargs) as conn, await conn.start_sftp_client() as sftp:
        await sftp.get(remote_path, local_path)


def check_lines_for_pattern(lines: list[str], break_on: list[str]) -> tuple[str, str] | None:
    """Check a batch of log lines for a break-on pattern match.

    Args:
        lines: Lines to scan.
        break_on: Case-insensitive substrings that trigger a match.

    Returns:
        ``(matched_pattern, matched_line)`` on the first hit, or ``None``.
    """
    for line in lines:
        line_lower = line.lower()
        for pattern in break_on:
            if pattern.lower() in line_lower:
                return pattern, line
    return None


def _get_vboxapi_search_paths() -> tuple[str, ...]:
    """Return vboxapi SDK search paths, configurable via MCP_VM_BLACKBOX_VBOXAPI_PATHS.

    Environment variable accepts colon-separated paths (e.g. PATH-style).
    When unset, uses the default VirtualBox SDK locations.
    """
    env_paths = os.environ.get("MCP_VM_BLACKBOX_VBOXAPI_PATHS")
    if env_paths:
        return tuple(p.strip() for p in env_paths.split(":") if p.strip())
    return (
        "/usr/local/lib/python3.12/dist-packages",
        "/usr/lib/virtualbox",
        "/usr/lib/virtualbox/sdk/bindings/xpcom/python",
    )


def _load_vboxapi() -> Any:
    """Lazily import and return ``VirtualBoxManager`` from the vboxapi SDK.

    Adds the VirtualBox SDK directories to ``sys.path`` only if they are not
    already present, then loads ``vboxapi`` via :func:`importlib.import_module`.

    Returns:
        The ``VirtualBoxManager`` class from the ``vboxapi`` module.

    Raises:
        ToolError: When vboxapi cannot be imported (SDK not installed).
    """
    for search_path in _get_vboxapi_search_paths():
        if search_path not in sys.path:
            sys.path.insert(0, search_path)
    try:
        vboxapi = importlib.import_module("vboxapi")
    except ImportError as exc:
        raise ToolError(
            "vboxapi is not available. Install the VirtualBox SDK or ensure "
            "/usr/local/lib/python3.12/dist-packages/vboxapi/ exists."
        ) from exc
    return vboxapi.VirtualBoxManager


def project_root() -> Path:
    """Return the repository root (parent of packages/).

    Used to locate skills/ and agents/ directories at the top level
    of the vm-flightsimulator repo.

    Returns:
        Absolute Path to the repository root directory.
    """
    return Path(__file__).resolve().parent.parent.parent


def effective_root(repo_root: str | None) -> str:
    """Resolve the effective Vagrantfile root directory.

    Args:
        repo_root: Caller-supplied path, or ``None`` to use the default
            (``~/.mcp-vm-blackbox``).

    Returns:
        Absolute path string to the directory containing the Vagrantfile.
    """
    return repo_root if repo_root is not None else str(Path.home() / ".mcp-vm-blackbox")


def _atomic_write_json(path: Path, data: dict | list) -> None:
    """Atomically write *data* as pretty-printed JSON to *path*.

    Creates parent directories if needed.  Uses a temporary file in the same
    directory followed by an atomic rename so that readers never see a
    partially-written file.

    Args:
        path: Destination file path.
        data: JSON-serialisable dict **or list**.
    """
    path.parent.mkdir(parents=True, exist_ok=True)
    fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
    try:
        with os.fdopen(fd, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=2)
        Path(tmp).rename(path)  # atomic on POSIX; near-atomic on Windows
    except Exception:
        Path(tmp).unlink(missing_ok=True)
        raise
