"""Nextcloud calendar ingestion over CalDAV.

READ-ONLY INVARIANT: this module only ever issues CalDAV REPORT/PROPFIND reads
(`Calendar.search()` and the principal/calendar discovery it needs). It never
calls `save_event`, `add_event`, `Event.delete()`, `make_calendar()` or anything
else that would mutate the collection, per docs/project-plan.md Phase 12 step 8
and the Phase 8 "no silent mutation" precedent. The digest can say an event needs
attention; it can never move, create or delete one.

Auth is a Nextcloud **app password**, not OAuth2 and not the account password —
the same reasoning email_imap.py documents for Gmail App Passwords. Nextcloud
issues them at Settings -> Security -> Devices & sessions -> Create new app
password, and they are mandatory for CalDAV once two-factor authentication is on,
because the DAV endpoints have no way to prompt for a second factor. Even without
2FA they are the right credential here: revocable on their own, scoped to this
one integration, and they leave the account password out of a file on the
container host.

Sourcing, verified 2026-07-28:

  The protocol is not hand-rolled. `caldav` on PyPI (3.2.1, 2026-05-28, Python
  >=3.10, https://pypi.org/project/caldav/) does discovery, the calendar-query
  REPORT and recurrence expansion; hand-writing that XML against a real server is
  a bad trade. It pulls in `icalendar`, which is what actually parses the
  returned VEVENTs here.

  URL: point CALDAV_URL at Nextcloud's DAV root, `https://<host>/remote.php/dav`
  — the library discovers the principal and its calendars from there. Nextcloud
  documents the equivalent per-user form
  `https://<host>/remote.php/dav/principals/users/<username>/`; either works.

  `search(start=..., end=..., event=True, expand=True)` is the documented way to
  get a time-bounded list of events with recurrences expanded into concrete
  occurrences. Without `expand`, a weekly recurring event comes back once, as its
  original master VEVENT with an RRULE, and the digest would report a meeting on
  the day it was first created. caldav 2.0+ can expand client-side when the
  server will not, but sabre/dav servers that reject the request outright are
  handled by the retry below.

The window is deliberately asymmetric: everything else in this component looks
backwards over the digest window, but a calendar is mostly useful forwards, so
this reads `lookback_hours` back (to catch what happened earlier today, and
events still in progress) and CALDAV_LOOKAHEAD_HOURS forwards.
"""

import logging
import os
from datetime import date, datetime, timedelta, timezone

LOG = logging.getLogger(__name__)

DEFAULT_LOOKAHEAD_HOURS = 48
DEFAULT_MAX_EVENTS = 100
MAX_DESCRIPTION_CHARS = 500


def _csv_set(name):
    return {item.strip().lower() for item in os.environ.get(name, "").split(",") if item.strip()}


def _stamp(value):
    if value is None:
        return None, False
    if isinstance(value, datetime):
        # A floating (tz-naive) DTSTART is legal iCalendar; treating it as UTC keeps
        # the ordering sane instead of raising on the comparison.
        aware = value if value.tzinfo else value.replace(tzinfo=timezone.utc)
        return aware.astimezone(timezone.utc).isoformat().replace("+00:00", "Z"), False
    if isinstance(value, date):
        return value.isoformat(), True
    return None, False


def _text(component, key, limit=None):
    value = component.get(key)
    if value is None:
        return None
    text = str(value).strip()
    if not text:
        return None
    return text[:limit] if limit else text


def _events_from(raw, calendar_name, ical_parser):
    events = []
    for component in ical_parser.from_ical(raw).walk("VEVENT"):
        start_property = component.get("dtstart")
        end_property = component.get("dtend")
        start, all_day = _stamp(getattr(start_property, "dt", None))
        end, _ = _stamp(getattr(end_property, "dt", None))
        if start is None:
            continue
        events.append(
            {
                "source": "caldav",
                "category": "calendar_event",
                "calendar": calendar_name,
                "uid": _text(component, "uid"),
                "summary": _text(component, "summary") or "(no title)",
                "start": start,
                "end": end,
                "all_day": all_day,
                "location": _text(component, "location"),
                "description": _text(component, "description", MAX_DESCRIPTION_CHARS),
                "status": _text(component, "status"),
                "recurring": component.get("rrule") is not None,
            }
        )
    return events


def _search(calendar, window_start, window_end):
    try:
        return calendar.search(start=window_start, end=window_end, event=True, expand=True)
    except Exception:
        # Some sabre/dav deployments reject an expand request outright. An
        # unexpanded window is still worth having; the only cost is that a
        # recurring series shows up as its master event.
        LOG.warning("caldav: expanded search failed, retrying unexpanded", exc_info=True)
        return calendar.search(start=window_start, end=window_end, event=True)


def _calendar_name(calendar):
    try:
        return str(calendar.name or "")
    except Exception:
        # .name is a lazy PROPFIND; a calendar that will not name itself is still
        # readable, and dropping it over a missing display name would be absurd.
        return ""


def fetch(lookback_hours):
    url = os.environ.get("CALDAV_URL", "").strip()
    username = os.environ.get("CALDAV_USERNAME", "").strip()
    password = os.environ.get("CALDAV_PASSWORD", "")

    if not (url and username and password):
        LOG.warning("caldav: CALDAV_URL/CALDAV_USERNAME/CALDAV_PASSWORD not all set, skipping")
        return []

    lookahead_hours = float(os.environ.get("CALDAV_LOOKAHEAD_HOURS", DEFAULT_LOOKAHEAD_HOURS))
    max_events = int(os.environ.get("CALDAV_MAX_EVENTS", DEFAULT_MAX_EVENTS))
    wanted = _csv_set("CALDAV_CALENDARS")
    verify = os.environ.get("CALDAV_VERIFY_TLS", "true").strip().lower() == "true"
    if not verify:
        LOG.warning("caldav: CALDAV_VERIFY_TLS is false, the app password is sent over an unverified session")

    now = datetime.now(timezone.utc)
    window_start = now - timedelta(hours=float(lookback_hours))
    window_end = now + timedelta(hours=lookahead_hours)

    events = []
    try:
        # Imported here, not at module scope, so a missing/broken optional
        # dependency degrades this one source instead of the whole run. The
        # absolute import resolves to the PyPI `caldav` package, not to this
        # module, which is only ever reachable as `ingest.caldav`.
        import caldav as caldav_lib
        from icalendar import Calendar as ICalendar

        with caldav_lib.DAVClient(
            url=url, username=username, password=password, ssl_verify_cert=verify
        ) as client:
            for calendar in client.principal().calendars():
                name = _calendar_name(calendar)
                if wanted and name.lower() not in wanted:
                    continue
                try:
                    for item in _search(calendar, window_start, window_end):
                        events.extend(_events_from(item.data, name, ICalendar))
                except Exception:
                    LOG.warning("caldav: calendar %r could not be read, skipping", name, exc_info=True)
    except Exception:
        LOG.warning("caldav: ingestion failed, returning nothing", exc_info=True)
        return []

    events.sort(key=lambda event: event["start"])
    if len(events) > max_events:
        LOG.warning("caldav: %d events in the window, truncating to %d", len(events), max_events)
        events = events[:max_events]

    LOG.info("caldav: %d event(s) from -%sh to +%sh", len(events), lookback_hours, lookahead_hours)
    return events
