"""Unit tests for packer_tools.py — Packer image build and cache (PLAT-03).

Tests cover:
- Manifest load/save/find/remove helpers
- _compute_inputs_hash determinism and change detection
- packer_build_image: happy path (API call succeeds, manifest updated)
- packer_build_image: uses httpx (required dependency)
- packer_build_image: Packer API call fails raises ToolError
- packer_image_status: cached, not cached, stale detection
- packer_list_images: empty and populated cache
"""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

try:
    from mcp_vm_blackbox.models import PackerCacheEntry
    from mcp_vm_blackbox.packer_tools import (
        _compute_inputs_hash,
        _dispatch_packer_build,
        _find_entry,
        _load_manifest,
        _manifest_path,
        _remove_entry,
        _save_manifest,
        packer_build_image,
        packer_image_status,
        packer_list_images,
    )
except ImportError as exc:
    pytest.fail(f"packer_tools module missing or broken: {exc}")

from typing import TYPE_CHECKING

from fastmcp.exceptions import ToolError

if TYPE_CHECKING:
    from pathlib import Path

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


@pytest.fixture
def tmp_cache_dir(tmp_path: Path) -> Path:
    return tmp_path / "packer-cache"


@pytest.fixture(autouse=True)
def patch_cache_dir(tmp_cache_dir: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    import mcp_vm_blackbox.packer_tools as mod

    monkeypatch.setattr(mod, "_PACKER_CACHE_DIR", tmp_cache_dir)


def _make_entry(image_name: str = "test-image", platform: str = "linux") -> PackerCacheEntry:
    return PackerCacheEntry(
        image_name=image_name,
        image_path=f"/tmp/cache/{image_name}",
        platform=platform,
        built_at="2026-01-01T00:00:00+00:00",
        inputs_hash="abc123",
        template_path="/tmp/template.pkr.hcl",
    )


# ---------------------------------------------------------------------------
# Manifest helpers
# ---------------------------------------------------------------------------


def test_manifest_path(tmp_cache_dir: Path) -> None:
    """_manifest_path returns the expected manifest.json path."""
    path = _manifest_path(tmp_cache_dir)
    assert path.name == "manifest.json"
    assert path.parent == tmp_cache_dir


def test_load_manifest_empty(tmp_cache_dir: Path) -> None:
    """_load_manifest returns empty list when no manifest exists."""
    entries = _load_manifest(tmp_cache_dir)
    assert entries == []


def test_save_and_load_manifest(tmp_cache_dir: Path) -> None:
    """_save_manifest writes JSON; _load_manifest reads it back correctly."""
    entries = [_make_entry("img-a"), _make_entry("img-b", "windows")]
    _save_manifest(tmp_cache_dir, entries)
    loaded = _load_manifest(tmp_cache_dir)
    assert len(loaded) == 2
    assert loaded[0].image_name == "img-a"
    assert loaded[1].platform == "windows"


def test_find_entry_present(tmp_cache_dir: Path) -> None:
    """_find_entry returns the entry when image_name matches."""
    entries = [_make_entry("alpha"), _make_entry("beta")]
    found = _find_entry(entries, "beta")
    assert found is not None
    assert found.image_name == "beta"


def test_find_entry_absent(tmp_cache_dir: Path) -> None:
    """_find_entry returns None when image_name is not in the list."""
    entries = [_make_entry("alpha")]
    found = _find_entry(entries, "gamma")
    assert found is None


def test_remove_entry(tmp_cache_dir: Path) -> None:
    """_remove_entry removes the named entry from the list."""
    entries = [_make_entry("alpha"), _make_entry("beta")]
    remaining = _remove_entry(entries, "alpha")
    assert len(remaining) == 1
    assert remaining[0].image_name == "beta"


# ---------------------------------------------------------------------------
# _compute_inputs_hash
# ---------------------------------------------------------------------------


def test_inputs_hash_deterministic(tmp_path: Path) -> None:
    """_compute_inputs_hash produces the same hash for identical inputs."""
    tpl = tmp_path / "template.pkr.hcl"
    tpl.write_text("source = {}")
    h1 = _compute_inputs_hash(str(tpl), "linux", "my-image")
    h2 = _compute_inputs_hash(str(tpl), "linux", "my-image")
    assert h1 == h2


def test_inputs_hash_changes_on_template_update(tmp_path: Path) -> None:
    """_compute_inputs_hash changes when template content changes."""
    tpl = tmp_path / "template.pkr.hcl"
    tpl.write_text("source = {}")
    h1 = _compute_inputs_hash(str(tpl), "linux", "my-image")
    tpl.write_text("source = { changed = true }")
    h2 = _compute_inputs_hash(str(tpl), "linux", "my-image")
    assert h1 != h2


def test_inputs_hash_changes_on_platform_change(tmp_path: Path) -> None:
    """_compute_inputs_hash changes when platform changes."""
    tpl = tmp_path / "template.pkr.hcl"
    tpl.write_text("source = {}")
    h_linux = _compute_inputs_hash(str(tpl), "linux", "my-image")
    h_macos = _compute_inputs_hash(str(tpl), "macos", "my-image")
    assert h_linux != h_macos


def test_inputs_hash_missing_template() -> None:
    """_compute_inputs_hash uses the path string when file does not exist."""
    h = _compute_inputs_hash("/nonexistent/template.pkr.hcl", "linux", "img")
    assert isinstance(h, str)
    assert len(h) == 64  # sha256 hex digest


# ---------------------------------------------------------------------------
# packer_build_image
# ---------------------------------------------------------------------------


async def test_build_raises_on_api_failure() -> None:
    """packer_build_image raises ToolError when Packer HTTP API call fails."""
    import mcp_vm_blackbox.packer_tools as mod

    with (
        patch.object(mod, "_dispatch_packer_build", AsyncMock(side_effect=ToolError("Packer API unreachable"))),
        pytest.raises(ToolError, match="Packer API unreachable"),
    ):
        await packer_build_image(image_name="test-img", template_path="/tmp/template.pkr.hcl", platform="linux")


async def test_build_happy_path(tmp_cache_dir: Path) -> None:
    """packer_build_image dispatches build and updates cache manifest."""
    import mcp_vm_blackbox.packer_tools as mod

    with patch.object(mod, "_dispatch_packer_build", AsyncMock(return_value={"build_id": "abc123"})):
        result = await packer_build_image(
            image_name="my-linux-base", template_path="/tmp/template.pkr.hcl", platform="linux"
        )

    assert result.status == "dispatched"
    assert result.image_name == "my-linux-base"
    assert result.cache_dir == str(tmp_cache_dir)

    # Manifest was written.
    manifest = _load_manifest(tmp_cache_dir)
    assert len(manifest) == 1
    assert manifest[0].image_name == "my-linux-base"
    assert manifest[0].platform == "linux"


async def test_build_replaces_existing_manifest_entry(tmp_cache_dir: Path) -> None:
    """packer_build_image replaces a previous entry for the same image."""
    import mcp_vm_blackbox.packer_tools as mod

    # Seed an old entry.
    old_entry = _make_entry("my-linux-base")
    _save_manifest(tmp_cache_dir, [old_entry])

    with patch.object(mod, "_dispatch_packer_build", AsyncMock(return_value={"build_id": "xyz789"})):
        await packer_build_image(
            image_name="my-linux-base", template_path="/tmp/new-template.pkr.hcl", platform="linux"
        )

    manifest = _load_manifest(tmp_cache_dir)
    assert len(manifest) == 1  # Not doubled.
    assert manifest[0].template_path == "/tmp/new-template.pkr.hcl"


async def test_api_unreachable_error_message() -> None:
    """_dispatch_packer_build ToolError names env vars and start command."""
    mock_client = MagicMock()
    mock_client.__aenter__ = AsyncMock(side_effect=OSError("Connection refused"))
    mock_client.__aexit__ = AsyncMock(return_value=None)

    with (
        patch("mcp_vm_blackbox.packer_tools.httpx.AsyncClient", return_value=mock_client),
        pytest.raises(ToolError) as exc_info,
    ):
        await _dispatch_packer_build("/tmp/t.pkr.hcl", "img", "linux", "/tmp/out")

    message = str(exc_info.value)
    assert "MCP_VM_BLACKBOX_PACKER_API_HOST" in message
    assert "MCP_VM_BLACKBOX_PACKER_API_PORT" in message
    assert "packer server" in message


# ---------------------------------------------------------------------------
# packer_image_status
# ---------------------------------------------------------------------------


async def test_status_not_cached() -> None:
    """packer_image_status returns cached=False when image is not in manifest."""
    result = await packer_image_status("nonexistent-image")
    assert result.cached is False
    assert result.stale is False
    assert result.entry is None


async def test_status_cached_not_stale(tmp_cache_dir: Path, tmp_path: Path) -> None:
    """packer_image_status returns cached=True and stale=False when inputs match."""
    tpl = tmp_path / "template.pkr.hcl"
    tpl.write_text("source = {}")

    # Build a cache entry with the current inputs hash.
    inputs_hash = _compute_inputs_hash(str(tpl), "linux", "my-image")
    entry = PackerCacheEntry(
        image_name="my-image",
        image_path="/tmp/cache/my-image",
        platform="linux",
        built_at="2026-01-01T00:00:00+00:00",
        inputs_hash=inputs_hash,
        template_path=str(tpl),
    )
    _save_manifest(tmp_cache_dir, [entry])

    result = await packer_image_status("my-image", str(tpl), "linux")
    assert result.cached is True
    assert result.stale is False
    assert result.entry is not None


async def test_status_cached_stale(tmp_cache_dir: Path, tmp_path: Path) -> None:
    """packer_image_status returns stale=True when template content has changed."""
    tpl = tmp_path / "template.pkr.hcl"
    tpl.write_text("source = {}")

    # Entry has a different hash from when template was first written.
    entry = PackerCacheEntry(
        image_name="my-image",
        image_path="/tmp/cache/my-image",
        platform="linux",
        built_at="2026-01-01T00:00:00+00:00",
        inputs_hash="old_stale_hash",
        template_path=str(tpl),
    )
    _save_manifest(tmp_cache_dir, [entry])

    # Now template has changed.
    tpl.write_text("source = { changed = true }")

    result = await packer_image_status("my-image", str(tpl), "linux")
    assert result.cached is True
    assert result.stale is True


async def test_status_cached_no_staleness_check(tmp_cache_dir: Path) -> None:
    """packer_image_status returns stale=False when no template/platform provided."""
    entry = _make_entry("my-image")
    _save_manifest(tmp_cache_dir, [entry])

    result = await packer_image_status("my-image")
    assert result.cached is True
    assert result.stale is False


# ---------------------------------------------------------------------------
# packer_list_images
# ---------------------------------------------------------------------------


async def test_list_empty_cache() -> None:
    """packer_list_images returns count=0 when cache is empty."""
    result = await packer_list_images()
    assert result.count == 0
    assert result.images == []


def test_save_manifest_atomic_sequential_writes(tmp_cache_dir: Path) -> None:
    """Sequential _save_manifest calls are atomic; only the last write persists."""
    first_entries = [_make_entry("img-first")]
    second_entries = [_make_entry("img-second-a"), _make_entry("img-second-b", "macos")]

    _save_manifest(tmp_cache_dir, first_entries)
    _save_manifest(tmp_cache_dir, second_entries)

    loaded = _load_manifest(tmp_cache_dir)
    assert len(loaded) == 2
    names = {e.image_name for e in loaded}
    assert names == {"img-second-a", "img-second-b"}

    # Verify the raw JSON on disk is valid (no partial writes).
    import json as _json

    raw = _manifest_path(tmp_cache_dir).read_text()
    parsed = _json.loads(raw)
    assert isinstance(parsed, list)
    assert len(parsed) == 2


async def test_list_populated_cache(tmp_cache_dir: Path) -> None:
    """packer_list_images returns all manifest entries."""
    entries = [_make_entry("img-a"), _make_entry("img-b", "macos")]
    _save_manifest(tmp_cache_dir, entries)

    result = await packer_list_images()
    assert result.count == 2
    names = {e.image_name for e in result.images}
    assert names == {"img-a", "img-b"}
