"""Comprehensive E2E tests for the LLMHosts CLI.

Tests all major CLI commands: doctor, serve, tunnel, keys, suggest-models,
setup, discover, config, monitor, telemetry, cache, engine, team, device,
audit, alerts, uninstall, train-router, update-weights.

Uses Click's CliRunner for process-level testing with isolated temp dirs
to avoid polluting real config.
"""

from __future__ import annotations

import json
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, MagicMock, patch

import pytest
from click.testing import CliRunner

from llmhosts.cli import main

if TYPE_CHECKING:
    from pathlib import Path


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


@pytest.fixture()
def runner() -> CliRunner:
    """Isolated CliRunner for clean output capture."""
    return CliRunner()


@pytest.fixture()
def isolated_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    """Set HOME to a temp directory so all config/data goes to an isolated location."""
    monkeypatch.setenv("HOME", str(tmp_path))
    # Also ensure llmhosts_dir() creates inside the temp HOME
    llmhosts_dir = tmp_path / ".llmhosts"
    llmhosts_dir.mkdir(parents=True, exist_ok=True)
    return tmp_path


@pytest.fixture()
def isolated_config(isolated_home: Path) -> Path:
    """Create a minimal config.toml in the isolated home directory."""
    config_dir = isolated_home / ".llmhosts"
    config_dir.mkdir(parents=True, exist_ok=True)
    config_file = config_dir / "config.toml"
    config_file.write_text(
        '[server]\nhost = "127.0.0.1"\nport = 4000\n\n'
        '[ollama]\nhost = "http://localhost:11434"\n\n'
        '[engine]\ntype = "ollama"\n'
    )
    return config_dir


# ===========================================================================
# doctor command
# ===========================================================================


class TestDoctorCommand:
    """E2E tests for `llmhosts doctor`."""

    @pytest.mark.e2e
    def test_doctor_runs_and_exits_cleanly(self, runner: CliRunner) -> None:
        """Doctor should run all checks without crashing."""
        result = runner.invoke(main, ["doctor"])
        # May exit 0 (all pass) or 1 (some checks fail) -- both are valid
        assert result.exit_code in (0, 1)
        assert result.exception is None or isinstance(result.exception, SystemExit)

    @pytest.mark.e2e
    def test_doctor_shows_check_categories(self, runner: CliRunner) -> None:
        """Doctor output should mention key check categories."""
        result = runner.invoke(main, ["doctor"])
        output = result.output.lower()
        # Doctor checks Python, config, hardware, etc.
        # At minimum the "Plain-English System Check" section should appear
        assert "system check" in output or "python" in output or "check" in output

    @pytest.mark.e2e
    def test_doctor_fix_flag_accepted(self, runner: CliRunner) -> None:
        """Doctor --fix should be accepted without error."""
        result = runner.invoke(main, ["doctor", "--fix"])
        assert result.exit_code in (0, 1)
        assert result.exception is None or isinstance(result.exception, SystemExit)
        # --fix should produce some output about fixing or no fixable issues
        output = result.output.lower()
        assert "fix" in output or "check" in output or "issue" in output

    @pytest.mark.e2e
    def test_doctor_help(self, runner: CliRunner) -> None:
        """Doctor --help should show usage information."""
        result = runner.invoke(main, ["doctor", "--help"])
        assert result.exit_code == 0
        assert "--fix" in result.output
        assert "Verify" in result.output or "setup" in result.output.lower()


# ===========================================================================
# serve command
# ===========================================================================


class TestServeCommand:
    """E2E tests for `llmhosts serve`."""

    @pytest.mark.e2e
    def test_serve_help(self, runner: CliRunner) -> None:
        """Serve --help should show all expected flags."""
        result = runner.invoke(main, ["serve", "--help"])
        assert result.exit_code == 0
        assert "--host" in result.output
        assert "--port" in result.output
        assert "--no-tui" in result.output
        assert "--config" in result.output
        assert "--log-level" in result.output
        assert "--interactive" in result.output

    @pytest.mark.e2e
    def test_serve_invalid_port(self, runner: CliRunner) -> None:
        """Serve with an invalid port type should fail with a Click error."""
        result = runner.invoke(main, ["serve", "--port", "not-a-number"])
        assert result.exit_code != 0
        assert (
            "Invalid value" in result.output or "not a valid integer" in result.output.lower() or result.exit_code == 2
        )

    @pytest.mark.e2e
    def test_serve_invalid_log_level(self, runner: CliRunner) -> None:
        """Serve with an invalid log level should fail."""
        result = runner.invoke(main, ["serve", "--log-level", "foobar"])
        assert result.exit_code != 0

    @pytest.mark.e2e
    def test_serve_nonexistent_config(self, runner: CliRunner) -> None:
        """Serve with a non-existent config path should fail."""
        result = runner.invoke(main, ["serve", "--config", "/tmp/nonexistent-llmhosts-config.toml"])
        assert result.exit_code != 0


