/**
 * wfrun/run.mjs — Orchestrator for C17 declarative workflows.
 *
 * Three verbs, chosen via `subcommand`:
 *   - validate : parse + schema + DAG + reference reachability. No side effects.
 *   - plan     : validate + bind inputs + interpolate node bodies (plan mode,
 *                leaves unknown node-output refs literal). Emits the run plan
 *                on stdout, writes nothing.
 *   - run      : plan + if every node is `kind: tool`, execute wave-by-wave
 *                via spawnSync of `apt-tools <command> <args…>`. Persists
 *                .aperant/wfrun/{run_id}/{manifest,plan,result}.json via
 *                atomicWriteJson. If a non-tool node is present the run
 *                returns `{status: 'plan-only', reason, ...}` and writes
 *                manifest.json + plan.json (no result.json).
 *
 * Security posture:
 *   - tool nodes invoke the apt-tools binary only. `run.command` must match
 *     an allowlist matching the dispatch table in bin/apt-tools.mjs (seeded
 *     here; kept in sync by humans — a drift test would be over-engineering
 *     for v1 since the list is short and the risk surface is small).
 *   - No network, no filesystem write outside .aperant/wfrun/**.
 */

import { spawn } from 'node:child_process'
import { randomBytes } from 'node:crypto'
import { dirname, isAbsolute, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { atomicWriteJson } from '../util/atomic-write.mjs'
import { resolveWaves } from './dag.mjs'
import {
	collectReferences,
	InterpolationError,
	interpolate,
	interpolateNode,
} from './interpolate.mjs'
import { parseWorkflowFile } from './parse.mjs'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
// src/cli/wfrun/run.mjs → packages/framework/bin/apt-tools.mjs
const APT_TOOLS_BIN = resolve(__dirname, '..', '..', '..', 'bin', 'apt-tools.mjs')

/**
 * Allowlist of `apt-tools` subcommands invokable from a `kind: tool` node.
 * Matches the dispatch switch in bin/apt-tools.mjs (excluding mutating
 * lifecycle verbs that should be driven by their own skills, not by
 * declarative workflows).
 */
const TOOL_COMMAND_ALLOWLIST = new Set([
	'state',
	'config',
	'state-artifacts',
	'detect-project',
	'risk-check',
	'task',
	'event',
	'effects',
	'wave',
	'coverage-check',
	'worktree',
	'detect-worktree',
	'template',
	'check-version',
	'health-check',
	'route',
	'audit',
	'toolchain-detect',
	'host-detect',
	'reconcile',
	'team',
	'active',
	'roadmap',
	'workflow',
	'gate',
	'constitution',
	'consistency',
	'pr-review',
	'plan-approval',
	'validate-evidence',
	'tokens',
	'token-budget',
])

/**
 * Parse --input k=v flags (repeatable) from an extraArgs array.
 * Malformed invocations (missing value, missing '=') are surfaced as
 * structured errors instead of being silently dropped.
 *
 * @param {string[]} extraArgs
 * @returns {{ok: true, inputs: Record<string, string>} | {ok: false, errors: string[]}}
 */
export function parseInputFlags(extraArgs) {
	const inputs = {}
	const errors = []
	for (let i = 0; i < extraArgs.length; i++) {
		if (extraArgs[i] !== '--input') continue
		const pair = extraArgs[i + 1]
		if (pair === undefined || pair.startsWith('--')) {
			errors.push('--input requires a "name=value" argument')
			continue
		}
		const eq = pair.indexOf('=')
		if (eq <= 0) {
			errors.push(`--input "${pair}" must be in the form name=value`)
			i++
			continue
		}
		inputs[pair.slice(0, eq)] = pair.slice(eq + 1)
		i++
	}
	if (errors.length > 0) return { ok: false, errors }
	return { ok: true, inputs }
}

/**
 * Bind declared inputs: apply defaults, enforce required, reject unknown.
 * @returns {{ok: true, values: Record<string,string>} | {ok: false, errors: string[]}}
 */
function bindInputs(workflow, userInputs) {
	const declared = new Map()
	for (const def of workflow.inputs ?? []) declared.set(def.name, def)

	const errors = []
	for (const name of Object.keys(userInputs)) {
		if (!declared.has(name)) {
			errors.push(`unknown input: "${name}"`)
		}
	}

	const values = {}
	for (const def of declared.values()) {
		if (Object.hasOwn(userInputs, def.name)) {
			values[def.name] = userInputs[def.name]
		} else if (def.default !== undefined) {
			values[def.name] = def.default
		} else if (def.required !== false) {
			errors.push(`missing required input: "${def.name}"`)
		}
	}
	if (errors.length > 0) return { ok: false, errors }
	return { ok: true, values }
}

/**
 * Build the symbolic context used during plan-mode interpolation. Inputs
 * resolve to their bound string values; each declared node is represented
 * by `{outputs: {<key>: '${nodeId.outputs.key}'}}` so unresolved refs keep
 * the original `${…}` token for the caller to handle.
 */
function buildPlanContext(workflow, inputs) {
	const ctx = { inputs: { ...inputs } }
	for (const node of workflow.nodes) {
		const outputs = {}
		for (const key of node.outputs ?? []) {
			outputs[key] = `\${${node.id}.outputs.${key}}`
		}
		ctx[node.id] = { outputs }
	}
	return ctx
}

/**
 * Verify every ${…} reference in node bodies is structurally resolvable
 * against the declared inputs + nodes/outputs graph. Only checks shapes —
 * values don't need to exist yet.
 */
function validateReferences(workflow) {
	const declaredInputNames = new Set((workflow.inputs ?? []).map((i) => i.name))
	const nodeIndex = new Map()
	for (const n of workflow.nodes) {
		nodeIndex.set(n.id, new Set(n.outputs ?? []))
	}

	const errors = []
	const walk = (value, location) => {
		if (typeof value === 'string') {
			for (const path of collectReferences(value)) {
				const parts = path.split('.').map((s) => s.trim())
				if (parts[0] === 'inputs') {
					const name = parts[1]
					if (!name || !declaredInputNames.has(name)) {
						errors.push({ path: location, message: `unknown input "${name ?? ''}" in \${${path}}` })
					}
				} else {
					// must be <nodeId>.outputs.<key>
					if (parts.length !== 3 || parts[1] !== 'outputs') {
						errors.push({
							path: location,
							message: `invalid reference shape \${${path}} (expected inputs.<name> or <nodeId>.outputs.<key>)`,
						})
						continue
					}
					const nodeId = parts[0]
					const key = parts[2]
					const outs = nodeIndex.get(nodeId)
					if (!outs) {
						errors.push({ path: location, message: `unknown node "${nodeId}" in \${${path}}` })
					} else if (!outs.has(key)) {
						errors.push({
							path: location,
							message: `node "${nodeId}" does not declare output "${key}"`,
						})
					}
				}
			}
			return
		}
		if (Array.isArray(value)) {
			for (let i = 0; i < value.length; i++) walk(value[i], `${location}[${i}]`)
			return
		}
		if (value != null && typeof value === 'object') {
			for (const [k, v] of Object.entries(value)) walk(v, `${location}.${k}`)
		}
	}

	for (let i = 0; i < workflow.nodes.length; i++) {
		walk(workflow.nodes[i], `nodes[${i}]`)
	}
	return errors
}

/**
 * Generate a run-id: wf-YYYYMMDD-HHmmss-<4 hex>.
 */
export function generateRunId(now = new Date()) {
	const pad = (n) => String(n).padStart(2, '0')
	const ymd = now.getUTCFullYear().toString() + pad(now.getUTCMonth() + 1) + pad(now.getUTCDate())
	const hms = pad(now.getUTCHours()) + pad(now.getUTCMinutes()) + pad(now.getUTCSeconds())
	const hex = randomBytes(2).toString('hex')
	return `wf-${ymd}-${hms}-${hex}`
}

/**
 * Shape the run plan (waves of interpolated node summaries).
 */
function shapeWaves(waves, context) {
	return waves.map((wave) =>
		wave.map((node) => {
			const interp = interpolateNode(node, context, { mode: 'plan' })
			if (interp.kind === 'tool') {
				return {
					id: interp.id,
					kind: 'tool',
					command: interp.run.command,
					args: interp.run.args ?? [],
					depends_on: interp.depends_on ?? [],
					outputs: interp.outputs ?? [],
				}
			}
			if (interp.kind === 'agent') {
				return {
					id: interp.id,
					kind: 'agent',
					agent: interp.agent,
					prompt: interp.prompt,
					depends_on: interp.depends_on ?? [],
					outputs: interp.outputs ?? [],
				}
			}
			// skill
			return {
				id: interp.id,
				kind: 'skill',
				skill: interp.skill,
				prompt: interp.prompt,
				depends_on: interp.depends_on ?? [],
				outputs: interp.outputs ?? [],
			}
		}),
	)
}

/**
 * Main entrypoint dispatched by cmdWfrun.
 * @param {{subcommand: 'validate'|'plan'|'run', workflowPath: string, projectDir: string, inputs: Record<string,string>, flags?: Map<string,any>}} params
 * @returns {{ok: true, result: object} | {ok: false, errors: any[]}}
 */
export async function runWfrun({ subcommand, workflowPath, projectDir, inputs }) {
	const absWorkflow = isAbsolute(workflowPath) ? workflowPath : resolve(process.cwd(), workflowPath)
	const parsed = parseWorkflowFile(absWorkflow)
	if (!parsed.ok) {
		return { ok: false, errors: parsed.errors }
	}
	const workflow = parsed.workflow

	// DAG waves
	const waveResult = resolveWaves(workflow.nodes)
	if (!waveResult.ok) {
		return {
			ok: false,
			errors: [
				{
					path: 'nodes',
					message: `cycle detected: ${waveResult.cycle.join(' → ')}`,
					cycle: waveResult.cycle,
				},
			],
		}
	}
	const waves = waveResult.waves

	// Reference reachability (shape-only)
	const refErrors = validateReferences(workflow)
	if (refErrors.length > 0) {
		return { ok: false, errors: refErrors }
	}

	if (subcommand === 'validate') {
		return {
			ok: true,
			result: {
				status: 'ok',
				command: 'wfrun-validate',
				workflow_id: workflow.metadata.id,
				node_count: workflow.nodes.length,
				wave_count: waves.length,
			},
		}
	}

	// inputs
	const bound = bindInputs(workflow, inputs)
	if (!bound.ok) {
		return { ok: false, errors: bound.errors.map((m) => ({ path: 'inputs', message: m })) }
	}

	const planContext = buildPlanContext(workflow, bound.values)
	let shapedWaves
	try {
		shapedWaves = shapeWaves(waves, planContext)
	} catch (err) {
		if (err instanceof InterpolationError) {
			return { ok: false, errors: [{ path: err.path, message: err.message }] }
		}
		throw err
	}

	const inlineExecutable = workflow.nodes.every((n) => n.kind === 'tool')
	const reason = inlineExecutable
		? 'all nodes are kind:tool — inline runnable'
		: `contains non-tool nodes (${workflow.nodes
				.filter((n) => n.kind !== 'tool')
				.map((n) => `${n.id}:${n.kind}`)
				.join(', ')}) — caller must spawn`

	const runId = generateRunId()

	const planEnvelope = {
		run_id: runId,
		workflow_id: workflow.metadata.id,
		waves: shapedWaves,
		inline_executable: inlineExecutable,
		reason,
		inputs: bound.values,
	}

	if (subcommand === 'plan') {
		return { ok: true, result: { status: 'ok', command: 'wfrun-plan', ...planEnvelope } }
	}

	// subcommand === 'run'
	const runDir = resolve(projectDir, '.aperant', 'wfrun', runId)
	const manifest = {
		run_id: runId,
		workflow_id: workflow.metadata.id,
		workflow_path: absWorkflow,
		started_at: new Date().toISOString(),
		inputs: bound.values,
		node_count: workflow.nodes.length,
		wave_count: waves.length,
	}
	atomicWriteJson(resolve(runDir, 'manifest.json'), manifest)
	atomicWriteJson(resolve(runDir, 'plan.json'), planEnvelope)

	if (!inlineExecutable) {
		return {
			ok: true,
			result: {
				status: 'plan-only',
				command: 'wfrun-run',
				run_id: runId,
				workflow_id: workflow.metadata.id,
				reason,
				waves: shapedWaves,
				inline_executable: false,
				artifacts: {
					manifest: resolve(runDir, 'manifest.json'),
					plan: resolve(runDir, 'plan.json'),
				},
			},
		}
	}

	// Inline tool execution: wave-by-wave, nodes within a wave in parallel.
	// We use `spawn` (async) + Promise.all per wave to actually overlap
	// subprocess execution when a wave has >1 node. Between waves we await —
	// so dependency ordering is preserved.
	const executeToolNode = (node) =>
		new Promise((resolvePromise) => {
			if (!TOOL_COMMAND_ALLOWLIST.has(node.command)) {
				const now = new Date().toISOString()
				resolvePromise({
					node_id: node.id,
					command: node.command,
					args: node.args,
					exit_code: 127,
					error: `command "${node.command}" not in tool allowlist`,
					started_at: now,
					completed_at: now,
				})
				return
			}
			const started = new Date().toISOString()
			const child = spawn('node', [APT_TOOLS_BIN, node.command, ...node.args], {
				cwd: projectDir,
				stdio: ['ignore', 'pipe', 'pipe'],
			})
			const timer = setTimeout(() => {
				try {
					child.kill('SIGKILL')
				} catch {
					/* ignore — process may already be dead */
				}
			}, 60_000)
			let stdout = ''
			let stderr = ''
			child.stdout.setEncoding('utf-8')
			child.stderr.setEncoding('utf-8')
			// Cap buffered stdout/stderr to avoid OOM on chatty subcommands —
			// only the last 4KB is retained in the result anyway.
			const MAX_BUF = 1_048_576 // 1 MB
			child.stdout.on('data', (chunk) => {
				stdout += chunk
				if (stdout.length > MAX_BUF) stdout = stdout.slice(-MAX_BUF)
			})
			child.stderr.on('data', (chunk) => {
				stderr += chunk
				if (stderr.length > MAX_BUF) stderr = stderr.slice(-MAX_BUF)
			})
			let spawnErr = null
			child.on('error', (err) => {
				spawnErr = err
			})
			child.on('close', (code, signal) => {
				clearTimeout(timer)
				const completed = new Date().toISOString()
				const record = {
					node_id: node.id,
					command: node.command,
					args: node.args,
					exit_code: code ?? (signal ? 128 : 1),
					stdout_tail: stdout.length > 4096 ? stdout.slice(-4096) : stdout,
					stderr_tail: stderr.length > 4096 ? stderr.slice(-4096) : stderr,
					started_at: started,
					completed_at: completed,
				}
				if (signal) record.signal = signal
				if (spawnErr) record.error = spawnErr.message
				resolvePromise(record)
			})
		})

	const perNode = []
	for (const wave of shapedWaves) {
		const waveResults = await Promise.all(wave.map((node) => executeToolNode(node)))
		perNode.push(...waveResults)
	}

	const result = {
		run_id: runId,
		workflow_id: workflow.metadata.id,
		completed_at: new Date().toISOString(),
		inline_executable: true,
		nodes: perNode,
	}
	atomicWriteJson(resolve(runDir, 'result.json'), result)

	return {
		ok: true,
		result: {
			status: 'ok',
			command: 'wfrun-run',
			run_id: runId,
			workflow_id: workflow.metadata.id,
			inline_executable: true,
			nodes: perNode,
			artifacts: {
				manifest: resolve(runDir, 'manifest.json'),
				plan: resolve(runDir, 'plan.json'),
				result: resolve(runDir, 'result.json'),
			},
		},
	}
}

// Re-export for use in the CLI command wrapper.
export { interpolate }
