"""CalDAV sync provider — bidirectional sync with Apple Calendar (U6).

Uses the ``caldav`` library for the CalDAV protocol and ``icalendar`` for
ICS serialization/parsing. Conflict resolution is last-write-wins based on
``last_modified``; conflicts emit a ``calendar_sync_conflict`` WS notification
via the injectable ``notify_callback`` (G4).
"""

from __future__ import annotations

import asyncio
import json
import logging
import uuid
from collections.abc import Awaitable, Callable
from datetime import date, datetime, timedelta, timezone
from pathlib import Path

import caldav
from icalendar import Calendar, Event
from icalendar.prop import vRecur

from agentkit.calendar.db import (
    DEFAULT_CALENDAR_DB_PATH,
    get_event_by_external_id,
    insert_event,
    update_event,
)
from agentkit.calendar.models import CalendarEvent, ExternalCalendarConfig, _now_iso
from agentkit.calendar.sync.base import AbstractSyncProvider

logger = logging.getLogger(__name__)

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


def _parse_iso(dt_str: str) -> datetime:
    """Parse ISO 8601 string to UTC-aware datetime."""
    dt = datetime.fromisoformat(dt_str)
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(timezone.utc)


def _to_iso_utc(dt: datetime) -> str:
    """Convert datetime to ISO 8601 UTC string."""
    if dt.tzinfo is None:
        dt = dt.replace(tzinfo=timezone.utc)
    return dt.astimezone(timezone.utc).isoformat()


def _extract_dt(component: object, key: str) -> tuple[str, bool]:
    """Extract date/datetime from icalendar component. Returns (iso, is_all_day)."""
    prop = component.get(key)
    if prop is None:
        return "", False
    val = prop.dt
    if isinstance(val, date) and not isinstance(val, datetime):
        return val.isoformat(), True
    return _to_iso_utc(val), False


