/**
 * Transcript-based session reader for the OpenRouter adapter.
 *
 * The `openrouter-agent-harness` library writes an append-only JSONL side-car
 * at `<logsRoot>/<sessionId>/transcript.jsonl` containing every user-visible
 * record (`session_start`, `user`, `assistant`, `tool_result`, `compact`,
 * `session_end`) with per-record timestamps, post-routing model names,
 * token usage, cost, and turn durations. This file is the canonical
 * user-visible view of an OR session.
 *
 * `readOpenRouterTranscript` opens the file synchronously, line-parses it,
 * and projects each {@link TranscriptRecord} into the neutral
 * {@link ParsedMessage} timeline the rest of callboard consumes. Sessions
 * persisted before the transcript landed (or those started with
 * `persistSession: false` for utility flows) have no transcript file —
 * callers fall back to {@link readOpenRouterSession} (state.json plus the
 * req_*&#47;gen_* tree) for those.
 *
 * @see ../../../node_modules/@wolpertingerlabs/openrouter-agent-harness/dist/logging/transcript.d.ts
 */
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { storeBase64Image } from "../../../services/image-storage.js";
import { extractTextContent } from "./sessionParser.js";
import { serverToolToMessages } from "./serverTools.js";
/**
 * `session_end` reason written by the OR library when the run was aborted by
 * the user (its `ABORT_REASON` constant). Aborts are user-initiated stops, not
 * failures — they get no error bubble.
 */
const OR_ABORT_REASON = "aborted";
const SUPPORTED_SCHEMA_VERSION = 1;
/**
 * Read `<sessionDir>/transcript.jsonl` if it exists and project every record
 * into a {@link ParsedMessage} timeline. Returns `null` when the file is
 * absent (caller should fall back to the state.json-based parser);
 * returns an empty array when the file exists but contains only records
 * we don't render (e.g. session_start / session_end).
 *
 * Sync I/O: each transcript is one record per turn, so a multi-thousand-turn
 * chat is still tens of KB. The route handler that calls this is sync
 * end-to-end (see `chats.ts:GET /:id/messages` → `parseSessionMessages`),
 * so streaming would require interface-wide async churn for no real-world
 * benefit at current sizes.
 */
export function readOpenRouterTranscript(sessionDir) {
    const path = join(sessionDir, "transcript.jsonl");
    if (!existsSync(path))
        return null;
    let raw;
    try {
        raw = readFileSync(path, "utf-8");
    }
    catch {
        // File present but unreadable — treat as absent so caller can fall back.
        // Other failure modes (permissions, mid-write truncation) shouldn't
        // wedge the entire chat view.
        return null;
    }
    const messages = [];
    for (const line of raw.split("\n")) {
        if (line.length === 0)
            continue;
        let rec;
        try {
            rec = JSON.parse(line);
        }
        catch {
            // Skip malformed lines — the OR library's reader does the same, and
            // a partial mid-write line shouldn't break the whole timeline.
            continue;
        }
        if (rec.v !== SUPPORTED_SCHEMA_VERSION)
            continue;
        const ts = typeof rec.ts === "string" ? rec.ts : undefined;
        const kind = rec.kind;
        if (kind === "user") {
            const rawText = typeof rec.text === "string" ? rec.text : "";
            const { text, imageIds } = unwrapUserText(rawText);
            messages.push({
                role: "user",
                type: "text",
                content: text,
                ...(ts && { timestamp: ts }),
                ...(imageIds.length > 0 && { imageIds }),
            });
        }
        else if (kind === "assistant") {
            messages.push(...translateAssistantRecord(rec, ts));
        }
        else if (kind === "tool_result") {
            const callId = typeof rec.callId === "string" ? rec.callId : undefined;
            const name = typeof rec.name === "string" ? rec.name : "";
            const content = stringifyToolOutput(rec.output);
            messages.push({
                role: "user",
                type: "tool_result",
                content,
                ...(ts && { timestamp: ts }),
                ...(callId && { toolUseId: callId }),
                // OR's defensive fallback writes `name: ""` when the matching
                // function_call item wasn't observed. Omit toolName entirely in
                // that case so MessageBubble's "Tool result" header doesn't render
                // a stray empty label.
                ...(name && { toolName: name }),
            });
        }
        else if (kind === "server_tool") {
            // OpenRouter server tools (datetime / web_search / web_fetch) execute
            // on OR's servers — one record carries both the invocation and its
            // result. Synthesize the tool_use/tool_result pair with provenance so
            // the UI renders them alongside client-side tool calls (harness ≥0.3.0
            // writes these; older transcripts simply have none).
            const toolType = typeof rec.toolType === "string" ? rec.toolType : "";
            if (toolType) {
                const callId = typeof rec.callId === "string" ? rec.callId : undefined;
                messages.push(...serverToolToMessages({ toolType, ...(callId && { callId }), input: rec.input, output: rec.output }, ts));
            }
        }
        else if (kind === "compact") {
            // Mirror the Claude provider's compact_boundary projection so the
            // UI can render the boundary line the same way regardless of
            // provider. summaryText is preserved as the message content for
            // post-compact diagnostics.
            const summary = typeof rec.summaryText === "string" ? rec.summaryText : "";
            messages.push({
                role: "system",
                type: "system",
                subtype: "compact_boundary",
                content: summary,
                ...(ts && { timestamp: ts }),
            });
        }
        else if (kind === "session_end") {
            // A run that terminated with a provider/API error persists the
            // human-readable reason here (since openrouter-agent-harness#215 it
            // includes the upstream provider, per-attempt statuses, and routing
            // summary). Project it as a system error message so the failure
            // survives reloads instead of living only in the live SSE stream.
            // Aborts are user-initiated stops, not failures — skip those, and
            // skip reason-less errors (nothing useful to show). success /
            // max_turns / max_budget end states already surface through the
            // `done` event's reason chip.
            const reason = typeof rec.reason === "string" ? rec.reason : "";
            if (rec.status === "error" && reason.length > 0 && reason !== OR_ABORT_REASON) {
                messages.push({
                    role: "system",
                    type: "system",
                    subtype: "session_error",
                    content: reason,
                    ...(ts && { timestamp: ts }),
                });
            }
        }
        // session_start carries only run-level metadata (cwd, config) — skip.
    }
    return messages;
}
/**
 * Expand a single `assistant` transcript record into the per-block
 * ParsedMessage sequence the UI expects: reasoning → tool_use(s) → text.
 *
 * Per-cycle metadata (model, usage, costUsd, durationMs, requestId, ts) is
 * attached to whichever assistant-role message will be the last one in the
 * sequence — typically the final text, but for tool-only turns (no `text`)
 * it lands on the last tool_use so the "Tokens / Cost / Duration" line
 * stays visible. Reasoning carries timestamp only; per-token usage on
 * reasoning would duplicate the figures shown on the turn's main row.
 */