# ===========================================================================
# tunnel command
# ===========================================================================


class TestTunnelCommand:
    """E2E tests for `llmhosts tunnel` and its subcommands."""

    @pytest.mark.e2e
    def test_tunnel_help(self, runner: CliRunner) -> None:
        """Tunnel --help should show subcommands and options."""
        result = runner.invoke(main, ["tunnel", "--help"])
        assert result.exit_code == 0
        assert "start" in result.output
        assert "status" in result.output
        assert "stop" in result.output
        assert "--port" in result.output
        assert "--no-auth" in result.output

    @pytest.mark.e2e
    def test_tunnel_start_help(self, runner: CliRunner) -> None:
        """Tunnel start --help should show usage."""
        result = runner.invoke(main, ["tunnel", "start", "--help"])
        assert result.exit_code == 0
        assert "Start the tunnel" in result.output

    @pytest.mark.e2e
    def test_tunnel_status_no_providers(self, runner: CliRunner) -> None:
        """Tunnel status should handle no providers gracefully."""
        result = runner.invoke(main, ["tunnel", "status"])
        # In CI, the Rust relay extension may not be built
        # So we expect either status info or "not available" message
        assert result.exit_code == 0
        output = result.output.lower()
        assert "status" in output or "no tunnel" in output or "provider" in output

    @pytest.mark.e2e
    def test_tunnel_stop_help(self, runner: CliRunner) -> None:
        """Tunnel stop --help should show usage."""
        result = runner.invoke(main, ["tunnel", "stop", "--help"])
        assert result.exit_code == 0
        assert "Stop any active tunnel" in result.output

    @pytest.mark.e2e
    def test_tunnel_invalid_provider(self, runner: CliRunner) -> None:
        """Tunnel with an invalid provider should fail."""
        result = runner.invoke(main, ["tunnel", "--provider", "invalid_provider"])
        assert result.exit_code != 0


# ===========================================================================
# keys command
# ===========================================================================


class TestKeysCommand:
    """E2E tests for `llmhosts keys` add/list/remove/validate."""

    @pytest.mark.e2e
    def test_keys_help(self, runner: CliRunner) -> None:
        """Keys --help should show subcommands."""
        result = runner.invoke(main, ["keys", "--help"])
        assert result.exit_code == 0
        assert "add" in result.output
        assert "list" in result.output
        assert "remove" in result.output
        assert "validate" in result.output

    @pytest.mark.e2e
    def test_keys_add_and_list(self, runner: CliRunner, isolated_home: Path) -> None:
        """Adding a key should appear in keys list."""
        # Add a test key
        result = runner.invoke(main, ["keys", "add", "openai", "sk-test-key-1234567890abcdef"])
        assert result.exit_code == 0
        assert "openai" in result.output.lower()

        # List keys
        result = runner.invoke(main, ["keys", "list"])
        assert result.exit_code == 0
        assert "openai" in result.output.lower()

    @pytest.mark.e2e
    def test_keys_add_multiple_providers(self, runner: CliRunner, isolated_home: Path) -> None:
        """Adding keys for multiple providers should all appear in list."""
        runner.invoke(main, ["keys", "add", "openai", "sk-test-key-aaaa1111"])
        runner.invoke(main, ["keys", "add", "anthropic", "sk-ant-test-bbbb2222"])

        result = runner.invoke(main, ["keys", "list"])
        assert result.exit_code == 0
        assert "openai" in result.output.lower()
        assert "anthropic" in result.output.lower()

    @pytest.mark.e2e
    def test_keys_add_invalid_provider(self, runner: CliRunner) -> None:
        """Adding a key with an unsupported provider should fail."""
        result = runner.invoke(main, ["keys", "add", "invalid_provider", "sk-test"])
        assert result.exit_code != 0

    @pytest.mark.e2e
    def test_keys_remove(self, runner: CliRunner, isolated_home: Path) -> None:
        """Removing a key should make it disappear from list."""
        # Add then remove
        runner.invoke(main, ["keys", "add", "groq", "gsk-test-key-12345678"])
        result = runner.invoke(main, ["keys", "remove", "groq"])
        assert result.exit_code == 0
        assert "groq" in result.output.lower()

        # Verify it's gone
        result = runner.invoke(main, ["keys", "list"])
        # Either shows "No API keys" or the table without groq
        output = result.output.lower()
        assert "no api keys" in output or "groq" not in output

    @pytest.mark.e2e
    def test_keys_remove_nonexistent(self, runner: CliRunner, isolated_home: Path) -> None:
        """Removing a key that doesn't exist should fail."""
        result = runner.invoke(main, ["keys", "remove", "openai"])
        assert result.exit_code != 0
        assert "no key" in result.output.lower() or "error" in result.output.lower()

    @pytest.mark.e2e
    def test_keys_list_empty(self, runner: CliRunner, isolated_home: Path) -> None:
        """Keys list with no stored keys should succeed with informative message."""
        result = runner.invoke(main, ["keys", "list"])
        assert result.exit_code == 0
        assert "no api keys" in result.output.lower() or "add one" in result.output.lower()

    @pytest.mark.e2e
    def test_keys_add_overwrite(self, runner: CliRunner, isolated_home: Path) -> None:
        """Adding a key for the same provider should overwrite."""
        runner.invoke(main, ["keys", "add", "openai", "sk-first-key-aaaaaa"])
        result = runner.invoke(main, ["keys", "add", "openai", "sk-second-key-bbbbbb"])
        assert result.exit_code == 0
        assert "openai" in result.output.lower()

    @pytest.mark.e2e
    def test_keys_validate_help(self, runner: CliRunner) -> None:
        """Keys validate --help should show usage."""
        result = runner.invoke(main, ["keys", "validate", "--help"])
        assert result.exit_code == 0
        assert "Validate" in result.output

    @pytest.mark.e2e
    def test_keys_validate_no_key(self, runner: CliRunner, isolated_home: Path) -> None:
        """Validating a provider with no stored key should fail."""
        result = runner.invoke(main, ["keys", "validate", "openai"])
        assert result.exit_code != 0
        assert "no key" in result.output.lower() or "error" in result.output.lower()

    @pytest.mark.e2e
    def test_keys_validate_empty(self, runner: CliRunner, isolated_home: Path) -> None:
        """Validating with no keys configured should report cleanly."""
        result = runner.invoke(main, ["keys", "validate"])
        assert result.exit_code == 0
        assert "no api keys" in result.output.lower()


