"""Tests for CI tools (ci_check, ci_run, ci_preflight, ci_pipeline_status).

Tests: SSH connectivity check, command execution, preflight versions, pipeline status.
How: Mock _ssh_run, _resolve, subprocess.run, and python-gitlab so tests run offline.
Why: CI environments lack SSH hosts and GitLab API; tools must return correct models.
"""

from __future__ import annotations

import os
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock

import pytest
from git import Repo

from mcp_vm_blackbox.targets import TargetConfig

if TYPE_CHECKING:
    from pytest_mock import MockerFixture

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------


@pytest.fixture
def mock_resolve_local(mocker: MockerFixture) -> MagicMock:
    """Patch _resolve to return a local TargetConfig (ssh_host='')."""
    cfg = TargetConfig(ssh_host="")
    return mocker.patch("mcp_vm_blackbox.ci_tools._resolve", return_value=cfg)


@pytest.fixture
def mock_resolve_remote(mocker: MockerFixture) -> MagicMock:
    """Patch _resolve to return a remote TargetConfig."""
    cfg = TargetConfig(ssh_host="ci@runner.example.com")
    return mocker.patch("mcp_vm_blackbox.ci_tools._resolve", return_value=cfg)


@pytest.fixture
def mock_ssh_run(mocker: MockerFixture) -> AsyncMock:
    """Patch _ssh_run for controlled output (ci_check, ci_run, ci_preflight)."""
    result = MagicMock()
    result.stdout = ""
    result.stderr = ""
    result.returncode = 0
    return mocker.patch("mcp_vm_blackbox.ci_tools._ssh_run", new_callable=AsyncMock, return_value=result)


def _make_ssh_result(stdout: str = "", stderr: str = "", returncode: int = 0) -> MagicMock:
    """Build a mock _SshRunResult for tests."""
    r = MagicMock()
    r.stdout = stdout
    r.stderr = stderr
    r.returncode = returncode
    return r


# ---------------------------------------------------------------------------
# ci_check
# ---------------------------------------------------------------------------


class TestCiCheck:
    """Tests for ci_check tool."""

    async def test_ci_check_local_target_returns_note(
        self, mock_resolve_local: MagicMock, mocker: MockerFixture
    ) -> None:
        """ci_check returns connected=True with note when target is local.

        Tests: Local target early-exit path.
        How: Mock _resolve to return is_remote=False; verify no _ssh_run call.
        Why: Local targets skip SSH; user gets VBoxManage path instead.
        """
        mocker.patch("mcp_vm_blackbox.ci_tools.shutil.which", return_value="/usr/bin/VBoxManage")

        from mcp_vm_blackbox.ci_tools import ci_check

        result = await ci_check(target="local")

        assert result.connected is True
        assert "local target" in (result.note or "")
        assert result.vboxmanage == "/usr/bin/VBoxManage"
        assert result.error is None

    async def test_ci_check_local_target_vboxmanage_not_found(
        self, mock_resolve_local: MagicMock, mocker: MockerFixture
    ) -> None:
        """ci_check returns 'not found' when VBoxManage is not on PATH.

        Tests: Local target with missing VBoxManage.
        How: Mock shutil.which to return None.
        Why: User should know whether VBoxManage is available.
        """
        mocker.patch("mcp_vm_blackbox.ci_tools.shutil.which", return_value=None)

        from mcp_vm_blackbox.ci_tools import ci_check

        result = await ci_check(target="local")

        assert result.connected is True
        assert result.vboxmanage == "not found"

    async def test_ci_check_remote_success(self, mock_resolve_remote: MagicMock, mock_ssh_run: AsyncMock) -> None:
        """ci_check returns disk, memory, uptime when SSH succeeds.

        Tests: Remote target happy path.
        How: Mock _ssh_run to return three lines; verify parsing.
        Why: User gets host stats for debugging.
        """
        mock_ssh_run.return_value = _make_ssh_result(
            stdout="tmpfs 1.0G 100M 900M 10% /\nMem: 8000 2000 6000\n 12:00 up 1 day", returncode=0
        )

        from mcp_vm_blackbox.ci_tools import ci_check

        result = await ci_check(target="ci")

        assert result.connected is True
        assert result.disk == "tmpfs 1.0G 100M 900M 10% /"
        assert result.memory == "Mem: 8000 2000 6000"
        assert result.uptime == " 12:00 up 1 day"
        assert result.error is None

    async def test_ci_check_remote_nonzero_returncode(
        self, mock_resolve_remote: MagicMock, mock_ssh_run: AsyncMock
    ) -> None:
        """ci_check returns connected=False when SSH command fails.

        Tests: Non-zero exit code path.
        How: Mock _ssh_run returncode=1.
        Why: User must know connection failed.
        """
        mock_ssh_run.return_value = _make_ssh_result(stdout="", stderr="error", returncode=1)

        from mcp_vm_blackbox.ci_tools import ci_check

        result = await ci_check(target="ci")

        assert result.connected is False
        assert result.error is not None
        assert "SSH" in result.error

    async def test_ci_check_remote_timeout(self, mock_resolve_remote: MagicMock, mock_ssh_run: AsyncMock) -> None:
        """ci_check returns connected=False on TimeoutError.

        Tests: asyncio.wait_for timeout path.
        How: Make _ssh_run raise TimeoutError.
        Why: Slow SSH keys must surface as error.
        """
        mock_ssh_run.side_effect = TimeoutError("timed out")

        from mcp_vm_blackbox.ci_tools import ci_check

        result = await ci_check(target="ci", ssh_connect_timeout=5)

        assert result.connected is False
        assert result.error is not None
        assert "TimeoutError" in result.error or "timeout" in result.error.lower()

    async def test_ci_check_remote_generic_exception(
        self, mock_resolve_remote: MagicMock, mock_ssh_run: AsyncMock
    ) -> None:
        """ci_check returns connected=False on generic exception.

        Tests: Exception handling path (e.g. asyncssh failure).
        How: Make _ssh_run raise RuntimeError.
        Why: All failures must be surfaced.
        """
        mock_ssh_run.side_effect = RuntimeError("connection refused")

        from mcp_vm_blackbox.ci_tools import ci_check

        result = await ci_check(target="ci")

        assert result.connected is False
        assert result.error is not None
        assert "RuntimeError" in result.error or "connection refused" in result.error

    async def test_ci_check_remote_partial_lines(self, mock_resolve_remote: MagicMock, mock_ssh_run: AsyncMock) -> None:
        """ci_check handles fewer than 3 stat lines gracefully.

        Tests: Edge case with incomplete stdout.
        How: Return only one line; verify no crash and 'unknown' for missing.
        Why: Robust parsing under odd output.
        """
        mock_ssh_run.return_value = _make_ssh_result(stdout="tmpfs 1.0G 100M 900M 10% /", returncode=0)

        from mcp_vm_blackbox.ci_tools import ci_check

        result = await ci_check(target="ci")

        assert result.connected is True
        assert result.disk == "tmpfs 1.0G 100M 900M 10% /"
        assert result.memory == "unknown"
        assert result.uptime == "unknown"


