/**
 * voice-distil-profile — walk the operator's `human-only` corpus and
 * persist a `:VoiceProfile` style card.
 *
 * Task 462: one `:VoiceProfile` per `(accountId, userId, format)`.
 * `format` ∈ `{text, email, social-post, article, marketing-copy, note}`.
 *
 *   - When `format` is supplied, distils that format only.
 *   - When `format` is omitted, enumerates distinct format values present
 *     in the corpus (nodes where `n.format IS NOT NULL`) and distils each
 *     that fires the cadence guard. Returns `skipReason: 'no-format-tagged-content'`
 *     when enumeration yields zero formats.
 *
 * Cadence guard: re-runs on ≥20% corpus growth since last profile, OR
 * ≥30 days since `generatedAt`. `force: true` overrides both.
 *
 * Body-text sampling caps at ~120k chars (rough proxy for tokens at
 * 4 chars/token = 30k tokens) — well inside Haiku's 200k context window
 * with headroom for the system prompt + tool schema.
 *
 * Task 424 — the Haiku style-card composition moved into the calling agent's
 * turn. The agent reads the corpus exemplars and feedback intents (this tool
 * returns them when called with `mode='sample'`), composes the style-card
 * YAML, then calls this tool again with `mode='write'` + `styleCardYaml` set.
 */
import neo4j from "neo4j-driver";
import { getSession } from "../lib/neo4j.js";
import { notTrashed } from "../lib/voice-corpus.js";
import { VOICE_CORPUS_WHERE, voiceCorpusWhereWithFormat, FORMAT_VALUES, } from "../lib/voice-corpus.js";
/**
 * Coerce a neo4j cypher RETURN value to a plain JS number.
 *
 * Neo4j-driver represents INTs as `Integer` objects (high/low 32-bit
 * pair to safely carry int64). `Number()` *usually* works via valueOf()
 * but the safe path is `Integer#toNumber()`. Returns null when the
 * value is null/undefined so cadence-guard arithmetic stays explicit
 * about the "no prior profile" case.
 */
function toJsNumber(value) {
    if (value === null || value === undefined)
        return null;
    if (typeof value === "number")
        return value;
    if (neo4j.isInt(value))
        return value.toNumber();
    if (typeof value === "string") {
        const parsed = Number(value);
        return Number.isFinite(parsed) ? parsed : null;
    }
    return null;
}
const SAMPLE_CHAR_CAP = 120_000;
const RE_RUN_GROWTH_FRACTION = 0.2;
const RE_RUN_AGE_DAYS = 30;
// The agent produces a YAML style card with at minimum these keys:
//   sentenceLengthDistribution, register, favoured, avoided, taboos,
//   openingPattern, closingPattern, exemplarSentences
// Exemplar sentences should be quoted verbatim from the returned corpus.
/**
 * Single-format distil. Opens a new session for each call.
 */
