"""Tests for cross-node knowledge sync over SSH (node_sync module).

Tests cover:
- PeerNode, SyncResult, SyncRoundResult dataclass behavior
- Config loading from YAML
- Sync state persistence (load/save)
- SSH and rsync command building (direct and proxy)
- Push/pull operations with mocked subprocess
- Full sync_peer lifecycle
- sync_all aggregation and error handling
- get_status reporting
"""
import json
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest
import yaml

from greymatter_core.node_sync import (
    NodeSync,
    PeerNode,
    SyncResult,
    SyncRoundResult,
)


# ---------------------------------------------------------------------------
# Dataclass tests
# ---------------------------------------------------------------------------

class TestPeerNode:
    def test_defaults(self):
        peer = PeerNode(name="hub", host="10.0.0.1", user="agent")
        assert peer.enabled is True
        assert peer.ssh_key is None
        assert peer.proxy_host is None
        assert peer.remote_path == "~/.greymatter/knowledge/entries"
        assert peer.remote_observations_path == "~/.greymatter/knowledge/observations"
        assert peer.remote_patterns_path == "~/.greymatter/knowledge/patterns"

    def test_custom_fields(self):
        peer = PeerNode(
            name="worker",
            host="10.0.0.2",
            user="deploy",
            ssh_key="~/.ssh/id_ed25519",
            proxy_host="jump.local",
            proxy_user="jumpuser",
            proxy_key="~/.ssh/jump_key",
            enabled=False,
        )
        assert peer.ssh_key == "~/.ssh/id_ed25519"
        assert peer.proxy_host == "jump.local"
        assert peer.proxy_user == "jumpuser"
        assert not peer.enabled


class TestSyncResult:
    def test_ok_when_no_errors(self):
        r = SyncResult(peer="hub", direction="push")
        assert r.ok is True
        assert r.files_transferred == 0
        assert r.files_skipped == 0

    def test_not_ok_with_errors(self):
        r = SyncResult(peer="hub", direction="push", errors=["timeout"])
        assert r.ok is False

    def test_field_defaults(self):
        r = SyncResult(peer="hub", direction="pull")
        assert r.entries_imported == 0
        assert r.entries_updated == 0
        assert r.duration_s == 0.0
        assert r.timestamp == ""


class TestSyncRoundResult:
    def test_empty_round(self):
        rr = SyncRoundResult()
        assert rr.total_transferred == 0
        assert rr.total_errors == 0
        assert rr.ok is True

    def test_aggregation(self):
        rr = SyncRoundResult(results=[
            SyncResult(peer="a", direction="push", files_transferred=3),
            SyncResult(peer="a", direction="pull", files_transferred=2, errors=["fail"]),
            SyncResult(peer="b", direction="push", files_transferred=1),
        ])
        assert rr.total_transferred == 6
        assert rr.total_errors == 1
        assert rr.ok is False

    def test_ok_when_all_clean(self):
        rr = SyncRoundResult(results=[
            SyncResult(peer="a", direction="push"),
            SyncResult(peer="a", direction="pull"),
        ])
        assert rr.ok is True


# ---------------------------------------------------------------------------
# Config loading
# ---------------------------------------------------------------------------