# ---------------------------------------------------------------------------
# ci_run
# ---------------------------------------------------------------------------


class TestCiRun:
    """Tests for ci_run tool."""

    async def test_ci_run_success(self, mock_resolve_remote: MagicMock, mock_ssh_run: AsyncMock) -> None:
        """ci_run returns CommandResult with stdout, stderr, exit_code.

        Tests: Happy path for remote command execution.
        How: Mock _ssh_run to return successful result.
        Why: User needs command output.
        """
        mock_ssh_run.return_value = _make_ssh_result(stdout="  hello world  \n", stderr="  \n", returncode=0)

        from mcp_vm_blackbox.ci_tools import ci_run

        result = await ci_run(command="echo hello world", target="ci")

        assert result.stdout == "hello world"
        assert result.stderr == ""
        assert result.exit_code == 0
        assert result.error is None

    async def test_ci_run_timeout_returns_error(self, mock_resolve_remote: MagicMock, mock_ssh_run: AsyncMock) -> None:
        """ci_run returns CommandResult with error and exit_code=-1 on timeout.

        Tests: TimeoutError handling.
        How: Make _ssh_run raise TimeoutError via asyncio.wait_for.
        Why: Timeouts must be clearly communicated.
        """
        mock_ssh_run.side_effect = TimeoutError()

        from mcp_vm_blackbox.ci_tools import ci_run

        result = await ci_run(command="sleep 999", target="ci", timeout_s=1)

        assert result.exit_code == -1
        assert result.error is not None
        assert "timed out" in result.error or "timeout" in result.error.lower()

    async def test_ci_run_generic_exception(self, mock_resolve_remote: MagicMock, mock_ssh_run: AsyncMock) -> None:
        """ci_run returns CommandResult with error on generic exception.

        Tests: Exception path (e.g. connection refused).
        How: Make _ssh_run raise OSError.
        Why: All failures must surface.
        """
        mock_ssh_run.side_effect = OSError("connection refused")

        from mcp_vm_blackbox.ci_tools import ci_run

        result = await ci_run(command="ls", target="ci")

        assert result.exit_code == -1
        assert result.error is not None
        assert "Command failed" in result.error

    async def test_ci_run_passes_command_and_timeout(
        self, mock_resolve_remote: MagicMock, mock_ssh_run: AsyncMock
    ) -> None:
        """ci_run passes command via bash -c and timeout to wait_for.

        Tests: Correct arguments to _ssh_run and asyncio.wait_for.
        How: Verify mock call args.
        Why: Ensure timeout and command are respected.
        """
        mock_ssh_run.return_value = _make_ssh_result(stdout="ok", returncode=0)

        from mcp_vm_blackbox.ci_tools import ci_run

        await ci_run(command="ls -la", target="ci", timeout_s=30)

        mock_ssh_run.assert_called_once()
        call_args = mock_ssh_run.call_args[0]
        assert call_args[1] == ["bash", "-c", "ls -la"]


