"""Unit tests for infra_provisioning.py — PostgreSQL provisioning tools (INFRA-01/02/03).

Tests cover:
- State persistence and loading (_save_state, _load_state)
- infra_provision_postgres: happy path, psycopg-missing ToolError
- infra_destroy_postgres: happy path, no-state ToolError, psycopg-missing ToolError
- infra_status: provisioned + connected, provisioned + no psycopg, not provisioned
- _state_path uses safe key encoding
"""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

try:
    from mcp_vm_blackbox.infra_provisioning import (
        _build_connstr,
        infra_destroy_postgres,
        infra_provision_postgres,
        infra_status,
    )
    from mcp_vm_blackbox.models import InfraState
except ImportError as exc:
    pytest.fail(f"infra_provisioning module missing or broken: {exc}")

from typing import TYPE_CHECKING

from fastmcp.exceptions import ToolError

if TYPE_CHECKING:
    from pathlib import Path

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

_VM = "Test-VM"
_SCENARIO = "test-scenario"


@pytest.fixture
def tmp_infra_dir(tmp_path: Path) -> Path:
    """Override MCP_VM_BLACKBOX_INFRA_DIR to a temp directory."""
    return tmp_path / "infra"


@pytest.fixture(autouse=True)
def patch_infra_dir(tmp_infra_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    """Patch the _INFRA_DIR constant in the module under test."""
    import mcp_vm_blackbox.infra_provisioning as mod

    monkeypatch.setattr(mod, "_INFRA_DIR", tmp_infra_dir)


def _make_state(scenario_key: str = "Test-VM/test-scenario") -> InfraState:
    return InfraState(
        scenario_key=scenario_key,
        db_endpoint="localhost:5432",
        db_name="scenario_db",
        db_user="scenario_user",
        db_password="secret",
        network_config={"pg_host": "localhost", "pg_port": "5432"},
        provisioned_at="2026-01-01T00:00:00+00:00",
    )


# ---------------------------------------------------------------------------
# State path encoding
# ---------------------------------------------------------------------------


def test_state_path_safe_encoding(tmp_infra_dir: Path) -> None:
    """Slashes in composite key are replaced with __ in the filename."""
    import mcp_vm_blackbox.infra_provisioning as mod

    path = mod._state_path("VM-Name/scenario-name")
    assert "/" not in path.name
    assert "VM-Name__scenario-name" in path.name


# ---------------------------------------------------------------------------
# Save / load / remove state
# ---------------------------------------------------------------------------


def test_save_and_load_state(tmp_infra_dir: Path) -> None:
    """_save_state writes JSON; _load_state reads it back into InfraState."""
    import mcp_vm_blackbox.infra_provisioning as mod

    state = _make_state()
    path = mod._save_state(state)

    assert path.exists()

    loaded = mod._load_state("Test-VM/test-scenario")
    assert loaded is not None
    assert loaded.db_name == "scenario_db"
    assert loaded.db_user == "scenario_user"


def test_load_state_missing(tmp_infra_dir: Path) -> None:
    """_load_state returns None when no file exists."""
    import mcp_vm_blackbox.infra_provisioning as mod

    result = mod._load_state("nonexistent/scenario")
    assert result is None


def test_remove_state(tmp_infra_dir: Path) -> None:
    """_remove_state removes the file and returns its path."""
    import mcp_vm_blackbox.infra_provisioning as mod

    state = _make_state()
    mod._save_state(state)
    path = mod._remove_state("Test-VM/test-scenario")
    assert path is not None
    assert not path.exists()


def test_remove_state_missing(tmp_infra_dir: Path) -> None:
    """_remove_state returns None when file does not exist."""
    import mcp_vm_blackbox.infra_provisioning as mod

    result = mod._remove_state("nonexistent/scenario")
    assert result is None


# ---------------------------------------------------------------------------
# _build_connstr
# ---------------------------------------------------------------------------


def test_build_connstr() -> None:
    """_build_connstr produces a valid psycopg connection string."""
    cs = _build_connstr("localhost", 5432, "user", "pass", "mydb")
    assert "host=localhost" in cs
    assert "port=5432" in cs
    assert "user=user" in cs
    assert "password=pass" in cs
    assert "dbname=mydb" in cs


# ---------------------------------------------------------------------------
# infra_provision_postgres
# ---------------------------------------------------------------------------


async def test_provision_raises_when_psycopg_missing() -> None:
    """infra_provision_postgres raises ToolError when psycopg is not installed."""
    with patch("mcp_vm_blackbox.infra_provisioning._load_psycopg") as mock_load:
        mock_load.side_effect = ToolError("psycopg is not available.")
        with pytest.raises(ToolError, match="psycopg is not available"):
            await infra_provision_postgres(_VM, _SCENARIO)


async def test_provision_happy_path(tmp_infra_dir: Path) -> None:
    """infra_provision_postgres creates DB, verifies connectivity, persists state."""
    import mcp_vm_blackbox.infra_provisioning as mod

    # Build mock psycopg module with async context manager support.
    mock_conn = AsyncMock()
    mock_conn.execute = AsyncMock()
    mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
    mock_conn.__aexit__ = AsyncMock(return_value=False)

    # fetchone for db-exists check returns None (DB doesn't exist yet).
    mock_cursor = AsyncMock()
    mock_cursor.fetchone = AsyncMock(return_value=None)
    mock_conn.execute = AsyncMock(return_value=mock_cursor)

    mock_psycopg = MagicMock()
    mock_psycopg.AsyncConnection.connect = AsyncMock(return_value=mock_conn)

    with (
        patch.object(mod, "_load_psycopg", return_value=mock_psycopg),
        patch.object(mod, "_verify_connectivity", AsyncMock(return_value=True)),
    ):
        result = await infra_provision_postgres(_VM, _SCENARIO)

    assert result.connected is True
    assert result.db_endpoint == "localhost:5432"
    assert result.scenario_key == "Test-VM/test-scenario"
    # State file was written.
    assert result.state_path is not None


async def test_provision_superuser_failure_raises() -> None:
    """infra_provision_postgres raises ToolError when the DB connection fails."""
    import mcp_vm_blackbox.infra_provisioning as mod

    mock_psycopg = MagicMock()
    mock_psycopg.AsyncConnection.connect = AsyncMock(side_effect=Exception("connection refused"))

    with (
        patch.object(mod, "_load_psycopg", return_value=mock_psycopg),
        pytest.raises(ToolError, match="PostgreSQL provisioning failed"),
    ):
        await infra_provision_postgres(_VM, _SCENARIO)


# ---------------------------------------------------------------------------
# infra_destroy_postgres
# ---------------------------------------------------------------------------


async def test_destroy_raises_when_no_state() -> None:
    """infra_destroy_postgres raises ToolError when no infra state exists."""
    with pytest.raises(ToolError, match="No provisioned infra state"):
        await infra_destroy_postgres(_VM, _SCENARIO)


async def test_destroy_happy_path(tmp_infra_dir: Path) -> None:
    """infra_destroy_postgres drops DB and user, removes state file."""
    import mcp_vm_blackbox.infra_provisioning as mod

    # Seed the state file.
    state = _make_state()
    mod._save_state(state)

    mock_conn = AsyncMock()
    mock_conn.execute = AsyncMock()
    mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
    mock_conn.__aexit__ = AsyncMock(return_value=False)

    mock_psycopg = MagicMock()
    mock_psycopg.AsyncConnection.connect = AsyncMock(return_value=mock_conn)

    with patch.object(mod, "_load_psycopg", return_value=mock_psycopg):
        result = await infra_destroy_postgres(_VM, _SCENARIO)

    assert result.destroyed is True
    assert result.scenario_key == "Test-VM/test-scenario"
    # State file removed.
    state_path = mod._state_path("Test-VM/test-scenario")
    assert not state_path.exists()


async def test_destroy_raises_when_psycopg_missing(tmp_infra_dir: Path) -> None:
    """infra_destroy_postgres raises ToolError when psycopg is unavailable."""
    import mcp_vm_blackbox.infra_provisioning as mod

    state = _make_state()
    mod._save_state(state)

    with (
        patch.object(mod, "_load_psycopg", side_effect=ToolError("psycopg missing")),
        pytest.raises(ToolError, match="psycopg missing"),
    ):
        await infra_destroy_postgres(_VM, _SCENARIO)


# ---------------------------------------------------------------------------
# infra_status
# ---------------------------------------------------------------------------


async def test_status_not_provisioned() -> None:
    """infra_status returns provisioned=False when no state file exists."""
    result = await infra_status(_VM, _SCENARIO)
    assert result.provisioned is False
    assert result.connected is None
    assert result.db_endpoint is None


async def test_status_provisioned_with_connectivity(tmp_infra_dir: Path) -> None:
    """infra_status returns provisioned=True and connected=True when DB is reachable."""
    import mcp_vm_blackbox.infra_provisioning as mod

    state = _make_state()
    mod._save_state(state)

    mock_psycopg = MagicMock()

    with (
        patch.object(mod, "_load_psycopg", return_value=mock_psycopg),
        patch.object(mod, "_verify_connectivity", AsyncMock(return_value=True)),
    ):
        result = await infra_status(_VM, _SCENARIO)

    assert result.provisioned is True
    assert result.connected is True
    assert result.db_endpoint == "localhost:5432"


async def test_status_provisioned_psycopg_missing(tmp_infra_dir: Path) -> None:
    """infra_status reports provisioned=True but connected=None when psycopg missing."""
    import mcp_vm_blackbox.infra_provisioning as mod

    state = _make_state()
    mod._save_state(state)

    with patch.object(mod, "_load_psycopg", side_effect=ToolError("psycopg missing")):
        result = await infra_status(_VM, _SCENARIO)

    assert result.provisioned is True
    assert result.connected is None
    assert result.db_endpoint == "localhost:5432"
