import { parseContacts, parseEvents, type CalendarEvent, type Contact } from './ical';

// The JSON door: what Officer's own web UI reads.
//
// This talks DAV to Radicale over loopback rather than reading Radicale's on-disk format. The storage
// layout is Radicale's private business and changes between versions; PROPFIND is its supported
// interface and costs one in-process HTTP hop. Parsing the storage directly would be faster and would
// break silently on an upgrade, which is a bad trade for a personal calendar.
//
// The browser never sees any of this XML — that is the entire point of having a JSON door. See
// docs/nextcloud-replacement.md, "two front doors, not one".

export type Collection = {
  /** DAV path, relative to the /dav mount — the id the UI passes back. */
  path: string;
  displayName: string;
  kind: 'calendar' | 'addressbook';
  /** Radicale stores a per-collection colour when a client sets one; used for the UI's dot. */
  color?: string;
};

type Fetcher = (path: string, init: RequestInit) => Promise<Response>;

// Deliberately not a real XML parser. The only documents read here are Radicale's own multistatus
// responses — a narrow, machine-generated shape, not arbitrary user XML — and adding a parser
// dependency to pull four fields out of it is not worth the weight. If this ever needs to read XML
// from somewhere less predictable, it should grow a parser rather than more regexes.
const between = (xml: string, tag: string): string[] => {
  const out: string[] = [];
  const re = new RegExp(`<(?:[a-zA-Z0-9]+:)?${tag}[^>]*>([\\s\\S]*?)</(?:[a-zA-Z0-9]+:)?${tag}>`, 'g');
  let m: RegExpExecArray | null;
  while ((m = re.exec(xml))) out.push(m[1] ?? '');
  return out;
};

const first = (xml: string, tag: string): string | undefined => between(xml, tag)[0];

const decodeEntities = (s: string) =>
  s
    .replace(/&lt;/g, '<')
    .replace(/&gt;/g, '>')
    .replace(/&quot;/g, '"')
    .replace(/&#39;/g, "'")
    .replace(/&amp;/g, '&');

const PROPFIND_COLLECTIONS = `<?xml version="1.0"?>
<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CR="urn:ietf:params:xml:ns:carddav" xmlns:IC="http://apple.com/ns/ical/">
  <D:prop><D:resourcetype/><D:displayname/><IC:calendar-color/></D:prop>
</D:propfind>`;

/** Every calendar and address book belonging to this user. */
export async function listCollections(fetcher: Fetcher, userId: string): Promise<Collection[]> {
  const res = await fetcher(`/${userId}/`, {
    method: 'PROPFIND',
    headers: { Depth: '1', 'Content-Type': 'application/xml' },
    body: PROPFIND_COLLECTIONS,
  });
  if (!res.ok && res.status !== 207) return [];
  const xml = await res.text();

  const collections: Collection[] = [];
  for (const response of between(xml, 'response')) {
    const href = first(response, 'href');
    if (!href) continue;

    // The tag must END here — `\b` alone also matches `<C:calendar-home-set/>`, which the principal
    // carries, and that made the principal itself show up in the list as a calendar named "1".
    const isCalendar = /<(?:[a-zA-Z0-9]+:)?calendar\s*\/>/.test(response);
    const isAddressbook = /<(?:[a-zA-Z0-9]+:)?addressbook\s*\/>/.test(response);
    if (!isCalendar && !isAddressbook) continue; // the principal itself, or a plain collection

    const path = decodeEntities(href.trim());
    const displayName = decodeEntities((first(response, 'displayname') ?? '').trim());
    const color = (first(response, 'calendar-color') ?? '').trim() || undefined;

    const lastSegment = path.replace(/\/$/, '').split('/').pop() || 'Untitled';

    collections.push({
      path,
      // Radicale's default displayname is the collection's own path ("1/work"), which is an id, not a
      // name. A real client sets something human when it creates a collection; until one does, the last
      // segment is the better label — and it is what every DAV client falls back to anyway.
      displayName: displayName && !displayName.includes('/') ? displayName : lastSegment,
      kind: isCalendar ? 'calendar' : 'addressbook',
      color,
    });
  }
  return collections;
}

/**
 * Every object in one collection, in one round trip.
 *
 * REPORT, not PROPFIND. `calendar-data` and `address-data` are not WebDAV live properties — RFC 4791 and
 * RFC 6352 define them as report-only, and Radicale correctly returns an empty prop for them under
 * PROPFIND. Asking the wrong way produces a 207 full of nothing, which reads exactly like "the calendar
 * is empty" and is a genuinely confusing way to be wrong.
 *
 * One report rather than a listing plus a GET per item: a calendar with a thousand events would
 * otherwise be a thousand round trips.
 */
async function readObjects(fetcher: Fetcher, path: string, kind: 'calendar' | 'addressbook'): Promise<string[]> {
  const dataTag = kind === 'calendar' ? 'calendar-data' : 'address-data';
  const ns = kind === 'calendar' ? 'urn:ietf:params:xml:ns:caldav' : 'urn:ietf:params:xml:ns:carddav';
  const queryTag = kind === 'calendar' ? 'calendar-query' : 'addressbook-query';
  // An empty filter means "everything". A calendar-query with no comp-filter is legal and is the
  // cheapest way to say it.
  const filter = kind === 'calendar' ? '<X:filter><X:comp-filter name="VCALENDAR"/></X:filter>' : '<X:filter/>';

  const res = await fetcher(path, {
    method: 'REPORT',
    headers: { Depth: '1', 'Content-Type': 'application/xml' },
    body: `<?xml version="1.0"?><X:${queryTag} xmlns:D="DAV:" xmlns:X="${ns}"><D:prop><X:${dataTag}/></D:prop>${filter}</X:${queryTag}>`,
  });
  if (!res.ok && res.status !== 207) return [];
  const xml = await res.text();
  return between(xml, dataTag)
    .map((chunk) => decodeEntities(chunk).trim())
    .filter(Boolean);
}

export async function listEvents(fetcher: Fetcher, path: string): Promise<CalendarEvent[]> {
  const blobs = await readObjects(fetcher, path, 'calendar');
  return blobs.flatMap(parseEvents);
}

export async function listContacts(fetcher: Fetcher, path: string): Promise<Contact[]> {
  const blobs = await readObjects(fetcher, path, 'addressbook');
  return blobs.flatMap(parseContacts);
}