# ---------------------------------------------------------------------------
# ci_preflight
# ---------------------------------------------------------------------------


class TestCiPreflight:
    """Tests for ci_preflight tool."""

    # Sample /proc/modules lines used across tests.
    # Format: name size refcount users state offset
    _PROC_MODULES_NO_KVM = "virtio 20480 0 - Live 0xffffffffc0a00000"
    _PROC_MODULES_KVM_INTEL_IDLE = (
        "kvm_intel 401408 0 - Live 0xffffffffc0b00000\nkvm 1245184 1 kvm_intel Live 0xffffffffc0900000"
    )
    _PROC_MODULES_KVM_INTEL_BUSY = (
        "kvm_intel 401408 3 - Live 0xffffffffc0b00000\nkvm 1245184 4 kvm_intel Live 0xffffffffc0900000"
    )
    _PROC_MODULES_KVM_AMD_IDLE = (
        "kvm_amd 401408 0 - Live 0xffffffffc0b00000\nkvm 1245184 1 kvm_amd Live 0xffffffffc0900000"
    )

    async def test_ci_preflight_all_tools_found(self, mock_resolve_remote: MagicMock, mock_ssh_run: AsyncMock) -> None:
        """ci_preflight returns versions and no issues when tools installed.

        Tests: Happy path with VBoxManage, vagrant, packer present and no KVM.
        How: Mock _ssh_run to return version strings then empty /proc/modules.
        Why: Preflight must report available tools.
        """
        side_effects = [
            _make_ssh_result(stdout="7.0.14", returncode=0),
            _make_ssh_result(stdout="Vagrant 2.4.0", returncode=0),
            _make_ssh_result(stdout="1.11.0", returncode=0),
            _make_ssh_result(stdout=self._PROC_MODULES_NO_KVM, returncode=0),
        ]
        mock_ssh_run.side_effect = side_effects

        from mcp_vm_blackbox.ci_tools import ci_preflight

        result = await ci_preflight(target="ci")

        assert result.versions["VBoxManage"] == "7.0.14"
        assert result.versions["vagrant"] == "Vagrant 2.4.0"
        assert result.versions["packer"] == "1.11.0"
        assert result.kvm_modules_unloaded is True
        assert result.kvm_conflict is None
        assert result.issues == []

    async def test_ci_preflight_some_tools_not_found(
        self, mock_resolve_remote: MagicMock, mock_ssh_run: AsyncMock
    ) -> None:
        """ci_preflight populates issues when tools are missing.

        Tests: Partial installation scenario with kvm_intel loaded and busy.
        How: Return 'not found' for packer; return kvm_intel busy /proc/modules.
        Why: User must know what is missing and what KVM conflict exists.
        """
        side_effects = [
            _make_ssh_result(stdout="7.0.14", returncode=0),
            _make_ssh_result(stdout="Vagrant 2.4.0", returncode=0),
            _make_ssh_result(stdout="not found", returncode=0),
            _make_ssh_result(stdout=self._PROC_MODULES_KVM_INTEL_BUSY, returncode=0),
        ]
        mock_ssh_run.side_effect = side_effects

        from mcp_vm_blackbox.ci_tools import ci_preflight

        result = await ci_preflight(target="ci")

        assert any("packer" in i for i in result.issues)
        assert result.kvm_modules_unloaded is False

    async def test_ci_preflight_kvm_unloaded(self, mock_resolve_remote: MagicMock, mock_ssh_run: AsyncMock) -> None:
        """ci_preflight sets kvm_modules_unloaded when /proc/modules has no kvm lines.

        Tests: KVM module detection — no kvm* entries present.
        How: Mock /proc/modules read to return non-KVM modules only.
        Why: KVM state affects VirtualBox.
        """
        side_effects = [
            _make_ssh_result(stdout="7.0", returncode=0),
            _make_ssh_result(stdout="Vagrant 2.4", returncode=0),
            _make_ssh_result(stdout="1.11", returncode=0),
            _make_ssh_result(stdout=self._PROC_MODULES_NO_KVM, returncode=0),
        ]
        mock_ssh_run.side_effect = side_effects

        from mcp_vm_blackbox.ci_tools import ci_preflight

        result = await ci_preflight(target="ci")

        assert result.kvm_modules_unloaded is True
        assert result.kvm_conflict is None

    async def test_ci_preflight_kvm_intel_idle_auto_unloads_successfully(
        self, mock_resolve_remote: MagicMock, mock_ssh_run: AsyncMock, mocker: MockerFixture
    ) -> None:
        """ci_preflight auto-unloads kvm_intel when refcount=0 and unload succeeds.

        Tests: kvm_intel loaded, refcount=0, modprobe -r succeeds.
        How: ci_tools._ssh_run handles versions+proc/modules; kvm_tools._ssh_run
             handles _lsmod_kvm and modprobe -r calls from the auto-unload path.
        Why: Auto-fix path — user should not need to run kvm_unload manually.
        """
        # ci_tools._ssh_run: version queries (1-3) + /proc/modules read (4)
        mock_ssh_run.side_effect = [
            _make_ssh_result(stdout="7.0", returncode=0),
            _make_ssh_result(stdout="Vagrant 2.4", returncode=0),
            _make_ssh_result(stdout="1.11", returncode=0),
            _make_ssh_result(stdout=self._PROC_MODULES_KVM_INTEL_IDLE, returncode=0),
        ]
        # kvm_tools._ssh_run: _lsmod_kvm, modprobe -r kvm_intel, modprobe -r kvm
        kvm_ssh = mocker.patch(
            "mcp_vm_blackbox.kvm_tools._ssh_run",
            new_callable=AsyncMock,
            side_effect=[
                _make_ssh_result(stdout="kvm_intel\nkvm", returncode=0),
                _make_ssh_result(stdout="", returncode=0),
                _make_ssh_result(stdout="", returncode=0),
            ],
        )

        from mcp_vm_blackbox.ci_tools import ci_preflight

        result = await ci_preflight(target="ci")

        assert result.kvm_auto_unloaded is True
        assert result.kvm_modules_unloaded is True
        assert result.kvm_conflict is None
        assert not any("KVM conflict" in i for i in result.issues)
        assert kvm_ssh.call_count == 3

    async def test_ci_preflight_kvm_intel_idle_auto_unload_failure_reports_error(
        self, mock_resolve_remote: MagicMock, mock_ssh_run: AsyncMock, mocker: MockerFixture
    ) -> None:
        """ci_preflight sets kvm_conflict when refcount=0 but modprobe -r fails.

        Tests: kvm_intel loaded, refcount=0, modprobe -r returns permission denied.
        How: kvm_tools._ssh_run modprobe -r call returns returncode=1; verify lsmod
             still shows module loaded so _unload_modules returns failure.
        Why: Caller must know auto-unload failed and how to fix it manually.
        """
        mock_ssh_run.side_effect = [
            _make_ssh_result(stdout="7.0", returncode=0),
            _make_ssh_result(stdout="Vagrant 2.4", returncode=0),
            _make_ssh_result(stdout="1.11", returncode=0),
            _make_ssh_result(stdout=self._PROC_MODULES_KVM_INTEL_IDLE, returncode=0),
        ]
        # kvm_tools._ssh_run: _lsmod_kvm, modprobe -r kvm_intel (fails), lsmod verify
        mocker.patch(
            "mcp_vm_blackbox.kvm_tools._ssh_run",
            new_callable=AsyncMock,
            side_effect=[
                _make_ssh_result(stdout="kvm_intel\nkvm", returncode=0),
                _make_ssh_result(stdout="", stderr="rmmod: ERROR: Module kvm_intel is in use", returncode=1),
                _make_ssh_result(stdout="kvm_intel\nkvm", returncode=0),
            ],
        )

        from mcp_vm_blackbox.ci_tools import ci_preflight

        result = await ci_preflight(target="ci")

        assert result.kvm_auto_unloaded is False
        assert result.kvm_modules_unloaded is False
        assert result.kvm_conflict is not None
        assert "kvm_intel" in result.kvm_conflict
        assert "Auto-unload failed" in result.kvm_conflict
        assert "VERR_VMX_IN_VMX_ROOT_MODE" in result.kvm_conflict
        assert "sudo rmmod" in result.kvm_conflict
        assert any("KVM conflict" in i for i in result.issues)

    async def test_ci_preflight_kvm_intel_busy_reports_in_use_no_unload_attempt(
        self, mock_resolve_remote: MagicMock, mock_ssh_run: AsyncMock, mocker: MockerFixture
    ) -> None:
        """ci_preflight reports kvm_intel in-use without attempting unload when refcount>0.

        Tests: kvm_intel loaded, refcount=3, active users present.
        How: Return /proc/modules with kvm_intel at refcount 3; no kvm_tools calls made.
        Why: Cannot auto-unload — user must stop KVM VMs first.
        """
        mock_ssh_run.side_effect = [
            _make_ssh_result(stdout="7.0", returncode=0),
            _make_ssh_result(stdout="Vagrant 2.4", returncode=0),
            _make_ssh_result(stdout="1.11", returncode=0),
            _make_ssh_result(stdout=self._PROC_MODULES_KVM_INTEL_BUSY, returncode=0),
        ]
        kvm_ssh = mocker.patch("mcp_vm_blackbox.kvm_tools._ssh_run", new_callable=AsyncMock)

        from mcp_vm_blackbox.ci_tools import ci_preflight

        result = await ci_preflight(target="ci")

        assert result.kvm_auto_unloaded is False
        assert result.kvm_modules_unloaded is False
        assert result.kvm_conflict is not None
        assert "kvm_intel" in result.kvm_conflict
        assert "refcount=3" in result.kvm_conflict
        assert "cannot auto-unload" in result.kvm_conflict.lower()
        assert "VERR_VMX_IN_VMX_ROOT_MODE" in result.kvm_conflict
        # No kvm_tools._ssh_run calls — refcount>0 path skips unload entirely
        assert kvm_ssh.call_count == 0

    async def test_ci_preflight_kvm_amd_idle_auto_unloads_successfully(
        self, mock_resolve_remote: MagicMock, mock_ssh_run: AsyncMock, mocker: MockerFixture
    ) -> None:
        """ci_preflight auto-unloads kvm_amd when refcount=0 and unload succeeds.

        Tests: AMD CPU host with kvm_amd loaded at refcount=0.
        How: ci_tools._ssh_run handles versions+proc/modules; kvm_tools._ssh_run
             handles _lsmod_kvm and modprobe -r calls.
        Why: AMD hosts have the same VMX root mode conflict — auto-fix applies equally.
        """
        mock_ssh_run.side_effect = [
            _make_ssh_result(stdout="7.0", returncode=0),
            _make_ssh_result(stdout="Vagrant 2.4", returncode=0),
            _make_ssh_result(stdout="1.11", returncode=0),
            _make_ssh_result(stdout=self._PROC_MODULES_KVM_AMD_IDLE, returncode=0),
        ]
        mocker.patch(
            "mcp_vm_blackbox.kvm_tools._ssh_run",
            new_callable=AsyncMock,
            side_effect=[
                _make_ssh_result(stdout="kvm_amd\nkvm", returncode=0),
                _make_ssh_result(stdout="", returncode=0),  # modprobe -r kvm_amd
                _make_ssh_result(stdout="", returncode=0),  # modprobe -r kvm
            ],
        )

        from mcp_vm_blackbox.ci_tools import ci_preflight

        result = await ci_preflight(target="ci")

        assert result.kvm_auto_unloaded is True
        assert result.kvm_modules_unloaded is True
        assert result.kvm_conflict is None

    async def test_ci_preflight_empty_proc_modules_treats_as_unloaded(
        self, mock_resolve_remote: MagicMock, mock_ssh_run: AsyncMock
    ) -> None:
        """ci_preflight treats empty /proc/modules output as no KVM loaded.

        Tests: /proc/modules unavailable or empty (e.g. non-Linux host).
        How: Return empty string for /proc/modules read.
        Why: No module data means no detectable conflict.
        """
        side_effects = [
            _make_ssh_result(stdout="7.0", returncode=0),
            _make_ssh_result(stdout="Vagrant 2.4", returncode=0),
            _make_ssh_result(stdout="1.11", returncode=0),
            _make_ssh_result(stdout="", returncode=0),
        ]
        mock_ssh_run.side_effect = side_effects

        from mcp_vm_blackbox.ci_tools import ci_preflight

        result = await ci_preflight(target="ci")

        assert result.kvm_modules_unloaded is True
        assert result.kvm_conflict is None