# ===========================================================================
# suggest-models command
# ===========================================================================


class TestSuggestModelsCommand:
    """E2E tests for `llmhosts suggest-models`."""

    @pytest.mark.e2e
    def test_suggest_models_help(self, runner: CliRunner) -> None:
        """suggest-models --help should show usage."""
        result = runner.invoke(main, ["suggest-models", "--help"])
        assert result.exit_code == 0
        assert "--use-case" in result.output
        assert "--show-installed" in result.output
        assert "--json-output" in result.output

    @pytest.mark.e2e
    def test_suggest_models_runs(self, runner: CliRunner) -> None:
        """suggest-models should produce output about hardware and models."""
        result = runner.invoke(main, ["suggest-models"])
        # Should succeed or fail gracefully (no hardware / no Ollama in CI)
        assert result.exit_code in (0, 1)
        output = result.output.lower()
        # Should at least mention hardware detection
        assert "hardware" in output or "gpu" in output or "ram" in output or "detecting" in output

    @pytest.mark.e2e
    def test_suggest_models_with_use_case(self, runner: CliRunner) -> None:
        """suggest-models with --use-case should filter suggestions."""
        result = runner.invoke(main, ["suggest-models", "--use-case", "coding"])
        assert result.exit_code in (0, 1)

    @pytest.mark.e2e
    def test_suggest_models_json_output(self, runner: CliRunner) -> None:
        """suggest-models --json-output should produce valid JSON (or exit 1 if no hardware)."""
        result = runner.invoke(main, ["suggest-models", "--json-output"])
        if result.exit_code == 0:
            # If it succeeded, check for JSON array or "no new model" text
            output = result.output.strip()
            if output.startswith("["):
                parsed = json.loads(output)
                assert isinstance(parsed, list)

    @pytest.mark.e2e
    def test_suggest_models_invalid_use_case(self, runner: CliRunner) -> None:
        """suggest-models with invalid use case should fail."""
        result = runner.invoke(main, ["suggest-models", "--use-case", "invalid"])
        assert result.exit_code != 0


# ===========================================================================
# setup command
# ===========================================================================


class TestSetupCommand:
    """E2E tests for `llmhosts setup`."""

    @pytest.mark.e2e
    def test_setup_help(self, runner: CliRunner) -> None:
        """setup --help should show usage."""
        result = runner.invoke(main, ["setup", "--help"])
        assert result.exit_code == 0
        assert "wizard" in result.output.lower() or "setup" in result.output.lower()

    @pytest.mark.e2e
    def test_setup_interactive_abort(self, runner: CliRunner, isolated_home: Path) -> None:
        """setup should start the wizard and accept input for the first question."""
        # Simulate user answering first question (spending range) then EOF
        # The setup wizard uses Rich Prompt.ask which reads from stdin
        result = runner.invoke(main, ["setup"], input="1\nopenai\ncoding\nn\n")
        # It may fail partway (hardware detection etc.) but shouldn't crash
        assert result.exception is None or isinstance(result.exception, (SystemExit, EOFError))


