/**
 * expandOccurrences() — server-side recurrence expansion with VTIMEZONE + allDay split.
 *
 * Architecture invariant (D-09):
 *   Recurring events are expanded SERVER-SIDE. The client receives concrete occurrences only.
 *   This function must never be imported in apps/pwa.
 *
 * Key implementation notes:
 *   - VTIMEZONE components MUST be registered before constructing ICAL.RecurExpansion (Pitfall 3).
 *     Without this, ical.js falls back to UTC and DST transitions produce ±1h wall-clock errors.
 *   - All-day events (dtstart.isDate === true) are serialized as 'YYYY-MM-DD' strings (D-13).
 *     Never use midnight-UTC datetime for all-day events (Pitfall 2).
 *   - EXDATE exclusions are handled internally by ICAL.RecurExpansion — no manual filtering needed.
 *   - Malformed rawVevent returns [] without throwing (matches sync.ts resilience pattern).
 *
 * Sources:
 *   - https://github.com/kewisch/ical.js/wiki/Common-Use-Cases (RecurExpansion + VTIMEZONE pattern)
 *   - .planning/phases/02-calendar-display/02-RESEARCH.md §Pattern 1 + §Code Examples
 */

import ICAL from 'ical.js';

/**
 * A concrete calendar event occurrence ready for UI consumption.
 * Each field the Schedule-X frontend needs is present; no raw iCalendar blobs exposed.
 *
 * id: stable identity key = `${uid}::${startIso}` — Schedule-X uses this for dedup.
 * start/end:
 *   - All-day:  'YYYY-MM-DD' (DATE string, no time component) — must use Temporal.PlainDate on client
 *   - Timed:    IANA-annotated ISO-8601 e.g. '2026-06-01T10:00:00-04:00[America/New_York]' —
 *               Temporal.ZonedDateTime.from() requires the IANA bracket; offset-only strings throw.
 *
 * Color routing (D-06, CAL-02):
 *   The client routes calendarId for Schedule-X as: isShared ? 'shared' : String(ownerUserId)
 *   The DB calendarId is also present for reference but NOT used as the Schedule-X calendarId.
 */
export interface CalendarOccurrence {
  /** `ev-<sanitized-uid>-<epochMs>` — Schedule-X-safe stable id (see makeOccurrenceId) */
  id: string;
  uid: string;
  calendarId: number;
  calendarName: string;
  /**
   * The Fastmail user ID who owns the calendar this occurrence belongs to.
   * Client routes Schedule-X calendarId as String(ownerUserId) for personal calendars.
   */
  ownerUserId: number;
  /**
   * Display name of the calendar owner (users.displayName).
   * Null when the user has not set a display name.
   * The popover uses this to show the owner name for personal events;
   * falls back to calendarName when null.
   */
  ownerName: string | null;
  /** Hex color: users.color for personal calendars, '#F25C7A' for shared-family */
  color: string;
  /** True when this occurrence belongs to the shared-family calendar (calendars.isShared=true) */
  isShared: boolean;
  title: string;
  /** 'YYYY-MM-DD' for all-day events; IANA-annotated ISO string for timed events e.g. '2026-06-01T10:00:00-04:00[America/New_York]' */
  start: string;
  /** 'YYYY-MM-DD' for all-day events; IANA-annotated ISO string for timed events e.g. '2026-06-01T11:00:00-04:00[America/New_York]' */
  end: string;
  allDay: boolean;
  location: string | null;
  description: string | null;
  /** True when this occurrence belongs to a recurring series (has RRULE). False for single events. */
  hasRrule: boolean;
}

/**
 * Meta passed per-row from the events route join result.
 * Carries color and ownership info from the calendars→users join.
 */
export interface OccurrenceMeta {
  calendarId: number;
  calendarName: string;
  ownerUserId: number;
  /** Display name of the calendar owner; null when users.displayName is not set */
  ownerName: string | null;
  /** Hex color: pre-computed by the route (users.color or shared-family constant) */
  color: string;
  isShared: boolean;
}

/**
 * Build a Schedule-X-safe occurrence id.
 *
 * Schedule-X validates event ids against `document.querySelector` — the id must be a valid
 * CSS identifier (letters, digits, '-', '_'), must NOT contain ':', '[', ']', '+', and must
 * NOT start with a digit. The old `${uid}::${iso}` format violated this (the ISO timestamp
 * carries ':' and the '[IANA/Zone]' bracket), so eventsService.set() threw and blanked the
 * calendar.
 *
 * Format: `ev-<sanitized-uid>-<epochMs>`
 *   - 'ev-' prefix guarantees a non-digit first character.
 *   - uid is sanitized (any non [A-Za-z0-9_-] char → '_') since iCal UIDs may contain '@', '.'.
 *   - epochMs (occurrence start instant) disambiguates recurring occurrences and is stable
 *     across refetches/windows, so Schedule-X dedup and the popover id lookup keep matching.
 */
