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

from __future__ import annotations

import functools
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


@functools.lru_cache(maxsize=1)
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
