import type {
  IssueProviderPluginDefinition,
  PluginFieldMapping,
  PluginHttp,
  PluginIssue,
  PluginSearchResult,
} from '@super-productivity/plugin-api';
import ICAL from 'ical.js';

declare const PluginAPI: {
  registerIssueProvider(definition: IssueProviderPluginDefinition): void;
};

// --- Config ---

interface CaldavCalendarConfig {
  serverUrl?: string;
  username?: string;
  password?: string;
  readCalendarIds?: string[];
  writeCalendarId?: string;
  syncRangeWeeks?: string;
  isAutoTimeBlock?: boolean;
  timeBlockCalendarId?: string;
}

const getWriteCalendarId = (cfg: CaldavCalendarConfig): string =>
  cfg.writeCalendarId || '';

const getTimeBlockCalendarId = (cfg: CaldavCalendarConfig): string =>
  cfg.timeBlockCalendarId || getWriteCalendarId(cfg);

const getReadCalendarIds = (cfg: CaldavCalendarConfig): string[] =>
  cfg.readCalendarIds?.length ? cfg.readCalendarIds : [];

const getServerUrl = (cfg: CaldavCalendarConfig): string => {
  let url = cfg.serverUrl || '';
  if (url.endsWith('/')) url = url.slice(0, -1);
  return url;
};

// --- Compound IDs ---
// With multiple read calendars, CRUD methods need to know which calendar
// an event belongs to. Format: "calendarHref::eventHref" with an optional
// "#occ=<ms>" suffix to disambiguate expanded RRULE occurrences while
// keeping the eventHref portion resolvable as a real CalDAV resource.
// The '#' fragment marker is used because URL.pathname strips fragments,
// so a server-controlled href can never legitimately contain it.

const COMPOUND_SEP = '::';
const OCCURRENCE_SEP = '#occ=';

const toCompoundId = (
  calendarHref: string,
  eventHref: string,
  occurrenceMs?: number,
): string => {
  const base = `${calendarHref}${COMPOUND_SEP}${eventHref}`;
  return occurrenceMs !== undefined ? `${base}${OCCURRENCE_SEP}${occurrenceMs}` : base;
};

const parseCompoundId = (
  id: string,
  fallbackCalendarHref: string,
): { calendarHref: string; eventHref: string; occurrenceMs?: number } => {
  // Strip the occurrence suffix first so the remaining base id is unambiguous.
  // A present `#occ=<digits>` marks an expanded RRULE instance whose eventHref
  // still resolves to the shared master resource — surface it so write/read
  // paths can stay occurrence-aware instead of silently hitting the master.
  const occIdx = id.lastIndexOf(OCCURRENCE_SEP);
  const hasOccurrence =
    occIdx !== -1 && /^\d+$/.test(id.slice(occIdx + OCCURRENCE_SEP.length));
  const occurrenceMs = hasOccurrence
    ? parseInt(id.slice(occIdx + OCCURRENCE_SEP.length), 10)
    : undefined;
  const baseId = hasOccurrence ? id.slice(0, occIdx) : id;
  const sep = baseId.indexOf(COMPOUND_SEP);
  if (sep === -1) {
    return { calendarHref: fallbackCalendarHref, eventHref: baseId, occurrenceMs };
  }
  return {
    calendarHref: baseId.slice(0, sep),
    eventHref: baseId.slice(sep + COMPOUND_SEP.length),
    occurrenceMs,
  };
};

/**
 * A write that targets a single expanded RRULE occurrence (an `#occ=` id) can't
 * be applied: the occurrence maps back to the shared master resource, so writing
 * it would mutate the whole series. We refuse with this marked error. The
 * `isExpectedSyncSkip` flag tells the host's two-way sync that this is an
 * expected limitation — the user edited/deleted their *task*, not the calendar —
 * so it stays silent instead of showing a sync-failure snack. Explicit calendar
 * actions (agenda reschedule/delete) don't check the flag and still surface the
 * message, which is the honest feedback there. See issue #7492.
 */
const unsupportedOccurrenceWriteError = (action: 'edit' | 'delete'): Error =>
  Object.assign(
    new Error(
      `${action === 'edit' ? 'Editing' : 'Deleting'} a single occurrence of a ` +
        `recurring event is not supported yet. ` +
        `Please ${action} the event in your calendar app instead.`,
    ),
    { isExpectedSyncSkip: true },
  );

// --- iCal Helpers ---

/** Unfold iCal line continuations (RFC 5545 Section 3.1) */
const unfoldIcal = (data: string): string =>
  data.replace(/\r\n[ \t]/g, '').replace(/\n[ \t]/g, '');

/** Escape text for iCal property values (RFC 5545 Section 3.3.11) */
const escapeIcalText = (text: string): string =>
  text
    .replace(/\\/g, '\\\\')
    .replace(/;/g, '\\;')
    .replace(/,/g, '\\,')
    .replace(/\r\n/g, '\\n')
    .replace(/\r/g, '\\n')
    .replace(/\n/g, '\\n');

/** Unescape iCal property values */
const unescapeIcalText = (text: string): string =>
  text
    .replace(/\\n/gi, '\n')
    .replace(/\\,/g, ',')
    .replace(/\\;/g, ';')
    .replace(/\\{2}/g, '\\');

/** Fold long iCal lines at 75 octets (RFC 5545 Section 3.1) */
const _encoder = new TextEncoder();
const _decoder = new TextDecoder();
const foldIcalLine = (line: string): string => {
  const bytes = _encoder.encode(line);
  if (bytes.length <= 75) return line;
  const parts: string[] = [];
  let byteOffset = 0;
  let isFirst = true;
  while (byteOffset < bytes.length) {
    const maxBytes = isFirst ? 75 : 74;
    let end = Math.min(byteOffset + maxBytes, bytes.length);
    // Don't split in the middle of a multi-byte UTF-8 sequence
    while (end > byteOffset && (bytes[end] & 0xc0) === 0x80) end--;
    if (end === byteOffset) end = byteOffset + 1;
    parts.push(_decoder.decode(bytes.slice(byteOffset, end)));
    byteOffset = end;
    isFirst = false;
  }
  return parts.join('\r\n ');
};

interface ParsedVEvent {
  uid: string;
  summary: string;
  description: string;
  dtstart: string;
  dtend: string;
  dtstartParams: string;
  dtendParams: string;
  duration: string;
  status: string;
  lastModified: string;
  etag: string;
}

/** Extract a property value from unfolded iCal lines */
const getIcalProp = (lines: string[], name: string): string => {
  const prefix1 = name + ':';
  const prefix2 = name + ';';
  for (const line of lines) {
    if (line.startsWith(prefix1)) return line.slice(prefix1.length);
    if (line.startsWith(prefix2)) {
      const colonIdx = line.indexOf(':');
      if (colonIdx !== -1) return line.slice(colonIdx + 1);
    }
  }
  return '';
};

/** Extract property parameters (e.g. TZID=America/New_York) */
const getIcalPropParams = (lines: string[], name: string): string => {
  const prefix = name + ';';
  for (const line of lines) {
    if (line.startsWith(prefix)) {
      const colonIdx = line.indexOf(':');
      if (colonIdx !== -1) return line.slice(prefix.length, colonIdx);
    }
  }
  return '';
};

/**
 * Parse iCal date/time value to a JS Date.
 * Handles: 20260320T100000Z (UTC), 20260320T100000 (floating/local),
 *          TZID=America/New_York:20260320T100000 (timezone — treated as local),
 *          20260320 (date-only, VALUE=DATE).
 */
