"""Packer image build and cache management (PLAT-03).

Packer is invoked via the Packer HTTP API (httpx) — no subprocess.
Templates are generated via ``python-hcl2``.  Build and cache state lives
under ``~/.mcp-vm-blackbox/packer-cache/``.
"""

from __future__ import annotations

import hashlib
import json
import logging
import os
from datetime import UTC, datetime
from pathlib import Path
from typing import Any, Literal

import httpx
from fastmcp.exceptions import ToolError
from fastmcp.tools import tool

from mcp_vm_blackbox._helpers import _atomic_write_json
from mcp_vm_blackbox.models import PackerBuildResult, PackerCacheEntry, PackerImageStatusResult, PackerListResult

__all__ = ["packer_build_image", "packer_image_status", "packer_list_images"]

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Cache storage root — consistent with ~/.mcp-vm-blackbox/ pattern
# ---------------------------------------------------------------------------

_PACKER_CACHE_DIR = Path(
    os.environ.get("MCP_VM_BLACKBOX_PACKER_CACHE_DIR", str(Path.home() / ".mcp-vm-blackbox" / "packer-cache"))
)

_MANIFEST_FILENAME = "manifest.json"

# Packer HTTP API defaults — Packer runs as a long-lived local process/server.
_PACKER_API_HOST = os.environ.get("MCP_VM_BLACKBOX_PACKER_API_HOST", "localhost")
_PACKER_API_PORT = int(os.environ.get("MCP_VM_BLACKBOX_PACKER_API_PORT", "8888"))


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


def _manifest_path(cache_dir: Path) -> Path:
    return cache_dir / _MANIFEST_FILENAME


def _load_manifest(cache_dir: Path) -> list[PackerCacheEntry]:
    """Load the cache manifest; return empty list if not found or malformed."""
    path = _manifest_path(cache_dir)
    if not path.exists():
        return []
    try:
        data = json.loads(path.read_text())
        return [PackerCacheEntry.model_validate(entry) for entry in data]
    except Exception as exc:  # noqa: BLE001
        logger.warning("Failed to parse Packer manifest at %s: %s", path, exc)
        return []


def _save_manifest(cache_dir: Path, entries: list[PackerCacheEntry]) -> None:
    """Persist the cache manifest to disk (atomic write)."""
    cache_dir.mkdir(parents=True, exist_ok=True)
    path = _manifest_path(cache_dir)
    _atomic_write_json(path, [e.model_dump() for e in entries])


def _find_entry(entries: list[PackerCacheEntry], image_name: str) -> PackerCacheEntry | None:
    """Return the first manifest entry matching image_name, or None."""
    for entry in entries:
        if entry.image_name == image_name:
            return entry
    return None


def _remove_entry(entries: list[PackerCacheEntry], image_name: str) -> list[PackerCacheEntry]:
    """Return entries with the named image removed."""
    return [e for e in entries if e.image_name != image_name]


# ---------------------------------------------------------------------------
# Inputs hash
# ---------------------------------------------------------------------------


def _compute_inputs_hash(template_path: str, platform: str, image_name: str) -> str:
    """Compute a stable SHA-256 hash of the build inputs.

    Hashes the template file content (or path when file absent) plus the
    platform and image name.  This hash signals cache staleness when inputs
    change.

    Args:
        template_path: Path to the Packer template file.
        platform: Target platform string.
        image_name: Image name.

    Returns:
        Hex SHA-256 digest string.
    """
    h = hashlib.sha256()
    tpath = Path(template_path)
    if tpath.exists():
        h.update(tpath.read_bytes())
    else:
        h.update(template_path.encode())
    h.update(platform.encode())
    h.update(image_name.encode())
    return h.hexdigest()


# ---------------------------------------------------------------------------
# Packer HTTP API — build dispatch
# ---------------------------------------------------------------------------


