import { tryReadConfig, writeConfig } from './config'
import { access, readdir, readFile } from 'node:fs/promises'
import path from 'node:path'
import {
	getSuiteFilePath,
	readSuite,
	type TestSuite,
	upsertSuiteMeta,
	withSuiteMutationLock,
	writeSuite,
	type TestRow as SuiteRow
} from './suite'
import { getRootPath } from '../lib/get-root-path'
import z from 'zod'
import type { ResultEntry, WrittenRunResult } from './academy'
import { makeContext, makeContextDiff } from './context-utils'

export type CsvRow = Record<string, string>

export type TestRow = SuiteRow

export const RunConfigSchema = z.object({
	repeats: z.number().optional(),
	concurrency: z.number().optional(),
	defaultContext: z.record(z.unknown()).optional(),
	expandedTestIds: z.array(z.string()).optional()
})

export type RunConfig = z.infer<typeof RunConfigSchema>

export interface RunResult {
	success: boolean
	result: string
	evaluation: string
	rendered_prompt: string
	received_prompt: string
	stringified_conversation: string
	received_context: string
	traces: any[]
	errors: string
	logs: string[]
}

export interface PreviousTestResultSummary {
	success_count: number
	total_runs: number
	report_timestamp: string // ISO
}

export interface TestResult {
	test_id: string
	success_rate_percent?: number
	success_rate?: string
	results: RunResult[]
	report_timestamp: string // ISO
	is_old: boolean
	previous?: PreviousTestResultSummary
}
export interface TestTable {
	rows: TestRow[]
	headers: string[]
}

const defaultTestRow: Omit<TestRow, 'id'> = {
	pinned: 'FALSE',
	scenario: 'New scenario',
	author: '',
	category: '',
	assertion_type: 'llm-rubric',
	assertion_value: '',
	conversation: 'user: ...',
	expected_output: '',
	timeout: '0',
	similarity_threshold: '60',
	context: {}
}

// Utility: ensure each row has a unique id; persist if any are missing/duplicated.
export async function loadTests(agent: string): Promise<TestTable> {
	return withSuiteMutationLock(agent, async () => {
		const suite = await readSuite(agent)
		let changed = false
		const seenIds = new Set<string>()
		const rows: TestRow[] = suite.tests.map((r) => {
			let id = String(r.id || '').trim()
			if (!id || seenIds.has(id)) {
				changed = true
				id = globalThis.crypto?.randomUUID?.() || randomUUID()
			}
			seenIds.add(id)
			// previously tests were storing context as a string...
			const context = typeof r.context === 'string' ? JSON.parse(r.context) : r.context
			return { ...r, context, id }
		})
		if (changed) {
			await writeSuite(agent, { ...suite, tests: rows })
		}
		const headers = [
			'id',
			'pinned',
			'scenario',
			'step',
			'author',
			'category',
			'assertion_type',
			'assertion_value',
			'conversation',
			'expected_output',
			'timeout',
			'similarity_threshold',
			'context'
		]
		return { rows, headers }
	})
}

// Utility: update a single row by id and write the CSV
export async function patchTest(
	agent: string,
	id: string,
	patch: Partial<TestRow>
): Promise<TestRow | null> {
	return withSuiteMutationLock(agent, async () => {
		const suite = await readSuite(agent)
		const idx = suite.tests.findIndex((r) => r.id === id)
		if (idx < 0) return null
		const current = suite.tests[idx]
		const updated = preparePatch(current, patch)
		const nextRows = suite.tests.map((r, i) => (i === idx ? updated : r))
		await writeSuite(agent, { ...suite, tests: nextRows })
		return updated
	})
}

function preparePatch(current: TestRow, patch: Partial<TestRow>) {
	return {
		...defaultTestRow,
		...current,
		...patch,
		context: makeContext(patch.context || current.context)
	}
}

// Batch update multiple rows in a single read/write to avoid race conditions
export async function patchTestsBatch(
	agent: string,
	updates: Array<{ id: string; patch: Partial<TestRow> }>
): Promise<TestRow[]> {
	if (!Array.isArray(updates) || updates.length === 0) return []
	return withSuiteMutationLock(agent, async () => {
		const suite = await readSuite(agent)
		const byId = new Map(suite.tests.map((r) => [r.id, r]))
		const updatedRows: TestRow[] = []
		for (const { id, patch } of updates) {
			const current = byId.get(id)
			if (!current) continue
			const updated = preparePatch(current, patch)
			byId.set(id, updated)
			updatedRows.push(updated)
		}
		const nextRows: TestRow[] = suite.tests.map((r) => byId.get(r.id) ?? r)
		await writeSuite(agent, { ...suite, tests: nextRows })
		return updatedRows
	})
}

