"""CalendarService — business-logic layer for calendar operations.

REST routes (U2) and agent tools are thin wrappers over this service.
The service dispatches to ``db`` functions for persistence and to
``recurrence`` for RRULE expansion.
"""

from __future__ import annotations

import dataclasses
import logging
import uuid
from collections.abc import Awaitable, Callable
from datetime import datetime, timezone
from pathlib import Path

import aiosqlite

from agentkit.calendar.db import (
    DEFAULT_CALENDAR_DB_PATH,
    add_tag_to_event,
    delete_event as db_delete_event,
    get_event as db_get_event,
    get_invitation as db_get_invitation,
    insert_event,
    insert_event_type,
    insert_invitation,
    insert_reminder_rule,
    insert_tag,
    list_event_types as db_list_event_types,
    list_events as db_list_events,
    list_invitations as db_list_invitations,
    list_reminder_rules_for_type,
    list_tags as db_list_tags,
    update_event as db_update_event,
    update_event_type as db_update_event_type,
    update_invitation_status,
)
from agentkit.calendar.models import (
    CalendarEvent,
    EventType,
    Invitation,
    ReminderRule,
    Tag,
    _now_iso,
)
from agentkit.calendar.recurrence import expand_rrule
from agentkit.server.auth.models import DEFAULT_AUTH_DB_PATH

logger = logging.getLogger(__name__)


def _validate_iso(dt_str: str) -> None:
    """Validate that *dt_str* is a parseable ISO 8601 string."""
    if not dt_str:
        raise ValueError("start_time must be a valid ISO 8601 string")
    try:
        datetime.fromisoformat(dt_str)
    except ValueError:
        raise ValueError(f"Invalid ISO 8601 format: {dt_str}")


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


def _format_dt(dt: datetime) -> str:
    """Format datetime as ISO 8601 UTC string."""
    return dt.astimezone(timezone.utc).isoformat()


