/**
 * Asset caption + OCR service (P3b — indexing pillar).
 *
 * Turns an image asset's bytes into searchable text: a concise factual
 * description plus an OCR transcription of any legible text. The sweep
 * (asset-index-sweep.ts) stores that text in `assets.db.caption`, keyed by the
 * immutable assetId (asset-as-embed, decision D1 — DB column, no sidecar), and
 * folds it into the search text of every entry that embeds the image, so a
 * captioned image becomes findable by its content, not just its filename.
 *
 * This module owns ONLY the model call + the bounded fan-out. It does NOT touch
 * disk, git, or LanceDB — the sweep wires those so the service stays a pure,
 * unit-testable transform over `(bytes) → text`.
 *
 * Guardrails (decision A3 + Codex):
 *  - **Bounded concurrency** (default 2): vision is not a free background
 *    service; a bulk import / first-boot migration must not fan out unbounded.
 *  - **Per-call timeout**: a hung provider call can't wedge the pool.
 *  - **Retry cap → permanent `failed`**: transient errors retry up to a cap,
 *    then the asset is honestly marked filename-only.
 *  - **Failure isolation**: one image's failure never aborts the batch.
 *  - **Gateway degrade (option a)**: `completeSimple()` cannot reach the
 *    Studiograph Credits gateway (it routes through its own client), so in
 *    gateway mode captioning is skipped and the image stays honestly
 *    filename-only — NOT routed through the gateway here.
 */
import { getModel, completeSimple } from '@mariozechner/pi-ai';
import { isStudiographCreditsMode } from './cloud-inference-billing.js';
import { supportsVision } from './model-capabilities.js';
import { bufferToImageContent } from './scratch-attachment-service.js';
const DEFAULT_TIMEOUT_MS = 60_000;
const DEFAULT_MAX_ATTEMPTS = 2;
const DEFAULT_MAX_TOKENS = 1024;
/**
 * Bytes above which we skip captioning. Base64-encoding inflates the buffer
 * ~33% into the request body, and most vision providers reject large payloads
 * (413) or time out — both of which would otherwise burn the retry cap and
 * mark a perfectly valid image permanently `failed`. The doc extractor has the
 * analogous `SIDECAR_EXTRACT_BYTES_CAP` guard; this is its image-path peer.
 * An oversized image stays honestly filename-only.
 */
const DEFAULT_CAPTION_MAX_BYTES = 8 * 1024 * 1024;
/**
 * Image MIME types vision models actually accept. The presign allowlist was
 * widened (P4) to png/jpeg/gif/webp/avif/heic, but Anthropic + most providers
 * only decode png/jpeg/gif/webp; shipping image/avif or image/heic returns a
 * 400 that would burn the retry cap and land terminal `failed` for a
 * perfectly real image. Skip those up-front so they read honestly as
 * filename-only (reason `unsupported-mime`) instead of a wasted vision call.
 * Note `application/octet-stream` is intentionally absent — an asset whose
 * mime wasn't classified shouldn't be guessed as captionable.
 */
const CAPTIONABLE_MIME = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
/** Bounded caption fan-out — vision is metered, keep it small (A3). */
export const DEFAULT_CAPTION_CONCURRENCY = 2;
const CAPTION_SYSTEM_PROMPT = 'You index images for full-text search. Given one image, output plain text ' +
    'with two parts and nothing else:\n' +
    '1. A concise, factual description (subjects, setting, document/diagram/chart ' +
    'type, notable colors). One short paragraph.\n' +
    '2. On a new line starting with "OCR:", transcribe ALL legible text in the ' +
    'image verbatim. If there is no text, write "OCR: (none)".\n' +
    'No preamble, no markdown, no commentary. Keep it under ~250 words.';
const CAPTION_USER_PROMPT = 'Describe this image and transcribe any text it contains, following the ' +
    'system instructions exactly.';
/**
 * Is captioning reachable for this model config right now? Separate from
 * `supportsVision` because the gateway IS vision-capable yet unreachable via
 * `completeSimple` (decision A6 + the gateway-degrade note).
 */
