"""Tests for specter_cli.api — programmatic GPU provisioning API."""

from __future__ import annotations

import os
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

from specter_cli.api import (
    AuthError,
    ProvisionError,
    SpecterError,
    _print_status,
    gpu,
    install,
    status,
    uninstall,
)

_LOAD_CONFIG = "specter_cli.config.load_remote_config"
_HAS_ACTIVE = "specter_cli.config.has_active_session"
_SAVE_CONFIG = "specter_cli.config.save_remote_config"
_CLEAR_CONFIG = "specter_cli.config.clear_remote_config"
_REGISTER = "specter_cli.config.register_session"
_DEREGISTER = "specter_cli.config.deregister_session"
_GET_TOKEN = "specter_cli.api._get_token"
_GET_SSH_KEY = "specter_cli.api._get_ssh_public_key"

_SAMPLE_SESSION = {
    "session_id": "s_test123",
    "status": "ready",
    "ssh": {"host": "1.2.3.4", "user": "root", "port": 22},
}

_SAMPLE_CONFIG = {
    "session_id": "s_test123",
    "gpu_type": "h100",
    "host": "1.2.3.4",
    "user": "root",
    "ssh_port": 22,
    "venv_path": "/tmp/test-venv",
}


# ── install() ─────────────────────────────────────────────────────────


