"""Unit tests for ArtifactSyncer: dedup check, rsync subprocess, fan-out, best-effort."""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, patch

import pytest

from vmcluster_mcp.artifact.syncer import ArtifactSyncer
from vmcluster_mcp.exec.ssh import SSHConnectionManager
from vmcluster_mcp.models import ExecResult, SyncNodeResult, SyncStatus


# -- Helpers --


def _exec_result(exit_code: int = 0, stdout: str = "", stderr: str = "") -> ExecResult:
    return ExecResult(
        exit_code=exit_code,
        stdout=stdout,
        stderr=stderr,
        duration_s=0.1,
        timed_out=False,
    )


def _mock_proc(returncode: int = 0, stdout: bytes = b"", stderr: bytes = b"") -> MagicMock:
    """Build a mock asyncio.Process with a preset returncode and communicate result."""
    proc = MagicMock()
    proc.returncode = returncode
    proc.communicate = AsyncMock(return_value=(stdout, stderr))
    return proc


class _FakeNode:
    def __init__(self, name: str, ip: str) -> None:
        self.name = name
        self.ip = ip


class _FakeSsh:
    user = "root"
    key_path = "/key"


# -- sync_node tests --


async def test_sync_node_skips_when_dir_exists_no_force():
    """sync_node returns skipped=True when remote dir exists and force=False."""
    ssh_manager = AsyncMock(spec=SSHConnectionManager)
    ssh_manager.execute = AsyncMock(return_value=_exec_result(exit_code=0))  # test -d succeeds

    syncer = ArtifactSyncer(ssh_manager)
    result = await syncer.sync_node(
        ip="10.0.0.1",
        port=22,
        user="root",
        key_path="/key",
        source_path="/build",
        dest_path="/opt/artifacts",
        artifact_id="abc123",
        force=False,
    )

    assert result.skipped is True
    assert result.rsync_exit_code == 0
    assert result.bytes_transferred == 0


async def test_sync_node_rsync_when_dir_not_exists():
    """sync_node runs rsync when remote dir does not exist (test -d fails, exit_code=1)."""
    ssh_manager = AsyncMock(spec=SSHConnectionManager)
    ssh_manager.execute = AsyncMock(return_value=_exec_result(exit_code=1))  # test -d fails

    syncer = ArtifactSyncer(ssh_manager)
    proc = _mock_proc(returncode=0, stdout=b"sent 1234 bytes")

    with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)):
        result = await syncer.sync_node(
            ip="10.0.0.1",
            port=22,
            user="root",
            key_path="/key",
            source_path="/build",
            dest_path="/opt/artifacts",
            artifact_id="abc123",
            force=False,
        )

    assert result.skipped is False
    assert result.rsync_exit_code == 0


async def test_sync_node_rsync_when_force_even_if_dir_exists():
    """sync_node runs rsync when force=True even if remote dir already exists."""
    ssh_manager = AsyncMock(spec=SSHConnectionManager)
    ssh_manager.execute = AsyncMock(return_value=_exec_result(exit_code=0))  # test -d succeeds

    syncer = ArtifactSyncer(ssh_manager)
    proc = _mock_proc(returncode=0)

    with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)):
        result = await syncer.sync_node(
            ip="10.0.0.1",
            port=22,
            user="root",
            key_path="/key",
            source_path="/build",
            dest_path="/opt/artifacts",
            artifact_id="abc123",
            force=True,
        )

    assert result.skipped is False


async def test_sync_node_reports_rsync_failure():
    """sync_node reports non-zero rsync_exit_code on rsync failure."""
    ssh_manager = AsyncMock(spec=SSHConnectionManager)
    ssh_manager.execute = AsyncMock(return_value=_exec_result(exit_code=1))  # test -d fails

    syncer = ArtifactSyncer(ssh_manager)
    proc = _mock_proc(returncode=23, stderr=b"error: partial transfer due to error")

    with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)):
        result = await syncer.sync_node(
            ip="10.0.0.1",
            port=22,
            user="root",
            key_path="/key",
            source_path="/build",
            dest_path="/opt/artifacts",
            artifact_id="abc123",
        )

    assert result.rsync_exit_code == 23
    assert result.skipped is False


