"""Snapshot management logic for VM clusters.

All libvirt I/O is delegated to LibvirtClient for thread-safety (D-08).
"""

from __future__ import annotations

import asyncio
import logging
import os
import xml.etree.ElementTree as ET
from datetime import UTC, datetime
from typing import Any

import libvirt

from vmcluster_mcp.cluster.libvirt_client import LibvirtClient, domain_name
from vmcluster_mcp.models import (
    ClusterStatus,
    ErrorDetail,
    NodeStatus,
    SnapshotDeleteNodeResult,
    SnapshotDeleteResult,
    SnapshotInfo,
    SnapshotNodeInfo,
    ToolResult,
)

logger = logging.getLogger("vmcluster_mcp.snapshot")


class SnapshotManager:
    """Creates and lists VM cluster snapshots via LibvirtClient."""

    def __init__(self, client: LibvirtClient) -> None:
        self._client = client

    async def create_snapshot(
        self,
        cluster_name: str,
        snapshot_name: str,
        nodes: list[Any],
        overlay_dir: str,
    ) -> ToolResult:
        """Create an external disk-only snapshot for all nodes concurrently (D-01/D-03/D-04).

        Args:
            cluster_name: The cluster name (used for domain lookup and paths).
            snapshot_name: Name for this snapshot, must be unique.
            nodes: List of NodeSpec objects from the topology.
            overlay_dir: Base directory for overlay files.

        Returns:
            ToolResult[SnapshotInfo] on success, ToolResult(success=False) on any failure.
        """
        snap_dir = os.path.join(overlay_dir, cluster_name, "snapshots", snapshot_name)

        # Idempotency guard: check if snapshot already exists in libvirt (D-06 variant)
        first_dom_name = domain_name(cluster_name, nodes[0].name)
        first_dom = await self._client.lookup_domain(first_dom_name)
        if first_dom is not None:
            existing = await self._client.snapshot_lookup_by_name(first_dom, snapshot_name)
            if existing is not None:
                return ToolResult(
                    success=False,
                    error=ErrorDetail(
                        code="SNAPSHOT_EXISTS",
                        message=f"Snapshot '{snapshot_name}' already exists in cluster '{cluster_name}'",
                        recoverable=True,
                    ),
                )

        # Create snapshot directory before concurrent node operations
        os.makedirs(snap_dir, exist_ok=True)

        async def snapshot_node(node: Any) -> SnapshotNodeInfo:
            dom_nm = domain_name(cluster_name, node.name)
            dom = await self._client.lookup_domain(dom_nm)
            if dom is None:
                raise RuntimeError(f"Domain {dom_nm} not found")

            disk_path = os.path.join(snap_dir, f"{node.name}.qcow2")
            snap_xml = (
                f"<domainsnapshot>"
                f"<name>{snapshot_name}</name>"
                f"<disks>"
                f"<disk name='vda'><source file='{disk_path}'/></disk>"
                f"</disks>"
                f"</domainsnapshot>"
            )
            await self._client.snapshot_create_xml(
                dom, snap_xml, libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_DISK_ONLY
            )
            size_bytes = os.path.getsize(disk_path)
            return SnapshotNodeInfo(
                node_name=node.name,
                disk_path=disk_path,
                size_bytes=size_bytes,
            )

        results = await asyncio.gather(
            *(snapshot_node(node) for node in nodes),
            return_exceptions=True,
        )

        errors = [r for r in results if isinstance(r, Exception)]
        if errors:
            error_msg = "; ".join(str(e) for e in errors)
            logger.error("Snapshot '%s' failed on one or more nodes: %s", snapshot_name, error_msg)
            return ToolResult(
                success=False,
                error=ErrorDetail(
                    code="SNAPSHOT_FAILED",
                    message=f"Snapshot failed on one or more nodes: {error_msg}",
                    recoverable=False,
                ),
            )

        per_node = {info.node_name: info for info in results}
        created_at = datetime.now(UTC).isoformat()
        return ToolResult(
            success=True,
            result=SnapshotInfo(
                snapshot_name=snapshot_name,
                cluster_name=cluster_name,
                created_at=created_at,
                per_node=per_node,
            ),
        )

    async def list_snapshots(
        self,
        cluster_name: str,
        nodes: list[Any],
        overlay_dir: str,
    ) -> ToolResult:
        """List all snapshots for a cluster, grouped by snapshot name.

        Iterates over all nodes, collects snapshot metadata from libvirt XML,
        and groups per-node info by snapshot name.

        Args:
            cluster_name: Name of the cluster.
            nodes: List of NodeSpec objects from the topology.
            overlay_dir: Base directory (used for size lookups).

        Returns:
            ToolResult[list[SnapshotInfo]] — empty list if no snapshots exist.
        """
        snapshots: dict[str, SnapshotInfo] = {}

        for node in nodes:
            dom_nm = domain_name(cluster_name, node.name)
            dom = await self._client.lookup_domain(dom_nm)
            if dom is None:
                logger.warning("Domain %s not found, skipping snapshot listing", dom_nm)
                continue

            node_snaps = await self._client.list_all_snapshots(dom)
            for snap in node_snaps:
                xml_desc = await self._client.snapshot_get_xml_desc(snap)
                snap_name, created_at, disk_path = _parse_snapshot_xml(xml_desc)

                try:
                    size_bytes = os.path.getsize(disk_path) if disk_path else 0
                except FileNotFoundError:
                    size_bytes = 0

                node_info = SnapshotNodeInfo(
                    node_name=node.name,
                    disk_path=disk_path or "",
                    size_bytes=size_bytes,
                )

                if snap_name not in snapshots:
                    snapshots[snap_name] = SnapshotInfo(
                        snapshot_name=snap_name,
                        cluster_name=cluster_name,
                        created_at=created_at,
                        per_node={},
                    )
                snapshots[snap_name].per_node[node.name] = node_info

        return ToolResult(success=True, result=list(snapshots.values()))

    async def revert_snapshot(
        self,
        cluster_name: str,
        snapshot_name: str,
        nodes: list[Any],
        overlay_dir: str,
        topo: Any,
        restart_after: bool = True,
        wait_for_ssh: bool = True,
        ssh_timeout_s: int = 120,
    ) -> ToolResult:
        """Revert cluster to a named snapshot with optional restart and SSH wait.

        Enforces destroy-all-before-revert discipline (D-07):
          Phase 1 — destroy all active domains (asyncio.gather, awaited to completion)
          Phase 2 — revert each domain to the named snapshot (asyncio.gather)
          Phase 3 — optionally restart all domains (asyncio.gather)
          Phase 4 — optionally wait for SSH reachability on every node

        Any failure is fatal and returns success=False with no silent partial reverts.

        Args:
            cluster_name: Cluster name used for domain lookup.
            snapshot_name: Name of the snapshot to revert to.
            nodes: List of NodeSpec objects from the topology.
            overlay_dir: Base overlay directory (reserved for API consistency).
            topo: TopologySpec; provides SSH settings and network name.
            restart_after: Boot all nodes after revert (default True).
            wait_for_ssh: Block until SSH-reachable (only when restart_after=True).
            ssh_timeout_s: Per-node SSH wait timeout in seconds.

        Returns:
            ToolResult[ClusterStatus] on success, ToolResult(success=False) on any failure.
        """
        # Phase 1: Destroy all active domains before any revert begins
        async def destroy_node(node: Any) -> None:
            dom_nm = domain_name(cluster_name, node.name)
            dom = await self._client.lookup_domain(dom_nm)
            if dom is None:
                raise RuntimeError(f"Domain {dom_nm} not found")
            if await self._client.is_domain_active(dom):
                logger.info("Destroying domain %s before revert", dom_nm)
                await self._client.destroy_domain(dom)

        destroy_results = await asyncio.gather(
            *(destroy_node(n) for n in nodes), return_exceptions=True
        )
        destroy_errors = [r for r in destroy_results if isinstance(r, Exception)]
        if destroy_errors:
            error_msg = "; ".join(str(e) for e in destroy_errors)
            return ToolResult(
                success=False,
                error=ErrorDetail(
                    code="DOMAIN_NOT_FOUND",
                    message=f"Failed to prepare domains for revert: {error_msg}",
                    recoverable=False,
                ),
            )

        # Phase 2: Revert each domain to the named snapshot
        async def revert_node(node: Any) -> None:
            dom_nm = domain_name(cluster_name, node.name)
            dom = await self._client.lookup_domain(dom_nm)
            if dom is None:
                raise RuntimeError(f"Domain {dom_nm} not found")
            snap = await self._client.snapshot_lookup_by_name(dom, snapshot_name)
            if snap is None:
                raise RuntimeError(
                    f"Snapshot '{snapshot_name}' not found on domain {dom_nm}"
                )
            await self._client.revert_to_snapshot(
                dom, snap, libvirt.VIR_DOMAIN_SNAPSHOT_REVERT_FORCE
            )

        revert_results = await asyncio.gather(
            *(revert_node(n) for n in nodes), return_exceptions=True
        )
        revert_errors = [r for r in revert_results if isinstance(r, Exception)]
        if revert_errors:
            error_msg = "; ".join(str(e) for e in revert_errors)
            logger.error("Revert to '%s' failed: %s", snapshot_name, error_msg)
            return ToolResult(
                success=False,
                error=ErrorDetail(
                    code="REVERT_FAILED",
                    message=f"Snapshot revert failed on one or more nodes: {error_msg}",
                    recoverable=False,
                ),
            )

        # Phase 3: Restart (if requested)
        if restart_after:

            async def start_node(node: Any) -> None:
                await self._client.start_domain_by_name(
                    domain_name(cluster_name, node.name)
                )

            start_results = await asyncio.gather(
                *(start_node(n) for n in nodes), return_exceptions=True
            )
            start_errors = [r for r in start_results if isinstance(r, Exception)]
            if start_errors:
                error_msg = "; ".join(str(e) for e in start_errors)
                return ToolResult(
                    success=False,
                    error=ErrorDetail(
                        code="START_FAILED",
                        message=f"Domain start failed after revert: {error_msg}",
                        recoverable=False,
                    ),
                )

            # Phase 4: SSH wait — lazy import avoids circular dependency at module level
            if wait_for_ssh:
                from vmcluster_mcp.cluster import _wait_for_ssh  # noqa: PLC0415

                ssh_results = await asyncio.gather(
                    *(
                        _wait_for_ssh(
                            n.ip, 22, topo.ssh.user, topo.ssh.key_path, ssh_timeout_s
                        )
                        for n in nodes
                    ),
                    return_exceptions=True,
                )
                failed_nodes = []
                for i, res in enumerate(ssh_results):
                    if isinstance(res, Exception) or not res[0]:
                        failed_nodes.append(nodes[i].name)
                if failed_nodes:
                    return ToolResult(
                        success=False,
                        error=ErrorDetail(
                            code="SSH_TIMEOUT",
                            message=(
                                f"SSH not reachable after revert on: {failed_nodes}"
                            ),
                            recoverable=False,
                        ),
                    )

        # Build ClusterStatus from current domain states
        ssh_ok_set: set[str] = set()
        if restart_after and wait_for_ssh:
            ssh_ok_set = {n.name for n in nodes}

        node_statuses = []
        for node in nodes:
            dom = await self._client.lookup_domain(domain_name(cluster_name, node.name))
            state = "unknown"
            if dom is not None:
                state = await self._client.get_domain_state(dom)
            node_statuses.append(
                NodeStatus(
                    name=node.name,
                    role=node.role,
                    ip=node.ip,
                    domain_state=state,
                    ssh_reachable=node.name in ssh_ok_set,
                )
            )

        net = await self._client.lookup_network(topo.network.name)
        network_active = False
        if net is not None:
            network_active = await self._client.is_network_active(net)

        return ToolResult(
            success=True,
            result=ClusterStatus(
                cluster_name=cluster_name,
                network_active=network_active,
                nodes=node_statuses,
            ),
        )

    async def delete_snapshot(
        self,
        cluster_name: str,
        snapshot_name: str,
        nodes: list[Any],
    ) -> ToolResult:
        """Delete a snapshot from all nodes with best-effort per-node semantics (D-05/D-08).

        For each node:
          - If the snapshot or domain is not found: records an error, continues.
          - Active-backing guard (D-05): if the snapshot's disk overlay IS the
            currently-active domain disk source, refuses deletion for that node.
          - Otherwise: deletes libvirt snapshot metadata and removes the overlay file.

        Individual node failures are recorded but do not abort other nodes.

        Args:
            cluster_name: The cluster name.
            snapshot_name: Name of the snapshot to delete.
            nodes: List of NodeSpec objects.

        Returns:
            ToolResult[SnapshotDeleteResult] with per-node status.
            success=True only if all nodes successfully deleted the snapshot.
        """

        async def delete_node(node: Any) -> SnapshotDeleteNodeResult:
            dom_nm = domain_name(cluster_name, node.name)
            dom = await self._client.lookup_domain(dom_nm)
            if dom is None:
                return SnapshotDeleteNodeResult(
                    node_name=node.name,
                    deleted=False,
                    error=f"Domain {dom_nm} not found",
                )

            snap = await self._client.snapshot_lookup_by_name(dom, snapshot_name)
            if snap is None:
                return SnapshotDeleteNodeResult(
                    node_name=node.name,
                    deleted=False,
                    error=f"Snapshot '{snapshot_name}' not found on {dom_nm}",
                )

            # Active-backing guard (D-05): parse snapshot and domain XML
            snap_xml = await self._client.snapshot_get_xml_desc(snap)
            _, _, snap_disk_path = _parse_snapshot_xml(snap_xml)

            if snap_disk_path is not None:
                domain_xml = await self._client.get_domain_xml(dom)
                active_disk = _get_active_disk_path(domain_xml)
                if active_disk is not None and active_disk == snap_disk_path:
                    return SnapshotDeleteNodeResult(
                        node_name=node.name,
                        deleted=False,
                        error="Cannot delete active backing layer",
                    )

            # Delete snapshot metadata from libvirt
            await self._client.delete_snapshot(snap)

            # Remove overlay file from disk (FileNotFoundError is not an error)
            if snap_disk_path is not None:
                try:
                    os.remove(snap_disk_path)
                    logger.info("Removed snapshot overlay: %s", snap_disk_path)
                except FileNotFoundError:
                    pass

            return SnapshotDeleteNodeResult(node_name=node.name, deleted=True)

        raw_results = await asyncio.gather(
            *(delete_node(n) for n in nodes), return_exceptions=True
        )
        per_node: dict[str, SnapshotDeleteNodeResult] = {}
        for i, res in enumerate(raw_results):
            if isinstance(res, Exception):
                per_node[nodes[i].name] = SnapshotDeleteNodeResult(
                    node_name=nodes[i].name,
                    deleted=False,
                    error=str(res),
                )
            else:
                per_node[nodes[i].name] = res

        overall_success = all(v.deleted for v in per_node.values())
        return ToolResult(
            success=overall_success,
            result=SnapshotDeleteResult(
                snapshot_name=snapshot_name,
                cluster_name=cluster_name,
                per_node=per_node,
            ),
        )


