// cspell:words conv
import { type ProviderOptions } from 'promptfoo'
import * as promptfoo from 'promptfoo'
import { type Message, type StepTrace, type StepTracer } from '@recombine-ai/engine'
import { loadAgents } from './emulate-agents'
import { type Assertion as PFAssertion } from 'promptfoo'
// Backend: per-project JSON test suites (no CSV)
import { tryReadConfig } from './config'
import { mkdir, rename, unlink, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { readSuite } from './suite'
// Use types derived from promptfoo exports instead of incorrect default type imports

import type { ApiProvider, CallApiContextParams, ProviderResponse } from 'promptfoo'
import deepmerge from 'deepmerge'
import fs from 'node:fs'
import { getRootPath } from '../lib/get-root-path'
import { makeContext } from './context-utils'

// ----------------- Type aliases & helpers -----------------
type Context = Record<string, unknown>

type Conversation = Message[]

interface CsvRow {
	id?: string
	assertion_type: string
	assertion_value: string
	conversation: string
	expected_output: string
	similarity_threshold: number | string
	pinned: 'TRUE' | 'FALSE'
	context: string
	timeout: string
	scenario?: string
}

export interface ResultEntry {
	success: boolean
	result: string
	evaluation: string
	rendered_prompt: string
	received_prompt: string
	stringified_conversation: string
	received_context: string
	// Raw traces array for this run (sanitized where necessary)
	traces: StepTrace[]
	errors: string
	logs: string[]
}

export interface TableRow extends CsvRow {
	// Link to details view for this row
	prompts: string
	success_rate_percent: number
	success_rate: string
	// Per-run results (new format)
	results: ResultEntry[]
}

export interface WrittenRunReportConfig {
	repeats: number
	concurrency: number
	defaultContext: Record<string, any>
}

export interface WrittenRunReportRow extends TableRow {
	agent: string
	test_id: string
	run_timestamp: string
	run_config: string
	pinned_at_run: 'TRUE' | 'FALSE'
	run_scope: 'all' | 'pinned'
}

export interface WrittenRunReport {
	agent: string
	run_timestamp: string
	run_scope: 'all' | 'pinned'
	run_config: WrittenRunReportConfig
	rows: WrittenRunReportRow[]
}

type PromptTestsScope = 'all' | 'pinned'

type PromptTestsSelection = { scope: PromptTestsScope } | { testId: string }

const activeRunsBySuiteKey = new Map<string, AbortController>()

export type SuiteSelection = {
	// `suiteKey` is the filesystem/report key (e.g. `voiceSupport.en-gb`).
	// `agentId` is the canonical factory key (workflow export key, e.g. `voiceSupport`).
	// Keeping these separate avoids any parsing/guessing in the test runner.
	suiteKey: string
	agentId: string
	region?: string
}

export function cancelPromptTests(suiteKey: string): boolean {
	const controller = activeRunsBySuiteKey.get(suiteKey)
	if (!controller) return false
	controller.abort()
	return true
}

export async function runPromptTestsManaged(
	suite: SuiteSelection,
	selection: PromptTestsSelection = { scope: 'pinned' }
): Promise<WrittenRunReport> {
	if (activeRunsBySuiteKey.has(suite.suiteKey)) {
		throw new Error('Tests already running for this agent')
	}
	const controller = new AbortController()
	activeRunsBySuiteKey.set(suite.suiteKey, controller)
	try {
		return await runPromptTests(suite, selection, { signal: controller.signal })
	} finally {
		activeRunsBySuiteKey.delete(suite.suiteKey)
	}
}

// Deep merge helper using 'deepmerge' with array replacement semantics
const mergeContexts = (baseCtx: Record<string, any>, testCtx: Record<string, any>) =>
	deepmerge(baseCtx || {}, testCtx || {}, { arrayMerge: (_d, s, _o) => s })

// Parse a multi-line sheet conversation string into engine messages
function parseConversationFromText(conversation: string | undefined | null): Conversation {
	if (!conversation) return []

	// Greedily accumulate lines for a role until the next role marker.
	// Recognized markers (case-insensitive) at the beginning of a line:
	//   agent:, assistant:, user:, system:
	const lines = conversation.split(/\r?\n/)
	const messages: Conversation = []

	let currentRole: 'agent' | 'user' | 'system' | null = null
	let buffer: string[] = []

	const roleMatch = (line: string) => line.match(/^\s*(agent|assistant|user|system)\s*:\s*(.*)$/i)
	const flush = () => {
		if (currentRole !== null) {
			messages.push({ sender: currentRole, text: buffer.join('\n') })
		}
	}

	for (let i = 0; i < lines.length; i++) {
		const line = lines[i] ?? ''
		const m = roleMatch(line)
		if (m) {
			// Starting a new role block; flush previous
			flush()
			const roleRaw = (m[1] || '').toLowerCase()
			currentRole = roleRaw === 'assistant' ? 'agent' : (roleRaw as 'agent' | 'user' | 'system')
			buffer = [m[2] ?? '']
		} else if (currentRole !== null) {
			// Continuation of the current role block (allow blank lines)
			buffer.push(line)
		} else {
			// No current role block yet.
			// Previously we silently ignored preamble lines; that's almost always a test authoring mistake
			// (e.g. a malformed "User:"/"Agent:" prefix like "?Agent:").
			if (line.trim()) {
				const snippet = line.length > 200 ? `${line.slice(0, 200)}…` : line
				throw new Error(
					`Unparsed conversation content before the first role block at line ${i + 1}: ${snippet}\n` +
						`Start each block with "User:", "Agent:" (or "Assistant:"), or "System:".`
				)
			}
		}
	}

	// Flush the last buffered message
	flush()

	return messages.filter(
		(m) => m.sender === 'user' || m.sender === 'agent' || m.sender === 'system'
	)
}

// Map pinned sheet rows to common test inputs
function toTestInputs(
	rows: (CsvRow & { index: number })[],
	defaultContext?: Record<string, any>,
	selection: PromptTestsSelection = { scope: 'pinned' }
) {
	return rows
		.filter((r) => {
			if ('testId' in selection) return String(r.id ?? '') === String(selection.testId)
			return selection.scope === 'all' ? true : r.pinned === 'TRUE'
		})
		.map((row) => {
			let conversation: Conversation = []
			let conversationParseError: string | undefined
			try {
				conversation = parseConversationFromText(row.conversation)
			} catch (err) {
				conversation = []
				conversationParseError = err instanceof Error ? err.message : String(err)
			}

			return {
				id: row.id,
				scenario: row.scenario,
				conversation,
				conversationParseError,
				assertions: [
					{
						type: row.assertion_type as PFAssertion['type'],
						value: row.assertion_value
					},
					{
						type: 'similar' as 'similar',
						value: String(row.expected_output),
						threshold: Number(row.similarity_threshold) / 100
					}
				],
				context: (() => {
					const testCtx = makeContext(row.context)
					return defaultContext ? mergeContexts(defaultContext, testCtx) : testCtx
				})(),
				timeoutSeconds: (() => {
					const rawValue = row.timeout
					if (rawValue === undefined || rawValue === null || String(rawValue).trim() === '') {
						throw new Error('Timeout value is required')
					}
					const raw = Number(rawValue)
					if (isNaN(raw)) {
						throw new Error(`Invalid timeout value: ${rawValue}`)
					}
					if (raw > 1000) {
						throw new Error(`Timeout value exceeds maximum of 1000: ${raw}`)
					}
					if (raw < 0) {
						throw new Error(`Timeout value must be non-negative: ${raw}`)
					}
					return raw
				})()
			}
		})
}

export class RecombineAgentProvider implements ApiProvider {
	protected providerId: string
	public config: Record<string, unknown>
	private abortSignal?: AbortSignal

	constructor(options: ProviderOptions) {
		this.providerId = options.id || 'recombine-agent'
		this.config = options.config || {}
	}

	setAbortSignal(signal: AbortSignal | undefined) {
		this.abortSignal = signal
	}

	id() {
		return this.providerId
	}

	async callApi(_prompt: string, context?: CallApiContextParams): Promise<ProviderResponse> {
		const projectRoot = path.dirname(await getRootPath())
		const vars = (context?.vars || {}) as unknown as {
			agentId: string
			region?: string
			conversation: Conversation
			conversationParseError?: string
			context: Context
			timeoutSeconds?: number
		}

		if (typeof vars.conversationParseError === 'string' && vars.conversationParseError.trim()) {
			return { output: '', error: vars.conversationParseError }
		}

		let captured: string = ''
		const capturedLogs: string[] = []
		const capturedStepTraces: StepTrace[] = []

		// Streaming log writer; initialized after basic validation
		let logStream: any = null
		let logsFilePath = ''

		// Clone the conversation so parallel runs don't share the same array reference.
		// Shallow-clone each message; messages are simple { sender, text } objects in this flow.
		const conv: Conversation = Array.isArray(vars.conversation)
			? (vars.conversation as Conversation).map((m) => ({ ...m }))
			: []

		// Prepare a streaming log writer so logs are written as they occur
		{
			const runId = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
			const logsDir = path.join(projectRoot, 'data', 'logs')
			await mkdir(logsDir, { recursive: true })
			logsFilePath = path.join(logsDir, `capturedLogs_${runId}.log`)
			logStream = fs.createWriteStream(logsFilePath, { flags: 'a' })
		}

		const stepTracer: StepTracer = {
			addStepTrace: (stepTrace: StepTrace) => {
				try {
					console.log(`trace: ${stepTrace.name}`)
					capturedStepTraces.push(stepTrace)
				} catch {
					// best-effort capture; ignore serialization issues
				}
			},
			async flush() {}
		}

		const push = (level: 'log' | 'debug' | 'warn' | 'error', args: any[]) => {
			try {
				const raw = args
					.map((a) => {
						try {
							return typeof a === 'string' ? a : JSON.stringify(a)
						} catch {
							return String(a)
						}
					})
					.join(' ')
				const prefix = `[${level.toUpperCase()}] `
				let line = `${prefix}${raw}`
				// Enforce max 5000 chars per entry for logs
				if (line.length > 5000) {
					line = line.slice(0, 5000) + '... [TRUNCATED]'
				}
				capturedLogs.push(line)
				try {
					logStream.write(line + '\n')
				} catch {
					/* best-effort stream write */
				}
			} catch {
				// best-effort capture
			}
		}
		const logger = {
			log: (...args: any[]) => push('log', args),
			debug: (...args: any[]) => push('debug', args),
			warn: (...args: any[]) => push('warn', args),
			error: (...args: any[]) => push('error', args)
		}

		try {
			// Run the agent via loadAgents using provided conversation
			await loadAgents({
				agentId: vars.agentId,
				region: vars.region,
				getMessages: () => conv,
				sendMessage: async (message: Message | string) => {
					const m = typeof message === 'string' ? { sender: 'agent', text: message } : message
					if (m.sender === 'agent') {
						captured = typeof m.text === 'string' ? m.text : JSON.stringify(m)
						logger.log('Agent message captured:', captured)
					}
				},
				stepTracer,
				logger,
				context: vars.context || {},
				timeoutSeconds: Number(vars.timeoutSeconds),
				abortSignal: this.abortSignal
			})

			const logsDir = path.join(projectRoot, 'data', 'logs')
			await mkdir(logsDir, { recursive: true })
			const filePath = path.join(logsDir, `capturedStepTraces_${Date.now()}.json`)
			await writeFile(filePath, JSON.stringify(capturedStepTraces, null, 2))

			// Prepare sanitized traces for sheet output
			const sanitizeTrace = (t: StepTrace): any => {
				try {
					const clone: any = { ...t }
					const isMain = clone?.name === 'main' || clone?.name === 'mainReply'
					// Truncate large prompt fields on non-main steps
					const truncate = (s: any, max = 5000) => {
						if (typeof s !== 'string') return s
						return s.length > max ? s.slice(0, max) + '... [TRUNCATED]' : s
					}
					if (isMain) {
						// Hide main step rendered/received prompts entirely in traces
						if ('renderedPrompt' in clone) delete clone.renderedPrompt
						if ('receivedPrompt' in clone) delete clone.receivedPrompt
					} else {
						if (typeof clone.renderedPrompt !== 'undefined') {
							clone.renderedPrompt = truncate(clone.renderedPrompt)
						}
						if (typeof clone.receivedPrompt !== 'undefined') {
							clone.receivedPrompt = truncate(clone.receivedPrompt)
						}
					}
					return clone
				} catch {
					return t
				}
			}
			const sanitizedStepTraces = Array.isArray(capturedStepTraces)
				? capturedStepTraces.map(sanitizeTrace)
				: []

			if (captured && captured.trim()) {
				return {
					output: captured,
					metadata: {
						logs: capturedLogs,
						stepTraces: sanitizedStepTraces,
						rawStepTraces: capturedStepTraces
					}
				}
			}

			return {
				output: '',
				error: 'No agent reply captured',
				metadata: {
					logs: capturedLogs,
					stepTraces: sanitizedStepTraces,
					rawStepTraces: capturedStepTraces
				}
			}
		} catch (err) {
			const msg = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
			capturedLogs.push(`[ERROR] ${msg}`)
			try {
				logStream.write(`[ERROR] ${msg}\n`)
			} catch {
				/* best-effort */
			}
			return {
				output: '',
				error: msg,
				metadata: { logs: capturedLogs, stepTraces: capturedStepTraces }
			}
		} finally {
			// Ensure the stream flushes all data
			if (logStream) {
				await new Promise<void>((resolve) => {
					try {
						logStream.end(resolve)
					} catch {
						resolve()
					}
				})
			}
		}
	}
}

// ----------------- Helpers -----------------
// Derive the result type returned inside Eval from promptfoo.evaluate
type PFEval = Awaited<ReturnType<typeof promptfoo.evaluate>>
type PFEvalResult = PFEval['results'][number]

const toStringSafe = (v: unknown): string => {
	if (typeof v === 'string') return v
	try {
		return JSON.stringify(v)
	} catch {
		return String(v)
	}
}

function toResultEntry(r: any): ResultEntry {
	const rawTraces = Array.isArray(r.metadata?.rawStepTraces)
		? r.metadata?.rawStepTraces
		: Array.isArray(r.metadata?.stepTraces)
			? r.metadata?.stepTraces
			: []
	// Pick the last trace whose name === 'main'
	const lastMain = (() => {
		for (let i = rawTraces.length - 1; i >= 0; i--) {
			const t = rawTraces[i]
			if (t && t.name === 'main') return t
		}
		return undefined
	})()
	const rendered_prompt =
		lastMain && typeof lastMain.renderedPrompt !== 'undefined'
			? toStringSafe(lastMain.renderedPrompt)
			: 'NO RENDERED PROMPT'
	const received_prompt =
		lastMain && typeof lastMain.receivedPrompt !== 'undefined'
			? toStringSafe(lastMain.receivedPrompt)
			: 'NO RECEIVED PROMPT'
	const stringified_conversation =
		lastMain && typeof lastMain.stringifiedConversation !== 'undefined'
			? toStringSafe(lastMain.stringifiedConversation)
			: 'NO STRINGIFIED CONVERSATION'
	const received_context =
		lastMain && typeof lastMain.receivedContext !== 'undefined'
			? toStringSafe(lastMain.receivedContext)
			: 'NO RECEIVED CONTEXT'

	const logs = Array.isArray(r.metadata?.logs) ? (r.metadata?.logs as string[]) : []
	const traces = Array.isArray(r.metadata?.stepTraces) ? r.metadata?.stepTraces : []
	return {
		success: !!r.success,
		result: String(r.response?.output ?? ''),
		evaluation: String(r.gradingResult?.reason ?? ''),
		rendered_prompt,
		received_prompt,
		stringified_conversation,
		received_context,
		traces,
		errors: String(r.error ?? ''),
		logs
	}
}

function buildTableRows(
	evals: Array<Awaited<ReturnType<typeof promptfoo.evaluate>>>,
	csvRows: CsvRow[],
	agentId: string
): TableRow[] {
	const bucket = new Map<string, PFEvalResult[]>()

	for (const ev of evals) {
		for (const res of ev.results) {
			const testId = String(res.metadata?.testId ?? '')
			;(bucket.get(testId) ?? bucket.set(testId, []).get(testId)!).push(res)
		}
	}

	return csvRows.map((row) => {
		const rowId = row.id
		const evalResults = bucket.get(String(rowId)) ?? []
		const passCount = evalResults.filter((r) => r.success).length
		const totalRuns = evalResults.length
		const pct = totalRuns ? Math.round((passCount / totalRuns) * 100) : 0
		const rateStr = `${passCount}/${totalRuns}`

		const results: ResultEntry[] = evalResults.map((r) => toResultEntry(r))

		return {
			...row,
			prompts: `suite://${agentId}?testId=${rowId}`,
			success_rate_percent: pct,
			success_rate: rateStr,
			results
		}
	})
}

export async function runPromptTests(
	suite: SuiteSelection,
	selection: PromptTestsSelection = { scope: 'pinned' },
	opts?: { signal?: AbortSignal }
): Promise<WrittenRunReport> {
	const signal = opts?.signal
	const scope = 'scope' in selection ? selection.scope : 'pinned'
	const onlyTestId = 'testId' in selection ? selection.testId : undefined
	const suiteKey = suite.suiteKey
	const loadedSuite = await readSuite(suiteKey)
	const rows = loadedSuite.tests as unknown as CsvRow[]
	const cfg = (await tryReadConfig(suiteKey)) || {}

	const repeats = Number(cfg?.repeats ?? 1)
	const concurrency = Number(cfg?.concurrency ?? 1)
	const baseContext = loadedSuite?.meta?.defaultContext || {}

	// Add an index field to each row for mapping
	const typedRows = rows as unknown as CsvRow[]
	const indexedRows = typedRows.map((row, index) => ({ ...row, index }))

	if (onlyTestId && !indexedRows.some((row) => String(row.id ?? '') === String(onlyTestId))) {
		throw new Error(`Test not found: ${onlyTestId}`)
	}

	const tests = toTestInputs(indexedRows, baseContext, selection)

	// Provider function with stable label
	const providerInstance = new RecombineAgentProvider({
		id: 'recombine-agent'
	})
	providerInstance.setAbortSignal(signal)
	const providerFn = Object.assign(
		(_promptStr: string, ctx?: CallApiContextParams) => {
			if (signal?.aborted) {
				throw new Error('Operation cancelled')
			}
			return providerInstance.callApi(_promptStr, ctx)
		},
		{ label: 'recombine-agent' }
	)

	const hookKey = `__bosun_promptfoo_hook_${process.pid}_${Date.now()}_${Math.random().toString(16).slice(2)}`
	const extensionCode = `globalThis[${JSON.stringify(hookKey)}]?.(output, context)`

	const testSuites = tests.map((test) => ({
		prompts: ['agent-run'], // placeholder; provider ignores
		scenario: test.scenario,
		tests: [
			{
				metadata: { testId: test.id },
				assert: test.assertions,
				model: 'not used!',
				vars: {
					agentId: suite.agentId,
					...(suite.region ? { region: suite.region } : {}),
					context: test.context,
					conversation: test.conversation,
					...(test.conversationParseError
						? { conversationParseError: test.conversationParseError }
						: {}),
					timeoutSeconds: (test as any).timeoutSeconds
				}
			}
		],
		providers: [providerFn],
		extensions: [extensionCode]
	}))

	const projectRoot = path.dirname(await getRootPath())
	const outDir = path.join(projectRoot, 'data', 'test_runs')
	await mkdir(outDir, { recursive: true })

	// Use full ISO timestamp and sanitize ':' for filesystem safety
	const iso = new Date().toISOString()
	const isoFile = iso.replace(/:/g, '-')
	const outPath = path.join(outDir, `${suiteKey}.${isoFile}.report.json`)

	async function writeReport(report: WrittenRunReport) {
		const dir = path.dirname(outPath)
		const base = path.basename(outPath)
		const tmpPath = path.join(
			dir,
			`.${base}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`
		)
		const json = JSON.stringify(report, null, 2)
		await writeFile(tmpPath, json, 'utf8')
		try {
			await rename(tmpPath, outPath)
		} catch (e: any) {
			// Windows doesn't allow atomic rename over an existing file.
			try {
				await unlink(outPath)
			} catch {
				// ignore
			}
			await rename(tmpPath, outPath)
		}
	}

	const runConfig: WrittenRunReportConfig = {
		repeats,
		concurrency,
		defaultContext: baseContext
	}

	const body: WrittenRunReport = {
		agent: suiteKey,
		run_timestamp: iso,
		run_scope: scope,
		run_config: runConfig,
		rows: []
	}

	// Write an empty report immediately so clients can start polling this run.
	await writeReport(body)

	const byTestId = new Map<string, WrittenRunReportRow>()

	const originalRowById = new Map<string, CsvRow>()
	for (const r of typedRows) {
		const id = String((r as any)?.id ?? '').trim()
		if (id) originalRowById.set(id, r)
	}

	const countsByTestId = new Map<string, { pass: number; total: number }>()

	let writeTimer: ReturnType<typeof setTimeout> | null = null
	let writePending = false
	let writeChain: Promise<void> = Promise.resolve()
	const enqueueWrite = () => {
		writeChain = writeChain.catch(() => {}).then(() => writeReport(body))
		return writeChain
	}
	const requestWrite = () => {
		writePending = true
		if (writeTimer) return
		writeTimer = setTimeout(() => {
			writeTimer = null
			if (!writePending) return
			writePending = false
			void enqueueWrite()
		}, 200)
	}

	;(globalThis as any)[hookKey] = (hookName: string, ctx: any) => {
		try {
			if (hookName !== 'afterEach') return
			if (signal?.aborted) {
				throw new Error('Operation cancelled')
			}
			const testId = String(ctx?.test?.metadata?.testId ?? '')
			if (!testId) return
			const r = ctx?.result
			if (!r) return

			const originalRow = originalRowById.get(testId)
			if (!originalRow) return

			let reportRow = byTestId.get(testId)
			if (!reportRow) {
				const pinned: 'TRUE' | 'FALSE' =
					String((originalRow as any).pinned || '').toUpperCase() === 'TRUE' ? 'TRUE' : 'FALSE'
				const nextRow: WrittenRunReportRow = {
					...(originalRow as any),
					prompts: `suite://${suiteKey}?testId=${testId}`,
					success_rate_percent: 0,
					success_rate: '0/0',
					results: [],
					agent: suiteKey,
					test_id: testId,
					run_timestamp: iso,
					run_config: JSON.stringify(runConfig),
					pinned_at_run: scope === 'all' || !!onlyTestId ? 'TRUE' : pinned,
					run_scope: scope
				}
				reportRow = nextRow
				byTestId.set(testId, nextRow)
				body.rows = Array.from(byTestId.values())
			}
			if (!reportRow) return

			const entry = toResultEntry(r)
			reportRow.results.push(entry)

			const counts = countsByTestId.get(testId) ?? { pass: 0, total: 0 }
			counts.total += 1
			if (entry.success) counts.pass += 1
			countsByTestId.set(testId, counts)

			reportRow.success_rate = `${counts.pass}/${counts.total}`
			reportRow.success_rate_percent = counts.total
				? Math.round((counts.pass / counts.total) * 100)
				: 0

			requestWrite()
		} catch (err) {
			if (signal?.aborted) throw err
			// ignore
		}
	}

	try {
		for (const suite of testSuites) {
			if (signal?.aborted) break
			const testId = String((suite.tests?.[0] as any)?.metadata?.testId ?? '')
			const originalRow = testId ? typedRows.find((r) => String(r.id) === testId) : undefined
			try {
				console.log(`Running: ${suite.scenario}`)
				const ev = await promptfoo.evaluate(suite, {
					repeat: repeats,
					cache: false,
					maxConcurrency: concurrency,
					abortSignal: signal
				})

				if (originalRow && testId) {
					const rowToWrite = buildTableRows([ev], [originalRow], suiteKey)[0]
					const pinned: 'TRUE' | 'FALSE' =
						(rowToWrite.pinned || '').toUpperCase() === 'TRUE' ? 'TRUE' : 'FALSE'
					byTestId.set(testId, {
						...rowToWrite,
						agent: suiteKey,
						test_id: testId,
						run_timestamp: iso,
						run_config: JSON.stringify(runConfig),
						pinned_at_run: scope === 'all' || !!onlyTestId ? 'TRUE' : pinned,
						run_scope: scope
					})
					body.rows = Array.from(byTestId.values())
					await enqueueWrite()
				}
			} catch (err) {
				const cancelled =
					!!signal?.aborted ||
					(err instanceof Error && /operation cancelled/i.test(err.message || ''))
				if (cancelled) {
					break
				}
				const msg = err instanceof Error ? `${err.name}: ${err.message}` : String(err)
				const ev = {
					results: [
						{
							success: false,
							response: { output: '' },
							error: msg,
							metadata: { testId: (suite.tests?.[0] as any)?.metadata?.testId }
						} as any
					]
				} as any

				// Persist failure row immediately for real-time UI updates.
				if (originalRow && testId) {
					const rowToWrite = buildTableRows([ev], [originalRow], suiteKey)[0]
					const pinned: 'TRUE' | 'FALSE' =
						(rowToWrite.pinned || '').toUpperCase() === 'TRUE' ? 'TRUE' : 'FALSE'
					byTestId.set(testId, {
						...rowToWrite,
						agent: suiteKey,
						test_id: testId,
						run_timestamp: iso,
						run_config: JSON.stringify(runConfig),
						pinned_at_run: scope === 'all' || !!onlyTestId ? 'TRUE' : pinned,
						run_scope: scope
					})
					body.rows = Array.from(byTestId.values())
					await enqueueWrite()
				}
			}
		}
	} finally {
		if (writeTimer) clearTimeout(writeTimer)
		writeTimer = null
		delete (globalThis as any)[hookKey]
		await writeChain
	}

	// Ensure the final report is written even if no tests ran (or an update was skipped).
	body.rows = Array.from(byTestId.values())
	await enqueueWrite()

	return body
}

export type WrittenRunResult = Awaited<ReturnType<typeof runPromptTests>>
