/**
 * Image archive module: upload pruned images to S3 or TOS (Volcengine Object
 * Storage) before they are replaced with placeholders in the JSONL transcript.
 *
 * Each archived image gets a unique placeholder PNG with the archive ID
 * embedded in a PNG tEXt metadata chunk, enabling precise restoration without
 * relying on positional assumptions.
 *
 * Archive records are appended to a sidecar JSONL file alongside the
 * transcript, keeping the transcript structure untouched.
 *
 * Configuration lives in `config.json` under the `imageArchive` key.
 * When absent, archiving is disabled and the pruner falls back to the
 * standard static placeholder.
 */
import { createHash } from "crypto";
import { createRequire } from "module";
import zlib from "zlib";
import fs from "fs";
import path from "path";
import { loadConfig, getFailedUploadsDir } from "../config/index.js";
import { resolveTosArchiveConfig } from "../tos-credentials.js";
import { logger } from "../logger.js";
function buildArchiveConfig(cfg) {
    const ia = cfg.imageArchive;
    if (!ia) {
        // No per-agent imageArchive config — try shared TOS credentials from heartbeat
        const tosConfig = resolveTosArchiveConfig(cfg);
        return tosConfig ?? null;
    }
    if (ia.provider === "tos") {
        const tosConfig = resolveTosArchiveConfig(cfg);
        if (!tosConfig) {
            logger.warn("[image-archive] TOS provider requires TOS credentials from config or onboarding heartbeat");
            return null;
        }
        return {
            provider: "tos",
            bucket: ia.bucket,
            region: ia.tosRegion,
            endpoint: ia.tosEndpoint,
            accessKeyId: tosConfig.accessKeyId,
            accessKeySecret: tosConfig.accessKeySecret,
        };
    }
    // provider === "s3"
    if (!ia.s3AccessKeyId || !ia.s3SecretAccessKey) {
        logger.warn("[image-archive] S3 provider requires s3AccessKeyId and s3SecretAccessKey in config");
        return null;
    }
    return {
        provider: "s3",
        bucket: ia.bucket,
        region: ia.s3Region,
        accessKeyId: ia.s3AccessKeyId,
        secretAccessKey: ia.s3SecretAccessKey,
    };
}
let _config = undefined;
export function resetArchiveConfigCache() {
    _config = undefined;
}
function getConfig() {
    if (_config === undefined) {
        try {
            _config = buildArchiveConfig(loadConfig());
        }
        catch {
            _config = null;
        }
    }
    return _config;
}
/** Returns true when image archiving is configured and ready. */
export function isArchiveEnabled() {
    return getConfig() !== null;
}
// ---------------------------------------------------------------------------
// PNG tEXt chunk helpers
// ---------------------------------------------------------------------------
const TEXT_CHUNK_KEYWORD = "vc-archive-id";
/**
 * Compute CRC-32 used by PNG chunks. Node.js 22+ exposes `zlib.crc32`; for
 * older runtimes we fall back to a manual table-driven implementation, but
 * this project requires Node >= 24.12.0 so the native path is always taken.
 */
function pngCrc32(data) {
    // zlib.crc32 returns an unsigned 32-bit integer.
    return zlib.crc32(data);
}
/** The canonical 1x1 transparent PNG without any tEXt chunk. */
const BASE_PNG_BYTES = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==", "base64");
/**
 * Generate a 1x1 transparent PNG with the archive ID embedded in a tEXt
 * metadata chunk.  The resulting PNG is ~100 bytes — negligible overhead.
 */
export function generatePlaceholderPng(archiveId) {
    // PNG = signature + chunks.  We insert a tEXt chunk right before IEND.
    // IEND is always the last 12 bytes of a valid PNG.
    const iendOffset = BASE_PNG_BYTES.length - 12;
    // tEXt data: keyword (null-terminated ASCII) + text value
    const keywordBuf = Buffer.from(TEXT_CHUNK_KEYWORD + "\0", "ascii");
    const valueBuf = Buffer.from(archiveId, "ascii");
    const textData = Buffer.concat([keywordBuf, valueBuf]);
    // Chunk = length(4) + type(4) + data(N) + crc(4)
    const typeBuf = Buffer.from("tEXt", "ascii");
    const lengthBuf = Buffer.alloc(4);
    lengthBuf.writeUInt32BE(textData.length, 0);
    const crcInput = Buffer.concat([typeBuf, textData]);
    const crcVal = pngCrc32(crcInput);
    const crcBuf = Buffer.alloc(4);
    crcBuf.writeUInt32BE(crcVal >>> 0, 0);
    const textChunk = Buffer.concat([lengthBuf, typeBuf, textData, crcBuf]);
    // Assemble: [everything before IEND] + [tEXt chunk] + [IEND]
    const beforeIend = BASE_PNG_BYTES.subarray(0, iendOffset);
    const iendChunk = BASE_PNG_BYTES.subarray(iendOffset);
    const png = Buffer.concat([beforeIend, textChunk, iendChunk]);
    return png.toString("base64");
}
/**
 * Extract the `vc-archive-id` value from a placeholder PNG's tEXt chunk.
 * Returns null if the PNG doesn't contain our marker.
 */