class TestConfigLoading:
    def test_no_config_file(self, tmp_path):
        ns = NodeSync(
            config_path=tmp_path / "nonexistent.yaml",
            entries_dir=tmp_path / "entries",
            observations_dir=tmp_path / "observations",
            patterns_dir=tmp_path / "patterns",
            state_path=tmp_path / "state.json",
        )
        assert ns.peers == []

    def test_loads_peers(self, tmp_path):
        config = {
            "peers": [
                {"name": "hub", "host": "10.0.0.1", "user": "agent"},
                {"name": "worker", "host": "10.0.0.2"},
            ]
        }
        cfg_path = tmp_path / "sync_config.yaml"
        cfg_path.write_text(yaml.dump(config))

        ns = NodeSync(
            config_path=cfg_path,
            entries_dir=tmp_path / "entries",
            observations_dir=tmp_path / "observations",
            patterns_dir=tmp_path / "patterns",
            state_path=tmp_path / "state.json",
        )
        assert len(ns.peers) == 2
        assert ns.peers[0].name == "hub"
        assert ns.peers[1].user == "agenticai"

    def test_disabled_peers_excluded(self, tmp_path):
        config = {
            "peers": [
                {"name": "hub", "host": "10.0.0.1", "enabled": True},
                {"name": "disabled", "host": "10.0.0.3", "enabled": False},
            ]
        }
        cfg_path = tmp_path / "sync_config.yaml"
        cfg_path.write_text(yaml.dump(config))

        ns = NodeSync(
            config_path=cfg_path,
            entries_dir=tmp_path / "entries",
            observations_dir=tmp_path / "observations",
            patterns_dir=tmp_path / "patterns",
            state_path=tmp_path / "state.json",
        )
        assert len(ns.peers) == 1
        assert ns.peers[0].name == "hub"

    def test_empty_config_file(self, tmp_path):
        cfg_path = tmp_path / "sync_config.yaml"
        cfg_path.write_text("")

        ns = NodeSync(
            config_path=cfg_path,
            entries_dir=tmp_path / "entries",
            observations_dir=tmp_path / "observations",
            patterns_dir=tmp_path / "patterns",
            state_path=tmp_path / "state.json",
        )
        assert ns.peers == []

    def test_peer_with_proxy_fields(self, tmp_path):
        config = {
            "peers": [{
                "name": "remote",
                "host": "10.0.0.5",
                "proxy_host": "jump.local",
                "proxy_user": "jumpuser",
                "proxy_key": "~/.ssh/jump",
            }]
        }
        cfg_path = tmp_path / "sync_config.yaml"
        cfg_path.write_text(yaml.dump(config))

        ns = NodeSync(
            config_path=cfg_path,
            entries_dir=tmp_path / "entries",
            observations_dir=tmp_path / "observations",
            patterns_dir=tmp_path / "patterns",
            state_path=tmp_path / "state.json",
        )
        peer = ns.peers[0]
        assert peer.proxy_host == "jump.local"
        assert peer.proxy_user == "jumpuser"
        assert peer.proxy_key == "~/.ssh/jump"


# ---------------------------------------------------------------------------
# State persistence
# ---------------------------------------------------------------------------

class TestStatePersistence:
    def _make_sync(self, tmp_path):
        return NodeSync(
            config_path=tmp_path / "nonexistent.yaml",
            entries_dir=tmp_path / "entries",
            observations_dir=tmp_path / "observations",
            patterns_dir=tmp_path / "patterns",
            state_path=tmp_path / "state.json",
        )

    def test_empty_state_on_first_load(self, tmp_path):
        ns = self._make_sync(tmp_path)
        assert ns._sync_state == {}

    def test_save_and_reload(self, tmp_path):
        ns = self._make_sync(tmp_path)
        ns._sync_state = {"hub": {"last_sync": "2026-03-23T10:00:00"}}
        ns._save_state()

        ns2 = self._make_sync(tmp_path)
        assert ns2._sync_state["hub"]["last_sync"] == "2026-03-23T10:00:00"

    def test_state_file_created_in_nested_dir(self, tmp_path):
        state_path = tmp_path / "nested" / "deep" / "state.json"
        ns = NodeSync(
            config_path=tmp_path / "nonexistent.yaml",
            entries_dir=tmp_path / "entries",
            observations_dir=tmp_path / "observations",
            patterns_dir=tmp_path / "patterns",
            state_path=state_path,
        )
        ns._sync_state = {"test": True}
        ns._save_state()
        assert state_path.exists()
        assert json.loads(state_path.read_text()) == {"test": True}


# ---------------------------------------------------------------------------
# Command building
# ---------------------------------------------------------------------------

