"""Universal memory sync engine for cross-agent interoperability.

Provides bidirectional sync between headroom's memory DB and any
agent's native memory format via pluggable adapters.

Architecture:
    DB ← sync_import → Agent files   (agent's knowledge enters the shared DB)
    DB → sync_export → Agent files   (shared knowledge flows to the agent)
    sync() = import + export          (bidirectional, fast no-op when unchanged)

Usage:
    from headroom.memory.sync import sync, SyncResult
    from headroom.memory.sync_adapters.claude_code import ClaudeCodeAdapter

    adapter = ClaudeCodeAdapter(memory_dir=Path("~/.claude/projects/.../memory"))
    backend = LocalBackend(config)

    result: SyncResult = await sync(backend, adapter, user_id="tcms")
"""

from __future__ import annotations

import hashlib
import json
import logging
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from headroom import paths as _paths

logger = logging.getLogger("headroom.memory.sync")

# State file for fast no-op detection (workspace bucket, respects
# HEADROOM_WORKSPACE_DIR). Resolved at import time, matching prior behavior.
_DEFAULT_STATE_PATH = _paths.sync_state_path()


# ---------------------------------------------------------------------------
# Data models
# ---------------------------------------------------------------------------


@dataclass
class SyncResult:
    """Result of a sync operation."""

    imported: int = 0  # agent files → DB
    exported: int = 0  # DB → agent files
    skipped_unchanged: int = 0
    skipped_dedup: int = 0
    duration_ms: float = 0


@dataclass
class AgentMemory:
    """A memory entry read from an agent's native format."""

    content: str
    category: str = ""
    source_file: str = ""
    content_hash: str = ""
    metadata: dict[str, Any] = field(default_factory=dict)

    def __post_init__(self) -> None:
        if not self.content_hash:
            self.content_hash = hashlib.sha256(self.content.encode()).hexdigest()[:16]


# ---------------------------------------------------------------------------
# Adapter interface
# ---------------------------------------------------------------------------


class AgentMemoryAdapter(ABC):
    """Base class for agent memory format adapters.

    Each agent (Claude Code, Codex, Aider, Cursor) has a subclass
    that knows how to read/write that agent's native memory format.
    """

    agent_name: str = "unknown"

    @abstractmethod
    async def read_memories(self) -> list[AgentMemory]:
        """Read memories from the agent's native format.

        Returns a list of AgentMemory entries found in the agent's files.
        """
        ...

    @abstractmethod
    async def write_memories(self, memories: list[dict[str, Any]]) -> int:
        """Write memories to the agent's native format.

        Args:
            memories: List of dicts with keys: content, category, importance,
                      headroom_id, source_agent, content_hash.

        Returns:
            Count of memories written.
        """
        ...

    @abstractmethod
    def fingerprint(self) -> str:
        """Fast hash of the agent's memory state.

        Used for no-op detection: if the fingerprint hasn't changed
        since last sync, we can skip the full read/compare cycle.
        """
        ...


# ---------------------------------------------------------------------------
# Sync state persistence
# ---------------------------------------------------------------------------


def _load_sync_state(state_path: Path) -> dict[str, Any]:
    """Load sync state from disk."""
    if state_path.exists():
        try:
            result: dict[str, Any] = json.loads(state_path.read_text(encoding="utf-8"))
            return result
        except (json.JSONDecodeError, OSError):
            pass
    return {}


def _save_sync_state(state_path: Path, state: dict[str, Any]) -> None:
    """Save sync state to disk."""
    state_path.parent.mkdir(parents=True, exist_ok=True)
    state_path.write_text(json.dumps(state, indent=2), encoding="utf-8")


def _db_fingerprint(memories: list[Any]) -> str:
    """Compute a fast fingerprint of DB state."""
    if not memories:
        return "empty"
    # Hash: count + most recent created_at
    parts = [str(len(memories))]
    for m in memories[:5]:  # Sample first 5 for speed
        parts.append(getattr(m, "id", "")[:8])
    return hashlib.sha256("|".join(parts).encode()).hexdigest()[:16]


# ---------------------------------------------------------------------------
# Sync engine
# ---------------------------------------------------------------------------


