/**
 * TraceCollector — extracts structured trace data from provider responses.
 *
 * Parses tool calls, token usage, and timing data from Promptfoo result
 * objects and normalizes them into the canonical `EvalTrace` shape.
 *
 * Works via inline extraction — parsing provider response metadata
 * directly, without requiring additional infrastructure.
 *
 * @see docs/design-docs/architecture-overhaul/observability-telemetry.md
 * @see packages/core/src/types/trace.ts — EvalTrace types
 */
import { classifyToolCall } from "./tool-classifier.js";
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
 * Collect a trace from a single provider response.
 *
 * Extracts tool calls, token usage, timing, and builds the
 * chronological event log.
 */
export function collectTrace(response, options) {
    const traceId = `trace-${options.runId}-${options.taskId}-${options.testCaseIndex}`;
    // Extract tool calls
    const toolCalls = extractToolCalls(response, options.toolCallSource ?? "provider-metadata", options.toolCategories, options.maxOutputBytes ?? 10_240);
    // Extract token usage
    const tokensUsed = extractTokenUsage(response);
    // Compute cost (caller provides pricing separately)
    const durationMs = response.latencyMs ?? 0;
    // Real execution-time window (W-trace-real-timestamps). Prefer the bounds the
    // mode adapter measured around the observed run (literacy forwards the
    // agent-observer session's start/end); otherwise reconstruct the window from
    // `durationMs` so it never collapses to a zero-length span stamped at
    // emission time.
    const { startedAt, completedAt } = resolveTraceWindow(response.startedAt, response.completedAt, durationMs);
    // Build spans — one root span for the test case, plus per-turn `llm-turn`
    // spans interleaved with the `tool-call` spans issued in each turn, laid
    // out in execution order (D0088 timing). Reads the raw tool calls (not the
    // normalized `ToolCallRecord[]`) because they carry the per-call `turnIndex`.
    const spans = buildSpans(options, durationMs, response.metadata?.llmTimings, response.metadata?.toolCalls);
    // Build event log — a flat, timestamped projection of the SAME per-turn
    // (`llmTimings`) + per-call (`durationMs`) timeline that drives the spans,
    // anchored at the real `startedAt` (W-trace-event-timestamps). Events and
    // spans therefore tell one chronological story; the old behavior of stamping
    // every event with a single collector-time value (N identical timestamps) is
    // gone.
    const events = buildEventLog(startedAt, durationMs, tokensUsed, toolCalls, response.metadata?.toolCalls, response.metadata?.llmTimings);
    // Extract URLs and search terms from tool calls
    const { urlsVisited, searchTerms, filesRead, filesWritten } = extractAccessPatterns(toolCalls);
    return {
        traceId,
        mode: options.mode,
        runId: options.runId,
        taskId: options.taskId,
        testCaseIndex: options.testCaseIndex,
        modelId: options.modelId,
        spans,
        toolCalls,
        urlsVisited,
        searchTerms,
        filesRead,
        filesWritten,
        tokensUsed,
        costEstimate: 0, // Set by cost calculator separately
        durationMs,
        events,
        startedAt,
        completedAt,
    };
}
/**
 * Merge multiple per-turn traces into a single test case trace.
 *
 * Each turn produces its own trace. This function combines them into
 * a parent trace with per-turn spans.
 */