// Add a new row at the top
export async function addTest(
	agent: string,
	mergeContexts: boolean,
	seed?: Partial<TestRow>
): Promise<TestRow> {
	return withSuiteMutationLock(agent, async () => {
		await ensureConfigExists(agent)
		const suite = await loadSuiteOrEmpty(agent)
		const id = globalThis.crypto?.randomUUID?.() || randomUUID()

		const newRow: TestRow = {
			...defaultTestRow,
			id,
			...seed,
			context: mergeContexts
				? makeContextDiff(seed?.context, suite.meta?.defaultContext)
				: makeContext(seed?.context)
		}
		await writeSuite(agent, { ...(suite as any), tests: [newRow, ...suite.tests] })
		await ensureTestExpandedByDefault(agent, newRow.id)
		return newRow
	})
}

async function ensureTestExpandedByDefault(agent: string, id: string): Promise<void> {
	const normalizedId = String(id ?? '').trim()
	if (!normalizedId) return

	const cfg = (await tryReadConfig(agent)) || {}
	const existingIds = Array.isArray((cfg as any).expandedTestIds)
		? (cfg as any).expandedTestIds
				.map((v: unknown) => (typeof v === 'string' ? v.trim() : ''))
				.filter((v: string) => v.length > 0)
		: []

	if (existingIds.includes(normalizedId)) return
	await writeConfig(agent, { ...(cfg as any), expandedTestIds: [...existingIds, normalizedId] })
}

async function ensureConfigExists(agent: string): Promise<void> {
	const existing = await tryReadConfig(agent)
	if (existing) return
	await writeConfig(agent, { repeats: 1, concurrency: 1, expandedTestIds: [] })
}

async function loadSuiteOrEmpty(agent: string): Promise<TestSuite> {
	const jsonlPath = await getSuiteFilePath(agent)
	const legacyPath = jsonlPath.replace(/\.jsonl$/, '.json')

	const exists = async (p: string) => {
		try {
			await access(p)
			return true
		} catch {
			return false
		}
	}

	if (await exists(jsonlPath)) return readSuite(agent)
	if (await exists(legacyPath)) return readSuite(agent)

	return { version: 1, meta: {}, tests: [] }
}

export async function deleteTest(agent: string, id: string): Promise<boolean> {
	return withSuiteMutationLock(agent, async () => {
		const suite = await readSuite(agent)
		const filtered = suite.tests.filter((r) => r.id !== id)
		if (filtered.length === suite.tests.length) return false
		await writeSuite(agent, { ...suite, tests: filtered })
		return true
	})
}

export async function reorderTests(agent: string, order: string[]): Promise<void> {
	await withSuiteMutationLock(agent, async () => {
		const suite = await readSuite(agent)
		const map = new Map(suite.tests.map((r) => [r.id, r]))
		const next: TestRow[] = []
		for (const id of order) {
			const r = map.get(id)
			if (r) {
				next.push(r)
				map.delete(id)
			}
		}
		// Append any rows that were not included in order (safety)
		for (const r of map.values()) next.push(r)
		await writeSuite(agent, { ...suite, tests: next })
	})
}

export async function loadConfig(agent: string): Promise<RunConfig> {
	const cfg = (await tryReadConfig(agent)) || {}
	// Always source defaultContext from the suite meta, not config
	let defaultContext: Record<string, unknown> = {}
	try {
		const suite = await readSuite(agent)
		defaultContext = (suite.meta?.defaultContext as any) || {}
	} catch {
		// ignore
	}

	const expandedTestIds = Array.isArray((cfg as any).expandedTestIds)
		? (cfg as any).expandedTestIds.filter((id: unknown) => typeof id === 'string')
		: []
	return {
		repeats: typeof (cfg as any).repeats === 'number' ? (cfg as any).repeats : 1,
		concurrency: typeof (cfg as any).concurrency === 'number' ? (cfg as any).concurrency : 1,
		defaultContext,
		expandedTestIds
	}
}

export async function saveConfig(agent: string, cfg: RunConfig): Promise<void> {
	// Merge patch into existing config
	const current = (await tryReadConfig(agent)) || {}
	const expandedTestIds = Array.isArray(cfg.expandedTestIds)
		? Array.from(
				new Set(
					cfg.expandedTestIds.map((id) => String(id || '').trim()).filter((id) => id.length > 0)
				)
			)
		: Array.isArray((current as any).expandedTestIds)
			? (current as any).expandedTestIds
			: undefined
	const next = {
		repeats: typeof cfg.repeats === 'number' ? cfg.repeats : (current as any).repeats,
		concurrency:
			typeof cfg.concurrency === 'number' ? cfg.concurrency : (current as any).concurrency,
		expandedTestIds
	}
	await writeConfig(agent, next)
	// Persist defaultContext exclusively in the suite meta (do not write to config)
	if (cfg.defaultContext) {
		await upsertSuiteMeta(agent, { defaultContext: cfg.defaultContext })
	}
}

