/**
 * Image recovery for the data-processing pipeline.
 *
 * Loads sidecar `.image-archive.jsonl` files to build an archive-ID → URL
 * index, then downloads original images from the archive to restore
 * placeholder PNGs back to full base64 data.
 *
 * Downloads use `tosutil` CLI for fast batch-parallel transfers.  The caller
 * must invoke {@link prefetchImages} once before processing records so that
 * all archived images are available locally.
 */
import { createHash } from "node:crypto";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import readline from "node:readline";
// ---------------------------------------------------------------------------
// tosutil-based image cache
// ---------------------------------------------------------------------------
const DEFAULT_TOSUTIL_PATH = "/Users/zhlmmc/Workspaces/data-collector/tosutil";
const DEFAULT_BUCKET = "visionclaw-images";
/**
 * Extract the object key from an archive URL.
 * e.g. "https://visionclaw-images.tos-cn-beijing.volces.com/images/2026-03-21/0caa3911/img_xxx.png"
 *   → "images/2026-03-21/0caa3911/img_xxx.png"
 */
function extractKeyFromUrl(url) {
    try {
        const u = new URL(url);
        return u.pathname.startsWith("/") ? u.pathname.slice(1) : u.pathname;
    }
    catch {
        return null;
    }
}
/**
 * Collect the unique TOS key prefixes that need to be downloaded.
 * Groups by the directory portion of the key (e.g.
 * `images/2026-03-21/0caa3911/`) so we can batch-download per prefix.
 */
function collectPrefixes(sidecarIndex) {
    const prefixes = new Set();
    for (const entry of sidecarIndex.values()) {
        const key = extractKeyFromUrl(entry.url);
        if (key) {
            const dir = key.substring(0, key.lastIndexOf("/") + 1);
            if (dir)
                prefixes.add(dir);
        }
    }
    return prefixes;
}
/**
 * Batch-download all archived images to a local cache directory using
 * `tosutil cp -r`.  Returns an {@link ImageCache} handle that
 * {@link recoverImagesInContent} uses to read images from disk.
 */
export function prefetchImages(sidecarIndex, cacheDir, imagesDir, tosutilPath = DEFAULT_TOSUTIL_PATH, bucket = DEFAULT_BUCKET) {
    fs.mkdirSync(cacheDir, { recursive: true });
    fs.mkdirSync(imagesDir, { recursive: true });
    const prefixes = collectPrefixes(sidecarIndex);
    console.log(`  Downloading ${sidecarIndex.size} images across ${prefixes.size} prefixes…`);
    for (const prefix of prefixes) {
        const src = `tos://${bucket}/${prefix}`;
        const dst = path.join(cacheDir, prefix);
        fs.mkdirSync(dst, { recursive: true });
        try {
            execFileSync(tosutilPath, [
                "cp", src, dst, "-r", "-f", "-j=20", "-flat",
            ], { stdio: "pipe", timeout: 120_000 });
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            console.warn(`  Warning: tosutil failed for prefix ${prefix}: ${msg}`);
        }
    }
    return { cacheDir, imagesDir, written: new Set() };
}
/**
 * Resolve the local path for a cached image, or null if it doesn't exist.
 */
function resolveCachedPath(url, cache) {
    const key = extractKeyFromUrl(url);
    if (!key)
        return null;
    const filename = key.substring(key.lastIndexOf("/") + 1);
    const dir = key.substring(0, key.lastIndexOf("/") + 1);
    const localPath = path.join(cache.cacheDir, dir, filename);
    return fs.existsSync(localPath) ? localPath : null;
}
/**
 * Derive the output filename from an archive ID.
 * The archive ID already has the form `img_<ts>_<hash>`, so just append
 * the extension from the media type.
 */
function outputFilename(archiveId, mediaType) {
    const ext = mediaType === "image/jpeg" ? ".jpg"
        : mediaType === "image/webp" ? ".webp"
            : ".png";
    return `${archiveId}${ext}`;
}
/**
 * Copy a cached image to the output images directory (if not already copied)
 * and return the relative path (e.g. `images/img_xxx.png`).
 */
function copyToImagesDir(archiveId, cachedPath, mediaType, cache) {
    const filename = outputFilename(archiveId, mediaType);
    if (!cache.written.has(archiveId)) {
        fs.copyFileSync(cachedPath, path.join(cache.imagesDir, filename));
        cache.written.add(archiveId);
    }
    return `images/${filename}`;
}
/**
 * Save a raw (non-archived) base64 image to the output images directory.
 * Uses a content hash for the filename to deduplicate identical images.
 */