async function distilForFormat(params) {
    const { accountId, userId, format, force = false } = params;
    const session = getSession();
    const now = new Date();
    const nowIso = now.toISOString();
    try {
        // 1. Read existing per-format profile (if any) for cadence-guard comparison.
        const existing = await session.run(`MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
       OPTIONAL MATCH (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
       RETURN elementId(p) AS profileId, p.corpusSize AS corpusSize,
              p.generatedAt AS generatedAt`, { accountId, userId, format });
        const existingRow = existing.records[0];
        const prevProfileId = existingRow?.get("profileId") ?? null;
        const prevCorpusSize = toJsNumber(existingRow?.get("corpusSize"));
        const prevGeneratedAt = existingRow?.get("generatedAt") ?? null;
        // 2. Count corpus for this specific format.
        const corpusWhere = voiceCorpusWhereWithFormat(format);
        const countResult = await session.run(`MATCH (n)
       WHERE ${corpusWhere}
       RETURN count(n) AS c`, { accountId, format });
        const corpusSize = toJsNumber(countResult.records[0]?.get("c")) ?? 0;
        if (corpusSize === 0) {
            process.stderr.write(`[voice-distil] adminUser=${userId} format=${format} skip reason=empty-corpus\n`);
            return {
                profileId: prevProfileId,
                corpusSize: 0,
                generatedAt: prevGeneratedAt,
                feedbackEntries: 0,
                skipped: true,
                skipReason: "empty-corpus",
            };
        }
        // 3. Cadence guard.
        if (!force && prevProfileId && prevCorpusSize !== null && prevGeneratedAt) {
            const ageMs = now.getTime() - new Date(prevGeneratedAt).getTime();
            const ageDays = ageMs / (24 * 60 * 60 * 1000);
            const growth = prevCorpusSize > 0
                ? (corpusSize - prevCorpusSize) / prevCorpusSize
                : 1;
            const ageTrigger = ageDays >= RE_RUN_AGE_DAYS;
            const growthTrigger = growth >= RE_RUN_GROWTH_FRACTION;
            if (!ageTrigger && !growthTrigger) {
                const reason = ageDays < 1 ? "recent" : "below-threshold";
                process.stderr.write(`[voice-distil] adminUser=${userId} format=${format} skip reason=${reason} corpusSize=${corpusSize} prevCorpus=${prevCorpusSize} ageDays=${ageDays.toFixed(1)}\n`);
                return {
                    profileId: prevProfileId,
                    corpusSize,
                    generatedAt: prevGeneratedAt,
                    feedbackEntries: 0,
                    skipped: true,
                    skipReason: reason,
                };
            }
        }
        // 4. Sample corpus bodies up to the char cap. Pick recent first.
        //
        // Body resolution (Task 471):
        //   - For a :KnowledgeDocument with HAS_SECTION children, concatenate
        //     child :Section bodies in `position` order — KD.body itself is
        //     null after the Task 465 server-slicing change.
        //   - Other labels (:Message, :SocialPost, :Section chat chunks) read
        //     n.body directly via the existing COALESCE fallback chain.
        //
        // `collect(s.body)` is null-skipping in Cypher, so KDs with no children
        // produce sectionBodies = [] and fall through to the COALESCE branch
        // correctly. The `usedSectionWalk` flag is returned for per-exemplar
        // observability.
        const sampled = await session.run(`MATCH (n)
       WHERE ${corpusWhere}
       WITH n, coalesce(n.dateSent, n.datePublished, n.firstMessageAt, n.dateCreated, n.createdAt) AS ts
       OPTIONAL MATCH (parent:KnowledgeDocument)-[:HAS_SECTION]->(n)
       WHERE 'Section' IN labels(n) AND parent.conversationIdentity IS NOT NULL
         AND ${notTrashed("parent")}
       WITH n, ts, parent
       OPTIONAL MATCH (n)-[:HAS_SECTION]->(s:Section)
       WHERE ${notTrashed("s")}
       WITH n, ts, parent, s ORDER BY s.position
       WITH n, ts, parent, collect(s.body) AS sectionBodies
       WITH n, ts, parent,
            'KnowledgeDocument' IN labels(n) AND size(sectionBodies) > 0 AS usedSectionWalk,
            CASE
              WHEN 'KnowledgeDocument' IN labels(n) AND size(sectionBodies) > 0
                THEN reduce(acc = '', b IN sectionBodies | acc + b + '\n\n')
              ELSE coalesce(n.body, n.abstract, n.subject, n.title, n.summary, '')
            END AS body
       RETURN elementId(n) AS id,
              labels(n) AS labels,
              body,
              usedSectionWalk,
              parent.conversationIdentity IS NOT NULL AS isConversationChunk,
              ts
       ORDER BY ts IS NULL, ts DESC
       LIMIT 500`, { accountId, format });
        const samples = [];
        let sectionWalkCount = 0;
        let nBodyCount = 0;
        let charBudget = SAMPLE_CHAR_CAP;
        for (const r of sampled.records) {
            const body = r.get("body") ?? "";
            const labels = r.get("labels") ?? [];
            const isConversationChunk = Boolean(r.get("isConversationChunk"));
            const usedSectionWalk = Boolean(r.get("usedSectionWalk"));
            const label = isConversationChunk
                ? "Conversation"
                : (labels.find((l) => ["KnowledgeDocument", "Message", "SocialPost"].includes(l)) ?? labels[0] ?? "Unknown");
            if (body.length === 0)
                continue;
            const trimmed = body.length > charBudget ? body.slice(0, charBudget) : body;
            samples.push({ id: r.get("id"), label, body: trimmed });
            if (usedSectionWalk)
                sectionWalkCount += 1;
            else
                nBodyCount += 1;
            charBudget -= trimmed.length;
            if (charBudget <= 0)
                break;
        }
        // 5. Pull feedback intents from existing :VoiceEdit nodes for this format.
        const feedback = await session.run(`MATCH (e:VoiceEdit {userId: $userId, accountId: $accountId, format: $format})
       RETURN e.intent AS intent ORDER BY e.occurredAt DESC LIMIT 50`, { accountId, userId, format });
        const feedbackIntents = feedback.records
            .map((r) => r.get("intent"))
            .filter((x) => typeof x === "string" && x.length > 0);
        // 6. Mode branch (Task 424).
        const mode = params.mode ?? "sample";
        if (mode === "sample") {
            process.stderr.write(`[voice-distil] adminUser=${userId} format=${format} mode=sample corpusSize=${corpusSize} ` +
                `exemplars=${samples.length} feedbackEntries=${feedbackIntents.length} ` +
                `sample-body-source=section-walk:${sectionWalkCount} n.body:${nBodyCount}\n`);
            return {
                profileId: prevProfileId,
                corpusSize,
                generatedAt: prevGeneratedAt,
                feedbackEntries: feedbackIntents.length,
                skipped: true,
                skipReason: "missing-style-card",
                exemplars: samples,
                feedbackIntents,
            };
        }
        const styleCardYaml = (params.styleCardYaml ?? "").trim();
        if (!styleCardYaml) {
            throw new Error("voice-distil-profile: mode='write' requires styleCardYaml (the YAML you composed from the sample-mode response).");
        }
        // 7. Write the :VoiceProfile node — keyed on (accountId, userId, format) —
        //    plus LEARNED_FROM edges to every sampled node.
        const writeResult = await session.run(`MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
       MERGE (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
         ON CREATE SET p.createdAt = $now
       SET p.styleCard = $styleCard,
           p.generatedAt = $now,
           p.corpusSize = $corpusSize,
           p.feedbackEntries = $feedbackEntries,
           p.updatedAt = $now
       WITH p
       UNWIND $sampleIds AS sid
       MATCH (s) WHERE elementId(s) = sid
       MERGE (p)-[:LEARNED_FROM]->(s)
       RETURN elementId(p) AS profileId`, {
            accountId,
            userId,
            format,
            styleCard: styleCardYaml,
            now: nowIso,
            corpusSize,
            feedbackEntries: feedbackIntents.length,
            sampleIds: samples.map((s) => s.id),
        });
        if (writeResult.records.length === 0) {
            throw new Error(`voice-mirror: :AdminUser {accountId='${accountId}', userId='${userId}'} not found. Onboarding must promote the operator to :AdminUser before distillation.`);
        }
        const profileId = writeResult.records[0].get("profileId");
        process.stderr.write(`[voice-distil] adminUser=${userId} format=${format} mode=write corpusSize=${corpusSize} generatedAt=${nowIso} feedbackEntries=${feedbackIntents.length}\n`);
        return {
            profileId,
            corpusSize,
            generatedAt: nowIso,
            feedbackEntries: feedbackIntents.length,
            skipped: false,
        };
    }
    finally {
        await session.close();
    }
}
/**
 * Single-document amend (Task 472).
 *
 * Operator-initiated incremental update: the operator names one or more
 * documents (`nodeIds`), the tool reads only those plus the existing
 * `:VoiceProfile.styleCard` for the format, and the calling agent decides
 * whether the documents move the profile or don't. Same body-resolution
 * and trashed/authorshipMode/label predicates as the full-corpus sample;
 * the corpus-wide format predicate is dropped (the operator picked the
 * docs); the cadence guard does not apply.
 */