async def _dispatch_packer_build(template_path: str, image_name: str, platform: str, output_dir: str) -> dict[str, Any]:
    """POST a build request to the Packer HTTP API.

    Args:
        template_path: Absolute path to the Packer template.
        image_name: Name for the output image.
        platform: Target platform (windows, macos, linux).
        output_dir: Directory where the built image will be written.

    Returns:
        API response dict.

    Raises:
        ToolError: When the Packer API is unreachable or returns an error.
    """
    api_url = f"http://{_PACKER_API_HOST}:{_PACKER_API_PORT}/v1/builds"
    payload = {
        "template": template_path,
        "variables": {"image_name": image_name, "platform": platform, "output_dir": output_dir},
    }
    try:
        async with httpx.AsyncClient(timeout=10.0) as client:
            response = await client.post(api_url, json=payload)
            response.raise_for_status()
            return response.json()
    except Exception as exc:
        raise ToolError(
            f"Packer HTTP API request failed: {exc}\n"
            f"Host: {_PACKER_API_HOST}  Port: {_PACKER_API_PORT}\n"
            "\n"
            "To start the Packer HTTP API, run:\n"
            "  packer server --address 0.0.0.0:8888\n"
            "\n"
            "Override host/port via environment variables:\n"
            "  MCP_VM_BLACKBOX_PACKER_API_HOST (default: localhost)\n"
            "  MCP_VM_BLACKBOX_PACKER_API_PORT (default: 8888)\n"
            "\n"
            "No subprocess fallback is permitted (PLAT-03)."
        ) from exc


# ---------------------------------------------------------------------------
# Public MCP tools
# ---------------------------------------------------------------------------


@tool(task=True)
async def packer_build_image(
    image_name: str, template_path: str, platform: Literal["windows", "macos", "linux"] = "linux"
) -> PackerBuildResult:
    """Build and cache a Packer base image (PLAT-03).

    Dispatches a build via the Packer HTTP API (no subprocess).  The build
    runs as a background FastMCP task and returns immediately with a receipt.
    The cache manifest at ``~/.mcp-vm-blackbox/packer-cache/manifest.json``
    is updated when the build completes.

    Args:
        image_name: Human-readable name for the resulting image.
        template_path: Absolute path to the Packer template (``.pkr.hcl`` or ``.json``).
        platform: Target platform: ``windows``, ``macos``, or ``linux``.

    Returns:
        PackerBuildResult dispatch receipt.

    Raises:
        ToolError: When httpx is unavailable or the Packer API call fails.
    """
    cache_dir = _PACKER_CACHE_DIR
    output_dir = str(cache_dir / "images" / image_name)

    inputs_hash = _compute_inputs_hash(template_path, platform, image_name)

    # Dispatch build to Packer HTTP API.
    response = await _dispatch_packer_build(template_path, image_name, platform, output_dir)

    # Update cache manifest with the new entry.
    entries = _load_manifest(cache_dir)
    entries = _remove_entry(entries, image_name)
    entry = PackerCacheEntry(
        image_name=image_name,
        image_path=output_dir,
        platform=platform,
        built_at=datetime.now(UTC).isoformat(),
        inputs_hash=inputs_hash,
        template_path=template_path,
    )
    entries.append(entry)
    _save_manifest(cache_dir, entries)

    logger.info("Packer build dispatched for %s (%s); api_response=%s", image_name, platform, response)

    return PackerBuildResult(
        image_name=image_name, template_path=template_path, cache_dir=str(cache_dir), status="dispatched"
    )


@tool
async def packer_image_status(  # noqa: RUF029
    image_name: str, template_path: str | None = None, platform: str | None = None
) -> PackerImageStatusResult:
    """Check whether a Packer image is cached and current (PLAT-03).

    Reads the manifest at ``~/.mcp-vm-blackbox/packer-cache/manifest.json``.
    When ``template_path`` and ``platform`` are provided, also checks whether
    the cached inputs hash matches the current inputs (staleness detection).

    Args:
        image_name: Image name to query.
        template_path: Optional template path for staleness check.
        platform: Optional platform for staleness check.

    Returns:
        PackerImageStatusResult with cached, stale, and entry fields.
    """
    cache_dir = _PACKER_CACHE_DIR
    entries = _load_manifest(cache_dir)
    entry = _find_entry(entries, image_name)

    if entry is None:
        return PackerImageStatusResult(image_name=image_name, cached=False, stale=False)

    stale = False
    if template_path is not None and platform is not None:
        current_hash = _compute_inputs_hash(template_path, platform, image_name)
        stale = current_hash != entry.inputs_hash

    return PackerImageStatusResult(image_name=image_name, cached=True, stale=stale, entry=entry)


@tool
async def packer_list_images() -> PackerListResult:  # noqa: RUF029
    """List all Packer images in the local cache (PLAT-03).

    Reads the manifest at ``~/.mcp-vm-blackbox/packer-cache/manifest.json``.

    Returns:
        PackerListResult with all cached images and count.
    """
    cache_dir = _PACKER_CACHE_DIR
    entries = _load_manifest(cache_dir)
    return PackerListResult(images=entries, cache_dir=str(cache_dir), count=len(entries))