function externalizeBase64Image(base64Data, mediaType, cache) {
    const hash = createHash("sha256").update(base64Data).digest("hex").slice(0, 12);
    const ext = mediaType === "image/jpeg" ? ".jpg"
        : mediaType === "image/webp" ? ".webp"
            : ".png";
    const filename = `raw_${hash}${ext}`;
    if (!cache.written.has(filename)) {
        fs.writeFileSync(path.join(cache.imagesDir, filename), Buffer.from(base64Data, "base64"));
        cache.written.add(filename);
    }
    return `images/${filename}`;
}
// ---------------------------------------------------------------------------
// Text placeholder extraction (matches new text-based placeholders from #328)
// ---------------------------------------------------------------------------
const TEXT_PLACEHOLDER_PREFIX = "[Screenshot: archived=";
function extractArchiveIdFromTextPlaceholder(text) {
    if (!text.startsWith(TEXT_PLACEHOLDER_PREFIX))
        return null;
    if (!text.endsWith("]"))
        return null;
    return text.slice(TEXT_PLACEHOLDER_PREFIX.length, -1) || null;
}
// ---------------------------------------------------------------------------
// PNG tEXt chunk extraction (duplicated from image-archive.ts to keep
// data-processing self-contained)
// ---------------------------------------------------------------------------
const TEXT_CHUNK_KEYWORD = "vc-archive-id";
function extractArchiveIdFromPng(pngBase64) {
    let buf;
    try {
        buf = Buffer.from(pngBase64, "base64");
    }
    catch {
        return null;
    }
    // PNG signature is 8 bytes, then chunks follow.
    let offset = 8;
    while (offset + 8 <= buf.length) {
        const chunkLen = buf.readUInt32BE(offset);
        const chunkType = buf.subarray(offset + 4, offset + 8).toString("ascii");
        if (chunkType === "tEXt") {
            const chunkData = buf.subarray(offset + 8, offset + 8 + chunkLen);
            const nullIdx = chunkData.indexOf(0);
            if (nullIdx >= 0) {
                const keyword = chunkData.subarray(0, nullIdx).toString("ascii");
                if (keyword === TEXT_CHUNK_KEYWORD) {
                    return chunkData.subarray(nullIdx + 1).toString("ascii");
                }
            }
        }
        offset += 12 + chunkLen;
    }
    return null;
}
// ---------------------------------------------------------------------------
// Sidecar index: archive ID → ArchiveEntry
// ---------------------------------------------------------------------------
export async function loadSidecarIndex(transcriptDir) {
    const index = new Map();
    const sidecarFiles = loadOrDiscoverSidecarFiles(transcriptDir);
    for (const filePath of sidecarFiles) {
        const rl = readline.createInterface({
            input: fs.createReadStream(filePath, { encoding: "utf-8" }),
            crlfDelay: Infinity,
        });
        for await (const raw of rl) {
            const trimmed = raw.trim();
            if (!trimmed)
                continue;
            try {
                const record = JSON.parse(trimmed);
                index.set(record.archiveId, {
                    url: record.url,
                    mediaType: record.mediaType,
                });
            }
            catch {
                // Skip malformed lines
            }
        }
    }
    return index;
}
function loadOrDiscoverSidecarFiles(transcriptDir) {
    const cachePath = path.join(transcriptDir, ".sidecar-search-cache.json");
    const cached = readSidecarSearchCache(cachePath);
    if (cached && cached.sidecarFiles.length > 0) {
        // Filter out stale entries if files were removed after cache creation.
        return cached.sidecarFiles.filter((filePath) => fs.existsSync(filePath));
    }
    const sidecarFiles = [];
    function collectSidecars(dir) {
        for (const name of fs.readdirSync(dir)) {
            const full = path.join(dir, name);
            const stat = fs.statSync(full);
            if (stat.isDirectory()) {
                collectSidecars(full);
            }
            else if (/\.image-archive(\.\d{4}-\d{2}-\d{2})?\.jsonl$/.test(name)) {
                sidecarFiles.push(full);
            }
        }
    }
    collectSidecars(transcriptDir);
    writeSidecarSearchCache(cachePath, sidecarFiles);
    return sidecarFiles;
}
function readSidecarSearchCache(cachePath) {
    try {
        if (!fs.existsSync(cachePath))
            return null;
        const raw = fs.readFileSync(cachePath, "utf-8");
        const parsed = JSON.parse(raw);
        if (typeof parsed !== "object" || parsed === null)
            return null;
        const sidecarFiles = parsed.sidecarFiles;
        if (!Array.isArray(sidecarFiles))
            return null;
        return {
            sidecarFiles: sidecarFiles.filter((entry) => typeof entry === "string"),
        };
    }
    catch {
        return null;
    }
}
function writeSidecarSearchCache(cachePath, sidecarFiles) {
    try {
        const payload = { sidecarFiles };
        fs.writeFileSync(cachePath, `${JSON.stringify(payload, null, 2)}\n`, "utf-8");
    }
    catch {
        // Best-effort cache write: processing should still succeed without cache.
    }
}
export function emptyRecoveryStats() {
    return { totalPlaceholders: 0, recovered: 0, failed: 0, noSidecarEntry: 0 };
}
export function mergeRecoveryStats(a, b) {
    return {
        totalPlaceholders: a.totalPlaceholders + b.totalPlaceholders,
        recovered: a.recovered + b.recovered,
        failed: a.failed + b.failed,
        noSidecarEntry: a.noSidecarEntry + b.noSidecarEntry,
    };
}
// ---------------------------------------------------------------------------
// Recover images in SDK content arrays
//
// Walks content blocks looking for image blocks with archive-id placeholders.
// When found, copies the original from the cache to the output images
// directory (deduplicated) and emits a file reference.
// ---------------------------------------------------------------------------
export async function recoverImagesInContent(content, sidecarIndex, stats, cache) {
    if (!Array.isArray(content))
        return content;
    const out = [];
    for (const block of content) {
        if (block !== null && typeof block === "object") {
            const b = block;
            // Handle text placeholders with embedded archive IDs (new format from #328)
            if (b.type === "text" && typeof b.text === "string") {
                const archiveId = extractArchiveIdFromTextPlaceholder(b.text);
                if (archiveId) {
                    stats.totalPlaceholders++;
                    const entry = sidecarIndex.get(archiveId);
                    if (entry) {
                        const cachedPath = cache
                            ? resolveCachedPath(entry.url, cache)
                            : null;
                        if (cachedPath && cache) {
                            const relPath = copyToImagesDir(archiveId, cachedPath, entry.mediaType, cache);
                            out.push({
                                type: "image",
                                source: {
                                    type: "file",
                                    path: relPath,
                                    media_type: entry.mediaType,
                                },
                            });
                            stats.recovered++;
                        }
                        else {
                            out.push({
                                type: "image",
                                source: {
                                    type: "url",
                                    url: entry.url,
                                },
                                _archive_id: archiveId,
                                _recovery: "download_failed",
                            });
                            stats.failed++;
                        }
                    }
                    else {
                        out.push({
                            ...b,
                            _archive_id: archiveId,
                            _recovery: "no_sidecar_entry",
                        });
                        stats.noSidecarEntry++;
                    }
                    continue;
                }
            }
            // Handle legacy PNG placeholders with embedded archive IDs (old format)
            if (b.type === "image") {
                const source = b.source;
                if (source?.type === "base64" && typeof source.data === "string") {
                    const archiveId = extractArchiveIdFromPng(source.data);
                    if (archiveId) {
                        stats.totalPlaceholders++;
                        const entry = sidecarIndex.get(archiveId);
                        if (entry) {
                            const cachedPath = cache
                                ? resolveCachedPath(entry.url, cache)
                                : null;
                            if (cachedPath && cache) {
                                const relPath = copyToImagesDir(archiveId, cachedPath, entry.mediaType, cache);
                                out.push({
                                    type: "image",
                                    source: {
                                        type: "file",
                                        path: relPath,
                                        media_type: entry.mediaType,
                                    },
                                });
                                stats.recovered++;
                            }
                            else {
                                out.push({
                                    type: "image",
                                    source: {
                                        type: "url",
                                        url: entry.url,
                                    },
                                    _archive_id: archiveId,
                                    _recovery: "download_failed",
                                });
                                stats.failed++;
                            }
                        }
                        else {
                            if (cache) {
                                const mt = (typeof source.media_type === "string" ? source.media_type : "image/png");
                                const relPath = externalizeBase64Image(source.data, mt, cache);
                                out.push({
                                    type: "image",
                                    source: { type: "file", path: relPath, media_type: mt },
                                    _archive_id: archiveId,
                                    _recovery: "no_sidecar_entry",
                                });
                            }
                            else {
                                out.push({
                                    ...b,
                                    _archive_id: archiveId,
                                    _recovery: "no_sidecar_entry",
                                });
                            }
                            stats.noSidecarEntry++;
                        }
                        continue;
                    }
                    // Non-archived base64 image — save to images dir
                    if (cache) {
                        const mt = (typeof source.media_type === "string" ? source.media_type : "image/png");
                        const relPath = externalizeBase64Image(source.data, mt, cache);
                        out.push({
                            type: "image",
                            source: { type: "file", path: relPath, media_type: mt },
                        });
                        continue;
                    }
                }
            }
            if (b.type === "tool_result" && Array.isArray(b.content)) {
                out.push({
                    ...b,
                    content: await recoverImagesInContent(b.content, sidecarIndex, stats, cache),
                });
                continue;
            }
        }
        out.push(block);
    }
    return out;
}
//# sourceMappingURL=image-recovery.js.map