"""CI host connectivity tools (ci_check, ci_run, ci_preflight, ci_pipeline_status)."""

from __future__ import annotations

import asyncio
import os
import re
import shutil
from pathlib import Path
from typing import TYPE_CHECKING, Annotated

import gitlab
from fastmcp.tools import tool
from git import InvalidGitRepositoryError, Repo
from pydantic import Field

from mcp_vm_blackbox._helpers import CI_CHECK_STAT_LINE_COUNT, VERSION_REQUIREMENTS, _resolve, _ssh_run
from mcp_vm_blackbox.kvm_tools import _get_vendor_module, _lsmod_kvm, _unload_modules
from mcp_vm_blackbox.models import CiCheckResult, CiPipelineStatusResult, CiPreflightResult, CommandResult

if TYPE_CHECKING:
    from collections.abc import Awaitable, Callable

    from mcp_vm_blackbox.targets import TargetConfig

# Column index of refcount in /proc/modules lines: "<name> <size> <refcount> ..."
_PROC_MODULES_REFCOUNT_IDX: int = 2

# ---------------------------------------------------------------------------
# Tools
# ---------------------------------------------------------------------------


@tool
async def ci_check(
    target: Annotated[str, Field(description='Named target or raw SSH string. Defaults to "ci".')] = "ci",
    ssh_connect_timeout: Annotated[int, Field(description="SSH ConnectTimeout in seconds.", ge=1)] = 10,
) -> CiCheckResult:
    """Check SSH connectivity and host stats (disk, memory, uptime).

    Args:
        target: Named target or raw SSH string.  Defaults to ``"ci"``.
        ssh_connect_timeout: SSH ConnectTimeout in seconds.  Defaults to 10.

    Returns:
        CiCheckResult with connected, disk, memory, uptime, and optionally error or note.
    """
    cfg = _resolve(target)
    if not cfg.is_remote:
        return CiCheckResult(
            connected=True, note="local target — no SSH check", vboxmanage=shutil.which("VBoxManage") or "not found"
        )

    try:
        result = await asyncio.wait_for(
            _ssh_run(cfg, ["df -h / | tail -1 && free -m | grep Mem && uptime"]), timeout=ssh_connect_timeout
        )
    except (TimeoutError, Exception) as exc:  # noqa: BLE001
        return CiCheckResult(
            connected=False,
            error=f"SSH to {cfg.ssh_host} failed ({exc.__class__.__name__}) — is the key loaded? Run ci_check first.",
        )

    if result.returncode != 0:
        return CiCheckResult(
            connected=False, error=f"SSH to {cfg.ssh_host} failed — is the key loaded? Run ci_check first."
        )

    lines = result.stdout.strip().splitlines()
    stat_keys = ("disk", "memory", "uptime")
    stats = {
        key: lines[i] if len(lines) > i else "unknown" for i, key in enumerate(stat_keys[:CI_CHECK_STAT_LINE_COUNT])
    }
    return CiCheckResult(connected=True, **stats)


@tool
async def ci_run(
    command: Annotated[str, Field(description="Shell command string to execute.")],
    target: Annotated[str, Field(description='Named target or raw SSH string. Defaults to "ci".')] = "ci",
    timeout_s: Annotated[int, Field(description="Timeout in seconds.", ge=1)] = 60,
) -> CommandResult:
    """Run a shell command on the CI host (or locally).

    Warning:
        This tool executes **arbitrary shell commands** as the process owner
        with no sandboxing.  For remote targets the command runs as the SSH
        user on the remote host; for ``target="local"`` it runs in the current
        process environment.  Only call this tool with commands you fully
        control and trust.

    Args:
        command: Shell command string to execute.
        target: Named target or raw SSH string.
        timeout_s: Timeout in seconds.

    Returns:
        CommandResult with stdout, stderr, exit_code.  Returns
        CommandResult with error and exit_code=-1 on timeout.
    """
    cfg = _resolve(target)
    cmd = ["bash", "-c", command]

    try:
        result = await asyncio.wait_for(_ssh_run(cfg, cmd), timeout=timeout_s)
        return CommandResult(stdout=result.stdout.strip(), stderr=result.stderr.strip(), exit_code=result.returncode)
    except TimeoutError:
        return CommandResult(stdout="", stderr="", exit_code=-1, error=f"Command timed out after {timeout_s}s")
    except Exception as exc:  # noqa: BLE001
        return CommandResult(stdout="", stderr="", exit_code=-1, error=f"Command failed: {exc!s}")


_GITLAB_URL_PATTERNS = (
    re.compile(r"https?://[^/]+/([a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)*)"),
    re.compile(r"git@[^:]+:([a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+(?:/[a-zA-Z0-9_.-]+)*)"),
)