# ---------------------------------------------------------------------------
# _get_gitlab_project_path (helper)
# ---------------------------------------------------------------------------


class TestGetGitlabProjectPath:
    """Tests for _get_gitlab_project_path helper."""

    def test_get_gitlab_project_path_https(self, tmp_path: Path) -> None:
        """_get_gitlab_project_path extracts path from https remote.

        Tests: GitPython repo.remotes iteration for https://gitlab.com/group/project.
        How: Init repo via GitPython and add GitLab origin remote.
        Why: Project path needed for pipeline status.
        """
        repo = Repo.init(tmp_path)
        repo.create_remote("origin", "https://gitlab.com/mygroup/myproject.git")

        from mcp_vm_blackbox.ci_tools import _get_gitlab_project_path

        result = _get_gitlab_project_path(tmp_path)

        assert result == "mygroup/myproject"

    def test_get_gitlab_project_path_ssh(self, tmp_path: Path) -> None:
        """_get_gitlab_project_path extracts path from git@ remote.

        Tests: GitPython repo.remotes for git@gitlab.com:group/project.
        How: Init repo via GitPython and add SSH-style GitLab remote.
        Why: Many users use SSH remotes.
        """
        repo = Repo.init(tmp_path)
        repo.create_remote("origin", "git@gitlab.com:acme/widgets.git")

        from mcp_vm_blackbox.ci_tools import _get_gitlab_project_path

        result = _get_gitlab_project_path(tmp_path)

        assert result == "acme/widgets"

    def test_get_gitlab_project_path_missing_config(self, tmp_path: Path) -> None:
        """_get_gitlab_project_path returns None when not a git repository."""
        from mcp_vm_blackbox.ci_tools import _get_gitlab_project_path

        result = _get_gitlab_project_path(tmp_path)

        assert result is None

    def test_get_gitlab_project_path_no_gitlab_remote(self, tmp_path: Path) -> None:
        """_get_gitlab_project_path returns None when no GitLab remote."""
        repo = Repo.init(tmp_path)
        repo.create_remote("origin", "https://github.com/user/repo.git")

        from mcp_vm_blackbox.ci_tools import _get_gitlab_project_path

        result = _get_gitlab_project_path(tmp_path)

        assert result is None


