"""Cross-node knowledge sync over SSH.

Extends KnowledgeYAMLSync with push/pull between GreyMatter nodes.
Uses SSH + rsync for transport (all nodes on Tailscale mesh).

Architecture:
- Each node has ~/.greymatter/knowledge/{entries,observations,patterns}/*.yaml
- Push: rsync local dirs → remote dirs
- Pull: rsync remote dirs → local, then import with newer_wins
- Full mesh or hub-and-spoke — topology defined in sync_config.yaml

Synced data types:
- Knowledge entries: all non-pruned entries
- Observations: confidence >= 0.5, not pruned
- Patterns: all patterns with evidence

Nodes without the full knowledge store (e.g. Ubuntu workers) still
receive YAML files for read access. Import into SQLite only happens
on nodes with greymatter_core installed.
"""

from __future__ import annotations

import json
import logging
import subprocess
import time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Optional

import yaml

logger = logging.getLogger(__name__)

CONFIG_PATH = Path("~/.greymatter/sync_config.yaml").expanduser()
ENTRIES_DIR = Path("~/.greymatter/knowledge/entries").expanduser()
OBSERVATIONS_DIR = Path("~/.greymatter/knowledge/observations").expanduser()
PATTERNS_DIR = Path("~/.greymatter/knowledge/patterns").expanduser()
SYNC_STATE_PATH = Path("~/.greymatter/knowledge/.sync_state.json").expanduser()


@dataclass
class PeerNode:
    """A remote node to sync with."""
    name: str
    host: str
    user: str
    ssh_key: Optional[str] = None
    remote_path: str = "~/.greymatter/knowledge/entries"
    remote_observations_path: str = "~/.greymatter/knowledge/observations"
    remote_patterns_path: str = "~/.greymatter/knowledge/patterns"
    proxy_host: Optional[str] = None  # SSH jump host if needed
    proxy_user: Optional[str] = None
    proxy_key: Optional[str] = None
    enabled: bool = True


@dataclass
class SyncResult:
    """Result of a single sync operation."""
    peer: str
    direction: str  # "push" or "pull"
    files_transferred: int = 0
    files_skipped: int = 0
    entries_imported: int = 0
    entries_updated: int = 0
    errors: list[str] = field(default_factory=list)
    duration_s: float = 0.0
    timestamp: str = ""

    @property
    def ok(self) -> bool:
        return len(self.errors) == 0


@dataclass
class SyncRoundResult:
    """Result of a full sync round across all peers."""
    results: list[SyncResult] = field(default_factory=list)
    started_at: str = ""
    finished_at: str = ""
    duration_s: float = 0.0

    @property
    def total_transferred(self) -> int:
        return sum(r.files_transferred for r in self.results)

    @property
    def total_errors(self) -> int:
        return sum(len(r.errors) for r in self.results)

    @property
    def ok(self) -> bool:
        return self.total_errors == 0


