/**
 * Asset index sweep (asset-as-embed) — the boot/recovery trigger of the image
 * caption / OCR pass.
 *
 * OV1: this is SEPARATE from the text/FTS reindex. The vector reindex (boot /
 * git-pull / reconcile) only refreshes the FTS+embedding text for changed
 * entries; it deliberately does NOT fire vision calls. This sweep is the one
 * place that drives image captioning, so vision spend is intentional and
 * bounded, never a side effect of a routine reindex.
 *
 * Asset-as-embed: derivation is keyed by the immutable assetId (the assets.db
 * row), NOT an `asset` entity — that type was retired by Phase F. Per indexable
 * IMAGE row (assets.db `media_type === 'image'`, non-SVG):
 *  - PERF1: reads the unfinished set from the LanceDB index-state table (one
 *    query) and skips rows already `full`/`failed` at the current version.
 *  - Migration (Codex CRITICAL): a row with NO state, or a row at a STALE
 *    `index_version`, is RE-derived — undefined is never treated as "done".
 *    `failed` at the current version is terminal (don't re-bill every boot).
 *  - Caption + OCR (bounded fan-out, A3). When captioning is unreachable
 *    (gateway / disabled / non-vision model) the image is left honestly
 *    `pending`, so flipping the gate or model later picks it up.
 *  - SVG is skipped — transitional until sanitize-html is wired.
 *
 * On a successful caption the text is stored in `assets.db.caption` (decision
 * D1 — DB column, NO sidecar; survives deletion of any embedding entry) and the
 * entries that EMBED the asset are re-upserted so the caption folds into their
 * search text (decision D2). The expensive work (vision, byte downloads) runs
 * CONCURRENTLY (bounded); the LanceDB index-state + embedder writes run SERIALLY
 * through `vectorService` (optimistic concurrency — racing mergeInserts conflict).
 */
import { captionImage, canCaption } from './asset-caption-service.js';
import { ASSET_INDEX_VERSION } from './vector-service.js';
import { isCaptionableImage } from './assets/base.js';
import { embeddedAssetIds } from '../core/embeds.js';
/** Run `fn` over `items` with a bounded number of concurrent workers. */
async function mapBounded(items, concurrency, fn) {
    let next = 0;
    const worker = async () => {
        while (true) {
            const i = next++;
            if (i >= items.length)
                return;
            await fn(items[i]);
        }
    };
    await Promise.all(Array.from({ length: Math.min(Math.max(1, concurrency), items.length || 1) }, () => worker()));
}
/**
 * Re-upsert every entry that embeds ANY freshly-captioned asset so the stored
 * captions (already in assets.db.caption) fold into the entries' search text
 * (decision D2). ONE pass per repo for the whole `captioned` batch: scan each
 * entry's embedded assetIds (the SAME `embeddedAssetIds` definition the fold
 * uses — body + frontmatter, no field exclusions) and re-upsert it if it embeds
 * any captioned asset. O(repos × entities) for the batch, not per-asset, and an
 * entry embedding several captioned images is re-upserted once. Best-effort +
 * isolated; the upserts serialize through `vectorService`.
 */
async function reindexEmbedders(deps, captioned, log) {
    const vec = deps.workspaceManager.vectorService;
    if (!vec || captioned.size === 0)
        return;
    for (const repo of deps.repos) {
        let graph;
        try {
            graph = deps.workspaceManager.getGraph(repo.name);
        }
        catch {
            continue;
        }
        let entities;
        try {
            entities = graph.list();
        }
        catch {
            continue;
        }
        for (const e of entities) {
            const ids = embeddedAssetIds(e.document?.content ?? '', e.data);
            if (ids.size === 0)
                continue;
            let embedsCaptioned = false;
            for (const id of ids) {
                if (captioned.has(id)) {
                    embedsCaptioned = true;
                    break;
                }
            }
            if (!embedsCaptioned)
                continue;
            try {
                await vec.upsert(repo.name, e);
            }
            catch (err) {
                log(`reindex embedder ${repo.name}/${e.id} failed: ${err instanceof Error ? err.message : err}`);
            }
        }
    }
}
/**
 * Drive the caption / OCR pass for every image asset that needs it. Safe to
 * call fire-and-forget at boot; resilient to per-asset failure.
 */
