"""Tests for Phase 5 polish: pull command, graceful degradation, doctor enhancements, uninstall.

Covers:
- ``llmhosts pull`` CLI command (help, VRAM warnings, success, failure)
- Graceful degradation in ``LLMHostUp`` (provision failure, auth failure, network offline)
- New doctor checks (disk for models, GPU drivers, GPU utilization, model registry)
- Enhanced uninstall guidance (Rich Panel, GPU Respect Principle)
"""

from __future__ import annotations

import io
from datetime import datetime  # noqa: F401 -- needed for Pydantic model_rebuild
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from click.testing import CliRunner
from rich.console import Console

from llmhosts.banner import DoctorCheck
from llmhosts.cli import main
from llmhosts.discovery.models import (
    DiscoveredEngine,
    DiscoveredModel,
    GPUInfo,
    HardwareProfile,
)
from llmhosts.doctor import Doctor
from llmhosts.tunnel.models import TunnelStatus
from llmhosts.up import LLMHostUp

if TYPE_CHECKING:
    from pathlib import Path

# Fix Pydantic forward-reference resolution
TunnelStatus.model_rebuild()
DiscoveredEngine.model_rebuild()


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


@pytest.fixture()
def runner() -> CliRunner:
    return CliRunner()


def _make_console() -> Console:
    return Console(file=io.StringIO(), no_color=True)


def _hw_with_gpu(vram_mb: int = 8192, name: str = "RTX 4060") -> HardwareProfile:
    return HardwareProfile(
        gpus=[GPUInfo(name=name, vram_total_mb=vram_mb, driver_version="560.35")],
        ram_total_gb=32.0,
        cpu_cores=8,
    )


def _hw_no_gpu() -> HardwareProfile:
    return HardwareProfile(ram_total_gb=16.0, cpu_cores=4)


def _engine(
    engine_type: str = "ollama",
    models: list[str] | None = None,
) -> DiscoveredEngine:
    model_list = [DiscoveredModel(id=m) for m in (models or ["llama3:8b"])]
    return DiscoveredEngine(
        engine_type=engine_type,
        host="127.0.0.1",
        port=11434,
        available=True,
        models=model_list,
        confidence=0.95,
    )


# ===========================================================================
# 5a. Pull command tests
# ===========================================================================