function makeOccurrenceId(uid: string, start: ICAL.Time): string {
  const safeUid = uid.replace(/[^A-Za-z0-9_-]/g, '_');
  const epochMs = start.toJSDate().getTime();
  return `ev-${safeUid}-${epochMs}`;
}

/**
 * Format a UTC offset (in seconds) as ±HH:MM.
 * ICAL.Time.utcOffset() returns total seconds (positive = east of UTC).
 */
function formatUtcOffset(offsetSeconds: number): string {
  const sign = offsetSeconds < 0 ? '-' : '+';
  const abs = Math.abs(offsetSeconds);
  const hours = Math.floor(abs / 3600);
  const minutes = Math.floor((abs % 3600) / 60);
  return `${sign}${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
}

/**
 * Serialize an ICAL.Time to an IANA-annotated ISO 8601 string for timed events,
 * or a plain 'YYYY-MM-DD' string for all-day events.
 *
 * For timed events: '2026-03-01T10:00:00-05:00[America/New_York]'
 *   The IANA bracket is REQUIRED — Temporal.ZonedDateTime.from() throws on offset-only
 *   strings such as '2026-03-01T10:00:00-05:00'. See verified diagnosis in PLAN.md.
 * For all-day events: '2026-06-15'
 */
function serializeTime(t: ICAL.Time, allDay: boolean): string {
  if (allDay) {
    // All-day: return DATE-only string — never construct a datetime (Pitfall 2)
    const y = String(t.year);
    const m = String(t.month).padStart(2, '0');
    const d = String(t.day).padStart(2, '0');
    return `${y}-${m}-${d}`;
  }

  // Timed: build an IANA-annotated ISO string so the client can construct Temporal.ZonedDateTime.
  // ICAL.Time.toString() gives 'YYYY-MM-DDTHH:mm:ssZ' for UTC or 'YYYY-MM-DDTHH:mm:ss' for local.
  // We always emit '...±HH:MM[IANA/Zone]' — the bracket is mandatory for ZonedDateTime.from().
  if (t.zone === ICAL.Timezone.utcTimezone) {
    // UTC zone: toString() produces '...Z'; strip the Z and emit +00:00[UTC].
    const base = t.toString().replace(/Z$/, ''); // e.g. '2026-03-01T10:00:00'
    return `${base}+00:00[UTC]`;
  }

  const tzid = t.zone?.tzid;
  // Floating zone (tzid === 'floating') has no real timezone — fall back to UTC.
  // This is a safe degradation: the event had no VTIMEZONE and no offset is knowable.
  if (!tzid || tzid === 'floating') {
    const base = t.toString(); // no trailing Z for floating
    return `${base}+00:00[UTC]`;
  }

  // Named IANA zone: combine base datetime + offset + IANA bracket.
  const base = t.toString(); // e.g. '2026-03-01T10:00:00'
  const offsetSec = t.utcOffset();
  return `${base}${formatUtcOffset(offsetSec)}[${tzid}]`;
}

/**
 * Expand a raw VCALENDAR/VEVENT string into concrete occurrences within [windowStart, windowEnd).
 *
 * @param rawVevent    Full VCALENDAR string (as stored in calendarEvents.rawVevent)
 * @param windowStart  Start of the requested date window (inclusive)
 * @param windowEnd    End of the requested date window (exclusive)
 * @param meta         Calendar/ownership metadata from the JOIN result
 * @returns            Array of concrete CalendarOccurrence objects; [] on parse failure or no match
 */
export function expandOccurrences(
  rawVevent: string,
  windowStart: Date,
  windowEnd: Date,
  calendarId: number,
  calendarName: string,
  ownerUserId: number,
  ownerName: string | null,
  color: string,
  isShared: boolean,
): CalendarOccurrence[] {
  // --- 1. Parse VCALENDAR — return [] on malformed input (matches sync.ts pattern) ---
  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(rawVevent);
  } catch {
    return [];
  }

  // 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);

  // --- 2. Register VTIMEZONE components BEFORE constructing RecurExpansion (MANDATORY) ---
  // Skipping this causes ical.js to fall back to UTC, producing ±1h DST errors (Pitfall 3).
  for (const vtz of comp.getAllSubcomponents('vtimezone')) {
    const tzid = vtz.getFirstPropertyValue('tzid') as string;
    if (tzid && !ICAL.TimezoneService.has(tzid)) {
      // TimezoneService.register(timezone, name?) — first arg is the Timezone object
      ICAL.TimezoneService.register(new ICAL.Timezone({ component: vtz, tzid }), tzid);
    }
  }

  // --- 3. Get the VEVENT component ---
  const vevent = comp.getFirstSubcomponent('vevent');
  if (!vevent) return [];

  const event = new ICAL.Event(vevent);
  const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time;
  if (!dtstart) return [];

  const allDay: boolean = dtstart.isDate;
  const uid: string = event.uid ?? '';

  // useUTC=true: windowStart/windowEnd are absolute instants (midnight-UTC of the
  // requested dates). Interpreting them in UTC keeps the occurrence-window comparison
  // absolute. With useUTC=false ical.js used the SERVER's local timezone, shifting the
  // window by the server's offset and dropping evening occurrences near the window end
  // (e.g. a 17:45-04:00 event = 21:45Z fell past a day window whose end was shifted to 20:00).
  const rangeStart = ICAL.Time.fromJSDate(windowStart, true);
  const rangeEnd = ICAL.Time.fromJSDate(windowEnd, true);

  const occurrences: CalendarOccurrence[] = [];

  // Capture once — used in both the non-recurring and recurring branches to populate hasRrule.
  const isRecurring = event.isRecurring();

  // --- 4. Non-recurring event: single occurrence check ---
  if (!isRecurring) {
    if (dtstart.compare(rangeStart) >= 0 && dtstart.compare(rangeEnd) < 0) {
      // Use ICAL.Event.endDate which derives end from DTEND, or DTSTART+DURATION, or sensible default.
      // Do NOT use getFirstPropertyValue('dtend') directly — events with only DURATION set return null,
      // producing zero-duration occurrences (BUG 1).
      let occEnd: ICAL.Time = event.endDate ?? dtstart;

      // Positive-duration guard: ensure timed events have non-zero height in Schedule-X.
      if (!allDay && occEnd.compare(dtstart) <= 0) {
        occEnd = dtstart.clone();
        occEnd.addDuration(ICAL.Duration.fromString('PT30M'));
      } else if (allDay && occEnd.compare(dtstart) <= 0) {
        occEnd = dtstart.clone();
        occEnd.addDuration(ICAL.Duration.fromString('P1D'));
      }

      const start = serializeTime(dtstart, allDay);
      const end = serializeTime(occEnd, allDay);
      occurrences.push({
        id: makeOccurrenceId(uid, dtstart),
        uid,
        calendarId,
        calendarName,
        ownerUserId,
        ownerName,
        color,
        isShared,
        title: event.summary ?? '',
        start,
        end,
        allDay,
        location: event.location ?? null,
        description: event.description ?? null,
        hasRrule: isRecurring, // always false in the non-recurring branch
      });
    }
    return occurrences;
  }

  // --- 5. Recurring event: use ICAL.RecurExpansion ---
  // RecurExpansion handles RRULE + RDATE + EXDATE internally — no manual EXDATE filtering (A1).
  // VTIMEZONE was registered in step 2 above, so DST occurrences get correct wall-clock time.
  const expand = new ICAL.RecurExpansion({ component: vevent, dtstart });

  let next: ICAL.Time | null | undefined;
  while ((next = expand.next() as ICAL.Time | null | undefined) && next.compare(rangeEnd) < 0) {
    if (next.compare(rangeStart) < 0) continue;

    // Compute occurrence end from event duration
    const duration = event.duration;
    let occEnd = next.clone();
    occEnd.addDuration(duration);

    // Positive-duration guard: same logic as non-recurring branch above.
    if (!allDay && occEnd.compare(next) <= 0) {
      occEnd = next.clone();
      occEnd.addDuration(ICAL.Duration.fromString('PT30M'));
    } else if (allDay && occEnd.compare(next) <= 0) {
      occEnd = next.clone();
      occEnd.addDuration(ICAL.Duration.fromString('P1D'));
    }

    const start = serializeTime(next, allDay);
    const end = serializeTime(occEnd, allDay);

    occurrences.push({
      id: makeOccurrenceId(uid, next),
      uid,
      calendarId,
      calendarName,
      ownerUserId,
      ownerName,
      color,
      isShared,
      title: event.summary ?? '',
      start,
      end,
      allDay,
      location: event.location ?? null,
      description: event.description ?? null,
      hasRrule: isRecurring, // always true in the recurring branch
    });
  }

  return occurrences;
}
