// Just enough iCalendar/vCard to drive a list view.
//
// SCOPE, DELIBERATELY SMALL. This unfolds lines, splits properties and pulls out the handful of fields a
// UI shows. It does NOT expand RRULE, and it does not resolve VTIMEZONE — see
// docs/nextcloud-replacement.md for why those are the expensive half and why Radicale, not this file,
// owns correctness. `rrule` is passed through raw so the UI can say "repeats" without either of us
// pretending to know when.
//
// Everything here is display-only. The authoritative copy of an event is the .ics on disk that DAV
// clients read and write; nothing in this file ever writes one back.

/** Unfold RFC 5545 continuation lines (a leading space or tab continues the previous line). */
function unfold(text: string): string[] {
  const out: string[] = [];
  for (const raw of text.split(/\r?\n/)) {
    if ((raw.startsWith(' ') || raw.startsWith('\t')) && out.length > 0) {
      out[out.length - 1] += raw.slice(1);
    } else if (raw.length > 0) {
      out.push(raw);
    }
  }
  return out;
}

export type Prop = { name: string; params: Record<string, string>; value: string };

/** `DTSTART;TZID=Europe/Lisbon:20260805T090000` → name, params, value. */
function parseLine(line: string): Prop | null {
  const colon = line.indexOf(':');
  if (colon < 0) return null;
  const head = line.slice(0, colon);
  const value = line.slice(colon + 1);
  const [name, ...paramParts] = head.split(';');
  const params: Record<string, string> = {};
  for (const part of paramParts) {
    const eq = part.indexOf('=');
    if (eq > 0) params[part.slice(0, eq).toUpperCase()] = part.slice(eq + 1).replace(/^"|"$/g, '');
  }
  return { name: (name ?? '').toUpperCase(), params, value };
}

// Escaped per RFC 5545 §3.3.11. Order matters: backslash last, or it would un-escape the escapes.
const unescapeText = (value: string) =>
  value.replace(/\\n/gi, '\n').replace(/\\,/g, ',').replace(/\\;/g, ';').replace(/\\\\/g, '\\');

/**
 * iCalendar dates come in three shapes and conflating them is how an all-day event ends up on the wrong
 * day: `20260805` (date only), `20260805T090000Z` (UTC) and `20260805T090000` with a TZID parameter
 * (floating/local). Only the first two can be resolved here without a timezone database, so the third is
 * returned as-is with `allDay:false` and the UI treats it as local — which is what it means to whoever
 * typed it in.
 */
export function parseDate(prop: Prop): { iso: string; allDay: boolean } | null {
  const v = prop.value.trim();
  const dateOnly = /^(\d{4})(\d{2})(\d{2})$/.exec(v);
  if (dateOnly) return { iso: `${dateOnly[1]}-${dateOnly[2]}-${dateOnly[3]}`, allDay: true };

  const dateTime = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/.exec(v);
  if (!dateTime) return null;
  const [, y, mo, d, h, mi, s, z] = dateTime;
  return { iso: `${y}-${mo}-${d}T${h}:${mi}:${s}${z ? 'Z' : ''}`, allDay: false };
}

export type CalendarEvent = {
  uid: string;
  summary: string;
  description?: string;
  location?: string;
  start?: string;
  end?: string;
  allDay: boolean;
  /** Raw RRULE, unexpanded. Present means "this repeats"; the UI must not infer occurrences from it. */
  rrule?: string;
  status?: string;
};

/** Every VEVENT in one .ics. A file usually holds one, plus overrides for a recurring series. */
export function parseEvents(ics: string): CalendarEvent[] {
  const events: CalendarEvent[] = [];
  let current: CalendarEvent | null = null;
  // Only VEVENT is collected; a VALARM or VTIMEZONE nested inside must not have its properties
  // attributed to the event that contains it.
  let depth: string[] = [];

  for (const line of unfold(ics)) {
    const prop = parseLine(line);
    if (!prop) continue;

    if (prop.name === 'BEGIN') {
      depth.push(prop.value.toUpperCase());
      if (prop.value.toUpperCase() === 'VEVENT') current = { uid: '', summary: '', allDay: false };
      continue;
    }
    if (prop.name === 'END') {
      if (prop.value.toUpperCase() === 'VEVENT' && current) {
        events.push(current);
        current = null;
      }
      depth.pop();
      continue;
    }
    if (!current || depth[depth.length - 1] !== 'VEVENT') continue;

    switch (prop.name) {
      case 'UID':
        current.uid = prop.value;
        break;
      case 'SUMMARY':
        current.summary = unescapeText(prop.value);
        break;
      case 'DESCRIPTION':
        current.description = unescapeText(prop.value);
        break;
      case 'LOCATION':
        current.location = unescapeText(prop.value);
        break;
      case 'STATUS':
        current.status = prop.value;
        break;
      case 'RRULE':
        current.rrule = prop.value;
        break;
      case 'DTSTART': {
        const parsed = parseDate(prop);
        if (parsed) {
          current.start = parsed.iso;
          current.allDay = parsed.allDay;
        }
        break;
      }
      case 'DTEND': {
        const parsed = parseDate(prop);
        if (parsed) current.end = parsed.iso;
        break;
      }
    }
  }
  return events;
}

export type Contact = {
  uid: string;
  fullName: string;
  emails: string[];
  phones: string[];
  org?: string;
  title?: string;
  /** Present when the card carries an embedded photo; the bytes are not inlined into list responses. */
  hasPhoto: boolean;
};

/** Every VCARD in one .vcf. */
export function parseContacts(vcf: string): Contact[] {
  const contacts: Contact[] = [];
  let current: Contact | null = null;

  for (const line of unfold(vcf)) {
    const prop = parseLine(line);
    if (!prop) continue;

    if (prop.name === 'BEGIN' && prop.value.toUpperCase() === 'VCARD') {
      current = { uid: '', fullName: '', emails: [], phones: [], hasPhoto: false };
      continue;
    }
    if (prop.name === 'END' && prop.value.toUpperCase() === 'VCARD') {
      if (current) contacts.push(current);
      current = null;
      continue;
    }
    if (!current) continue;

    switch (prop.name) {
      case 'UID':
        current.uid = prop.value;
        break;
      case 'FN':
        current.fullName = unescapeText(prop.value);
        break;
      case 'EMAIL':
        current.emails.push(prop.value.trim());
        break;
      case 'TEL':
        current.phones.push(prop.value.trim());
        break;
      case 'ORG':
        // ORG is structured: `Company;Department`. The first component is the one a list shows.
        current.org = unescapeText(prop.value.split(';')[0] ?? '');
        break;
      case 'TITLE':
        current.title = unescapeText(prop.value);
        break;
      case 'PHOTO':
        current.hasPhoto = true;
        break;
    }
  }
  // A card with no FN is legal but unshowable; fall back to the first email so it is at least findable.
  for (const contact of contacts) {
    if (!contact.fullName) contact.fullName = contact.emails[0] ?? contact.uid ?? 'Unnamed';
  }
  return contacts;
}