# ---------------------------------------------------------------------------
# ci_pipeline_status
# ---------------------------------------------------------------------------


class TestCiPipelineStatus:
    """Tests for ci_pipeline_status tool."""

    def test_ci_pipeline_status_no_project_identifier(self, mocker: MockerFixture) -> None:
        """ci_pipeline_status returns error when no project found.

        Tests: Missing CI_PROJECT_ID, GITLAB_PROJECT_PATH, and no git remote.
        How: Clear env, mock _get_gitlab_project_path to return None.
        Why: User must know why pipeline status failed.
        """
        mocker.patch.dict("os.environ", {}, clear=False)
        mocker.patch("mcp_vm_blackbox.ci_tools._get_gitlab_project_path", return_value=None)

        from mcp_vm_blackbox.ci_tools import ci_pipeline_status

        result = ci_pipeline_status()

        assert result.exit_code == 1
        assert result.error is not None
        assert "No GitLab project" in result.error or "GITLAB_PROJECT_PATH" in result.error

    def test_ci_pipeline_status_ci_project_id_invalid(self, mocker: MockerFixture) -> None:
        """ci_pipeline_status returns error when CI_PROJECT_ID is not numeric."""
        mocker.patch.dict("os.environ", {"CI_PROJECT_ID": "not-a-number"}, clear=False)

        from mcp_vm_blackbox.ci_tools import ci_pipeline_status

        result = ci_pipeline_status()

        assert result.exit_code == 1
        assert result.error is not None
        assert "CI_PROJECT_ID" in result.error or "numeric" in result.error

    def test_ci_pipeline_status_client_init_fails(self, mocker: MockerFixture) -> None:
        """ci_pipeline_status returns error when GitLab client init raises."""
        mocker.patch.dict("os.environ", {"CI_PROJECT_ID": "12345"}, clear=False)
        mocker.patch("mcp_vm_blackbox.ci_tools._create_gitlab_client", side_effect=RuntimeError("no token"))

        from mcp_vm_blackbox.ci_tools import ci_pipeline_status

        result = ci_pipeline_status()

        assert result.exit_code == 1
        assert result.error is not None
        assert "initialization" in result.error or "no token" in result.error

    def test_ci_pipeline_status_project_not_found(self, mocker: MockerFixture) -> None:
        """ci_pipeline_status returns error when project.get raises GitlabGetError."""
        import gitlab

        mocker.patch.dict("os.environ", {"CI_PROJECT_ID": "12345"}, clear=False)
        gl = MagicMock()
        gl.projects.get.side_effect = gitlab.exceptions.GitlabGetError(response_code=404, error_message="Not found")
        mocker.patch("mcp_vm_blackbox.ci_tools._create_gitlab_client", return_value=gl)

        from mcp_vm_blackbox.ci_tools import ci_pipeline_status

        result = ci_pipeline_status()

        assert result.exit_code == 1
        assert result.error is not None
        assert "not found" in result.error.lower() or "404" in result.error

    def test_ci_pipeline_status_auth_failed(self, mocker: MockerFixture) -> None:
        """ci_pipeline_status returns error when GitLab auth fails."""
        import gitlab

        mocker.patch.dict("os.environ", {"CI_PROJECT_ID": "12345"}, clear=False)
        gl = MagicMock()
        gl.projects.get.side_effect = gitlab.exceptions.GitlabAuthenticationError(
            response_code=401, error_message="Unauthorized"
        )
        mocker.patch("mcp_vm_blackbox.ci_tools._create_gitlab_client", return_value=gl)

        from mcp_vm_blackbox.ci_tools import ci_pipeline_status

        result = ci_pipeline_status()

        assert result.exit_code == 1
        assert result.error is not None
        assert "authentication" in result.error.lower() or "401" in result.error

    def test_ci_pipeline_status_list_fails(self, mocker: MockerFixture) -> None:
        """ci_pipeline_status returns error when pipelines.list raises GitlabListError."""
        import gitlab

        mocker.patch.dict("os.environ", {"CI_PROJECT_ID": "12345"}, clear=False)
        gl = MagicMock()
        project = MagicMock()
        project.pipelines.list.side_effect = gitlab.exceptions.GitlabListError(
            response_code=500, error_message="Internal error"
        )
        gl.projects.get.return_value = project
        mocker.patch("mcp_vm_blackbox.ci_tools._create_gitlab_client", return_value=gl)

        from mcp_vm_blackbox.ci_tools import ci_pipeline_status

        result = ci_pipeline_status()

        assert result.exit_code == 1
        assert result.error is not None
        assert "list" in result.error.lower() or "500" in result.error

    def test_ci_pipeline_status_success(self, mocker: MockerFixture) -> None:
        """ci_pipeline_status returns formatted pipeline list on success.

        Tests: Happy path with pipelines returned.
        How: Mock GitLab client, project, and pipelines.list.
        Why: User needs pipeline status for branch.
        """
        mocker.patch.dict("os.environ", {"CI_PROJECT_ID": "12345"}, clear=False)
        p1 = MagicMock()
        p1.id = 100
        p1.ref = "main"
        p1.status = "success"
        p1.created_at = "2025-03-05T10:00:00Z"
        p2 = MagicMock()
        p2.id = 99
        p2.ref = "main"
        p2.status = "running"
        p2.created_at = "2025-03-05T09:00:00Z"
        gl = MagicMock()
        project = MagicMock()
        project.pipelines.list.return_value = [p1, p2]
        gl.projects.get.return_value = project
        mocker.patch("mcp_vm_blackbox.ci_tools._create_gitlab_client", return_value=gl)

        from mcp_vm_blackbox.ci_tools import ci_pipeline_status

        result = ci_pipeline_status(branch="main", per_page=3)

        assert result.exit_code == 0
        assert result.error is None
        assert "100" in result.raw_output
        assert "success" in result.raw_output
        assert "99" in result.raw_output
        assert "running" in result.raw_output
        project.pipelines.list.assert_called_once_with(per_page=3, ref="main")

    def test_ci_pipeline_status_uses_gitlab_project_path(self, mocker: MockerFixture) -> None:
        """ci_pipeline_status uses GITLAB_PROJECT_PATH when CI_PROJECT_ID unset."""
        mocker.patch.dict("os.environ", {"GITLAB_PROJECT_PATH": "mygroup/myproject"}, clear=False)
        mocker.patch("mcp_vm_blackbox.ci_tools._get_gitlab_project_path", return_value=None)
        gl = MagicMock()
        project = MagicMock()
        project.pipelines.list.return_value = []
        gl.projects.get.return_value = project
        mocker.patch("mcp_vm_blackbox.ci_tools._create_gitlab_client", return_value=gl)

        from mcp_vm_blackbox.ci_tools import ci_pipeline_status

        result = ci_pipeline_status()

        assert result.exit_code == 0
        gl.projects.get.assert_called_once_with("mygroup/myproject")

    def test_ci_pipeline_status_resolves_branch_from_git(self, mocker: MockerFixture) -> None:
        """ci_pipeline_status uses current git branch when branch is None."""
        mocker.patch.dict("os.environ", {"CI_PROJECT_ID": "12345"}, clear=False)
        mocker.patch("mcp_vm_blackbox.ci_tools._resolve_branch", return_value="feature/xyz")
        gl = MagicMock()
        project = MagicMock()
        project.pipelines.list.return_value = []
        gl.projects.get.return_value = project
        mocker.patch("mcp_vm_blackbox.ci_tools._create_gitlab_client", return_value=gl)

        from mcp_vm_blackbox.ci_tools import ci_pipeline_status

        result = ci_pipeline_status(branch=None)

        assert result.exit_code == 0
        project = gl.projects.get.return_value
        project.pipelines.list.assert_called_once()
        call_kw = project.pipelines.list.call_args[1]
        assert call_kw.get("ref") == "feature/xyz"

    def test_ci_pipeline_status_empty_pipelines(self, mocker: MockerFixture) -> None:
        """ci_pipeline_status handles empty pipeline list."""
        mocker.patch.dict("os.environ", {"CI_PROJECT_ID": "12345"}, clear=False)
        gl = MagicMock()
        project = MagicMock()
        project.pipelines.list.return_value = []
        gl.projects.get.return_value = project
        mocker.patch("mcp_vm_blackbox.ci_tools._create_gitlab_client", return_value=gl)

        from mcp_vm_blackbox.ci_tools import ci_pipeline_status

        result = ci_pipeline_status()

        assert result.exit_code == 0
        assert "No pipelines found" in result.raw_output or "ID" in result.raw_output


