import type { StreamChunk } from '@n8n/agents';
import {
	applyForwardedChildChunk,
	emptyChildTrace,
	settleChildTrace,
	type PersistedChildTrace,
} from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
import { scrubSecretsInText } from '@n8n/utils/scrub-secrets';
import { extractFromAICalls, isFromAIOnlyExpression } from 'n8n-workflow';

import type { ToolRegistry } from './tool-registry';

/** Cap on child trace characters persisted per delegation. Tighter than the
 *  live forwarding budget because this is written into every parent execution row. */
const CHILD_TRACE_PERSIST_CHAR_BUDGET = 4_000;
const TIMELINE_BLOCK_MAX_DURATION_MS = 1_000;

/**
 * Walk a nodeParameters tree and substitute templated values with what the
 * LLM passed: both `$fromAI('key', ...)` placeholders and `={{ $json.path }}`
 * lookups (the LLM's structured input is the node's `$json` at runtime — see
 * `node-tool-factory.ts` where input flows in as `[{ json: input }]`). Used
 * when recording a `kind: 'node'` tool call so the timeline shows the values
 * the node would have run with, not the raw template strings the user
 * configured.
 *
 * Pure best-effort: parsing failures fall through to the raw string. The
 * goal is a clearer log entry, not exact expression-engine fidelity.
 */
function resolveTemplatesInValue(value: unknown, llmArgs: Record<string, unknown>): unknown {
	if (typeof value === 'string') return resolveTemplatesInString(value, llmArgs);
	if (Array.isArray(value)) return value.map((v) => resolveTemplatesInValue(v, llmArgs));
	if (value !== null && typeof value === 'object') {
		const out: Record<string, unknown> = {};
		for (const [k, v] of Object.entries(value)) {
			out[k] = resolveTemplatesInValue(v, llmArgs);
		}
		return out;
	}
	return value;
}

function resolveTemplatesInString(str: string, llmArgs: Record<string, unknown>): unknown {
	const afterFromAI = resolveFromAIInString(str, llmArgs);
	if (typeof afterFromAI !== 'string') return afterFromAI;
	return resolveJsonRefsInString(afterFromAI, llmArgs);
}

function resolveFromAIInString(str: string, llmArgs: Record<string, unknown>): unknown {
	if (!str.includes('$fromAI')) return str;

	let calls: ReturnType<typeof extractFromAICalls>;
	try {
		calls = extractFromAICalls(str);
	} catch {
		return str;
	}
	if (calls.length === 0) return str;

	// Full-string `$fromAI(...)` — replace the entire value with the resolved
	// arg so the timeline shows e.g. the literal prompt text, not `={{ ... }}`.
	if (isFromAIOnlyExpression(str)) {
		const call = calls[0];
		if (call.key in llmArgs) return llmArgs[call.key];
		if (call.defaultValue !== undefined) return call.defaultValue;
		return str;
	}

	// Mixed-content expression — substitute each `$fromAI(...)` call inline.
	// We re-scan with a forgiving pattern; precise expression-engine rules
	// (e.g. nested calls) aren't supported, but the common case of a single
	// call inside `={{ ... }}` works.
	const pattern = /\$fromAI\s*\([^)]*\)/g;
	return str.replace(pattern, (match) => {
		try {
			const inner = extractFromAICalls(match);
			if (inner.length === 0) return match;
			const call = inner[0];
			const resolved =
				call.key in llmArgs
					? llmArgs[call.key]
					: call.defaultValue !== undefined
						? call.defaultValue
						: undefined;
			if (resolved === undefined) return match;
			if (typeof resolved === 'object') return JSON.stringify(resolved);
			return String(resolved);
		} catch {
			return match;
		}
	});
}

// Single full-string expression like `={{ $json.foo.bar }}`. Captures the
// dotted path after `$json`. Bracket access and JS expressions are out of
// scope here — this resolver is for display only, not for actual node
// execution.
const FULL_JSON_REF_PATTERN = /^=\s*\{\{\s*\$json((?:\s*\.\s*[a-zA-Z_$][\w$]*)+)\s*\}\}\s*$/;
const INLINE_JSON_REF_PATTERN = /\{\{\s*\$json((?:\s*\.\s*[a-zA-Z_$][\w$]*)+)\s*\}\}/g;

