// Indexing orchestrator (Epic #189, Issue #196). Composes #194 discovery, #195 chunking,
// and #192 embedding into a single streaming pipeline that produces `vectors` rows for a
// capsule. Every state change emits one `IndexingEvent`; consumers drive the AsyncIterable
// to back-pressure the pipeline.
//
// Pipeline shape per source:
//
//   discoverAndExtract() ── (per file) ──┐
//                                        ├─ document-discovered
//                                        ├─ extraction skipped (unchanged): document-skipped
//                                        ├─ extraction persisted: document-extracted →
//                                        │      chunkDocument → document-chunked →
//                                        │      embedChunkBatch* → document-embedded
//                                        └─ extraction failed: document-failed
//
// Cancellation: a single `AbortSignal` flows into discovery, chunking, AND the embedding
// batcher. Aborting mid-document terminates the run with a `job-cancelled` event; rows
// already persisted for completed documents are kept (the source-of-truth for resume is
// the chunks/vectors tables, not the in-flight buffer).
//
// Force mode: deletes the capsule's pre-existing vector rows up front, then passes
// `force=true` into the chunker so chunks are also re-emitted. Discovery's incremental
// fast-path is bypassed by re-reading the bytes (the discovery layer already does the
// content-hash compare for us — when force=true the orchestrator deletes the vectors but
// the documents row stays valid, so chunk-and-embed still re-runs).
import { randomUUID } from "node:crypto";
import { verifyEmbeddingCapability } from "@oscharko-dev/keiko-model-gateway";
import { chunkDocument } from "../chunking/chunker-runner.js";
import { deleteChunksForDocument, hasStaleChunksForDocument } from "../chunking/chunker-persist.js";
import { getCapsule, updateCapsuleState } from "../capsule-lifecycle.js";
import { discoverAndExtract } from "../discovery/discovery-runner.js";
import { deleteDocumentRow, listPersistedDocumentsForSource, readDocumentTextRow, updateDocumentStatusRow, } from "../discovery/persist.js";
import { listCapsuleSources } from "../source-lifecycle.js";
import { finalizeJobRow, isJobCancellationRequested, insertJobRow, updateJobCounters, } from "./job-persist.js";
import { embedChunkBatch } from "./embedding-batcher.js";
import { countVectorsForDocument, deleteVectorsForCapsule, deleteVectorsForDocument, selectChunksForDocument, } from "./vector-persist.js";
import { DEFAULT_INDEXING_BATCH_SIZE, DEFAULT_INDEXING_CONCURRENCY, IndexingError, } from "./types.js";
// ─── Abort helper ─────────────────────────────────────────────────────────────
// Reads `signal?.aborted` through a function call so TypeScript's control-flow analysis
// does NOT narrow the optional chain after the first false branch. Mirrors the pattern in
// `discovery/discovery-runner.ts` and `discovery/walk.ts`.
function aborted(signal) {
    return signal?.aborted === true;
}
function cancellationRequested(state) {
    return (aborted(state.options.signal) ||
        isJobCancellationRequested(state.options.store._internal.db, state.jobId));
}
// ─── Bounded options ──────────────────────────────────────────────────────────
function clampBatchSize(raw) {
    const v = raw ?? DEFAULT_INDEXING_BATCH_SIZE;
    return Math.max(1, Math.min(DEFAULT_INDEXING_BATCH_SIZE, Math.floor(v)));
}
function clampConcurrency(raw) {
    const v = raw ?? DEFAULT_INDEXING_CONCURRENCY;
    return Math.max(1, Math.min(DEFAULT_INDEXING_CONCURRENCY, Math.floor(v)));
}
// ─── Source resolution ────────────────────────────────────────────────────────
function resolveSources(options, capsule) {
    const all = listCapsuleSources(options.store, capsule.id);
    if (all.length === 0) {
        throw new IndexingError("INVALID_OPTIONS", `Capsule ${String(capsule.id)} has no attached sources to index.`);
    }
    if (options.sourceIds === undefined)
        return all;
    const allow = new Set(options.sourceIds.map((s) => String(s)));
    return all.filter((s) => allow.has(String(s.id)));
}
function buildCounters(state) {
    return {
        total: state.totalDocuments,
        processed: state.processedDocuments,
        failed: state.failedDocuments,
        skipped: state.skippedDocuments,
        resumeToken: state.lastResumeToken === null ? null : String(state.lastResumeToken),
    };
}
function persistJobProgress(state) {
    updateJobCounters(state.options.store._internal.db, state.jobId, buildCounters(state));
}
function emitProgress(options, event) {
    if (options.progress === undefined)
        return;
    // Caller-provided callback; isolate so a throwing consumer cannot crash the orchestrator
    // mid-document. Errors are surfaced as a document-failed event would be — but we never
    // mutate state on a progress-callback throw because that would couple the caller's bug
    // to our run accounting.
    try {
        options.progress(event);
    }
    catch {
        // intentionally swallowed — progress sinks must not affect run correctness
    }
}
function clearDocumentArtifacts(state, documentId, options) {
    deleteVectorsForDocument(state.options.store._internal.db, state.capsule.id, documentId);
    if (options.deleteChunks) {
        deleteChunksForDocument(state.options.store._internal.db, state.capsule.id, documentId);
    }
}
function markDocumentFailed(state, documentId) {
    updateDocumentStatusRow(state.options.store._internal.db, state.capsule.id, documentId, "failed");
}
const SELECT_CHUNKS_WITH_OFFSETS_SQL = [
    "SELECT c.id, c.capsule_id, c.source_id, c.document_id, c.parsed_unit_id, c.order_index,",
    "  pu.character_start AS char_start, pu.character_end AS char_end",
    "FROM chunks AS c",
    "JOIN parsed_units AS pu ON pu.capsule_id = c.capsule_id AND pu.id = c.parsed_unit_id",
    "WHERE c.capsule_id = :c AND c.document_id = :d",
    "ORDER BY c.order_index ASC",
].join(" ");
function selectChunkProjections(state, documentId) {
    const rows = state.options.store._internal.db
        .prepare(SELECT_CHUNKS_WITH_OFFSETS_SQL)
        .all({ c: state.capsule.id, d: documentId });
    return rows;
}
function projectChunksToEmbed(state, documentId, sourceText) {
    const projections = selectChunkProjections(state, documentId);
    const out = [];
    for (const row of projections) {
        const start = row.char_start ?? 0;
        const end = row.char_end ?? sourceText.length;
        const text = sourceText.slice(start, end);
        out.push({
            id: row.id,
            capsuleId: row.capsule_id,
            sourceId: row.source_id,
            documentId,
            text,
        });
    }
    return out;
}
function scopeRootOf(source) {
    const scope = source.scope;
    if (scope.kind === "folder")
        return { absoluteRoot: scope.rootPath };
    if (scope.kind === "repository")
        return { absoluteRoot: scope.repositoryRoot };
    return { absoluteRoot: scope.rootPath };
}
function joinAbs(root, rel) {
    if (root.endsWith("/"))
        return `${root}${rel}`;
    return `${root}/${rel}`;
}
function normaliseSep(p) {
    return p.replace(/\\/g, "/");
}
function isContained(absoluteRoot, absolutePath) {
    const normRoot = normaliseSep(absoluteRoot);
    const normPath = normaliseSep(absolutePath);
    if (normPath === normRoot)
        return true;
    const prefix = normRoot.endsWith("/") ? normRoot : `${normRoot}/`;
    return normPath.startsWith(prefix);
}
function readSourceText(state, source, relativePath) {
    const { absoluteRoot } = scopeRootOf(source);
    const abs = joinAbs(absoluteRoot, relativePath);
    let real;
    try {
        real = state.options.workspaceFs.realPath(abs);
    }
    catch (cause) {
        throw new IndexingError("PERSISTENCE_FAILED", `source realPath failed before embedding: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
    }
    if (!isContained(absoluteRoot, real)) {
        throw new IndexingError("PERSISTENCE_FAILED", `source realpath escapes scope root before embedding: ${relativePath}`);
    }
    return state.options.workspaceFs.readFileUtf8(normaliseSep(real));
}
function resolveChunkSourceText(state, documentId, source, relativePath) {
    const persistedText = readDocumentTextRow(state.options.store._internal.db, state.capsule.id, documentId);
    if (persistedText !== undefined) {
        return persistedText;
    }
    return readSourceText(state, source, relativePath);
}
// ─── Batch boundaries ─────────────────────────────────────────────────────────
function sliceIntoBatches(items, batchSize) {
    if (items.length === 0)
        return [];
    const out = [];
    for (let i = 0; i < items.length; i += batchSize) {
        out.push(items.slice(i, i + batchSize));
    }
    return out;
}
async function embedDocumentChunks(state, documentId, source, relativePath) {
    // Text-like documents are re-read from disk; binary parsers persist a normalized text
    // projection so chunk slicing stays aligned with extracted content.
    const sourceText = resolveChunkSourceText(state, documentId, source, relativePath);
    const chunks = projectChunksToEmbed(state, documentId, sourceText);
    if (chunks.length === 0) {
        return { vectorCount: 0, errors: [], lastChunkId: null };
    }
    const batches = sliceIntoBatches(chunks, state.batchSize);
    const errors = [];
    let vectorCount = 0;
    let lastChunkId = null;
    for (const batch of batches) {
        if (cancellationRequested(state))
            break;
        const result = await embedChunkBatch(batch, {
            adapter: state.options.embeddingAdapter,
            store: state.options.store,
            pinnedIdentity: state.capsule.embeddingModelIdentity,
            concurrency: state.concurrency,
            ...(state.options.signal !== undefined ? { signal: state.options.signal } : {}),
            now: state.now,
            idSource: state.idSource,
        });
        vectorCount += result.vectors.length;
        errors.push(...result.errors);
        if (result.vectors.length > 0) {
            const last = result.vectors[result.vectors.length - 1];
            if (last !== undefined)
                lastChunkId = last.chunkId;
        }
        // Identity-incompatibility is detected by the batcher — stop emitting further batches
        // for this document so the orchestrator can mark the whole job failed.
        if (result.errors.some((e) => e.code === "INCOMPATIBLE_EMBEDDING_IDENTITY")) {
            break;
        }
        if (cancellationRequested(state))
            break;
    }
    return { vectorCount, errors, lastChunkId };
}
// ─── Document handlers ────────────────────────────────────────────────────────
function handleExtractionSkipped(state, result) {
    state.skippedDocuments += 1;
    return {
        kind: "document-skipped",
        jobId: state.jobId,
        documentId: result.outcome.kind === "skipped" ? result.outcome.document.id : "",
        reason: "unchanged",
    };
}
function handleExtractionFailed(state, result) {
    state.failedDocuments += 1;
    const errMessage = result.outcome.kind === "failed" ? result.outcome.error.message : "extraction failed";
    const errCode = result.outcome.kind === "failed" ? result.outcome.error.code : "READ_FAILED";
    if (result.outcome.kind === "failed") {
        clearDocumentArtifacts(state, result.outcome.document.id, { deleteChunks: true });
        markDocumentFailed(state, result.outcome.document.id);
    }
    const error = { code: `DISCOVERY_FAILED:${errCode}`, message: errMessage };
    state.lastError = error;
    return {
        kind: "document-failed",
        jobId: state.jobId,
        ...(result.outcome.kind === "failed" ? { documentId: result.outcome.document.id } : {}),
        relativePath: result.relativePath,
        error,
    };
}
function resolveChunkCount(state, documentId, skippedExisting, freshChunkIds) {
    if (!skippedExisting)
        return freshChunkIds.length;
    // When skippedExisting, the chunks table already holds the rows from a prior run; count
    // them so the chunked event still reports an accurate number.
    return selectChunksForDocument(state.options.store._internal.db, state.capsule.id, documentId)
        .length;
}
function chunkPersistedDocument(state, result) {
    if (result.outcome.kind !== "persisted") {
        throw new IndexingError("INVALID_OPTIONS", "chunkPersistedDocument called with non-persisted result");
    }
    const documentId = result.outcome.document.id;
    const sourceText = resolveChunkSourceText(state, documentId, sourceForResult(state, result), result.relativePath);
    const chunkResult = chunkDocument(state.options.store, {
        capsuleId: state.capsule.id,
        sourceId: result.sourceId,
        documentId,
        sourceText,
        force: state.options.force === true,
        ...(state.options.signal !== undefined ? { signal: state.options.signal } : {}),
    }, state.options.chunkingOptions);
    const chunkCount = resolveChunkCount(state, documentId, chunkResult.skippedExisting, chunkResult.chunkIds);
    return {
        events: chunkedDocumentEvents(state.jobId, documentId, result.relativePath, chunkCount),
        documentId,
        chunkCount,
    };
}
function chunkedDocumentEvents(jobId, documentId, relativePath, chunkCount) {
    return [
        { kind: "document-extracted", jobId, documentId, relativePath },
        { kind: "document-chunked", jobId, documentId, chunkCount },
    ];
}
function sourceForResult(state, result) {
    // Sources are resolved once at job start (see buildInitialState) and cached on RunState.
    // The capsule lifecycleState gates concurrent mutation, so the map stays consistent for
    // the duration of the run — no per-document SELECT against capsule_sources.
    const match = state.sourcesById.get(String(result.sourceId));
    if (match === undefined) {
        throw new IndexingError("INVALID_OPTIONS", `result references unknown source ${String(result.sourceId)}`);
    }
    return match;
}
// Incremental fast-path: skips embedding when vectors already exist (non-force run), or
// deletes prior vectors to prepare for a forced re-embed.
// Returns a PersistedHandling to short-circuit when already-embedded, undefined to continue.
function applyIncrementalFastPath(state, documentId) {
    const staleChunks = hasStaleChunksForDocument(state.options.store._internal.db, state.capsule.id, documentId);
    if (state.options.force !== true) {
        // Incremental fast-path #2: if vectors already exist for this document AND not in force
        // mode, skip the embedding step entirely. The chunker is also a no-op in this case.
        const existing = countVectorsForDocument(state.options.store._internal.db, state.capsule.id, documentId);
        if (existing > 0 && !staleChunks) {
            state.skippedDocuments += 1;
            return {
                events: [
                    { kind: "document-skipped", jobId: state.jobId, documentId, reason: "already-embedded" },
                ],
            };
        }
        if (existing > 0 && staleChunks) {
            deleteVectorsForDocument(state.options.store._internal.db, state.capsule.id, documentId);
        }
        return undefined;
    }
    // Force mode: tear down prior vectors so the re-embed is the only surviving set.
    deleteVectorsForDocument(state.options.store._internal.db, state.capsule.id, documentId);
    return undefined;
}
// Runs the chunker and returns its result, or a PersistedHandling failure event on throw.
function tryChunkDocument(state, result, documentId) {
    try {
        return { chunked: chunkPersistedDocument(state, result) };
    }
    catch (cause) {
        if (cancellationRequested(state)) {
            clearDocumentArtifacts(state, documentId, { deleteChunks: true });
            return { events: [] };
        }
        state.failedDocuments += 1;
        clearDocumentArtifacts(state, documentId, { deleteChunks: true });
        markDocumentFailed(state, documentId);
        const error = {
            code: "CHUNKING_FAILED",
            message: cause instanceof Error ? cause.message : String(cause),
        };
        state.lastError = error;
        return {
            events: [
                {
                    kind: "document-failed",
                    jobId: state.jobId,
                    documentId,
                    relativePath: result.relativePath,
                    error,
                },
            ],
        };
    }
}
function appendDocumentFailure(state, events, documentId, relativePath, error, options) {
    state.failedDocuments += 1;
    clearDocumentArtifacts(state, documentId, options);
    markDocumentFailed(state, documentId);
    state.lastError = error;
    events.push({
        kind: "document-failed",
        jobId: state.jobId,
        documentId,
        relativePath,
        error,
    });
    return { events };
}
function completeEmbeddedDocument(state, events, documentId, embedResult) {
    state.processedDocuments += 1;
    state.vectorsPersisted += embedResult.vectorCount;
    if (embedResult.lastChunkId !== null)
        state.lastResumeToken = embedResult.lastChunkId;
    events.push({
        kind: "document-embedded",
        jobId: state.jobId,
        documentId,
        vectorCount: embedResult.vectorCount,
        resumeToken: embedResult.lastChunkId ?? `${String(documentId)}#empty`,
    });
    return { events };
}
function isCancellationOnlyEmbedResult(state, embedResult) {
    return (cancellationRequested(state) &&
        embedResult.errors.length > 0 &&
        embedResult.errors.every((error) => error.code === "CANCELLED"));
}
// Maps an EmbedDocumentResult into PersistedHandling events, mutating run-state counters.
function applyEmbedResult(state, documentId, relativePath, priorEvents, embedResult) {
    const events = [...priorEvents];
    if (isCancellationOnlyEmbedResult(state, embedResult)) {
        clearDocumentArtifacts(state, documentId, { deleteChunks: false });
        return { events };
    }
    const identityErr = embedResult.errors.find((e) => e.code === "INCOMPATIBLE_EMBEDDING_IDENTITY");
    if (identityErr !== undefined) {
        return {
            ...appendDocumentFailure(state, events, documentId, relativePath, identityErr, {
                deleteChunks: false,
            }),
            identityFailure: identityErr,
        };
    }
    if (embedResult.errors.length > 0) {
        const firstErr = embedResult.errors[0] ?? {
            code: "EMBEDDING_ADAPTER_FAILED",
            message: "embedding adapter failed",
        };
        return appendDocumentFailure(state, events, documentId, relativePath, firstErr, {
            deleteChunks: false,
        });
    }
    return completeEmbeddedDocument(state, events, documentId, embedResult);
}
// Wraps the chunk-then-embed pipeline for a single persisted document.
async function handlePersistedDocument(state, result) {
    const documentId = result.outcome.kind === "persisted" ? result.outcome.document.id : null;
    if (documentId === null)
        return { events: [] };
    if (result.outcome.document.status === "unsupported") {
        clearDocumentArtifacts(state, documentId, { deleteChunks: true });
        state.skippedDocuments += 1;
        return {
            events: [
                {
                    kind: "document-extracted",
                    jobId: state.jobId,
                    documentId,
                    relativePath: result.relativePath,
                },
                { kind: "document-skipped", jobId: state.jobId, documentId, reason: "unsupported" },
            ],
        };
    }
    const fastPath = applyIncrementalFastPath(state, documentId);
    if (fastPath !== undefined)
        return fastPath;
    const chunkStep = tryChunkDocument(state, result, documentId);
    if (!("chunked" in chunkStep))
        return chunkStep;
    const embedResult = await embedDocumentChunks(state, documentId, sourceForResult(state, result), result.relativePath);
    return applyEmbedResult(state, documentId, result.relativePath, chunkStep.chunked.events, embedResult);
}
// ─── Per-source pipeline ──────────────────────────────────────────────────────
async function* runOneSource(state, source) {
    const stream = discoverAndExtract({
        fs: state.options.workspaceFs,
        store: state.options.store,
        parserRegistry: state.options.parserRegistry,
    }, sourceDiscoveryParams(state, source));
    const progress = {
        cancelled: false,
        sawScopeError: false,
        completed: false,
        discoveredPaths: new Set(),
    };
    for await (const evt of stream) {
        observeSourceEvent(progress, evt);
        if (cancellationRequested(state)) {
            progress.cancelled = true;
            break;
        }
        const events = await handleDiscoveryEvent(state, evt);
        if (events.length > 0) {
            persistJobProgress(state);
        }
        for (const e of events) {
            yield e;
            if (e.kind === "document-failed" && e.error.code === "INCOMPATIBLE_EMBEDDING_IDENTITY") {
                return;
            }
        }
        // After yielding a batch we re-check the signal — the consumer's awaiting iterator
        // may have aborted between events.
        if (cancellationRequested(state)) {
            progress.cancelled = true;
            break;
        }
    }
    finalizeSourceRun(state, source, progress);
}
// Routes a file-extracted event: force-skipped docs are re-shaped to persisted so the
// standard chunk-and-embed pipeline runs on them.
async function handleFileExtracted(state, result) {
    if (result.outcome.kind === "skipped") {
        // In force mode, an "unchanged" document still needs chunk-and-embed because the
        // orchestrator deleted the vector rows at job-started. Re-shape the skipped outcome
        // as a persisted outcome (the document row exists and is valid) so the standard
        // pipeline runs. Outside force mode, surface the skip as-is.
        const staleChunks = hasStaleChunksForDocument(state.options.store._internal.db, state.capsule.id, result.outcome.document.id);
        if (state.options.force === true || staleChunks) {
            const synthetic = {
                capsuleId: result.capsuleId,
                sourceId: result.sourceId,
                relativePath: result.relativePath,
                outcome: { kind: "persisted", document: result.outcome.document },
                diagnostics: result.diagnostics,
            };
            const handled = await handlePersistedDocument(state, synthetic);
            return handled.events;
        }
        return [handleExtractionSkipped(state, result)];
    }
    if (result.outcome.kind === "failed") {
        return [handleExtractionFailed(state, result)];
    }
    const handled = await handlePersistedDocument(state, result);
    return handled.events;
}
async function handleDiscoveryEvent(state, evt) {
    if (evt.kind === "file-discovered") {
        state.totalDocuments += 1;
        return [
            {
                kind: "document-discovered",
                jobId: state.jobId,
                relativePath: evt.relativePath,
                sizeBytes: evt.sizeBytes,
            },
        ];
    }
    if (evt.kind === "scope-error") {
        state.failedDocuments += 1;
        const err = {
            code: `DISCOVERY_FAILED:${evt.error.code}`,
            message: evt.error.message,
        };
        state.lastError = err;
        const failed = {
            kind: "document-failed",
            jobId: state.jobId,
            ...(evt.error.relativePath !== undefined ? { relativePath: evt.error.relativePath } : {}),
            error: err,
        };
        return [failed];
    }
    if (evt.kind === "cancelled" || evt.kind === "completed") {
        // No-op at this level: the outer loop drives terminal events.
        return [];
    }
    // evt.kind === "file-extracted"
    return handleFileExtracted(state, evt.result);
}
function sourceDiscoveryParams(state, source) {
    return {
        capsuleId: state.capsule.id,
        source,
        ...(state.options.discoveryOptions !== undefined
            ? { discovery: state.options.discoveryOptions }
            : state.options.signal !== undefined
                ? { discovery: { maxDepth: 12, maxFiles: 5_000, signal: state.options.signal } }
                : {}),
    };
}
function observeSourceEvent(progress, evt) {
    if (evt.kind === "file-discovered") {
        progress.discoveredPaths.add(evt.relativePath);
        return;
    }
    if (evt.kind === "scope-error") {
        progress.sawScopeError = true;
        return;
    }
    if (evt.kind === "cancelled") {
        progress.cancelled = true;
        return;
    }
    if (evt.kind === "completed") {
        progress.completed = true;
    }
}
function pruneDeletedSourceDocuments(state, source, discoveredPaths) {
    const persisted = listPersistedDocumentsForSource(state.options.store._internal.db, state.capsule.id, source.id);
    for (const document of persisted) {
        if (discoveredPaths.has(document.document_path))
            continue;
        deleteDocumentRow(state.options.store._internal.db, state.capsule.id, document.id);
    }
}
function finalizeSourceRun(state, source, progress) {
    if (progress.cancelled)
        return;
    if (!progress.completed || progress.sawScopeError)
        return;
    pruneDeletedSourceDocuments(state, source, progress.discoveredPaths);
}
// ─── Capsule resolution + job lifecycle ───────────────────────────────────────
function resolveCapsule(options) {
    const capsule = getCapsule(options.store, options.capsuleId);
    if (capsule === undefined) {
        throw new IndexingError("CAPSULE_NOT_FOUND", `capsule not found: ${String(options.capsuleId)}`);
    }
    return capsule;
}
function buildInitialState(options, capsule, sources, jobId, startedAt) {
    const sourcesById = new Map();
    for (const source of sources)
        sourcesById.set(String(source.id), source);
    return {
        jobId,
        capsule,
        options,
        batchSize: clampBatchSize(options.batchSize),
        concurrency: clampConcurrency(options.concurrency),
        now: options.now ?? options.store._internal.now,
        idSource: options.idSource ?? (() => randomUUID()),
        startedAt,
        sourcesById,
        totalDocuments: 0,
        processedDocuments: 0,
        failedDocuments: 0,
        skippedDocuments: 0,
        vectorsPersisted: 0,
        lastResumeToken: null,
    };
}
function buildResult(state, status, finishedAt) {
    return {
        jobId: state.jobId,
        capsuleId: state.capsule.id,
        status,
        totalDocuments: state.totalDocuments,
        processedDocuments: state.processedDocuments,
        failedDocuments: state.failedDocuments,
        skippedDocuments: state.skippedDocuments,
        vectorsPersisted: state.vectorsPersisted,
        startedAt: state.startedAt,
        finishedAt,
        ...(state.lastError !== undefined ? { lastError: state.lastError } : {}),
        embeddingIdentity: state.capsule.embeddingModelIdentity,
    };
}
async function verifyEmbeddingPreflight(state) {
    try {
        const result = await verifyEmbeddingCapability(state.options.embeddingAdapter, {
            modelId: state.capsule.embeddingModelIdentity.modelId,
            provider: state.capsule.embeddingModelIdentity.provider,
            vectorMetric: state.capsule.embeddingModelIdentity.vectorMetric,
            expectedDimensions: state.capsule.embeddingModelIdentity.vectorDimensions,
            ...(state.options.signal !== undefined ? { signal: state.options.signal } : {}),
        });
        if (result.ok)
            return undefined;
        return {
            code: result.reason === "dimension-mismatch"
                ? "INCOMPATIBLE_EMBEDDING_IDENTITY"
                : "EMBEDDING_ADAPTER_FAILED",
            message: result.safeMessage,
        };
    }
    catch (cause) {
        if (cancellationRequested(state) ||
            (cause instanceof DOMException && cause.name === "AbortError")) {
            return { code: "CANCELLED", message: "indexing aborted via AbortSignal" };
        }
        return {
            code: "EMBEDDING_ADAPTER_FAILED",
            message: cause instanceof Error ? cause.message : String(cause),
        };
    }
}
function persistStartedJob(state, sources) {
    insertJobRow(state.options.store._internal.db, {
        id: state.jobId,
        capsuleId: state.capsule.id,
        sourceIds: sources.map((source) => source.id),
        startedAt: state.startedAt,
    });
    try {
        updateCapsuleState(state.options.store, state.capsule.id, "indexing");
    }
    catch {
        // The capsule state column is informational — failing to flip it must not abort the
        // run. The events stream remains the source of truth.
    }
}
function emitJobStarted(state, sources) {
    const event = {
        kind: "job-started",
        jobId: state.jobId,
        capsuleId: state.capsule.id,
        sourceIds: sources.map((source) => source.id),
        startedAt: state.startedAt,
    };
    state.options.auditSink?.emit({
        kind: "indexing-job-started",
        capsuleId: state.capsule.id,
        jobId: state.jobId,
        occurredAt: state.startedAt,
    });
    return emit(state, event);
}
async function* runSourcesWithProgress(state, sources) {
    let identityFailure;
    for (const source of sources) {
        if (cancellationRequested(state) || identityFailure !== undefined) {
            break;
        }
        identityFailure = yield* iterateSourceEvents(state, source);
        persistJobProgress(state);
    }
    return identityFailure;
}
// ─── Public entrypoint ────────────────────────────────────────────────────────
export async function* runIndexingJob(options) {
    const capsule = resolveCapsule(options);
    const sources = resolveSources(options, capsule);
    const startedAt = (options.now ?? options.store._internal.now)();
    const idSource = options.idSource ?? (() => randomUUID());
    const jobId = idSource();
    const state = buildInitialState(options, capsule, sources, jobId, startedAt);
    persistStartedJob(state, sources);
    yield emitJobStarted(state, sources);
    if (cancellationRequested(state)) {
        yield* finalize(state, undefined);
        return;
    }
    const preflightFailure = await verifyEmbeddingPreflight(state);
    if (cancellationRequested(state)) {
        yield* finalize(state, undefined);
        return;
    }
    if (preflightFailure !== undefined) {
        state.lastError = preflightFailure;
        yield* finalize(state, preflightFailure);
        return;
    }
    // Force mode: tear down ALL vectors for the capsule up front. Per-document teardown
    // still runs in handlePersistedDocument as a defence-in-depth measure.
    if (options.force === true) {
        deleteVectorsForCapsule(state.options.store._internal.db, capsule.id);
    }
    const identityFailure = yield* runSourcesWithProgress(state, sources);
    yield* finalize(state, identityFailure);
}
// Drains one source's event stream, yielding each event to the outer generator.
// Returns the identity-failure error if encountered, undefined otherwise.
async function* iterateSourceEvents(state, source) {
    for await (const evt of runOneSource(state, source)) {
        yield emit(state, evt);
        if (evt.kind === "document-failed" && evt.error.code === "INCOMPATIBLE_EMBEDDING_IDENTITY") {
            return evt.error;
        }
    }
    return undefined;
}
function emit(state, event) {
    emitProgress(state.options, event);
    return event;
}
function resolveJobStatus(state, fatalFailure) {
    if (fatalFailure !== undefined) {
        state.lastError = fatalFailure;
        return "failed";
    }
    if (cancellationRequested(state))
        return "cancelled";
    if (state.failedDocuments > 0 && state.processedDocuments === 0)
        return "failed";
    return "succeeded";
}
function* finalize(state, fatalFailure) {
    const finishedAt = state.now();
    const status = resolveJobStatus(state, fatalFailure);
    finalizeJobRow(state.options.store._internal.db, {
        id: state.jobId,
        status,
        finishedAt,
        counters: buildCounters(state),
        ...(state.lastError !== undefined ? { lastError: state.lastError } : {}),
    });
    try {
        updateCapsuleState(state.options.store, state.capsule.id, status === "succeeded" ? "ready" : "error");
    }
    catch {
        // informational only — see the started block for the rationale
    }
    const result = buildResult(state, status, finishedAt);
    if (status === "cancelled") {
        yield emit(state, { kind: "job-cancelled", jobId: state.jobId, result });
        return;
    }
    if (status === "failed") {
        const err = state.lastError ?? { code: "EMBEDDING_ADAPTER_FAILED", message: "indexing failed" };
        state.options.auditSink?.emit({
            kind: "indexing-job-failed",
            capsuleId: state.capsule.id,
            jobId: state.jobId,
            errorCode: err.code,
            occurredAt: finishedAt,
        });
        yield emit(state, { kind: "job-failed", jobId: state.jobId, error: err, result });
        return;
    }
    state.options.auditSink?.emit({
        kind: "indexing-job-completed",
        capsuleId: state.capsule.id,
        jobId: state.jobId,
        processedDocuments: result.processedDocuments,
        failedDocuments: result.failedDocuments,
        occurredAt: finishedAt,
    });
    yield emit(state, { kind: "job-completed", jobId: state.jobId, result });
}