# ===========================================================================
# discover command
# ===========================================================================


class TestDiscoverCommand:
    """E2E tests for `llmhosts discover`."""

    @pytest.mark.e2e
    def test_discover_help(self, runner: CliRunner) -> None:
        """discover --help should show usage."""
        result = runner.invoke(main, ["discover", "--help"])
        assert result.exit_code == 0
        assert "--timeout" in result.output
        assert "--json-output" in result.output

    @pytest.mark.e2e
    def test_discover_missing_dependency(self, runner: CliRunner) -> None:
        """discover should handle missing zeroconf gracefully."""
        # MDNSDiscovery and scan_lan are imported inside the discover function,
        # so we patch them at the source module level.
        with patch("llmhosts.discovery.mdns.MDNSDiscovery") as mock_cls:
            mock_instance = MagicMock()
            mock_instance.is_available = False
            mock_cls.return_value = mock_instance

            result = runner.invoke(main, ["discover"])
            # Should exit 1 with a helpful message about the missing dependency
            assert result.exit_code == 1
            assert "dependency" in result.output.lower() or "zeroconf" in result.output.lower()

    @pytest.mark.e2e
    def test_discover_no_nodes_found(self, runner: CliRunner) -> None:
        """discover should handle empty scan results gracefully."""
        with patch("llmhosts.discovery.mdns.MDNSDiscovery") as mock_cls:
            mock_instance = MagicMock()
            mock_instance.is_available = True
            mock_cls.return_value = mock_instance

            with patch("llmhosts.discovery.mdns.scan_lan", new_callable=AsyncMock, return_value=[]):
                result = runner.invoke(main, ["discover"])
                # The command should handle no results gracefully
                # Exit code 0 with informative message
                assert result.exit_code == 0
                output = result.output.lower()
                assert "no llmhosts" in output or "not found" in output or "scanning" in output


# ===========================================================================
# config command
# ===========================================================================


class TestConfigCommand:
    """E2E tests for `llmhosts config`."""

    @pytest.mark.e2e
    def test_config_help(self, runner: CliRunner) -> None:
        """config --help should show subcommands."""
        result = runner.invoke(main, ["config", "--help"])
        assert result.exit_code == 0
        assert "set" in result.output

    @pytest.mark.e2e
    def test_config_set_help(self, runner: CliRunner) -> None:
        """config set --help should show usage."""
        result = runner.invoke(main, ["config", "set", "--help"])
        assert result.exit_code == 0
        assert "engine" in result.output

    @pytest.mark.e2e
    def test_config_set_engine_local(self, runner: CliRunner, isolated_home: Path, isolated_config: Path) -> None:
        """config set engine local should persist the value."""
        result = runner.invoke(main, ["config", "set", "engine", "local"])
        assert result.exit_code == 0
        assert "local" in result.output.lower()

    @pytest.mark.e2e
    def test_config_set_engine_vllm(self, runner: CliRunner, isolated_home: Path, isolated_config: Path) -> None:
        """config set engine vllm should persist the value."""
        result = runner.invoke(main, ["config", "set", "engine", "vllm"])
        assert result.exit_code == 0
        assert "vllm" in result.output.lower()

    @pytest.mark.e2e
    def test_config_set_engine_auto(self, runner: CliRunner, isolated_home: Path, isolated_config: Path) -> None:
        """config set engine auto should persist the value."""
        result = runner.invoke(main, ["config", "set", "engine", "auto"])
        assert result.exit_code == 0
        assert "auto" in result.output.lower()

    @pytest.mark.e2e
    def test_config_set_invalid_key(self, runner: CliRunner) -> None:
        """config set with an invalid key should fail."""
        result = runner.invoke(main, ["config", "set", "invalid_key", "value"])
        assert result.exit_code != 0

    @pytest.mark.e2e
    def test_config_set_invalid_value(self, runner: CliRunner) -> None:
        """config set engine with an invalid value should fail."""
        result = runner.invoke(main, ["config", "set", "engine", "invalid_engine"])
        assert result.exit_code != 0


# ===========================================================================
# engine command
# ===========================================================================