const parseIcalDateTime = (value: string, params: string): Date | null => {
  // Defensive: callers occasionally pass epoch-ms numbers (PluginSearchResult
  // shape) instead of iCal strings; never call .slice() on a non-string. See #8564.
  if (typeof value !== 'string' || !value) return null;
  // Date-only: YYYYMMDD
  if (value.length === 8) {
    const y = parseInt(value.slice(0, 4), 10);
    const m = parseInt(value.slice(4, 6), 10) - 1;
    const d = parseInt(value.slice(6, 8), 10);
    const date = new Date(y, m, d);
    return isNaN(date.getTime()) ? null : date;
  }
  // DateTime: YYYYMMDDTHHmmss or YYYYMMDDTHHmmssZ
  const y = parseInt(value.slice(0, 4), 10);
  const m = parseInt(value.slice(4, 6), 10) - 1;
  const d = parseInt(value.slice(6, 8), 10);
  const h = parseInt(value.slice(9, 11), 10);
  const min = parseInt(value.slice(11, 13), 10);
  const s = parseInt(value.slice(13, 15), 10);
  if (value.endsWith('Z')) {
    const date = new Date(Date.UTC(y, m, d, h, min, s));
    return isNaN(date.getTime()) ? null : date;
  }
  // Try to resolve TZID via Intl API.
  // Uses formatToParts to extract timezone-shifted components without
  // local-timezone contamination from Date string parsing.
  const tzidMatch = params.match(/TZID=([^;:]+)/);
  if (tzidMatch) {
    try {
      const utcMs = Date.UTC(y, m, d, h, min, s);
      const utcDate = new Date(utcMs);
      const fmt = new Intl.DateTimeFormat('en-US', {
        timeZone: tzidMatch[1],
        year: 'numeric',
        month: 'numeric',
        day: 'numeric',
        hour: 'numeric',
        minute: 'numeric',
        second: 'numeric',
        hour12: false,
      });
      const parts = fmt.formatToParts(utcDate);
      const g = (t: string): number =>
        parseInt(parts.find((p) => p.type === t)?.value || '0', 10);
      const inTzMs = Date.UTC(
        g('year'),
        g('month') - 1,
        g('day'),
        g('hour'),
        g('minute'),
        g('second'),
      );
      const offset = inTzMs - utcMs;
      const result = new Date(utcMs - offset);
      if (!isNaN(result.getTime())) return result;
    } catch {
      // Unknown TZID — fall through to local time
    }
  }
  const date = new Date(y, m, d, h, min, s);
  return isNaN(date.getTime()) ? null : date;
};

/** Check if a DTSTART is a date-only value (VALUE=DATE) */
const isDateOnly = (value: string, params: string): boolean =>
  value.length === 8 || params.includes('VALUE=DATE');

/**
 * Parse iCal DURATION (RFC 5545 Section 3.3.6) to milliseconds.
 * Examples: PT1H, PT30M, PT1H30M, P1D, P1DT2H30M
 */
const parseDuration = (dur: string): number => {
  if (!dur) return 0;
  const sign = dur.startsWith('-') ? -1 : 1;
  let ms = 0;
  const dayMatch = dur.match(/(\d+)D/);
  const hourMatch = dur.match(/(\d+)H/);
  const minMatch = dur.match(/(\d+)M/);
  const secMatch = dur.match(/(\d+)S/);
  const weekMatch = dur.match(/(\d+)W/);
  if (weekMatch) ms += parseInt(weekMatch[1], 10) * 7 * 24 * 60 * 60 * 1000;
  if (dayMatch) ms += parseInt(dayMatch[1], 10) * 24 * 60 * 60 * 1000;
  if (hourMatch) ms += parseInt(hourMatch[1], 10) * 60 * 60 * 1000;
  if (minMatch) ms += parseInt(minMatch[1], 10) * 60 * 1000;
  if (secMatch) ms += parseInt(secMatch[1], 10) * 1000;
  return sign * ms;
};

/** Format a Date as iCal UTC datetime: YYYYMMDDTHHmmssZ */
const toIcalUtcDateTime = (date: Date): string => {
  const pad = (n: number): string => String(n).padStart(2, '0');
  return (
    `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}` +
    `T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`
  );
};