# ---------------------------------------------------------------------------
# _create_gitlab_client (helper)
# ---------------------------------------------------------------------------


class TestCreateGitlabClient:
    """Tests for _create_gitlab_client helper."""

    def test_create_gitlab_client_uses_ci_job_token(self, mocker: MockerFixture) -> None:
        """_create_gitlab_client uses CI_JOB_TOKEN when set (GitLab CI)."""
        mocker.patch.dict(
            "os.environ",
            {"CI_SERVER_URL": "https://gitlab.example.com", "CI_JOB_TOKEN": "secret-job-token"},
            clear=False,
        )
        mocker.patch("pathlib.Path.exists", return_value=False)
        mock_gitlab = mocker.patch("mcp_vm_blackbox.ci_tools.gitlab.Gitlab")

        from mcp_vm_blackbox.ci_tools import _create_gitlab_client

        _create_gitlab_client()

        mock_gitlab.assert_called_once_with("https://gitlab.example.com", job_token="secret-job-token")

    def test_create_gitlab_client_uses_private_token(self, mocker: MockerFixture) -> None:
        """_create_gitlab_client uses GITLAB_TOKEN when set."""
        mocker.patch.dict("os.environ", {"GITLAB_HOST": "https://gitlab.org", "GITLAB_TOKEN": "glpat-xxx"}, clear=False)
        mocker.patch("pathlib.Path.exists", return_value=False)
        mock_gitlab = mocker.patch("mcp_vm_blackbox.ci_tools.gitlab.Gitlab")

        from mcp_vm_blackbox.ci_tools import _create_gitlab_client

        _create_gitlab_client()

        mock_gitlab.assert_called_once_with("https://gitlab.org", private_token="glpat-xxx")

    def test_create_gitlab_client_fallback_unauthenticated(self, mocker: MockerFixture) -> None:
        """_create_gitlab_client uses default URL when no token."""
        env_clean = {
            k: v
            for k, v in os.environ.items()
            if k not in ("PYTHON_GITLAB_CFG", "GITLAB_HOST", "GITLAB_TOKEN", "CI_SERVER_URL", "CI_JOB_TOKEN")
        }
        mocker.patch.dict("os.environ", env_clean, clear=True)
        mocker.patch("pathlib.Path.exists", return_value=False)
        mock_gitlab = mocker.patch("mcp_vm_blackbox.ci_tools.gitlab.Gitlab")

        from mcp_vm_blackbox.ci_tools import _create_gitlab_client

        _create_gitlab_client()

        mock_gitlab.assert_called_once_with("https://gitlab.com")

    def test_create_gitlab_client_uses_python_gitlab_cfg(self, mocker: MockerFixture, tmp_path: Path) -> None:
        """_create_gitlab_client uses PYTHON_GITLAB_CFG when path exists."""
        cfg_file = tmp_path / "python-gitlab.cfg"
        cfg_file.write_text("[gitlab.com]\nurl = https://gitlab.com\n")
        mocker.patch.dict("os.environ", {"PYTHON_GITLAB_CFG": str(cfg_file)}, clear=False)
        mock_from_config = mocker.patch("mcp_vm_blackbox.ci_tools.gitlab.Gitlab.from_config")

        from mcp_vm_blackbox.ci_tools import _create_gitlab_client

        _create_gitlab_client()

        mock_from_config.assert_called_once_with("gitlab.com", [str(cfg_file)])

    def test_create_gitlab_client_uses_user_cfg(self, mocker: MockerFixture, tmp_path: Path) -> None:
        """_create_gitlab_client uses ~/.python-gitlab.cfg when it exists."""
        mocker.patch.dict("os.environ", {}, clear=False)
        user_cfg = tmp_path / ".python-gitlab.cfg"
        user_cfg.write_text("[gitlab.com]\nurl = https://gitlab.com\n")
        mocker.patch("mcp_vm_blackbox.ci_tools.Path.home", return_value=tmp_path)
        mock_from_config = mocker.patch("mcp_vm_blackbox.ci_tools.gitlab.Gitlab.from_config")

        from mcp_vm_blackbox.ci_tools import _create_gitlab_client

        _create_gitlab_client()

        mock_from_config.assert_called_once_with("gitlab.com", [str(user_cfg)])