class TestEngineCommand:
    """E2E tests for `llmhosts engine`."""

    @pytest.mark.e2e
    def test_engine_help(self, runner: CliRunner) -> None:
        """engine --help should show subcommands."""
        result = runner.invoke(main, ["engine", "--help"])
        assert result.exit_code == 0
        assert "list" in result.output
        assert "set" in result.output
        assert "compare" in result.output

    @pytest.mark.e2e
    def test_engine_list_runs(self, runner: CliRunner) -> None:
        """engine list should complete without crashing."""
        result = runner.invoke(main, ["engine", "list"])
        assert result.exit_code == 0
        assert "ollama" in result.output.lower()
        assert "vllm" in result.output.lower()

    @pytest.mark.e2e
    def test_engine_set_help(self, runner: CliRunner) -> None:
        """engine set --help should show usage."""
        result = runner.invoke(main, ["engine", "set", "--help"])
        assert result.exit_code == 0

    @pytest.mark.e2e
    def test_engine_set_local(self, runner: CliRunner, isolated_home: Path, isolated_config: Path) -> None:
        """engine set local should persist."""
        result = runner.invoke(main, ["engine", "set", "local"])
        assert result.exit_code == 0

    @pytest.mark.e2e
    def test_engine_set_invalid(self, runner: CliRunner) -> None:
        """engine set with invalid engine should fail."""
        result = runner.invoke(main, ["engine", "set", "invalid_engine"])
        assert result.exit_code != 0

    @pytest.mark.e2e
    def test_engine_compare_runs(self, runner: CliRunner) -> None:
        """engine compare should complete (even when engines unavailable)."""
        result = runner.invoke(main, ["engine", "compare", "llama3.2"])
        assert result.exit_code == 0
        assert "Local Engine" in result.output
        assert "vLLM" in result.output


# ===========================================================================
# monitor command
# ===========================================================================


class TestMonitorCommand:
    """E2E tests for `llmhosts monitor`."""

    @pytest.mark.e2e
    def test_monitor_help(self, runner: CliRunner) -> None:
        """monitor --help should show usage."""
        result = runner.invoke(main, ["monitor", "--help"])
        assert result.exit_code == 0
        assert "--interval" in result.output
        assert "--json" in result.output

    @pytest.mark.e2e
    def test_monitor_json_snapshot(self, runner: CliRunner) -> None:
        """monitor --json should produce a single JSON snapshot."""
        result = runner.invoke(main, ["monitor", "--json"])
        assert result.exit_code == 0
        # Should be valid JSON
        parsed = json.loads(result.output)
        assert isinstance(parsed, dict)
        # Should contain expected resource keys
        assert "cpu_percent" in parsed or "memory_mb" in parsed


# ===========================================================================
# telemetry command
# ===========================================================================


class TestTelemetryCommand:
    """E2E tests for `llmhosts telemetry`."""

    @pytest.mark.e2e
    def test_telemetry_help(self, runner: CliRunner) -> None:
        """telemetry --help should show usage."""
        result = runner.invoke(main, ["telemetry", "--help"])
        assert result.exit_code == 0
        assert "--status" in result.output
        assert "--recent" in result.output

    @pytest.mark.e2e
    def test_telemetry_status(self, runner: CliRunner) -> None:
        """telemetry --status should report telemetry status."""
        result = runner.invoke(main, ["telemetry", "--status"])
        assert result.exit_code == 0
        output = result.output.lower()
        assert "telemetry" in output


# ===========================================================================
# cache command
# ===========================================================================


class TestCacheCommand:
    """E2E tests for `llmhosts cache`."""

    @pytest.mark.e2e
    def test_cache_help(self, runner: CliRunner) -> None:
        """cache --help should show subcommands."""
        result = runner.invoke(main, ["cache", "--help"])
        assert result.exit_code == 0
        assert "stats" in result.output
        assert "clear" in result.output

    @pytest.mark.e2e
    def test_cache_stats_runs(self, runner: CliRunner, isolated_home: Path) -> None:
        """cache stats should run without crashing."""
        result = runner.invoke(main, ["cache", "stats"])
        assert result.exit_code == 0
        output = result.output.lower()
        assert "cache" in output

    @pytest.mark.e2e
    def test_cache_clear_help(self, runner: CliRunner) -> None:
        """cache clear --help should show options."""
        result = runner.invoke(main, ["cache", "clear", "--help"])
        assert result.exit_code == 0
        assert "--tier" in result.output
        assert "--namespace" in result.output

    @pytest.mark.e2e
    def test_cache_clear_all(self, runner: CliRunner, isolated_home: Path) -> None:
        """cache clear should run without crashing."""
        result = runner.invoke(main, ["cache", "clear"])
        assert result.exit_code == 0
        assert "cleared" in result.output.lower() or "0" in result.output


# ===========================================================================
# team command
# ===========================================================================