def _get_gitlab_project_path(cwd: Path) -> str | None:
    """Extract GitLab project path from git remotes via GitPython.

    Args:
        cwd: Repository root or directory containing .git.

    Returns:
        Project path (e.g. ``group/project``) if a GitLab remote exists,
        otherwise None.
    """
    try:
        repo = Repo(cwd)
    except InvalidGitRepositoryError:
        return None
    for remote in repo.remotes:
        url = remote.url
        if "gitlab" not in url.lower():
            continue
        for pattern in _GITLAB_URL_PATTERNS:
            m = pattern.search(url)
            if m:
                return m.group(1).removesuffix(".git")
    return None


def _create_gitlab_client() -> gitlab.Gitlab:
    """Create a GitLab client from config or environment.

    Uses PYTHON_GITLAB_CFG / ~/.python-gitlab.cfg when available.
    Otherwise uses GITLAB_HOST and GITLAB_TOKEN (or CI_SERVER_URL and
    CI_JOB_TOKEN in GitLab CI).

    Returns:
        Authenticated GitLab client.

    Raises:
        gitlab.exceptions.GitlabAuthenticationError: When no valid token.
    """
    cfg_path = os.environ.get("PYTHON_GITLAB_CFG")
    if cfg_path and Path(cfg_path).exists():
        gl = gitlab.Gitlab.from_config("gitlab.com", [cfg_path])
    else:
        user_cfg = Path.home() / ".python-gitlab.cfg"
        if user_cfg.exists():
            gl = gitlab.Gitlab.from_config("gitlab.com", [str(user_cfg)])
        else:
            url = os.environ.get("CI_SERVER_URL", os.environ.get("GITLAB_HOST", "https://gitlab.com"))
            job_token = os.environ.get("CI_JOB_TOKEN")
            private_token = os.environ.get("GITLAB_TOKEN")
            if job_token:
                gl = gitlab.Gitlab(url, job_token=job_token)
            elif private_token:
                gl = gitlab.Gitlab(url, private_token=private_token)
            else:
                gl = gitlab.Gitlab(url)
    return gl


def _resolve_branch(branch: str | None, cwd: Path) -> str | None:
    """Resolve branch name; use current git branch when branch is None."""
    if branch:
        return branch
    try:
        repo = Repo(cwd)
    except InvalidGitRepositoryError:
        return None
    if repo.head.is_detached:
        return None
    return repo.active_branch.name


@tool
def ci_pipeline_status(
    branch: Annotated[str | None, Field(description="Branch name. Defaults to current branch when None.")] = None,
    per_page: Annotated[int, Field(description="Number of pipeline entries to return.", ge=1, le=100)] = 3,
) -> CiPipelineStatusResult:
    """Get current pipeline status for this repo from GitLab.

    Uses the GitLab API via python-gitlab. Project identification: CI_PROJECT_ID,
    CI_PROJECT_PATH, GITLAB_PROJECT_PATH, or GitLab remote from git. Auth:
    GITLAB_TOKEN, CI_JOB_TOKEN, or ~/.python-gitlab.cfg; falls back to
    unauthenticated when no token is available.

    Args:
        branch: Branch name.  Defaults to the current branch when ``None``.
        per_page: Number of pipeline entries to return.  Defaults to 3.

    Returns:
        CiPipelineStatusResult with raw_output, exit_code, and optionally error.
    """
    repo_root = Path.cwd()
    project_id_raw = os.environ.get("CI_PROJECT_ID")
    project_identifier: int | str | None = None
    if project_id_raw:
        try:
            project_identifier = int(project_id_raw)
        except ValueError:
            project_identifier = None  # Signal error below
    else:
        project_identifier = (
            os.environ.get("CI_PROJECT_PATH")
            or os.environ.get("GITLAB_PROJECT_PATH")
            or _get_gitlab_project_path(repo_root)
        )

    if not project_identifier:
        err_msg = (
            "CI_PROJECT_ID must be a numeric project ID."
            if project_id_raw
            else "No GitLab project found. Add a GitLab remote, or set "
            "GITLAB_PROJECT_PATH, CI_PROJECT_PATH, or CI_PROJECT_ID."
        )
        return CiPipelineStatusResult(raw_output="", exit_code=1, error=err_msg)

    try:
        gl = _create_gitlab_client()
    except Exception as exc:  # noqa: BLE001
        return CiPipelineStatusResult(raw_output="", exit_code=1, error=f"GitLab client initialization failed: {exc!s}")

    ref_filter = _resolve_branch(branch, repo_root)

    try:
        project = gl.projects.get(project_identifier)
    except gitlab.exceptions.GitlabGetError as exc:
        return CiPipelineStatusResult(
            raw_output="", exit_code=1, error=f"Project {project_identifier!r} not found: {exc!s}"
        )
    except gitlab.exceptions.GitlabAuthenticationError as exc:
        return CiPipelineStatusResult(raw_output="", exit_code=1, error=f"GitLab authentication failed: {exc!s}")

    try:
        if ref_filter:
            pipelines = project.pipelines.list(per_page=per_page, ref=ref_filter)
        else:
            pipelines = project.pipelines.list(per_page=per_page)
    except gitlab.exceptions.GitlabListError as exc:
        return CiPipelineStatusResult(raw_output="", exit_code=1, error=f"Failed to list pipelines: {exc!s}")

    lines = ["ID\tStatus\tRef\tCreated"]
    for p in pipelines:
        ref = getattr(p, "ref", "—") or "—"
        status = getattr(p, "status", "unknown") or "unknown"
        created = getattr(p, "created_at", "—") or "—"
        # Strip timezone suffix for brevity
        if isinstance(created, str) and "T" in created:
            created = created.split("T")[0] + " " + created.split("T")[1][:8]
        lines.append(f"{p.id}\t{status}\t{ref}\t{created}")

    raw_output = "\n".join(lines) if lines else "No pipelines found."
    return CiPipelineStatusResult(raw_output=raw_output, exit_code=0, error=None)


