import fs from "fs";
import fsp from "fs/promises";
import os from "os";
import path from "path";
import readline from "readline";
import { logger } from "../logger.js";
import { getImageDimensions, resizeBase64ForVision, CLAUDE_MAX_LONG_EDGE_PX } from "../tools/screenshot.js";
import { sanitizeJsonValueInPlace } from "../utils/json-sanitize.js";
import { archiveImage, appendToSidecar, isArchiveEnabled, extractArchiveId } from "./image-archive.js";
const PLACEHOLDER_PNG_BASE64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==";
export const IMAGE_TEXT_PLACEHOLDER = "[Screenshot]";
export const IMAGE_TEXT_PLACEHOLDER_WITH_ID_PREFIX = "[Screenshot: archived=";
/**
 * Extract the archive ID from a text placeholder string like
 * `[Screenshot: archived=img_1234_abcdef01]`.
 * Returns null if the text doesn't match the placeholder pattern.
 */
export function extractArchiveIdFromTextPlaceholder(text) {
    if (!text.startsWith(IMAGE_TEXT_PLACEHOLDER_WITH_ID_PREFIX))
        return null;
    if (!text.endsWith("]"))
        return null;
    return text.slice(IMAGE_TEXT_PLACEHOLDER_WITH_ID_PREFIX.length, -1) || null;
}
function isObject(v) {
    return typeof v === "object" && v !== null;
}
function getImageBlockData(block) {
    const directData = block.data;
    if (typeof directData === "string" && directData.length > 0)
        return directData;
    const source = block.source;
    if (!isObject(source))
        return null;
    if (source.type !== "base64")
        return null;
    const data = source.data;
    return typeof data === "string" && data.length > 0 ? data : null;
}
function isBase64ImageBlock(block) {
    if (!isObject(block))
        return false;
    if (block.type !== "image")
        return false;
    const data = getImageBlockData(block);
    if (!data)
        return false;
    // Ignore our own placeholders so repeated runs don't keep counting them.
    if (data === PLACEHOLDER_PNG_BASE64)
        return false;
    if (extractArchiveId(data) !== null)
        return false;
    return true;
}
/**
 * Archive the original image (if configured) then replace the block with a
 * plain text marker.  The block is mutated in-place: all image-specific keys
 * are removed and replaced with `{type: "text", text: "[Screenshot]"}`.
 *
 * This avoids accumulating placeholder image blocks that count toward the
 * Claude API's 100-image limit (see #328).
 */
function replaceBase64ImageBlock(block, sessionId, sidecarPath, transcriptPath) {
    const source = block.source;
    const originalData = getImageBlockData(block) ?? undefined;
    const sourceMediaType = isObject(source) && typeof source.media_type === "string" ? source.media_type : undefined;
    const directMediaType = typeof block.mimeType === "string" ? block.mimeType : undefined;
    const originalMediaType = sourceMediaType ?? directMediaType ?? "image/png";
    let archiveId;
    if (isArchiveEnabled() && originalData) {
        const result = archiveImage(originalData, originalMediaType, sessionId);
        if (result) {
            appendToSidecar(sidecarPath, result, transcriptPath);
            archiveId = result.archiveId;
        }
    }
    // Transform image block into a text block in-place
    for (const key of Object.keys(block)) {
        // eslint-disable-next-line @typescript-eslint/no-dynamic-delete
        delete block[key];
    }
    block.type = "text";
    block.text = archiveId
        ? `${IMAGE_TEXT_PLACEHOLDER_WITH_ID_PREFIX}${archiveId}]`
        : IMAGE_TEXT_PLACEHOLDER;
}
/**
 * Count ALL blocks with `type: "image"` anywhere in the parsed object tree,
 * regardless of whether they are real base64 images or placeholders.
 * This matches what Claude API counts toward its 100-image limit.
 */
function countAllImageTypeBlocks(root) {
    let count = 0;
    const visit = (node) => {
        if (Array.isArray(node)) {
            for (const item of node)
                visit(item);
            return;
        }
        if (!isObject(node))
            return;
        if (node.type === "image") {
            count += 1;
        }
        for (const v of Object.values(node)) {
            visit(v);
        }
    };
    visit(root);
    return count;
}
/**
 * Check if a base64 image exceeds the many-image dimension limit and resize
 * it in-place if needed. Returns true if the image was resized.
 */