class TestPullCommand:
    """Tests for ``llmhosts pull`` CLI command."""

    @pytest.mark.unit
    def test_pull_help(self, runner: CliRunner) -> None:
        result = runner.invoke(main, ["pull", "--help"])
        assert result.exit_code == 0
        assert "Download a GGUF model" in result.output
        assert "--auto" in result.output
        assert "--list" in result.output

    @pytest.mark.unit
    def test_pull_no_args_shows_usage(self, runner: CliRunner) -> None:
        result = runner.invoke(main, ["pull"])
        assert result.exit_code != 0
        assert "Usage" in result.output or "llmhosts pull" in result.output

    @pytest.mark.unit
    def test_pull_list_shows_models(self, runner: CliRunner) -> None:
        """--list shows available models with local status."""
        with patch(
            "llmhosts.models.download.list_local_models",
            return_value=["qwen2.5:0.5b"],
        ):
            result = runner.invoke(main, ["pull", "--list"])
            assert result.exit_code == 0
            assert "qwen2.5:0.5b" in result.output
            assert "llama3.1:8b" in result.output

    @pytest.mark.unit
    def test_pull_success_with_gpu(self, runner: CliRunner) -> None:
        """Pull completes successfully with GPU hardware."""
        hw = _hw_with_gpu()

        with (
            patch(
                "llmhosts.discovery.hardware.HardwareDetector.detect",
                new_callable=AsyncMock,
                return_value=hw,
            ),
            patch(
                "llmhosts.models.download.download_model",
                return_value="/home/user/.llmhosts/models/qwen2.5-7b-instruct-q4_k_m.gguf",
            ),
        ):
            result = runner.invoke(main, ["pull", "qwen2.5:7b"])
            assert result.exit_code == 0
            assert "Model ready" in result.output

    @pytest.mark.unit
    def test_pull_success_no_gpu(self, runner: CliRunner) -> None:
        """Pull completes successfully without GPU."""
        hw = _hw_no_gpu()

        with (
            patch(
                "llmhosts.discovery.hardware.HardwareDetector.detect",
                new_callable=AsyncMock,
                return_value=hw,
            ),
            patch(
                "llmhosts.models.download.download_model",
                return_value="/home/user/.llmhosts/models/qwen2.5-7b-instruct-q4_k_m.gguf",
            ),
        ):
            result = runner.invoke(main, ["pull", "qwen2.5:7b"])
            assert result.exit_code == 0
            assert "Model ready" in result.output

    @pytest.mark.unit
    def test_pull_unknown_model_shows_error(self, runner: CliRunner) -> None:
        """Unknown model name shows friendly error."""
        with patch(
            "llmhosts.models.download.download_model",
            side_effect=ValueError("Unknown model: nonexistent:1b"),
        ):
            result = runner.invoke(main, ["pull", "nonexistent:1b"])
            assert result.exit_code == 1
            assert "Unknown model" in result.output
            assert "--list" in result.output

    @pytest.mark.unit
    def test_pull_vram_warning_large_model(self, runner: CliRunner) -> None:
        """Large model for small GPU shows VRAM warning."""
        hw = _hw_with_gpu(vram_mb=4096, name="GTX 1070")  # 4GB GPU

        with (
            patch(
                "llmhosts.discovery.hardware.HardwareDetector.detect",
                new_callable=AsyncMock,
                return_value=hw,
            ),
            patch(
                "llmhosts.models.download.download_model",
                return_value="/home/user/.llmhosts/models/qwen2.5-7b-instruct-q4_k_m.gguf",
            ),
        ):
            result = runner.invoke(main, ["pull", "qwen2.5:7b"])  # needs 6GB VRAM
            assert result.exit_code == 0
            assert "Warning" in result.output

    @pytest.mark.unit
    def test_pull_hardware_detection_failure_continues(self, runner: CliRunner) -> None:
        """Pull continues even if hardware detection fails."""
        with (
            patch(
                "llmhosts.discovery.hardware.HardwareDetector.detect",
                new_callable=AsyncMock,
                side_effect=RuntimeError("no GPU"),
            ),
            patch(
                "llmhosts.models.download.download_model",
                return_value="/home/user/.llmhosts/models/qwen2.5-7b-instruct-q4_k_m.gguf",
            ),
        ):
            result = runner.invoke(main, ["pull", "qwen2.5:7b"])
            assert result.exit_code == 0
            assert "Model ready" in result.output

    @pytest.mark.unit
    def test_pull_auto_recommends_model(self, runner: CliRunner) -> None:
        """--auto recommends a model based on available VRAM."""
        with patch(
            "llmhosts.models.download.download_model",
            return_value="/home/user/.llmhosts/models/qwen2.5-0.5b-instruct-q4_k_m.gguf",
        ):
            result = runner.invoke(main, ["pull", "--auto"])
            assert result.exit_code == 0
            assert "Recommended" in result.output
            assert "Model ready" in result.output


# ===========================================================================
# 5b. Graceful degradation tests
# ===========================================================================