/** Format a Date as iCal date-only: YYYYMMDD */
const toIcalDate = (date: Date): string => {
  const pad = (n: number): string => String(n).padStart(2, '0');
  return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}`;
};

/** Convert a compact iCal date (YYYYMMDD) to an ISO calendar date (YYYY-MM-DD) */
const ymdToIsoDate = (ymd: string): string =>
  `${ymd.slice(0, 4)}-${ymd.slice(4, 6)}-${ymd.slice(6, 8)}`;

/** Format a timestamp as UTC ISO 8601 */
const toUTCISO = (timestamp: number): string => new Date(timestamp).toISOString();

/** Parse VEVENT blocks from unfolded iCal data */
const parseVEvents = (icalData: string): ParsedVEvent[] => {
  const unfolded = unfoldIcal(icalData);
  const events: ParsedVEvent[] = [];
  let pos = 0;
  while (true) {
    const start = unfolded.indexOf('BEGIN:VEVENT', pos);
    if (start === -1) break;
    const end = unfolded.indexOf('END:VEVENT', start);
    if (end === -1) break;
    const block = unfolded.slice(start, end + 'END:VEVENT'.length);
    const lines = block.split(/\r?\n/).filter((l) => l.length > 0);
    const dtstartRaw = getIcalProp(lines, 'DTSTART');
    const dtendRaw = getIcalProp(lines, 'DTEND');
    events.push({
      uid: getIcalProp(lines, 'UID'),
      summary: unescapeIcalText(getIcalProp(lines, 'SUMMARY')),
      description: unescapeIcalText(getIcalProp(lines, 'DESCRIPTION')),
      dtstart: dtstartRaw,
      dtend: dtendRaw,
      dtstartParams: getIcalPropParams(lines, 'DTSTART'),
      dtendParams: getIcalPropParams(lines, 'DTEND'),
      duration: getIcalProp(lines, 'DURATION'),
      status: getIcalProp(lines, 'STATUS'),
      lastModified: getIcalProp(lines, 'LAST-MODIFIED'),
      etag: '',
    });
    pos = end + 'END:VEVENT'.length;
  }
  return events;
};

/** Build a full iCalendar string for a VEVENT */
const buildICalEvent = (event: {
  uid: string;
  summary: string;
  description?: string;
  dtstart: string;
  dtstartParam?: string;
  dtend?: string;
  dtendParam?: string;
  status?: string;
}): string => {
  const now = toIcalUtcDateTime(new Date());
  const lines: string[] = [
    'BEGIN:VCALENDAR',
    'VERSION:2.0',
    'PRODID:-//Super Productivity//CalDAV Plugin//EN',
    'BEGIN:VEVENT',
    foldIcalLine(`UID:${event.uid}`),
    `DTSTAMP:${now}`,
  ];
  if (event.dtstartParam) {
    lines.push(foldIcalLine(`DTSTART;${event.dtstartParam}:${event.dtstart}`));
  } else {
    lines.push(foldIcalLine(`DTSTART:${event.dtstart}`));
  }
  if (event.dtend) {
    if (event.dtendParam) {
      lines.push(foldIcalLine(`DTEND;${event.dtendParam}:${event.dtend}`));
    } else {
      lines.push(foldIcalLine(`DTEND:${event.dtend}`));
    }
  }
  lines.push(foldIcalLine(`SUMMARY:${escapeIcalText(event.summary)}`));
  if (event.description) {
    lines.push(foldIcalLine(`DESCRIPTION:${escapeIcalText(event.description)}`));
  }
  if (event.status) {
    lines.push(`STATUS:${event.status}`);
  }
  lines.push(`LAST-MODIFIED:${now}`);
  lines.push('END:VEVENT');
  lines.push('END:VCALENDAR');
  return lines.join('\r\n') + '\r\n';
};

/**
 * Modify specific properties in an existing iCal string.
 * Uses line-based replacement matching on base property name (before ';' or ':')
 * to correctly handle property parameter changes (e.g. DTSTART → DTSTART;VALUE=DATE).
 */
const modifyICalEvent = (icalData: string, changes: Record<string, string>): string => {
  const lines = unfoldIcal(icalData).split(/\r?\n/);
  const now = toIcalUtcDateTime(new Date());

  // Index changes by base property name (e.g. "DTSTART;VALUE=DATE" → "DTSTART")
  const changesByBase = new Map<string, string>();
  for (const [prop, value] of Object.entries(changes)) {
    const baseName = prop.split(/[;:]/)[0];
    changesByBase.set(baseName, foldIcalLine(`${prop}:${value}`));
  }

  // RFC 5545: DTEND and DURATION are mutually exclusive.
  // When setting DTEND, strip existing DURATION (and vice versa).
  const stripProps = new Set<string>();
  if (changesByBase.has('DTEND')) stripProps.add('DURATION');
  if (changesByBase.has('DURATION')) stripProps.add('DTEND');

  const replaced = new Set<string>();
  const result: string[] = [];
  let seqBumped = false;
  let inVevent = false;

  for (const line of lines) {
    const baseName = line.split(/[;:]/)[0];

    if (line === 'BEGIN:VEVENT') inVevent = true;
    if (line === 'END:VEVENT') inVevent = false;

    // Only modify properties inside the VEVENT block to avoid
    // corrupting VTIMEZONE or other component blocks.
    if (!inVevent) {
      result.push(line);
      continue;
    }

    if (stripProps.has(baseName)) continue;

    if (changesByBase.has(baseName) && !replaced.has(baseName)) {
      result.push(changesByBase.get(baseName)!);
      replaced.add(baseName);
      continue;
    } else if (changesByBase.has(baseName)) {
      // Skip duplicate old lines
      continue;
    }

    if (baseName === 'LAST-MODIFIED' || baseName === 'DTSTAMP') {
      result.push(`${baseName}:${now}`);
      continue;
    }
    if (baseName === 'SEQUENCE') {
      const seq = parseInt(line.split(':')[1] || '0', 10) + 1;
      result.push(`SEQUENCE:${seq}`);
      seqBumped = true;
      continue;
    }
    result.push(line);
  }

  // Insert any changes that didn't replace existing lines
  const endIdx = result.findIndex((l) => l === 'END:VEVENT');
  if (endIdx !== -1) {
    const toInsert: string[] = [];
    for (const [baseName, newLine] of changesByBase) {
      if (!replaced.has(baseName)) toInsert.push(newLine);
    }
    if (!seqBumped) toInsert.push('SEQUENCE:1');
    result.splice(endIdx, 0, ...toInsert);
  }

  return result.join('\r\n') + '\r\n';
};

// --- XML Helpers ---

const DAV_NS = 'DAV:';
const CALDAV_NS = 'urn:ietf:params:xml:ns:caldav';
const CS_NS = 'http://calendarserver.org/ns/';

/**
 * Parse an XML string into a Document, throwing on parser errors.
 * The error message intentionally omits the parser output / response body —
 * it is untrusted server content and the log is exportable (never log it).
 * DOMParser (browser + jsdom) does not resolve external entities, so this is
 * safe against XXE; do not swap it for a server-side XML lib without disabling
 * entity resolution.
 */
const parseXmlDoc = (xml: string): Document => {
  const doc = new DOMParser().parseFromString(xml, 'application/xml');
  if (doc.querySelector('parsererror')) {
    throw new Error('[CalDAV] Failed to parse XML response');
  }
  return doc;
};

/** Build PROPFIND body for calendar discovery */
const buildPropfindBody = (): string =>
  `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:cs="${CS_NS}" xmlns:c="${CALDAV_NS}">
  <d:prop>
    <d:displayname/>
    <d:resourcetype/>
    <cs:getctag/>
    <c:supported-calendar-component-set/>
  </d:prop>
</d:propfind>`;

/** Build REPORT body for time-range event query */
const buildCalendarQueryBody = (start: string, end: string): string =>
  `<?xml version="1.0" encoding="UTF-8"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="${CALDAV_NS}">
  <d:prop>
    <d:getetag/>
    <c:calendar-data/>
  </d:prop>
  <c:filter>
    <c:comp-filter name="VCALENDAR">
      <c:comp-filter name="VEVENT">
        <c:time-range start="${start}" end="${end}"/>
      </c:comp-filter>
    </c:comp-filter>
  </c:filter>
</c:calendar-query>`;

/** Get text content from an XML element, searching by local name across namespaces */
const getXmlText = (parent: Element, localName: string): string => {
  // Try known namespaces
  for (const ns of [DAV_NS, CALDAV_NS, CS_NS]) {
    const el = parent.getElementsByTagNameNS(ns, localName)[0];
    if (el?.textContent) return el.textContent;
  }
  // Fallback: search by local name
  const all = parent.getElementsByTagName('*');
  for (let i = 0; i < all.length; i++) {
    if (all[i].localName === localName && all[i].textContent) {
      return all[i].textContent!;
    }
  }
  return '';
};

/** Check if element has a child element with given local name */
const hasXmlChild = (parent: Element, localName: string): boolean => {
  const all = parent.getElementsByTagName('*');
  for (let i = 0; i < all.length; i++) {
    if (all[i].localName === localName) return true;
  }
  return false;
};

interface CalendarInfo {
  href: string;
  displayName: string;
  supportsVevent: boolean;
}

/** Parse PROPFIND multistatus response for calendar discovery */
const parseCalendarList = (xml: string): CalendarInfo[] => {
  const doc = parseXmlDoc(xml);
  const responses = doc.getElementsByTagNameNS(DAV_NS, 'response');
  const calendars: CalendarInfo[] = [];

  for (let i = 0; i < responses.length; i++) {
    const resp = responses[i];
    const href = getXmlText(resp, 'href');
    if (!href) continue;

    // Check if this is a calendar resource
    const resourceType = resp.getElementsByTagNameNS(DAV_NS, 'resourcetype')[0];
    if (!resourceType) continue;
    const isCalendar = hasXmlChild(resourceType, 'calendar');
    if (!isCalendar) continue;

    const displayName =
      getXmlText(resp, 'displayname') || href.split('/').filter(Boolean).pop() || href;

    // Check supported components
    let supportsVevent = true; // Default to true if not specified
    const compSet = resp.getElementsByTagNameNS(
      CALDAV_NS,
      'supported-calendar-component-set',
    )[0];
    if (compSet) {
      const comps = compSet.getElementsByTagNameNS(CALDAV_NS, 'comp');
      if (comps.length > 0) {
        supportsVevent = false;
        for (let j = 0; j < comps.length; j++) {
          if (comps[j].getAttribute('name') === 'VEVENT') {
            supportsVevent = true;
            break;
          }
        }
      }
    }

    calendars.push({ href, displayName, supportsVevent });
  }

  return calendars;
};

interface CalendarEventResponse {
  href: string;
  etag: string;
  calendarData: string;
}

/** Parse REPORT multistatus response for calendar events */
const parseEventResponses = (xml: string): CalendarEventResponse[] => {
  const doc = parseXmlDoc(xml);
  const responses = doc.getElementsByTagNameNS(DAV_NS, 'response');
  const events: CalendarEventResponse[] = [];

  for (let i = 0; i < responses.length; i++) {
    const resp = responses[i];
    const rawHref = getXmlText(resp, 'href');
    const etag = getXmlText(resp, 'getetag').replace(/"/g, '');
    const calendarData = getXmlText(resp, 'calendar-data');
    if (rawHref && calendarData) {
      // Normalize href to pathname so IDs are consistent with createIssue
      const href =
        rawHref.startsWith('http://') || rawHref.startsWith('https://')
          ? new URL(rawHref).pathname
          : rawHref;
      events.push({ href, etag, calendarData });
    }
  }

  return events;
};

// --- CalDAV Operations ---

/** Resolve the base URL for PROPFIND. Appends trailing slash if missing. */
const ensureTrailingSlash = (url: string): string =>
  url.endsWith('/') ? url : url + '/';

const caldavHeaders = (extra?: Record<string, string>): Record<string, string> => ({
  'Content-Type': 'application/xml; charset=utf-8',
  ...extra,
});

/**
 * Resolve a server-supplied href against the configured server origin and
 * refuse anything that escapes it. Discovery follows hrefs that the (untrusted)
 * server controls — principal, calendar-home-set — so this is the SSRF
 * boundary: credentials are attached to every request and must never be sent
 * off-origin. Resolving via the URL constructor handles relative, absolute, and
 * protocol-relative (`//host`) forms uniformly; a prefix sniff + string concat
 * would mis-handle `//host` and uppercase schemes. The href is omitted from the
 * error (untrusted content; the log is exportable).
 */