export function canCaption(config) {
    if (isStudiographCreditsMode())
        return { ok: false, reason: 'gateway' };
    if (!config.apiKey)
        return { ok: false, reason: 'no-api-key' };
    if (!supportsVision(config.provider, config.modelId))
        return { ok: false, reason: 'no-vision' };
    return { ok: true };
}
/** Reject a promise that outruns `ms`. */
function withTimeout(p, ms) {
    return new Promise((resolve, reject) => {
        const timer = setTimeout(() => reject(new Error(`caption call timed out after ${ms}ms`)), ms);
        p.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
    });
}
/**
 * Caption + OCR a single image. Gated by `canCaption`; never throws — a
 * permanent failure resolves to `{ status: 'failed', reason }` so the caller
 * can record honest index state.
 */
export async function captionImage(buffer, mimeType, config, opts = {}) {
    const gate = canCaption(config);
    if (!gate.ok)
        return { status: 'failed', reason: gate.reason };
    // The model can only decode a subset of the (widened) presign image set.
    // Skip avif/heic/etc. before spending a vision call that would 400.
    if (!CAPTIONABLE_MIME.has(mimeType.toLowerCase())) {
        return { status: 'failed', reason: 'unsupported-mime' };
    }
    const maxBytes = opts.maxBytes ?? DEFAULT_CAPTION_MAX_BYTES;
    if (buffer.length > maxBytes) {
        // Too large to ship to the vision model — skip rather than 413/timeout into
        // a permanent failure. `oversize` reads honestly as filename-only.
        return { status: 'failed', reason: 'oversize' };
    }
    const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
    const maxAttempts = Math.max(1, opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS);
    const maxTokens = opts.maxTokens ?? DEFAULT_MAX_TOKENS;
    const complete = opts.completeFn ?? completeSimple;
    // Resolve a pi-ai model. Unknown-to-pi-ai models still pass canCaption via
    // the capability map; completeSimple would then reject them, which surfaces
    // as a (correct) failure below. A best-effort placeholder keeps the call
    // shape intact and lets an injected completeFn ignore it.
    let model;
    try {
        model = getModel(config.provider, config.modelId);
    }
    catch {
        model = { provider: config.provider, id: config.modelId };
    }
    const context = {
        systemPrompt: CAPTION_SYSTEM_PROMPT,
        messages: [
            {
                role: 'user',
                content: [
                    bufferToImageContent(buffer, mimeType),
                    { type: 'text', text: CAPTION_USER_PROMPT },
                ],
                timestamp: Date.now(),
            },
        ],
    };
    let lastReason = 'error';
    for (let attempt = 0; attempt < maxAttempts; attempt++) {
        try {
            const result = await withTimeout(complete(model, context, { apiKey: config.apiKey, maxTokens }), timeoutMs);
            if (result.stopReason === 'error') {
                lastReason = 'error';
                continue;
            }
            const text = (result.content ?? [])
                .filter((c) => c.type === 'text')
                .map((c) => c.text ?? '')
                .join('')
                .trim();
            if (text)
                return { status: 'full', text };
            lastReason = 'empty';
        }
        catch (err) {
            lastReason = err instanceof Error && /timed out/.test(err.message) ? 'timeout' : 'error';
        }
    }
    return { status: 'failed', reason: lastReason };
}
/**
 * Caption a batch of images with bounded concurrency and full failure
 * isolation — a single image throwing or timing out never sinks the batch.
 * Results come back in input order, each tagged with its job key.
 */
export async function captionImages(jobs, config, opts = {}) {
    const concurrency = Math.max(1, opts.concurrency ?? DEFAULT_CAPTION_CONCURRENCY);
    const results = new Array(jobs.length);
    let next = 0;
    async function worker() {
        while (true) {
            const i = next++;
            if (i >= jobs.length)
                return;
            const job = jobs[i];
            try {
                const r = await captionImage(job.buffer, job.mimeType, config, opts);
                results[i] = { key: job.key, ...r };
            }
            catch {
                // captionImage is designed not to throw, but isolate regardless.
                results[i] = { key: job.key, status: 'failed', reason: 'error' };
            }
        }
    }
    const workers = Array.from({ length: Math.min(concurrency, jobs.length) }, () => worker());
    await Promise.all(workers);
    return results;
}
//# sourceMappingURL=asset-caption-service.js.map