"""SyncManager — orchestrates external calendar sync providers (U6, G8).

Iterates user ``ExternalCalendarConfig`` entries, dispatches to the matching
provider (CalDAV, Outlook, …), and resolves conflicts via last-write-wins
with WS notification. The manager is intended to be registered on
``app.state.sync_manager`` and started/stopped in ``app.py`` lifespan.

ponytail: wiring into ``app.py`` lifespan is deferred — this module provides
the API only. Upgrade: add ``SyncManager.start()``/``stop()`` asyncio loop
in ``app.py`` next to the reminder scheduler.
"""

from __future__ import annotations

import logging
from collections.abc import Awaitable, Callable
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from agentkit.calendar.db import (
    DEFAULT_CALENDAR_DB_PATH,
    list_events,
    list_external_configs,
    update_external_config,
)
from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig, _now_iso
from agentkit.calendar.sync.base import AbstractSyncProvider
from agentkit.calendar.sync.caldav_provider import CalDAVSyncProvider

logger = logging.getLogger(__name__)

# Async callback signature: (event_type: str, payload: dict) -> None
NotifyCallback = Callable[[str, dict[str, Any]], Awaitable[None]]


class SyncManager:
    """Orchestrates all external calendar sync providers for a user.

    Providers are registered by ``ExternalCalendarConfig.provider`` name.
    The ``notify_callback`` is forwarded to providers for conflict WS push.
    """

    def __init__(
        self,
        db_path: str | Path | None = None,
        notify_callback: NotifyCallback | None = None,
        providers: dict[str, AbstractSyncProvider] | None = None,
    ) -> None:
        self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
        self._notify = notify_callback
        # ponytail: provider registry is hardcoded for now; if a third provider
        # (e.g. Google) is added, switch to entry-point discovery. Upgrade:
        # ``importlib.metadata.entry_points(group="agentkit.sync_providers")``.
        self._providers: dict[str, AbstractSyncProvider] = providers or {
            "caldav": CalDAVSyncProvider(db_path=self.db_path, notify_callback=notify_callback),
        }

    def _get_provider(self, provider_name: str) -> AbstractSyncProvider | None:
        """Return the provider for *provider_name*, or None if unsupported."""
        return self._providers.get(provider_name)

    # ------------------------------------------------------------------
    # Sync orchestration
    # ------------------------------------------------------------------

    async def sync_all(self, user_id: str) -> dict[str, Any]:
        """Sync all external calendar configs for a user.

        Returns ``{"synced": N, "errors": [...]}``.
        """
        configs = await list_external_configs(user_id, self.db_path)
        synced = 0
        errors: list[str] = []
        for config in configs:
            try:
                await self.sync_provider(config.id)
                synced += 1
            except Exception as e:
                errors.append(f"{config.id}: {e}")
                logger.warning("Sync failed for config %s: %s", config.id, e)
        return {"synced": synced, "errors": errors}

    async def sync_provider(self, config_id: str) -> dict[str, Any]:
        """Sync a single external calendar config by ID.

        Pulls remote changes, then pushes local changes modified since
        ``last_sync``. Updates ``last_sync`` on success.

        Returns ``{"pulled": N, "pushed": M}``.
        """
        config = await self._get_config(config_id)
        if config is None:
            raise ValueError(f"ExternalCalendarConfig not found: {config_id}")

        provider = self._get_provider(config.provider)
        if provider is None:
            raise ValueError(f"Unsupported provider: {config.provider}")

        since = config.last_sync

        # 1. Pull remote → local (creates/updates local events, resolves conflicts)
        pulled = await provider.pull_changes(config, since=since)

        # 2. Push local changes → remote (events modified since last_sync)
        # ponytail: reset `since` to now after pull so pulled events (whose
        # last_modified was set to the remote timestamp) aren't pushed back to
        # remote, creating a sync loop. Upgrade: filter by pulled event IDs for
        # sub-second accuracy.
        since = datetime.now(timezone.utc).isoformat()
        local_events = await self._get_modified_events(config, since)
        pushed = await provider.push_changes(config, local_events)

        # 3. Update last_sync
        now = _now_iso()
        await update_external_config(config.id, {"last_sync": now}, self.db_path)
        config.last_sync = now

        return {"pulled": len(pulled), "pushed": len(pushed)}

    async def _get_config(self, config_id: str) -> ExternalCalendarConfig | None:
        """Fetch a single ExternalCalendarConfig by ID."""
        # ponytail: no db.get_external_config(id) exists; we list all for the
        # user. This is O(N) over the user's configs (typically <5). Upgrade:
        # add ``get_external_config(config_id)`` to db.py if this becomes hot.
        # We don't know the user_id here, so scan all configs in the DB.
        import aiosqlite

        from agentkit.calendar.db import _row_to_external_config

        async with aiosqlite.connect(str(self.db_path)) as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(
                "SELECT * FROM calendar_external_configs WHERE id = ?", (config_id,)
            )
            row = await cursor.fetchone()
            return _row_to_external_config(row) if row else None

    async def _get_modified_events(
        self, config: ExternalCalendarConfig, since: str | None
    ) -> list[CalendarEvent]:
        """Return local events for the user modified since *since*.

        When *since* is None (first sync), returns all events for the user
        that do not yet have an ``external_id`` (i.e. new local events to push).
        When *since* is set, returns events whose ``last_modified`` >= *since*.
        """
        events = await list_events(config.user_id, db_path=self.db_path)
        if since is None:
            # First sync: push only events without external_id
            return [e for e in events if not e.external_id]

        since_dt = datetime.fromisoformat(since)
        if since_dt.tzinfo is None:
            since_dt = since_dt.replace(tzinfo=timezone.utc)

        result: list[CalendarEvent] = []
        for event in events:
            if not event.last_modified:
                continue
            event_lm = datetime.fromisoformat(event.last_modified)
            if event_lm.tzinfo is None:
                event_lm = event_lm.replace(tzinfo=timezone.utc)
            if event_lm >= since_dt:
                result.append(event)
        return result

    # ------------------------------------------------------------------
    # Conflict resolution
    # ------------------------------------------------------------------

    async def resolve_conflict(
        self, local_event: CalendarEvent, remote_event: CalendarEvent
    ) -> CalendarEvent:
        """Resolve a sync conflict using last-write-wins (G8/G4).

        Compares ``last_modified`` timestamps. The newer event wins. Sends a
        ``calendar_sync_conflict`` WS notification via the notify callback.
        Returns the winning event.
        """
        local_lm = (
            datetime.fromisoformat(local_event.last_modified)
            if local_event.last_modified
            else datetime.min.replace(tzinfo=timezone.utc)
        )
        if local_lm.tzinfo is None:
            local_lm = local_lm.replace(tzinfo=timezone.utc)

        remote_lm = (
            datetime.fromisoformat(remote_event.last_modified)
            if remote_event.last_modified
            else datetime.min.replace(tzinfo=timezone.utc)
        )
        if remote_lm.tzinfo is None:
            remote_lm = remote_lm.replace(tzinfo=timezone.utc)

        if remote_lm > local_lm:
            winner = remote_event
            winner_label = "remote"
        elif local_lm > remote_lm:
            winner = local_event
            winner_label = "local"
        else:
            # Equal timestamps → local wins by default
            winner = local_event
            winner_label = "local"

        logger.info(
            "Conflict resolved for event %s: winner=%s (local_lm=%s remote_lm=%s)",
            local_event.id,
            winner_label,
            local_event.last_modified,
            remote_event.last_modified,
        )

        # G4: WS notification
        if self._notify is not None:
            payload = {
                "event_id": local_event.id,
                "title": local_event.title,
                "external_id": local_event.external_id,
                "local_last_modified": local_event.last_modified,
                "remote_last_modified": remote_event.last_modified,
                "winner": winner_label,
            }
            await self._notify("calendar_sync_conflict", payload)

        return winner