function resolveJsonRefsInString(str: string, llmArgs: Record<string, unknown>): unknown {
	if (!str.startsWith('=') || !str.includes('$json')) return str;

	const fullMatch = str.match(FULL_JSON_REF_PATTERN);
	if (fullMatch) {
		const resolved = lookupJsonPath(llmArgs, fullMatch[1]);
		return resolved === undefined ? str : resolved;
	}

	let replaced = false;
	const out = str.replace(INLINE_JSON_REF_PATTERN, (match, path: string) => {
		const resolved = lookupJsonPath(llmArgs, path);
		if (resolved === undefined) return match;
		replaced = true;
		if (typeof resolved === 'object') return JSON.stringify(resolved);
		return String(resolved);
	});
	return replaced ? out : str;
}

function lookupJsonPath(root: Record<string, unknown>, dottedPath: string): unknown {
	const segments = dottedPath
		.split('.')
		.map((s) => s.trim())
		.filter((s) => s.length > 0);
	let cur: unknown = root;
	for (const seg of segments) {
		if (cur === null || cur === undefined) return undefined;
		if (typeof cur !== 'object') return undefined;
		cur = (cur as Record<string, unknown>)[seg];
	}
	return cur;
}

/**
 * Tool errors arrive on the `tool-result` chunk as raw `Error` instances
 * (see `agent-runtime.ts` → `tool-result` write on `batch.errors`). Persisting
 * those directly produces `"output": {}` because `Error.name`/`message`/`stack`
 * are non-enumerable, so the timeline drops the diagnostic the UI needs. Wrap
 * Errors and bare strings into an enumerable `{ error }` shape; pass through
 * objects that already carry their own shape.
 */
function normaliseToolErrorOutput(output: unknown): unknown {
	if (output instanceof Error) {
		return { error: output.message || output.name || 'Tool execution failed' };
	}
	if (typeof output === 'string') {
		return { error: output };
	}
	return output;
}

function normaliseStreamError(error: unknown): string {
	if (error instanceof Error) {
		return scrubSecretsInText(error.message || error.name || 'Agent execution failed');
	}
	if (typeof error === 'string') return scrubSecretsInText(error);

	const sanitized = sanitizeExecutionLogValue(error);
	try {
		return scrubSecretsInText(JSON.stringify(sanitized));
	} catch {
		return scrubSecretsInText(String(error));
	}
}

const REDACTED_VALUE = '[REDACTED]';
const CIRCULAR_VALUE = '[Circular]';

function isSecretKey(key: string): boolean {
	const probe = `${key}=value`;
	return scrubSecretsInText(probe) !== probe;
}

function sanitizeExecutionLogValue(value: unknown, seen = new WeakSet<object>()): unknown {
	if (typeof value === 'string') return scrubSecretsInText(value);

	if (Array.isArray(value)) {
		if (seen.has(value)) return CIRCULAR_VALUE;
		seen.add(value);
		const sanitized = value.map((item) => sanitizeExecutionLogValue(item, seen));
		seen.delete(value);
		return sanitized;
	}

	if (!isRecord(value)) return value;

	if (seen.has(value)) return CIRCULAR_VALUE;
	seen.add(value);

	const sanitized: Record<string, unknown> = {};
	for (const [key, item] of Object.entries(value)) {
		sanitized[key] = isSecretKey(key) ? REDACTED_VALUE : sanitizeExecutionLogValue(item, seen);
	}

	seen.delete(value);
	return sanitized;
}

function sanitizeExecutionLogRecord(value: unknown): Record<string, unknown> | undefined {
	const sanitized = sanitizeExecutionLogValue(value);
	return isRecord(sanitized) ? sanitized : undefined;
}

export interface ToolCallDetails {
	toolName: string;
	displayName?: string;
	kind: 'tool' | 'workflow' | 'node';
	input: unknown;
	node?: {
		type: string;
		typeVersion?: number;
		parameters?: Record<string, unknown>;
	};
	workflow?: {
		id?: string;
		name?: string;
		triggerType?: string;
	};
}

