/**
 * ICS (iCalendar / RFC 5545) generation and parsing.
 *
 * Supports a VEVENT-only subset:
 *   SUMMARY, DESCRIPTION, DTSTART, DTEND, LOCATION, UID, RRULE, VTIMEZONE ref.
 *
 * Cron→RRULE mapping:
 *   daily        (0 H * * *)           → FREQ=DAILY
 *   weekly+days  (0 H * * 1,3,5)       → FREQ=WEEKLY;BYDAY=MO,WE,FR
 *   monthly-date (0 H D * *)           → FREQ=MONTHLY;BYMONTHDAY=D
 *   unmappable   (anything else)        → no RRULE; individual VEVENTs per occurrence
 */
import cronParser from "cron-parser";
const { parseExpression } = cronParser;
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const CRLF = "\r\n";
const PRODID = "-//Maxy//Scheduling//EN";
const MAX_LINE_OCTETS = 75;
const WEEKDAY_MAP = {
    0: "SU", 1: "MO", 2: "TU", 3: "WE", 4: "TH", 5: "FR", 6: "SA", 7: "SU",
};
// ---------------------------------------------------------------------------
// Text escaping (RFC 5545 §3.3.11)
// ---------------------------------------------------------------------------
function escapeText(value) {
    return value
        .replace(/\\/g, "\\\\")
        .replace(/;/g, "\\;")
        .replace(/,/g, "\\,")
        .replace(/\r?\n/g, "\\n");
}
function unescapeText(value) {
    // Order matters: unescape \\\\ → \\ first, then \\n, \\, \\;
    // Otherwise "\\\\n" (literal backslash + n) is misread as newline.
    return value
        .replace(/\\\\/g, "\0BKSL\0") // stash real backslashes
        .replace(/\\n/gi, "\n")
        .replace(/\\,/g, ",")
        .replace(/\\;/g, ";")
        .replace(/\0BKSL\0/g, "\\"); // restore backslashes
}
// ---------------------------------------------------------------------------
// Line folding (RFC 5545 §3.1) — fold at 75-octet boundary
// ---------------------------------------------------------------------------
function foldLine(line) {
    const buf = Buffer.from(line, "utf-8");
    if (buf.length <= MAX_LINE_OCTETS)
        return line;
    const parts = [];
    let offset = 0;
    let first = true;
    while (offset < buf.length) {
        // First line: 75 octets. Continuation lines: 74 (space prefix counts as 1).
        const maxChunk = first ? MAX_LINE_OCTETS : MAX_LINE_OCTETS - 1;
        let end = Math.min(offset + maxChunk, buf.length);
        // Don't split multi-byte UTF-8 sequences: back up if we're mid-character.
        // UTF-8 continuation bytes have the form 10xxxxxx (0x80–0xBF).
        while (end < buf.length && end > offset && (buf[end] & 0xc0) === 0x80) {
            end--;
        }
        const chunk = buf.subarray(offset, end).toString("utf-8");
        parts.push(first ? chunk : " " + chunk);
        offset = end;
        first = false;
    }
    return parts.join(CRLF);
}
// ---------------------------------------------------------------------------
// Date formatting — ISO 8601 UTC → ICS DATE-TIME (UTC "Z" suffix)
// ---------------------------------------------------------------------------
function toICSDateTime(iso) {
    const d = new Date(iso);
    if (isNaN(d.getTime()))
        throw new Error(`Invalid date: ${iso}`);
    const pad = (n) => String(n).padStart(2, "0");
    return (`${d.getUTCFullYear()}${pad(d.getUTCMonth() + 1)}${pad(d.getUTCDate())}T` +
        `${pad(d.getUTCHours())}${pad(d.getUTCMinutes())}${pad(d.getUTCSeconds())}Z`);
}
function fromICSDateTime(ics) {
    // DATE-TIME: 20260413T140000Z or 20260413T140000
    const dtMatch = ics.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/);
    if (dtMatch) {
        const [, y, mo, d, h, mi, s] = dtMatch;
        // Non-UTC times (no Z suffix) are treated as UTC — the TZID parameter was
        // stripped by the property parser and is not available here. This is a known
        // limitation documented in the task spec (no VTIMEZONE support).
        const dateStr = `${y}-${mo}-${d}T${h}:${mi}:${s}Z`;
        const date = new Date(dateStr);
        return isNaN(date.getTime()) ? ics : date.toISOString();
    }
    // DATE-only (all-day events): 20260413
    const dateMatch = ics.match(/^(\d{4})(\d{2})(\d{2})$/);
    if (dateMatch) {
        const [, y, mo, d] = dateMatch;
        return `${y}-${mo}-${d}T00:00:00.000Z`;
    }
    return ics; // Return as-is if unparseable — caller can flag
}
export function cronToRRule(cron) {
    // Parse to validate — throws on malformed cron
    parseExpression(cron); // validates but we inspect fields manually
    const parts = cron.trim().split(/\s+/);
    if (parts.length !== 5) {
        return { rrule: null, reason: `Expected 5-field cron, got ${parts.length} fields` };
    }
    const [, , dom, mon, dow] = parts;
    // Daily: dom=*, mon=*, dow=*
    if (dom === "*" && mon === "*" && dow === "*") {
        return { rrule: "FREQ=DAILY" };
    }
    // Weekly with specific days: dom=*, mon=*, dow=comma-separated digits or ranges
    if (dom === "*" && mon === "*" && dow !== "*") {
        const days = expandCronField(dow, 0, 7); // 7 = Sunday (vixie cron alias)
        if (!days)
            return { rrule: null, reason: `Cannot parse day-of-week field: ${dow}` };
        const byDay = days.map((d) => WEEKDAY_MAP[d]).filter(Boolean);
        if (byDay.length === 0)
            return { rrule: null, reason: `No valid days in: ${dow}` };
        return { rrule: `FREQ=WEEKLY;BYDAY=${byDay.join(",")}` };
    }
    // Monthly by date: dom=single number, mon=*, dow=*
    if (mon === "*" && dow === "*" && /^\d{1,2}$/.test(dom)) {
        const dayNum = parseInt(dom, 10);
        if (dayNum >= 1 && dayNum <= 31) {
            return { rrule: `FREQ=MONTHLY;BYMONTHDAY=${dayNum}` };
        }
    }
    return { rrule: null, reason: `Cron pattern "${cron}" cannot be mapped to an RRULE` };
}
/**
 * Expand a cron field into an array of numbers.
 * Handles: single values, comma lists, ranges (1-5), step values (*\/2).
 * Returns null if parsing fails.
 */