async def sync(
    backend: Any,
    adapter: AgentMemoryAdapter,
    user_id: str,
    state_path: Path = _DEFAULT_STATE_PATH,
    force: bool = False,
) -> SyncResult:
    """Bidirectional sync between headroom DB and an agent's memory.

    1. Fast no-op check (fingerprint comparison)
    2. Import: agent files → DB (new entries only, deduped by content hash)
    3. Export: DB → agent files (entries not already in agent's files)

    Args:
        backend: LocalBackend instance (must have save_memory, get_user_memories).
        adapter: Agent-specific memory adapter.
        user_id: User ID for memory scoping.
        state_path: Path to sync state file.
        force: Skip no-op check and always sync.

    Returns:
        SyncResult with import/export counts and timing.
    """
    start = time.monotonic()
    result = SyncResult()

    # --- Fast no-op check ---
    if not force:
        state = _load_sync_state(state_path)
        adapter_key = f"{adapter.agent_name}:{user_id}"
        prev = state.get(adapter_key, {})

        current_agent_fp = adapter.fingerprint()
        all_memories = await backend.get_user_memories(user_id, limit=500)
        current_db_fp = _db_fingerprint(all_memories)

        if (
            prev.get("agent_fingerprint") == current_agent_fp
            and prev.get("db_fingerprint") == current_db_fp
        ):
            result.duration_ms = (time.monotonic() - start) * 1000
            logger.info(
                f"Sync [{adapter.agent_name}]: no-op — nothing changed ({result.duration_ms:.1f}ms)"
            )
            return result
    else:
        all_memories = await backend.get_user_memories(user_id, limit=500)

    # --- Phase 1: Import (agent files → DB) ---
    result.imported = await sync_import(backend, adapter, user_id, all_memories)

    # --- Phase 2: Export (DB → agent files) ---
    # Re-fetch if imports happened (new entries)
    if result.imported > 0:
        all_memories = await backend.get_user_memories(user_id, limit=500)
    result.exported = await sync_export(backend, adapter, user_id, all_memories)

    # --- Update sync state ---
    state = _load_sync_state(state_path)
    adapter_key = f"{adapter.agent_name}:{user_id}"
    state[adapter_key] = {
        "agent_fingerprint": adapter.fingerprint(),
        "db_fingerprint": _db_fingerprint(all_memories),
        "last_sync": datetime.now(timezone.utc).isoformat(),
        "last_imported": result.imported,
        "last_exported": result.exported,
    }
    _save_sync_state(state_path, state)

    result.duration_ms = (time.monotonic() - start) * 1000
    logger.info(
        f"Sync [{adapter.agent_name}]: imported={result.imported}, "
        f"exported={result.exported} ({result.duration_ms:.1f}ms)"
    )
    return result


async def sync_import(
    backend: Any,
    adapter: AgentMemoryAdapter,
    user_id: str,
    existing_memories: list[Any] | None = None,
) -> int:
    """Import: agent files → DB. Returns count imported."""
    agent_memories = await adapter.read_memories()
    if not agent_memories:
        return 0

    # Build set of existing content hashes for dedup
    if existing_memories is None:
        existing_memories = await backend.get_user_memories(user_id, limit=500)

    existing_hashes: set[str] = set()
    for mem in existing_memories:
        h = (mem.metadata or {}).get("content_hash", "")
        if h:
            existing_hashes.add(h)
        # Also hash the content directly for safety
        existing_hashes.add(hashlib.sha256(mem.content.encode()).hexdigest()[:16])

    imported = 0
    for am in agent_memories:
        if am.content_hash in existing_hashes:
            continue

        # Save to DB with lineage metadata
        await backend.save_memory(
            content=am.content,
            user_id=user_id,
            importance=0.6,
            metadata={
                "source_agent": adapter.agent_name,
                "source_file": am.source_file,
                "content_hash": am.content_hash,
                "synced_at": datetime.now(timezone.utc).isoformat(),
                "sync_direction": "import",
                **am.metadata,
            },
        )
        existing_hashes.add(am.content_hash)
        imported += 1

    if imported:
        logger.info(f"Sync [{adapter.agent_name}]: imported {imported} memories from agent files")
    return imported


