/**
 * Graph-ingest path for ICS calendar archives.
 *
 *   .ics file ──▶ parseICS ──▶ (dry-run preview │ commit writes)
 *
 * Writes one `:Meeting` per VEVENT, anchors it to the importing `:AdminUser`
 * via `[:IMPORTED]`, resolves attendees to `:Person` by lowercased email, and
 * MERGEs `[:ATTENDED]` edges with role + partstat.
 *
 * Cross-account collision rule: `Person.email` is globally unique in the
 * schema. When an attendee email already exists under a different accountId,
 * the import skips the attendance edge (no edge written, the foreign Person
 * is untouched) and increments `skippedCrossAccountPerson`. The Meeting still
 * lands, anchored to the importer's AdminUser. This preserves absolute brand
 * / account isolation while keeping the import as a whole non-fatal.
 */
import { randomUUID } from "node:crypto";
import { parseICS } from "./ics.js";
import { embed } from "../../../../../lib/embed-client/dist/index.js";
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
export async function ingestIcs(params) {
    const start = Date.now();
    const importId = randomUUID();
    const { fileContent, filename, mode, accountId, userId, sessionId, session } = params;
    const parsed = parseICS(fileContent);
    // Partition events into valid / skipped buckets. parseICS already rejects
    // VEVENTs without DTSTART (they land in parseErrors); the parser stops short
    // of inferring skip-reason buckets, so we classify here too. UID is required
    // for the (accountId, uid) composite key.
    const valid = [];
    let skippedNoUid = 0;
    let skippedNoStartTime = 0;
    for (const ev of parsed.events) {
        if (!ev.dtstart) {
            skippedNoStartTime++;
            continue;
        }
        if (!ev.uid) {
            skippedNoUid++;
            continue;
        }
        valid.push(ev);
    }
    // Events the parser already rejected for missing DTSTART surface via
    // parseErrors. Count those too so the operator-visible signal matches the
    // file's actual content.
    for (const err of parsed.parseErrors) {
        if (err.includes("missing required DTSTART"))
            skippedNoStartTime++;
    }
    if (mode === "dry-run") {
        return dryRunPreview({
            session,
            accountId,
            valid,
            filename,
            importId,
            skippedNoUid,
            skippedNoStartTime,
            parseErrors: parsed.parseErrors,
            eventsParsed: parsed.events.length,
            start,
        });
    }
    return commit({
        session,
        accountId,
        userId,
        sessionId,
        valid,
        filename,
        importId,
        skippedNoUid,
        skippedNoStartTime,
        parseErrors: parsed.parseErrors,
        eventsParsed: parsed.events.length,
        start,
    });
}
async function dryRunPreview(ctx) {
    // Flatten attendees across valid events. Track raw counts so the preview
    // numbers match what the commit phase would emit.
    let attendees = 0;
    let organizers = 0;
    let nonMailto = 0;
    const addressOrder = []; // dedup-preserving insertion order
    const addressSeen = new Set();
    const addressDisplayName = new Map();
    for (const ev of ctx.valid) {
        for (const a of ev.attendees) {
            if (a.role === "organizer")
                organizers++;
            else
                attendees++;
            if (!a.address) {
                nonMailto++;
                continue;
            }
            if (!addressSeen.has(a.address)) {
                addressSeen.add(a.address);
                addressOrder.push(a.address);
            }
            if (a.displayName && !addressDisplayName.has(a.address)) {
                addressDisplayName.set(a.address, a.displayName);
            }
        }
    }
    // One round-trip to classify every unique address against the live graph.
    const existing = new Map();
    if (addressOrder.length > 0) {
        const result = await ctx.session.run(`
      UNWIND $emails AS email
      MATCH (p:Person {email: email})
      RETURN p.email AS email, p.accountId AS accountId, p.displayName AS displayName
      `, { emails: addressOrder });
        for (const rec of result.records) {
            existing.set(rec.get("email"), {
                accountId: rec.get("accountId") ?? null,
                displayName: rec.get("displayName") ?? null,
            });
        }
    }
    let existingSameAccount = 0;
    let existingCrossAccount = 0;
    let willCreate = 0;
    const sample = [];
    const SAMPLE_CAP = 20;
    for (const address of addressOrder) {
        const hit = existing.get(address);
        let status;
        let existingDisplayName;
        if (!hit) {
            willCreate++;
            status = "will-create";
        }
        else if (hit.accountId === ctx.accountId) {
            existingSameAccount++;
            status = "existing-same-account";
            if (hit.displayName)
                existingDisplayName = hit.displayName;
        }
        else {
            existingCrossAccount++;
            status = "existing-cross-account";
            if (hit.displayName)
                existingDisplayName = hit.displayName;
        }
        if (sample.length < SAMPLE_CAP) {
            const entry = { address, status };
            const dn = addressDisplayName.get(address);
            if (dn)
                entry.displayName = dn;
            if (existingDisplayName)
                entry.existingDisplayName = existingDisplayName;
            sample.push(entry);
        }
    }
    // Non-mailto attendees are interesting enough to sample at least one.
    if (nonMailto > 0 && sample.length < SAMPLE_CAP) {
        for (const ev of ctx.valid) {
            for (const a of ev.attendees) {
                if (a.address)
                    continue;
                sample.push({
                    address: a.rawValue,
                    status: "non-mailto",
                    ...(a.displayName ? { displayName: a.displayName } : {}),
                });
                if (sample.length >= SAMPLE_CAP)
                    break;
            }
            if (sample.length >= SAMPLE_CAP)
                break;
        }
    }
    const ms = Date.now() - ctx.start;
    process.stderr.write(`[ics-ingest] importId=${ctx.importId} filename=${ctx.filename} mode=dry-run ` +
        `events=${ctx.eventsParsed} meetings=${ctx.valid.length} ` +
        `attendees=${attendees} organizers=${organizers} nonMailto=${nonMailto} ` +
        `existingSame=${existingSameAccount} existingCross=${existingCrossAccount} ` +
        `willCreate=${willCreate} skippedNoUid=${ctx.skippedNoUid} ` +
        `skippedNoStartTime=${ctx.skippedNoStartTime} ms=${ms}\n`);
    return {
        mode: "dry-run",
        filename: ctx.filename,
        importId: ctx.importId,
        events: ctx.eventsParsed,
        validMeetings: ctx.valid.length,
        skippedNoUid: ctx.skippedNoUid,
        skippedNoStartTime: ctx.skippedNoStartTime,
        preview: {
            attendees,
            organizers,
            nonMailtoAttendees: nonMailto,
            existingSameAccount,
            existingCrossAccount,
            willCreate,
        },
        sampleAttendees: sample,
        parseErrors: ctx.parseErrors,
        ms,
    };
}
async function commit(ctx) {
    // Resolve the importing AdminUser once. We refuse to start the commit if
    // the userId doesn't resolve — anchoring every Meeting to a real AdminUser
    // is what keeps :Meeting from being an orphan.
    const adminCheck = await ctx.session.run(`MATCH (a:AdminUser {userId: $userId}) RETURN elementId(a) AS id LIMIT 1`, { userId: ctx.userId });
    if (adminCheck.records.length === 0) {
        throw new Error(`ics-ingest: no :AdminUser found for userId=${ctx.userId}. ` +
            `The importer must exist in the graph before ingesting an .ics archive.`);
    }
    let meetingsCreated = 0;
    let meetingsUpdated = 0;
    let peopleCreated = 0;
    let existingPersonsLinked = 0;
    let skippedCrossAccountPerson = 0;
    let attendees = 0;
    let organizers = 0;
    // One transaction per meeting keeps the blast radius small — if attendee
    // resolution fails on event #4000, events 1..3999 are already durably
    // committed. Calendar archives are long flat lists, not interlinked.
    for (const ev of ctx.valid) {
        const meetingProps = {
            uid: ev.uid,
            title: ev.summary,
            startsAt: ev.dtstart,
            endsAt: ev.dtend ?? null,
            location: ev.location ?? null,
            description: ev.description ?? null,
            isAllDay: ev.isAllDay,
            sourceFilename: ctx.filename,
            accountId: ctx.accountId,
            sessionId: ctx.sessionId ?? null,
        };
        const meetingResult = await ctx.session.executeWrite(async (tx) => {
            const r = await tx.run(`
        MATCH (a:AdminUser {userId: $userId})
        MERGE (m:Meeting {accountId: $accountId, uid: $props.uid})
          ON CREATE SET
            m.title           = $props.title,
            m.startsAt        = datetime($props.startsAt),
            m.endsAt          = CASE WHEN $props.endsAt IS NULL THEN null ELSE datetime($props.endsAt) END,
            m.location        = $props.location,
            m.description     = $props.description,
            m.isAllDay        = $props.isAllDay,
            m.source          = 'ics',
            m.sourceFilename  = $props.sourceFilename,
            m.scope           = 'admin',
            m.createdAt       = datetime(),
            m.updatedAt       = datetime(),
            m.createdByAgent  = 'ics-ingest',
            m.createdBySource = 'ics-ingest',
            m.createdBySession = $sessionId,
            m._created        = true
          ON MATCH SET
            m.title           = $props.title,
            m.startsAt        = datetime($props.startsAt),
            m.endsAt          = CASE WHEN $props.endsAt IS NULL THEN null ELSE datetime($props.endsAt) END,
            m.location        = $props.location,
            m.description     = $props.description,
            m.isAllDay        = $props.isAllDay,
            m.sourceFilename  = $props.sourceFilename,
            m.updatedAt       = datetime(),
            m._created        = false
        WITH a, m, m._created AS created
        REMOVE m._created
        MERGE (a)-[imp:IMPORTED]->(m)
          ON CREATE SET imp.createdAt = datetime(), imp.source = 'ics-ingest'
        RETURN created, elementId(m) AS meetingId
        `, {
                userId: ctx.userId,
                accountId: ctx.accountId,
                props: meetingProps,
                sessionId: ctx.sessionId ?? null,
            });
            const created = r.records[0].get("created");
            const meetingId = r.records[0].get("meetingId");
            return { created, meetingId };
        });
        if (meetingResult.created)
            meetingsCreated++;
        else
            meetingsUpdated++;
        // Attendee + organizer resolution. We separate organizers from attendees
        // only for counting; the edge written is :ATTENDED in both cases, with the
        // role property carrying the distinction.
        for (const att of ev.attendees) {
            if (att.role === "organizer")
                organizers++;
            else
                attendees++;
            if (!att.address)
                continue; // non-mailto: counted but no graph effect
            // Task 636 — :Person is vector-indexed; embed the attendee stub so a
            // brand-new Person is findable by semantic memory-search at write time.
            // Computed before the write tx so the Ollama call does not hold a write
            // transaction open. Only the new-Person branch consumes it; a failed
            // embed sets null (audit catches it) and the write proceeds.
            let attendeeEmbedding = null;
            try {
                attendeeEmbedding = await embed(`[Person] ${att.displayName ?? ""} <${att.address}>`.trim());
            }
            catch (err) {
                process.stderr.write(`[graph-write] warn reason=embed-failed labels=Person agent=ics-ingest detail=${err instanceof Error ? err.message : String(err)}\n`);
            }
            const personResult = await ctx.session.executeWrite(async (tx) => {
                // Pre-flight existence check so we never silently create an :ATTENDED
                // edge from a foreign-accountId Person.
                const lookup = await tx.run(`MATCH (p:Person {email: $email}) RETURN p.accountId AS accountId LIMIT 1`, { email: att.address });
                const existingAccountId = lookup.records[0]?.get("accountId");
                if (existingAccountId && existingAccountId !== ctx.accountId) {
                    return { outcome: "cross-account-skip" };
                }
                if (existingAccountId === ctx.accountId) {
                    // Same-account Person: link without overwriting their identity.
                    await tx.run(`
            MATCH (p:Person {email: $email})
            MATCH (m:Meeting) WHERE elementId(m) = $meetingId
            MERGE (p)-[r:ATTENDED]->(m)
              ON CREATE SET r.role = $role, r.partstat = $partstat,
                            r.createdAt = datetime(),
                            r.createdBySource = 'ics-ingest'
              ON MATCH  SET r.role = $role, r.partstat = $partstat
            FOREACH (_ IN CASE WHEN p.displayName IS NULL AND $displayName IS NOT NULL THEN [1] ELSE [] END |
              SET p.displayName = $displayName
            )
            `, {
                        email: att.address,
                        meetingId: meetingResult.meetingId,
                        role: att.role,
                        partstat: att.partstat ?? null,
                        displayName: att.displayName ?? null,
                    });
                    return { outcome: "linked-existing" };
                }
                // Brand new Person stub under the importer's account.
                await tx.run(`
          MATCH (m:Meeting) WHERE elementId(m) = $meetingId
          CREATE (p:Person {
            email:             $email,
            accountId:         $accountId,
            scope:             'admin',
            source:            'ics-ingest',
            displayName:       $displayName,
            embedding:         $embedding,
            createdAt:         datetime(),
            createdByAgent:    'ics-ingest',
            createdBySource:   'ics-ingest',
            createdBySession:  $sessionId
          })
          CREATE (p)-[:ATTENDED {
            role:            $role,
            partstat:        $partstat,
            createdAt:       datetime(),
            createdBySource: 'ics-ingest'
          }]->(m)
          `, {
                    email: att.address,
                    accountId: ctx.accountId,
                    displayName: att.displayName ?? null,
                    embedding: attendeeEmbedding,
                    sessionId: ctx.sessionId ?? null,
                    meetingId: meetingResult.meetingId,
                    role: att.role,
                    partstat: att.partstat ?? null,
                });
                return { outcome: "person-created" };
            });
            if (personResult.outcome === "cross-account-skip") {
                skippedCrossAccountPerson++;
            }
            else if (personResult.outcome === "linked-existing") {
                existingPersonsLinked++;
            }
            else {
                peopleCreated++;
            }
        }
    }
    const ms = Date.now() - ctx.start;
    process.stderr.write(`[ics-ingest] importId=${ctx.importId} filename=${ctx.filename} mode=commit ` +
        `events=${ctx.eventsParsed} meetings=${ctx.valid.length} ` +
        `meetingsCreated=${meetingsCreated} meetingsUpdated=${meetingsUpdated} ` +
        `peopleCreated=${peopleCreated} existingPersonsLinked=${existingPersonsLinked} ` +
        `attendees=${attendees} organizers=${organizers} ` +
        `skippedCrossAccountPerson=${skippedCrossAccountPerson} ` +
        `skippedNoUid=${ctx.skippedNoUid} skippedNoStartTime=${ctx.skippedNoStartTime} ` +
        `ms=${ms}\n`);
    return {
        mode: "commit",
        filename: ctx.filename,
        importId: ctx.importId,
        events: ctx.eventsParsed,
        meetingsCreated,
        meetingsUpdated,
        peopleCreated,
        existingPersonsLinked,
        attendees,
        organizers,
        skippedNoUid: ctx.skippedNoUid,
        skippedNoStartTime: ctx.skippedNoStartTime,
        skippedCrossAccountPerson,
        parseErrors: ctx.parseErrors,
        ms,
    };
}
//# sourceMappingURL=ics-graph-ingest.js.map