class TestCommandBuilding:
    def _make_sync(self, tmp_path):
        return NodeSync(
            config_path=tmp_path / "nonexistent.yaml",
            entries_dir=tmp_path / "entries",
            observations_dir=tmp_path / "observations",
            patterns_dir=tmp_path / "patterns",
            state_path=tmp_path / "state.json",
        )

    def test_ssh_cmd_basic(self, tmp_path):
        ns = self._make_sync(tmp_path)
        peer = PeerNode(name="hub", host="10.0.0.1", user="agent")
        cmd = ns._build_ssh_cmd(peer)
        assert cmd[0] == "ssh"
        assert "BatchMode=yes" in cmd
        assert "agent@10.0.0.1" in cmd

    def test_ssh_cmd_with_key(self, tmp_path):
        ns = self._make_sync(tmp_path)
        peer = PeerNode(name="hub", host="10.0.0.1", user="agent", ssh_key="~/.ssh/id_ed25519")
        cmd = ns._build_ssh_cmd(peer)
        assert "-i" in cmd

    def test_ssh_cmd_with_proxy(self, tmp_path):
        ns = self._make_sync(tmp_path)
        peer = PeerNode(
            name="hub", host="10.0.0.1", user="agent",
            proxy_host="jump.local", proxy_user="jumpuser",
        )
        cmd = ns._build_ssh_cmd(peer)
        proxy_parts = [c for c in cmd if "ProxyCommand=" in c]
        assert len(proxy_parts) == 1
        assert "jump.local" in proxy_parts[0]

    def test_rsync_cmd_includes_yaml_filter(self, tmp_path):
        ns = self._make_sync(tmp_path)
        peer = PeerNode(name="hub", host="10.0.0.1", user="agent")
        cmd = ns._build_rsync_cmd(peer, "/src/", "agent@10.0.0.1:/dst/")
        assert cmd[0] == "rsync"
        assert "--include=*.yaml" in cmd
        assert "--exclude=.*" in cmd

    def test_rsync_cmd_delete_flag(self, tmp_path):
        ns = self._make_sync(tmp_path)
        peer = PeerNode(name="hub", host="10.0.0.1", user="agent")
        cmd_no_delete = ns._build_rsync_cmd(peer, "/src/", "/dst/")
        assert "--delete" not in cmd_no_delete

        cmd_delete = ns._build_rsync_cmd(peer, "/src/", "/dst/", delete=True)
        assert "--delete" in cmd_delete

    def test_rsync_cmd_with_proxy(self, tmp_path):
        ns = self._make_sync(tmp_path)
        peer = PeerNode(
            name="hub", host="10.0.0.1", user="agent",
            proxy_host="jump.local",
        )
        cmd = ns._build_rsync_cmd(peer, "/src/", "/dst/")
        e_idx = cmd.index("-e")
        ssh_arg = cmd[e_idx + 1]
        assert "ProxyCommand" in ssh_arg


# ---------------------------------------------------------------------------
# Push / Pull with mocked subprocess
# ---------------------------------------------------------------------------

