/**
 * CalDAV sync: REPORT → ical.js parse → MariaDB upsert.
 *
 * D-13 schema contract:
 *   - All-day events: dtstart_date (DATE string), dtstart_utc=null, all_day=true
 *   - Timed events:   dtstart_utc (TIMESTAMP as JS Date), dtstart_date=null, all_day=false
 *   - NEVER coerce DATE to DATETIME (Pitfall #3)
 *   - Raw VEVENT blob stored verbatim — only server-returned objects cached (T-03-05)
 *
 * Idempotency: calendar_id + uid composite unique key → onDuplicateKeyUpdate.
 * ctag/syncToken updated on every sync so the poller can skip unchanged calendars.
 *
 * Sources:
 *   - https://github.com/natelindev/tsdav (fetchCalendarObjects)
 *   - https://github.com/kewisch/ical.js (ICAL.parse, ICAL.Component, ICAL.Time.isDate)
 */

import type { DAVCalendar } from 'tsdav';
import type { FastmailClient } from './client.js';
import ICAL from 'ical.js';
import { and, eq, notInArray } from 'drizzle-orm';
import { db } from '../db/client.js';
import { calendars, calendarEvents } from '../db/schema.js';
import type { EventChange } from '../lib/eventChangeDispatcher.js';

/**
 * Fetches all calendar objects for a given DAVCalendar, parses VEVENTs with ical.js,
 * and upserts the results into the MariaDB cache.
 *
 * @param client  - tsdav DAVClient (already authenticated)
 * @param davCal  - DAVCalendar object from fetchCalendars()
 * @param userId  - FamilySync user ID (from member_credentials row)
 */