async function amendForFormat(params) {
    const { accountId, userId, format, nodeIds } = params;
    const session = getSession();
    try {
        // 1. Existing profile (may be absent — first-time amend with no prior card).
        const profileRow = await session.run(`MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
       OPTIONAL MATCH (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
       RETURN elementId(p) AS profileId,
              p.styleCard AS styleCard,
              p.corpusSize AS corpusSize,
              p.generatedAt AS generatedAt`, { accountId, userId, format });
        const prow = profileRow.records[0];
        const prevProfileId = prow?.get("profileId") ?? null;
        const existingStyleCard = prow?.get("styleCard") ?? null;
        const prevCorpusSize = toJsNumber(prow?.get("corpusSize")) ?? 0;
        const prevGeneratedAt = prow?.get("generatedAt") ?? null;
        // 2. Per-document eligibility classification. Each input id gets a
        //    verdict; the first failing predicate wins. The predicate set mirrors
        //    VOICE_CORPUS_WHERE except `format` is dropped (operator picked the
        //    docs) and conversation-section sub-pattern is inlined for the
        //    per-document case.
        const eligibilityResult = await session.run(`UNWIND $nodeIds AS nid
       OPTIONAL MATCH (n) WHERE elementId(n) = nid
       WITH nid, n,
            CASE
              WHEN n IS NULL THEN 'node-not-found'
              WHEN n.accountId <> $accountId THEN 'wrong-account'
              WHEN NOT coalesce(n.authorshipMode, '') IN ['human-only', 'human-led-agent-assisted']
                THEN 'ineligible-authorship-mode'
              WHEN n:Trashed OR n.deletedAt IS NOT NULL THEN 'trashed'
              WHEN NOT (
                any(lbl IN labels(n) WHERE lbl IN ['KnowledgeDocument', 'Message', 'SocialPost'])
                OR ('Section' IN labels(n) AND EXISTS {
                  MATCH (parent:KnowledgeDocument)-[:HAS_SECTION]->(n)
                  WHERE parent.conversationIdentity IS NOT NULL
                    AND ${notTrashed("parent")}
                })
              ) THEN 'ineligible-label'
              ELSE 'ok'
            END AS verdict
       RETURN nid, verdict`, { accountId, nodeIds });
        const eligibleIds = [];
        const ineligible = [];
        for (const r of eligibilityResult.records) {
            const nid = r.get("nid");
            const verdict = r.get("verdict");
            if (verdict === "ok")
                eligibleIds.push(nid);
            else
                ineligible.push({ nodeId: nid, reason: verdict });
        }
        // 3. Feedback intents (same as sample) — agent benefits even on a no-op
        //    amend ("you've already corrected this voice tic ten times").
        const feedback = await session.run(`MATCH (e:VoiceEdit {userId: $userId, accountId: $accountId, format: $format})
       RETURN e.intent AS intent ORDER BY e.occurredAt DESC LIMIT 50`, { accountId, userId, format });
        const feedbackIntents = feedback.records
            .map((r) => r.get("intent"))
            .filter((x) => typeof x === "string" && x.length > 0);
        if (eligibleIds.length === 0) {
            process.stderr.write(`[voice-distil] mode=amend userId=${userId} format=${format} nodeIds=${nodeIds.length} ` +
                `existingProfile=${prevProfileId ?? "none"} eligible=0 ineligible=${ineligible.length}\n`);
            return {
                profileId: prevProfileId,
                corpusSize: prevCorpusSize,
                generatedAt: prevGeneratedAt,
                feedbackEntries: feedbackIntents.length,
                skipped: true,
                skipReason: "amend-no-eligible-documents",
                existingStyleCard,
                eligible: 0,
                ineligible,
                exemplars: [],
                feedbackIntents,
            };
        }
        // 4. Body walk for eligible ids — identical body resolution to the
        //    full-corpus sample path: KD-with-Section-children concatenates child
        //    bodies in `position` order (trashed sections filtered); other labels
        //    fall through to the COALESCE chain.
        const bodyResult = await session.run(`UNWIND $eligibleIds AS nid
       MATCH (n) WHERE elementId(n) = nid
       OPTIONAL MATCH (parent:KnowledgeDocument)-[:HAS_SECTION]->(n)
       WHERE 'Section' IN labels(n) AND parent.conversationIdentity IS NOT NULL
         AND ${notTrashed("parent")}
       WITH n, parent
       OPTIONAL MATCH (n)-[:HAS_SECTION]->(s:Section)
       WHERE ${notTrashed("s")}
       WITH n, parent, s ORDER BY s.position
       WITH n, parent, collect(s.body) AS sectionBodies
       WITH n, parent,
            'KnowledgeDocument' IN labels(n) AND size(sectionBodies) > 0 AS usedSectionWalk,
            CASE
              WHEN 'KnowledgeDocument' IN labels(n) AND size(sectionBodies) > 0
                THEN reduce(acc = '', b IN sectionBodies | acc + b + '\n\n')
              ELSE coalesce(n.body, n.abstract, n.subject, n.title, n.summary, '')
            END AS body
       RETURN elementId(n) AS id,
              labels(n) AS labels,
              body,
              usedSectionWalk,
              parent.conversationIdentity IS NOT NULL AS isConversationChunk`, { eligibleIds });
        const exemplars = [];
        let charBudget = SAMPLE_CHAR_CAP;
        let sectionWalkCount = 0;
        let nBodyCount = 0;
        for (const r of bodyResult.records) {
            const body = r.get("body") ?? "";
            if (body.length === 0)
                continue;
            const labels = r.get("labels") ?? [];
            const isConversationChunk = Boolean(r.get("isConversationChunk"));
            const usedSectionWalk = Boolean(r.get("usedSectionWalk"));
            const label = isConversationChunk
                ? "Conversation"
                : (labels.find((l) => ["KnowledgeDocument", "Message", "SocialPost"].includes(l)) ?? labels[0] ?? "Unknown");
            const trimmed = body.length > charBudget ? body.slice(0, charBudget) : body;
            exemplars.push({ id: r.get("id"), label, body: trimmed });
            if (usedSectionWalk)
                sectionWalkCount += 1;
            else
                nBodyCount += 1;
            charBudget -= trimmed.length;
            if (charBudget <= 0)
                break;
        }
        process.stderr.write(`[voice-distil] mode=amend userId=${userId} format=${format} nodeIds=${nodeIds.length} ` +
            `existingProfile=${prevProfileId ?? "none"} eligible=${eligibleIds.length} ineligible=${ineligible.length} ` +
            `exemplars=${exemplars.length} sample-body-source=section-walk:${sectionWalkCount} n.body:${nBodyCount}\n`);
        // `skipped: false` because the function DID deliver value — exemplars
        // + existingStyleCard for the agent to act on. The agent decides
        // whether to compose new YAML (calls `mode='write'`) or replies "no
        // material delta"; either is a valid amend outcome, neither is a
        // "skip" in the cadence-guard sense.
        return {
            profileId: prevProfileId,
            corpusSize: prevCorpusSize,
            generatedAt: prevGeneratedAt,
            feedbackEntries: feedbackIntents.length,
            skipped: false,
            existingStyleCard,
            eligible: eligibleIds.length,
            ineligible,
            exemplars,
            feedbackIntents,
        };
    }
    finally {
        await session.close();
    }
}
/**
 * Amend write (Task 472). Persists the agent-composed style card after an
 * `amend` sample. Bypasses the cadence guard and the full corpus walk; the
 * only `LEARNED_FROM` edges added are to `amendedFromNodeIds`. Pre-existing
 * `LEARNED_FROM` edges remain via `MERGE`. `corpusSize` on the profile is
 * untouched — the amend doesn't change the operator's qualifying corpus,
 * only the styleCard's interpretation of it.
 */