export function mergeTraces(turns, parentOptions) {
    if (turns.length === 0) {
        return collectTrace({}, parentOptions);
    }
    const first = turns[0];
    const last = turns[turns.length - 1];
    const traceId = `trace-${parentOptions.runId}-${parentOptions.taskId}-${parentOptions.testCaseIndex}`;
    // Merge all tool calls, events, and access patterns
    const toolCalls = turns.flatMap((t) => t.toolCalls);
    const events = turns.flatMap((t) => t.events);
    const urlsVisited = [...new Set(turns.flatMap((t) => t.urlsVisited))];
    const searchTerms = [...new Set(turns.flatMap((t) => t.searchTerms))];
    const filesRead = [...new Set(turns.flatMap((t) => t.filesRead))];
    const filesWritten = [...new Set(turns.flatMap((t) => t.filesWritten))];
    // Aggregate token usage
    const tokensUsed = {
        promptTokens: turns.reduce((sum, t) => sum + t.tokensUsed.promptTokens, 0),
        completionTokens: turns.reduce((sum, t) => sum + t.tokensUsed.completionTokens, 0),
        totalTokens: turns.reduce((sum, t) => sum + t.tokensUsed.totalTokens, 0),
        toolTokens: turns.reduce((sum, t) => sum + (t.tokensUsed.toolTokens ?? 0), 0),
    };
    // Build per-turn spans
    const spans = [
        {
            spanId: `${traceId}-root`,
            parentSpanId: null,
            operation: "test-case",
            startMs: 0,
            endMs: turns.reduce((sum, t) => sum + t.durationMs, 0),
            attributes: { turnCount: turns.length },
        },
        ...turns.map((turn, i) => ({
            spanId: `${traceId}-turn-${i}`,
            parentSpanId: `${traceId}-root`,
            operation: `turn-${i}`,
            startMs: turns.slice(0, i).reduce((sum, t) => sum + t.durationMs, 0),
            endMs: turns.slice(0, i + 1).reduce((sum, t) => sum + t.durationMs, 0),
            attributes: {
                modelId: turn.modelId,
                toolCallCount: turn.toolCalls.length,
            },
        })),
    ];
    return {
        traceId,
        mode: parentOptions.mode,
        runId: parentOptions.runId,
        taskId: parentOptions.taskId,
        testCaseIndex: parentOptions.testCaseIndex,
        modelId: parentOptions.modelId,
        spans,
        toolCalls,
        urlsVisited,
        searchTerms,
        filesRead,
        filesWritten,
        tokensUsed,
        costEstimate: turns.reduce((sum, t) => sum + t.costEstimate, 0),
        durationMs: turns.reduce((sum, t) => sum + t.durationMs, 0),
        events,
        startedAt: first.startedAt,
        completedAt: last.completedAt,
    };
}
// ---------------------------------------------------------------------------
// Tool call extraction
// ---------------------------------------------------------------------------
function extractToolCalls(response, source, customCategories, maxOutputBytes) {
    const rawCalls = response.metadata?.toolCalls ?? [];
    return rawCalls.map((raw) => {
        const name = raw.name ?? raw.function?.name ?? "unknown";
        const input = raw.input ?? parseJsonSafe(raw.function?.arguments) ?? {};
        let output = raw.output;
        // Truncate output if too large
        if (maxOutputBytes && output) {
            const serialized = JSON.stringify(output);
            if (serialized.length > maxOutputBytes) {
                output = serialized.slice(0, maxOutputBytes) + "... [truncated]";
            }
        }
        return {
            name,
            input: input,
            output,
            durationMs: raw.durationMs ?? 0,
            error: raw.error,
            category: classifyToolCall(name, customCategories),
            source: raw.source ?? source,
        };
    });
}
// ---------------------------------------------------------------------------
// Token usage extraction
// ---------------------------------------------------------------------------
function extractTokenUsage(response) {
    const usage = response.tokenUsage;
    if (!usage) {
        return {
            promptTokens: 0,
            completionTokens: 0,
            totalTokens: 0,
        };
    }
    const prompt = usage.prompt ?? 0;
    const completion = usage.completion ?? 0;
    return {
        promptTokens: prompt,
        completionTokens: completion,
        totalTokens: usage.total ?? prompt + completion,
        ...(usage.cached ? { toolTokens: usage.cached } : {}),
    };
}
// ---------------------------------------------------------------------------
// Access pattern extraction
// ---------------------------------------------------------------------------
function extractAccessPatterns(toolCalls) {
    const urlsVisited = new Set();
    const searchTerms = new Set();
    const filesRead = new Set();
    const filesWritten = new Set();
    for (const call of toolCalls) {
        switch (call.category) {
            case "navigate":
            case "read": {
                const url = extractString(call.input, "url");
                if (url)
                    urlsVisited.add(url);
                // `file_path` is the claude-agent-sdk / Claude Code Read tool key
                // (agent-harness mode); `path`/`file`/`filename` cover other providers.
                const filePath = extractString(call.input, "path", "file", "filename", "file_path");
                if (filePath) {
                    if (call.category === "read")
                        filesRead.add(filePath);
                }
                break;
            }
            case "search": {
                const query = extractString(call.input, "query", "term", "search");
                if (query)
                    searchTerms.add(query);
                break;
            }
            case "write": {
                const writePath = extractString(call.input, "path", "file", "filename", "file_path");
                if (writePath)
                    filesWritten.add(writePath);
                break;
            }
        }
    }
    return {
        urlsVisited: [...urlsVisited],
        searchTerms: [...searchTerms],
        filesRead: [...filesRead],
        filesWritten: [...filesWritten],
    };
}
// ---------------------------------------------------------------------------
// Event log
// ---------------------------------------------------------------------------
/**
 * Build the chronological event log as a flat projection of the execution
 * timeline (W-trace-event-timestamps).
 *
 * Each event's timestamp is `startedAt` (the real trace-start anchor from
 * `resolveTraceWindow`) plus the offset that `layoutTimeline` assigns from the
 * same `llmTimings` / per-call `durationMs` measurements that drive the spans.
 * A timed tool call's `tool_call_end` therefore lands at the exact instant its
 * `tool-call` span ends — events and spans tell one story.
 *
 * The event *set* is unchanged (one `llm_request`, a start/end pair per tool
 * call, one `llm_response`); only the timestamps are now derived instead of all
 * sharing one collector-time value.
 */