const resolveHref = (cfg: CaldavCalendarConfig, href: string): string => {
  const serverOrigin = new URL(getServerUrl(cfg)).origin;
  const resolved = new URL(href, serverOrigin + '/');
  if (resolved.origin !== serverOrigin) {
    throw new Error('[CalDAV] Refusing cross-origin href');
  }
  return resolved.toString();
};

/** Build PROPFIND body to bootstrap discovery: principal + calendar-home-set */
const buildDiscoveryPropfindBody = (): string =>
  `<?xml version="1.0" encoding="UTF-8"?>
<d:propfind xmlns:d="DAV:" xmlns:c="${CALDAV_NS}">
  <d:prop>
    <d:current-user-principal/>
    <c:calendar-home-set/>
  </d:prop>
</d:propfind>`;

/**
 * Extract the first `<d:href>` nested inside the named property element
 * (e.g. DAV `current-user-principal` or CalDAV `calendar-home-set`).
 */
const getHrefInProp = (doc: Document, ns: string, localName: string): string => {
  const els = doc.getElementsByTagNameNS(ns, localName);
  for (let i = 0; i < els.length; i++) {
    const href = els[i].getElementsByTagNameNS(DAV_NS, 'href')[0];
    const text = href?.textContent?.trim();
    if (text) return text;
  }
  return '';
};

const propfind = (
  http: PluginHttp,
  url: string,
  body: string,
  depth: '0' | '1',
): Promise<string> =>
  http.request<string>('PROPFIND', url, body, {
    headers: { ...caldavHeaders(), Depth: depth },
    responseType: 'text',
  });

/** PROPFIND Depth:1 a collection and return its VEVENT-capable calendars */
const enumerateCalendars = async (
  http: PluginHttp,
  url: string,
): Promise<{ label: string; value: string }[]> => {
  const xml = await propfind(http, url, buildPropfindBody(), '1');
  return parseCalendarList(xml)
    .filter((c) => c.supportsVevent)
    .map((c) => ({ label: c.displayName, value: c.href }));
};

/**
 * Resolve the calendar-home-set collection that actually holds the user's
 * calendars. CalDAV servers advertise a root or principal URL — e.g. Nextcloud's
 * `https://host/remote.php/dav`, Fastmail's `https://caldav.fastmail.com/dav/` —
 * not the calendar-home itself, so a plain Depth:1 PROPFIND there lists no
 * `<calendar>` resources (they sit one or two levels deeper). Follow the RFC 4791
 * bootstrap: read `calendar-home-set` directly, else find the
 * `current-user-principal` and read `calendar-home-set` from it. Returns '' when
 * neither can be resolved.
 */
const resolveCalendarHome = async (
  http: PluginHttp,
  cfg: CaldavCalendarConfig,
  enteredUrl: string,
): Promise<string> => {
  const doc = parseXmlDoc(
    await propfind(http, enteredUrl, buildDiscoveryPropfindBody(), '0'),
  );
  const directHome = getHrefInProp(doc, CALDAV_NS, 'calendar-home-set');
  if (directHome) return ensureTrailingSlash(resolveHref(cfg, directHome));

  const principal = getHrefInProp(doc, DAV_NS, 'current-user-principal');
  if (!principal) return '';
  const principalDoc = parseXmlDoc(
    await propfind(http, resolveHref(cfg, principal), buildDiscoveryPropfindBody(), '0'),
  );
  const home = getHrefInProp(principalDoc, CALDAV_NS, 'calendar-home-set');
  return home ? ensureTrailingSlash(resolveHref(cfg, home)) : '';
};

/**
 * Discover calendars supporting VEVENT.
 * First try the entered URL directly (handles users who pasted the calendar-home
 * collection). If that lists no calendars, fall back to RFC 4791 service
 * discovery via the principal / calendar-home-set. See issue #8259.
 */
const discoverCalendars = async (
  http: PluginHttp,
  cfg: CaldavCalendarConfig,
): Promise<{ label: string; value: string }[]> => {
  const enteredUrl = ensureTrailingSlash(getServerUrl(cfg));

  try {
    const direct = await enumerateCalendars(http, enteredUrl);
    if (direct.length) return direct;
  } catch {
    // The entered URL may be a principal/root collection that rejects Depth:1.
    // Fall through to the discovery bootstrap below.
  }

  const home = await resolveCalendarHome(http, cfg, enteredUrl);
  if (!home) return [];
  return enumerateCalendars(http, home);
};

// --- ical.js-based RRULE expansion ---
// CalDAV servers often return the master VEVENT with RRULE intact and expect
// the client to expand occurrences within the requested time window.
// Implementation mirrors src/app/features/schedule/ical/get-relevant-events-from-ical.ts.

interface ICalVEvent {
  getFirstPropertyValue(name: string): unknown;
  getAllProperties(name: string): { getValues(): unknown[] }[];
  getAllSubcomponents(name: string): ICalVEvent[];
  getFirstSubcomponent(name: string): ICalVEvent | null;
}

// ical.js's published types don't expose the runtime ICAL namespace cleanly.
const ICAL_NS = ICAL as unknown as {
  parse: (s: string) => unknown;
  Component: new (jcal: unknown) => ICalVEvent;
  Timezone: new (opts: { tzid: unknown; component: ICalVEvent }) => { tzid: string };
  TimezoneService: {
    has: (tzid: string) => boolean;
    register: (tz: { tzid: string }) => void;
    remove: (tzid: string) => void;
  };
  helpers: { updateTimezones: (comp: ICalVEvent) => ICalVEvent };
};

const MAX_OCCURRENCES_PER_EVENT = 1000;

const icalTimeToMs = (t: unknown): number | null => {
  if (!t || typeof (t as { toJSDate?: () => Date }).toJSDate !== 'function') return null;
  const d = (t as { toJSDate: () => Date }).toJSDate();
  const ms = d.getTime();
  return isNaN(ms) ? null : ms;
};

const isAllDayIcal = (vevent: ICalVEvent): boolean => {
  const dtstart = vevent.getFirstPropertyValue('dtstart');
  return (dtstart as { isDate?: boolean })?.isDate === true;
};

const calcDurationMs = (vevent: ICalVEvent, startMs: number): number => {
  const dtend = vevent.getFirstPropertyValue('dtend');
  if (dtend) {
    const endMs = icalTimeToMs(dtend);
    if (endMs !== null) return endMs - startMs;
  }
  const dur = vevent.getFirstPropertyValue('duration');
  if (dur && typeof (dur as { toSeconds?: () => number }).toSeconds === 'function') {
    return (dur as { toSeconds: () => number }).toSeconds() * 1000;
  }
  return 0;
};