export async function sweepAssetIndex(deps, opts = {}) {
    const log = opts.log ?? (() => { });
    const concurrency = opts.concurrency ?? 2;
    const result = { scanned: 0, derived: 0, failed: 0, skippedUpToDate: 0, leftPending: 0 };
    const vec = deps.workspaceManager.vectorService;
    if (!vec)
        return result;
    // A targeted drain with an empty set has no work — skip even the LanceDB read.
    // `undefined` means "consider everything" (boot recovery), NOT an empty set.
    const onlyAssets = opts.onlyAssets;
    if (onlyAssets && onlyAssets.size === 0)
        return result;
    const assetIndex = deps.workspaceManager.getAssetIndex();
    // PERF1: one query for the whole unfinished set, no per-asset re-derive.
    const stateMap = await vec.getAllIndexStates();
    // Collect the work-list from assets.db image rows across the swept repos.
    const items = [];
    for (const repo of deps.repos) {
        let rows;
        try {
            rows = assetIndex.listByRepo(repo.name);
        }
        catch {
            continue;
        }
        for (const row of rows) {
            // Only raster images are captioned (SVG stays transitional). Shared with
            // the live trigger so the drain and boot sweep can't disagree.
            if (!isCaptionableImage(row.mediaType, row.filename))
                continue;
            // Targeted drain: skip assets the caller didn't enqueue.
            if (onlyAssets && !onlyAssets.has(row.assetId))
                continue;
            result.scanned++;
            const state = stateMap.get(row.assetId);
            // Up-to-date = a current-version row that's finished (full) or terminal
            // (failed). Missing row / stale version / pending ⇒ (re-)derive. D1: there
            // is no sidecar to stat for an image — the assetId-keyed state row IS the
            // truth (the caption lives in assets.db.caption).
            const upToDate = !!state
                && state.version >= ASSET_INDEX_VERSION
                && (state.status === 'full' || state.status === 'failed');
            if (upToDate) {
                result.skippedUpToDate++;
                continue;
            }
            items.push({ assetId: row.assetId, repo: repo.name, filename: row.filename, contentType: row.contentType });
        }
    }
    // Respect the optional safety cap.
    const images = opts.maxAssets != null ? items.slice(0, Math.max(0, opts.maxAssets)) : items;
    // Caption reachability gate (per-workspace flag + model capability).
    const captionGate = deps.captioningEnabled
        ? (deps.captionConfig ? canCaption(deps.captionConfig) : { ok: false, reason: 'no-model' })
        : { ok: false, reason: 'disabled' };
    if (!captionGate.ok) {
        // Degrade gracefully (option a): can't caption now. Seed/keep a `pending`
        // row so the image is honestly filename-only; it transitions when the gate
        // flips (config enabled / model switched), no version bump needed.
        if (images.length > 0) {
            log(`captioning skipped (${captionGate.reason}); ${images.length} image(s) left pending (filename-only)`);
            for (const item of images) {
                try {
                    const existing = stateMap.get(item.assetId);
                    if (existing?.status !== 'pending') {
                        await vec.setIndexState(item.assetId, 'pending', { reason: captionGate.reason, repo: item.repo });
                    }
                    result.leftPending++;
                }
                catch { /* best-effort */ }
            }
        }
        log(`[index-sweep] scanned=${result.scanned} derived=0 failed=0 skipped=${result.skippedUpToDate} pending=${result.leftPending}`);
        return result;
    }
    // ── Caption + OCR each image, gated + bounded ──
    const captionConfig = deps.captionConfig;
    const derivations = new Array(images.length);
    await mapBounded(images.map((item, i) => ({ item, i })), concurrency, async ({ item, i }) => {
        try {
            const graph = deps.workspaceManager.getGraph(item.repo);
            const buffer = await graph.downloadAssetById(item.assetId, item.filename);
            const caption = await captionImage(buffer, item.contentType || 'image/png', captionConfig, { completeFn: opts.completeFn });
            derivations[i] = caption.status === 'full' && caption.text
                ? { item, text: caption.text, reason: '' }
                : { item, text: null, reason: caption.reason ?? 'error' };
        }
        catch (err) {
            derivations[i] = { item, text: null, reason: err instanceof Error ? err.message.slice(0, 120) : 'error' };
        }
    });
    // ── Persist serially (one LanceDB writer at a time) ──
    const captioned = new Set(); // assetIds whose caption landed this sweep
    for (const d of derivations) {
        if (!d)
            continue;
        const ref = `${d.item.repo}/${d.item.assetId}`;
        if (!d.text) {
            // The caption produced no text → honest `failed` (filename-only).
            try {
                await vec.setIndexState(d.item.assetId, 'failed', { reason: d.reason || 'error', repo: d.item.repo });
            }
            catch (err) {
                log(`persist(failed-state) failed for ${ref}: ${err instanceof Error ? err.message : err}`);
            }
            result.failed++;
            continue;
        }
        // We have caption text. Store it in assets.db FIRST, then stamp `full`.
        // Splitting the two means a status-stamp hiccup can't mislabel an
        // already-stored caption as `failed`. The embedder re-index runs once for
        // the whole batch AFTER this loop (so every caption is in place first).
        try {
            assetIndex.setCaption(d.item.assetId, d.text);
        }
        catch (err) {
            // A TRANSIENT infra error (sqlite hiccup) — NOT "this image can't be
            // captioned." The caption text is in hand; only persisting it failed.
            // Leave it `pending` so the next sweep retries. Counted for observability.
            log(`persist(caption) failed for ${ref} (transient; will retry next sweep): ${err instanceof Error ? err.message : err}`);
            try {
                await vec.setIndexState(d.item.assetId, 'pending', { reason: 'caption-write-retry', repo: d.item.repo });
            }
            catch { /* best-effort */ }
            result.failed++;
            continue;
        }
        // Caption is stored. Stamp `full`; if the stamp alone fails the caption is
        // still in assets.db — count it as derived and accept a rare re-derive next
        // boot rather than mislabeling a stored caption `failed`.
        try {
            await vec.setIndexState(d.item.assetId, 'full', { repo: d.item.repo });
        }
        catch (err) {
            log(`persist(full-state) failed for ${ref} (caption is stored; will re-stamp next sweep): ${err instanceof Error ? err.message : err}`);
        }
        captioned.add(d.item.assetId);
        result.derived++;
    }
    // Fold the freshly-stored captions into the entries that embed them — one pass
    // per repo for the whole batch. Done after the persist loop so every caption
    // is in assets.db before any embedder is re-indexed.
    await reindexEmbedders(deps, captioned, log);
    log(`[index-sweep] scanned=${result.scanned} derived=${result.derived} failed=${result.failed} skipped=${result.skippedUpToDate} pending=${result.leftPending}`);
    return result;
}
//# sourceMappingURL=asset-index-sweep.js.map