/** Build the sanitized, resolved tool configuration shown in preview approvals. */
export function buildToolCallDetails(
	registry: ToolRegistry,
	toolName: string,
	input: unknown,
): ToolCallDetails {
	const entry = registry.get(toolName);
	const kind = entry?.kind ?? 'tool';
	const details: ToolCallDetails = {
		toolName,
		kind,
		input: sanitizeExecutionLogValue(input),
	};

	if (entry?.nodeDisplayName) details.displayName = entry.nodeDisplayName;

	if (kind === 'node' && entry?.nodeType) {
		details.node = {
			type: entry.nodeType,
			...(entry.nodeTypeVersion !== undefined && { typeVersion: entry.nodeTypeVersion }),
			...(entry.nodeParameters !== undefined && {
				parameters: sanitizeExecutionLogRecord(
					resolveTemplatesInValue(entry.nodeParameters, isRecord(input) ? input : {}),
				),
			}),
		};
	} else if (kind === 'workflow') {
		details.workflow = {
			...(entry?.workflowId !== undefined && { id: entry.workflowId }),
			...(entry?.workflowName !== undefined && { name: entry.workflowName }),
			...(entry?.triggerType !== undefined && { triggerType: entry.triggerType }),
		};
		if (entry?.workflowName) details.displayName = entry.workflowName;
	}

	return details;
}

export interface RecordedUsage {
	promptTokens: number;
	completionTokens: number;
	totalTokens: number;
}

export type TimelineEvent =
	| { type: 'text'; content: string; timestamp: number; endTime?: number }
	| { type: 'reasoning'; content: string; timestamp: number; endTime?: number }
	| {
			type: 'tool-call';
			kind: 'tool' | 'workflow' | 'node';
			name: string;
			toolCallId: string;
			input: unknown;
			output: unknown;
			startTime: number;
			endTime: number;
			success: boolean;
			workflowId?: string;
			workflowName?: string;
			workflowExecutionId?: string;
			triggerType?: string;
			nodeType?: string;
			nodeTypeVersion?: number;
			nodeDisplayName?: string;
			/**
			 * Configured node parameters from the agent's JSON config (only set
			 * for `kind: 'node'`). The LLM's runtime args go into `input`; this
			 * field carries the node's actual configuration so the session-
			 * detail viewer can show what the node was set up to do.
			 */
			nodeParameters?: Record<string, unknown>;
			childTrace?: PersistedChildTrace;
	  }
	| {
			type: 'suspension';
			toolName: string;
			toolCallId: string;
			timestamp: number;
			input?: unknown;
			suspendPayload?: unknown;
	  }
	| {
			type: 'hitl-response';
			toolCallId: string;
			response: unknown;
			timestamp: number;
	  };

/**
 * Collects execution data from agent stream chunks.
 * Used to build an execution record after a message cycle completes.
 */
export interface MessageRecord {
	assistantResponse: string;
	model: string | null;
	finishReason: string;
	usage: RecordedUsage | null;
	totalCost: number | null;
	timeline: TimelineEvent[];
	startTime: number;
	duration: number;
	error: string | null;
}

export class ExecutionRecorder {
	private readonly registry: ToolRegistry;

	constructor(
		registry?: ToolRegistry,
		private readonly onTimelineSnapshot?: (timeline: TimelineEvent[]) => void,
	) {
		this.registry = registry ?? new Map();
	}

	private textParts: string[] = [];

	/** Text buffer for the current segment (flushed to timeline on boundaries). */
	private textBuffer: string[] = [];

	/** Reasoning buffer for the current segment (kept separate from user-facing text). */
	private reasoningBuffer: string[] = [];

	private model: string | null = null;

	private finishReason = 'unknown';

	private usage: RecordedUsage | null = null;

	private totalCost: number | null = null;

	private timeline: TimelineEvent[] = [];

	/** Wall-clock when the first text-delta of the current segment arrived. */
	private textStartTime: number | null = null;

	/** Wall-clock when the current reasoning segment started. */
	private reasoningStartTime: number | null = null;

	private timelineSnapshotTimer?: NodeJS.Timeout;

	private _suspended = false;

	private error: string | null = null;

	private readonly startTime = Date.now();

	private childTraceChars = new Map<string, number>();

	/** Record the human response that caused a suspended tool call to resume. */
	recordHitlResponse(toolCallId: string, response: unknown): void {
		this.flushReasoningBuffer();
		this.flushTextBuffer();
		this.appendCompletedEvent({
			type: 'hitl-response',
			toolCallId,
			response: sanitizeExecutionLogValue(response),
			timestamp: Date.now(),
		});
	}