# ---------------------------------------------------------------------------
# _resolve_branch (helper)
# ---------------------------------------------------------------------------


class TestResolveBranch:
    """Tests for _resolve_branch helper."""

    def test_resolve_branch_explicit_branch(self) -> None:
        """_resolve_branch returns branch when provided."""
        from mcp_vm_blackbox.ci_tools import _resolve_branch

        result = _resolve_branch("feature/xyz", Path.cwd())
        assert result == "feature/xyz"

    def test_resolve_branch_from_git(self, tmp_path: Path) -> None:
        """_resolve_branch uses GitPython active_branch when branch is None."""
        repo = Repo.init(tmp_path)
        (tmp_path / "f").write_text("x")
        repo.index.add(["f"])
        repo.index.commit("init")
        repo.git.checkout("-b", "develop")

        from mcp_vm_blackbox.ci_tools import _resolve_branch

        result = _resolve_branch(None, tmp_path)

        assert result == "develop"

    def test_resolve_branch_detached_head_returns_none(self, tmp_path: Path) -> None:
        """_resolve_branch returns None when HEAD is detached."""
        repo = Repo.init(tmp_path)
        (tmp_path / "f").write_text("x")
        repo.index.add(["f"])
        repo.index.commit("init")
        repo.git.checkout(repo.head.commit.hexsha)

        from mcp_vm_blackbox.ci_tools import _resolve_branch

        result = _resolve_branch(None, tmp_path)

        assert result is None

    def test_resolve_branch_missing_head_returns_none(self, tmp_path: Path) -> None:
        """_resolve_branch returns None when not a git repository."""
        from mcp_vm_blackbox.ci_tools import _resolve_branch

        result = _resolve_branch(None, tmp_path)

        assert result is None