class TestGracefulDegradation:
    """Tests for graceful degradation in ``LLMHostUp``."""

    @pytest.mark.unit
    async def test_provision_failure_records_degraded(self) -> None:
        """Provision failure should record degraded state, not crash."""
        con = _make_console()
        up = LLMHostUp(con, no_tui=True, dry_run=True)
        up._hardware = _hw_with_gpu()
        up._recommended_model = {"name": "qwen2.5:0.5b", "size_gb": 0.4}

        # Mock download to fail
        with (
            patch(
                "llmhosts.models.download.list_local_models",
                return_value=[],
            ),
            patch(
                "llmhosts.models.download.download_model",
                side_effect=RuntimeError("Download failed"),
            ),
        ):
            await up._phase_provision()

        assert "provision" in up._degraded
        assert "Download failed" in up._degraded["provision"]

    @pytest.mark.unit
    async def test_provision_exception_records_degraded(self) -> None:
        """Provision exception should not crash, should record degraded."""
        con = _make_console()
        up = LLMHostUp(con, no_tui=True, dry_run=True)
        up._hardware = _hw_with_gpu()
        up._recommended_model = {"name": "qwen2.5:0.5b", "size_gb": 0.4}

        with (
            patch(
                "llmhosts.models.download.list_local_models",
                return_value=[],
            ),
            patch(
                "llmhosts.models.download.download_model",
                side_effect=Exception("kaboom"),
            ),
        ):
            # Should NOT raise
            await up._phase_provision()

        assert "provision" in up._degraded
        assert "kaboom" in up._degraded["provision"]

    @pytest.mark.unit
    async def test_no_recommendation_records_degraded(self) -> None:
        """No model recommendation should record degraded state and continue."""
        con = _make_console()
        up = LLMHostUp(con, no_tui=True, dry_run=True)
        up._hardware = _hw_with_gpu()
        up._recommended_model = None  # No recommendation

        with patch(
            "llmhosts.models.download.list_local_models",
            return_value=[],
        ):
            await up._phase_provision()

        assert "provision" in up._degraded
        assert "No model recommendation" in up._degraded["provision"]

    @pytest.mark.unit
    async def test_connect_auth_failure_records_degraded(self) -> None:
        """Auth failure should record degraded state but not crash."""
        con = _make_console()
        up = LLMHostUp(con, no_tui=True)
        up._engines = [_engine()]

        with (
            patch.object(up, "_start_tunnel", new_callable=AsyncMock),
            patch.object(
                up,
                "_authenticate",
                new_callable=AsyncMock,
                side_effect=RuntimeError("auth failed"),
            ),
            patch.object(up, "_print_degraded_summary"),
            patch.object(up, "_print_success"),
        ):
            await up._phase_connect()

        assert "auth" in up._degraded

    @pytest.mark.unit
    async def test_connect_tunnel_failure_records_degraded(self) -> None:
        """Tunnel failure should record degraded state."""
        con = _make_console()
        up = LLMHostUp(con, no_tui=True)
        up._engines = [_engine()]

        with (
            patch.object(
                up,
                "_start_tunnel",
                new_callable=AsyncMock,
                side_effect=RuntimeError("no tunnel"),
            ),
            patch.object(up, "_authenticate", new_callable=AsyncMock),
            patch.object(up, "_print_degraded_summary"),
            patch.object(up, "_print_success"),
        ):
            await up._phase_connect()

        assert "tunnel" in up._degraded

    @pytest.mark.unit
    async def test_network_offline_records_degraded(self) -> None:
        """Network check failure should record degraded network state."""
        con = _make_console()
        up = LLMHostUp(con, no_tui=True, dry_run=True)

        import httpx

        with (
            patch(
                "llmhosts.discovery.hardware.HardwareDetector.detect",
                new_callable=AsyncMock,
                return_value=_hw_with_gpu(),
            ),
            patch.object(
                up._scanner,
                "scan",
                new_callable=AsyncMock,
                return_value=[_engine()],
            ),
            patch(
                "llmhosts.up.httpx.AsyncClient.head",
                new_callable=AsyncMock,
                side_effect=httpx.ConnectError("no network"),
            ),
        ):
            await up._phase_assess()

        assert "network" in up._degraded

    @pytest.mark.unit
    def test_print_degraded_summary_no_issues(self) -> None:
        """No degraded state produces no output."""
        con = _make_console()
        up = LLMHostUp(con, no_tui=True)
        up._degraded = {}
        up._print_degraded_summary()
        output = con.file.getvalue()  # type: ignore[union-attr]
        assert "Degraded Mode" not in output

    @pytest.mark.unit
    def test_print_degraded_summary_with_issues(self) -> None:
        """Degraded state prints a Rich panel with summaries."""
        con = _make_console()
        up = LLMHostUp(con, no_tui=True)
        up._degraded = {
            "provision": "Download failed",
            "auth": "Auth timeout",
        }
        up._print_degraded_summary()
        output = con.file.getvalue()  # type: ignore[union-attr]
        assert "Degraded Mode" in output
        assert "Model download" in output or "download" in output.lower()
        assert "Cloud authentication" in output or "auth" in output.lower()

    @pytest.mark.unit
    def test_print_degraded_summary_network_offline(self) -> None:
        """Network degradation suggests offline model import."""
        con = _make_console()
        up = LLMHostUp(con, no_tui=True)
        up._degraded = {"network": "No internet"}
        up._print_degraded_summary()
        output = con.file.getvalue()  # type: ignore[union-attr]
        assert "Network" in output
        assert "offline" in output.lower() or "import" in output.lower()