class TestPushPull:
    def _make_sync(self, tmp_path):
        return NodeSync(
            config_path=tmp_path / "nonexistent.yaml",
            entries_dir=tmp_path / "entries",
            observations_dir=tmp_path / "observations",
            patterns_dir=tmp_path / "patterns",
            state_path=tmp_path / "state.json",
        )

    @patch("greymatter_core.node_sync.subprocess.run")
    def test_push_success(self, mock_run, tmp_path):
        ns = self._make_sync(tmp_path)
        peer = PeerNode(name="hub", host="10.0.0.1", user="agent")
        mock_run.side_effect = [
            MagicMock(returncode=0),
            MagicMock(
                returncode=0,
                stdout=">f+++++++++ entry1.yaml\n>f+++++++++ entry2.yaml\n.f...p..... existing.yaml\n",
            ),
        ]
        result = ns.push(peer)
        assert result.ok
        assert result.files_transferred == 2
        assert result.files_skipped == 1
        assert result.direction == "push"

    @patch("greymatter_core.node_sync.subprocess.run")
    def test_push_remote_dir_failure(self, mock_run, tmp_path):
        ns = self._make_sync(tmp_path)
        peer = PeerNode(name="hub", host="10.0.0.1", user="agent")
        mock_run.return_value = MagicMock(returncode=1)
        result = ns.push(peer)
        assert not result.ok
        assert "Cannot create remote dir" in result.errors[0]

    @patch("greymatter_core.node_sync.subprocess.run")
    def test_push_rsync_failure(self, mock_run, tmp_path):
        ns = self._make_sync(tmp_path)
        peer = PeerNode(name="hub", host="10.0.0.1", user="agent")
        mock_run.side_effect = [
            MagicMock(returncode=0),
            MagicMock(returncode=1, stderr="connection refused"),
        ]
        result = ns.push(peer)
        assert not result.ok
        assert "connection refused" in result.errors[0]

    @patch("greymatter_core.node_sync.subprocess.run")
    def test_push_timeout(self, mock_run, tmp_path):
        ns = self._make_sync(tmp_path)
        peer = PeerNode(name="hub", host="10.0.0.1", user="agent")
        mock_run.side_effect = [
            MagicMock(returncode=0),
            subprocess.TimeoutExpired(cmd="rsync", timeout=120),
        ]
        result = ns.push(peer)
        assert not result.ok
        assert "timed out" in result.errors[0]

    @patch("greymatter_core.node_sync.subprocess.run")
    def test_pull_success(self, mock_run, tmp_path):
        ns = self._make_sync(tmp_path)
        peer = PeerNode(name="hub", host="10.0.0.1", user="agent")
        mock_run.return_value = MagicMock(
            returncode=0,
            stdout=">f+++++++++ new_entry.yaml\n",
        )
        result = ns.pull(peer, import_to_db=False)
        assert result.ok
        assert result.files_transferred == 1
        assert result.direction == "pull"

    @patch("greymatter_core.node_sync.subprocess.run")
    def test_pull_rsync_failure(self, mock_run, tmp_path):
        ns = self._make_sync(tmp_path)
        peer = PeerNode(name="hub", host="10.0.0.1", user="agent")
        mock_run.return_value = MagicMock(returncode=1, stderr="host unreachable")
        result = ns.pull(peer)
        assert not result.ok
        assert "host unreachable" in result.errors[0]

    @patch("greymatter_core.node_sync.subprocess.run")
    def test_pull_timeout(self, mock_run, tmp_path):
        ns = self._make_sync(tmp_path)
        peer = PeerNode(name="hub", host="10.0.0.1", user="agent")
        mock_run.side_effect = subprocess.TimeoutExpired(cmd="rsync", timeout=120)
        result = ns.pull(peer)
        assert not result.ok
        assert "timed out" in result.errors[0]


# ---------------------------------------------------------------------------
# rsync_push / rsync_pull (labeled variants)
# ---------------------------------------------------------------------------

class TestLabeledRsyncOps:
    def _make_sync(self, tmp_path):
        return NodeSync(
            config_path=tmp_path / "nonexistent.yaml",
            entries_dir=tmp_path / "entries",
            observations_dir=tmp_path / "observations",
            patterns_dir=tmp_path / "patterns",
            state_path=tmp_path / "state.json",
        )

    @patch("greymatter_core.node_sync.subprocess.run")
    def test_rsync_push_observations(self, mock_run, tmp_path):
        ns = self._make_sync(tmp_path)
        peer = PeerNode(name="hub", host="10.0.0.1", user="agent")
        mock_run.side_effect = [
            MagicMock(returncode=0),
            MagicMock(returncode=0, stdout=">f+++++++++ obs1.yaml\n"),
        ]
        result = ns._rsync_push(
            peer, ns.observations_dir, peer.remote_observations_path, "observations"
        )
        assert result.ok
        assert result.direction == "push-observations"
        assert result.files_transferred == 1

    @patch("greymatter_core.node_sync.subprocess.run")
    def test_rsync_pull_patterns(self, mock_run, tmp_path):
        ns = self._make_sync(tmp_path)
        peer = PeerNode(name="hub", host="10.0.0.1", user="agent")
        mock_run.return_value = MagicMock(
            returncode=0,
            stdout=">f+++++++++ pat1.yaml\n>f+++++++++ pat2.yaml\n",
        )
        result = ns._rsync_pull(
            peer, peer.remote_patterns_path, ns.patterns_dir, "patterns"
        )
        assert result.ok
        assert result.direction == "pull-patterns"
        assert result.files_transferred == 2

    @patch("greymatter_core.node_sync.subprocess.run")
    def test_rsync_push_remote_dir_failure(self, mock_run, tmp_path):
        ns = self._make_sync(tmp_path)
        peer = PeerNode(name="hub", host="10.0.0.1", user="agent")
        mock_run.return_value = MagicMock(returncode=1)
        result = ns._rsync_push(peer, ns.observations_dir, "/remote/obs", "observations")
        assert not result.ok
        assert "Cannot create remote observations dir" in result.errors[0]