class TestTeamCommand:
    """E2E tests for `llmhosts team`."""

    @pytest.mark.e2e
    def test_team_help(self, runner: CliRunner) -> None:
        """team --help should show subcommands."""
        result = runner.invoke(main, ["team", "--help"])
        assert result.exit_code == 0
        assert "create" in result.output
        assert "add" in result.output
        assert "list" in result.output
        assert "remove" in result.output

    @pytest.mark.e2e
    def test_team_create_and_list(self, runner: CliRunner, isolated_home: Path) -> None:
        """Creating a team should succeed and appear in list."""
        result = runner.invoke(main, ["team", "create", "Test Team", "--email", "test@example.com"])
        assert result.exit_code == 0
        assert "Test Team" in result.output

        result = runner.invoke(main, ["team", "list"])
        assert result.exit_code == 0
        assert "test@example.com" in result.output

    @pytest.mark.e2e
    def test_team_list_no_team(self, runner: CliRunner, isolated_home: Path) -> None:
        """team list with no team should show informative message."""
        result = runner.invoke(main, ["team", "list"])
        assert result.exit_code in (0, 1)
        output = result.output.lower()
        assert "no team" in output or "create" in output or "error" in output

    @pytest.mark.e2e
    def test_team_alerts_help(self, runner: CliRunner) -> None:
        """team alerts --help should show usage."""
        result = runner.invoke(main, ["team", "alerts", "--help"])
        assert result.exit_code == 0


# ===========================================================================
# device command
# ===========================================================================


class TestDeviceCommand:
    """E2E tests for `llmhosts device`."""

    @pytest.mark.e2e
    def test_device_help(self, runner: CliRunner) -> None:
        """device --help should show subcommands."""
        result = runner.invoke(main, ["device", "--help"])
        assert result.exit_code == 0
        assert "add" in result.output
        assert "list" in result.output
        assert "remove" in result.output
        assert "status" in result.output

    @pytest.mark.e2e
    def test_device_add_and_list(self, runner: CliRunner, isolated_home: Path) -> None:
        """Adding a device should succeed and appear in list."""
        result = runner.invoke(main, ["device", "add", "test-gpu-box"])
        assert result.exit_code == 0
        assert "test-gpu-box" in result.output

        result = runner.invoke(main, ["device", "list"])
        assert result.exit_code == 0
        assert "test-gpu-box" in result.output

    @pytest.mark.e2e
    def test_device_add_with_role(self, runner: CliRunner, isolated_home: Path) -> None:
        """Adding a device with --role should set the role."""
        result = runner.invoke(main, ["device", "add", "client-laptop", "--role", "client"])
        assert result.exit_code == 0
        assert "client" in result.output.lower()

    @pytest.mark.e2e
    def test_device_remove(self, runner: CliRunner, isolated_home: Path) -> None:
        """Removing a device should succeed."""
        runner.invoke(main, ["device", "add", "temp-device"])
        result = runner.invoke(main, ["device", "remove", "temp-device"])
        assert result.exit_code == 0
        assert "removed" in result.output.lower() or "temp-device" in result.output

    @pytest.mark.e2e
    def test_device_remove_nonexistent(self, runner: CliRunner, isolated_home: Path) -> None:
        """Removing a nonexistent device should fail."""
        result = runner.invoke(main, ["device", "remove", "nonexistent-device"])
        assert result.exit_code != 0

    @pytest.mark.e2e
    def test_device_list_empty(self, runner: CliRunner, isolated_home: Path) -> None:
        """device list with no devices should show informative message."""
        result = runner.invoke(main, ["device", "list"])
        assert result.exit_code == 0
        output = result.output.lower()
        assert "no device" in output or "add" in output


# ===========================================================================
# hive command
# ===========================================================================


class TestHiveCommand:
    """E2E tests for `llmhosts hive`."""

    @pytest.mark.e2e
    def test_hive_help(self, runner: CliRunner) -> None:
        """hive --help should show subcommands."""
        result = runner.invoke(main, ["hive", "--help"])
        assert result.exit_code == 0
        assert "create" in result.output
        assert "join" in result.output

    @pytest.mark.e2e
    def test_hive_create(self, runner: CliRunner, isolated_home: Path) -> None:
        """hive create should create a Hive and show invite code."""
        # Need to monkeypatch llmhosts_dir for hive create
        with patch("llmhosts.config.llmhosts_dir", return_value=isolated_home / ".llmhosts"):
            result = runner.invoke(main, ["hive", "create", "Test Hive", "--owner-name", "Alice"])
            assert result.exit_code == 0
            assert "Created Hive" in result.output or "Test Hive" in result.output


# ===========================================================================
# audit command
# ===========================================================================


class TestAuditCommand:
    """E2E tests for `llmhosts audit`."""

    @pytest.mark.e2e
    def test_audit_help(self, runner: CliRunner) -> None:
        """audit --help should show subcommands."""
        result = runner.invoke(main, ["audit", "--help"])
        assert result.exit_code == 0
        assert "export" in result.output
        assert "verify" in result.output

    @pytest.mark.e2e
    def test_audit_export_help(self, runner: CliRunner) -> None:
        """audit export --help should show options."""
        result = runner.invoke(main, ["audit", "export", "--help"])
        assert result.exit_code == 0
        assert "--format" in result.output
        assert "--since" in result.output
        assert "--output" in result.output