# ===========================================================================
# 5c. Doctor enhancement tests
# ===========================================================================


class TestDoctorEnhancements:
    """Tests for new doctor checks."""

    @pytest.fixture()
    def doctor(self, tmp_path: Path) -> Doctor:
        """Create a Doctor with a temporary data dir."""
        from llmhosts.config import load_config

        config = load_config()
        return Doctor(config=config, data_dir=tmp_path / ".llmhosts")

    @pytest.mark.unit
    async def test_check_disk_for_models_plenty(self, doctor: Doctor) -> None:
        """Plenty of disk space should pass."""
        with patch.object(Doctor, "_get_disk_space", return_value=(500.0, 100.0)):
            result = await doctor.check_disk_for_models()
        assert result.status == "ok"
        assert "room for multiple" in result.message

    @pytest.mark.unit
    async def test_check_disk_for_models_tight(self, doctor: Doctor) -> None:
        """Tight disk space should warn."""
        with patch.object(Doctor, "_get_disk_space", return_value=(500.0, 2.0)):
            result = await doctor.check_disk_for_models()
        assert result.status == "warn"
        assert "tight" in result.message

    @pytest.mark.unit
    async def test_check_disk_for_models_insufficient(self, doctor: Doctor) -> None:
        """Insufficient disk space should fail."""
        with patch.object(Doctor, "_get_disk_space", return_value=(500.0, 0.5)):
            result = await doctor.check_disk_for_models()
        assert result.status == "fail"
        assert "insufficient" in result.message.lower()

    @pytest.mark.unit
    async def test_check_gpu_drivers_no_gpu(self, doctor: Doctor) -> None:
        """No GPU should report OK (not applicable)."""
        hw = _hw_no_gpu()
        with patch(
            "llmhosts.discovery.hardware.HardwareDetector.detect",
            new_callable=AsyncMock,
            return_value=hw,
        ):
            result = await doctor.check_gpu_drivers()
        assert result.status == "ok"
        assert "No GPU" in result.message

    @pytest.mark.unit
    async def test_check_gpu_drivers_up_to_date(self, doctor: Doctor) -> None:
        """Up-to-date GPU driver should report OK."""
        hw = _hw_with_gpu()  # driver_version="560.35"
        with patch(
            "llmhosts.discovery.hardware.HardwareDetector.detect",
            new_callable=AsyncMock,
            return_value=hw,
        ):
            result = await doctor.check_gpu_drivers()
        assert result.status == "ok"
        assert "Up to date" in result.message

    @pytest.mark.unit
    async def test_check_gpu_drivers_outdated_nvidia(self, doctor: Doctor) -> None:
        """Outdated NVIDIA driver should warn with update command."""
        hw = HardwareProfile(
            gpus=[GPUInfo(name="RTX 3060", vram_total_mb=12288, driver_version="535.86")],
            ram_total_gb=32.0,
        )
        with patch(
            "llmhosts.discovery.hardware.HardwareDetector.detect",
            new_callable=AsyncMock,
            return_value=hw,
        ):
            result = await doctor.check_gpu_drivers()
        assert result.status == "warn"
        assert "outdated" in result.message.lower()

    @pytest.mark.unit
    async def test_check_gpu_drivers_detection_failure(self, doctor: Doctor) -> None:
        """Hardware detection failure should still return OK."""
        with patch(
            "llmhosts.discovery.hardware.HardwareDetector.detect",
            new_callable=AsyncMock,
            side_effect=RuntimeError("no pynvml"),
        ):
            result = await doctor.check_gpu_drivers()
        assert result.status == "ok"
        assert "unavailable" in result.message.lower()

    @pytest.mark.unit
    async def test_check_gpu_utilization_no_nvidia_smi(self, doctor: Doctor) -> None:
        """No nvidia-smi should skip gracefully."""
        with patch("llmhosts.doctor.shutil.which", return_value=None):
            result = await doctor.check_gpu_utilization()
        assert result.status == "ok"
        assert "skipped" in result.message.lower() or "not available" in result.message.lower()

    @pytest.mark.unit
    async def test_check_gpu_utilization_with_data(self, doctor: Doctor) -> None:
        """nvidia-smi with data should show utilization."""

        class _FakeProc:
            returncode = 0

            async def communicate(self):
                return (b"RTX 4090, 45, 8192, 24576\n", b"")

        async def _fake_create_subprocess(*args, **kwargs):
            return _FakeProc()

        with (
            patch("llmhosts.doctor.shutil.which", return_value="/usr/bin/nvidia-smi"),
            patch("asyncio.create_subprocess_exec", _fake_create_subprocess),
        ):
            result = await doctor.check_gpu_utilization()
        assert result.status == "ok"

    @pytest.mark.unit
    async def test_check_model_registry_reachable(self, doctor: Doctor) -> None:
        """Reachable model registry should pass."""

        class _FakeHTTPClient:
            async def __aenter__(self):
                return self

            async def __aexit__(self, *args):
                pass

            async def head(self, url, **kwargs):
                resp = MagicMock()
                resp.status_code = 200
                return resp

        with patch("httpx.AsyncClient", return_value=_FakeHTTPClient()):
            result = await doctor.check_model_registry_network()
        assert result.status == "ok"
        assert "reachable" in result.message.lower()

    @pytest.mark.unit
    async def test_check_model_registry_unreachable(self, doctor: Doctor) -> None:
        """Unreachable model registry should warn."""
        import httpx

        class _FailingHTTPClient:
            async def __aenter__(self):
                return self

            async def __aexit__(self, *args):
                pass

            async def head(self, url, **kwargs):
                raise httpx.ConnectError("offline")

        with patch("httpx.AsyncClient", return_value=_FailingHTTPClient()):
            result = await doctor.check_model_registry_network()
        assert result.status == "warn"
        assert "unreachable" in result.message.lower() or "offline" in result.message.lower()

    @pytest.mark.unit
    async def test_run_all_includes_new_checks(self, doctor: Doctor) -> None:
        """run_all() should include the new check names."""
        with (
            patch.object(
                doctor,
                "check_disk_for_models",
                new_callable=AsyncMock,
                return_value=DoctorCheck(name="Disk (models)", status="ok", message="ok"),
            ),
            patch.object(
                doctor,
                "check_gpu_drivers",
                new_callable=AsyncMock,
                return_value=DoctorCheck(name="GPU drivers", status="ok", message="ok"),
            ),
            patch.object(
                doctor,
                "check_gpu_utilization",
                new_callable=AsyncMock,
                return_value=DoctorCheck(name="GPU utilization", status="ok", message="ok"),
            ),
            patch.object(
                doctor,
                "check_model_registry_network",
                new_callable=AsyncMock,
                return_value=DoctorCheck(name="Model registry", status="ok", message="ok"),
            ),
            patch.object(
                doctor,
                "check_python",
                new_callable=AsyncMock,
                return_value=DoctorCheck(name="Python version", status="ok", message="ok"),
            ),
            patch.object(
                doctor,
                "check_config",
                new_callable=AsyncMock,
                return_value=DoctorCheck(name="Config file", status="ok", message="ok"),
            ),
            patch.object(
                doctor,
                "check_ollama",
                new_callable=AsyncMock,
                return_value=DoctorCheck(name="Inference Engine", status="ok", message="ok"),
            ),
            patch.object(
                doctor,
                "check_keys",
                new_callable=AsyncMock,
                return_value=DoctorCheck(name="BYOK API keys", status="ok", message="ok"),
            ),
            patch.object(
                doctor,
                "check_port",
                new_callable=AsyncMock,
                return_value=DoctorCheck(name="Proxy port", status="ok", message="ok"),
            ),
            patch.object(
                doctor,
                "check_tier",
                new_callable=AsyncMock,
                return_value=DoctorCheck(name="Installed tier", status="ok", message="ok"),
            ),
            patch.object(
                doctor,
                "check_disk",
                new_callable=AsyncMock,
                return_value=DoctorCheck(name="Disk space", status="ok", message="ok"),
            ),
            patch.object(
                doctor,
                "check_hardware",
                new_callable=AsyncMock,
                return_value=DoctorCheck(name="Hardware", status="ok", message="ok"),
            ),
            patch.object(
                doctor,
                "check_database",
                new_callable=AsyncMock,
                return_value=DoctorCheck(name="Database", status="ok", message="ok"),
            ),
            patch.object(
                doctor,
                "check_cache",
                new_callable=AsyncMock,
                return_value=DoctorCheck(name="Cache", status="ok", message="ok"),
            ),
        ):
            results = await doctor.run_all()

        names = {r.name for r in results}
        assert "Disk (models)" in names
        assert "GPU drivers" in names
        assert "GPU utilization" in names
        assert "Model registry" in names