function translateAssistantRecord(rec, ts) {
    const out = [];
    const reasoning = typeof rec.reasoning === "string" ? rec.reasoning : "";
    if (reasoning) {
        out.push({
            role: "assistant",
            type: "thinking",
            content: reasoning,
            ...(ts && { timestamp: ts }),
        });
    }
    const toolCalls = Array.isArray(rec.toolCalls) ? rec.toolCalls : [];
    for (const tc of toolCalls) {
        if (!tc || typeof tc !== "object")
            continue;
        const t = tc;
        const callId = typeof t.callId === "string" ? t.callId : undefined;
        const name = typeof t.name === "string" ? t.name : "<unknown>";
        // The transcript records `input` as the structured tool args; the rest
        // of callboard expects tool_use `content` as the JSON-stringified args
        // (matching what state.json's function_call.arguments holds). Stringify
        // here so MessageBubble's getToolSummary regex parsing works
        // unchanged.
        const argsJson = stringifyToolInput(t.input);
        out.push({
            role: "assistant",
            type: "tool_use",
            toolName: name,
            content: argsJson,
            ...(ts && { timestamp: ts }),
            ...(callId && { toolUseId: callId }),
        });
    }
    const text = typeof rec.text === "string" ? rec.text : "";
    if (text) {
        out.push({
            role: "assistant",
            type: "text",
            content: text,
            ...(ts && { timestamp: ts }),
        });
    }
    // Attach per-cycle metadata to the last assistant-role item — that's
    // where users look for "what just happened in this turn" details.
    // If the turn produced no items (text empty AND no tool calls AND no
    // reasoning) we emit nothing; the UI will show a gap, but that's a
    // truthful representation of an empty model response.
    if (out.length > 0) {
        applyAssistantMeta(out[out.length - 1], rec);
    }
    return out;
}
function applyAssistantMeta(m, rec) {
    if (typeof rec.model === "string" && rec.model.length > 0)
        m.model = rec.model;
    if (typeof rec.requestId === "string" && rec.requestId.length > 0) {
        m.requestId = rec.requestId;
        // Synthesise a per-generation key so the responses debug table can list
        // each generation as its own row. The OR harness reuses the same
        // cycleRequestId across all intra-cycle turns (turn 0 = initial response,
        // turn 1 = first tool follow-up, etc.), so requestId alone cannot
        // distinguish them. Appending turnNumber produces a unique key per
        // generation while staying compatible with Claude rows that leave
        // generationKey unset (the panel falls back to requestId for those).
        const turnNumber = typeof rec.turnNumber === "number" ? rec.turnNumber : undefined;
        m.generationKey = turnNumber !== undefined ? `${rec.requestId}/${turnNumber}` : rec.requestId;
    }
    if (typeof rec.costUsd === "number")
        m.costUsd = rec.costUsd;
    if (typeof rec.durationMs === "number")
        m.durationMs = rec.durationMs;
    const usage = rec.usage;
    if (usage && typeof usage === "object") {
        const u = usage;
        const out = {};
        if (typeof u.prompt === "number") {
            // The transcript records `prompt` as the TOTAL input (cached + fresh)
            // to match the OpenAI/OR API shape. Subtract cached so the
            // displayed "in" number is the fresh-input portion — matches the
            // accounting convention applied in sessionParser.ts (cached are
            // surfaced separately as "cache read"). Clamp at 0 against upstream
            // accounting drift.
            const cached = typeof u.cached === "number" ? u.cached : 0;
            out.input_tokens = Math.max(0, u.prompt - cached);
            if (cached > 0)
                out.cache_read_input_tokens = cached;
        }
        if (typeof u.completion === "number")
            out.output_tokens = u.completion;
        if (typeof u.reasoning === "number" && u.reasoning > 0)
            out.reasoning_tokens = u.reasoning;
        if (Object.keys(out).length > 0)
            m.usage = out;
    }
}
/**
 * Detect & unwrap the OR library's JSON-stringified multimodal content shape.
 *
 * Multimodal user prompts (text + image attachments) reach `logTranscriptUser`
 * as a content-block array, which agent.ts:1805 then collapses with
 * `JSON.stringify` into a single `text` string before write. Left as-is, the
 * UI bubble would render the raw JSON. Instead, when `text` parses as an
 * array of OR `input_text` / `input_image` blocks, split out the displayable
 * text portion and intern any base64 image data via the shared image store,
 * returning the image IDs alongside so the caller can attach them to the
 * ParsedMessage (matching the Claude-path's `imageIds` field that
 * MessageBubble renders as thumbnails).
 *
 * Plain text passes through unchanged. Anything that looks like JSON but
 * isn't a content-block array also passes through verbatim — we won't
 * accidentally mangle a user message that legitimately starts with `[`.
 */