class CalDAVSyncProvider(AbstractSyncProvider):
    """Bidirectional CalDAV sync provider for Apple Calendar.

    The ``client_factory`` parameter allows tests to inject a mock CalDAV
    client without touching ``caldav.DAVClient``. When ``None``, a real
    ``caldav.DAVClient`` is constructed from ``config.credentials``.
    """

    def __init__(
        self,
        db_path: str | Path | None = None,
        client_factory: Callable[[ExternalCalendarConfig], object] | None = None,
        notify_callback: NotifyCallback | None = None,
    ) -> None:
        self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
        self._client_factory = client_factory
        self._notify = notify_callback
        # ponytail: conflicts list is in-memory only; if the process restarts
        # before a sync completes, conflict history is lost. Upgrade: persist
        # to a calendar_sync_conflicts table.

    # ------------------------------------------------------------------
    # Client construction
    # ------------------------------------------------------------------

    def _make_client(self, config: ExternalCalendarConfig) -> object:
        """Build a caldav.DAVClient from config credentials."""
        if self._client_factory is not None:
            return self._client_factory(config)
        # ponytail: credentials stored as plain JSON dict; encryption deferred.
        # Upgrade: use agentkit.server.auth.crypto to encrypt at rest.
        creds = json.loads(config.credentials) if config.credentials else {}
        return caldav.DAVClient(
            url=creds.get("url", ""),
            username=creds.get("username", ""),
            password=creds.get("password", ""),
            # ponytail: 30s timeout prevents indefinite hangs on unreachable
            # CalDAV servers. Upgrade: make configurable per-config.
            timeout=30,
        )

    def _get_calendar(self, config: ExternalCalendarConfig) -> object:
        """Connect and return the first calendar from the principal."""
        client = self._make_client(config)
        principal = client.principal()
        calendars = principal.calendars()
        if not calendars:
            raise RuntimeError("No CalDAV calendars found for this account")
        return calendars[0]

    # ------------------------------------------------------------------
    # Pull
    # ------------------------------------------------------------------

    async def pull_changes(
        self, config: ExternalCalendarConfig, since: str | None = None
    ) -> list[CalendarEvent]:
        """Pull remote CalDAV events into local DB.

        Fetches events in a date range starting from ``since`` (or 1 year ago
        if None). Matches by ``external_id`` (CalDAV UID). Creates new local
        events or updates existing ones. Conflicts (both sides modified) are
        resolved last-write-wins with a WS notification.
        """
        # caldav is synchronous — run in a thread to avoid blocking the loop
        remote_events = await asyncio.to_thread(self._fetch_remote_events, config, since)

        result: list[CalendarEvent] = []
        for remote in remote_events:
            local = await get_event_by_external_id(
                remote.external_id, "caldav", config.user_id, self.db_path
            )
            if local is None:
                # New remote event → create local
                await insert_event(remote, self.db_path)
                result.append(remote)
            else:
                # Existing → check for conflict
                resolved = await self._resolve_pull_conflict(local, remote)
                if resolved is not None:
                    result.append(resolved)
        return result

    def _fetch_remote_events(
        self, config: ExternalCalendarConfig, since: str | None
    ) -> list[CalendarEvent]:
        """Synchronous CalDAV fetch — runs in thread."""
        calendar = self._get_calendar(config)

        # Date range: since → now+90d (or wide default)
        if since:
            start_dt = _parse_iso(since)
        else:
            start_dt = datetime.now(timezone.utc) - timedelta(days=365)
        end_dt = datetime.now(timezone.utc) + timedelta(days=90)

        caldav_events = calendar.date_search(start_dt, end_dt)
        events: list[CalendarEvent] = []
        for ce in caldav_events:
            parsed = self._parse_caldav_event(ce, config.user_id)
            if parsed is not None:
                events.append(parsed)
        return events

    def _parse_caldav_event(self, caldav_event: object, user_id: str) -> CalendarEvent | None:
        """Convert a caldav.Event to a CalendarEvent dataclass."""
        try:
            ical_data = caldav_event.data
            cal = Calendar.from_ical(ical_data)
        except Exception as e:
            logger.warning("Failed to parse CalDAV event: %s", e)
            return None

        for component in cal.walk("VEVENT"):
            uid = str(component.get("UID", "") or "") or None
            if not uid:
                continue

            title = str(component.get("SUMMARY", "") or "")
            if not title:
                continue

            start_str, is_all_day = _extract_dt(component, "DTSTART")
            end_str, _ = _extract_dt(component, "DTEND")
            if not end_str:
                end_str = start_str

            description = str(component.get("DESCRIPTION", "") or "")
            location = str(component.get("LOCATION", "") or "")

            rrule_str: str | None = None
            rrule = component.get("RRULE")
            if rrule is not None:
                rrule_str = rrule.to_ical().decode("utf-8")

            # LAST-MODIFIED from VEVENT (for conflict resolution)
            lm_prop = component.get("LAST-MODIFIED")
            if lm_prop is not None:
                last_modified = _to_iso_utc(lm_prop.dt)
            else:
                last_modified = _now_iso()

            now = _now_iso()
            return CalendarEvent(
                id=uuid.uuid4().hex,
                user_id=user_id,
                title=title,
                description=description,
                start_time=start_str,
                end_time=end_str,
                is_all_day=is_all_day,
                location=location,
                rrule=rrule_str,
                source="manual",
                external_id=uid,
                external_provider="caldav",
                last_modified=last_modified,
                created_at=now,
            )
        return None

    async def _resolve_pull_conflict(
        self, local: CalendarEvent, remote: CalendarEvent
    ) -> CalendarEvent | None:
        """Resolve conflict when both local and remote exist.

        If remote is newer → update local. If local is newer → conflict
        (last-write-wins keeps local, but log + notify). If equal → no-op.
        Returns the winning event (or None if local kept unchanged).
        """
        local_lm = (
            _parse_iso(local.last_modified)
            if local.last_modified
            else datetime.min.replace(tzinfo=timezone.utc)
        )
        remote_lm = (
            _parse_iso(remote.last_modified)
            if remote.last_modified
            else datetime.min.replace(tzinfo=timezone.utc)
        )

        if remote_lm > local_lm:
            # Remote wins → update local
            await self._notify_conflict(local, remote, winner="remote")
            fields = {
                "title": remote.title,
                "description": remote.description,
                "start_time": remote.start_time,
                "end_time": remote.end_time,
                "is_all_day": remote.is_all_day,
                "location": remote.location,
                "rrule": remote.rrule,
                "last_modified": remote.last_modified,
            }
            await update_event(local.id, fields, self.db_path)
            return remote

        if local_lm > remote_lm:
            # Local wins → conflict, keep local, notify
            await self._notify_conflict(local, remote, winner="local")
            return None

        # Equal timestamps → no change needed
        return None

    # ------------------------------------------------------------------
    # Push
    # ------------------------------------------------------------------

    async def push_changes(
        self, config: ExternalCalendarConfig, events: list[CalendarEvent]
    ) -> list[CalendarEvent]:
        """Push local events to CalDAV. Returns events with external_id set."""
        result: list[CalendarEvent] = []
        for event in events:
            updated = await self._push_single(config, event)
            result.append(updated)
        return result

    async def _push_single(
        self, config: ExternalCalendarConfig, event: CalendarEvent
    ) -> CalendarEvent:
        """Push a single event to CalDAV, return event with external_id set."""
        ical_bytes = self._event_to_ics(event)
        # caldav is synchronous — run in thread
        saved_uid = await asyncio.to_thread(self._save_remote_event, config, ical_bytes, event)

        # If event had no external_id, set it from the saved UID
        if not event.external_id and saved_uid:
            fields = {
                "external_id": saved_uid,
                "external_provider": "caldav",
                "last_modified": _now_iso(),
            }
            await update_event(event.id, fields, self.db_path)
            event.external_id = saved_uid
            event.external_provider = "caldav"

        return event

    def _save_remote_event(
        self, config: ExternalCalendarConfig, ical_bytes: bytes, event: CalendarEvent
    ) -> str | None:
        """Synchronous CalDAV save — runs in thread. Returns remote UID."""
        calendar = self._get_calendar(config)
        saved = calendar.save_event(ical_bytes)
        # Extract UID from saved event
        try:
            cal = Calendar.from_ical(saved.data)
            for comp in cal.walk("VEVENT"):
                uid = str(comp.get("UID", "") or "") or None
                if uid:
                    return uid
        except Exception as e:
            logger.warning("Failed to extract UID from saved event: %s", e)
        return event.external_id

    def _event_to_ics(self, event: CalendarEvent) -> bytes:
        """Convert CalendarEvent to ICS bytes using icalendar library."""
        cal = Calendar()
        cal.add("prodid", "-//Fischer AgentKit//Calendar//EN")
        cal.add("version", "2.0")

        vevent = Event()
        # Use external_id if available, else local id as UID
        vevent.add("uid", event.external_id or event.id)
        vevent.add("summary", event.title)

        if event.start_time:
            start_dt = _parse_iso(event.start_time)
            if event.is_all_day:
                vevent.add("dtstart", start_dt.date())
            else:
                vevent.add("dtstart", start_dt)

        if event.end_time:
            end_dt = _parse_iso(event.end_time)
            if event.is_all_day:
                vevent.add("dtend", end_dt.date())
            else:
                vevent.add("dtend", end_dt)

        if event.description:
            vevent.add("description", event.description)
        if event.location:
            vevent.add("location", event.location)
        if event.rrule:
            vevent.add("rrule", vRecur.from_ical(event.rrule))

        # LAST-MODIFIED for conflict resolution
        if event.last_modified:
            vevent.add("last-modified", _parse_iso(event.last_modified))

        cal.add_component(vevent)
        return cal.to_ical()

    # ------------------------------------------------------------------
    # Test connection
    # ------------------------------------------------------------------

    async def test_connection(self, config: ExternalCalendarConfig) -> tuple[bool, str]:
        """Test CalDAV connectivity. Returns (ok, error_msg)."""
        try:
            await asyncio.to_thread(self._get_calendar, config)
            return True, ""
        except Exception as e:
            return False, str(e)

    # ------------------------------------------------------------------
    # Conflict notification
    # ------------------------------------------------------------------

    async def _notify_conflict(
        self, local: CalendarEvent, remote: CalendarEvent, winner: str
    ) -> None:
        """Log conflict and send WS notification via callback (G4)."""
        logger.info(
            "Sync conflict for event %s (external_id=%s): local_lm=%s remote_lm=%s winner=%s",
            local.id,
            local.external_id,
            local.last_modified,
            remote.last_modified,
            winner,
        )
        if self._notify is not None:
            payload = {
                "event_id": local.id,
                "title": local.title,
                "external_id": local.external_id,
                "local_last_modified": local.last_modified,
                "remote_last_modified": remote.last_modified,
                "winner": winner,
            }
            await self._notify("calendar_sync_conflict", payload)