function expandCronField(field, min, max) {
    try {
        const values = new Set();
        for (const part of field.split(",")) {
            if (part.includes("-")) {
                const [startStr, endStr] = part.split("-");
                const start = parseInt(startStr, 10);
                const end = parseInt(endStr, 10);
                if (isNaN(start) || isNaN(end))
                    return null;
                for (let i = start; i <= end; i++)
                    values.add(i);
            }
            else if (part.includes("/")) {
                return null; // Step values can't map cleanly to RRULE BYDAY
            }
            else {
                const num = parseInt(part, 10);
                if (isNaN(num) || num < min || num > max)
                    return null;
                values.add(num);
            }
        }
        return Array.from(values).sort((a, b) => a - b);
    }
    catch {
        return null;
    }
}
export function generateICS(events, options) {
    const occurrences = options?.fallbackOccurrences ?? 10;
    const lines = [];
    lines.push("BEGIN:VCALENDAR");
    lines.push("VERSION:2.0");
    lines.push(`PRODID:${PRODID}`);
    lines.push("CALSCALE:GREGORIAN");
    lines.push("METHOD:PUBLISH");
    for (const event of events) {
        if (event.recurrence) {
            const result = cronToRRule(event.recurrence);
            if (result.rrule) {
                // Mappable → single VEVENT with RRULE
                emitVEvent(lines, event, result.rrule);
            }
            else {
                // Unmappable → individual VEVENTs per occurrence
                emitFallbackVEvents(lines, event, occurrences, result.reason);
            }
        }
        else {
            // One-time event
            emitVEvent(lines, event);
        }
    }
    lines.push("END:VCALENDAR");
    return lines.map(foldLine).join(CRLF) + CRLF;
}
function emitVEvent(lines, event, rrule) {
    lines.push("BEGIN:VEVENT");
    lines.push(`UID:${event.eventId}@maxy.bot`);
    lines.push(`DTSTAMP:${toICSDateTime(new Date().toISOString())}`);
    lines.push(`DTSTART:${toICSDateTime(event.startDate)}`);
    if (event.endDate) {
        lines.push(`DTEND:${toICSDateTime(event.endDate)}`);
    }
    lines.push(`SUMMARY:${escapeText(event.name)}`);
    if (event.description) {
        lines.push(`DESCRIPTION:${escapeText(event.description)}`);
    }
    if (event.location) {
        lines.push(`LOCATION:${escapeText(event.location)}`);
    }
    if (rrule) {
        lines.push(`RRULE:${rrule}`);
    }
    lines.push("END:VEVENT");
}
function emitFallbackVEvents(lines, event, count, reason) {
    try {
        const expr = parseExpression(event.recurrence, {
            currentDate: new Date(event.startDate),
            tz: event.timezone ?? "UTC",
        });
        for (let i = 0; i < count; i++) {
            const next = expr.next();
            const start = next.toISOString();
            // Compute end by applying same duration as original event
            let end;
            if (event.endDate) {
                const duration = new Date(event.endDate).getTime() - new Date(event.startDate).getTime();
                end = new Date(new Date(start).getTime() + duration).toISOString();
            }
            const desc = [
                event.description ?? "",
                `Recurrence: ${event.recurrence}`,
                reason ? `(${reason})` : "",
            ].filter(Boolean).join("\n");
            lines.push("BEGIN:VEVENT");
            lines.push(`UID:${event.eventId}-${i}@maxy.bot`);
            lines.push(`DTSTAMP:${toICSDateTime(new Date().toISOString())}`);
            lines.push(`DTSTART:${toICSDateTime(start)}`);
            if (end) {
                lines.push(`DTEND:${toICSDateTime(end)}`);
            }
            lines.push(`SUMMARY:${escapeText(event.name)}`);
            lines.push(`DESCRIPTION:${escapeText(desc)}`);
            if (event.location) {
                lines.push(`LOCATION:${escapeText(event.location)}`);
            }
            lines.push("END:VEVENT");
        }
    }
    catch (err) {
        // If cron parsing fails, emit a single event with the error noted
        const desc = [
            event.description ?? "",
            `Recurrence: ${event.recurrence} (failed to expand: ${err instanceof Error ? err.message : String(err)})`,
        ].filter(Boolean).join("\n");
        emitVEvent(lines, { ...event, description: desc });
    }
}
// ---------------------------------------------------------------------------
// ICS Parsing
// ---------------------------------------------------------------------------
export function parseICS(content) {
    const events = [];
    const parseErrors = [];
    // Unfold continuation lines (RFC 5545 §3.1): CRLF followed by space/tab
    const unfolded = content.replace(/\r?\n[ \t]/g, "");
    // Normalize line endings
    const rawLines = unfolded.split(/\r?\n/);
    // Check for VCALENDAR wrapper
    const hasVCalendar = rawLines.some((l) => l.trim() === "BEGIN:VCALENDAR");
    if (!hasVCalendar) {
        parseErrors.push("Missing BEGIN:VCALENDAR — not a valid iCalendar file");
        return { events, parseErrors };
    }
    // Extract VEVENT blocks — track nesting to skip VALARM/etc. inside VEVENT
    let inVEvent = false;
    let nestedDepth = 0; // tracks BEGIN:VALARM, BEGIN:VTIMEZONE etc. inside VEVENT
    let currentProps = new Map();
    // ATTENDEE may repeat per VEVENT (Map.set would overwrite). Keep raw lines
    // and parse at END:VEVENT.
    let attendeeLines = [];
    let organizerLine = null;
    // VALUE=DATE on DTSTART signals an all-day event (RFC 5545 §3.3.4).
    // The Map-based prop store strips parameters, so we detect this inline.
    let dtStartIsDate = false;
    let eventIndex = 0;
    for (const line of rawLines) {
        const trimmed = line.trim();
        if (trimmed === "BEGIN:VEVENT") {
            inVEvent = true;
            nestedDepth = 0;
            currentProps = new Map();
            attendeeLines = [];
            organizerLine = null;
            dtStartIsDate = false;
            eventIndex++;
            continue;
        }
        // Track nested components (VALARM, etc.) inside VEVENT
        if (inVEvent && trimmed.startsWith("BEGIN:") && trimmed !== "BEGIN:VEVENT") {
            nestedDepth++;
            continue;
        }
        if (inVEvent && trimmed.startsWith("END:") && trimmed !== "END:VEVENT" && nestedDepth > 0) {
            nestedDepth--;
            continue;
        }
        if (trimmed === "END:VEVENT") {
            if (!inVEvent)
                continue;
            inVEvent = false;
            nestedDepth = 0;
            // Extract required fields
            const summary = currentProps.get("SUMMARY");
            const dtstart = currentProps.get("DTSTART");
            if (!dtstart) {
                parseErrors.push(`VEVENT #${eventIndex}: missing required DTSTART property`);
                continue;
            }
            const attendees = [];
            if (organizerLine) {
                const a = parseAttendeeLine(organizerLine, "organizer");
                if (a)
                    attendees.push(a);
            }
            for (const al of attendeeLines) {
                const a = parseAttendeeLine(al, null);
                if (a)
                    attendees.push(a);
            }
            const parsed = {
                summary: summary ? unescapeText(summary) : "(untitled)",
                dtstart: fromICSDateTime(extractDateValue(dtstart)),
                isAllDay: dtStartIsDate,
                attendees,
            };
            const dtend = currentProps.get("DTEND");
            if (dtend)
                parsed.dtend = fromICSDateTime(extractDateValue(dtend));
            const location = currentProps.get("LOCATION");
            if (location)
                parsed.location = unescapeText(location);
            const description = currentProps.get("DESCRIPTION");
            if (description)
                parsed.description = unescapeText(description);
            const uid = currentProps.get("UID");
            if (uid)
                parsed.uid = uid;
            const rrule = currentProps.get("RRULE");
            if (rrule)
                parsed.recurrence = rrule;
            events.push(parsed);
            continue;
        }
        if (inVEvent && nestedDepth === 0) {
            // Parse property: NAME;params:VALUE or NAME:VALUE
            const colonIdx = line.indexOf(":");
            if (colonIdx === -1)
                continue;
            const propFull = line.substring(0, colonIdx);
            const propName = propFull.split(";")[0].trim().toUpperCase();
            // ATTENDEE may repeat; ORGANIZER appears at most once. Route both away
            // from the single-value currentProps Map so re-occurrences aren't lost.
            if (propName === "ATTENDEE") {
                attendeeLines.push(line);
                continue;
            }
            if (propName === "ORGANIZER") {
                organizerLine = line;
                continue;
            }
            // DTSTART;VALUE=DATE:20260413 → all-day event. Detect before stripping
            // parameters.
            if (propName === "DTSTART" && /;VALUE=DATE(?:[;:]|$)/i.test(propFull)) {
                dtStartIsDate = true;
            }
            const value = line.substring(colonIdx + 1);
            currentProps.set(propName, value);
        }
    }
    // Check for unclosed VEVENT
    if (inVEvent) {
        parseErrors.push(`VEVENT #${eventIndex}: missing END:VEVENT — file may be truncated`);
    }
    // Check for END:VCALENDAR
    const hasEndVCal = rawLines.some((l) => l.trim() === "END:VCALENDAR");
    if (!hasEndVCal) {
        parseErrors.push("Missing END:VCALENDAR — file may be truncated");
    }
    return { events, parseErrors };
}
/**
 * Extract the date value from a DTSTART/DTEND property value.
 * Handles both plain values (20260413T140000Z) and parameterised values
 * where the TZID was already stripped by the property parser.
 */
