"""rsync-based artifact syncer with SSH dedup check and concurrent fan-out."""

from __future__ import annotations

import asyncio
import logging
import time

from vmcluster_mcp.exec.ssh import SSHConnectionManager
from vmcluster_mcp.models import SyncNodeResult, SyncStatus

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


class ArtifactSyncer:
    """Transfer build artifacts to cluster nodes via rsync with content-addressed dedup.

    Before running rsync, performs a remote `test -d` check via SSH.  If the
    artifact directory already exists and ``force=False``, the node is skipped.
    All nodes are synced concurrently via ``asyncio.gather``; per-node failures
    do not abort other nodes (best-effort, D-08).
    """

    def __init__(self, ssh_manager: SSHConnectionManager) -> None:
        self._ssh = ssh_manager

    async def sync_node(
        self,
        ip: str,
        port: int,
        user: str,
        key_path: str,
        source_path: str,
        dest_path: str,
        artifact_id: str,
        force: bool = False,
    ) -> SyncNodeResult:
        """Sync one node: dedup check then rsync subprocess.

        Args:
            ip: Node IP address.
            port: SSH port.
            user: SSH username.
            key_path: Path to SSH private key.
            source_path: Local build tree root to sync.
            dest_path: Remote base directory (artifact lands at dest_path/artifact_id/).
            artifact_id: Content-addressed artifact identifier.
            force: Skip dedup check and always run rsync.

        Returns:
            SyncNodeResult with bytes_transferred, duration_s, rsync_exit_code, skipped.
        """
        # Dedup check (D-06): skip if remote dir already present and not forced
        if not force:
            check = await self._ssh.execute(
                ip,
                port,
                user,
                key_path,
                f"test -d {dest_path}/{artifact_id}",
                timeout_s=5,
            )
            if check.exit_code == 0:
                return SyncNodeResult(
                    node_name=ip,
                    bytes_transferred=0,
                    duration_s=0.0,
                    rsync_exit_code=0,
                    skipped=True,
                )

        # rsync via subprocess (D-04): SSH transport with host-key checking disabled
        ssh_opts = (
            f"ssh -i {key_path} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
        )
        start = time.monotonic()
        proc = await asyncio.create_subprocess_exec(
            "rsync",
            "-avz",
            "--delete",
            "-e",
            ssh_opts,
            f"{source_path}/",
            f"{user}@{ip}:{dest_path}/{artifact_id}/",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout_bytes, _ = await proc.communicate()
        duration = time.monotonic() - start

        bytes_transferred = _parse_bytes_transferred(
            stdout_bytes.decode("utf-8", errors="replace")
        )

        return SyncNodeResult(
            node_name=ip,
            bytes_transferred=bytes_transferred,
            duration_s=duration,
            rsync_exit_code=proc.returncode,
            skipped=False,
        )

    async def sync_all(
        self,
        nodes: list,
        ssh_spec,
        source_path: str,
        dest_base: str,
        artifact_id: str,
        force: bool = False,
    ) -> SyncStatus:
        """Sync artifact to all nodes concurrently (asyncio.gather fan-out, D-05).

        Per-node exceptions are caught and reported as rsync_exit_code=-1
        without aborting other nodes (best-effort, D-08).

        Args:
            nodes: List of NodeSpec-compatible objects with .name and .ip attributes.
            ssh_spec: SshSpec-compatible object with .user and .key_path.
            source_path: Local build tree root.
            dest_base: Remote base directory for all artifact destinations.
            artifact_id: Content-addressed artifact identifier.
            force: Pass through to sync_node; skip dedup check on all nodes.

        Returns:
            SyncStatus with per_node mapping of node_name → SyncNodeResult.
        """
        tasks = [
            self.sync_node(
                ip=node.ip,
                port=22,
                user=ssh_spec.user,
                key_path=ssh_spec.key_path,
                source_path=source_path,
                dest_path=dest_base,
                artifact_id=artifact_id,
                force=force,
            )
            for node in nodes
        ]
        raw_results = await asyncio.gather(*tasks, return_exceptions=True)

        per_node: dict[str, SyncNodeResult] = {}
        for node, result in zip(nodes, raw_results):
            if isinstance(result, Exception):
                logger.warning(
                    "sync_node raised exception for %s (%s): %s",
                    node.name,
                    node.ip,
                    result,
                )
                per_node[node.name] = SyncNodeResult(
                    node_name=node.name,
                    bytes_transferred=0,
                    duration_s=0.0,
                    rsync_exit_code=-1,
                    skipped=False,
                )
            else:
                per_node[node.name] = SyncNodeResult(
                    node_name=node.name,
                    bytes_transferred=result.bytes_transferred,
                    duration_s=result.duration_s,
                    rsync_exit_code=result.rsync_exit_code,
                    skipped=result.skipped,
                )

        return SyncStatus(per_node=per_node)


def _parse_bytes_transferred(rsync_output: str) -> int:
    """Parse the bytes-sent figure from rsync verbose output.

    rsync prints a line like: "sent 12345 bytes  received 456 bytes  ..."
    Returns 0 if the figure cannot be parsed.
    """
    for line in rsync_output.splitlines():
        if "sent" in line and "bytes" in line:
            parts = line.split()
            for i, part in enumerate(parts):
                if part == "sent" and i + 1 < len(parts):
                    try:
                        return int(parts[i + 1].replace(",", ""))
                    except (ValueError, IndexError):
                        pass
    return 0