class TestInstall:
    """specter.install() — provision a GPU or no-op if already active."""

    @patch(_REGISTER)
    @patch(_SAVE_CONFIG)
    @patch("specter_cli.deps.sync_deps")
    @patch("specter_cli.venv.patch_venv")
    @patch("specter_cli.deps.create_remote_venv")
    @patch("specter_cli.broker_client.BrokerClient.get_session", return_value=_SAMPLE_SESSION)
    @patch("specter_cli.broker_client.BrokerClient.provision", return_value=_SAMPLE_SESSION)
    @patch("specter_cli.broker_client.BrokerClient.close")
    @patch(_GET_SSH_KEY, return_value="ssh-ed25519 AAAA...")
    @patch(_GET_TOKEN, return_value="tok_test")
    @patch(_HAS_ACTIVE, return_value=False)
    def test_fresh_install(
        self,
        _has_active: MagicMock,
        _token: MagicMock,
        _ssh_key: MagicMock,
        _close: MagicMock,
        _provision: MagicMock,
        _get_session: MagicMock,
        _create_venv: MagicMock,
        _patch_venv: MagicMock,
        _sync_deps: MagicMock,
        _save_config: MagicMock,
        _register: MagicMock,
    ) -> None:
        """Fresh install provisions, patches venv, saves config, returns."""
        with patch.dict(os.environ, {"VIRTUAL_ENV": "/tmp/test-venv"}):
            result = install("h100")

        _provision.assert_called_once()
        _create_venv.assert_called_once()
        _patch_venv.assert_called_once_with(Path("/tmp/test-venv"), "h100")
        _save_config.assert_called_once()
        _register.assert_called_once()
        assert result["gpu_type"] == "h100"
        assert result["session_id"] == "s_test123"

    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    @patch(_HAS_ACTIVE, return_value=True)
    def test_idempotent_same_gpu(self, _has_active: MagicMock, _load_config: MagicMock) -> None:
        """install() with same GPU type already active returns existing config."""
        with patch.dict(os.environ, {"VIRTUAL_ENV": "/tmp/test-venv"}):
            result = install("h100")

        assert result == _SAMPLE_CONFIG

    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    @patch(_HAS_ACTIVE, return_value=True)
    def test_different_gpu_raises(self, _has_active: MagicMock, _load_config: MagicMock) -> None:
        """install() with different GPU type raises SpecterError."""
        with (
            patch.dict(os.environ, {"VIRTUAL_ENV": "/tmp/test-venv"}),
            pytest.raises(SpecterError, match="Active session uses h100"),
        ):
            install("a100")

    @patch(_HAS_ACTIVE, return_value=False)
    def test_no_auth_raises(self, _has_active: MagicMock) -> None:
        """install() without auth raises AuthError."""
        with (
            patch.dict(os.environ, {"VIRTUAL_ENV": "/tmp/test-venv"}, clear=False),
            patch("specter_cli.api._get_token", side_effect=AuthError("Not authenticated")),
            pytest.raises(AuthError),
        ):
            install("h100")

    @patch(_GET_SSH_KEY, return_value="ssh-ed25519 AAAA...")
    @patch(_GET_TOKEN, return_value="tok_test")
    @patch(_HAS_ACTIVE, return_value=False)
    def test_provision_error_propagates(
        self,
        _has_active: MagicMock,
        _token: MagicMock,
        _ssh_key: MagicMock,
    ) -> None:
        """Broker provision failure raises ProvisionError."""
        from specter_cli.broker_client import BrokerError

        with (
            patch.dict(os.environ, {"VIRTUAL_ENV": "/tmp/test-venv"}, clear=False),
            patch(
                "specter_cli.broker_client.BrokerClient.provision",
                side_effect=BrokerError(400, "invalid_gpu_type", "not available"),
            ),
            patch("specter_cli.broker_client.BrokerClient.close"),
            pytest.raises(ProvisionError, match="not available"),
        ):
            install("invalid_gpu")

    @patch(_REGISTER)
    @patch(_SAVE_CONFIG)
    @patch("specter_cli.deps.create_remote_venv")
    @patch("specter_cli.broker_client.BrokerClient.get_session", return_value=_SAMPLE_SESSION)
    @patch("specter_cli.broker_client.BrokerClient.provision", return_value=_SAMPLE_SESSION)
    @patch("specter_cli.broker_client.BrokerClient.close")
    @patch(_GET_SSH_KEY, return_value="ssh-ed25519 AAAA...")
    @patch(_GET_TOKEN, return_value="tok_test")
    @patch(_HAS_ACTIVE, return_value=False)
    def test_global_install_no_venv_patch(
        self,
        _has_active: MagicMock,
        _token: MagicMock,
        _ssh_key: MagicMock,
        _close: MagicMock,
        _provision: MagicMock,
        _get_session: MagicMock,
        _create_venv: MagicMock,
        _save_config: MagicMock,
        _register: MagicMock,
    ) -> None:
        """Without VIRTUAL_ENV, install uses global scope, no venv patch."""
        env = {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"}
        with patch.dict(os.environ, env, clear=True):
            result = install("h100")

        assert result["gpu_type"] == "h100"
        # Should not call register_session (global scope).
        _register.assert_not_called()

    @patch("specter_cli.broker_client.BrokerClient.get_session")
    @patch("specter_cli.broker_client.BrokerClient.provision", return_value=_SAMPLE_SESSION)
    @patch("specter_cli.broker_client.BrokerClient.close")
    @patch(_GET_SSH_KEY, return_value="ssh-ed25519 AAAA...")
    @patch(_GET_TOKEN, return_value="tok_test")
    @patch(_HAS_ACTIVE, return_value=False)
    def test_provision_error_state(
        self,
        _has_active: MagicMock,
        _token: MagicMock,
        _ssh_key: MagicMock,
        _close: MagicMock,
        _provision: MagicMock,
        mock_get_session: MagicMock,
    ) -> None:
        """Session entering 'error' state raises ProvisionError."""
        mock_get_session.return_value = {
            "session_id": "s_test123",
            "status": "error",
            "error_message": "out of capacity",
        }
        with (
            patch.dict(os.environ, {"VIRTUAL_ENV": "/tmp/test-venv"}, clear=False),
            pytest.raises(ProvisionError, match="out of capacity"),
        ):
            install("h100")

    @patch("specter_cli.broker_client.BrokerClient.get_session")
    @patch("specter_cli.broker_client.BrokerClient.provision", return_value=_SAMPLE_SESSION)
    @patch("specter_cli.broker_client.BrokerClient.close")
    @patch(_GET_SSH_KEY, return_value="ssh-ed25519 AAAA...")
    @patch(_GET_TOKEN, return_value="tok_test")
    @patch(_HAS_ACTIVE, return_value=False)
    @patch("specter_cli.api.time")
    def test_provision_timeout(
        self,
        mock_time: MagicMock,
        _has_active: MagicMock,
        _token: MagicMock,
        _ssh_key: MagicMock,
        _close: MagicMock,
        _provision: MagicMock,
        mock_get_session: MagicMock,
    ) -> None:
        """Polling times out after deadline, raises ProvisionError."""
        mock_get_session.return_value = {
            "session_id": "s_test123",
            "status": "provisioning",
        }
        # Simulate time passing: first call returns start, second returns past deadline.
        mock_time.monotonic.side_effect = [0.0, 601.0]
        mock_time.sleep = MagicMock()

        with (
            patch.dict(os.environ, {"VIRTUAL_ENV": "/tmp/test-venv"}, clear=False),
            pytest.raises(ProvisionError, match="Timed out"),
        ):
            install("h100")


# ── uninstall() ───────────────────────────────────────────────────────


class TestUninstall:
    """specter.uninstall() — terminate session and restore venv."""

    @patch(_CLEAR_CONFIG)
    @patch(_DEREGISTER)
    @patch("specter_cli.venv.restore_venv")
    @patch("specter_cli.broker_client.BrokerClient.destroy_session")
    @patch("specter_cli.broker_client.BrokerClient.close")
    @patch(_GET_TOKEN, return_value="tok_test")
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_uninstall_venv(
        self,
        _load: MagicMock,
        _token: MagicMock,
        _close: MagicMock,
        mock_destroy: MagicMock,
        mock_restore: MagicMock,
        mock_deregister: MagicMock,
        mock_clear: MagicMock,
    ) -> None:
        """Uninstall in venv scope destroys session and restores venv."""
        with patch.dict(os.environ, {"VIRTUAL_ENV": "/tmp/test-venv"}):
            uninstall()

        mock_destroy.assert_called_once_with("s_test123")
        mock_restore.assert_called_once_with(Path("/tmp/test-venv"))
        mock_deregister.assert_called_once_with("/tmp/test-venv")
        mock_clear.assert_called_once()

    @patch(_LOAD_CONFIG, return_value={})
    def test_no_session_raises(self, _load: MagicMock) -> None:
        """Uninstall with no active session raises SpecterError."""
        with (
            patch.dict(os.environ, {"VIRTUAL_ENV": "/tmp/test-venv"}),
            pytest.raises(SpecterError, match="No active GPU session"),
        ):
            uninstall()

    @patch(_LOAD_CONFIG, return_value=None)
    def test_none_config_raises(self, _load: MagicMock) -> None:
        """Uninstall with None config raises SpecterError."""
        with (
            patch.dict(os.environ, {"VIRTUAL_ENV": "/tmp/test-venv"}),
            pytest.raises(SpecterError, match="No active GPU session"),
        ):
            uninstall()

    @patch(_CLEAR_CONFIG)
    @patch("specter_cli.broker_client.BrokerClient.destroy_session")
    @patch("specter_cli.broker_client.BrokerClient.close")
    @patch(_GET_TOKEN, return_value="tok_test")
    @patch(
        _LOAD_CONFIG,
        return_value={
            "session_id": "s_global1",
            "gpu_type": "h100",
            "host": "1.2.3.4",
            "user": "root",
        },
    )
    def test_uninstall_global(
        self,
        _load: MagicMock,
        _token: MagicMock,
        _close: MagicMock,
        mock_destroy: MagicMock,
        mock_clear: MagicMock,
    ) -> None:
        """Uninstall in global scope destroys session without venv restore."""
        env = {k: v for k, v in os.environ.items() if k != "VIRTUAL_ENV"}
        with patch.dict(os.environ, env, clear=True):
            uninstall()

        mock_destroy.assert_called_once_with("s_global1")
        mock_clear.assert_called_once()

    @patch("specter_cli.broker_client.BrokerClient.close")
    @patch(_GET_TOKEN, return_value="tok_test")
    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_uninstall_no_auth_raises(
        self,
        _load: MagicMock,
        _token: MagicMock,
        _close: MagicMock,
    ) -> None:
        """Uninstall raises AuthError when not authenticated."""
        with (
            patch.dict(os.environ, {"VIRTUAL_ENV": "/tmp/test-venv"}),
            patch("specter_cli.api._get_token", side_effect=AuthError("Not authenticated")),
            pytest.raises(AuthError),
        ):
            uninstall()


# ── status() ──────────────────────────────────────────────────────────


class TestStatus:
    """specter.status() — returns session info or None."""

    @patch(_LOAD_CONFIG, return_value=_SAMPLE_CONFIG)
    def test_active_session(self, _load: MagicMock) -> None:
        """Returns session dict when active."""
        with patch.dict(os.environ, {"VIRTUAL_ENV": "/tmp/test-venv"}):
            result = status()

        assert result is not None
        assert result["session_id"] == "s_test123"
        assert result["gpu_type"] == "h100"
        assert result["host"] == "1.2.3.4"

    @patch(_LOAD_CONFIG, return_value={})
    def test_no_session(self, _load: MagicMock) -> None:
        """Returns None when no session."""
        with patch.dict(os.environ, {"VIRTUAL_ENV": "/tmp/test-venv"}):
            result = status()

        assert result is None

    @patch(_LOAD_CONFIG, return_value=None)
    def test_none_config(self, _load: MagicMock) -> None:
        """Returns None when config is None."""
        with patch.dict(os.environ, {"VIRTUAL_ENV": "/tmp/test-venv"}):
            result = status()

        assert result is None

    @patch(_LOAD_CONFIG, return_value={"gpu_type": "h100"})
    def test_config_without_session_id(self, _load: MagicMock) -> None:
        """Returns None when config exists but has no session_id."""
        with patch.dict(os.environ, {"VIRTUAL_ENV": "/tmp/test-venv"}):
            result = status()

        assert result is None


# ── gpu() context manager ─────────────────────────────────────────────


class TestGpuContextManager:
    """specter.gpu() — context manager for auto-cleanup."""

    @patch("specter_cli.api.uninstall")
    @patch("specter_cli.api.install", return_value={"session_id": "s_ctx1", "gpu_type": "h100"})
    def test_context_manager_calls_install_and_uninstall(
        self, mock_install: MagicMock, mock_uninstall: MagicMock
    ) -> None:
        """Context manager calls install on enter and uninstall on exit."""
        with gpu("h100") as session:
            assert session["session_id"] == "s_ctx1"

        mock_install.assert_called_once_with("h100", gpu_count=1, disk_gb=20, image=None)
        mock_uninstall.assert_called_once()

    @patch("specter_cli.api.uninstall")
    @patch("specter_cli.api.install", return_value={"session_id": "s_ctx2", "gpu_type": "a100"})
    def test_context_manager_uninstalls_on_exception(
        self, mock_install: MagicMock, mock_uninstall: MagicMock
    ) -> None:
        """Context manager calls uninstall even if body raises."""
        with pytest.raises(ValueError, match="test error"), gpu("a100"):
            raise ValueError("test error")

        mock_uninstall.assert_called_once()

    @patch(
        "specter_cli.api.uninstall",
        side_effect=SpecterError("session already terminated"),
    )
    @patch("specter_cli.api.install", return_value={"session_id": "s_ctx3", "gpu_type": "h100"})
    def test_context_manager_swallows_uninstall_error(
        self, mock_install: MagicMock, mock_uninstall: MagicMock
    ) -> None:
        """Uninstall errors during cleanup are swallowed (best-effort)."""
        # Should not raise.
        with gpu("h100") as session:
            assert session["session_id"] == "s_ctx3"

    @patch("specter_cli.api.uninstall")
    @patch("specter_cli.api.install", return_value={"session_id": "s_ctx4", "gpu_type": "h100"})
    def test_context_manager_passes_kwargs(
        self, mock_install: MagicMock, mock_uninstall: MagicMock
    ) -> None:
        """Context manager forwards gpu_count, disk_gb, image to install."""
        with gpu("h100", gpu_count=2, disk_gb=50, image="pytorch/pytorch:latest"):
            pass

        mock_install.assert_called_once_with(
            "h100", gpu_count=2, disk_gb=50, image="pytorch/pytorch:latest"
        )


# ── _print_status ─────────────────────────────────────────────────────


class TestPrintStatus:
    """_print_status() — dim ANSI output."""

    def test_with_session_id(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Prints GPU type and session ID."""
        _print_status("h100", "s_abc123")
        captured = capsys.readouterr()
        assert "h100 installed" in captured.err
        assert "s_abc123" in captured.err

    def test_without_session_id(self, capsys: pytest.CaptureFixture[str]) -> None:
        """Prints GPU type without session ID."""
        _print_status("a100")
        captured = capsys.readouterr()
        assert "a100 installed" in captured.err
        assert "session" not in captured.err


# ── Exception hierarchy ───────────────────────────────────────────────


class TestExceptions:
    """Exception classes are properly structured."""

    def test_auth_error_is_specter_error(self) -> None:
        assert issubclass(AuthError, SpecterError)

    def test_provision_error_is_specter_error(self) -> None:
        assert issubclass(ProvisionError, SpecterError)

    def test_specter_error_is_exception(self) -> None:
        assert issubclass(SpecterError, Exception)


# ── __init__.py re-exports ────────────────────────────────────────────


class TestReExports:
    """specter_cli re-exports API at package level."""

    def test_install_reexported(self) -> None:
        from specter_cli import install as install_fn

        assert install_fn is install

    def test_uninstall_reexported(self) -> None:
        from specter_cli import uninstall as uninstall_fn

        assert uninstall_fn is uninstall

    def test_status_reexported(self) -> None:
        from specter_cli import status as status_fn

        assert status_fn is status

    def test_gpu_reexported(self) -> None:
        from specter_cli import gpu as gpu_fn

        assert gpu_fn is gpu

    def test_errors_reexported(self) -> None:
        import specter_cli

        assert specter_cli.AuthError is AuthError
        assert specter_cli.ProvisionError is ProvisionError
        assert specter_cli.SpecterError is SpecterError