# ===========================================================================
# alerts command
# ===========================================================================


class TestAlertsCommand:
    """E2E tests for `llmhosts alerts`."""

    @pytest.mark.e2e
    def test_alerts_help(self, runner: CliRunner) -> None:
        """alerts --help should show subcommands."""
        result = runner.invoke(main, ["alerts", "--help"])
        assert result.exit_code == 0
        assert "list" in result.output
        assert "test" in result.output

    @pytest.mark.e2e
    def test_alerts_list_runs(self, runner: CliRunner, isolated_home: Path) -> None:
        """alerts list should run without crashing."""
        result = runner.invoke(main, ["alerts", "list"])
        # May show "disabled" or rules table
        assert result.exit_code == 0
        output = result.output.lower()
        assert "alert" in output or "disabled" in output or "no" in output


# ===========================================================================
# db command
# ===========================================================================


class TestDbCommand:
    """E2E tests for `llmhosts db`."""

    @pytest.mark.e2e
    def test_db_help(self, runner: CliRunner) -> None:
        """db --help should show migration subcommands."""
        result = runner.invoke(main, ["db", "--help"])
        assert result.exit_code == 0
        assert "upgrade" in result.output
        assert "downgrade" in result.output
        assert "current" in result.output
        assert "history" in result.output


# ===========================================================================
# uninstall command
# ===========================================================================