# ===========================================================================
# 5d. Uninstall guidance tests
# ===========================================================================


class TestUninstallGuidance:
    """Tests for enhanced uninstall command."""

    @pytest.mark.unit
    def test_uninstall_help_shows_gpu_respect(self, runner: CliRunner) -> None:
        """Uninstall --help should mention models and engine are KEPT."""
        result = runner.invoke(main, ["uninstall", "--help"])
        assert result.exit_code == 0
        assert "KEPT" in result.output
        assert "--remove-config" in result.output
        assert "never" in result.output.lower() or "NEVER" in result.output

    @pytest.mark.unit
    def test_uninstall_shows_rich_panel(
        self,
        runner: CliRunner,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Uninstall should show a Rich Panel with what's removed and kept."""
        data_dir = tmp_path / ".llmhosts"
        data_dir.mkdir()
        (data_dir / "config.toml").write_text("test = true")

        monkeypatch.setenv("HOME", str(tmp_path))
        # Say "no" to confirmation
        result = runner.invoke(main, ["uninstall"], input="n\n")
        assert result.exit_code == 0
        # Should show the panel content
        assert "Uninstall Plan" in result.output
        assert "REMOVED" in result.output
        assert "KEPT" in result.output

    @pytest.mark.unit
    def test_uninstall_panel_shows_models_kept(
        self,
        runner: CliRunner,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """Uninstall plan should show models are kept."""
        data_dir = tmp_path / ".llmhosts"
        data_dir.mkdir()
        (data_dir / "config.toml").write_text("test = true")

        monkeypatch.setenv("HOME", str(tmp_path))
        result = runner.invoke(main, ["uninstall"], input="n\n")
        assert result.exit_code == 0
        assert "AI models" in result.output
        assert "never delete" in result.output.lower() or "we never" in result.output.lower()

    @pytest.mark.unit
    def test_uninstall_remove_engine_warns(
        self,
        runner: CliRunner,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """--remove-engine should show CAUTION in the panel."""
        data_dir = tmp_path / ".llmhosts"
        data_dir.mkdir()
        (data_dir / "config.toml").write_text("test = true")

        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setattr("shutil.which", lambda cmd: None)
        result = runner.invoke(main, ["uninstall", "--remove-engine"], input="n\n")
        assert result.exit_code == 0
        assert "CAUTION" in result.output

    @pytest.mark.unit
    def test_uninstall_keep_data_panel(
        self,
        runner: CliRunner,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """--keep-data should show data directory is KEPT."""
        data_dir = tmp_path / ".llmhosts"
        data_dir.mkdir()
        (data_dir / "config.toml").write_text("test = true")

        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setattr("shutil.which", lambda cmd: None)
        result = runner.invoke(main, ["uninstall", "--keep-data", "-y"])
        assert result.exit_code == 0
        assert "KEPT" in result.output
        # Data dir should still exist
        assert data_dir.exists()

    @pytest.mark.unit
    def test_uninstall_remove_config_flag(
        self,
        runner: CliRunner,
        tmp_path: Path,
        monkeypatch: pytest.MonkeyPatch,
    ) -> None:
        """--remove-config should remove the data directory."""
        data_dir = tmp_path / ".llmhosts"
        data_dir.mkdir()
        (data_dir / "config.toml").write_text("test = true")

        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setattr("shutil.which", lambda cmd: None)
        result = runner.invoke(main, ["uninstall", "--remove-config", "-y"])
        assert result.exit_code == 0
        # Data dir should be removed
        assert not data_dir.exists()