const getExdateMs = (vevent: ICalVEvent): number[] => {
  const out: number[] = [];
  const props = vevent.getAllProperties('exdate');
  for (const p of props) {
    for (const v of p.getValues()) {
      const ms = icalTimeToMs(v);
      if (ms !== null) out.push(ms);
    }
  }
  return out;
};

const isCancelledStatus = (status: unknown): boolean => {
  if (typeof status === 'string') return status.toUpperCase() === 'CANCELLED';
  const fn = (status as { toUpperCase?: () => string })?.toUpperCase;
  return typeof fn === 'function' && fn.call(status) === 'CANCELLED';
};

interface ExceptionInstance {
  vevent: ICalVEvent;
  recurrenceMs: number;
  isCancelled: boolean;
}

const buildExceptionMap = (vevents: ICalVEvent[]): Map<string, ExceptionInstance[]> => {
  const map = new Map<string, ExceptionInstance[]>();
  for (const ve of vevents) {
    const recurrenceMs = icalTimeToMs(ve.getFirstPropertyValue('recurrence-id'));
    if (recurrenceMs === null) continue;
    const uid = ve.getFirstPropertyValue('uid');
    if (!uid) continue;
    const isCancelled = isCancelledStatus(ve.getFirstPropertyValue('status'));
    const uidStr = String(uid);
    if (!map.has(uidStr)) map.set(uidStr, []);
    map.get(uidStr)!.push({ vevent: ve, recurrenceMs, isCancelled });
  }
  return map;
};

const veventToOccurrence = (
  vevent: ICalVEvent,
  startMs: number,
  durationMs: number,
  isAllDay: boolean,
  calendarHref: string,
  eventHref: string,
  occurrenceMs?: number,
): PluginSearchResult => {
  const summary = vevent.getFirstPropertyValue('summary');
  const description = vevent.getFirstPropertyValue('description');
  const status = vevent.getFirstPropertyValue('status');
  return {
    id: toCompoundId(calendarHref, eventHref, occurrenceMs),
    title: (typeof summary === 'string' && summary) || '(No title)',
    status: (typeof status === 'string' && status) || 'CONFIRMED',
    start: startMs,
    dueWithTime: isAllDay ? undefined : startMs,
    duration: durationMs,
    isAllDay,
    description: typeof description === 'string' ? description : undefined,
  };
};

/**
 * Parse iCal data with ical.js and emit one PluginSearchResult per occurrence
 * within [rangeStartMs, rangeEndMs). Handles RRULE, EXDATE, and RECURRENCE-ID
 * exception instances (overrides + cancellations).
 */
const expandIcalToSearchResults = (
  icalData: string,
  calendarHref: string,
  eventHref: string,
  rangeStartMs: number,
  rangeEndMs: number,
): PluginSearchResult[] => {
  let parsed: unknown;
  try {
    parsed = ICAL_NS.parse(icalData);
  } catch {
    return [];
  }
  let comp: ICalVEvent;
  try {
    comp = new ICAL_NS.Component(parsed);
  } catch {
    return [];
  }

  // Register any embedded VTIMEZONE blocks so TZID lookups during expansion succeed.
  const tzAdded: string[] = [];
  if (comp.getFirstSubcomponent('vtimezone')) {
    for (const vtz of comp.getAllSubcomponents('vtimezone')) {
      try {
        const tz = new ICAL_NS.Timezone({
          tzid: vtz.getFirstPropertyValue('tzid'),
          component: vtz,
        });
        if (!ICAL_NS.TimezoneService.has(tz.tzid)) {
          ICAL_NS.TimezoneService.register(tz);
          tzAdded.push(tz.tzid);
        }
      } catch {
        // ignore malformed VTIMEZONE
      }
    }
  }

  let vevents: ICalVEvent[];
  try {
    vevents = ICAL_NS.helpers.updateTimezones(comp).getAllSubcomponents('vevent');
  } catch {
    vevents = comp.getAllSubcomponents('vevent');
  }

  const exceptionMap = buildExceptionMap(vevents);
  const out: PluginSearchResult[] = [];

  for (const ve of vevents) {
    // Per-event guard: a single malformed VEVENT must not drop the rest of
    // the calendar's events. Mirrors the pattern in get-relevant-events-from-ical.ts.
    try {
      // Skip exception events here; they're handled together with their master.
      if (icalTimeToMs(ve.getFirstPropertyValue('recurrence-id')) !== null) continue;
      if (isCancelledStatus(ve.getFirstPropertyValue('status'))) continue;

      const dtstart = ve.getFirstPropertyValue('dtstart');
      const startMs = icalTimeToMs(dtstart);
      if (startMs === null) continue;
      const isAllDay = isAllDayIcal(ve);
      const durationMs = calcDurationMs(ve, startMs);

      const rrule = ve.getFirstPropertyValue('rrule');
      if (!rrule) {
        if (startMs >= rangeStartMs && startMs < rangeEndMs) {
          out.push(
            veventToOccurrence(
              ve,
              startMs,
              durationMs,
              isAllDay,
              calendarHref,
              eventHref,
            ),
          );
        }
        continue;
      }

      const uid = ve.getFirstPropertyValue('uid');
      const exceptions: ExceptionInstance[] = uid
        ? exceptionMap.get(String(uid)) || []
        : [];
      const exceptionTimes = new Set(exceptions.map((e) => e.recurrenceMs));
      for (const ms of getExdateMs(ve)) exceptionTimes.add(ms);

      let iter: { next: () => unknown };
      try {
        iter = (rrule as { iterator: (s: unknown) => { next: () => unknown } }).iterator(
          dtstart,
        );
      } catch {
        continue;
      }

      // Cap counts EMITTED occurrences only — past-window skips and out-of-range
      // breaks must not consume the budget, otherwise a master event with
      // DTSTART years before `rangeStartMs` (common when the server returns the
      // un-expanded master) silently produces zero in-window results.
      let emitted = 0;
      for (
        let next = iter.next() as { toJSDate: () => Date } | null;
        next != null;
        next = iter.next() as { toJSDate: () => Date } | null
      ) {
        const ms = next.toJSDate().getTime();
        if (isNaN(ms)) continue;
        if (ms >= rangeEndMs) break;
        if (ms < rangeStartMs) continue;
        if (exceptionTimes.has(ms)) continue;
        out.push(
          veventToOccurrence(ve, ms, durationMs, isAllDay, calendarHref, eventHref, ms),
        );
        if (++emitted >= MAX_OCCURRENCES_PER_EVENT) break;
      }

      for (const ex of exceptions) {
        if (ex.isCancelled) continue;
        const exStartMs = icalTimeToMs(ex.vevent.getFirstPropertyValue('dtstart'));
        if (exStartMs === null) continue;
        if (exStartMs < rangeStartMs || exStartMs >= rangeEndMs) continue;
        out.push(
          veventToOccurrence(
            ex.vevent,
            exStartMs,
            calcDurationMs(ex.vevent, exStartMs),
            isAllDayIcal(ex.vevent),
            calendarHref,
            eventHref,
            ex.recurrenceMs,
          ),
        );
      }
    } catch {
      // Skip a malformed VEVENT, keep processing the rest.
    }
  }

  for (const tzid of tzAdded) {
    try {
      ICAL_NS.TimezoneService.remove(tzid);
    } catch {
      // ignore
    }
  }

  return out;
};