async def sync_export(
    backend: Any,
    adapter: AgentMemoryAdapter,
    user_id: str,
    existing_memories: list[Any] | None = None,
) -> int:
    """Export: DB → agent files. Returns count exported."""
    if existing_memories is None:
        existing_memories = await backend.get_user_memories(user_id, limit=500)

    if not existing_memories:
        return 0

    # Read what the agent already has (to avoid re-exporting)
    agent_memories = await adapter.read_memories()
    agent_hashes: set[str] = {am.content_hash for am in agent_memories}

    # Find memories to export (not already in agent, not imported FROM this agent)
    to_export: list[dict[str, Any]] = []
    for mem in existing_memories:
        content_hash = hashlib.sha256(mem.content.encode()).hexdigest()[:16]

        # Skip if agent already has it
        if content_hash in agent_hashes:
            continue

        # Skip if this memory was originally imported FROM this same agent
        # (prevents echo: agent → DB → agent)
        meta = mem.metadata or {}
        if (
            meta.get("source_agent") == adapter.agent_name
            and meta.get("sync_direction") == "import"
        ):
            continue

        to_export.append(
            {
                "content": mem.content,
                "category": getattr(mem, "category", "") or "",
                "importance": getattr(mem, "importance", 0.5),
                "headroom_id": mem.id,
                "source_agent": meta.get("source_agent", "unknown"),
                "content_hash": content_hash,
                "created_at": mem.created_at.isoformat()
                if hasattr(mem.created_at, "isoformat")
                else str(mem.created_at),
            }
        )

    if not to_export:
        return 0

    exported = await adapter.write_memories(to_export)
    if exported:
        logger.info(f"Sync [{adapter.agent_name}]: exported {exported} memories to agent files")
    return exported


# ---------------------------------------------------------------------------
# CLI entry point: python -m headroom.memory.sync --db ... --user ... --agent ...
# ---------------------------------------------------------------------------


def _build_sync_backend(db_path: str) -> Any:
    """Build the memory backend used by the sync subprocess.

    Match the proxy MCP server (see ``headroom/memory/mcp_server.py``): use the
    torch-free ONNX embedder so ``wrap --memory`` sync works on the proxy extras
    without sentence-transformers/PyTorch (#1092). It loads the same
    ``all-MiniLM-L6-v2`` 384-dim model as the local embedder, so vectors stay
    compatible with what the proxy writes — no DB migration.
    """
    from headroom.memory.backends.local import LocalBackend, LocalBackendConfig

    config = LocalBackendConfig(db_path=db_path, embedder_backend="onnx")
    return LocalBackend(config)


def main() -> None:
    """CLI entry point for running sync from a subprocess."""
    import argparse

    parser = argparse.ArgumentParser(description="Headroom memory sync")
    parser.add_argument("--db", required=True, help="Path to memory DB")
    parser.add_argument("--user", required=True, help="User ID")
    parser.add_argument("--agent", required=True, choices=["claude", "codex"], help="Agent to sync")
    parser.add_argument("--force", action="store_true", help="Skip no-op check")
    args = parser.parse_args()

    import asyncio
    import json as _json

    async def _run() -> None:
        backend = _build_sync_backend(args.db)
        await backend._ensure_initialized()

        if args.agent == "claude":
            from headroom.memory.sync_adapters.claude_code import (
                ClaudeCodeAdapter,
                get_claude_memory_dir,
            )

            adapter: ClaudeCodeAdapter | Any = ClaudeCodeAdapter(get_claude_memory_dir())
        elif args.agent == "codex":
            from headroom.memory.sync_adapters.codex_agent import CodexAdapter

            adapter = CodexAdapter()
        else:
            print(_json.dumps({"error": f"Unknown agent: {args.agent}"}))
            return

        result = await sync(backend, adapter, args.user, force=args.force)
        await backend.close()
        print(
            _json.dumps(
                {
                    "imported": result.imported,
                    "exported": result.exported,
                    "ms": round(result.duration_ms),
                }
            )
        )

    asyncio.run(_run())


if __name__ == "__main__":
    main()