export async function syncCalendar(
  client: FastmailClient,
  davCal: DAVCalendar,
  userId: number,
  onChanges?: (changes: EventChange[]) => void,
): Promise<void> {
  // 1. Upsert the calendar collection row.
  //    ctag / syncToken: use ?? null defensively (Pitfall #6 — Fastmail may omit either)
  await db
    .insert(calendars)
    .values({
      userId,
      url: davCal.url,
      displayName: typeof davCal.displayName === 'string' ? davCal.displayName : null,
      ctag: davCal.ctag ?? null,
      syncToken: davCal.syncToken ?? null,
      lastSyncedAt: new Date(),
    })
    .onDuplicateKeyUpdate({
      set: {
        ctag: davCal.ctag ?? null,
        syncToken: davCal.syncToken ?? null,
        lastSyncedAt: new Date(),
      },
    });

  // 2. Select the calendar row to get its DB id (insertId is unreliable on ON DUPLICATE KEY UPDATE).
  //    BUG B: scope by (userId, url) — the same collection URL exists for both members
  //    (shared Fastmail account, D-16). A url-only lookup returned the OTHER member's
  //    row (lowest id), so events were cached under the wrong calendarId.
  const [cal] = await db
    .select()
    .from(calendars)
    .where(and(eq(calendars.userId, userId), eq(calendars.url, davCal.url)))
    .limit(1);
  if (!cal) {
    // Should never happen — we just upserted it
    throw new Error(
      `syncCalendar: could not find calendar row for userId=${userId} url=${davCal.url}`,
    );
  }

  // 3. Fetch all calendar objects (REPORT calendar-query).
  const objects = await client.fetchCalendarObjects({ calendar: davCal });

  // 4. Parse each VCALENDAR/VEVENT and upsert into calendar_events.
  //    Track every uid we see on the server so step 5 can prune cache rows that
  //    no longer exist on Fastmail (deletes — local or external).
  //    Also collect EventChange records for the onChanges callback (NOTIF-03).
  const seenUids: string[] = [];
  const changes: EventChange[] = [];

  for (const obj of objects) {
    if (!obj.data) continue;

    let parsed: ReturnType<typeof ICAL.parse>;
    try {
      // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- ical.js parse() returns 'any'; result is only passed to ICAL.Component which accepts it
      parsed = ICAL.parse(obj.data as string);
    } catch {
      // Malformed VCALENDAR — skip but do not crash the sync
      continue;
    }

    // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() output is 'any'; ICAL.Component is the correct consumer of this value
    const comp = new ICAL.Component(parsed);
    const vevent = comp.getFirstSubcomponent('vevent');
    if (!vevent) continue;

    // ical.js getFirstPropertyValue returns a union type; cast to ICAL.Time for date handling
    const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time | null;
    const uid = vevent.getFirstPropertyValue('uid') as string | null;
    if (!uid) continue;
    seenUids.push(uid);

    // D-13 / Pitfall #3: isDate=true → DATE column; isDate=false → TIMESTAMP column
    const allDay: boolean = dtstart?.isDate ?? false;

    // Determine if this event is a recurring master (has RRULE or RDATE).
    // Use ICAL.Event.isRecurring() for parity with expand.ts — it checks both properties.
    const isRecurring: boolean = new ICAL.Event(vevent).isRecurring();

    // dtstartDate: Drizzle's `date` column accepts a Date object or null.
    // We convert the YYYY-MM-DD string from ical.js to a Date (at midnight UTC) so
    // Drizzle serialises it correctly as a DATE without a time component.
    const dtstartDateValue: Date | null =
      allDay && dtstart ? new Date(dtstart.toString().slice(0, 10) + 'T00:00:00Z') : null;
    const dtstartUtcValue: Date | null = !allDay && dtstart ? dtstart.toJSDate() : null;

    // Extract SUMMARY → title (NOTIF-01 dependency; closes title column stub).
    const titleValue: string | null =
      (vevent.getFirstPropertyValue('summary') as string | null) ?? null;

    // Extract LOCATION for meaningful-change detection (D-04).
    const locationValue: string | null =
      (vevent.getFirstPropertyValue('location') as string | null) ?? null;

    // NOTIF-03: look up the existing row so we can classify add vs update.
    // One indexed lookup on (calendarId, uid) — cheap, covered by uniq_calendar_uid.
    // D-13: this read is from MariaDB cache, not Fastmail.
    let oldRow: typeof calendarEvents.$inferSelect | null = null;
    if (onChanges) {
      const existing = await db
        .select()
        .from(calendarEvents)
        .where(and(eq(calendarEvents.calendarId, cal.id), eq(calendarEvents.uid, uid)))
        .limit(1);
      oldRow = existing[0] ?? null;
    }

    await db
      .insert(calendarEvents)
      .values({
        calendarId: cal.id,
        uid,
        etag: obj.etag ?? null,
        objectUrl: obj.url ?? null,
        rawVevent: obj.data as string,
        title: titleValue,
        dtstartUtc: dtstartUtcValue,
        dtstartDate: dtstartDateValue,
        allDay,
        hasRrule: isRecurring,
      })
      .onDuplicateKeyUpdate({
        set: {
          etag: obj.etag ?? null,
          objectUrl: obj.url ?? null,
          rawVevent: obj.data as string,
          title: titleValue,
          dtstartUtc: dtstartUtcValue,
          dtstartDate: dtstartDateValue,
          allDay,
          hasRrule: isRecurring,
          updatedAt: new Date(),
        },
      });

    // Collect change record for onChanges callback (NOTIF-03).
    if (onChanges) {
      if (oldRow === null) {
        // New event — always meaningful (added)
        changes.push({
          uid,
          title: titleValue,
          operation: 'create',
          dtstartUtc: dtstartUtcValue,
          allDay,
        });
      } else {
        // Existing event — compute which meaningful fields changed (D-04)
        const changedFields: string[] = [];

        // Compare dtstartUtc (timed events)
        const oldUtcMs = oldRow.dtstartUtc ? new Date(oldRow.dtstartUtc).getTime() : null;
        const newUtcMs = dtstartUtcValue ? dtstartUtcValue.getTime() : null;
        if (oldUtcMs !== newUtcMs) changedFields.push('dtstartUtc');

        // Compare dtstartDate (all-day events) — compare ISO date string
        const oldDateStr = oldRow.dtstartDate
          ? new Date(oldRow.dtstartDate).toISOString().slice(0, 10)
          : null;
        const newDateStr = dtstartDateValue ? dtstartDateValue.toISOString().slice(0, 10) : null;
        if (oldDateStr !== newDateStr) changedFields.push('dtstartDate');

        // Compare allDay flag
        if ((oldRow.allDay ? true : false) !== allDay) changedFields.push('allDay');

        // Compare title (SUMMARY)
        const oldTitle = oldRow.title ?? null;
        if (oldTitle !== titleValue) changedFields.push('title');

        // Compare location — extract from old rawVevent for comparison
        let oldLocation: string | null = null;
        if (oldRow.rawVevent) {
          try {
            // eslint-disable-next-line @typescript-eslint/no-unsafe-argument -- ical.js parse() returns 'any'; ICAL.Component is the correct consumer of this value
            const oldComp = new ICAL.Component(ICAL.parse(oldRow.rawVevent));
            const oldVevent = oldComp.getFirstSubcomponent('vevent');
            if (oldVevent) {
              oldLocation = (oldVevent.getFirstPropertyValue('location') as string | null) ?? null;
            }
          } catch {
            // Malformed old VEVENT — skip location comparison
          }
        }
        if (oldLocation !== locationValue) changedFields.push('location');

        if (changedFields.length > 0) {
          changes.push({
            uid,
            title: titleValue,
            operation: 'update',
            changedFields,
            dtstartUtc: dtstartUtcValue,
            allDay,
          });
        }
      }
    }
  }

  // 5. Prune deletes: remove cached events for THIS calendar whose uid is no
  //    longer present on the server. Without this, a deleted event (local delete
  //    via the outbox, or an external delete in another client) lingers in
  //    calendar_events forever — GET /api/events keeps returning it and the UI
  //    shows a ghost event that "won't delete". Scoped to cal.id so it never
  //    touches another calendar or the other household member's rows (BUG B).
  //    When the server returns zero events, prune the whole calendar's cache.
  //    NOTIF-03: collect deleted uids for the onChanges callback.
  //
  //    WR-04: pre-capture the rows to delete BEFORE the DB delete so we know what
  //    was removed, then push changes AFTER the delete completes. This ensures the
  //    onChanges payload only describes events that are truly gone from the cache —
  //    not events that may have been re-fetched in a concurrent poll.
  let pendingDeleteRows: Array<{ uid: string; title: string | null }> = [];
  if (onChanges && seenUids.length > 0) {
    // Find cached uids that are about to be pruned so we can emit delete changes
    pendingDeleteRows = await db
      .select({ uid: calendarEvents.uid, title: calendarEvents.title })
      .from(calendarEvents)
      .where(and(eq(calendarEvents.calendarId, cal.id), notInArray(calendarEvents.uid, seenUids)));
  } else if (onChanges) {
    // NEW-WR-01: server returned zero events → entire calendar cache will be cleared.
    // Capture ALL currently-cached rows before the delete so delete-change events
    // are emitted for each one. Without this, bulk/clear deletions were silently dropped.
    pendingDeleteRows = await db
      .select({ uid: calendarEvents.uid, title: calendarEvents.title })
      .from(calendarEvents)
      .where(eq(calendarEvents.calendarId, cal.id));
  }

  if (seenUids.length > 0) {
    await db
      .delete(calendarEvents)
      .where(and(eq(calendarEvents.calendarId, cal.id), notInArray(calendarEvents.uid, seenUids)));
  } else {
    await db.delete(calendarEvents).where(eq(calendarEvents.calendarId, cal.id));
  }

  // Collect delete changes AFTER the DB delete (WR-04: avoids race where the same
  // uid is re-inserted by a concurrent poll before onChanges fires).
  for (const row of pendingDeleteRows) {
    changes.push({
      uid: row.uid,
      title: row.title ?? null,
      operation: 'delete',
    });
  }

  // 6. Fire onChanges callback if provided and there are changes (NOTIF-03).
  //    Fire-and-forget: sync correctness must not depend on push success.
  if (onChanges && changes.length > 0) {
    onChanges(changes);
  }
}