function unwrapUserText(raw) {
    if (!raw.startsWith("["))
        return { text: raw, imageIds: [] };
    let parsed;
    try {
        parsed = JSON.parse(raw);
    }
    catch {
        return { text: raw, imageIds: [] };
    }
    if (!Array.isArray(parsed))
        return { text: raw, imageIds: [] };
    const textParts = [];
    const imageIds = [];
    let recognized = false;
    for (const block of parsed) {
        if (!block || typeof block !== "object")
            return { text: raw, imageIds: [] };
        const b = block;
        if (b.type === "input_text" && typeof b.text === "string") {
            if (b.text.length > 0)
                textParts.push(b.text);
            recognized = true;
            continue;
        }
        if (b.type === "input_image" && typeof b.image_url === "string") {
            const id = storeDataUriImage(b.image_url);
            if (id)
                imageIds.push(id);
            recognized = true;
            continue;
        }
        // Unknown block kind — bail and treat the whole thing as plain text so
        // we don't silently drop content we don't understand.
        return { text: raw, imageIds: [] };
    }
    if (!recognized)
        return { text: raw, imageIds: [] };
    return { text: textParts.join("\n"), imageIds };
}
/**
 * Decode a `data:<mime>;base64,<data>` URI and intern it via the shared image
 * store. Returns the resulting image ID, or `null` when the URI isn't a
 * recognized data URL (http/https URLs are intentionally skipped — we don't
 * mirror remote images into local storage).
 */
function storeDataUriImage(imageUrl) {
    const match = imageUrl.match(/^data:([^;]+);base64,(.+)$/);
    if (!match)
        return null;
    const [, mimeType, base64Data] = match;
    return storeBase64Image(base64Data, mimeType);
}
/**
 * Best-effort JSON-stringify a `tool_call.input` for storage as the
 * `tool_use` ParsedMessage `content`. The OR transcript records input as
 * the structured object; consumers downstream (MessageBubble's
 * getToolSummary) parse it back out, so JSON form is the round-trip
 * format.
 */
function stringifyToolInput(input) {
    if (input === null || input === undefined)
        return "{}";
    if (typeof input === "string")
        return input;
    try {
        const json = JSON.stringify(input);
        return json ?? "{}";
    }
    catch {
        return "{}";
    }
}
/**
 * Tool output extraction: the transcript records `output` as whatever the
 * tool returned — a string, an object, an array, or null. Project it onto
 * a single display string using the same shape-tolerant rules
 * sessionParser.ts uses for state.json's `function_call_output`.
 */
function stringifyToolOutput(output) {
    if (output === null || output === undefined)
        return "";
    if (typeof output === "string")
        return output;
    // For structured outputs, route through the shared content extractor
    // so content-block arrays render the same way they do for state.json-
    // derived tool results.
    const extracted = extractTextContent(output);
    if (extracted)
        return extracted;
    try {
        const json = JSON.stringify(output);
        return json ?? String(output);
    }
    catch {
        return String(output);
    }
}