# ---------------------------------------------------------------------------
# sync_all
# ---------------------------------------------------------------------------

class TestSyncAll:
    @patch("greymatter_core.node_sync.subprocess.run")
    def test_sync_all_no_peers(self, mock_run, tmp_path):
        ns = NodeSync(
            config_path=tmp_path / "nonexistent.yaml",
            entries_dir=tmp_path / "entries",
            observations_dir=tmp_path / "observations",
            patterns_dir=tmp_path / "patterns",
            state_path=tmp_path / "state.json",
        )
        round_result = ns.sync_all()
        assert round_result.ok
        assert round_result.total_transferred == 0
        assert round_result.started_at != ""
        assert round_result.finished_at != ""

    @patch("greymatter_core.node_sync.subprocess.run")
    def test_sync_all_handles_exception(self, mock_run, tmp_path):
        ns = NodeSync(
            config_path=tmp_path / "nonexistent.yaml",
            entries_dir=tmp_path / "entries",
            observations_dir=tmp_path / "observations",
            patterns_dir=tmp_path / "patterns",
            state_path=tmp_path / "state.json",
        )
        ns.peers.append(PeerNode(name="bad", host="10.0.0.1", user="agent"))
        mock_run.side_effect = OSError("network down")
        round_result = ns.sync_all()
        assert not round_result.ok
        assert round_result.total_errors > 0


# ---------------------------------------------------------------------------
# get_status
# ---------------------------------------------------------------------------

class TestGetStatus:
    def test_status_with_no_peers(self, tmp_path):
        ns = NodeSync(
            config_path=tmp_path / "nonexistent.yaml",
            entries_dir=tmp_path / "entries",
            observations_dir=tmp_path / "observations",
            patterns_dir=tmp_path / "patterns",
            state_path=tmp_path / "state.json",
        )
        status = ns.get_status()
        assert status["local_entries"] == 0
        assert status["local_observations"] == 0
        assert status["local_patterns"] == 0
        assert status["peers"] == {}

    def test_status_counts_yaml_files(self, tmp_path):
        entries = tmp_path / "entries"
        entries.mkdir(parents=True)
        (entries / "e1.yaml").write_text("id: e1")
        (entries / "e2.yaml").write_text("id: e2")
        (entries / "ignored.txt").write_text("not yaml")

        ns = NodeSync(
            config_path=tmp_path / "nonexistent.yaml",
            entries_dir=entries,
            observations_dir=tmp_path / "observations",
            patterns_dir=tmp_path / "patterns",
            state_path=tmp_path / "state.json",
        )
        status = ns.get_status()
        assert status["local_entries"] == 2

    def test_status_includes_peer_state(self, tmp_path):
        config = {"peers": [{"name": "hub", "host": "10.0.0.1"}]}
        cfg_path = tmp_path / "sync_config.yaml"
        cfg_path.write_text(yaml.dump(config))

        ns = NodeSync(
            config_path=cfg_path,
            entries_dir=tmp_path / "entries",
            observations_dir=tmp_path / "observations",
            patterns_dir=tmp_path / "patterns",
            state_path=tmp_path / "state.json",
        )
        ns._sync_state["hub"] = {
            "last_sync": "2026-03-23T12:00:00",
            "last_push": {"entries_transferred": 5},
        }
        status = ns.get_status()
        assert "hub" in status["peers"]
        assert status["peers"]["hub"]["last_sync"] == "2026-03-23T12:00:00"
        assert status["peers"]["hub"]["last_push"]["entries_transferred"] == 5


# ---------------------------------------------------------------------------
# Directory creation
# ---------------------------------------------------------------------------

class TestDirectoryCreation:
    def test_creates_knowledge_dirs(self, tmp_path):
        entries = tmp_path / "entries"
        observations = tmp_path / "observations"
        patterns = tmp_path / "patterns"
        assert not entries.exists()

        NodeSync(
            config_path=tmp_path / "nonexistent.yaml",
            entries_dir=entries,
            observations_dir=observations,
            patterns_dir=patterns,
            state_path=tmp_path / "state.json",
        )
        assert entries.is_dir()
        assert observations.is_dir()
        assert patterns.is_dir()