function buildEventLog(startedAt, durationMs, tokensUsed, toolCalls, rawToolCalls, llmTimings) {
    const anchorMs = new Date(startedAt).getTime();
    const at = (offsetMs) => new Date(anchorMs + offsetMs).toISOString();
    const events = [];
    // The model's first call is issued at trace start.
    events.push({
        timestamp: at(0),
        type: "llm_request",
        data: { promptTokens: tokensUsed.promptTokens },
    });
    // Tool calls the timeline placed (turn-matched) — stamp each with its real
    // start/end offset, in execution order, matching its `tool-call` span.
    const { segments, endMs } = layoutTimeline(llmTimings, rawToolCalls);
    const placed = new Set();
    for (const seg of segments) {
        if (seg.kind !== "tool")
            continue;
        placed.add(seg.toolIndex);
        pushToolEvents(events, toolCalls[seg.toolIndex], at(seg.startMs), at(seg.endMs));
    }
    // Tool calls the timeline could not place (untimed, no `turnIndex`, or a mode
    // with no `llmTimings` at all) still belong in the log. Lay them out in array
    // order after the timed portion, advancing a cursor by each recorded duration
    // — an untimed call collapses to an instant rather than borrowing a false one.
    let cursor = endMs;
    for (let i = 0; i < toolCalls.length; i++) {
        if (placed.has(i))
            continue;
        const call = toolCalls[i];
        const start = cursor;
        cursor += Math.max(0, call.durationMs);
        pushToolEvents(events, call, at(start), at(cursor));
    }
    // The final model response lands at the end of the timeline — never before
    // the recorded wall-clock duration.
    events.push({
        timestamp: at(Math.max(cursor, durationMs)),
        type: "llm_response",
        data: {
            completionTokens: tokensUsed.completionTokens,
            totalTokens: tokensUsed.totalTokens,
            durationMs,
        },
    });
    return events;
}
/** Emit the `tool_call_start` / `tool_call_end` pair for one tool call. */
function pushToolEvents(events, call, startTimestamp, endTimestamp) {
    events.push({
        timestamp: startTimestamp,
        type: "tool_call_start",
        data: { name: call.name, category: call.category },
    });
    events.push({
        timestamp: endTimestamp,
        type: "tool_call_end",
        data: {
            name: call.name,
            durationMs: call.durationMs,
            ...(call.error ? { error: call.error } : {}),
        },
    });
}
/**
 * Lay the per-turn model calls and their tool round-trips onto one cursor in
 * execution order (D0088 timing): each `llmTimings` entry is a turn, and the
 * tool calls issued in that turn (matched by `turnIndex`, in array order)
 * follow it, each advancing the cursor by its recorded `durationMs`.
 *
 * A tool call with no positive duration produces a zero-width segment (it does
 * not advance the cursor). `buildSpans` drops those so the gantt has no
 * zero-length bars; `buildEventLog` still logs them in position. A call whose
 * `turnIndex` matches no turn (or any call when `llmTimings` is absent) is not
 * placed here at all — `buildEventLog` appends it after the timed portion.
 */