export function extractArchiveId(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");
                }
            }
        }
        // Skip to next chunk: length(4) + type(4) + data(chunkLen) + crc(4)
        offset += 12 + chunkLen;
    }
    return null;
}
// ---------------------------------------------------------------------------
// Object key / URL helpers
// ---------------------------------------------------------------------------
function mediaTypeToExtension(mediaType) {
    if (mediaType.includes("jpeg") || mediaType.includes("jpg"))
        return ".jpg";
    if (mediaType.includes("gif"))
        return ".gif";
    if (mediaType.includes("webp"))
        return ".webp";
    return ".png";
}
function buildKey(date, sessionId, archiveId, mediaType) {
    const ext = mediaTypeToExtension(mediaType);
    return `images/${date}/${sessionId}/${archiveId}${ext}`;
}
function buildUrl(config, key) {
    if (config.provider === "tos") {
        return `https://${config.bucket}.${config.endpoint}/${key}`;
    }
    return `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
}
// TODO: Persist original destination metadata (provider/bucket/region/endpoint)
// with failed uploads if archive config can change over time. For now we assume
// retries run against a stable archive config.
/**
 * Save the image buffer to the failed-uploads directory so it can be retried
 * later.  The file name is the archive ID; a `.meta.json` sidecar stores the
 * object key, content-type and other metadata needed for a future upload.
 *
 * Uses synchronous I/O since this runs on the error path and must not lose
 * data.  The failed-uploads directory is small (cleared on success) so
 * blocking for a few ms is acceptable.
 */
function saveToLocalFallback(buffer, contentType, key, archiveId) {
    try {
        const dir = getFailedUploadsDir();
        fs.mkdirSync(dir, { recursive: true });
        const ext = mediaTypeToExtension(contentType);
        const dataPath = path.join(dir, `${archiveId}${ext}`);
        const metaPath = path.join(dir, `${archiveId}.meta.json`);
        fs.writeFileSync(dataPath, buffer);
        const meta = {
            key,
            contentType,
            archiveId,
            savedAt: new Date().toISOString(),
        };
        fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2), "utf-8");
        logger.warn(`[image-archive] saved ${archiveId} locally to ${dataPath} for retry`);
    }
    catch (saveErr) {
        logger.warn(`[image-archive] local fallback save also failed for ${archiveId}: ${saveErr instanceof Error ? saveErr.message : String(saveErr)}`);
    }
}
/** Guard: true while a retry drain is already running. */
let _drainInProgress = false;
/**
 * Attempt to re-upload locally-saved images from previous failed uploads.
 * Runs in the background after a successful upload — if the network is
 * healthy enough for one upload to succeed, pending ones are likely to
 * succeed too.
 *
 * Each successfully re-uploaded image has its local files removed.
 */
async function drainFailedUploads(config) {
    if (_drainInProgress)
        return;
    _drainInProgress = true;
    try {
        const dir = getFailedUploadsDir();
        if (!fs.existsSync(dir))
            return;
        const metaFiles = fs.readdirSync(dir).filter((f) => f.endsWith(".meta.json"));
        if (metaFiles.length === 0)
            return;
        logger.debug(`[image-archive] draining ${metaFiles.length} failed upload(s)…`);
        for (const metaFile of metaFiles) {
            const metaPath = path.join(dir, metaFile);
            let meta;
            try {
                meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
            }
            catch {
                // Corrupted meta file — remove it
                try {
                    fs.unlinkSync(metaPath);
                }
                catch { /* best-effort */ }
                continue;
            }
            const ext = mediaTypeToExtension(meta.contentType);
            const dataPath = path.join(dir, `${meta.archiveId}${ext}`);
            if (!fs.existsSync(dataPath)) {
                // Data file missing — remove orphaned meta
                try {
                    fs.unlinkSync(metaPath);
                }
                catch { /* best-effort */ }
                continue;
            }
            try {
                const buffer = fs.readFileSync(dataPath);
                const upload = config.provider === "tos"
                    ? uploadToTos(buffer, meta.contentType, meta.key, config)
                    : uploadToS3(buffer, meta.contentType, meta.key, config);
                await upload;
                // Success — clean up local files
                try {
                    fs.unlinkSync(dataPath);
                }
                catch { /* best-effort */ }
                try {
                    fs.unlinkSync(metaPath);
                }
                catch { /* best-effort */ }
                logger.debug(`[image-archive] retry succeeded for ${meta.archiveId}`);
            }
            catch (err) {
                // Still failing — leave files in place for next drain attempt
                logger.debug(`[image-archive] retry still failing for ${meta.archiveId}: ${err instanceof Error ? err.message : String(err)}`);
                // Don't retry the rest — if one fails, the endpoint is likely still down
                break;
            }
        }
    }
    catch (err) {
        logger.debug(`[image-archive] drain failed: ${err instanceof Error ? err.message : String(err)}`);
    }
    finally {
        _drainInProgress = false;
    }
}
/** Exported for testing: returns the number of pending failed uploads. */
export function countPendingFailedUploads() {
    try {
        const dir = getFailedUploadsDir();
        if (!fs.existsSync(dir))
            return 0;
        return fs.readdirSync(dir).filter((f) => f.endsWith(".meta.json")).length;
    }
    catch {
        return 0;
    }
}
// ---------------------------------------------------------------------------
// Upload
// ---------------------------------------------------------------------------
/**
 * Prepare an archive entry and start a background upload.
 *
 * Returns the archive result (including the custom placeholder) immediately
 * without waiting for the upload.  The upload runs fire-and-forget in the
 * background:
 *
 * - On **success**: the uploaded archive ID is logged and any previously
 *   failed uploads in the local fallback directory are drained.
 * - On **failure**: the original image bytes are saved to
 *   `<configDir>/failed-uploads/` alongside a `.meta.json` sidecar so the
 *   image can be retried on the next successful upload.
 *
 * Returns null only when archiving is not configured.
 */
export function archiveImage(base64Data, mediaType, sessionId) {
    const config = getConfig();
    if (!config)
        return null;
    const now = new Date();
    const date = now.toISOString().slice(0, 10);
    const sha256Full = createHash("sha256").update(base64Data).digest("hex");
    const sha8 = sha256Full.slice(0, 8);
    const archiveId = `img_${now.getTime()}_${sha8}`;
    const key = buildKey(date, sessionId, archiveId, mediaType);
    const url = buildUrl(config, key);
    const archivedAt = now.toISOString();
    const originalSizeBytes = Math.ceil(base64Data.length * 3 / 4);
    // Fire-and-forget: start upload in background.
    const buffer = Buffer.from(base64Data, "base64");
    const upload = config.provider === "tos"
        ? uploadToTos(buffer, mediaType, key, config)
        : uploadToS3(buffer, mediaType, key, config);
    upload
        .then(() => {
        logger.debug(`[image-archive] uploaded ${archiveId} (${config.provider})`);
        // Network is healthy — try to drain any previously failed uploads.
        drainFailedUploads(config).catch((_err) => { });
    })
        .catch((err) => {
        logger.warn(`[image-archive] upload failed for ${archiveId}: ${err instanceof Error ? err.message : String(err)}`);
        // Save locally so the image is not lost.
        saveToLocalFallback(buffer, mediaType, key, archiveId);
    });
    return {
        archiveId,
        url,
        placeholderBase64: generatePlaceholderPng(archiveId),
        sha256: sha256Full,
        mediaType,
        originalSizeBytes,
        sessionId,
        archivedAt,
    };
}
// ---------------------------------------------------------------------------
// Sidecar file
// ---------------------------------------------------------------------------
/**
 * Append an archive record to the sidecar JSONL file.  Creates the file if
 * it does not exist.  Uses synchronous I/O so the caller is not blocked by
 * a promise — the sidecar is a small local append, not a network operation.
 */
export function appendToSidecar(sidecarPath, result, transcriptPath) {
    const record = {
        archiveId: result.archiveId,
        url: result.url,
        sha256: result.sha256,
        mediaType: result.mediaType,
        originalSizeBytes: result.originalSizeBytes,
        sessionId: result.sessionId,
        transcriptPath,
        archivedAt: result.archivedAt,
    };
    fs.appendFileSync(sidecarPath, JSON.stringify(record) + "\n", "utf-8");
}
// ---------------------------------------------------------------------------
// Provider implementations
// ---------------------------------------------------------------------------
async function uploadToTos(buffer, contentType, key, config) {
    const _require = createRequire(import.meta.url);
    /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
    const { TosClient } = _require("@volcengine/tos-sdk");
    const client = new TosClient({
        accessKeyId: config.accessKeyId,
        accessKeySecret: config.accessKeySecret,
        region: config.region,
        endpoint: config.endpoint,
        bucket: config.bucket,
        secure: true,
    });
    await client.putObject({
        bucket: config.bucket,
        key,
        body: buffer,
        contentType,
    });
    /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
}
async function uploadToS3(buffer, contentType, key, config) {
    const _require = createRequire(import.meta.url);
    /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
    const { S3Client, PutObjectCommand } = _require("@aws-sdk/client-s3");
    const client = new S3Client({
        region: config.region,
        credentials: {
            accessKeyId: config.accessKeyId,
            secretAccessKey: config.secretAccessKey,
        },
    });
    await client.send(new PutObjectCommand({
        Bucket: config.bucket,
        Key: key,
        Body: buffer,
        ContentType: contentType,
    }));
    /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access */
}
//# sourceMappingURL=image-archive.js.map