	/** Feed a stream chunk into the recorder. */
	record(chunk: StreamChunk): void {
		switch (chunk.type) {
			case 'text-delta':
				this.flushReasoningBuffer();
				if (this.textStartTime === null) {
					this.textStartTime = Date.now();
				}
				this.textParts.push(chunk.delta);
				this.textBuffer.push(chunk.delta);
				this.scheduleTimelineSnapshot();
				break;
			case 'reasoning-start':
				this.flushTextBuffer();
				this.flushReasoningBuffer();
				this.reasoningStartTime = Date.now();
				break;
			case 'reasoning-delta':
				this.flushTextBuffer();
				if (this.reasoningStartTime === null) {
					this.reasoningStartTime = Date.now();
				}
				this.reasoningBuffer.push(chunk.delta);
				this.scheduleTimelineSnapshot();
				break;
			case 'reasoning-end':
				this.flushReasoningBuffer();
				break;
			case 'tool-call':
				this.recordToolCall(chunk.toolCallId, chunk.toolName, chunk.input);
				break;
			case 'tool-execution-start':
				this.recordToolExecutionStart(chunk.toolCallId, chunk.startTime);
				break;
			case 'tool-execution-end':
				this.recordToolExecutionEnd(chunk.toolCallId, chunk.isError, chunk.endTime);
				break;
			case 'subagent-chunk': {
				if (chunk.parentToolCallId === undefined) break;
				const entry = this.findOpenTimelineToolCall(chunk.parentToolCallId);
				if (!entry) break;
				let inner = chunk.chunk;
				if (inner.type === 'text-delta' || inner.type === 'reasoning-delta') {
					// Trim rather than skip whole: a delta straddling the cap would
					// otherwise be persisted in full and overshoot it.
					const used = this.childTraceChars.get(chunk.parentToolCallId) ?? 0;
					const remaining = CHILD_TRACE_PERSIST_CHAR_BUDGET - used;
					if (remaining <= 0) break;
					const delta = inner.delta.slice(0, remaining);
					this.childTraceChars.set(chunk.parentToolCallId, used + delta.length);
					inner = { ...inner, delta };
				}
				entry.childTrace ??= emptyChildTrace();
				applyForwardedChildChunk(entry.childTrace, inner);
				break;
			}
			case 'tool-result':
				this.recordToolResult(
					chunk.toolCallId,
					chunk.toolName,
					chunk.output,
					chunk.isError === true,
				);
				break;
			case 'finish':
				this.flushReasoningBuffer();
				this.flushTextBuffer();
				this.finishReason = chunk.finishReason;
				if (chunk.usage) {
					this.usage = {
						promptTokens: chunk.usage.promptTokens,
						completionTokens: chunk.usage.completionTokens,
						totalTokens: chunk.usage.totalTokens,
					};
				}
				this.model = chunk.model ?? null;
				this.totalCost = chunk.usage?.cost ?? null;
				break;
			case 'tool-call-suspended':
				this.flushReasoningBuffer();
				this.flushTextBuffer();
				this._suspended = true;
				this.appendCompletedEvent({
					type: 'suspension',
					toolName: chunk.toolName ?? '',
					toolCallId: chunk.toolCallId ?? '',
					timestamp: Date.now(),
					...(chunk.input !== undefined && {
						input: sanitizeExecutionLogValue(chunk.input),
					}),
					...(chunk.suspendPayload !== undefined && {
						suspendPayload: sanitizeExecutionLogValue(chunk.suspendPayload),
					}),
				});
				break;
			case 'error': {
				this.flushReasoningBuffer();
				this.flushTextBuffer();
				this.error ??= normaliseStreamError(chunk.error);
				break;
			}
		}
	}

	/** Whether the stream ended with a tool-call suspension (incomplete cycle). */
	get suspended(): boolean {
		return this._suspended;
	}

	get startedAt(): Date {
		return new Date(this.startTime);
	}

	/** Build the final message record after the stream has ended. */
	getMessageRecord(): MessageRecord {
		this.flushReasoningBuffer();
		this.flushTextBuffer();
		return {
			assistantResponse: this.textParts.join(''),
			model: this.model,
			finishReason: this.finishReason,
			usage: this.usage,
			totalCost: this.totalCost,
			timeline: this.timeline,
			startTime: this.startTime,
			duration: Date.now() - this.startTime,
			error: this.error,
		};
	}