async def test_sync_node_rsync_command_includes_correct_flags():
    """rsync command includes -avz, --delete, StrictHostKeyChecking=no, UserKnownHostsFile=/dev/null."""
    ssh_manager = AsyncMock(spec=SSHConnectionManager)
    ssh_manager.execute = AsyncMock(return_value=_exec_result(exit_code=1))  # test -d fails

    syncer = ArtifactSyncer(ssh_manager)
    proc = _mock_proc(returncode=0)
    captured_args: list = []

    async def _capture(*args, **kwargs):
        captured_args.extend(args)
        return proc

    with patch("asyncio.create_subprocess_exec", new=AsyncMock(side_effect=_capture)):
        await syncer.sync_node(
            ip="10.0.0.1",
            port=22,
            user="root",
            key_path="/key",
            source_path="/build",
            dest_path="/opt/artifacts",
            artifact_id="abc123",
        )

    cmd = " ".join(str(a) for a in captured_args)
    assert "-avz" in cmd
    assert "--delete" in cmd
    assert "StrictHostKeyChecking=no" in cmd
    assert "UserKnownHostsFile=/dev/null" in cmd


# -- sync_all tests --


async def test_sync_all_fans_out_across_all_nodes():
    """sync_all fans out to all nodes via asyncio.gather, returning SyncStatus."""
    ssh_manager = AsyncMock(spec=SSHConnectionManager)
    ssh_manager.execute = AsyncMock(return_value=_exec_result(exit_code=1))  # test -d always fails

    syncer = ArtifactSyncer(ssh_manager)
    proc = _mock_proc(returncode=0)
    nodes = [_FakeNode("node1", "10.0.0.1"), _FakeNode("node2", "10.0.0.2")]

    with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc)):
        status = await syncer.sync_all(
            nodes=nodes,
            ssh_spec=_FakeSsh(),
            source_path="/build",
            dest_base="/opt/artifacts",
            artifact_id="abc123",
        )

    assert isinstance(status, SyncStatus)
    assert "node1" in status.per_node
    assert "node2" in status.per_node
    assert status.per_node["node1"].rsync_exit_code == 0
    assert status.per_node["node2"].rsync_exit_code == 0


async def test_sync_all_per_node_failure_does_not_abort_others():
    """Per-node rsync failure does not abort other nodes (best-effort D-08)."""
    ssh_manager = AsyncMock(spec=SSHConnectionManager)
    ssh_manager.execute = AsyncMock(return_value=_exec_result(exit_code=1))  # test -d always fails

    syncer = ArtifactSyncer(ssh_manager)
    proc_fail = _mock_proc(returncode=23)
    proc_ok = _mock_proc(returncode=0)
    nodes = [_FakeNode("fail_node", "10.0.0.1"), _FakeNode("ok_node", "10.0.0.2")]

    with patch("asyncio.create_subprocess_exec", side_effect=[proc_fail, proc_ok]):
        status = await syncer.sync_all(
            nodes=nodes,
            ssh_spec=_FakeSsh(),
            source_path="/build",
            dest_base="/opt/artifacts",
            artifact_id="abc123",
        )

    # Both nodes must be reported regardless of individual failure
    assert "fail_node" in status.per_node
    assert "ok_node" in status.per_node
    exit_codes = {n: r.rsync_exit_code for n, r in status.per_node.items()}
    assert 23 in exit_codes.values()
    assert 0 in exit_codes.values()


async def test_sync_all_exception_in_node_returns_error_result():
    """Exception raised during sync_node is caught and reported as rsync_exit_code=-1."""
    ssh_manager = AsyncMock(spec=SSHConnectionManager)
    # Make execute raise an exception for the failing node
    ssh_manager.execute = AsyncMock(side_effect=RuntimeError("SSH connection failed"))

    syncer = ArtifactSyncer(ssh_manager)
    nodes = [_FakeNode("err_node", "10.0.0.1"), _FakeNode("ok_node", "10.0.0.2")]

    # Patch execute selectively: fail first node, succeed on second
    call_count = 0

    async def _selective_execute(*args, **kwargs):
        nonlocal call_count
        call_count += 1
        if call_count == 1:
            raise RuntimeError("timeout")
        return _exec_result(exit_code=1)

    ssh_manager.execute = AsyncMock(side_effect=_selective_execute)
    proc_ok = _mock_proc(returncode=0)

    with patch("asyncio.create_subprocess_exec", AsyncMock(return_value=proc_ok)):
        status = await syncer.sync_all(
            nodes=nodes,
            ssh_spec=_FakeSsh(),
            source_path="/build",
            dest_base="/opt/artifacts",
            artifact_id="abc123",
        )

    # Errored node should have rsync_exit_code=-1, other node should still complete
    assert "err_node" in status.per_node
    assert "ok_node" in status.per_node
    assert status.per_node["err_node"].rsync_exit_code == -1
    assert status.per_node["ok_node"].rsync_exit_code == 0