async function resizeOversizedImageBlock(block) {
    const source = block.source;
    const data = getImageBlockData(block);
    if (!data)
        return false;
    try {
        const buffer = Buffer.from(data, "base64");
        const dims = await getImageDimensions(buffer);
        if (!dims)
            return false;
        const longEdge = Math.max(dims.width, dims.height);
        if (longEdge <= CLAUDE_MAX_LONG_EDGE_PX)
            return false;
        const resized = await resizeBase64ForVision(data);
        if (resized === data)
            return false;
        if (typeof block.data === "string") {
            block.data = resized;
        }
        else if (isObject(source)) {
            source.data = resized;
        }
        return true;
    }
    catch (err) {
        logger.warn(`[image-pruner] resize check failed: ${err instanceof Error ? err.message : String(err)}`);
        return false;
    }
}
/**
 * Scan a tool output (arbitrary JSON structure) for base64 image blocks that
 * exceed the many-image dimension limit and resize them in-place.
 * Returns the (possibly mutated) output and how many images were resized.
 */
export async function resizeOversizedImagesInToolOutput(toolOutput) {
    const refs = collectBase64ImageBlockRefs(toolOutput);
    let resizedCount = 0;
    for (const ref of refs) {
        const didResize = await resizeOversizedImageBlock(ref);
        if (didResize)
            resizedCount++;
    }
    return { resizedCount };
}
function collectBase64ImageBlockRefs(root) {
    const refs = [];
    const visit = (node) => {
        if (Array.isArray(node)) {
            for (const item of node)
                visit(item);
            return;
        }
        if (!isObject(node))
            return;
        if (isBase64ImageBlock(node)) {
            refs.push(node);
            // still traverse in case nested? (shouldn't matter)
        }
        for (const v of Object.values(node)) {
            visit(v);
        }
    };
    visit(root);
    return refs;
}
async function writeFileAtomic(filePath, content) {
    const dir = path.dirname(filePath);
    const tmp = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
    await fsp.writeFile(tmp, content, "utf8");
    await fsp.rename(tmp, filePath);
}
/**
 * Remove empty text blocks from assistant message content arrays.
 * The Claude API rejects `{type: "text", text: ""}` on input even though the
 * model can produce them during streaming.  Mutates the parsed line in-place.
 *
 * Returns the number of empty blocks found.  When ALL content blocks are empty
 * text the caller should drop the entire line — signalled by returning `-1`.
 */
function sanitizeEmptyTextBlocks(parsed) {
    if (!isObject(parsed))
        return 0;
    const msg = parsed.message;
    if (!isObject(msg))
        return 0;
    if (msg.role !== "assistant")
        return 0;
    const content = msg.content;
    if (!Array.isArray(content))
        return 0;
    const before = content.length;
    const filtered = content.filter((block) => !(block.type === "text" && (!block.text || block.text.length === 0)));
    if (filtered.length === before)
        return 0;
    if (filtered.length === 0) {
        return -1;
    }
    msg.content = filtered;
    return before - filtered.length;
}
/**
 * Remove image blocks with `source.type === "url"` from message content arrays.
 * Claude API (especially via Bedrock) does not support URL image sources; these
 * blocks cause 400 errors.  Mutates the parsed line in-place.
 *
 * Returns the number of URL image blocks removed.
 */
function sanitizeUrlImageBlocks(parsed) {
    if (!isObject(parsed))
        return 0;
    const msg = parsed.message;
    if (!isObject(msg))
        return 0;
    const content = msg.content;
    if (!Array.isArray(content))
        return 0;
    const before = content.length;
    const filtered = content.filter((block) => {
        if (block.type !== "image")
            return true;
        const source = block.source;
        if (!isObject(source))
            return true;
        return source.type !== "url";
    });
    if (filtered.length === before)
        return 0;
    msg.content = filtered;
    return before - filtered.length;
}
/**
 * Collect all `tool_use` IDs from assistant messages across parsed JSONL lines.
 */
function collectToolUseIds(parsedLines) {
    const ids = new Set();
    for (const parsed of parsedLines) {
        if (!isObject(parsed))
            continue;
        const msg = parsed.message;
        if (!isObject(msg))
            continue;
        if (msg.role !== "assistant")
            continue;
        const content = msg.content;
        if (!Array.isArray(content))
            continue;
        for (const block of content) {
            if (block.type === "tool_use" && typeof block.id === "string") {
                ids.add(block.id);
            }
        }
    }
    return ids;
}
/**
 * Check whether a user message line contains an orphaned `tool_result` whose
 * `tool_use_id` has no matching `tool_use` in any assistant message.
 * Returns the number of orphaned blocks found (the whole line should be dropped).
 */