async def _check_and_fix_kvm(cfg: TargetConfig, run: Callable[..., Awaitable[str]]) -> tuple[bool, bool, str | None]:
    """Detect KVM modules and auto-unload if idle.

    Returns:
        (kvm_modules_unloaded, kvm_auto_unloaded, kvm_conflict).
    """
    refcount_idx = 2
    proc_modules_raw = await run("cat /proc/modules 2>/dev/null || echo ''")
    vendor_module: str | None = None
    refcount: int = 0
    any_loaded = False
    for line in proc_modules_raw.splitlines():
        parts = line.split()
        if not parts:
            continue
        mod_name = parts[0]
        if mod_name.startswith("kvm"):
            any_loaded = True
        if mod_name in {"kvm_intel", "kvm_amd"}:
            vendor_module = mod_name
            try:
                refcount = int(parts[refcount_idx]) if len(parts) > refcount_idx else 0
            except ValueError:
                refcount = 0

    if not any_loaded:
        return True, False, None

    if vendor_module is None:
        return False, False, None

    if refcount == 0:
        modules_loaded = await _lsmod_kvm(cfg)
        vendor_module_name = _get_vendor_module(vendor_module, modules_loaded)
        success, unload_error = await _unload_modules(cfg, modules_loaded, vendor_module_name)
        if success:
            return True, True, None
        return (
            False,
            False,
            (
                f"{vendor_module} is loaded (refcount=0, no active users). "
                "VirtualBox cannot start VMs while KVM holds VMX root mode "
                "(VERR_VMX_IN_VMX_ROOT_MODE). "
                f"Auto-unload failed: {unload_error}. "
                f"Run: sudo rmmod {vendor_module} && sudo rmmod kvm"
            ),
        )

    return (
        False,
        False,
        (
            f"{vendor_module} is loaded and in use (refcount={refcount}). "
            "VirtualBox cannot start VMs while KVM holds VMX root mode "
            "(VERR_VMX_IN_VMX_ROOT_MODE). "
            "KVM in use by another process — cannot auto-unload. "
            "Stop all KVM virtual machines first, then run the kvm_unload tool."
        ),
    )


@tool
async def ci_preflight(
    target: Annotated[str, Field(description='Named target or raw SSH string. Defaults to "ci".')] = "ci",
) -> CiPreflightResult:
    """Check that the CI host has required tools at minimum versions.

    Also reports the KVM kernel module load state.

    Args:
        target: Named target or raw SSH string.

    Returns:
        CiPreflightResult with versions, kvm_modules_unloaded, issues.
    """
    cfg = _resolve(target)

    async def _run(cmd: str) -> str:
        result = await _ssh_run(cfg, ["bash", "-c", cmd])
        return result.stdout.strip()

    versions = {
        "VBoxManage": await _run("VBoxManage --version 2>/dev/null || echo 'not found'"),
        "vagrant": await _run("vagrant --version 2>/dev/null || echo 'not found'"),
        "packer": await _run("packer --version 2>/dev/null || echo 'not found'"),
    }

    kvm_modules_unloaded, kvm_auto_unloaded, kvm_conflict = await _check_and_fix_kvm(cfg, _run)

    issues = [
        f"{tool}: not installed" for tool in VERSION_REQUIREMENTS if "not found" in versions.get(tool, "not found")
    ]
    if kvm_conflict:
        issues.append(f"KVM conflict: {kvm_conflict}")

    return CiPreflightResult(
        versions=versions,
        kvm_modules_unloaded=kvm_modules_unloaded,
        kvm_auto_unloaded=kvm_auto_unloaded,
        kvm_conflict=kvm_conflict,
        issues=issues,
    )