class TestUninstallCommand:
    """E2E tests for `llmhosts uninstall`."""

    @pytest.mark.e2e
    def test_uninstall_help(self, runner: CliRunner) -> None:
        """uninstall --help should show options."""
        result = runner.invoke(main, ["uninstall", "--help"])
        assert result.exit_code == 0
        assert "--keep-data" in result.output
        assert "--remove-engine" in result.output
        assert "--yes" in result.output

    @pytest.mark.e2e
    def test_uninstall_nothing_to_remove(
        self, runner: CliRunner, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        """uninstall on a clean system should say nothing to remove."""
        # Use a bare tmp_path with no .llmhosts directory so nothing is detected
        monkeypatch.setenv("HOME", str(tmp_path))
        monkeypatch.setattr("shutil.which", lambda cmd: None)
        result = runner.invoke(main, ["uninstall", "-y"])
        assert result.exit_code == 0
        assert "Nothing to remove" in result.output

    @pytest.mark.e2e
    def test_uninstall_abort(self, runner: CliRunner, isolated_home: Path) -> None:
        """uninstall should abort when user says no."""
        data_dir = isolated_home / ".llmhosts"
        data_dir.mkdir(parents=True, exist_ok=True)
        (data_dir / "config.toml").write_text("test = true")
        (data_dir / "keys.db").write_bytes(b"\x00" * 512)

        result = runner.invoke(main, ["uninstall"], input="n\n")
        assert result.exit_code == 0
        # Data dir should still exist
        assert data_dir.exists()


# ===========================================================================
# train-router command
# ===========================================================================


class TestTrainRouterCommand:
    """E2E tests for `llmhosts train-router`."""

    @pytest.mark.e2e
    def test_train_router_help(self, runner: CliRunner) -> None:
        """train-router --help should show usage."""
        result = runner.invoke(main, ["train-router", "--help"])
        assert result.exit_code == 0
        assert "--dataset" in result.output or "--n-samples" in result.output or "train" in result.output.lower()


# ===========================================================================
# update-weights command
# ===========================================================================


class TestUpdateWeightsCommand:
    """E2E tests for `llmhosts update-weights`."""

    @pytest.mark.e2e
    def test_update_weights_help(self, runner: CliRunner) -> None:
        """update-weights --help should show usage."""
        result = runner.invoke(main, ["update-weights", "--help"])
        assert result.exit_code == 0
        assert "weight" in result.output.lower() or "registry" in result.output.lower()


# ===========================================================================
# up command
# ===========================================================================


class TestUpCommand:
    """E2E tests for `llmhosts up`."""

    @pytest.mark.e2e
    def test_up_help(self, runner: CliRunner) -> None:
        """up --help should show usage."""
        result = runner.invoke(main, ["up", "--help"])
        assert result.exit_code == 0
        assert "--port" in result.output
        assert "--server" in result.output
        assert "--name" in result.output

    @pytest.mark.e2e
    def test_up_invalid_port(self, runner: CliRunner) -> None:
        """up with an invalid port type should fail."""
        result = runner.invoke(main, ["up", "--port", "not-a-number"])
        assert result.exit_code != 0


# ===========================================================================
# Root CLI tests
# ===========================================================================


class TestRootCLI:
    """E2E tests for root-level CLI behavior."""

    @pytest.mark.e2e
    def test_version(self, runner: CliRunner) -> None:
        """--version should print the version."""
        from llmhosts import __version__

        result = runner.invoke(main, ["--version"])
        assert result.exit_code == 0
        assert __version__ in result.output

    @pytest.mark.e2e
    def test_help(self, runner: CliRunner) -> None:
        """--help should list all available commands."""
        result = runner.invoke(main, ["--help"])
        assert result.exit_code == 0
        # Check key commands are listed
        for cmd in ["serve", "doctor", "keys", "tunnel", "setup", "discover", "config", "monitor"]:
            assert cmd in result.output

    @pytest.mark.e2e
    def test_no_command_shows_help(self, runner: CliRunner) -> None:
        """Invoking with no args should show help."""
        result = runner.invoke(main, [])
        assert result.exit_code == 0
        assert "LLMHosts" in result.output

    @pytest.mark.e2e
    def test_invalid_command(self, runner: CliRunner) -> None:
        """Invoking with an unknown command should fail."""
        result = runner.invoke(main, ["nonexistent-command"])
        assert result.exit_code != 0

    @pytest.mark.e2e
    def test_debug_flag_accepted(self, runner: CliRunner) -> None:
        """--debug flag should be accepted on root."""
        result = runner.invoke(main, ["--debug", "--help"])
        assert result.exit_code == 0


# ===========================================================================
# Cross-command integration tests
# ===========================================================================


class TestCrossCommandIntegration:
    """Integration tests that exercise multiple CLI commands in sequence."""

    @pytest.mark.e2e
    def test_keys_lifecycle(self, runner: CliRunner, isolated_home: Path) -> None:
        """Full lifecycle: add -> list -> remove -> list (empty)."""
        # Add
        result = runner.invoke(main, ["keys", "add", "deepseek", "sk-deep-test-1234567890"])
        assert result.exit_code == 0

        # List - should contain deepseek
        result = runner.invoke(main, ["keys", "list"])
        assert result.exit_code == 0
        assert "deepseek" in result.output.lower()

        # Remove
        result = runner.invoke(main, ["keys", "remove", "deepseek"])
        assert result.exit_code == 0

        # List again - should be empty or not contain deepseek
        result = runner.invoke(main, ["keys", "list"])
        assert result.exit_code == 0
        output = result.output.lower()
        assert "no api keys" in output or "deepseek" not in output

    @pytest.mark.e2e
    def test_device_lifecycle(self, runner: CliRunner, isolated_home: Path) -> None:
        """Full lifecycle: add -> list -> remove -> list (empty)."""
        # Add
        result = runner.invoke(main, ["device", "add", "lifecycle-test-box"])
        assert result.exit_code == 0

        # List
        result = runner.invoke(main, ["device", "list"])
        assert result.exit_code == 0
        assert "lifecycle-test-box" in result.output

        # Remove
        result = runner.invoke(main, ["device", "remove", "lifecycle-test-box"])
        assert result.exit_code == 0

        # List again
        result = runner.invoke(main, ["device", "list"])
        assert result.exit_code == 0
        assert "lifecycle-test-box" not in result.output

    @pytest.mark.e2e
    def test_config_then_engine_set(self, runner: CliRunner, isolated_home: Path, isolated_config: Path) -> None:
        """Setting engine via config set and engine set should both work."""
        # Via config set
        result = runner.invoke(main, ["config", "set", "engine", "vllm"])
        assert result.exit_code == 0

        # Read back via engine set (different path to same config)
        result = runner.invoke(main, ["engine", "set", "local"])
        assert result.exit_code == 0

    @pytest.mark.e2e
    def test_multiple_key_providers(self, runner: CliRunner, isolated_home: Path) -> None:
        """Adding keys for all supported providers should work."""
        providers_keys = [
            ("openai", "sk-openai-test-aaaa1111"),
            ("anthropic", "sk-ant-test-bbbb2222"),
            ("groq", "gsk-groq-test-cccc3333"),
            ("mistral", "mk-mistral-test-dddd4444"),
            ("together", "tg-together-test-eeee5555"),
            ("deepseek", "sk-deepseek-test-ffff6666"),
            ("google", "AIzaGoogle-test-gggg7777"),
            ("openrouter", "sk-or-test-hhhh8888"),
        ]

        for provider, key in providers_keys:
            result = runner.invoke(main, ["keys", "add", provider, key])
            assert result.exit_code == 0, f"Failed to add key for {provider}: {result.output}"

        # List all
        result = runner.invoke(main, ["keys", "list"])
        assert result.exit_code == 0
        for provider, _ in providers_keys:
            assert provider in result.output.lower(), f"Provider {provider} not found in list output"