class CalendarService:
    """Create, query, and manage calendar events, types, tags, and invitations.

    Mirrors ``DocumentService``: ``__init__`` stores a db_path, async methods
    delegate to ``calendar.db`` functions. RRULE expansion is handled here
    so routes and tools get a flat list of occurrences.
    """

    def __init__(
        self,
        db_path: str | Path | None = None,
        auth_db_path: str | Path | None = None,
        notify_callback: Callable[[str, dict[str, object]], Awaitable[None]] | None = None,
    ) -> None:
        self.db_path = Path(db_path) if db_path is not None else DEFAULT_CALENDAR_DB_PATH
        self.auth_db_path = Path(auth_db_path) if auth_db_path is not None else DEFAULT_AUTH_DB_PATH
        # Optional WS broadcast callback — wired by app.py so create_event
        # can push `calendar_event_created` to the user's open chat tabs
        # without the service depending on the portal module.
        self._notify = notify_callback

    # ------------------------------------------------------------------
    # Event CRUD
    # ------------------------------------------------------------------

    async def create_event(
        self,
        user_id: str,
        title: str,
        start_time: str,
        end_time: str,
        description: str = "",
        location: str = "",
        is_all_day: bool = False,
        event_type_id: str | None = None,
        rrule: str | None = None,
        source: str = "manual",
        is_invited: bool = False,
        conversation_id: str | None = None,
        tag_ids: list[str] | None = None,
        reminder_rules: list[ReminderRule] | None = None,
    ) -> CalendarEvent:
        """Create a calendar event with UUID, timestamps, tags, and cloned reminders."""
        _validate_iso(start_time)
        now = _now_iso()
        event = CalendarEvent(
            id=uuid.uuid4().hex,
            user_id=user_id,
            title=title,
            description=description,
            start_time=start_time,
            end_time=end_time,
            is_all_day=is_all_day,
            location=location,
            event_type_id=event_type_id,
            rrule=rrule,
            source=source,
            is_invited=is_invited,
            conversation_id=conversation_id,
            last_modified=now,
            created_at=now,
        )
        await insert_event(event, self.db_path)

        # Link tags if provided — skip any that don't belong to the user
        if tag_ids:
            user_tags = await self.list_tags(user_id)
            user_tag_ids = {t.id for t in user_tags}
            for tag_id in tag_ids:
                if tag_id not in user_tag_ids:
                    logger.debug("Skipping tag %s — not owned by user %s", tag_id, user_id)
                    continue
                await add_tag_to_event(event.id, tag_id, self.db_path)

        # Clone type-level default reminder rules to the event — skip if type
        # doesn't belong to the user
        if event_type_id:
            user_types = await self.list_event_types(user_id)
            user_type_ids = {t.id for t in user_types}
            if event_type_id not in user_type_ids:
                logger.debug(
                    "Skipping event_type %s — not owned by user %s", event_type_id, user_id
                )
            else:
                type_rules = await list_reminder_rules_for_type(event_type_id, self.db_path)
                for rule in type_rules:
                    cloned = dataclasses.replace(
                        rule,
                        id=uuid.uuid4().hex,
                        event_id=event.id,
                        event_type_id=None,
                    )
                    await insert_reminder_rule(cloned, self.db_path)

        # Insert explicit per-event reminder rules supplied by the caller (e.g. agent tool)
        if reminder_rules:
            for rule in reminder_rules:
                await insert_reminder_rule(
                    dataclasses.replace(
                        rule,
                        id=uuid.uuid4().hex,
                        event_id=event.id,
                        event_type_id=None,
                    ),
                    self.db_path,
                )

        logger.info(f"Created event {event.id} ({title}) for user {user_id}")
        # Broadcast to the user's open chat tabs so the calendar view
        # refreshes in real time without a manual reload. Best-effort:
        # WS delivery failure must not roll back the successful insert.
        if self._notify is not None:
            try:
                await self._notify(
                    user_id,
                    {"type": "calendar_event_created", "data": {"event": event.to_dict()}},
                )
            except Exception:
                logger.warning(
                    "calendar_event_created broadcast failed for event %s",
                    event.id,
                    exc_info=True,
                )
        return event

    async def get_event(self, event_id: str) -> CalendarEvent | None:
        """Return a single event by id, or None."""
        return await db_get_event(event_id, self.db_path)

    async def list_events(
        self,
        user_id: str,
        start: str | None = None,
        end: str | None = None,
        event_type_id: str | None = None,
        tag_id: str | None = None,
    ) -> list[CalendarEvent]:
        """List events for a user, expanding recurring events within [start, end].

        Non-recurring events are filtered by date range manually (not in the
        DB query) so that recurring events whose first occurrence falls
        outside the range are still included and expanded.
        """
        # Fetch all events for the user with type/tag filters — no date filter
        # at the DB level so recurring events are not excluded.
        events = await db_list_events(
            user_id,
            event_type_id=event_type_id,
            tag_id=tag_id,
            db_path=self.db_path,
        )

        result: list[CalendarEvent] = []
        for event in events:
            if event.rrule:
                # Expand recurring event within [start, end] range
                try:
                    occurrences = expand_rrule(
                        event.rrule,
                        event.start_time,
                        range_start=start,
                        range_end=end,
                    )
                except ValueError:
                    # ponytail: malformed RRULE → fall back to single occurrence
                    # so one bad event doesn't crash the whole list. Upgrade
                    # path: surface a validation error at create_event time.
                    logger.warning(
                        "Malformed RRULE %r for event %s; falling back to start_time",
                        event.rrule,
                        event.id,
                    )
                    occurrences = [event.start_time]
                for occ_start_str in occurrences:
                    occ = self._make_occurrence(event, occ_start_str)
                    result.append(occ)
            else:
                # Non-recurring: filter by date range manually
                if _is_in_range(event.start_time, start, end):
                    result.append(event)

        # Sort by start_time for consistent ordering
        result.sort(key=lambda e: e.start_time)
        return result

    def _make_occurrence(self, event: CalendarEvent, occ_start_str: str) -> CalendarEvent:
        """Create a copy of *event* with start/end times shifted to the occurrence."""
        occ_start_dt = _parse_dt(occ_start_str)
        original_start = _parse_dt(event.start_time)
        original_end = _parse_dt(event.end_time)
        duration = original_end - original_start
        occ_end_dt = occ_start_dt + duration
        return dataclasses.replace(
            event,
            start_time=_format_dt(occ_start_dt),
            end_time=_format_dt(occ_end_dt),
        )

    async def update_event(self, event_id: str, fields: dict) -> bool:
        """Update specific fields of an event. Auto-updates last_modified."""
        fields = {**fields, "last_modified": _now_iso()}
        return await db_update_event(event_id, fields, self.db_path)

    async def delete_event(self, event_id: str) -> bool:
        """Delete an event and its dependent rows."""
        return await db_delete_event(event_id, self.db_path)

    # ------------------------------------------------------------------
    # Event Type CRUD
    # ------------------------------------------------------------------

    async def list_event_types(self, user_id: str) -> list[EventType]:
        """List all event types for a user."""
        return await db_list_event_types(user_id, self.db_path)

    async def get_event_type(self, type_id: str) -> EventType | None:
        """Return a single event type by id, or None."""
        async with aiosqlite.connect(str(self.db_path)) as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(
                "SELECT * FROM calendar_event_types WHERE id = ?",
                (type_id,),
            )
            row = await cursor.fetchone()
        if row is None:
            return None
        return EventType(
            id=row["id"],
            user_id=row["user_id"],
            name=row["name"],
            color=row["color"],
            is_default=bool(row["is_default"]),
        )

    async def create_event_type(
        self,
        user_id: str,
        name: str,
        color: str = "#4A90D9",
    ) -> EventType:
        """Create a new event type."""
        et = EventType(
            id=uuid.uuid4().hex,
            user_id=user_id,
            name=name,
            color=color,
        )
        await insert_event_type(et, self.db_path)
        return et

    async def update_event_type(self, type_id: str, fields: dict) -> bool:
        """Update specific fields of an event type."""
        return await db_update_event_type(type_id, fields, self.db_path)

    # ------------------------------------------------------------------
    # Tag CRUD
    # ------------------------------------------------------------------

    async def list_tags(self, user_id: str) -> list[Tag]:
        """List all tags for a user."""
        return await db_list_tags(user_id, self.db_path)

    async def create_tag(self, user_id: str, name: str) -> Tag:
        """Create a new tag."""
        tag = Tag(
            id=uuid.uuid4().hex,
            user_id=user_id,
            name=name,
        )
        await insert_tag(tag, self.db_path)
        return tag

    # ------------------------------------------------------------------
    # Invitation CRUD
    # ------------------------------------------------------------------

    async def create_invitation(
        self,
        event_id: str,
        inviter_user_id: str,
        invitee_email: str,
    ) -> Invitation:
        """Create an invitation with status='pending'."""
        invitation = Invitation(
            id=uuid.uuid4().hex,
            event_id=event_id,
            inviter_user_id=inviter_user_id,
            invitee_email=invitee_email,
            status="pending",
        )
        await insert_invitation(invitation, self.db_path)
        return invitation

    async def respond_to_invitation(self, invitation_id: str, status: str) -> bool:
        """Update invitation status and set responded_at."""
        return await update_invitation_status(
            invitation_id,
            status,
            _now_iso(),
            self.db_path,
        )

    async def get_invitation(self, invitation_id: str) -> Invitation | None:
        """Return a single invitation by id, or None."""
        return await db_get_invitation(invitation_id, self.db_path)

    async def list_invitations(self, invitee_email: str) -> list[Invitation]:
        """List all invitations for an invitee email."""
        return await db_list_invitations(invitee_email, self.db_path)

    # ------------------------------------------------------------------
    # User search (auth DB)
    # ------------------------------------------------------------------

    async def search_users(self, q: str) -> list[dict]:
        """Search users by username or email. Returns top 10 matches.

        Only ``username`` and ``email`` are returned — never user_id or
        password fields (G5/A3 — least-privilege user search).
        """
        escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
        pattern = f"%{escaped_q}%"
        async with aiosqlite.connect(str(self.auth_db_path)) as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(
                "SELECT username, email FROM users "
                "WHERE username LIKE ? ESCAPE '\\' OR email LIKE ? ESCAPE '\\' LIMIT 10",
                (pattern, pattern),
            )
            rows = await cursor.fetchall()
        return [{"username": row["username"], "email": row["email"]} for row in rows]

    async def get_user_email(self, user_id: str) -> str | None:
        """Look up a user's email from the auth DB by user_id."""
        async with aiosqlite.connect(str(self.auth_db_path)) as db:
            db.row_factory = aiosqlite.Row
            cursor = await db.execute(
                "SELECT email FROM users WHERE id = ?",
                (user_id,),
            )
            row = await cursor.fetchone()
        return row["email"] if row else None


def _is_in_range(dt_str: str, start: str | None, end: str | None) -> bool:
    """Check if *dt_str* falls within the half-open range [start, end)."""
    dt = _parse_dt(dt_str)
    if start is not None:
        if dt < _parse_dt(start):
            return False
    if end is not None:
        if dt >= _parse_dt(end):
            return False
    return True