/** Fetch events from a single calendar via REPORT */
const fetchEventsForCalendar = async (
  http: PluginHttp,
  calendarHref: string,
  cfg: CaldavCalendarConfig,
): Promise<PluginSearchResult[]> => {
  const syncRangeWeeks = Math.max(parseInt(cfg.syncRangeWeeks || '', 10) || 2, 1);
  const now = new Date();
  // Anchor the window to start-of-today (UTC) so events already in progress
  // earlier on the same day stay visible. Using `now` as the lower bound would
  // hide an ongoing meeting. UTC anchor keeps the math timezone-independent.
  const rangeStartMs = Date.UTC(
    now.getUTCFullYear(),
    now.getUTCMonth(),
    now.getUTCDate(),
  );
  const rangeEndMs = rangeStartMs + syncRangeWeeks * 7 * 24 * 60 * 60 * 1000;
  const start = toIcalUtcDateTime(new Date(rangeStartMs));
  const end = toIcalUtcDateTime(new Date(rangeEndMs));

  const calUrl = resolveHref(cfg, calendarHref);
  const xml = await http.request<string>(
    'REPORT',
    calUrl,
    buildCalendarQueryBody(start, end),
    {
      headers: { ...caldavHeaders(), Depth: '1' },
      responseType: 'text',
    },
  );

  const responses = parseEventResponses(xml);
  return responses.flatMap((r) =>
    expandIcalToSearchResults(
      r.calendarData,
      calendarHref,
      r.href,
      rangeStartMs,
      rangeEndMs,
    ),
  );
};

/** Fetch events from all read calendars, merged and sorted by start time */
const fetchEvents = async (
  http: PluginHttp,
  cfg: CaldavCalendarConfig,
  opts?: { maxResults?: number },
): Promise<PluginSearchResult[]> => {
  const calendarIds = getReadCalendarIds(cfg);
  if (calendarIds.length === 0) return [];
  const results = await Promise.all(
    calendarIds.map((calId) => fetchEventsForCalendar(http, calId, cfg)),
  );
  let merged = results.flat().sort((a, b) => (a.start ?? 0) - (b.start ?? 0));
  if (opts?.maxResults) {
    merged = merged.slice(0, opts.maxResults);
  }
  return merged;
};

/** Build a new event URL from calendar href and UID (for creating new events) */
const buildNewEventUrl = (
  cfg: CaldavCalendarConfig,
  calendarHref: string,
  uid: string,
): string => {
  const calUrl = ensureTrailingSlash(resolveHref(cfg, calendarHref));
  return calUrl + encodeURIComponent(uid) + '.ics';
};

/** Derive a deterministic CalDAV UID from a task ID.
 * CalDAV UIDs are opaque strings with no charset restriction (unlike Google Calendar). */
const taskIdToCaldavUid = (taskId: string): string => `sp-${taskId}@super-productivity`;

const isHttpStatus = (err: unknown, status: number): boolean =>
  typeof err === 'object' &&
  err !== null &&
  'status' in err &&
  (err as { status: number }).status === status;

const RETRY_MAX_ATTEMPTS = 4;

/**
 * Self-hosted CalDAV servers (Nextcloud, Baikal, Radicale, …) return 429
 * (rate limited) or 503 (temporarily unavailable) under load. These are
 * transient and should be retried with backoff; other errors must not.
 */
const isTransientError = (err: unknown): boolean =>
  isHttpStatus(err, 429) || isHttpStatus(err, 503);

const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));

/** Run `fn`, retrying with exponential backoff + jitter on transient errors. */
const withTransientRetry = async <T>(fn: () => Promise<T>): Promise<T> => {
  for (let attempt = 1; ; attempt++) {
    try {
      return await fn();
    } catch (err) {
      if (!isTransientError(err) || attempt >= RETRY_MAX_ATTEMPTS) throw err;
      await sleep(500 * 2 ** (attempt - 1) + Math.floor(Math.random() * 250));
    }
  }
};

// --- Load calendars for config dropdowns ---

const loadCalendars = async (
  config: Record<string, unknown>,
  http: PluginHttp,
): Promise<{ label: string; value: string }[]> => {
  const cfg = config as unknown as CaldavCalendarConfig;
  if (!cfg.serverUrl || !cfg.username || !cfg.password) return [];
  return discoverCalendars(http, cfg);
};

// --- Plugin registration ---

