#!/usr/bin/env node

/**
 * apt-proof-video — Gemini-powered video analysis for proof verification
 *
 * Sends a Playwright .webm recording to Gemini and returns structured
 * verification analysis. Used by /apt:verify-proof for video evidence.
 *
 * Requires: GEMINI_API_KEY environment variable
 * Optional dep: @google/genai (installed when user opts into video verification)
 *
 * Usage:
 *   apt-proof-video analyze <video-path> [--model <model>] [--spec <spec-summary>]
 *   apt-proof-video capture           Record frames via CDP (multi-session mode)
 *   apt-proof-video report            Generate HTML report from a proof directory
 *   apt-proof-video models            List available Gemini models
 *   apt-proof-video --help            Print usage
 *
 * All output is JSON to stdout. Errors go to stderr.
 */

import { execSync } from 'node:child_process'
import {
	existsSync,
	mkdirSync,
	readdirSync,
	readFileSync,
	rmSync,
	statSync,
	unlinkSync,
	writeFileSync,
} from 'node:fs'
import * as http from 'node:http'
import { basename, dirname, extname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

const MODEL_PRESETS = {
	default: 'gemini-2.5-flash',
	thorough: 'gemini-3.1-pro-preview',
	fast: 'gemini-3.1-flash-lite-preview',
}

const SUPPORTED_MEDIA_TYPES = {
	// Video
	'.webm': 'video/webm',
	'.mp4': 'video/mp4',
	'.mov': 'video/mov',
	'.avi': 'video/avi',
	'.mpg': 'video/mpg',
	'.mpeg': 'video/mpeg',
	'.wmv': 'video/wmv',
	'.flv': 'video/x-flv',
	'.3gp': 'video/3gpp',
	// Images (Gemini supports these for screenshot analysis)
	'.jpg': 'image/jpeg',
	'.jpeg': 'image/jpeg',
	'.png': 'image/png',
	'.gif': 'image/gif',
	'.webp': 'image/webp',
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function output(data) {
	process.stdout.write(JSON.stringify(data, null, 2) + '\n')
}

function fail(message) {
	process.stderr.write(JSON.stringify({ error: message }) + '\n')
	process.exit(1)
}

function getVersion() {
	const pkgPath = resolve(__dirname, '..', 'package.json')
	const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
	return `apt-proof-video (${pkg.name} ${pkg.version})`
}

function resolveModel(modelArg) {
	if (!modelArg || modelArg === 'default') return MODEL_PRESETS.default
	if (MODEL_PRESETS[modelArg]) return MODEL_PRESETS[modelArg]
	// Allow passing a full model ID directly
	return modelArg
}

function getMimeType(filePath) {
	const ext = filePath.slice(filePath.lastIndexOf('.')).toLowerCase()
	return SUPPORTED_MEDIA_TYPES[ext] || null
}

// ---------------------------------------------------------------------------
// Prompts
// ---------------------------------------------------------------------------

function buildVerificationPrompt(specSummary) {
	const base = `You are a QA engineer reviewing a Playwright browser test recording. Provide a structured analysis in JSON format.

Return ONLY valid JSON with this exact structure:
{
  "pages": [
    { "url_or_description": "string", "timestamp_start": "MM:SS", "timestamp_end": "MM:SS" }
  ],
  "ui_elements": [
    { "type": "button|link|heading|input|nav|text|image|other", "text": "exact visible text", "location": "header|sidebar|main|footer|modal" }
  ],
  "interactions": [
    { "timestamp": "MM:SS", "action": "click|scroll|type|hover|navigate", "target": "description of element", "result": "what happened" }
  ],
  "anomalies": [
    { "timestamp": "MM:SS", "type": "layout-shift|slow-load|visual-glitch|error|unexpected-state", "description": "string", "severity": "critical|warning|info" }
  ],
  "verdict": {
    "result": "pass|fail|partial",
    "confidence": 0.95,
    "summary": "one-line summary",
    "details": "detailed explanation"
  }
}`

	if (specSummary) {
		return `${base}

The spec requirements for this feature are:
${specSummary}

Verify that the recording demonstrates these requirements being met.`
	}

	return base
}

function buildDescriptionPrompt() {
	return `You are reviewing a Playwright browser test recording. Describe EVERYTHING you see:

1. What website/page is shown?
2. What UI elements are visible (buttons, text, navigation, logos)?
3. What interactions/actions happen (clicks, scrolls, navigations)?
4. What page transitions occur?
5. What is the final state of the page?

Be extremely specific — mention exact text, colors, layout details.`
}

// ---------------------------------------------------------------------------
// SDK loader (handles missing @google/genai gracefully)
// ---------------------------------------------------------------------------

async function loadGenAI() {
	try {
		const mod = await import('@google/genai')
		return mod.GoogleGenAI
	} catch {
		fail(
			'@google/genai is not installed. Run: npm install @google/genai\n' +
				'This is required for Gemini video analysis in apt-proof-video.',
		)
	}
}

// ---------------------------------------------------------------------------
// Commands
// ---------------------------------------------------------------------------

async function cmdAnalyze(args) {
	const videoPath = args[0]
	if (!videoPath)
		fail('Usage: apt-proof-video analyze <video-path> [--model <model>] [--spec <summary>]')

	const resolvedPath = resolve(videoPath)
	if (!existsSync(resolvedPath)) fail(`Video file not found: ${resolvedPath}`)

	const mimeType = getMimeType(resolvedPath)
	if (!mimeType)
		fail(
			`Unsupported format: ${basename(resolvedPath)}. Supported: ${Object.keys(SUPPORTED_MEDIA_TYPES).join(', ')}`,
		)

	// Parse flags
	const modelIdx = args.indexOf('--model')
	const modelArg = modelIdx >= 0 ? args[modelIdx + 1] : 'default'
	const model = resolveModel(modelArg)

	const specIdx = args.indexOf('--spec')
	const specSummary = specIdx >= 0 ? args[specIdx + 1] : null

	const describeMode = args.includes('--describe')

	// Check API key
	const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
	if (!apiKey)
		fail(
			'Set GEMINI_API_KEY or GOOGLE_API_KEY environment variable. Get a free key at: https://aistudio.google.com/apikey',
		)

	// Load SDK
	const GoogleGenAI = await loadGenAI()
	const ai = new GoogleGenAI({ apiKey })

	// Read video
	const videoData = readFileSync(resolvedPath)
	const base64Video = videoData.toString('base64')
	const fileSizeKB = (videoData.length / 1024).toFixed(0)

	// Build prompt
	const prompt = describeMode ? buildDescriptionPrompt() : buildVerificationPrompt(specSummary)

	// Call Gemini
	const startTime = Date.now()

	process.stderr.write(`Analyzing ${basename(resolvedPath)} (${fileSizeKB} KB) with ${model}...\n`)

	try {
		const response = await ai.models.generateContent({
			model,
			contents: [
				{
					role: 'user',
					parts: [{ inlineData: { mimeType, data: base64Video } }, { text: prompt }],
				},
			],
		})

		const elapsed = ((Date.now() - startTime) / 1000).toFixed(1)
		const text = response.text

		// Try to parse as JSON (verification mode returns JSON)
		let analysis
		if (!describeMode) {
			try {
				// Strip markdown code fences if present
				const jsonStr = text.replace(/^```json\n?/, '').replace(/\n?```$/, '')
				analysis = JSON.parse(jsonStr)
			} catch {
				// If JSON parse fails, return as raw text
				analysis = { raw: text }
			}
		} else {
			analysis = { description: text }
		}

		output({
			status: 'ok',
			video: {
				path: resolvedPath,
				filename: basename(resolvedPath),
				sizeKB: parseInt(fileSizeKB),
				mimeType,
			},
			model,
			elapsed: parseFloat(elapsed),
			analysis,
		})
	} catch (err) {
		fail(`Gemini API error: ${err.message}`)
	}
}

// ---------------------------------------------------------------------------
// CDP capture helpers (self-contained, no build dependency)
// ---------------------------------------------------------------------------

/**
 * Generic CDP HTTP helper — fetches JSON from any CDP endpoint path.
 * Fully closes the HTTP connection before resolving to avoid conflicts with
 * the subsequent WebSocket connection.
 */
function cdpFetchJson(host, port, path) {
	return new Promise((resolve, reject) => {
		const req = http.get(`http://${host}:${port}${path}`, (res) => {
			let body = ''
			res.on('data', (chunk) => {
				body += chunk.toString()
			})
			res.on('end', () => {
				// Fully close HTTP connection before resolving — CDP only allows
				// one debugger connection per target. If this socket lingers,
				// the subsequent WebSocket connection will silently fail to
				// receive screencast frames.
				res.destroy()
				req.destroy()
				try {
					resolve(JSON.parse(body))
				} catch (err) {
					reject(new Error(`Failed to parse ${path}: ${err.message}`))
				}
			})
		})
		req.on('error', reject)
		req.setTimeout(5000, () => {
			req.destroy()
			reject(new Error(`CDP endpoint timeout at ${host}:${port}${path}`))
		})
	})
}

async function cmdCapture(args) {
	// Parse flags
	const portIdx = args.indexOf('--port')
	const port = portIdx >= 0 ? parseInt(args[portIdx + 1], 10) : 9222

	const hostIdx = args.indexOf('--host')
	const host = hostIdx >= 0 ? args[hostIdx + 1] : 'localhost'

	const durationIdx = args.indexOf('--duration')
	const duration = durationIdx >= 0 ? parseInt(args[durationIdx + 1], 10) : 30

	const fpsIdx = args.indexOf('--fps')
	const fps = fpsIdx >= 0 ? parseInt(args[fpsIdx + 1], 10) : 2

	const outputIdx = args.indexOf('--output')
	const outputPath = outputIdx >= 0 ? resolve(args[outputIdx + 1]) : resolve('./capture-frames')

	const qualityIdx = args.indexOf('--quality')
	const quality = qualityIdx >= 0 ? parseInt(args[qualityIdx + 1], 10) : 80

	const targetIdIdx = args.indexOf('--target')
	const requestedTargetId = targetIdIdx >= 0 ? args[targetIdIdx + 1] : null

	// Fetch page targets from /json
	process.stderr.write(`Checking CDP endpoint at ${host}:${port}...\n`)
	let targets
	try {
		targets = await cdpFetchJson(host, port, '/json')
	} catch (err) {
		fail(
			`CDP not available at ${host}:${port}. Make sure the app is running with --remote-debugging-port=${port}.\nError: ${err.message}`,
		)
	}

	if (!targets || targets.length === 0) {
		fail(`No debuggable pages found at ${host}:${port}.`)
	}

	// Select target
	let target
	if (requestedTargetId) {
		target = targets.find((t) => t.id === requestedTargetId)
		if (!target)
			fail(
				`Target ${requestedTargetId} not found. Available: ${targets.map((t) => t.id).join(', ')}`,
			)
	} else {
		target = targets.find((t) => t.type === 'page') ?? targets[0]
	}

	// Fetch browser-level WebSocket URL from /json/version
	let browserWsUrl
	try {
		const versionData = await cdpFetchJson(host, port, '/json/version')
		browserWsUrl = versionData.webSocketDebuggerUrl
		if (!browserWsUrl) throw new Error('webSocketDebuggerUrl not found in /json/version response')
	} catch (err) {
		fail(`Failed to get browser WebSocket URL: ${err.message}`)
	}

	// Wait for HTTP connections to fully close before opening WebSocket
	await new Promise((r) => setTimeout(r, 100))

	// Create output directory
	mkdirSync(outputPath, { recursive: true })

	process.stderr.write(`Connecting to browser-level CDP (multi-session mode)...\n`)
	process.stderr.write(`Target: ${target.url}\n`)
	process.stderr.write(`Capturing ${duration}s at ~${fps} fps\n`)
	process.stderr.write(`Output: ${outputPath}\n`)

	const framePaths = []
	let frameIndex = 0
	let cmdId = 1
	const startTime = Date.now()
	const lastFrameTime = 0
	const minIntervalMs = (1000 / fps) * 0.8

	// Capture using Page.captureScreenshot polling (works alongside MCP/other debuggers)
	// Unlike Page.startScreencast which is limited to one per target,
	// captureScreenshot is a request-response API that supports multiple sessions.
	await new Promise((captureResolve) => {
		const ws = new WebSocket(browserWsUrl)
		let attachedSessionId = null
		let captureInterval = null
		let pendingScreenshot = false
		const intervalMs = Math.max(250, Math.round(1000 / fps))

		const doCleanup = () => {
			if (captureInterval) {
				clearInterval(captureInterval)
				captureInterval = null
			}
			if (attachedSessionId) {
				try {
					ws.send(
						JSON.stringify({
							id: cmdId++,
							method: 'Target.detachFromTarget',
							params: { sessionId: attachedSessionId },
						}),
					)
				} catch {
					/* ignore */
				}
			}
			setTimeout(() => {
				ws.close()
				captureResolve()
			}, 200)
		}

		ws.addEventListener('open', () => {
			ws.send(
				JSON.stringify({
					id: cmdId++,
					method: 'Target.attachToTarget',
					params: { targetId: target.id, flatten: true },
				}),
			)
		})

		ws.addEventListener('message', (event) => {
			let msg
			try {
				msg = JSON.parse(event.data)
			} catch {
				return
			}

			// Get session ID from attachToTarget response
			if (!attachedSessionId && msg.result && msg.result.sessionId) {
				attachedSessionId = msg.result.sessionId
				process.stderr.write(`Session established (captureScreenshot polling mode)\n`)

				// Start polling loop
				const requestScreenshot = () => {
					if (pendingScreenshot) return
					pendingScreenshot = true
					ws.send(
						JSON.stringify({
							id: cmdId++,
							method: 'Page.captureScreenshot',
							sessionId: attachedSessionId,
							params: { format: 'jpeg', quality, optimizeForSpeed: true },
						}),
					)
				}

				// Take first frame immediately
				requestScreenshot()
				captureInterval = setInterval(requestScreenshot, intervalMs)

				// Schedule stop
				setTimeout(doCleanup, duration * 1000)
				return
			}

			// Handle screenshot response
			if (msg.result && msg.result.data && msg.sessionId === attachedSessionId) {
				pendingScreenshot = false
				const data = msg.result.data
				const elapsed = Date.now() - startTime

				const paddedIndex = String(frameIndex).padStart(4, '0')
				const filename = `frame-${paddedIndex}-${elapsed}ms.jpg`
				const framePath = join(outputPath, filename)
				try {
					writeFileSync(framePath, Buffer.from(data, 'base64'))
					framePaths.push(framePath)
				} catch (err) {
					process.stderr.write(`Failed to save frame ${frameIndex}: ${err.message}\n`)
				}

				frameIndex++

				if (frameIndex % 5 === 0) {
					process.stderr.write(
						`  ${frameIndex} frames captured (${(elapsed / 1000).toFixed(1)}s)\n`,
					)
				}
			}
		})

		ws.addEventListener('error', () => {
			// Ignore errors during close — they're expected when detaching
		})
	})

	const elapsed = (Date.now() - startTime) / 1000

	// Total bytes
	let totalBytes = 0
	for (const fp of framePaths) {
		try {
			totalBytes += statSync(fp).size
		} catch {
			/* skip */
		}
	}

	// --- Auto-stitch frames to MP4 via ffmpeg ---
	let videoPath = null
	let framesDeleted = false
	if (framePaths.length > 0) {
		const mp4Path = join(outputPath, 'recording.mp4')
		try {
			// Check if ffmpeg is available
			execSync('ffmpeg -version', { stdio: 'ignore' })

			// ffmpeg expects a sequential pattern — rename frames to sequential numbering
			const seqDir = join(outputPath, 'seq')
			mkdirSync(seqDir, { recursive: true })
			for (let i = 0; i < framePaths.length; i++) {
				const seqName = `frame-${String(i).padStart(5, '0')}.jpg`
				const seqPath = join(seqDir, seqName)
				writeFileSync(seqPath, readFileSync(framePaths[i]))
			}

			// Stitch with ffmpeg
			const ffmpegCmd = `ffmpeg -y -framerate ${fps} -i "${join(seqDir, 'frame-%05d.jpg')}" -c:v libx264 -pix_fmt yuv420p -crf 23 -preset fast "${mp4Path}"`
			process.stderr.write(`Stitching ${framePaths.length} frames to MP4...\n`)
			execSync(ffmpegCmd, { stdio: 'pipe' })

			if (existsSync(mp4Path)) {
				videoPath = mp4Path
				const mp4Size = statSync(mp4Path).size
				process.stderr.write(
					`Video written: ${mp4Path} (${(mp4Size / 1024 / 1024).toFixed(1)} MB)\n`,
				)

				// Delete individual frame files and seq dir
				for (const fp of framePaths) {
					try {
						unlinkSync(fp)
					} catch {
						/* ignore */
					}
				}
				// Clean up seq dir
				try {
					rmSync(seqDir, { recursive: true, force: true })
				} catch {
					/* ignore */
				}
				framesDeleted = true
				process.stderr.write(`Cleaned up ${framePaths.length} frame files.\n`)
			}
		} catch (err) {
			// ffmpeg not available or failed — keep frames
			process.stderr.write(
				`Warning: ffmpeg not available or failed (${err.message}). Keeping individual frame files.\n`,
			)
			// Clean up seq dir if it was created
			const seqDirCleanup = join(outputPath, 'seq')
			try {
				rmSync(seqDirCleanup, { recursive: true, force: true })
			} catch {
				/* ignore */
			}
		}
	}

	output({
		status: 'ok',
		capture: {
			host,
			port,
			targetUrl: target.url,
			outputPath,
			frameCount: frameIndex,
			duration: elapsed,
			fileSizeBytes: totalBytes,
			videoPath,
			framesDeleted,
			framePaths: framesDeleted ? [] : framePaths,
		},
	})
}

// ---------------------------------------------------------------------------
// HTML report helpers (ported from proof-report.ts — self-contained)
// ---------------------------------------------------------------------------

function escapeHtml(str) {
	return String(str)
		.replace(/&/g, '&amp;')
		.replace(/</g, '&lt;')
		.replace(/>/g, '&gt;')
		.replace(/"/g, '&quot;')
}

/** Lightweight markdown → HTML (no external deps) */
function renderMarkdown(md) {
	return (
		escapeHtml(md)
			// Headings
			.replace(
				/^### (.+)$/gm,
				'<h4 style="font-size:14px;color:#D6D876;margin:16px 0 6px;">$1</h4>',
			)
			.replace(/^## (.+)$/gm, '<h3 style="font-size:16px;color:#D6D876;margin:20px 0 8px;">$1</h3>')
			.replace(/^# (.+)$/gm, '<h2 style="font-size:18px;color:#f1f5f9;margin:20px 0 8px;">$1</h2>')
			// Bold + italic
			.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>')
			.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
			.replace(/\*(.+?)\*/g, '<em>$1</em>')
			// Inline code
			.replace(
				/`([^`]+)`/g,
				'<code style="background:#1e293b;padding:1px 5px;border-radius:4px;font-size:12px;">$1</code>',
			)
			// Horizontal rule
			.replace(/^---$/gm, '<hr style="border:none;border-top:1px solid #1e293b;margin:16px 0;" />')
			// Unordered list items
			.replace(/^- (.+)$/gm, '<li style="margin:2px 0;margin-left:20px;list-style:disc;">$1</li>')
			// Table (simple: | col | col |)
			.replace(/^\| (.+) \|$/gm, (match, inner) => {
				const cells = inner
					.split(' | ')
					.map(
						(c) => `<td style="padding:4px 10px;border-bottom:1px solid #1e293b;">${c.trim()}</td>`,
					)
					.join('')
				return `<tr>${cells}</tr>`
			})
			// Wrap consecutive <tr> in table
			.replace(
				/((?:<tr>.*<\/tr>\n?)+)/g,
				'<table style="width:100%;border-collapse:collapse;font-size:13px;margin:8px 0;">$1</table>',
			)
			// Table separator rows (|---|---|)
			.replace(/<tr><td[^>]*>-+<\/td>.*?<\/tr>/g, '')
			// Paragraphs (blank lines)
			.replace(/\n\n/g, '</p><p style="margin:8px 0;">')
			// Single newlines → <br>
			.replace(/\n/g, '<br />')
			// Wrap in paragraph
			.replace(/^/, '<p style="margin:8px 0;">')
			.replace(/$/, '</p>')
	)
}

function statusBadge(status) {
	const colors = {
		pass: '#22c55e',
		approved: '#22c55e',
		fail: '#ef4444',
		rejected: '#ef4444',
		partial: '#f59e0b',
		'approved-with-notes': '#f59e0b',
		skip: '#6b7280',
		error: '#ef4444',
	}
	const color = colors[status] ?? '#6b7280'
	return `<span style="background:${color};color:#fff;padding:2px 10px;border-radius:12px;font-size:13px;font-weight:600;text-transform:uppercase;">${escapeHtml(status)}</span>`
}

function renderDimensionBar(score, label, rationale) {
	const pct = score * 10
	const color = score >= 7 ? '#22c55e' : score >= 5 ? '#f59e0b' : '#ef4444'
	return `
		<div style="margin-bottom:12px;">
			<div style="display:flex;justify-content:space-between;margin-bottom:4px;">
				<span style="font-weight:600;text-transform:capitalize;">${escapeHtml(label)}</span>
				<span style="font-weight:700;color:${color};">${score}/10</span>
			</div>
			<div style="background:#1e293b;border-radius:6px;height:8px;overflow:hidden;">
				<div style="background:${color};height:100%;width:${pct}%;border-radius:6px;"></div>
			</div>
			<div style="font-size:12px;color:#94a3b8;margin-top:2px;">${escapeHtml(rationale)}</div>
		</div>`
}

function renderVerdictCard(verdict) {
	const providerColors = {
		claude: '#d97706',
		gemini: '#4285f4',
		codex: '#10b981',
	}
	const color = providerColors[verdict.provider] ?? '#6b7280'
	const confidence =
		typeof verdict.confidence === 'number' ? Math.round(verdict.confidence * 100) : '?'
	const findings = Array.isArray(verdict.findings) ? verdict.findings : []

	return `
		<div style="background:#1a1a2e;border:1px solid #1e293b;border-radius:12px;padding:20px;margin-bottom:12px;">
			<div style="display:flex;align-items:center;gap:12px;margin-bottom:8px;">
				<span style="background:${color};color:#fff;padding:2px 10px;border-radius:12px;font-size:12px;font-weight:600;">${escapeHtml(verdict.provider ?? 'unknown')}</span>
				${statusBadge(verdict.verdict ?? verdict.result ?? 'unknown')}
				<span style="font-size:13px;color:#94a3b8;">${escapeHtml(verdict.model ?? '')}</span>
				<span style="color:#64748b;font-size:12px;margin-left:auto;">confidence: ${confidence}%</span>
			</div>
			${verdict.role ? `<div style="font-size:13px;color:#94a3b8;margin-bottom:4px;font-weight:600;">Role: ${escapeHtml(verdict.role)}</div>` : ''}
			<p style="font-size:14px;margin:8px 0;">${escapeHtml(verdict.summary ?? verdict.details ?? '')}</p>
			${
				findings.length > 0
					? `<ul style="margin:4px 0 0 0;padding-left:20px;font-size:13px;color:#cbd5e1;">${findings.map((f) => `<li>${escapeHtml(f)}</li>`).join('')}</ul>`
					: ''
			}
		</div>`
}

/** Build the analysis sidebar HTML from Gemini analysis data */
function renderAnalysisPanel(analysisData) {
	if (!analysisData || !analysisData.verdict) {
		return '<div style="color:#64748b;font-size:13px;font-style:italic;">No AI analysis available.</div>'
	}
	const v = analysisData.verdict
	const confidence = typeof v.confidence === 'number' ? Math.round(v.confidence * 100) : '?'
	const uiElements = Array.isArray(analysisData.ui_elements) ? analysisData.ui_elements.length : 0
	const anomalies = Array.isArray(analysisData.anomalies) ? analysisData.anomalies.length : 0
	const interactions = Array.isArray(analysisData.interactions) ? analysisData.interactions : []

	let html = `
		<div style="margin-bottom:12px;">
			<div style="font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px;">Verdict</div>
			<div style="display:flex;align-items:center;gap:8px;">
				${statusBadge(v.result)}
				<span style="font-size:12px;color:#64748b;">${confidence}% confidence</span>
			</div>
		</div>
		<div style="margin-bottom:12px;">
			<div style="font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Summary</div>
			<div style="font-size:13px;color:#e2e8f0;line-height:1.5;">${escapeHtml(v.summary ?? '')}</div>
		</div>
		${
			v.details
				? `
		<div style="margin-bottom:12px;">
			<div style="font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Details</div>
			<div style="font-size:12px;color:#94a3b8;line-height:1.5;">${escapeHtml(v.details)}</div>
		</div>`
				: ''
		}`

	// Timestamped interactions (useful for video analysis)
	if (interactions.length > 0) {
		html += `
		<div style="margin-bottom:12px;">
			<div style="font-size:11px;color:#64748b;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:4px;">Interactions (${interactions.length})</div>
			<div style="font-size:12px;color:#94a3b8;max-height:200px;overflow-y:auto;">
				${interactions.map((i) => `<div style="margin-bottom:4px;padding:4px 0;border-bottom:1px solid #1e293b;"><span style="color:#D6D876;font-family:monospace;font-size:11px;">${escapeHtml(i.timestamp ?? '')}</span> ${escapeHtml(i.action ?? '')} → ${escapeHtml(i.target ?? '')}</div>`).join('')}
			</div>
		</div>`
	}

	html += `
		<div style="display:flex;gap:16px;margin-top:8px;">
			${uiElements > 0 ? `<div><span style="font-size:18px;font-weight:700;color:#D6D876;">${uiElements}</span><div style="font-size:10px;color:#64748b;">Elements</div></div>` : ''}
			${anomalies > 0 ? `<div><span style="font-size:18px;font-weight:700;color:#FF5C5C;">${anomalies}</span><div style="font-size:10px;color:#64748b;">Anomalies</div></div>` : ''}
		</div>`

	return html
}

/**
 * Render a screenshot evidence card.
 * @param {string} mode - 'relative' (file path) or 'embedded' (base64)
 */
function renderScreenshotCard(filename, base64Data, mimeType, analysisData, index, mode) {
	const ext = extname(filename).toLowerCase()
	const imgMime = mimeType || (ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/png')
	const imgId = `img-${index ?? 0}`

	const imgSrc = mode === 'embedded' ? `data:${imgMime};base64,${base64Data}` : escapeHtml(filename)

	return `
		<div class="evidence-card">
			<div style="font-size:13px;font-weight:600;color:#D6D876;margin-bottom:12px;">${escapeHtml(filename)}</div>
			<div style="display:flex;gap:16px;align-items:flex-start;">
				<div style="flex:3;min-width:0;">
					<img id="${imgId}" src="${imgSrc}" style="width:100%;border-radius:8px;border:1px solid #1e293b;cursor:pointer;" alt="${escapeHtml(filename)}" onclick="openLightbox('${imgId}')" title="Click to enlarge" />
				</div>
				<div style="flex:1;min-width:180px;padding:12px;background:#0f172a;border-radius:8px;border:1px solid #1e293b;">
					${renderAnalysisPanel(analysisData)}
				</div>
			</div>
		</div>`
}

/**
 * Render a video evidence card with inline player.
 * Videos always use relative paths (too large for base64).
 */
function renderVideoCard(filename, mimeType, analysisData, index) {
	const videoId = `video-${index ?? 0}`
	const vidMime = mimeType || 'video/webm'

	return `
		<div class="evidence-card">
			<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
				<span style="font-size:13px;font-weight:600;color:#D6D876;">${escapeHtml(filename)}</span>
				<span style="font-size:11px;color:#64748b;background:#1e293b;padding:2px 8px;border-radius:8px;">VIDEO</span>
			</div>
			<div style="display:flex;gap:16px;align-items:flex-start;">
				<div style="flex:3;min-width:0;">
					<video id="${videoId}" src="${escapeHtml(filename)}" controls preload="metadata" style="width:100%;border-radius:8px;border:1px solid #1e293b;background:#000;" title="Click play to watch verification recording">
						Your browser does not support the video tag.
					</video>
				</div>
				<div style="flex:1;min-width:180px;padding:12px;background:#0f172a;border-radius:8px;border:1px solid #1e293b;">
					${renderAnalysisPanel(analysisData)}
				</div>
			</div>
		</div>`
}

function generateReportHtml(opts) {
	const {
		title,
		runDate,
		verification, // parsed VERIFICATION.md (primary data source)
		screenshots, // [{filename, base64, mimeType}] — all found images
		screenshotMap, // Map: filename/basename -> screenshot data
		videos, // [{filename, mimeType, analysis}]
		verdicts, // [ModelVerdict-like objects]
		quorum, // optional
		dimensions, // [{dimension, score, rationale}]
		overallVerdict,
		mode, // 'relative' or 'embedded'
	} = opts
	const renderMode = mode ?? 'relative'
	const videoList = videos ?? []
	const v = verification // shorthand

	// --- Stat counts ---
	const totalTests = v ? v.features.reduce((n, f) => n + f.tests.length, 0) : 0
	const passedTests = v
		? v.features.reduce((n, f) => n + f.tests.filter((t) => t.result === 'pass').length, 0)
		: 0
	const failedTests = v
		? v.features.reduce((n, f) => n + f.tests.filter((t) => t.result === 'fail').length, 0)
		: 0
	const blockedTests = v
		? v.features.reduce((n, f) => n + f.tests.filter((t) => t.result === 'blocked').length, 0)
		: 0

	// --- Helper: resolve screenshot src ---
	const resolveImgSrc = (evidence) => {
		if (!evidence) return null
		const entry =
			screenshotMap?.get(evidence) ??
			screenshotMap?.get(basename(evidence)) ??
			screenshotMap?.get('screenshots/' + evidence)
		if (!entry) return null
		return renderMode === 'embedded' && entry.base64
			? `data:${entry.mimeType};base64,${entry.base64}`
			: entry.filename
	}

	let imgCounter = 0

	// --- QUORUM ---
	const quorumHtml = quorum
		? `<div style="background:#1a1a2e;border:2px solid ${quorum.finalVerdict === 'pass' ? '#22c55e' : quorum.finalVerdict === 'fail' ? '#ef4444' : '#f59e0b'};border-radius:12px;padding:20px;margin-bottom:24px;text-align:center;">
			<div style="font-size:14px;color:#94a3b8;margin-bottom:8px;">VERIFICATION QUORUM</div>
			<div style="font-size:28px;font-weight:700;margin-bottom:8px;">${statusBadge(quorum.finalVerdict)}</div>
		</div>`
		: ''

	// --- SUMMARY ---
	const summaryHtml = v?.summary?.outcome
		? `<div class="section">
			<h2>Summary</h2>
			<div class="card">
				<div style="font-size:15px;color:#e2e8f0;margin-bottom:12px;">${escapeHtml(v.summary.outcome)}</div>
				${v.summary.verdictText ? `<div style="font-size:13px;color:#94a3b8;">${escapeHtml(v.summary.verdictText)}</div>` : ''}
			</div>
		</div>`
		: ''

	// --- AUTOMATED CHECKS ---
	const renderCheck = (name, check) => {
		if (!check) return ''
		const isPass = check.status.startsWith('pass')
		const color = isPass ? '#22c55e' : '#ef4444'
		const icon = isPass ? '&#10003;' : '&#10007;'
		return `<div style="margin-bottom:12px;padding:12px;background:#0f172a;border-radius:8px;border-left:3px solid ${color};">
			<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px;">
				<span style="color:${color};font-weight:700;font-size:16px;">${icon}</span>
				<span style="font-weight:600;">${escapeHtml(name)}</span>
				<span style="font-size:12px;color:#64748b;margin-left:auto;">${escapeHtml(check.status)}</span>
			</div>
			${check.output ? `<details><summary style="font-size:12px;color:#64748b;cursor:pointer;">Show output</summary><pre style="font-size:11px;color:#94a3b8;margin-top:8px;overflow-x:auto;white-space:pre-wrap;">${escapeHtml(check.output)}</pre></details>` : ''}
		</div>`
	}

	const automatedChecksHtml = v?.automatedChecks
		? `<div class="section">
			<h2>Automated Checks</h2>
			<div class="card">
				${renderCheck('Tests', v.automatedChecks.tests)}
				${renderCheck('Type Check', v.automatedChecks.typecheck)}
				${renderCheck('Lint', v.automatedChecks.lint)}
			</div>
		</div>`
		: ''

	// --- QA DIMENSIONS ---
	const dimensionsHtml =
		dimensions && dimensions.length > 0
			? `<div class="section">
			<h2>QA Dimensions</h2>
			<div class="card">
				${dimensions.map((d) => renderDimensionBar(d.score, d.dimension, d.rationale ?? '')).join('')}
			</div>
		</div>`
			: ''

	// --- FEATURE CHECKLISTS ---
	const renderTestRow = (test, featureId) => {
		const resultColors = {
			pass: '#22c55e',
			fail: '#ef4444',
			blocked: '#f59e0b',
			skip: '#6b7280',
			pending: '#6b7280',
		}
		const color = resultColors[test.result] ?? '#6b7280'
		const icon =
			test.result === 'pass'
				? '&#10003;'
				: test.result === 'fail'
					? '&#10007;'
					: test.result === 'blocked'
						? '&#9888;'
						: '&#8226;'

		const imgSrc = resolveImgSrc(test.evidence)
		const imgId = imgSrc ? `img-${imgCounter++}` : null

		return `<div style="padding:12px;margin-bottom:8px;background:#0f172a;border-radius:8px;border-left:3px solid ${color};">
			<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
				<span style="color:${color};font-weight:700;font-size:14px;">${icon}</span>
				<span style="font-weight:600;font-size:13px;">${escapeHtml(test.name)}</span>
				${statusBadge(test.result ?? 'pending')}
				${test.severity ? `<span style="font-size:11px;color:#ef4444;background:#ef444420;padding:1px 6px;border-radius:8px;margin-left:4px;">${escapeHtml(test.severity)}</span>` : ''}
			</div>
			<div style="font-size:12px;color:#94a3b8;margin-bottom:4px;"><strong>Action:</strong> ${escapeHtml(test.action ?? '')}</div>
			<div style="font-size:12px;color:#94a3b8;margin-bottom:4px;"><strong>Expected:</strong> ${escapeHtml(test.expected ?? '')}</div>
			${test.actual ? `<div style="font-size:12px;color:#cbd5e1;margin-bottom:4px;"><strong>Actual:</strong> ${escapeHtml(test.actual)}</div>` : ''}
			${test.ai_analysis ? `<details style="margin-top:6px;"><summary style="font-size:11px;color:#D6D876;cursor:pointer;">AI Analysis</summary><div style="font-size:12px;color:#94a3b8;margin-top:4px;padding:8px;background:#12121A;border-radius:6px;line-height:1.5;">${escapeHtml(test.ai_analysis)}</div></details>` : ''}
			${test.notes ? `<div style="font-size:11px;color:#64748b;margin-top:4px;font-style:italic;">Note: ${escapeHtml(test.notes)}</div>` : ''}
			${imgSrc ? `<div style="margin-top:8px;"><img id="${imgId}" src="${imgSrc}" style="max-width:100%;max-height:300px;border-radius:6px;border:1px solid #1e293b;cursor:pointer;" onclick="openLightbox('${imgId}')" title="Click to enlarge" /></div>` : ''}
		</div>`
	}

	const featuresHtml =
		v?.features?.length > 0
			? `<div class="section">
			<h2>Feature Verification</h2>
			${v.features
				.map((f) => {
					const featureResultColors = {
						pass: '#22c55e',
						fail: '#ef4444',
						blocked: '#f59e0b',
						partial: '#f59e0b',
					}
					const fColor = featureResultColors[f.result] ?? '#6b7280'
					const passCount = f.tests.filter((t) => t.result === 'pass').length
					const totalCount = f.tests.length
					return `<div class="card" style="border-left:3px solid ${fColor};">
					<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px;">
						<h3 style="font-size:15px;color:#f1f5f9;margin:0;">${escapeHtml(f.name)}</h3>
						${statusBadge(f.result ?? 'pending')}
						<span style="font-size:12px;color:#64748b;margin-left:auto;">${passCount}/${totalCount} tests</span>
					</div>
					${f.route ? `<div style="font-size:11px;color:#64748b;margin-bottom:8px;">Route: <code style="color:#D6D876;">${escapeHtml(f.route)}</code></div>` : ''}
					${f.tests.map((t) => renderTestRow(t, f.id)).join('')}
				</div>`
				})
				.join('')}
		</div>`
			: ''

	// --- GAPS ---
	const gapsHtml =
		v?.gaps?.length > 0
			? `<div class="section">
			<h2>Gaps &amp; Issues</h2>
			${v.gaps
				.map((g) => {
					const sevColors = {
						blocker: '#ef4444',
						major: '#f97316',
						minor: '#f59e0b',
						cosmetic: '#6b7280',
					}
					const c = sevColors[g.severity] ?? '#6b7280'
					return `<div class="card" style="border-left:3px solid ${c};">
					<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px;">
						<span style="background:${c};color:#fff;padding:1px 8px;border-radius:8px;font-size:11px;font-weight:600;text-transform:uppercase;">${escapeHtml(g.severity ?? 'unknown')}</span>
						<span style="font-size:13px;font-weight:600;color:#e2e8f0;">${escapeHtml(g.feature ?? '')}</span>
					</div>
					<div style="font-size:13px;color:#cbd5e1;margin-bottom:4px;">${escapeHtml(g.truth ?? '')}</div>
					<div style="font-size:12px;color:#f87171;">${escapeHtml(g.reason ?? '')}</div>
					${g.root_cause ? `<div style="font-size:12px;color:#94a3b8;margin-top:4px;"><strong>Root cause:</strong> ${escapeHtml(g.root_cause)}</div>` : ''}
					${g.fix_plan ? `<div style="font-size:12px;color:#94a3b8;"><strong>Fix plan:</strong> ${escapeHtml(g.fix_plan)}</div>` : ''}
				</div>`
				})
				.join('')}
		</div>`
			: ''

	// --- VIDEOS ---
	const videosHtml =
		videoList.length > 0
			? `<div class="section">
			<h2>Video Evidence</h2>
			${videoList.map((vi, i) => renderVideoCard(vi.filename, vi.mimeType, vi.analysis, i)).join('')}
		</div>`
			: ''

	// --- MULTI-MODEL VERDICTS ---
	const verdictsHtml =
		verdicts.length > 0
			? `<div class="section">
			<h2>Multi-Model Verdicts</h2>
			${verdicts.map(renderVerdictCard).join('')}
		</div>`
			: ''

	// --- COVERAGE ---
	const coverageData = v?.coverage ?? null
	const coverageHtml = coverageData
		? (() => {
				const pct = typeof coverageData.percent === 'number' ? coverageData.percent : 0
				const barColor = pct >= 80 ? '#22c55e' : pct >= 50 ? '#f59e0b' : '#ef4444'
				return `<div class="section">
				<h2>Feature Coverage</h2>
				<div class="card">
					<div style="display:flex;justify-content:space-between;margin-bottom:8px;">
						<span style="font-weight:600;">Registered Features</span>
						<span style="font-weight:700;color:${barColor};">${coverageData.tested ?? 0} / ${coverageData.registered ?? 0} tested (${pct.toFixed(1)}%)</span>
					</div>
					<div style="background:#1e293b;border-radius:6px;height:12px;overflow:hidden;margin-bottom:16px;">
						<div style="background:${barColor};height:100%;width:${pct}%;border-radius:6px;transition:width 0.3s;"></div>
					</div>
					<div style="display:flex;gap:24px;flex-wrap:wrap;">
						<div class="stat"><div class="stat-value" style="color:#22c55e;">${coverageData.passed ?? 0}</div><div class="stat-label">Passed</div></div>
						<div class="stat"><div class="stat-value" style="color:#ef4444;">${coverageData.failed ?? 0}</div><div class="stat-label">Failed</div></div>
						<div class="stat"><div class="stat-value" style="color:#f59e0b;">${coverageData.blocked ?? 0}</div><div class="stat-label">Blocked</div></div>
						<div class="stat"><div class="stat-value" style="color:#6b7280;">${coverageData.skipped ?? 0}</div><div class="stat-label">Skipped</div></div>
					</div>
				</div>
			</div>`
			})()
		: ''

	// --- SKIPPED FEATURES ---
	const skippedList = v?.skipped ?? []
	const skippedHtml =
		skippedList.length > 0
			? `<div class="section">
			<h2>Skipped Features</h2>
			<details>
				<summary style="font-size:14px;color:#94a3b8;cursor:pointer;margin-bottom:12px;">${skippedList.length} features not tested — click to expand</summary>
				<div class="card" style="max-height:400px;overflow-y:auto;">
					${skippedList
						.map((s) => {
							const reasonColors = {
								time_constraint: '#f59e0b',
								blocked_by_platform: '#ef4444',
								not_applicable: '#6b7280',
							}
							const rColor = reasonColors[s.reason] ?? '#6b7280'
							return `<div style="padding:8px 12px;margin-bottom:4px;background:#0f172a;border-radius:6px;display:flex;align-items:center;gap:8px;">
							<span style="font-size:13px;font-weight:600;color:#cbd5e1;flex:1;">${escapeHtml(s.name ?? s.registry_id ?? '')}</span>
							<span style="font-size:11px;color:${rColor};background:${rColor}20;padding:1px 8px;border-radius:8px;white-space:nowrap;">${escapeHtml((s.reason ?? '').replace(/_/g, ' '))}</span>
							${s.note ? `<span style="font-size:11px;color:#64748b;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escapeHtml(s.note)}">${escapeHtml(s.note)}</span>` : ''}
						</div>`
						})
						.join('')}
				</div>
			</details>
		</div>`
			: ''

	// --- UNATTACHED SCREENSHOTS ---
	const attachedEvidence = new Set()
	if (v) {
		for (const f of v.features) {
			for (const t of f.tests) {
				if (t.evidence) {
					attachedEvidence.add(t.evidence)
					attachedEvidence.add(basename(t.evidence))
					attachedEvidence.add('screenshots/' + t.evidence)
				}
			}
		}
	}
	const unattached = screenshots.filter((s) => {
		// Exclude frame-*.jpg files (video frames, not screenshots)
		const fname = basename(s.filename)
		if (/^frame-\d+.*\.jpg$/i.test(fname)) return false
		return !attachedEvidence.has(s.filename) && !attachedEvidence.has(fname)
	})
	const unattachedHtml =
		unattached.length > 0
			? `<div class="section">
			<h2>Additional Screenshots</h2>
			<div class="grid-2">
				${unattached
					.map((s) => {
						const id = `img-${imgCounter++}`
						const src =
							renderMode === 'embedded' && s.base64
								? `data:${s.mimeType};base64,${s.base64}`
								: s.filename
						return `<div class="card" style="padding:12px;">
						<div style="font-size:11px;color:#64748b;margin-bottom:6px;">${escapeHtml(s.filename)}</div>
						<img id="${id}" src="${src}" style="width:100%;border-radius:6px;border:1px solid #1e293b;cursor:pointer;" onclick="openLightbox('${id}')" />
					</div>`
					})
					.join('')}
			</div>
		</div>`
			: ''

	const LOGO_B64 =
		'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAACXBIWXMAAAPoAAAD6AG1e1JrAAAD00lEQVR4nO2WaUxcVRiG372zIEsERFkKjQWGtkJqK2VkkY7g0LhQyjAyG1smbaJtTEw0xqSpW41VtLZJW7XRYGs1GtE0GpdqYqS20FKi0GEYoaQwUEupS/WP8fdj7gFmqGwzCEGT/ji595x7c77nvO93vnMkSZJY4iZdB+A/ByDLSw4goyhLBBAVqWXtmlvEu6LIS6PAa6+aKC5IEe9arbLYlkiBjkYztuKnnsyl93tHQAmNIge+LSqAMi65cX0iF3019LTbaHCuFgATuaGCyIttgSRJHDlUwqU+F/5uJ81HNmIqWoYiB1VQoSb3p2uqdSHYJzGdCretiqfrZBX9nTYunLPj97j4oKkMS3m6SNSJAOr//wyiqjQZTuyqmWGlKYM6nSKe+XmJeFqtDHuddLdZ8XscXPK5+PbTch52ZxMXe0MAWsCIFgwUFakjJkY/lwrSlMHISC3u2mwi9BqWp0bz4WEzl/tq8XfZ6GmrZKDzQUZ7XZz6cjO2SoMIOlnuAmMy+xuLOPXVZjpaqni/ycztOQkBNWYF0Ixb8Oi2NXx9bBNZGXGif3/Zco5/dB+jal50VdPdWslgZzWjvTUc3LMBnU7DSkMcbx8wMdjlYMTnZKCzmiGPg6NvllJqSgvYNiuALAdVaPmsgiv99by8qxBDRrwYN9+dyjefPCBAfKct+E5XcnWwjsbnCsQqh712+jqseFstDHvsbK1fPVPgmS1QxlVQV3TyeAV/XnYz4HHSdKAEY24SWq2Gl54xMuJz0HvWQm97FcM9Loy5iex+2sjFH50Mdds5tLc4MN8sdURiNoiU5Cgan83He8bKH8MN/D7UwMFXitHrNex9oUBAeFsr+fVCLQ+5s6nalM5ofw2+9mpy1wYLWdh1QBIQQekyV8SyfUsOHx8t468RN/t2F7IsJRrfGSvnz1q5cr6GHY+tw2xK4zd/HW/tN80l/dwAkgohTkb5mr6pKEUkpCEjjubDZvxddn7ur2X71hzK771VKHWPKfUaJecNIE2yRJVyYhutyoonMz2O5nfKGDxn4yevk7sKUtj3YiHH3tsY0pxhAUxXqDJWxPLDd1WM9Ll4940S4fmJzyswZNw4057/9wDKpIx+fqeRXwbq6GixkJ+XxK4dRgqNyaFKHx6ALCpdsO5v25LD1aEGTnxRwZ3rk8i7I1koEmZwwlYgKzOW1/dsoLutmp1P5HJzQqQAi4jQzCc4IQOoB09pcRqPP7KOesdKEm4aO4gmzoAxdeZ1aZFC+lGv0xATrZuSDwtwb5TCTEIJrXorWrh7orRQE10H4H8J8DeZduvrfEHCKAAAAABJRU5ErkJggg=='

	return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="data:image/png;base64,${LOGO_B64}" />
<title>${escapeHtml(title)}</title>
<style>
	* { box-sizing: border-box; margin: 0; padding: 0; }
	body {
		font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
		background: #0B0B0F;
		color: #e2e8f0;
		padding: 24px;
		line-height: 1.6;
	}
	.container { max-width: 1200px; margin: 0 auto; }
	h1 { font-size: 24px; margin-bottom: 4px; color: #f1f5f9; }
	h2 { font-size: 18px; margin-bottom: 12px; color: #D6D876; }
	h3 { color: #f1f5f9; }
	.meta { color: #64748b; font-size: 13px; margin-bottom: 24px; }
	.section { margin-bottom: 32px; }
	.card, .evidence-card {
		background: #12121A;
		border: 1px solid #1e293b;
		border-radius: 12px;
		padding: 20px;
		margin-bottom: 16px;
	}
	.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
	@media (max-width: 640px) { .grid-2 { grid-template-columns: 1fr; } }
	.hero {
		text-align: center;
		padding: 32px 0;
		border-bottom: 1px solid #1e293b;
		margin-bottom: 32px;
	}
	.hero-logo { display: flex; align-items: center; justify-content: center; gap: 10px; margin-bottom: 16px; }
	.hero-logo img { width: 28px; height: 28px; }
	.hero-logo span { font-size: 14px; font-weight: 700; letter-spacing: 2px; text-transform: uppercase; color: #D6D876; }
	.hero-status { font-size: 40px; margin: 16px 0; }
	.stat { text-align: center; padding: 16px; }
	.stat-value { font-size: 28px; font-weight: 700; color: #D6D876; }
	.stat-label { font-size: 12px; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; }
	code { background: #1e293b; padding: 1px 4px; border-radius: 3px; font-size: 12px; }
	details summary { user-select: none; cursor: pointer; }
	details summary:hover { color: #D6D876; }
	.footer { text-align: center; color: #475569; font-size: 12px; padding: 24px 0; border-top: 1px solid #1e293b; margin-top: 32px; }
	.footer a { color: #D6D876; text-decoration: none; }
	.lightbox-overlay { display: none; position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.92); z-index: 9999; cursor: zoom-out; align-items: center; justify-content: center; }
	.lightbox-overlay.active { display: flex; }
	.lightbox-overlay img { max-width: 95vw; max-height: 95vh; border-radius: 8px; box-shadow: 0 0 60px rgba(214,216,118,0.15); }
</style>
</head>
<body>
<div class="lightbox-overlay" id="lightbox" onclick="closeLightbox()">
	<img id="lightbox-img" src="" alt="Enlarged screenshot" />
</div>
<script>
function openLightbox(imgId) {
	var src = document.getElementById(imgId).src;
	document.getElementById('lightbox-img').src = src;
	document.getElementById('lightbox').classList.add('active');
}
function closeLightbox() {
	document.getElementById('lightbox').classList.remove('active');
	document.getElementById('lightbox-img').src = '';
}
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeLightbox(); });
</script>

<div class="container">

<div class="hero">
	<div class="hero-logo">
		<img src="data:image/png;base64,${LOGO_B64}" alt="Aperant" />
		<span>Aperant</span>
	</div>
	<h1>${escapeHtml(title)}</h1>
	<div class="meta">Generated: ${escapeHtml(runDate)}</div>
	<div class="hero-status">${statusBadge(overallVerdict)}</div>
	<div style="margin-top:12px;">
		<div style="display:flex;justify-content:center;gap:32px;">
			<div class="stat"><div class="stat-value">${v?.features?.length ?? screenshots.length}</div><div class="stat-label">Features</div></div>
			<div class="stat"><div class="stat-value">${passedTests}</div><div class="stat-label">Passed</div></div>
			<div class="stat"><div class="stat-value">${failedTests}</div><div class="stat-label">Failed</div></div>
			<div class="stat"><div class="stat-value">${blockedTests}</div><div class="stat-label">Blocked</div></div>
			${screenshots.length > 0 ? `<div class="stat"><div class="stat-value">${screenshots.length}</div><div class="stat-label">Screenshots</div></div>` : ''}
			${videoList.length > 0 ? `<div class="stat"><div class="stat-value">${videoList.length}</div><div class="stat-label">Videos</div></div>` : ''}
		</div>
	</div>
</div>

${quorumHtml}
${summaryHtml}
${automatedChecksHtml}
${coverageHtml}
${dimensionsHtml}
${featuresHtml}
${gapsHtml}
${skippedHtml}
${videosHtml}
${verdictsHtml}
${unattachedHtml}

<div class="footer">
	Generated by <a href="https://github.com/AndyMik90/Aperant">Aperant Framework</a> &bull; Proof verification report &bull; Click any screenshot to enlarge
</div>

</div>
</body>
</html>`
}

// ---------------------------------------------------------------------------
// verification.json loader (primary) + VERIFICATION.md parser (legacy fallback)
// ---------------------------------------------------------------------------

/**
 * Load verification data from a proof directory.
 * Tries verification.json first (structured, reliable), falls back to VERIFICATION.md (fragile).
 * Returns a normalized shape compatible with generateReportHtml().
 */
function loadVerificationData(proofDir) {
	// --- Try verification.json first ---
	const jsonPath = join(proofDir, 'verification.json')
	if (existsSync(jsonPath)) {
		try {
			const raw = JSON.parse(readFileSync(jsonPath, 'utf-8'))
			process.stderr.write(`  Loaded verification.json\n`)
			return mapVerificationJson(raw)
		} catch (err) {
			process.stderr.write(`  Warning: could not parse verification.json: ${err.message}\n`)
		}
	}

	// --- Fall back to VERIFICATION.md ---
	const mdPath = join(proofDir, 'VERIFICATION.md')
	if (existsSync(mdPath)) {
		try {
			const content = readFileSync(mdPath, 'utf-8')
			const result = parseVerificationMdLegacy(content)
			process.stderr.write(
				`  Parsed VERIFICATION.md (legacy): ${result.features.length} features, ${result.dimensions.length} dimensions, ${result.gaps.length} gaps\n`,
			)
			return result
		} catch (err) {
			process.stderr.write(`  Warning: could not parse VERIFICATION.md: ${err.message}\n`)
		}
	}

	return null
}

/**
 * Map verification.json schema to the internal shape used by generateReportHtml().
 */
function mapVerificationJson(json) {
	const meta = json.meta ?? {}
	const checks = json.automated_checks ?? {}
	const tests = json.tests ?? []
	const dims = json.dimensions ?? {}
	const gaps = json.gaps ?? []
	const skipped = json.skipped ?? []
	const coverage = json.coverage ?? null

	// Map automated checks
	const automatedChecks = {
		tests: checks.tests
			? {
					status: checks.tests.status,
					output: checks.tests.output ?? '',
					count: checks.tests.count,
				}
			: null,
		typecheck: checks.typecheck
			? { status: checks.typecheck.status, output: checks.typecheck.output ?? '' }
			: null,
		lint: checks.lint ? { status: checks.lint.status, output: checks.lint.output ?? '' } : null,
	}

	// Group tests into features by registry_id prefix or as a single group
	const featureGroups = new Map()
	for (const t of tests) {
		const groupKey = t.registry_id ? t.registry_id.split('-').slice(0, 1).join('-') : 'all'
		if (!featureGroups.has(groupKey)) {
			featureGroups.set(groupKey, {
				name: groupKey,
				id: groupKey,
				route: '',
				mcp: '',
				result: '',
				tests: [],
			})
		}
		featureGroups.get(groupKey).tests.push({
			name: t.name ?? t.id,
			id: t.id,
			registry_id: t.registry_id,
			precondition: t.precondition ?? '',
			action: t.action ?? '',
			expected: t.expected ?? '',
			actual: t.actual ?? '',
			evidence: t.evidence ?? null,
			ai_analysis: t.analysis ?? null,
			result: t.result ?? 'pending',
			severity: t.severity ?? null,
			notes: t.notes ?? null,
		})
	}

	// Derive feature-level results
	const features = []
	for (const [, f] of featureGroups) {
		const results = f.tests.map((t) => t.result)
		if (results.every((r) => r === 'pass')) f.result = 'pass'
		else if (results.some((r) => r === 'fail')) f.result = 'fail'
		else if (results.some((r) => r === 'blocked')) f.result = 'partial'
		else f.result = 'partial'
		// Capitalize feature name
		f.name = f.name.charAt(0).toUpperCase() + f.name.slice(1) + ' Features'
		features.push(f)
	}

	// Map dimensions
	const dimensions = []
	for (const [key, val] of Object.entries(dims)) {
		if (val && typeof val === 'object' && typeof val.score === 'number') {
			dimensions.push({
				dimension: key.replace(/_/g, ' '),
				score: val.score,
				rationale: val.rationale ?? '',
			})
		}
	}

	// Map gaps
	const mappedGaps = gaps.map((g) => ({
		severity: g.severity ?? 'unknown',
		truth: g.description ?? '',
		reason: g.description ?? '',
		feature: g.registry_id ?? '',
		evidence: g.evidence ?? null,
		root_cause: g.root_cause ?? null,
		fix_plan: g.fix_plan ?? null,
	}))

	return {
		frontmatter: {
			run_id: meta.run_id,
			date: meta.date,
			area: meta.area,
			verdict: meta.verdict,
			started: meta.started,
			duration_seconds: meta.duration_seconds,
		},
		summary: {
			outcome: meta.verdict ? `Verdict: ${meta.verdict}` : '',
			verdictText: meta.verdict ?? '',
		},
		automatedChecks,
		features,
		dimensions,
		gaps: mappedGaps,
		videoEvidence: [],
		modelVerdicts: [],
		// Extra fields from JSON schema
		skipped,
		coverage,
	}
}

/** @deprecated Use loadVerificationData() — kept as legacy fallback for VERIFICATION.md */
function parseVerificationMdLegacy(content) {
	const result = {
		frontmatter: {},
		summary: {},
		automatedChecks: { tests: null, typecheck: null, lint: null },
		features: [],
		dimensions: [],
		gaps: [],
		videoEvidence: [],
		modelVerdicts: [],
	}

	// Parse frontmatter
	const fmMatch = content.match(/^---\n([\s\S]*?)\n---/)
	if (fmMatch) {
		for (const line of fmMatch[1].split('\n')) {
			const m = line.match(/^(\w[\w_]*):\s*(.+)$/)
			if (m) {
				let val = m[2].trim().replace(/^["']|["']$/g, '')
				if (val === 'true') val = true
				else if (val === 'false') val = false
				result.frontmatter[m[1]] = val
			}
		}
	}

	// Parse Summary table
	const summaryMatch = content.match(/## Summary\n\n([\s\S]*?)(?=\n## |\n\*\*Outcome)/)
	if (summaryMatch) {
		for (const line of summaryMatch[1].split('\n')) {
			const m = line.match(/\|\s*(.+?)\s*\|\s*(.+?)\s*\|/)
			if (m && !m[1].includes('---') && !m[1].includes('Metric')) {
				result.summary[m[1].trim()] = m[2].trim()
			}
		}
	}

	// Parse Outcome and Verdict
	const outcomeMatch = content.match(/\*\*Outcome:\*\*\s*(.+)/)
	if (outcomeMatch) result.summary.outcome = outcomeMatch[1].trim()
	const verdictMatch = content.match(/\*\*Verdict:\*\*\s*(.+)/)
	if (verdictMatch) result.summary.verdictText = verdictMatch[1].trim()

	// Parse Automated Checks — supports YAML (status: pass) and markdown (- **Result:** PASS)
	const checksSection = content.match(/## Automated Checks\n([\s\S]*?)(?=\n## |---\n)/)
	if (checksSection) {
		const checksText = checksSection[1]
		const parseCheck = (...names) => {
			for (const name of names) {
				// Format A: YAML (status: pass\noutput: |\n  ...)
				const re = new RegExp(
					`### ${name}\\nstatus:\\s*(\\S+)[\\s\\S]*?output:\\s*\\|\\n([\\s\\S]*?)(?=\\n### |$)`,
				)
				const m = checksText.match(re)
				if (m)
					return {
						status: m[1],
						output: m[2]
							.split('\n')
							.map((l) => l.replace(/^ {2}/, ''))
							.join('\n')
							.trim(),
					}

				const simple = checksText.match(new RegExp(`### ${name}\\nstatus:\\s*(.+)`))
				if (simple) return { status: simple[1].trim(), output: '' }

				// Format B: Markdown bold (- **Result:** PASS\n- **Details:** ...)
				const mdBlock = checksText.match(new RegExp(`### ${name}\\n([\\s\\S]*?)(?=\\n### |$)`))
				if (mdBlock) {
					const resultMatch = mdBlock[1].match(/\*\*Result:\*\*\s*(.+)/)
					const detailsMatch = mdBlock[1].match(/\*\*(?:Details|Output):\*\*\s*(.+)/)
					if (resultMatch) {
						const status = resultMatch[1].toLowerCase().includes('pass') ? 'pass' : 'fail'
						const output = detailsMatch ? detailsMatch[1] : ''
						return { status: `${status} (${resultMatch[1].trim()})`, output }
					}
				}
			}
			return null
		}
		result.automatedChecks.tests = parseCheck('Tests')
		result.automatedChecks.typecheck = parseCheck('Type Check', 'TypeCheck', 'Typecheck')
		result.automatedChecks.lint = parseCheck('Lint')
	}

	// Parse Features and Tests — supports two formats:
	// Format A: "## Features" → "### Feature: X" → "#### Test N: Y" (template format)
	// Format B: "## Features Tested" → "### TEST-NN: Y" (flat format)
	const featuresSection = content.match(
		/## Features(?: Tested)?\n([\s\S]*?)(?=\n## (?:QA|Gaps|Video|Score|---)|$)/,
	)
	if (featuresSection) {
		const featText = featuresSection[1]

		// Detect format: does it have "### Feature:" blocks or "### TEST-" blocks?
		const hasFeatureBlocks = featText.includes('### Feature:')
		const hasTestBlocks = /### TEST-\d+/.test(featText)

		if (hasFeatureBlocks) {
			// Format A: grouped features with sub-tests
			const featureBlocks = featText.split(/\n### Feature: /).filter(Boolean)
			for (const block of featureBlocks) {
				const lines = block.split('\n')
				const name = lines[0].trim()
				const feature = { name, id: '', route: '', mcp: '', result: '', tests: [] }

				for (const line of lines.slice(1)) {
					const m = line.match(/^(id|route|mcp|result):\s*(.+)/)
					if (m) feature[m[1]] = m[2].trim()
				}

				const testBlocks = block.split(/\n#### Test \d+: /).filter((_, i) => i > 0)
				for (const tb of testBlocks) {
					const tLines = tb.split('\n')
					const test = { name: tLines[0].trim() }
					parseTestFields(tLines, test)
					feature.tests.push(test)
				}
				result.features.push(feature)
			}
		} else if (hasTestBlocks) {
			// Format B: flat list of tests (### TEST-01: Name)
			// Group all tests into a single "All Tests" feature
			const feature = {
				name: 'Terminal Features',
				id: 'terminals',
				route: '/#/terminals',
				mcp: 'electron',
				result: '',
				tests: [],
			}
			const testBlocks = featText.split(/\n### TEST-\d+: /).filter(Boolean)
			for (const tb of testBlocks) {
				const tLines = tb.split('\n')
				const testName = tLines[0].trim()
				const test = { name: testName }

				// Parse fields with "- **Field:** value" format (markdown bold)
				for (const line of tLines.slice(1)) {
					const boldMatch = line.match(/^- \*\*(\w[\w ]*?):\*\*\s*(.+)/)
					if (boldMatch) {
						const key = boldMatch[1].toLowerCase().replace(/ /g, '_')
						const val = boldMatch[2].trim()
						if (key === 'screenshot') test.evidence = val
						else if (key === 'analysis') test.ai_analysis = val
						else test[key] = val
					}
				}

				// Normalize result values
				if (test.result) {
					const r = test.result.toLowerCase()
					if (r.includes('pass')) test.result = 'pass'
					else if (r.includes('fail')) test.result = 'fail'
					else if (r.includes('block')) test.result = 'blocked'
					// Extract severity from result line like "pass (minor -- ...)"
					const sevMatch =
						test.result_raw?.match(/\((blocker|major|minor|cosmetic)/) ||
						r.match(/\((blocker|major|minor|cosmetic)/)
					if (sevMatch) test.severity = sevMatch[1]
				}

				feature.tests.push(test)
			}
			// Derive feature result
			const results = feature.tests.map((t) => t.result)
			if (results.every((r) => r === 'pass')) feature.result = 'pass'
			else if (results.some((r) => r === 'fail')) feature.result = 'fail'
			else if (results.some((r) => r === 'blocked')) feature.result = 'partial'
			else feature.result = 'partial'

			result.features.push(feature)
		}
	}

	// Helper: parse test fields from lines (YAML-style key: value)
	function parseTestFields(tLines, test) {
		const fields = [
			'precondition',
			'action',
			'expected',
			'actual',
			'evidence',
			'result',
			'severity',
			'notes',
		]
		for (const line of tLines) {
			for (const f of fields) {
				const m = line.match(new RegExp(`^${f}:\\s*(.+)`))
				if (m) test[f] = m[1].trim()
			}
			const aiM = line.match(/^ai_analysis:\s*\|?\s*$/)
			if (aiM) {
				const aiLines = []
				let idx = tLines.indexOf(line) + 1
				while (idx < tLines.length && (tLines[idx].startsWith('  ') || tLines[idx].trim() === '')) {
					aiLines.push(tLines[idx].replace(/^ {2}/, ''))
					idx++
				}
				test.ai_analysis = aiLines.join('\n').trim()
			}
			const aiInline = line.match(/^ai_analysis:\s*(.+)/)
			if (aiInline && !line.includes('|')) test.ai_analysis = aiInline[1].trim()
		}
	}

	// Parse QA Dimensions — supports table format and heading format
	const dimSection = content.match(/## QA Dimensions\n([\s\S]*?)(?=\n## |$)/)
	if (dimSection) {
		const dimText = dimSection[1]
		// Format A: table (| Completeness | 8/10 | rationale |)
		for (const line of dimText.split('\n')) {
			const m = line.match(/\|\s*(\w[\w ]*?)\s*\|\s*(\d+)\/10\s*\|\s*(.+?)\s*\|/)
			if (m) {
				result.dimensions.push({
					dimension: m[1].trim(),
					score: parseInt(m[2]),
					rationale: m[3].trim(),
				})
			}
		}
		// Format B: headings (### Completeness: 8/10\n- rationale line)
		if (result.dimensions.length === 0) {
			const headingMatches = dimText.matchAll(
				/### (\w[\w ]*?):\s*(\d+)\/10\n([\s\S]*?)(?=\n### |\n---|\n## |$)/g,
			)
			for (const hm of headingMatches) {
				const rationale = hm[3]
					.split('\n')
					.filter((l) => l.startsWith('- '))
					.map((l) => l.replace(/^- /, ''))
					.join('; ')
					.slice(0, 200)
				result.dimensions.push({ dimension: hm[1].trim(), score: parseInt(hm[2]), rationale })
			}
		}
	}

	// Parse Gaps — supports YAML format and table format
	const gapsSection = content.match(/## Gaps\n([\s\S]*?)(?=\n## |---\n|$)/)
	if (gapsSection) {
		const gapText = gapsSection[1]

		// Format A: YAML (- truth: "..." severity: ...)
		if (gapText.includes('truth:')) {
			const gapBlocks = gapText.split(/\n- truth:/).filter(Boolean)
			for (const gb of gapBlocks) {
				const gap = {}
				const fields = [
					'truth',
					'status',
					'reason',
					'severity',
					'feature',
					'test',
					'evidence',
					'root_cause',
					'fix_plan',
				]
				for (const line of ('truth:' + gb).split('\n')) {
					for (const f of fields) {
						const m = line.match(new RegExp(`^\\s*${f}:\\s*"?(.+?)"?\\s*$`))
						if (m) gap[f] = m[1]
					}
				}
				if (gap.truth || gap.reason) result.gaps.push(gap)
			}
		}
		// Format B: Table (| # | Severity | Description |)
		if (result.gaps.length === 0) {
			for (const line of gapText.split('\n')) {
				const m = line.match(/\|\s*\d+\s*\|\s*(\w+)\s*\|\s*(.+?)\s*\|/)
				if (m && !m[1].includes('---') && !m[1].includes('Severity')) {
					result.gaps.push({
						severity: m[1].toLowerCase(),
						reason: m[2].trim(),
						truth: m[2].trim(),
					})
				}
			}
		}
	}

	// Parse Video Evidence
	const videoSection = content.match(/## Video Evidence\n([\s\S]*?)(?=\n## |$)/)
	if (videoSection) {
		for (const line of videoSection[1].split('\n')) {
			const m = line.match(/^- (.+?):\s*(.+)/)
			if (m) result.videoEvidence.push({ file: m[1].trim(), description: m[2].trim() })
		}
	}

	return result
}

// ---------------------------------------------------------------------------
// Recursive file scanner
// ---------------------------------------------------------------------------

function scanDirRecursive(dir, baseDir) {
	const imageExts = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif'])
	const videoExts = new Set(['.webm', '.mp4', '.mov'])
	const results = { images: [], videos: [], jsonFiles: [] }
	const entries = readdirSync(dir, { withFileTypes: true })
	for (const entry of entries) {
		const fullPath = join(dir, entry.name)
		const relPath = fullPath.replace(baseDir + '/', '')
		if (entry.isDirectory() && !entry.name.startsWith('.') && entry.name !== 'seq') {
			const sub = scanDirRecursive(fullPath, baseDir)
			results.images.push(...sub.images)
			results.videos.push(...sub.videos)
			results.jsonFiles.push(...sub.jsonFiles)
		} else if (entry.isFile()) {
			const ext = extname(entry.name).toLowerCase()
			if (imageExts.has(ext) && !entry.name.endsWith('.encrypted')) {
				results.images.push({ relPath, fullPath })
			} else if (videoExts.has(ext)) {
				results.videos.push({ relPath, fullPath })
			} else if (ext === '.json') {
				results.jsonFiles.push({ relPath, fullPath })
			}
		}
	}
	return results
}

// ---------------------------------------------------------------------------
// report command
// ---------------------------------------------------------------------------

async function cmdReport(args) {
	const proofDirIdx = args.indexOf('--proof-dir')
	const proofDir = proofDirIdx >= 0 ? resolve(args[proofDirIdx + 1]) : resolve('.')

	const titleIdx = args.indexOf('--title')
	const title = titleIdx >= 0 ? args[titleIdx + 1] : `Proof Report — ${basename(proofDir)}`

	const openReport =
		args.includes('--open') || (!args.includes('--no-open') && process.platform === 'darwin')

	if (!existsSync(proofDir)) {
		fail(`Proof directory not found: ${proofDir}`)
	}

	process.stderr.write(`Scanning proof directory: ${proofDir}\n`)

	const isArchive = args.includes('--archive')

	// --- PRIMARY DATA SOURCE: verification.json (falls back to VERIFICATION.md) ---
	const verification = loadVerificationData(proofDir)

	// --- SCAN FILES RECURSIVELY ---
	const scanned = scanDirRecursive(proofDir, proofDir)
	process.stderr.write(
		`Found: ${scanned.images.length} images, ${scanned.videos.length} videos, ${scanned.jsonFiles.length} JSON files\n`,
	)

	// Build screenshot evidence map (filename -> evidence data)
	const screenshotMap = new Map()
	for (const img of scanned.images) {
		const ext = extname(img.relPath).toLowerCase()
		const mimeType = ext === '.jpg' || ext === '.jpeg' ? 'image/jpeg' : 'image/png'
		let base64 = null
		if (isArchive) {
			const imgData = readFileSync(img.fullPath)
			base64 = imgData.toString('base64')
			process.stderr.write(
				`  Embedded: ${img.relPath} (${(imgData.length / 1024).toFixed(0)} KB)\n`,
			)
		} else {
			const size = statSync(img.fullPath).size
			process.stderr.write(`  Linked: ${img.relPath} (${(size / 1024).toFixed(0)} KB)\n`)
		}
		screenshotMap.set(img.relPath, { filename: img.relPath, base64, mimeType })
		// Also index by basename for matching
		screenshotMap.set(basename(img.relPath), { filename: img.relPath, base64, mimeType })
	}

	// Build video list
	const videos = []
	for (const vid of scanned.videos) {
		const ext = extname(vid.relPath).toLowerCase()
		const mimeType =
			ext === '.mp4' ? 'video/mp4' : ext === '.mov' ? 'video/quicktime' : 'video/webm'
		const size = statSync(vid.fullPath).size
		process.stderr.write(`  Video: ${vid.relPath} (${(size / 1024 / 1024).toFixed(1)} MB)\n`)
		videos.push({ filename: vid.relPath, mimeType, analysis: null })
	}

	// Load JSON analysis files
	const analysisMap = new Map()
	let quorum = null
	const verdicts = []
	let jsonDimensions = []
	for (const jf of scanned.jsonFiles) {
		const jName = basename(jf.relPath)
		let parsed
		try {
			parsed = JSON.parse(readFileSync(jf.fullPath, 'utf-8'))
		} catch {
			continue
		}
		if (jName === 'report.json') {
			if (parsed.quorum) quorum = parsed.quorum
			if (parsed.verdicts) verdicts.push(...parsed.verdicts)
			if (parsed.qaSignoff?.dimensions) jsonDimensions = parsed.qaSignoff.dimensions
		} else if (jName.endsWith('-analysis.json')) {
			const stem = jName.replace(/-analysis\.json$/, '')
			analysisMap.set(stem, parsed.analysis ?? parsed)
		} else if (parsed.verdict || parsed.result) {
			if (parsed.provider || parsed.model) verdicts.push(parsed)
		}
	}

	// --- MERGE: VERIFICATION.md takes priority, files fill gaps ---
	const dimensions = verification?.dimensions?.length > 0 ? verification.dimensions : jsonDimensions

	const overallVerdict =
		verification?.frontmatter?.verdict ??
		quorum?.finalVerdict ??
		(verdicts.length > 0 ? (verdicts[0].verdict ?? 'partial') : 'partial')

	// Generate HTML
	const runDate = verification?.frontmatter?.started
		? new Date(verification.frontmatter.started).toISOString().replace('T', ' ').slice(0, 19) +
			' UTC'
		: new Date().toISOString().replace('T', ' ').slice(0, 19) + ' UTC'

	const html = generateReportHtml({
		title,
		runDate,
		verification,
		screenshots: [...screenshotMap.values()].filter(
			(v, i, a) => a.findIndex((x) => x.filename === v.filename) === i,
		),
		screenshotMap,
		videos,
		verdicts,
		quorum,
		dimensions,
		overallVerdict,
		mode: isArchive ? 'embedded' : 'relative',
	})

	// Write report.html
	const reportHtmlPath = join(proofDir, 'report.html')
	writeFileSync(reportHtmlPath, html, 'utf-8')
	process.stderr.write(`Report written: ${reportHtmlPath}\n`)

	// Generate report.md
	const reportMdPath = join(proofDir, 'report.md')
	const mdContent = generateReportMd({
		title,
		runDate,
		verification,
		screenshots: scanned.images,
		videos,
		verdicts,
		dimensions,
		overallVerdict,
	})
	writeFileSync(reportMdPath, mdContent, 'utf-8')
	process.stderr.write(`Markdown report written: ${reportMdPath}\n`)

	// Open in browser
	if (openReport) {
		try {
			const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open'
			execSync(`${openCmd} "${reportHtmlPath}"`)
			process.stderr.write(`Opened in browser.\n`)
		} catch (err) {
			process.stderr.write(`Could not open browser: ${err.message}\n`)
		}
	}

	output({
		status: 'ok',
		report: {
			html: reportHtmlPath,
			markdown: reportMdPath,
			screenshotCount: scanned.images.length,
			videoCount: scanned.videos.length,
			verdictCount: verdicts.length,
			featuresCount: verification?.features?.length ?? 0,
			gapsCount: verification?.gaps?.length ?? 0,
			mode: isArchive ? 'archive' : 'relative',
			title,
		},
	})
}

function generateReportMd({
	title,
	runDate,
	verification,
	screenshots,
	videos,
	verdicts,
	dimensions,
	overallVerdict,
}) {
	const lines = [
		`# ${title}`,
		'',
		`**Generated:** ${runDate}`,
		`**Verdict:** ${overallVerdict}`,
		'',
	]
	if (verification) {
		const s = verification.summary
		lines.push(`## Summary`, '')
		if (s.outcome) lines.push(`**Outcome:** ${s.outcome}`, '')
		lines.push(`| Metric | Value |`, `|--------|-------|`)
		for (const [k, v] of Object.entries(s)) {
			if (k !== 'outcome' && k !== 'verdictText') lines.push(`| ${k} | ${v} |`)
		}
		lines.push('')
		if (dimensions.length > 0) {
			lines.push(
				`## QA Dimensions`,
				'',
				`| Dimension | Score | Rationale |`,
				`|-----------|-------|-----------|`,
			)
			for (const d of dimensions) lines.push(`| ${d.dimension} | ${d.score}/10 | ${d.rationale} |`)
			lines.push('')
		}
		if (verification.features.length > 0) {
			lines.push(`## Features`, '')
			for (const f of verification.features) {
				const icon =
					f.result === 'pass'
						? '✅'
						: f.result === 'fail'
							? '❌'
							: f.result === 'blocked'
								? '⚠️'
								: '⏳'
				lines.push(`### ${icon} ${f.name}`, '')
				for (const t of f.tests) {
					const ti =
						t.result === 'pass'
							? '✅'
							: t.result === 'fail'
								? '❌'
								: t.result === 'blocked'
									? '⚠️'
									: '⏳'
					lines.push(`- ${ti} **${t.name}**: ${t.actual || t.expected || ''}`)
				}
				lines.push('')
			}
		}
		if (verification.gaps.length > 0) {
			lines.push(`## Gaps`, '')
			for (const g of verification.gaps) lines.push(`- **[${g.severity}]** ${g.truth}: ${g.reason}`)
			lines.push('')
		}
	}
	lines.push(`---`, `*Generated by [Aperant Framework](https://github.com/AndyMik90/Aperant)*`)
	return lines.join('\n')
}

async function cmdModels() {
	const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY
	if (!apiKey) fail('Set GEMINI_API_KEY or GOOGLE_API_KEY environment variable.')

	const GoogleGenAI = await loadGenAI()
	const ai = new GoogleGenAI({ apiKey })

	const models = []
	const result = await ai.models.list()
	for await (const model of result) {
		if (
			model.name.includes('flash') ||
			model.name.includes('pro') ||
			model.name.includes('3.1') ||
			model.name.includes('3-1') ||
			model.name.includes('2.5')
		) {
			models.push({
				id: model.name.replace('models/', ''),
				displayName: model.displayName || model.name,
			})
		}
	}

	output({
		presets: MODEL_PRESETS,
		available: models,
	})
}

// ---------------------------------------------------------------------------
// Help
// ---------------------------------------------------------------------------

function printHelp() {
	const help = `${getVersion()}

Usage: apt-proof-video <command> [args]

Commands:
  analyze <video>   Analyze a video file with Gemini
  capture           Record frames from a CDP-enabled app (multi-session mode)
  report            Generate HTML proof report from a proof directory
  models            List available Gemini models

Flags for 'analyze':
  --model <name>    Model preset or ID (default|thorough|fast or full model ID)
                    default  = ${MODEL_PRESETS.default}
                    thorough = ${MODEL_PRESETS.thorough}
                    fast     = ${MODEL_PRESETS.fast}
  --spec <summary>  Spec requirements to verify against
  --describe        Free-form description mode (no structured JSON)

Flags for 'capture':
  --port <n>        CDP debug port (default: 9222)
  --host <h>        CDP host (default: localhost)
  --duration <s>    Recording duration in seconds (default: 30)
  --fps <n>         Target frames per second (default: 2)
  --output <dir>    Output directory for JPEG frames (default: ./capture-frames)
  --quality <n>     JPEG quality 0-100 (default: 80)
  --target <id>     Specific CDP target ID (default: first page)

Flags for 'report':
  --proof-dir <d>   Directory containing screenshots, videos, and JSON analysis files
  --title <t>       Report title (default: directory name)
  --archive         Embed all images as base64 (portable single file, larger)
                    Default: relative paths (lightweight, must stay with proof dir)
  --open            Open report in browser after generation (default on macOS)
  --no-open         Skip opening in browser

Environment:
  GEMINI_API_KEY    Google AI API key (get free at https://aistudio.google.com/apikey)
  GOOGLE_API_KEY    Alternative env var name

All output is JSON to stdout. Progress goes to stderr.`

	process.stdout.write(help + '\n')
}

// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------

const args = process.argv.slice(2)
const command = args[0]

if (!command || command === '--help' || command === '-h') {
	printHelp()
	process.exit(0)
}

if (command === '--version' || command === '-v') {
	process.stdout.write(getVersion() + '\n')
	process.exit(0)
}

switch (command) {
	case 'analyze':
		await cmdAnalyze(args.slice(1))
		break
	case 'capture':
		await cmdCapture(args.slice(1))
		break
	case 'report':
		await cmdReport(args.slice(1))
		break
	case 'models':
		await cmdModels()
		break
	default:
		fail(`Unknown command: ${command}. Run apt-proof-video --help for usage.`)
}