// We no longer migrate legacy reports automatically. Please update old reports manually.

export async function loadLatestResultsByTest(agent: string): Promise<Record<string, TestResult>> {
	const projectRoot = path.dirname(await getRootPath())
	const outDir = path.join(projectRoot, 'data', 'test_runs')
	let entries: string[] = []
	try {
		entries = await readdir(outDir)
	} catch {
		return {}
	}
	const prefix = `${agent}.`
	const suffix = `.report.json`
	const files = entries.filter((n) => n.startsWith(prefix) && n.endsWith(suffix)).sort()
	if (!files.length) return {}

	const latestFile = files[files.length - 1]
	const latestTs = latestFile
		.slice(prefix.length, latestFile.length - suffix.length)
		.replace(/-/g, ':')

	const results: Record<string, TestResult> = {}
	for (let i = files.length - 1; i >= 0; i--) {
		const name = files[i]
		const full = path.join(outDir, name)
		const raw = await readFile(full, 'utf8')
		const parsed = JSON.parse(raw) as any
		const rows = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.rows) ? parsed.rows : []
		const ts = name.slice(prefix.length, name.length - suffix.length).replace(/-/g, ':')
		for (const r of rows) {
			const test_id = (r as any).test_id
			if (!test_id) {
				throw new Error(
					`Missing test_id in report ${name}. Please add 'test_id' and provenance columns manually.`
				)
			}
			const par = String((r as any).pinned_at_run ?? 'TRUE').toUpperCase()
			if (par === 'FALSE') continue

			const runResults: RunResult[] = Array.isArray((r as any).results)
				? ((r as any).results as RunResult[])
				: []
			const summary: PreviousTestResultSummary = {
				success_count: runResults.reduce((acc, rr) => acc + (rr?.success ? 1 : 0), 0),
				total_runs: runResults.length,
				report_timestamp: ts
			}

			const current = results[test_id]
			if (!current) {
				results[test_id] = {
					test_id,
					success_rate_percent: (r as any).success_rate_percent,
					success_rate: (r as any).success_rate,
					results: runResults,
					report_timestamp: ts,
					is_old: ts < latestTs
				}
				continue
			}
			if (!current.previous && ts < current.report_timestamp) {
				current.previous = summary
			}
		}
	}
	return results
}

// Fallback UUID for Node environments without global crypto
function randomUUID() {
	const rnd = () =>
		Math.floor(Math.random() * 0xffffffff)
			.toString(16)
			.padStart(8, '0')
	return `${rnd()}-${rnd().slice(0, 4)}-${rnd().slice(0, 4)}-${rnd().slice(0, 4)}-${rnd()}${rnd()}`
}

async function getRun(agent: string, ts?: string) {
	const projectRoot = path.dirname(await getRootPath())
	const outDir = path.join(projectRoot, 'data', 'test_runs')
	const file = `${agent}.${(ts || '').replace(/:/g, '-')}.report.json`
	const reportPath = ts ? path.join(outDir, file) : null

	async function readLatestPath(): Promise<string> {
		const entries = await readdir(outDir)
		const prefix = `${agent}.`
		const suffix = `.report.json`
		const candidates = entries.filter((n) => n.startsWith(prefix) && n.endsWith(suffix)).sort()
		if (!candidates.length) throw new Error('No reports found')
		return path.join(outDir, candidates[candidates.length - 1])
	}

	const chosen = reportPath || (await readLatestPath())
	const raw = await readFile(chosen, 'utf8')
	const parsed = JSON.parse(raw) as WrittenRunResult
	return parsed
}

type ResultKey = keyof Omit<ResultEntry, 'success'>

export async function getRunResultEntry(
	agent: string,
	testId: string,
	index = 0,
	ts?: string
): Promise<ResultEntry> {
	const result = await getRun(agent, ts)

	const row = result.rows.find((r) => r.test_id === testId)
	if (!row) throw new Error('Row not found in report')

	const run = row.results[index]
	if (!run) throw new Error(`Run not found for test ${testId}, index: ${index}`)

	return run
}

export async function getResultEntry(
	agent: string,
	testId: string,
	resultKey: ResultKey,
	index = 0,
	ts?: string
): Promise<string> {
	const result = await getRun(agent, ts)

	const row = result.rows.find((r) => r.test_id === testId)
	if (!row) throw new Error('Row not found in report')

	const run = row.results[index]
	if (!run) throw new Error(`Run not found for test ${testId}, index: ${index}`)

	let entryContent = run[resultKey]
	if (resultKey === 'traces') {
		entryContent = JSON.stringify(entryContent)
	}

	// TODO get separate RPCs for logs and traces (list)
	return Array.isArray(entryContent) ? entryContent.join('\n') : entryContent
}
