"""Entity synchronization service for multi-device sync.

Implements ``_cleveragents/sync/*`` operations: pull, push, and status.
Entities (actors, skills, actions, plans, sessions) are synchronised
between client and server using vector clocks for conflict detection
and last-writer-wins as the default conflict resolution strategy.

The ``local/`` namespace is **never** synced — it exists only on the
client.  Incremental sync is supported via ``since`` timestamps.

Offline support is provided through a queue of pending operations that
are retried on reconnect.
"""

from __future__ import annotations

from datetime import UTC, datetime

import structlog
from ulid import ULID

from cleveragents.a2a.sync_models import (
    ConflictResolution,
    SyncConflict,
    SyncDirection,
    SyncEntitySnapshot,
    SyncEntityType,
    SyncOperationStatus,
    SyncPullRequest,
    SyncPullResponse,
    SyncPushRequest,
    SyncPushResponse,
    SyncQueueEntry,
    SyncState,
    SyncStatusRequest,
    SyncStatusResponse,
    VectorClock,
)
from cleveragents.core.exceptions import BusinessRuleViolation, ValidationError

_logger: structlog.stdlib.BoundLogger = structlog.get_logger(__name__)


def _iso_now() -> str:
    """Return the current UTC time as an ISO-8601 string."""
    return datetime.now(tz=UTC).isoformat()