def _parse_snapshot_xml(xml_desc: str) -> tuple[str, str, str | None]:
    """Extract (snapshot_name, created_at_iso, disk_path) from snapshot XML.

    Args:
        xml_desc: XML string from snapshot.getXMLDesc().

    Returns:
        Tuple of (name, created_at_iso, disk_path_or_None).
        created_at_iso is an ISO 8601 string from epoch seconds.
        disk_path is None if no <disk><source file=...> element is found.
    """
    root = ET.fromstring(xml_desc)

    name_el = root.find("name")
    snap_name = name_el.text if name_el is not None else ""

    ct_el = root.find("creationTime")
    if ct_el is not None and ct_el.text:
        epoch = int(ct_el.text)
        created_at = datetime.fromtimestamp(epoch, tz=UTC).isoformat()
    else:
        created_at = datetime.now(UTC).isoformat()

    # Extract disk path from <disks><disk name='vda'><source file='...'/>
    disk_path: str | None = None
    for disk_el in root.findall(".//disks/disk"):
        src = disk_el.find("source")
        if src is not None:
            disk_path = src.get("file")
            break

    return snap_name, created_at, disk_path


def _get_active_disk_path(domain_xml: str) -> str | None:
    """Extract the currently-active vda source file path from domain XML.

    Args:
        domain_xml: XML string from domain.XMLDesc().

    Returns:
        The <source file='...'> path for the primary disk device, or None.
    """
    root = ET.fromstring(domain_xml)
    for disk_el in root.findall(".//devices/disk"):
        if disk_el.get("device") == "disk":
            target = disk_el.find("target")
            if target is not None and target.get("dev") in ("vda", "sda", "hda"):
                source = disk_el.find("source")
                if source is not None:
                    return source.get("file")
    return None