class NodeSync:
    """Cross-node knowledge sync engine.

    Syncs ~/.greymatter/knowledge/{entries,observations,patterns}/ between
    nodes using rsync over SSH. Supports direct and proxied connections.
    """

    def __init__(
        self,
        config_path: Path = CONFIG_PATH,
        entries_dir: Path = ENTRIES_DIR,
        observations_dir: Path = OBSERVATIONS_DIR,
        patterns_dir: Path = PATTERNS_DIR,
        state_path: Path = SYNC_STATE_PATH,
    ) -> None:
        self.config_path = config_path
        self.entries_dir = entries_dir
        self.observations_dir = observations_dir
        self.patterns_dir = patterns_dir
        self.state_path = state_path
        self.peers: list[PeerNode] = []
        self._sync_state: dict[str, Any] = {}

        self.entries_dir.mkdir(parents=True, exist_ok=True)
        self.observations_dir.mkdir(parents=True, exist_ok=True)
        self.patterns_dir.mkdir(parents=True, exist_ok=True)
        self._load_config()
        self._load_state()

    def _load_config(self) -> None:
        """Load peer definitions from sync_config.yaml."""
        if not self.config_path.exists():
            logger.warning("No sync config at %s", self.config_path)
            return

        with open(self.config_path) as f:
            config = yaml.safe_load(f) or {}

        for peer_data in config.get("peers", []):
            peer = PeerNode(
                name=peer_data["name"],
                host=peer_data["host"],
                user=peer_data.get("user", "agenticai"),
                ssh_key=peer_data.get("ssh_key"),
                remote_path=peer_data.get("remote_path", "~/.greymatter/knowledge/entries"),
                remote_observations_path=peer_data.get(
                    "remote_observations_path", "~/.greymatter/knowledge/observations"
                ),
                remote_patterns_path=peer_data.get(
                    "remote_patterns_path", "~/.greymatter/knowledge/patterns"
                ),
                proxy_host=peer_data.get("proxy_host"),
                proxy_user=peer_data.get("proxy_user"),
                proxy_key=peer_data.get("proxy_key"),
                enabled=peer_data.get("enabled", True),
            )
            if peer.enabled:
                self.peers.append(peer)

        logger.info("Loaded %d sync peers from config", len(self.peers))

    def _load_state(self) -> None:
        """Load persistent sync state (last sync times, etc.)."""
        if self.state_path.exists():
            with open(self.state_path) as f:
                self._sync_state = json.load(f)

    def _save_state(self) -> None:
        """Persist sync state."""
        self.state_path.parent.mkdir(parents=True, exist_ok=True)
        with open(self.state_path, "w") as f:
            json.dump(self._sync_state, f, indent=2)

    def _build_ssh_cmd(self, peer: PeerNode) -> list[str]:
        """Build SSH command prefix for a peer."""
        parts = ["ssh", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=no"]
        if peer.ssh_key:
            parts.extend(["-i", str(Path(peer.ssh_key).expanduser())])
        if peer.proxy_host:
            proxy_cmd = f"ssh -o BatchMode=yes -o StrictHostKeyChecking=no"
            if peer.proxy_key:
                proxy_cmd += f" -i {Path(peer.proxy_key).expanduser()}"
            proxy_user = peer.proxy_user or peer.user
            proxy_cmd += f" {proxy_user}@{peer.proxy_host} -W %h:%p"
            parts.extend(["-o", f"ProxyCommand={proxy_cmd}"])
        parts.append(f"{peer.user}@{peer.host}")
        return parts

    def _build_rsync_cmd(
        self,
        peer: PeerNode,
        source: str,
        dest: str,
        delete: bool = False,
    ) -> list[str]:
        """Build rsync command for file transfer."""
        ssh_cmd = "ssh -o BatchMode=yes -o StrictHostKeyChecking=no"
        if peer.ssh_key:
            ssh_cmd += f" -i {Path(peer.ssh_key).expanduser()}"
        if peer.proxy_host:
            proxy_cmd = f"ssh -o BatchMode=yes -o StrictHostKeyChecking=no"
            if peer.proxy_key:
                proxy_cmd += f" -i {Path(peer.proxy_key).expanduser()}"
            proxy_user = peer.proxy_user or peer.user
            proxy_cmd += f" {proxy_user}@{peer.proxy_host} -W %h:%p"
            ssh_cmd += f' -o "ProxyCommand={proxy_cmd}"'

        cmd = [
            "rsync", "-avz", "--itemize-changes",
            "--include=*.yaml", "--exclude=.*",
            "-e", ssh_cmd,
        ]
        if delete:
            cmd.append("--delete")
        cmd.extend([source, dest])
        return cmd

    def _ensure_remote_dir(self, peer: PeerNode) -> bool:
        """Ensure the remote entries directory exists."""
        return self._ensure_remote_dir_path(peer, peer.remote_path)

    def _ensure_remote_dir_path(self, peer: PeerNode, remote_path: str) -> bool:
        """Ensure a remote directory exists."""
        ssh_cmd = self._build_ssh_cmd(peer)
        cmd = ssh_cmd + [f"mkdir -p {remote_path}"]
        try:
            result = subprocess.run(
                cmd, capture_output=True, text=True, timeout=30
            )
            return result.returncode == 0
        except (subprocess.TimeoutExpired, OSError) as e:
            logger.error("Failed to create remote dir on %s: %s", peer.name, e)
            return False

    def _rsync_push(
        self,
        peer: PeerNode,
        local_dir: Path,
        remote_path: str,
        label: str,
    ) -> SyncResult:
        """Push a local directory to a remote peer via rsync.

        Args:
            peer: Remote peer node.
            local_dir: Local directory to push.
            remote_path: Remote directory path.
            label: Label for logging (e.g., "entries", "observations").

        Returns:
            SyncResult with transfer counts.
        """
        start = time.time()
        result = SyncResult(
            peer=peer.name,
            direction=f"push-{label}",
            timestamp=datetime.now().isoformat(),
        )

        if not self._ensure_remote_dir_path(peer, remote_path):
            result.errors.append(f"Cannot create remote {label} dir on {peer.name}")
            result.duration_s = time.time() - start
            return result

        source = str(local_dir) + "/"
        dest = f"{peer.user}@{peer.host}:{remote_path}/"

        cmd = self._build_rsync_cmd(peer, source, dest)
        try:
            proc = subprocess.run(
                cmd, capture_output=True, text=True, timeout=120
            )
            if proc.returncode != 0:
                result.errors.append(f"rsync push {label} failed: {proc.stderr.strip()}")
            else:
                for line in proc.stdout.splitlines():
                    if line.startswith(">f"):
                        result.files_transferred += 1
                    elif line.startswith(".f"):
                        result.files_skipped += 1

                logger.info(
                    "Push %s to %s: %d transferred, %d skipped",
                    label, peer.name, result.files_transferred, result.files_skipped,
                )
        except subprocess.TimeoutExpired:
            result.errors.append(f"rsync push {label} to {peer.name} timed out")
        except OSError as e:
            result.errors.append(f"rsync push {label} error: {e}")

        result.duration_s = time.time() - start
        return result

    def _rsync_pull(
        self,
        peer: PeerNode,
        remote_path: str,
        local_dir: Path,
        label: str,
    ) -> SyncResult:
        """Pull a remote directory from a peer via rsync.

        Args:
            peer: Remote peer node.
            remote_path: Remote directory path.
            local_dir: Local directory to pull into.
            label: Label for logging.

        Returns:
            SyncResult with transfer counts.
        """
        start = time.time()
        result = SyncResult(
            peer=peer.name,
            direction=f"pull-{label}",
            timestamp=datetime.now().isoformat(),
        )

        source = f"{peer.user}@{peer.host}:{remote_path}/"
        dest = str(local_dir) + "/"

        cmd = self._build_rsync_cmd(peer, source, dest)
        try:
            proc = subprocess.run(
                cmd, capture_output=True, text=True, timeout=120
            )
            if proc.returncode != 0:
                result.errors.append(f"rsync pull {label} failed: {proc.stderr.strip()}")
            else:
                for line in proc.stdout.splitlines():
                    if line.startswith(">f"):
                        result.files_transferred += 1
                    elif line.startswith(".f"):
                        result.files_skipped += 1

                logger.info(
                    "Pull %s from %s: %d transferred, %d skipped",
                    label, peer.name, result.files_transferred, result.files_skipped,
                )
        except subprocess.TimeoutExpired:
            result.errors.append(f"rsync pull {label} from {peer.name} timed out")
        except OSError as e:
            result.errors.append(f"rsync pull {label} error: {e}")

        result.duration_s = time.time() - start
        return result

    def push(self, peer: PeerNode) -> SyncResult:
        """Push local entries to a remote peer.

        Uses rsync to send new/updated YAML files. Does NOT delete
        remote files that don't exist locally (additive sync).
        """
        start = time.time()
        result = SyncResult(
            peer=peer.name,
            direction="push",
            timestamp=datetime.now().isoformat(),
        )

        if not self._ensure_remote_dir(peer):
            result.errors.append(f"Cannot create remote dir on {peer.name}")
            result.duration_s = time.time() - start
            return result

        source = str(self.entries_dir) + "/"
        dest = f"{peer.user}@{peer.host}:{peer.remote_path}/"

        cmd = self._build_rsync_cmd(peer, source, dest)
        try:
            proc = subprocess.run(
                cmd, capture_output=True, text=True, timeout=120
            )
            if proc.returncode != 0:
                result.errors.append(f"rsync push failed: {proc.stderr.strip()}")
            else:
                # Count transferred files from rsync itemize output
                for line in proc.stdout.splitlines():
                    if line.startswith(">f"):
                        result.files_transferred += 1
                    elif line.startswith(".f"):
                        result.files_skipped += 1

                logger.info(
                    "Push to %s: %d transferred, %d skipped",
                    peer.name, result.files_transferred, result.files_skipped,
                )
        except subprocess.TimeoutExpired:
            result.errors.append(f"rsync push to {peer.name} timed out")
        except OSError as e:
            result.errors.append(f"rsync push error: {e}")

        result.duration_s = time.time() - start
        return result

    def pull(self, peer: PeerNode, import_to_db: bool = True) -> SyncResult:
        """Pull entries from a remote peer.

        Downloads YAML files, then optionally imports into the local
        knowledge store using newer_wins conflict resolution.
        """
        start = time.time()
        result = SyncResult(
            peer=peer.name,
            direction="pull",
            timestamp=datetime.now().isoformat(),
        )

        source = f"{peer.user}@{peer.host}:{peer.remote_path}/"
        dest = str(self.entries_dir) + "/"

        cmd = self._build_rsync_cmd(peer, source, dest)
        try:
            proc = subprocess.run(
                cmd, capture_output=True, text=True, timeout=120
            )
            if proc.returncode != 0:
                result.errors.append(f"rsync pull failed: {proc.stderr.strip()}")
            else:
                for line in proc.stdout.splitlines():
                    if line.startswith(">f"):
                        result.files_transferred += 1
                    elif line.startswith(".f"):
                        result.files_skipped += 1

                logger.info(
                    "Pull from %s: %d transferred, %d skipped",
                    peer.name, result.files_transferred, result.files_skipped,
                )

                # Import new/updated files into local knowledge store
                if import_to_db and result.files_transferred > 0:
                    result = self._import_pulled_entries(result)

        except subprocess.TimeoutExpired:
            result.errors.append(f"rsync pull from {peer.name} timed out")
        except OSError as e:
            result.errors.append(f"rsync pull error: {e}")

        result.duration_s = time.time() - start
        return result

    def _import_pulled_entries(self, result: SyncResult) -> SyncResult:
        """Import pulled YAML files into the local knowledge store."""
        try:
            from .knowledge_sync import get_knowledge_sync

            sync = get_knowledge_sync()
            for yaml_file in self.entries_dir.glob("*.yaml"):
                try:
                    entry = sync.import_entry(yaml_file, overwrite=False)
                    if entry:
                        result.entries_imported += 1
                    else:
                        # Entry exists — try newer_wins update
                        with open(yaml_file) as f:
                            data = yaml.safe_load(f)
                        if data and "id" in data:
                            existing = sync.store.get(data["id"])
                            if existing and data.get("updated_at"):
                                remote_updated = datetime.fromisoformat(data["updated_at"])
                                if existing.updated_at and remote_updated > existing.updated_at:
                                    entry = sync.import_entry(yaml_file, overwrite=True)
                                    if entry:
                                        result.entries_updated += 1
                except Exception as e:
                    logger.debug("Import error for %s: %s", yaml_file.name, e)

        except ImportError:
            logger.debug("Knowledge store not available — YAML files synced but not imported")

        return result

    def _export_observations_to_yaml(self) -> int:
        """Export eligible observations to YAML before push.

        Returns:
            Number of observations exported.
        """
        try:
            from greymatter.lib.observation_sync import ObservationYAMLSync
            from greymatter_core.db import get_database

            sync = ObservationYAMLSync(get_database(), str(self.observations_dir.parent))
            return sync.export_eligible(self.observations_dir)
        except ImportError:
            logger.debug("Observation sync not available — skipping export")
            return 0

    def _export_patterns_to_yaml(self) -> int:
        """Export all patterns to YAML before push.

        Returns:
            Number of patterns exported.
        """
        try:
            from greymatter.lib.pattern_sync import PatternYAMLSync
            from greymatter_core.db import get_database

            sync = PatternYAMLSync(get_database(), str(self.patterns_dir.parent))
            return sync.export_all(self.patterns_dir)
        except ImportError:
            logger.debug("Pattern sync not available — skipping export")
            return 0

    def _import_pulled_observations(self, result: SyncResult) -> SyncResult:
        """Import pulled observation YAML files into the local database."""
        try:
            from greymatter.lib.observation_sync import ObservationYAMLSync
            from greymatter_core.db import get_database

            sync = ObservationYAMLSync(get_database(), str(self.observations_dir.parent))
            for yaml_file in self.observations_dir.glob("*.yaml"):
                try:
                    imported = sync.import_entry(yaml_file, overwrite=False)
                    if imported:
                        result.entries_imported += 1
                    else:
                        # Entry exists — try newer_wins update
                        with open(yaml_file) as f:
                            data = yaml.safe_load(f)
                        if data and "id" in data and data.get("updated_at"):
                            existing = sync.db.execute(
                                "SELECT updated_at FROM observations WHERE id = ?",
                                (data["id"],),
                            ).fetchone()
                            if existing:
                                local_updated = dict(existing).get("updated_at", "")
                                if data["updated_at"] > (local_updated or ""):
                                    sync.import_entry(yaml_file, overwrite=True)
                                    result.entries_updated += 1
                except Exception as e:
                    logger.debug("Observation import error for %s: %s", yaml_file.name, e)

        except ImportError:
            logger.debug("Observation sync not available — YAML files synced but not imported")

        return result

    def _import_pulled_patterns(self, result: SyncResult) -> SyncResult:
        """Import pulled pattern YAML files into the local database."""
        try:
            from greymatter.lib.pattern_sync import PatternYAMLSync
            from greymatter_core.db import get_database

            sync = PatternYAMLSync(get_database(), str(self.patterns_dir.parent))
            for yaml_file in self.patterns_dir.glob("*.yaml"):
                try:
                    imported = sync.import_entry(yaml_file, overwrite=False)
                    if imported:
                        result.entries_imported += 1
                    else:
                        # Entry exists — try newer_wins update
                        with open(yaml_file) as f:
                            data = yaml.safe_load(f)
                        if data and "id" in data and data.get("updated_at"):
                            existing = sync.db.execute(
                                "SELECT updated_at FROM patterns WHERE id = ?",
                                (data["id"],),
                            ).fetchone()
                            if existing:
                                local_updated = dict(existing).get("updated_at", "")
                                if data["updated_at"] > (local_updated or ""):
                                    sync.import_entry(yaml_file, overwrite=True)
                                    result.entries_updated += 1
                except Exception as e:
                    logger.debug("Pattern import error for %s: %s", yaml_file.name, e)

        except ImportError:
            logger.debug("Pattern sync not available — YAML files synced but not imported")

        return result

    def sync_peer(self, peer: PeerNode) -> list[SyncResult]:
        """Full bidirectional sync with a single peer.

        Syncs knowledge entries, observations (conf >= 0.5), and patterns.
        Export to YAML is performed before push to ensure fresh data.
        """
        results = []

        # --- Knowledge entries (existing behavior) ---
        push_result = self.push(peer)
        results.append(push_result)

        pull_result = self.pull(peer)
        results.append(pull_result)

        # --- Observations (conf >= 0.5, not pruned) ---
        obs_exported = self._export_observations_to_yaml()
        logger.info("Exported %d observations to YAML for sync", obs_exported)

        obs_push = self._rsync_push(
            peer, self.observations_dir, peer.remote_observations_path, "observations"
        )
        results.append(obs_push)

        obs_pull = self._rsync_pull(
            peer, peer.remote_observations_path, self.observations_dir, "observations"
        )
        if obs_pull.files_transferred > 0:
            obs_pull = self._import_pulled_observations(obs_pull)
        results.append(obs_pull)

        # --- Patterns (all patterns with evidence) ---
        pat_exported = self._export_patterns_to_yaml()
        logger.info("Exported %d patterns to YAML for sync", pat_exported)

        pat_push = self._rsync_push(
            peer, self.patterns_dir, peer.remote_patterns_path, "patterns"
        )
        results.append(pat_push)

        pat_pull = self._rsync_pull(
            peer, peer.remote_patterns_path, self.patterns_dir, "patterns"
        )
        if pat_pull.files_transferred > 0:
            pat_pull = self._import_pulled_patterns(pat_pull)
        results.append(pat_pull)

        # Update sync state
        self._sync_state[peer.name] = {
            "last_sync": datetime.now().isoformat(),
            "last_push": {
                "entries_transferred": push_result.files_transferred,
                "observations_transferred": obs_push.files_transferred,
                "patterns_transferred": pat_push.files_transferred,
                "errors": len(push_result.errors) + len(obs_push.errors) + len(pat_push.errors),
            },
            "last_pull": {
                "entries_transferred": pull_result.files_transferred,
                "entries_imported": pull_result.entries_imported,
                "entries_updated": pull_result.entries_updated,
                "observations_transferred": obs_pull.files_transferred,
                "observations_imported": obs_pull.entries_imported,
                "observations_updated": obs_pull.entries_updated,
                "patterns_transferred": pat_pull.files_transferred,
                "patterns_imported": pat_pull.entries_imported,
                "patterns_updated": pat_pull.entries_updated,
                "errors": len(pull_result.errors) + len(obs_pull.errors) + len(pat_pull.errors),
            },
        }
        self._save_state()

        return results

    def sync_all(self) -> SyncRoundResult:
        """Sync with all configured peers."""
        round_result = SyncRoundResult(
            started_at=datetime.now().isoformat(),
        )
        start = time.time()

        for peer in self.peers:
            try:
                results = self.sync_peer(peer)
                round_result.results.extend(results)
            except Exception as e:
                logger.error("Sync with %s failed: %s", peer.name, e)
                round_result.results.append(SyncResult(
                    peer=peer.name,
                    direction="error",
                    errors=[str(e)],
                    timestamp=datetime.now().isoformat(),
                ))

        round_result.finished_at = datetime.now().isoformat()
        round_result.duration_s = time.time() - start

        logger.info(
            "Sync round complete: %d peers, %d files transferred, %d errors (%.1fs)",
            len(self.peers),
            round_result.total_transferred,
            round_result.total_errors,
            round_result.duration_s,
        )

        return round_result

    def get_status(self) -> dict[str, Any]:
        """Get current sync status for all peers."""
        status = {
            "config": str(self.config_path),
            "entries_dir": str(self.entries_dir),
            "observations_dir": str(self.observations_dir),
            "patterns_dir": str(self.patterns_dir),
            "local_entries": len(list(self.entries_dir.glob("*.yaml"))),
            "local_observations": len(list(self.observations_dir.glob("*.yaml"))),
            "local_patterns": len(list(self.patterns_dir.glob("*.yaml"))),
            "peers": {},
        }
        for peer in self.peers:
            peer_state = self._sync_state.get(peer.name, {})
            status["peers"][peer.name] = {
                "host": peer.host,
                "enabled": peer.enabled,
                "last_sync": peer_state.get("last_sync", "never"),
                "last_push": peer_state.get("last_push", {}),
                "last_pull": peer_state.get("last_pull", {}),
            }
        return status


def get_node_sync() -> NodeSync:
    """Get a NodeSync instance with default config."""
    return NodeSync()