function layoutTimeline(llmTimings, toolCalls) {
    const segments = [];
    let cursor = 0;
    for (let turn = 0; turn < (llmTimings?.length ?? 0); turn++) {
        const llmMs = llmTimings[turn];
        segments.push({ kind: "llm", turn, startMs: cursor, endMs: cursor + llmMs });
        cursor += llmMs;
        for (let i = 0; i < (toolCalls?.length ?? 0); i++) {
            const call = toolCalls[i];
            if (call.turnIndex !== turn)
                continue;
            const toolMs = Math.max(0, call.durationMs ?? 0);
            segments.push({
                kind: "tool",
                turn,
                toolIndex: i,
                startMs: cursor,
                endMs: cursor + toolMs,
            });
            cursor += toolMs;
        }
    }
    return { segments, endMs: cursor };
}
function buildSpans(options, durationMs, llmTimings, toolCalls) {
    const traceId = `trace-${options.runId}-${options.taskId}-${options.testCaseIndex}`;
    const rootSpanId = `${traceId}-root`;
    // Each timeline segment becomes a child span, laid out sequentially so the
    // durations add up to (roughly) the full trace duration rather than leaving
    // the tool time as gaps. Zero-width tool segments (a call a mode didn't time)
    // are dropped — no zero-/negative-width bars; the event log still records
    // them. The `toolIndex` keeps span IDs stable across the flat tool-call array.
    const { segments, endMs: cursor } = layoutTimeline(llmTimings, toolCalls);
    const children = [];
    for (const seg of segments) {
        if (seg.kind === "llm") {
            children.push({
                spanId: `${traceId}-llm-turn-${seg.turn}`,
                parentSpanId: rootSpanId,
                operation: `llm-turn-${seg.turn}`,
                startMs: seg.startMs,
                endMs: seg.endMs,
                attributes: { turn: seg.turn, llmMs: seg.endMs - seg.startMs },
            });
            continue;
        }
        if (seg.endMs <= seg.startMs)
            continue;
        const call = toolCalls[seg.toolIndex];
        children.push({
            spanId: `${traceId}-tool-call-${seg.toolIndex}`,
            parentSpanId: rootSpanId,
            operation: `tool-call-${seg.toolIndex}`,
            startMs: seg.startMs,
            endMs: seg.endMs,
            attributes: {
                turn: seg.turn,
                tool: call.name ?? "unknown",
                ...(call.error !== undefined ? { failed: true } : {}),
            },
        });
    }
    // The root must bound every child. `durationMs` (wall clock) normally exceeds
    // the summed spans (`cursor`), but clamp up so a child can never exceed the
    // root if measurement noise pushes the cursor past the recorded duration.
    const root = {
        spanId: rootSpanId,
        parentSpanId: null,
        operation: "test-case",
        startMs: 0,
        endMs: Math.max(durationMs, cursor),
        attributes: {
            taskId: options.taskId,
            modelId: options.modelId,
            testCaseIndex: options.testCaseIndex,
        },
    };
    return [root, ...children];
}
// ---------------------------------------------------------------------------
// Execution-time window
// ---------------------------------------------------------------------------
/**
 * Resolve the trace's `startedAt`/`completedAt` window (W-trace-real-timestamps).
 *
 * Fidelity tiers, best first:
 *   1. Both bounds supplied by the adapter → used verbatim (literacy: the
 *      agent-observer session's real wall-clock start/end).
 *   2. One bound supplied → the missing end is reconstructed from `durationMs`.
 *   3. Neither supplied (MCP, agent-harness — no source anchor) → the window
 *      ends at emission time and `startedAt = completedAt - durationMs`.
 *
 * The window only collapses to a single instant when `durationMs` is 0 — the
 * old behavior of stamping both ends with one `new Date()` is gone.
 */
function resolveTraceWindow(startedAt, completedAt, durationMs) {
    if (startedAt && completedAt)
        return { startedAt, completedAt };
    const end = completedAt ??
        (startedAt ? shiftIso(startedAt, durationMs) : new Date().toISOString());
    const start = startedAt ?? shiftIso(end, -durationMs);
    return { startedAt: start, completedAt: end };
}
/** Shift an ISO 8601 timestamp by `deltaMs` milliseconds, returning ISO 8601. */
function shiftIso(iso, deltaMs) {
    return new Date(new Date(iso).getTime() + deltaMs).toISOString();
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function parseJsonSafe(str) {
    if (!str)
        return null;
    try {
        return JSON.parse(str);
    }
    catch {
        return null;
    }
}
function extractString(input, ...keys) {
    for (const key of keys) {
        const val = input[key];
        if (typeof val === "string" && val.length > 0)
            return val;
    }
    return undefined;
}