	/** Flush accumulated text into a timeline event. */
	private flushTextBuffer(): void {
		if (this.textStartTime !== null && this.timelineSnapshotTimer) {
			clearTimeout(this.timelineSnapshotTimer);
			this.timelineSnapshotTimer = undefined;
		}
		if (this.textBuffer.length === 0) return;
		const content = this.textBuffer.join('');
		if (content.trim()) {
			const now = Date.now();
			this.appendCompletedEvent({
				type: 'text',
				content,
				// Generation start (first text-delta) → end (now). Falls back to `now`
				// if no start was recorded (defensive: empty segment shouldn't reach here).
				timestamp: this.textStartTime ?? now,
				endTime: now,
			});
		}
		this.textBuffer = [];
		this.textStartTime = null;
	}

	/** Flush accumulated reasoning without including it in `assistantResponse`. */
	private flushReasoningBuffer(): void {
		if (this.reasoningStartTime !== null && this.timelineSnapshotTimer) {
			clearTimeout(this.timelineSnapshotTimer);
			this.timelineSnapshotTimer = undefined;
		}
		if (this.reasoningBuffer.length === 0) {
			this.reasoningStartTime = null;
			return;
		}
		const content = this.reasoningBuffer.join('');
		if (content.trim()) {
			const now = Date.now();
			this.appendCompletedEvent({
				type: 'reasoning',
				content,
				timestamp: this.reasoningStartTime ?? now,
				endTime: now,
			});
		}
		this.reasoningBuffer = [];
		this.reasoningStartTime = null;
	}

	private scheduleTimelineSnapshot(): void {
		if (this.timelineSnapshotTimer) return;
		this.timelineSnapshotTimer = setTimeout(() => {
			this.timelineSnapshotTimer = undefined;
			const now = Date.now();
			if (this.textStartTime !== null && this.textBuffer.length > 0) {
				this.emitTimelineSnapshot({
					type: 'text',
					content: this.textBuffer.join(''),
					timestamp: this.textStartTime,
					endTime: now,
				});
			} else if (this.reasoningStartTime !== null && this.reasoningBuffer.length > 0) {
				this.emitTimelineSnapshot({
					type: 'reasoning',
					content: this.reasoningBuffer.join(''),
					timestamp: this.reasoningStartTime,
					endTime: now,
				});
			}
		}, TIMELINE_BLOCK_MAX_DURATION_MS);
		this.timelineSnapshotTimer.unref();
	}

	/**
	 * Record a discrete `tool-call` chunk from the stream. The matching
	 * `tool-result` chunk closes the timeline entry.
	 */
	private recordToolCall(toolCallId: string, name: string, input: unknown): void {
		this.flushReasoningBuffer();
		this.flushTextBuffer();

		const recordedInput = sanitizeExecutionLogValue(input);

		const entry = this.registry.get(name);
		// Resolve both `$fromAI(...)` placeholders and simple `={{ $json.x }}`
		// references in nodeParameters using the LLM's args, so the timeline
		// shows the values the node would have run with (e.g. the actual
		// prompt text) rather than raw template strings.
		const llmArgs =
			input !== null && typeof input === 'object' ? (input as Record<string, unknown>) : {};
		const resolvedNodeParameters =
			entry?.nodeParameters !== undefined
				? sanitizeExecutionLogRecord(resolveTemplatesInValue(entry.nodeParameters, llmArgs))
				: undefined;
		this.timeline.push({
			type: 'tool-call',
			kind: entry?.kind ?? 'tool',
			name,
			toolCallId,
			input: recordedInput,
			output: undefined as unknown,
			startTime: Date.now(),
			endTime: 0,
			success: false,
			workflowId: entry?.workflowId,
			workflowName: entry?.workflowName,
			triggerType: entry?.triggerType,
			nodeType: entry?.nodeType,
			nodeTypeVersion: entry?.nodeTypeVersion,
			nodeDisplayName: entry?.nodeDisplayName,
			nodeParameters: resolvedNodeParameters,
		});
	}

	/**
	 * Real per-tool execution start, bridged from the runtime event bus. The
	 * `tool-call` chunk only marks when the model emitted the call; this marks
	 * when the handler actually started. Uses the server-stamped `startTime`
	 * carried on the chunk so the persisted duration matches the live one
	 * exactly (the FE reads the same value off the stream).
	 */
	private recordToolExecutionStart(toolCallId: string, startTime: number): void {
		if (!toolCallId) return;
		const entry = this.findOpenTimelineToolCall(toolCallId);
		if (entry) {
			entry.startTime = startTime;
			this.emitTimelineSnapshot();
		}
	}