PluginAPI.registerIssueProvider({
  configFields: [
    {
      key: 'serverUrl',
      type: 'input' as const,
      label: 'CalDAV server URL',
      description:
        'The CalDAV server URL — the root works (e.g. https://cloud.example.com/remote.php/dav for Nextcloud, https://caldav.fastmail.com/dav/ for Fastmail). A specific calendar-home URL also works.',
      required: true,
    },
    {
      key: 'username',
      type: 'input' as const,
      label: 'Username',
      required: true,
    },
    {
      key: 'password',
      type: 'password' as const,
      label: 'Password',
      required: true,
    },
    {
      key: 'readCalendarIds',
      type: 'multiSelect' as const,
      label: 'Calendars to display',
      description: 'Select which calendars to show in planner and schedule views.',
      options: [],
      loadOptions: loadCalendars,
    },
    {
      key: 'writeCalendarId',
      type: 'select' as const,
      label: 'Default calendar for new events',
      description: 'Used when creating or rescheduling events directly from the planner.',
      options: [],
      loadOptions: loadCalendars,
    },
    {
      key: 'syncRangeWeeks',
      type: 'input' as const,
      label: 'Sync range (weeks)',
      description: 'How many weeks ahead to sync events. Defaults to 2.',
      required: false,
      pattern: '^[0-9]*$',
    },
    {
      key: 'isAutoTimeBlock',
      type: 'checkbox' as const,
      label: 'Auto time blocking',
      description:
        'When you schedule a task to a specific time, automatically create a matching event in the CalDAV calendar. Rescheduling, completing, or deleting the task updates the event.',
    },
    {
      key: 'timeBlockCalendarId',
      type: 'select' as const,
      label: 'Time block calendar',
      description:
        'Which calendar to create time block events in. Use "Load Calendars" above to populate this list.',
      required: false,
      showIf: 'isAutoTimeBlock',
      options: [],
      loadOptions: loadCalendars,
    },
  ],

  getHeaders(config: Record<string, unknown>): Record<string, string> {
    const cfg = config as unknown as CaldavCalendarConfig;
    if (!cfg.username || !cfg.password) {
      return {};
    }
    // Use TextEncoder for UTF-8 safe Base64 encoding (btoa only supports Latin1)
    const credentials = new TextEncoder().encode(cfg.username + ':' + cfg.password);
    const base64 = btoa(Array.from(credentials, (b) => String.fromCharCode(b)).join(''));
    return { Authorization: 'Basic ' + base64 };
  },

  async searchIssues(
    searchTerm: string,
    config: Record<string, unknown>,
    http: PluginHttp,
  ): Promise<PluginSearchResult[]> {
    const cfg = config as unknown as CaldavCalendarConfig;
    const events = await fetchEvents(http, cfg);
    const term = searchTerm.toLowerCase();
    return events.filter(
      (e) =>
        e.title.toLowerCase().includes(term) ||
        (e.description && e.description.toLowerCase().includes(term)),
    );
  },

  async getById(
    issueId: string,
    config: Record<string, unknown>,
    http: PluginHttp,
  ): Promise<PluginIssue> {
    const cfg = config as unknown as CaldavCalendarConfig;
    const { eventHref, occurrenceMs } = parseCompoundId(issueId, getWriteCalendarId(cfg));
    const eventUrl = resolveHref(cfg, eventHref);
    const icalData = await http.get<string>(eventUrl, { responseType: 'text' });
    const events = parseVEvents(icalData);
    const event = events[0];
    if (!event) {
      throw new Error('Event not found: ' + eventHref);
    }

    // The master VEVENT only carries the series' first DTSTART. For an expanded
    // occurrence, re-anchor start/end onto THIS instance so the detail panel and
    // the two-way-sync pull use the occurrence's time instead of collapsing every
    // instance onto the master's. occurrenceMs is the same `toJSDate().getTime()`
    // value the expander stamped into the id, so it round-trips exactly. See #7492.
    let { dtstart: start, dtend: end } = event;
    let startParams = event.dtstartParams;
    let endParams = event.dtendParams;
    if (occurrenceMs !== undefined) {
      const allDay = isDateOnly(event.dtstart, event.dtstartParams);
      const masterStart = parseIcalDateTime(event.dtstart, event.dtstartParams);
      let durationMs = parseDuration(event.duration);
      if (!durationMs && masterStart && event.dtend) {
        const masterEnd = parseIcalDateTime(event.dtend, event.dtendParams);
        if (masterEnd) durationMs = masterEnd.getTime() - masterStart.getTime();
      }
      const occStart = new Date(occurrenceMs);
      if (allDay) {
        // ical.js represents all-day occurrences as local midnight, so the
        // local-getter formatter (toIcalDate) yields the correct calendar date.
        start = toIcalDate(occStart);
        startParams = 'VALUE=DATE';
        if (durationMs > 0) {
          const occEnd = new Date(occStart);
          occEnd.setDate(occEnd.getDate() + Math.round(durationMs / 86400000));
          end = toIcalDate(occEnd);
          endParams = 'VALUE=DATE';
        } else {
          end = '';
          endParams = '';
        }
      } else {
        start = toIcalUtcDateTime(occStart);
        startParams = '';
        end =
          durationMs > 0 ? toIcalUtcDateTime(new Date(occurrenceMs + durationMs)) : '';
        endParams = '';
      }
    }

    const startDate = parseIcalDateTime(start, startParams);
    const endDate = parseIcalDateTime(end, endParams);

    return {
      id: issueId,
      title: event.summary || '(No title)',
      body: event.description || '',
      state: event.status || 'CONFIRMED',
      lastUpdated: event.lastModified
        ? parseIcalDateTime(event.lastModified, '')?.getTime()
        : undefined,
      summary: event.summary || '(No title)',
      start,
      end,
      startParams,
      endParams,
      startFormatted: startDate?.toLocaleString() || '',
      endFormatted: endDate?.toLocaleString() || '',
      status: event.status,
      description: event.description,
      duration: event.duration,
    };
  },

  getIssueLink(issueId: string, config: Record<string, unknown>): string {
    const cfg = config as unknown as CaldavCalendarConfig;
    const { eventHref } = parseCompoundId(issueId, getWriteCalendarId(cfg));
    return resolveHref(cfg, eventHref);
  },

  async testConnection(
    config: Record<string, unknown>,
    http: PluginHttp,
  ): Promise<boolean> {
    const cfg = config as unknown as CaldavCalendarConfig;
    try {
      const url = ensureTrailingSlash(getServerUrl(cfg));
      await http.request<string>('PROPFIND', url, buildPropfindBody(), {
        headers: { ...caldavHeaders(), Depth: '0' },
        responseType: 'text',
      });
      return true;
    } catch {
      return false;
    }
  },

  async getNewIssuesForBacklog(
    config: Record<string, unknown>,
    http: PluginHttp,
  ): Promise<PluginSearchResult[]> {
    const cfg = config as unknown as CaldavCalendarConfig;
    return fetchEvents(http, cfg, { maxResults: 100 });
  },

  issueDisplay: [
    { field: 'summary', label: 'Title', type: 'text' },
    { field: 'startFormatted', label: 'Start', type: 'text' },
    { field: 'endFormatted', label: 'End', type: 'text' },
    { field: 'status', label: 'Status', type: 'text' },
    { field: 'description', label: 'Description', type: 'text' },
  ],

  fieldMappings: [
    {
      taskField: 'title',
      issueField: 'summary',
      defaultDirection: 'both',
      toIssueValue: (taskValue: unknown): string => (taskValue as string) ?? '',
      toTaskValue: (issueValue: unknown): string => {
        const val = issueValue as string;
        if (val && val.startsWith('[DONE] ')) return val.slice(7);
        return val || '(No title)';
      },
    },
    {
      taskField: 'notes',
      issueField: 'description',
      defaultDirection: 'both',
      toIssueValue: (taskValue: unknown): string => (taskValue as string) || '',
      toTaskValue: (issueValue: unknown): string => (issueValue as string) || '',
    },
    {
      taskField: 'dueWithTime',
      issueField: 'start_dateTime',
      defaultDirection: 'both',
      mutuallyExclusive: ['dueDay'],
      toIssueValue: (taskValue: unknown): string | null => {
        if (!taskValue) return null;
        return toUTCISO(taskValue as number);
      },
      toTaskValue: (issueValue: unknown): number | undefined => {
        if (!issueValue) return undefined;
        return new Date(issueValue as string).getTime();
      },
    },
    {
      taskField: 'timeEstimate',
      issueField: 'duration_ms',
      defaultDirection: 'both',
      toIssueValue: (taskValue: unknown): number => (taskValue as number) || 0,
      toTaskValue: (issueValue: unknown): number => (issueValue as number) || 0,
    },
    {
      taskField: 'dueDay',
      issueField: 'start_date',
      defaultDirection: 'both',
      mutuallyExclusive: ['dueWithTime'],
      toIssueValue: (taskValue: unknown): string | null => (taskValue as string) || null,
      toTaskValue: (issueValue: unknown): string | undefined =>
        (issueValue as string) || undefined,
    },
  ] satisfies PluginFieldMapping[],

  async updateIssue(
    id: string,
    changes: Record<string, unknown>,
    config: Record<string, unknown>,
    http: PluginHttp,
  ): Promise<void> {
    const cfg = config as unknown as CaldavCalendarConfig;
    const { eventHref, occurrenceMs } = parseCompoundId(id, getWriteCalendarId(cfg));
    // Editing one occurrence would rewrite the shared master (whole series).
    if (occurrenceMs !== undefined) throw unsupportedOccurrenceWriteError('edit');
    const eventUrl = resolveHref(cfg, eventHref);

    // Fetch current iCal data
    const currentIcal = await http.get<string>(eventUrl, { responseType: 'text' });
    // Try to get etag from a HEAD-like approach — we'll use If-Match: * as fallback
    // The etag was in the REPORT response, but we don't have it here.
    // Use * to indicate we want to update regardless.

    const icalChanges: Record<string, string> = {};

    if (changes.summary !== undefined) {
      icalChanges['SUMMARY'] = escapeIcalText(changes.summary as string);
    }
    if (changes.description !== undefined) {
      icalChanges['DESCRIPTION'] = escapeIcalText((changes.description as string) || '');
    }

    // Handle timed event updates
    if (changes.start_dateTime !== undefined) {
      if (changes.start_dateTime === null) {
        // Unscheduled — convert to all-day event for today so the remote
        // event stays in sync and doesn't re-apply the old time on next pull.
        const now = new Date();
        const tmrw = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
        icalChanges['DTSTART;VALUE=DATE'] = toIcalDate(now);
        icalChanges['DTEND;VALUE=DATE'] = toIcalDate(tmrw);
      } else {
        const startDate = new Date(changes.start_dateTime as string);
        const durationMs = (changes.duration_ms as number) || 30 * 60 * 1000;
        const endDate = new Date(startDate.getTime() + durationMs);
        icalChanges['DTSTART'] = toIcalUtcDateTime(startDate);
        icalChanges['DTEND'] = toIcalUtcDateTime(endDate);
      }
    } else if (changes.duration_ms !== undefined) {
      // Duration changed but start didn't — parse current start and compute new end
      const currentEvents = parseVEvents(currentIcal);
      const current = currentEvents[0];
      if (current) {
        const startDate = parseIcalDateTime(current.dtstart, current.dtstartParams);
        if (startDate && !isDateOnly(current.dtstart, current.dtstartParams)) {
          const endDate = new Date(startDate.getTime() + (changes.duration_ms as number));
          icalChanges['DTEND'] = toIcalUtcDateTime(endDate);
        }
      }
    }

    // Handle all-day event updates
    if (changes.start_date !== undefined) {
      if (changes.start_date === null) {
        // dueDay cleared — skip (likely dueWithTime set instead)
      } else {
        const dateStr = changes.start_date as string;
        const icalDate = dateStr.replace(/-/g, '');
        const startDate = new Date(dateStr + 'T00:00:00');
        const endDate = new Date(
          startDate.getFullYear(),
          startDate.getMonth(),
          startDate.getDate() + 1,
        );
        icalChanges['DTSTART;VALUE=DATE'] = icalDate;
        icalChanges['DTEND;VALUE=DATE'] = toIcalDate(endDate);
      }
    }

    if (Object.keys(icalChanges).length === 0) return;

    const modifiedIcal = modifyICalEvent(currentIcal, icalChanges);
    await http.put(eventUrl, modifiedIcal, {
      headers: { 'Content-Type': 'text/calendar; charset=utf-8' },
      responseType: 'text',
    });
  },

  extractSyncValues(issue: PluginIssue): Record<string, unknown> {
    const raw = issue as Record<string, unknown>;
    const startRaw = raw.start;
    const durationRaw = raw.duration;

    let startDateTime: string | undefined;
    let startDate: string | undefined;
    let durationMs = 0;

    if (typeof startRaw === 'number' && Number.isFinite(startRaw)) {
      // Panel / backlog / search shape (PluginSearchResult): epoch-ms `start`,
      // numeric `duration`, explicit `isAllDay`. This is what the "+ add to
      // schedule" flow feeds, so it must be handled here too — not only the
      // iCal-string shape that getById returns. Calling parseIcalDateTime on a
      // number used to throw `n.slice is not a function`. See #8564.
      // The Number.isFinite guard keeps a NaN/Infinity start from throwing in
      // toISOString or seeding a corrupt write-back baseline (it falls through
      // to the empty result instead).
      if (raw.isAllDay) {
        // All-day occurrences are stamped at local midnight, so local getters
        // (via toIcalDate) yield the correct calendar date.
        startDate = ymdToIsoDate(toIcalDate(new Date(startRaw)));
      } else {
        startDateTime = new Date(startRaw).toISOString();
      }
      durationMs = typeof durationRaw === 'number' ? durationRaw : 0;
    } else if (typeof startRaw === 'string' && startRaw) {
      // getById shape: raw iCal DTSTART string + params.
      const startParams = (raw.startParams as string) || '';
      const parsed = parseIcalDateTime(startRaw, startParams);
      if (parsed) {
        if (isDateOnly(startRaw, startParams)) {
          startDate = ymdToIsoDate(startRaw);
        } else {
          startDateTime = parsed.toISOString();
        }
      }

      const endRaw = raw.end as string | undefined;
      if (typeof durationRaw === 'string' && durationRaw) {
        durationMs = parseDuration(durationRaw);
      } else if (startDateTime && endRaw) {
        const endParams = (raw.endParams as string) || '';
        const endParsed = parseIcalDateTime(endRaw, endParams);
        if (endParsed) {
          durationMs = endParsed.getTime() - new Date(startDateTime).getTime();
        }
      }
    }

    return {
      summary: issue.title || '',
      description: issue.body || '',
      start_dateTime: startDateTime,
      start_date: startDate,
      duration_ms: durationMs,
    };
  },

  async createIssue(title: string, config: Record<string, unknown>, http: PluginHttp) {
    const cfg = config as unknown as CaldavCalendarConfig;
    const calendarHref = getWriteCalendarId(cfg);
    if (!calendarHref) {
      throw new Error(
        'No write calendar configured. Please select a calendar in the CalDAV settings.',
      );
    }
    const uid = crypto.randomUUID();
    const now = new Date();
    const today = toIcalDate(now);
    const tmrw = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
    const tomorrow = toIcalDate(tmrw);

    const icalData = buildICalEvent({
      uid,
      summary: title,
      dtstart: today,
      dtstartParam: 'VALUE=DATE',
      dtend: tomorrow,
      dtendParam: 'VALUE=DATE',
    });

    const eventUrl = buildNewEventUrl(cfg, calendarHref, uid);
    await http.put(eventUrl, icalData, {
      headers: {
        'Content-Type': 'text/calendar; charset=utf-8',
        'If-None-Match': '*',
      },
      responseType: 'text',
    });

    // Store the event path (not UID) in the compound ID for later CRUD
    const eventPath = new URL(eventUrl).pathname;
    const compoundId = toCompoundId(calendarHref, eventPath);
    return {
      issueId: compoundId,
      issueData: {
        id: compoundId,
        title,
        body: '',
        state: 'CONFIRMED',
        summary: title,
        start: today,
        end: tomorrow,
        startParams: 'VALUE=DATE',
        endParams: 'VALUE=DATE',
        startFormatted: now.toLocaleDateString(),
        endFormatted: tmrw.toLocaleDateString(),
        status: 'CONFIRMED',
        description: '',
      },
    };
  },

  deletedStates: ['CANCELLED'],

  timeBlock: {
    async upsertEvent(
      taskId: string,
      eventData: {
        title: string;
        dueWithTime: number;
        durationMs: number;
        isDone: boolean;
      },
      config: Record<string, unknown>,
      http: PluginHttp,
    ): Promise<void> {
      const cfg = config as unknown as CaldavCalendarConfig;
      const calendarHref = getTimeBlockCalendarId(cfg);
      if (!calendarHref) {
        throw new Error(
          'No write calendar configured. Please select a calendar in the CalDAV settings.',
        );
      }
      const uid = taskIdToCaldavUid(taskId);
      const summary = eventData.isDone ? `[DONE] ${eventData.title}` : eventData.title;
      const startDate = new Date(eventData.dueWithTime);
      const endDate = new Date(eventData.dueWithTime + eventData.durationMs);

      const icalData = buildICalEvent({
        uid,
        summary,
        dtstart: toIcalUtcDateTime(startDate),
        dtend: toIcalUtcDateTime(endDate),
      });

      const eventUrl = buildNewEventUrl(cfg, calendarHref, uid);
      // CalDAV PUT is inherently an upsert — creates if absent, replaces if
      // present (single idempotent write; no insert/patch double-write).
      await withTransientRetry(() =>
        http.put(eventUrl, icalData, {
          headers: { 'Content-Type': 'text/calendar; charset=utf-8' },
          responseType: 'text',
        }),
      );
    },

    async deleteEvent(
      taskId: string,
      config: Record<string, unknown>,
      http: PluginHttp,
    ): Promise<void> {
      const cfg = config as unknown as CaldavCalendarConfig;
      const calendarHref = getTimeBlockCalendarId(cfg);
      if (!calendarHref) return; // No calendar configured — nothing to delete
      const uid = taskIdToCaldavUid(taskId);
      const eventUrl = buildNewEventUrl(cfg, calendarHref, uid);
      await withTransientRetry(async () => {
        try {
          await http.delete(eventUrl, { responseType: 'text' });
        } catch (err: unknown) {
          if (!isHttpStatus(err, 404)) throw err;
        }
      });
    },
  },

  async deleteIssue(
    id: string,
    config: Record<string, unknown>,
    http: PluginHttp,
  ): Promise<void> {
    const cfg = config as unknown as CaldavCalendarConfig;
    const { eventHref, occurrenceMs } = parseCompoundId(id, getWriteCalendarId(cfg));
    // Deleting one occurrence would DELETE the shared master (whole series).
    if (occurrenceMs !== undefined) throw unsupportedOccurrenceWriteError('delete');
    const eventUrl = resolveHref(cfg, eventHref);
    await http.delete(eventUrl, { responseType: 'text' });
  },
});