function extractDateValue(value) {
    return value.trim();
}
// ---------------------------------------------------------------------------
// ATTENDEE / ORGANIZER parsing (RFC 5545 §3.8.4)
// ---------------------------------------------------------------------------
const ROLE_NORMALISE = {
    CHAIR: "chair",
    "REQ-PARTICIPANT": "req-participant",
    "OPT-PARTICIPANT": "opt-participant",
    "NON-PARTICIPANT": "non-participant",
};
/**
 * Parse one raw `ATTENDEE` or `ORGANIZER` line.
 *
 * `forcedRole` is `"organizer"` when parsing the ORGANIZER property (the role
 * isn't carried in the line itself — it's the property name). For ATTENDEE
 * lines pass `null` and the ROLE parameter (or its absence) decides.
 *
 * Returns null only if the line has no colon at all (malformed).
 */
function parseAttendeeLine(line, forcedRole) {
    const colonIdx = line.indexOf(":");
    if (colonIdx === -1)
        return null;
    const propFull = line.substring(0, colonIdx);
    const value = line.substring(colonIdx + 1).trim();
    // Params live between the property name and the first colon, semicolon-
    // separated. e.g. `ATTENDEE;CN="Alice";ROLE=REQ-PARTICIPANT;PARTSTAT=ACCEPTED`
    const paramSegments = propFull.split(";").slice(1);
    const params = new Map();
    for (const seg of paramSegments) {
        const eq = seg.indexOf("=");
        if (eq === -1)
            continue;
        const k = seg.substring(0, eq).trim().toUpperCase();
        let v = seg.substring(eq + 1).trim();
        // Strip surrounding double quotes from quoted-string param values.
        if (v.startsWith('"') && v.endsWith('"') && v.length >= 2)
            v = v.slice(1, -1);
        params.set(k, v);
    }
    // Address resolution. Only `mailto:` URIs become graph identities; other
    // schemes (urn:uuid:..., resource://room/...) are recorded with null and
    // surfaced in the dry-run preview rather than dropped.
    let address = null;
    const mailtoMatch = value.match(/^mailto:(.+)$/i);
    if (mailtoMatch) {
        address = mailtoMatch[1].trim().toLowerCase();
        if (!address)
            address = null;
    }
    // Role: ORGANIZER is fixed; ATTENDEE uses ROLE param (default REQ-PARTICIPANT
    // per RFC 5545 §3.2.18).
    let role;
    if (forcedRole) {
        role = forcedRole;
    }
    else {
        const roleParam = params.get("ROLE")?.toUpperCase() ?? "REQ-PARTICIPANT";
        role = ROLE_NORMALISE[roleParam] ?? "req-participant";
    }
    const attendee = {
        address,
        role,
        rawValue: value,
    };
    const cn = params.get("CN");
    if (cn)
        attendee.displayName = unescapeText(cn);
    const partstat = params.get("PARTSTAT");
    if (partstat)
        attendee.partstat = partstat.toLowerCase();
    return attendee;
}
//# sourceMappingURL=ics.js.map