	/**
	 * Real per-tool execution end, bridged from the runtime event bus. Closes
	 * the timeline entry with the server-stamped finish time so concurrently-
	 * executed tools keep distinct durations — the batched `tool-result` chunks
	 * all arrive together and would otherwise share a single end timestamp.
	 */
	private recordToolExecutionEnd(toolCallId: string, isError: boolean, endTime: number): void {
		if (!toolCallId) return;
		const entry = this.findOpenTimelineToolCall(toolCallId);
		if (entry) {
			entry.endTime = endTime;
			entry.success = !isError;
			if (entry.childTrace) settleChildTrace(entry.childTrace);
			this.emitTimelineSnapshot();
		}
	}

	/** Most recent not-yet-closed timeline tool-call entry for a tool call id. */
	private findOpenTimelineToolCall(
		toolCallId: string,
	): (TimelineEvent & { type: 'tool-call' }) | undefined {
		return [...this.timeline]
			.reverse()
			.find(
				(e): e is TimelineEvent & { type: 'tool-call' } =>
					e.type === 'tool-call' && e.toolCallId === toolCallId && e.endTime === 0,
			);
	}

	/**
	 * Record a discrete `tool-result` chunk from the stream. Closes the
	 * matching open timeline entry by `toolCallId` (preferred) or by name as
	 * a fallback.
	 *
	 * On HITL/approval resume, the SDK replays the `tool-result` for the
	 * pending call without a preceding `tool-call`, so there is no open
	 * timeline entry to close. In that case we synthesize one from the
	 * registry so workflow rows and execution-log links still render.
	 */
	private recordToolResult(
		toolCallId: string,
		name: string,
		output: unknown,
		isError: boolean,
	): void {
		const recordedOutput = sanitizeExecutionLogValue(
			isError ? normaliseToolErrorOutput(output) : output,
		);

		const pendingTimeline = [...this.timeline]
			.reverse()
			.find(
				(e): e is TimelineEvent & { type: 'tool-call' } =>
					e.type === 'tool-call' &&
					(toolCallId ? e.toolCallId === toolCallId : e.name === name && e.endTime === 0),
			);
		if (pendingTimeline) {
			pendingTimeline.output = recordedOutput;
			pendingTimeline.success = !isError;
			// `tool-execution-end` (real per-tool finish) normally closed this entry
			// already; only fall back to the batched result time if it never fired.
			if (pendingTimeline.endTime === 0) {
				pendingTimeline.endTime = Date.now();
			}
			if (pendingTimeline.childTrace) settleChildTrace(pendingTimeline.childTrace);

			if (pendingTimeline.kind === 'workflow' && isRecord(recordedOutput)) {
				const execId = recordedOutput.executionId;
				if (typeof execId === 'string') {
					pendingTimeline.workflowExecutionId = execId;
				}
			}
			this.emitTimelineSnapshot();
			return;
		}

		this.flushReasoningBuffer();
		this.flushTextBuffer();
		const entry = this.registry.get(name);
		const now = Date.now();
		const synthesized: TimelineEvent = {
			type: 'tool-call',
			kind: entry?.kind ?? 'tool',
			name,
			toolCallId,
			input: undefined,
			output: recordedOutput,
			startTime: now,
			endTime: now,
			success: !isError,
			workflowId: entry?.workflowId,
			workflowName: entry?.workflowName,
			triggerType: entry?.triggerType,
			nodeType: entry?.nodeType,
			nodeTypeVersion: entry?.nodeTypeVersion,
			nodeDisplayName: entry?.nodeDisplayName,
			nodeParameters:
				entry?.nodeParameters !== undefined
					? sanitizeExecutionLogRecord(entry.nodeParameters)
					: undefined,
		};
		if (synthesized.kind === 'workflow' && isRecord(recordedOutput)) {
			const execId = recordedOutput.executionId;
			if (typeof execId === 'string') {
				synthesized.workflowExecutionId = execId;
			}
		}
		this.appendCompletedEvent(synthesized);
	}

	private appendCompletedEvent(event: TimelineEvent): void {
		this.timeline.push(event);
		this.emitTimelineSnapshot();
	}

	private emitTimelineSnapshot(activeEvent?: TimelineEvent): void {
		if (!this.onTimelineSnapshot) return;
		const timeline = activeEvent ? [...this.timeline, activeEvent] : this.timeline;
		this.onTimelineSnapshot(structuredClone(timeline));
	}
}