function countOrphanedToolResults(parsed, validToolUseIds) {
    if (!isObject(parsed))
        return 0;
    const msg = parsed.message;
    if (!isObject(msg))
        return 0;
    if (msg.role !== "user")
        return 0;
    const content = msg.content;
    if (!Array.isArray(content))
        return 0;
    let orphaned = 0;
    for (const block of content) {
        if (block.type !== "tool_result")
            continue;
        const toolUseId = block.tool_use_id;
        if (typeof toolUseId !== "string" || !validToolUseIds.has(toolUseId)) {
            orphaned++;
        }
    }
    return orphaned;
}
export async function pruneSessionImages(options) {
    const { transcriptPath, keepLastNBase64Images } = options;
    const sessionId = path.basename(transcriptPath, ".jsonl").slice(0, 8);
    const today = new Date();
    const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(today.getDate()).padStart(2, "0")}`;
    const sidecarPath = transcriptPath.replace(/\.jsonl$/, `.image-archive.${dateStr}.jsonl`);
    logger.debug(`[image-pruner] start keepLast=${keepLastNBase64Images} file=${transcriptPath}`);
    // Fast path: if file doesn't exist, nothing to do.
    if (!fs.existsSync(transcriptPath)) {
        logger.debug("[image-pruner] skip (file missing)");
        return {
            totalImageBlocks: 0,
            keptImageBlocks: 0,
            prunedImageBlocks: 0,
            allImageBlocks: 0,
            resizedImageBlocks: 0,
            sanitizedEmptyTextBlocks: 0,
            sanitizedOrphanedToolResults: 0,
            fileChanged: false,
        };
    }
    // Single-pass: read all lines, collect image refs with their line indices,
    // then compute prune threshold and apply changes.
    const input = fs.createReadStream(transcriptPath, { encoding: "utf8" });
    const rl = readline.createInterface({ input, crlfDelay: Infinity });
    const lines = [];
    let totalImageBlocks = 0;
    for await (const line of rl) {
        if (!line.trim()) {
            lines.push({ raw: line, parsed: null, imageRefs: [] });
            continue;
        }
        let parsed;
        try {
            parsed = JSON.parse(line);
        }
        catch {
            lines.push({ raw: line, parsed: null, imageRefs: [] });
            continue;
        }
        const refs = collectBase64ImageBlockRefs(parsed);
        totalImageBlocks += refs.length;
        lines.push({ raw: line, parsed, imageRefs: refs });
    }
    rl.close();
    input.close();
    const keep = Math.max(0, keepLastNBase64Images);
    const pruneCount = Math.max(0, totalImageBlocks - keep);
    logger.debug(`[image-pruner] scanned totalBase64Images=${totalImageBlocks} prune=${pruneCount} keep=${keep}`);
    let remainingToPrune = pruneCount;
    let prunedImageBlocks = 0;
    let sanitizedEmptyTextBlocks = 0;
    let sanitizedOrphanedToolResults = 0;
    let sanitizedSurrogates = 0;
    let fileChanged = false;
    const keptImageRefs = [];
    const outLines = [];
    const allParsed = lines.map((l) => l.parsed).filter((p) => p !== null);
    const validToolUseIds = collectToolUseIds(allParsed);
    for (const entry of lines) {
        if (entry.parsed === null) {
            outLines.push(entry.raw);
            continue;
        }
        // Sanitize empty text blocks produced by SDK streaming artifacts
        const stripped = sanitizeEmptyTextBlocks(entry.parsed);
        if (stripped === -1) {
            sanitizedEmptyTextBlocks += 1;
            fileChanged = true;
            continue;
        }
        if (stripped > 0) {
            sanitizedEmptyTextBlocks += stripped;
            fileChanged = true;
        }
        // Strip URL-type image blocks (unsupported by Claude API)
        const urlStripped = sanitizeUrlImageBlocks(entry.parsed);
        if (urlStripped > 0) {
            fileChanged = true;
        }
        // Drop lines with orphaned tool_result blocks (no matching tool_use)
        const orphaned = countOrphanedToolResults(entry.parsed, validToolUseIds);
        if (orphaned > 0) {
            sanitizedOrphanedToolResults += orphaned;
            fileChanged = true;
            continue;
        }
        if (sanitizeJsonValueInPlace(entry.parsed)) {
            sanitizedSurrogates += 1;
            fileChanged = true;
        }
        for (const ref of entry.imageRefs) {
            if (remainingToPrune > 0) {
                replaceBase64ImageBlock(ref, sessionId, sidecarPath, transcriptPath);
                remainingToPrune -= 1;
                prunedImageBlocks += 1;
                fileChanged = true;
            }
            else {
                keptImageRefs.push(ref);
            }
        }
        try {
            outLines.push(JSON.stringify(entry.parsed));
        }
        catch {
            outLines.push(entry.raw);
        }
    }
    // Resize pass: check kept images for oversized dimensions
    let resizedImageBlocks = 0;
    for (const ref of keptImageRefs) {
        const didResize = await resizeOversizedImageBlock(ref);
        if (didResize) {
            resizedImageBlocks += 1;
            fileChanged = true;
        }
    }
    if (fileChanged) {
        await writeFileAtomic(transcriptPath, outLines.join(os.EOL) + os.EOL);
        logger.debug(`[image-pruner] wrote changes pruned=${prunedImageBlocks} resized=${resizedImageBlocks} sanitized=${sanitizedEmptyTextBlocks} orphanedToolResults=${sanitizedOrphanedToolResults} surrogateLines=${sanitizedSurrogates}`);
    }
    else {
        logger.debug("[image-pruner] completed without changes");
    }
    // Count ALL type:"image" blocks remaining in the transcript (real + placeholder).
    // This is the number Claude API counts toward its 100-image limit.
    let allImageBlocks = 0;
    for (const entry of lines) {
        if (entry.parsed !== null) {
            allImageBlocks += countAllImageTypeBlocks(entry.parsed);
        }
    }
    return {
        totalImageBlocks,
        keptImageBlocks: totalImageBlocks - prunedImageBlocks,
        prunedImageBlocks,
        allImageBlocks,
        resizedImageBlocks,
        sanitizedEmptyTextBlocks,
        sanitizedOrphanedToolResults,
        fileChanged,
    };
}
/**
 * Standalone transcript sanitizer: strips empty text blocks from assistant
 * messages and orphaned tool_result blocks without touching images.  Used as
 * a reactive repair when the API rejects a transcript with a 400 validation error.
 *
 * Returns true if the file was modified.
 */
export async function sanitizeTranscript(transcriptPath) {
    if (!fs.existsSync(transcriptPath))
        return false;
    const input = fs.createReadStream(transcriptPath, { encoding: "utf8" });
    const rl = readline.createInterface({ input, crlfDelay: Infinity });
    const rawLines = [];
    for await (const line of rl) {
        if (!line.trim()) {
            rawLines.push({ raw: line, parsed: null });
            continue;
        }
        let parsed;
        try {
            parsed = JSON.parse(line);
        }
        catch {
            rawLines.push({ raw: line, parsed: null });
            continue;
        }
        rawLines.push({ raw: line, parsed });
    }
    rl.close();
    input.close();
    const allParsed = rawLines.map((l) => l.parsed).filter((p) => p !== null);
    const validToolUseIds = collectToolUseIds(allParsed);
    let totalStrippedText = 0;
    let totalStrippedOrphans = 0;
    let totalStrippedUrlImages = 0;
    let totalSanitizedSurrogates = 0;
    const outLines = [];
    for (const entry of rawLines) {
        if (entry.parsed === null) {
            outLines.push(entry.raw);
            continue;
        }
        const stripped = sanitizeEmptyTextBlocks(entry.parsed);
        if (stripped === -1) {
            totalStrippedText += 1;
            continue;
        }
        totalStrippedText += stripped;
        // Strip URL-type image blocks (unsupported by Claude API)
        totalStrippedUrlImages += sanitizeUrlImageBlocks(entry.parsed);
        const orphaned = countOrphanedToolResults(entry.parsed, validToolUseIds);
        if (orphaned > 0) {
            totalStrippedOrphans += orphaned;
            continue;
        }
        if (sanitizeJsonValueInPlace(entry.parsed)) {
            totalSanitizedSurrogates += 1;
        }
        try {
            outLines.push(JSON.stringify(entry.parsed));
        }
        catch {
            outLines.push(entry.raw);
        }
    }
    const totalFixed = totalStrippedText +
        totalStrippedOrphans +
        totalStrippedUrlImages +
        totalSanitizedSurrogates;
    if (totalFixed === 0)
        return false;
    await writeFileAtomic(transcriptPath, outLines.join(os.EOL) + os.EOL);
    const parts = [];
    if (totalStrippedText > 0)
        parts.push(`${totalStrippedText} empty text block(s)`);
    if (totalStrippedOrphans > 0)
        parts.push(`${totalStrippedOrphans} orphaned tool_result(s)`);
    if (totalStrippedUrlImages > 0)
        parts.push(`${totalStrippedUrlImages} URL image block(s)`);
    if (totalSanitizedSurrogates > 0)
        parts.push(`${totalSanitizedSurrogates} surrogate-containing line(s)`);
    logger.system(`[transcript-sanitizer] removed ${parts.join(" and ")} from ${transcriptPath}`);
    return true;
}
//# sourceMappingURL=image-pruner.js.map