async function amendWriteForFormat(params) {
    const { accountId, userId, format, styleCardYaml, amendedFromNodeIds } = params;
    const session = getSession();
    const nowIso = new Date().toISOString();
    try {
        // 1. Pre-resolve the amend ids and refuse if none survive. The write
        //    Cypher SETs `p` before the UNWIND/MATCH on amend ids, so if every
        //    id is stale the profile would be mutated then the function would
        //    throw — leaving a silently advanced styleCard the operator never
        //    confirmed. Resolving here means the SET never runs on an empty
        //    target list, and stale ids surface as a structured error before
        //    any write commits. Code-review pass-1 fix.
        const resolve = await session.run(`UNWIND $amendIds AS sid
       MATCH (s) WHERE elementId(s) = sid
       RETURN elementId(s) AS id`, { amendIds: amendedFromNodeIds });
        const resolvedAmendIds = resolve.records.map((r) => r.get("id"));
        if (resolvedAmendIds.length === 0) {
            throw new Error(`voice-mirror: none of amendedFromNodeIds=${JSON.stringify(amendedFromNodeIds)} ` +
                `resolved to live nodes. Confirm the named docs exist and are accessible.`);
        }
        const feedback = await session.run(`MATCH (e:VoiceEdit {userId: $userId, accountId: $accountId, format: $format})
       RETURN count(e) AS c`, { accountId, userId, format });
        const feedbackEntries = toJsNumber(feedback.records[0]?.get("c")) ?? 0;
        const result = await session.run(`MATCH (a:AdminUser {accountId: $accountId, userId: $userId})
       MERGE (a)-[:HAS_VOICE_PROFILE]->(p:VoiceProfile {accountId: $accountId, userId: $userId, format: $format})
         ON CREATE SET p.createdAt = $now, p.corpusSize = 0
       SET p.styleCard = $styleCard,
           p.generatedAt = $now,
           p.feedbackEntries = $feedbackEntries,
           p.updatedAt = $now
       WITH p
       UNWIND $amendIds AS sid
       MATCH (s) WHERE elementId(s) = sid
       MERGE (p)-[:LEARNED_FROM]->(s)
       WITH p
       RETURN elementId(p) AS profileId, p.corpusSize AS corpusSize`, {
            accountId,
            userId,
            format,
            styleCard: styleCardYaml,
            now: nowIso,
            feedbackEntries,
            amendIds: resolvedAmendIds,
        });
        if (result.records.length === 0) {
            throw new Error(`voice-mirror: :AdminUser {accountId='${accountId}', userId='${userId}'} not found. ` +
                `Onboarding must promote the operator to :AdminUser before distillation.`);
        }
        const profileId = result.records[0].get("profileId");
        const corpusSize = toJsNumber(result.records[0].get("corpusSize")) ?? 0;
        process.stderr.write(`[voice-distil] mode=write userId=${userId} format=${format} amended=true ` +
            `amendedFromNodeIds=${amendedFromNodeIds.length} corpusSize=${corpusSize} ` +
            `generatedAt=${nowIso} feedbackEntries=${feedbackEntries}\n`);
        return {
            profileId,
            corpusSize,
            generatedAt: nowIso,
            feedbackEntries,
            skipped: false,
        };
    }
    finally {
        await session.close();
    }
}
export async function voiceDistilProfile(params) {
    const { accountId, userId, force = false } = params;
    if (!accountId || !userId) {
        throw new Error("voice-distil-profile: accountId and userId required");
    }
    // Amend sample (Task 472): operator-initiated single-document update.
    if (params.mode === "amend") {
        if (!params.format) {
            throw new Error("voice-distil-profile: mode='amend' requires format");
        }
        const nodeIds = params.nodeIds ?? [];
        if (nodeIds.length === 0) {
            throw new Error("voice-distil-profile: mode='amend' requires nodeIds (at least one)");
        }
        return amendForFormat({
            accountId,
            userId,
            format: params.format,
            nodeIds,
        });
    }
    // Amend write (Task 472): persist after the agent composed from an amend
    // sample. Triggered by `mode='write'` + non-empty `amendedFromNodeIds`.
    // Distinct from the full-corpus write — bypasses cadence + corpus walk.
    if (params.mode === "write" &&
        params.amendedFromNodeIds &&
        params.amendedFromNodeIds.length > 0) {
        if (!params.format) {
            throw new Error("voice-distil-profile: mode='write' with amendedFromNodeIds requires format");
        }
        const styleCardYaml = (params.styleCardYaml ?? "").trim();
        if (!styleCardYaml) {
            throw new Error("voice-distil-profile: mode='write' requires styleCardYaml (the YAML you composed from the amend-sample response).");
        }
        return amendWriteForFormat({
            accountId,
            userId,
            format: params.format,
            styleCardYaml,
            amendedFromNodeIds: params.amendedFromNodeIds,
        });
    }
    // Single-format path.
    if (params.format !== undefined) {
        return distilForFormat({ ...params, format: params.format, force });
    }
    // Multi-format path: enumerate distinct format values present in the corpus.
    const session = getSession();
    let formats = [];
    try {
        const enumResult = await session.run(`MATCH (n)
       WHERE ${VOICE_CORPUS_WHERE} AND n.format IS NOT NULL
       RETURN DISTINCT n.format AS fmt`, { accountId });
        formats = enumResult.records
            .map((r) => r.get("fmt"))
            .filter((f) => FORMAT_VALUES.includes(f));
    }
    finally {
        await session.close();
    }
    if (formats.length === 0) {
        process.stderr.write(`[voice-distil] adminUser=${userId} skip reason=no-format-tagged-content\n`);
        return {
            profileId: null,
            corpusSize: 0,
            generatedAt: null,
            feedbackEntries: 0,
            skipped: true,
            skipReason: "no-format-tagged-content",
        };
    }
    // Distil each discovered format sequentially.
    const results = [];
    for (const format of formats) {
        const result = await distilForFormat({ ...params, format, force });
        results.push(result);
    }
    return results;
}
//# sourceMappingURL=voice-distil-profile.js.map