class SyncService:
    """Application-layer service for entity synchronization.

    Coordinates pull, push, and status operations between local client
    storage and a remote server namespace.

    Args:
        node_id: Unique identifier for this client node (used in
            vector clocks).
    """

    def __init__(self, node_id: str | None = None) -> None:
        if node_id is not None and not isinstance(node_id, str):
            raise TypeError("node_id must be a string or None")
        self._node_id: str = node_id or str(ULID())
        self._sync_states: dict[str, SyncState] = {}
        self._local_entities: dict[str, SyncEntitySnapshot] = {}
        self._server_entities: dict[str, SyncEntitySnapshot] = {}
        self._conflicts: list[SyncConflict] = []
        self._offline_queue: list[SyncQueueEntry] = []

    # ------------------------------------------------------------------
    # Public properties
    # ------------------------------------------------------------------

    @property
    def node_id(self) -> str:
        """Return the unique node identifier for this client."""
        return self._node_id

    @property
    def offline_queue(self) -> list[SyncQueueEntry]:
        """Return the current offline operation queue."""
        return list(self._offline_queue)

    @property
    def conflicts(self) -> list[SyncConflict]:
        """Return all unresolved sync conflicts."""
        return [c for c in self._conflicts if not c.resolved]

    # ------------------------------------------------------------------
    # Core operations
    # ------------------------------------------------------------------

    def pull(self, request: SyncPullRequest) -> SyncPullResponse:
        """Pull entities from the server to the local cache.

        Downloads server namespace entities and detects conflicts
        using vector clocks.

        Args:
            request: The pull request parameters.

        Returns:
            A ``SyncPullResponse`` with pulled entities and any
            conflicts detected.

        Raises:
            TypeError: If *request* is not a ``SyncPullRequest``.
            ValidationError: If the namespace is ``local``.
        """
        if not isinstance(request, SyncPullRequest):
            raise TypeError("request must be a SyncPullRequest instance")
        if request.namespace == "local":
            raise ValidationError("Cannot sync the 'local' namespace")

        namespace = request.namespace or "default"
        now = _iso_now()

        _logger.info(
            "sync.pull.start",
            namespace=namespace,
            entity_types=[et.value for et in request.entity_types],
            since=request.since,
        )

        pulled: list[SyncEntitySnapshot] = []
        conflicts: list[SyncConflict] = []

        server_entities = self._get_server_entities(
            namespace=namespace,
            entity_types=request.entity_types,
            since=request.since,
        )

        for server_entity in server_entities:
            local_key = self._entity_key(server_entity)
            local_entity = self._local_entities.get(local_key)

            if local_entity is None or request.force:
                self._local_entities[local_key] = server_entity
                pulled.append(server_entity)
            elif server_entity.vector_clock.is_concurrent(local_entity.vector_clock):
                conflict = self._create_conflict(
                    local_entity=local_entity,
                    server_entity=server_entity,
                    namespace=namespace,
                )
                conflicts.append(conflict)
                self._conflicts.append(conflict)
            elif local_entity.vector_clock.happens_before(server_entity.vector_clock):
                self._local_entities[local_key] = server_entity
                pulled.append(server_entity)

        sync_state = self._update_sync_state(
            namespace=namespace,
            last_pull_at=now,
            conflicts=conflicts,
        )

        _logger.info(
            "sync.pull.complete",
            namespace=namespace,
            total_pulled=len(pulled),
            total_conflicts=len(conflicts),
        )

        return SyncPullResponse(
            entities=pulled,
            conflicts=conflicts,
            sync_state=sync_state,
            pulled_at=now,
            total_pulled=len(pulled),
        )

    def push(self, request: SyncPushRequest) -> SyncPushResponse:
        """Push local entities to the server namespace.

        Pushes entity definitions from the local cache to the server
        and handles conflicts according to the specified resolution
        strategy.

        Args:
            request: The push request parameters.

        Returns:
            A ``SyncPushResponse`` with accepted/rejected entities
            and any conflicts detected.

        Raises:
            TypeError: If *request* is not a ``SyncPushRequest``.
            ValidationError: If the namespace is ``local``.
        """
        if not isinstance(request, SyncPushRequest):
            raise TypeError("request must be a SyncPushRequest instance")
        if request.namespace == "local":
            raise ValidationError("Cannot sync the 'local' namespace")

        namespace = request.namespace or "default"
        now = _iso_now()

        _logger.info(
            "sync.push.start",
            namespace=namespace,
            entity_count=len(request.entities),
        )

        accepted: list[SyncEntitySnapshot] = []
        rejected: list[SyncEntitySnapshot] = []
        conflicts: list[SyncConflict] = []

        for entity in request.entities:
            server_key = self._entity_key(entity)
            server_entity = self._server_entities.get(server_key)

            if server_entity is None or request.force:
                updated = self._stamp_entity(entity, namespace)
                self._server_entities[server_key] = updated
                self._local_entities[server_key] = updated
                accepted.append(updated)
            elif entity.vector_clock.is_concurrent(server_entity.vector_clock):
                conflict = self._create_conflict(
                    local_entity=entity,
                    server_entity=server_entity,
                    namespace=namespace,
                )
                resolved_entity = self._resolve_conflict(
                    conflict=conflict,
                    resolution=request.resolution,
                )
                if resolved_entity is not None:
                    self._server_entities[server_key] = resolved_entity
                    self._local_entities[server_key] = resolved_entity
                    accepted.append(resolved_entity)
                    conflict.resolved = True
                    conflict.resolved_at = now
                else:
                    rejected.append(entity)
                conflicts.append(conflict)
                self._conflicts.append(conflict)
            elif server_entity.vector_clock.happens_before(entity.vector_clock):
                updated = self._stamp_entity(entity, namespace)
                self._server_entities[server_key] = updated
                self._local_entities[server_key] = updated
                accepted.append(updated)
            else:
                rejected.append(entity)

        sync_state = self._update_sync_state(
            namespace=namespace,
            last_push_at=now,
            conflicts=conflicts,
        )

        _logger.info(
            "sync.push.complete",
            namespace=namespace,
            total_accepted=len(accepted),
            total_rejected=len(rejected),
            total_conflicts=len(conflicts),
        )

        return SyncPushResponse(
            accepted=accepted,
            rejected=rejected,
            conflicts=conflicts,
            sync_state=sync_state,
            pushed_at=now,
            total_accepted=len(accepted),
            total_rejected=len(rejected),
        )

    def status(self, request: SyncStatusRequest) -> SyncStatusResponse:
        """Compare local and server entity versions to detect drift.

        Args:
            request: The status request parameters.

        Returns:
            A ``SyncStatusResponse`` with drift information and
            any detected conflicts.

        Raises:
            TypeError: If *request* is not a ``SyncStatusRequest``.
            ValidationError: If the namespace is ``local``.
        """
        if not isinstance(request, SyncStatusRequest):
            raise TypeError("request must be a SyncStatusRequest instance")
        if request.namespace == "local":
            raise ValidationError("Cannot sync the 'local' namespace")

        namespace = request.namespace or "default"
        now = _iso_now()

        _logger.info(
            "sync.status.start",
            namespace=namespace,
            entity_types=[et.value for et in request.entity_types],
        )

        local_ahead: list[SyncEntitySnapshot] = []
        server_ahead: list[SyncEntitySnapshot] = []
        conflicts: list[SyncConflict] = []

        all_keys = set(self._local_entities.keys()) | set(self._server_entities.keys())

        for key in all_keys:
            local_entity = self._local_entities.get(key)
            server_entity = self._server_entities.get(key)

            if local_entity is not None and server_entity is not None:
                if namespace and local_entity.namespace != namespace:
                    continue
                if (
                    request.entity_types
                    and local_entity.entity_type not in request.entity_types
                ):
                    continue

                if local_entity.vector_clock.is_concurrent(
                    server_entity.vector_clock,
                ):
                    conflict = self._create_conflict(
                        local_entity=local_entity,
                        server_entity=server_entity,
                        namespace=namespace,
                    )
                    conflicts.append(conflict)
                elif server_entity.vector_clock.happens_before(
                    local_entity.vector_clock,
                ):
                    local_ahead.append(local_entity)
                elif local_entity.vector_clock.happens_before(
                    server_entity.vector_clock,
                ):
                    server_ahead.append(server_entity)
            elif local_entity is not None:
                if namespace and local_entity.namespace != namespace:
                    continue
                if (
                    request.entity_types
                    and local_entity.entity_type not in request.entity_types
                ):
                    continue
                local_ahead.append(local_entity)
            elif server_entity is not None:
                if namespace and server_entity.namespace != namespace:
                    continue
                if (
                    request.entity_types
                    and server_entity.entity_type not in request.entity_types
                ):
                    continue
                server_ahead.append(server_entity)

        sync_state = self._sync_states.get(namespace)
        drift_detected = bool(local_ahead or server_ahead or conflicts)

        _logger.info(
            "sync.status.complete",
            namespace=namespace,
            drift_detected=drift_detected,
            local_ahead=len(local_ahead),
            server_ahead=len(server_ahead),
            conflicts=len(conflicts),
        )

        return SyncStatusResponse(
            namespace=namespace,
            sync_state=sync_state,
            drift_detected=drift_detected,
            local_ahead=local_ahead,
            server_ahead=server_ahead,
            conflicts=conflicts,
            checked_at=now,
        )

    # ------------------------------------------------------------------
    # Offline queue management
    # ------------------------------------------------------------------

    def enqueue_offline(
        self,
        direction: SyncDirection,
        namespace: str,
        entity: SyncEntitySnapshot,
    ) -> SyncQueueEntry:
        """Queue a sync operation for retry when offline.

        Args:
            direction: Whether this is a pull or push operation.
            namespace: Target namespace.
            entity: The entity snapshot to sync.

        Returns:
            The created ``SyncQueueEntry``.

        Raises:
            ValueError: If *namespace* is empty or ``local``.
            TypeError: If arguments have incorrect types.
        """
        if not isinstance(direction, SyncDirection):
            raise TypeError("direction must be a SyncDirection")
        if not isinstance(entity, SyncEntitySnapshot):
            raise TypeError("entity must be a SyncEntitySnapshot")
        if not namespace:
            raise ValueError("namespace must be a non-empty string")
        if namespace == "local":
            raise ValueError("Cannot queue sync for the 'local' namespace")

        entry = SyncQueueEntry(
            queue_id=str(ULID()),
            direction=direction,
            namespace=namespace,
            entity_type=entity.entity_type,
            entity_id=entity.entity_id,
            snapshot=entity,
            status=SyncOperationStatus.QUEUED,
            created_at=_iso_now(),
        )
        self._offline_queue.append(entry)

        _logger.info(
            "sync.offline.enqueued",
            queue_id=entry.queue_id,
            direction=direction.value,
            namespace=namespace,
            entity_id=entity.entity_id,
        )

        return entry

    def process_offline_queue(self) -> list[SyncQueueEntry]:
        """Process all queued offline operations.

        Attempts to execute each queued operation. Successfully
        processed entries are marked as completed; failed entries
        have their retry count incremented.

        Returns:
            List of processed queue entries with updated statuses.
        """
        processed: list[SyncQueueEntry] = []
        remaining: list[SyncQueueEntry] = []

        for entry in self._offline_queue:
            if entry.retry_count >= entry.max_retries:
                entry.status = SyncOperationStatus.FAILED
                entry.error_message = "Max retries exceeded"
                processed.append(entry)
                continue

            entry.status = SyncOperationStatus.IN_PROGRESS
            entry.last_attempt_at = _iso_now()

            try:
                if entry.direction == SyncDirection.PUSH:
                    push_req = SyncPushRequest(
                        namespace=entry.namespace,
                        entities=[entry.snapshot],
                    )
                    result = self.push(push_req)
                    if result.total_accepted > 0:
                        entry.status = SyncOperationStatus.COMPLETED
                        processed.append(entry)
                    else:
                        entry.status = SyncOperationStatus.QUEUED
                        entry.retry_count += 1
                        remaining.append(entry)
                elif entry.direction == SyncDirection.PULL:
                    pull_req = SyncPullRequest(
                        namespace=entry.namespace,
                        entity_types=[entry.entity_type],
                    )
                    result = self.pull(pull_req)
                    entry.status = SyncOperationStatus.COMPLETED
                    processed.append(entry)
            except Exception as exc:
                entry.status = SyncOperationStatus.QUEUED
                entry.retry_count += 1
                entry.error_message = str(exc)
                remaining.append(entry)

                _logger.warning(
                    "sync.offline.retry_failed",
                    queue_id=entry.queue_id,
                    retry_count=entry.retry_count,
                    error=str(exc),
                )

        self._offline_queue = remaining

        _logger.info(
            "sync.offline.processed",
            total_processed=len(processed),
            total_remaining=len(remaining),
        )

        return processed

    def resolve_conflict(
        self,
        conflict_id: str,
        resolution: ConflictResolution,
        winner: str | None = None,
    ) -> SyncConflict:
        """Manually resolve a sync conflict.

        Args:
            conflict_id: Identifier of the conflict to resolve.
            resolution: The resolution strategy to apply.
            winner: For manual resolution, which side wins
                (``'local'`` or ``'server'``).

        Returns:
            The updated ``SyncConflict``.

        Raises:
            ValueError: If *conflict_id* is empty or the conflict
                is not found.
            BusinessRuleViolation: If manual resolution is chosen
                without specifying a winner.
        """
        if not conflict_id:
            raise ValueError("conflict_id must be a non-empty string")
        if not isinstance(resolution, ConflictResolution):
            raise TypeError("resolution must be a ConflictResolution")

        target: SyncConflict | None = None
        for conflict in self._conflicts:
            if conflict.conflict_id == conflict_id:
                target = conflict
                break

        if target is None:
            raise ValueError(f"Conflict '{conflict_id}' not found")

        if resolution == ConflictResolution.MANUAL and not winner:
            raise BusinessRuleViolation(
                "Manual resolution requires specifying a winner ('local' or 'server')"
            )

        now = _iso_now()
        target.resolution = resolution
        target.resolved = True
        target.resolved_at = now

        if resolution == ConflictResolution.LAST_WRITER_WINS:
            local_ts = target.local_snapshot.updated_at or ""
            server_ts = target.server_snapshot.updated_at or ""
            target.winner = "local" if local_ts >= server_ts else "server"
        elif resolution == ConflictResolution.CLIENT_WINS:
            target.winner = "local"
        elif resolution == ConflictResolution.SERVER_WINS:
            target.winner = "server"
        elif resolution == ConflictResolution.MANUAL:
            target.winner = winner

        winning_snapshot = (
            target.local_snapshot
            if target.winner == "local"
            else target.server_snapshot
        )
        key = self._entity_key(winning_snapshot)
        self._local_entities[key] = winning_snapshot
        self._server_entities[key] = winning_snapshot

        _logger.info(
            "sync.conflict.resolved",
            conflict_id=conflict_id,
            resolution=resolution.value,
            winner=target.winner,
        )

        return target

    # ------------------------------------------------------------------
    # Entity management helpers (for testing / local mode)
    # ------------------------------------------------------------------

    def register_local_entity(self, entity: SyncEntitySnapshot) -> None:
        """Register an entity in the local cache.

        Args:
            entity: The entity snapshot to register.

        Raises:
            TypeError: If *entity* is not a ``SyncEntitySnapshot``.
        """
        if not isinstance(entity, SyncEntitySnapshot):
            raise TypeError("entity must be a SyncEntitySnapshot")
        key = self._entity_key(entity)
        self._local_entities[key] = entity

    def register_server_entity(self, entity: SyncEntitySnapshot) -> None:
        """Register an entity in the server cache.

        Args:
            entity: The entity snapshot to register.

        Raises:
            TypeError: If *entity* is not a ``SyncEntitySnapshot``.
        """
        if not isinstance(entity, SyncEntitySnapshot):
            raise TypeError("entity must be a SyncEntitySnapshot")
        key = self._entity_key(entity)
        self._server_entities[key] = entity

    # ------------------------------------------------------------------
    # Private helpers
    # ------------------------------------------------------------------

    def _entity_key(self, entity: SyncEntitySnapshot) -> str:
        """Return a unique key for an entity snapshot."""
        return f"{entity.namespace}/{entity.entity_type.value}/{entity.entity_id}"

    def _get_server_entities(
        self,
        namespace: str,
        entity_types: list[SyncEntityType],
        since: str | None = None,
    ) -> list[SyncEntitySnapshot]:
        """Retrieve server entities matching the filter criteria."""
        result: list[SyncEntitySnapshot] = []
        for entity in self._server_entities.values():
            if entity.namespace != namespace:
                continue
            if entity_types and entity.entity_type not in entity_types:
                continue
            if since and entity.updated_at and entity.updated_at <= since:
                continue
            result.append(entity)
        return result

    def _create_conflict(
        self,
        local_entity: SyncEntitySnapshot,
        server_entity: SyncEntitySnapshot,
        namespace: str,
    ) -> SyncConflict:
        """Create a sync conflict record."""
        return SyncConflict(
            conflict_id=str(ULID()),
            entity_id=local_entity.entity_id,
            entity_type=local_entity.entity_type,
            namespace=namespace,
            local_snapshot=local_entity,
            server_snapshot=server_entity,
            local_clock=local_entity.vector_clock,
            server_clock=server_entity.vector_clock,
        )

    def _resolve_conflict(
        self,
        conflict: SyncConflict,
        resolution: ConflictResolution,
    ) -> SyncEntitySnapshot | None:
        """Apply automatic conflict resolution and return the winner.

        Returns ``None`` if manual resolution is required.
        """
        if resolution == ConflictResolution.MANUAL:
            return None

        if resolution == ConflictResolution.CLIENT_WINS:
            conflict.winner = "local"
            return conflict.local_snapshot

        if resolution == ConflictResolution.SERVER_WINS:
            conflict.winner = "server"
            return conflict.server_snapshot

        # Default: last_writer_wins
        local_ts = conflict.local_snapshot.updated_at or ""
        server_ts = conflict.server_snapshot.updated_at or ""
        if local_ts >= server_ts:
            conflict.winner = "local"
            return conflict.local_snapshot
        conflict.winner = "server"
        return conflict.server_snapshot

    def _stamp_entity(
        self,
        entity: SyncEntitySnapshot,
        namespace: str,
    ) -> SyncEntitySnapshot:
        """Return a copy of *entity* with updated clock and timestamp."""
        new_clock = entity.vector_clock.increment(self._node_id)
        return SyncEntitySnapshot(
            entity_id=entity.entity_id,
            entity_type=entity.entity_type,
            namespace=namespace,
            name=entity.name,
            version=entity.version + 1,
            data=dict(entity.data),
            vector_clock=new_clock,
            updated_at=_iso_now(),
        )

    def _update_sync_state(
        self,
        namespace: str,
        last_pull_at: str | None = None,
        last_push_at: str | None = None,
        conflicts: list[SyncConflict] | None = None,
    ) -> SyncState:
        """Update and return the sync state for a namespace."""
        state = self._sync_states.get(namespace)
        now = _iso_now()

        if state is None:
            state = SyncState(
                namespace=namespace,
                last_sync_at=now,
                local_clock=VectorClock(),
                server_clock=VectorClock(),
            )

        state.last_sync_at = now
        if last_pull_at is not None:
            state.last_pull_at = last_pull_at
        if last_push_at is not None:
            state.last_push_at = last_push_at
        if conflicts is not None:
            state.conflicts = conflicts

        state.local_clock = state.local_clock.increment(self._node_id)
        self._sync_states[namespace] = state

        return state


__all__ = [
    "SyncService",
]
