/**
 * commands/effects.mjs — pending-effects outbox (record/list/ack/drain/clear) (C29).
 *
 * The outbox is the low-volume authoritative event queue: agents append
 * structured entries, a drainer replays them into canonical apt-tools paths.
 * Storage: .aperant/pending-effects.jsonl (one JSON entry per line).
 * Concurrency: every read/write goes through withFileLock on the file itself.
 */

import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { findMilestoneScopeGlobal, loadRoadmapForScope } from '../roadmap/io.mjs'
import { applyLifecyclePhaseSideEffect } from '../roadmap/lifecycle.mjs'
import { parseFlags } from '../util/args.mjs'
import { withFileLock } from '../util/fs-lock.mjs'
import { err, ok, rawText } from '../util/result.mjs'

function effectsPath(projectDir) {
	return join(resolve(projectDir), '.aperant', 'pending-effects.jsonl')
}

function readEffects(filePath) {
	if (!existsSync(filePath)) return []
	const raw = readFileSync(filePath, 'utf-8')
	const out = []
	for (const line of raw.split('\n')) {
		const trimmed = line.trim()
		if (!trimmed) continue
		try {
			out.push(JSON.parse(trimmed))
		} catch {
			// Skip corrupt lines silently — record/ack rewrite the file so
			// corruption self-heals on the next mutation.
		}
	}
	return out
}

function writeEffects(filePath, entries) {
	mkdirSync(dirname(filePath), { recursive: true })
	const body = entries.map((e) => JSON.stringify(e)).join('\n')
	const tmpPath = filePath + '.tmp.' + process.pid
	writeFileSync(tmpPath, entries.length === 0 ? '' : body + '\n', 'utf-8')
	renameSync(tmpPath, filePath)
}

function generateEffectId() {
	const ts = Date.now().toString(36)
	const rand = Math.random().toString(36).slice(2, 8)
	return `ef-${ts}-${rand}`
}

export function cmdEffects(subcommand, projectDir, extraArgs) {
	if (!subcommand)
		return err(
			'Usage: apt-tools effects <record|list|ack|drain|pending-count|clear> <project-dir> [flags]',
		)
	if (!projectDir) return err('Usage: apt-tools effects ' + subcommand + ' <project-dir> [flags]')

	const filePath = effectsPath(projectDir)
	const flags = parseFlags(extraArgs)

	switch (subcommand) {
		case 'record': {
			const op = flags.get('op')
			const agent = flags.get('agent')
			const mode = flags.get('mode') || ''
			const affectsRaw = flags.get('affects') || ''
			const contextRaw = flags.get('context') || ''

			if (!op) return err('Required: --op <operation-name>')
			if (!agent) return err('Required: --agent <agent-type>')

			const affects = affectsRaw
				? affectsRaw
						.split(',')
						.map((s) => s.trim())
						.filter(Boolean)
				: []

			let context = {}
			if (contextRaw) {
				try {
					context = JSON.parse(contextRaw)
				} catch (e) {
					return err(`Invalid JSON for --context: ${e.message}`)
				}
			}

			const entry = {
				id: generateEffectId(),
				op,
				agent,
				mode,
				affects,
				context,
				created_at: new Date().toISOString(),
				attempts: 0,
			}

			withFileLock(
				filePath,
				() => {
					const entries = readEffects(filePath)
					entries.push(entry)
					writeEffects(filePath, entries)
				},
				{ projectRoot: resolve(projectDir) },
			)

			return ok({ status: 'ok', command: 'effects-record', id: entry.id, entry })
		}

		case 'list': {
			const format = flags.get('format') || 'text'
			const entries = withFileLock(filePath, () => readEffects(filePath), {
				projectRoot: resolve(projectDir),
			})

			if (format === 'json') {
				return ok({ status: 'ok', command: 'effects-list', count: entries.length, entries })
			}
			// Raw-text path: build the human-readable string and route through
			// wrap()'s rawText shape (subtask 15) — no JSON envelope wrapper.
			// (Subtask 20: replaces the legacy `process.stdout.write + return
			// undefined` pattern that depended on wrap()'s now-deleted
			// legacy-tolerance branch.)
			let text
			if (entries.length === 0) {
				text = 'No pending effects.\n'
			} else {
				const lines = [`${entries.length} pending effect(s):`, '']
				for (const e of entries) {
					lines.push(`  ${e.id}`)
					lines.push(`    op:      ${e.op}`)
					lines.push(`    agent:   ${e.agent}`)
					if (e.mode) lines.push(`    mode:    ${e.mode}`)
					if (e.affects.length) lines.push(`    affects: ${e.affects.join(', ')}`)
					lines.push(`    created: ${e.created_at}`)
					lines.push('')
				}
				text = `${lines.join('\n')}`
			}
			return rawText(text, 0)
		}

		case 'pending-count': {
			const entries = withFileLock(filePath, () => readEffects(filePath), {
				projectRoot: resolve(projectDir),
			})
			return ok({ status: 'ok', command: 'effects-pending-count', count: entries.length })
		}

		case 'ack': {
			const id = flags.get('id')
			if (!id) return err('Required: --id <effect-id>')

			let removed = null
			let unknown = false
			withFileLock(
				filePath,
				() => {
					const entries = readEffects(filePath)
					const idx = entries.findIndex((e) => e.id === id)
					if (idx === -1) {
						// Tolerate unknown ids so drain retries and concurrent
						// drainers are safe. The caller pattern-matches on
						// status:ok and can inspect `removed`/`note` to detect
						// the no-op.
						unknown = true
						return
					}
					removed = entries.splice(idx, 1)[0]
					writeEffects(filePath, entries)
				},
				{ projectRoot: resolve(projectDir) },
			)
			if (unknown) {
				return ok({
					status: 'ok',
					command: 'effects-ack',
					id,
					removed: null,
					note: 'unknown-id',
				})
			}
			return ok({ status: 'ok', command: 'effects-ack', id, removed })
		}

		case 'clear': {
			withFileLock(filePath, () => writeEffects(filePath, []), { projectRoot: resolve(projectDir) })
			return ok({ status: 'ok', command: 'effects-clear' })
		}

		case 'drain': {
			const targetDir = resolve(projectDir)
			const dryRun = extraArgs.includes('--dry-run')
			const verbose = extraArgs.includes('--verbose')
			const limitRaw = flags.get('limit')
			const limit = limitRaw ? Math.max(0, parseInt(limitRaw, 10) || 0) : 0

			// ── Op contract (authoritative, pure JS state machine) ───────────
			// Every op lives in one of three buckets:
			//   - ack-only:     no mutation, drop from queue
			//   - phase-trans:  (now empty) — lifecycle phase transitions moved
			//                   to `task update --lifecycle-phase` and `task
			//                   close --verdict approved`, which call the shared
			//                   `applyLifecyclePhaseSideEffect` helper inline.
			//   - decision:     append to decisions ledger
			// The unknown-op ack-only fallback below silently absorbs any
			// legacy `task.*.start` / `task.*.complete` entries left in an
			// in-flight queue on upgrade, so this change is forward-compatible.
			const ACK_ONLY_OPS = new Set(['roadmap.render'])
			const PHASE_TRANSITIONS = {}

			const transitions = []
			const commits = []
			let drained = 0
			let failed = 0
			let parked = 0

			function logv(msg) {
				if (verbose) process.stderr.write(`[effects-drain] ${msg}\n`)
			}

			function resolveScopeForEntry(entry) {
				const ctx = entry.context || {}
				if (ctx.scope) return ctx.scope
				if (ctx.milestone_id) return findMilestoneScopeGlobal(targetDir, ctx.milestone_id)
				return null
			}

			function applyPhaseTransition(entry, spec) {
				const ctx = entry.context || {}
				const scope = resolveScopeForEntry(entry)
				if (!scope) {
					logv(`  ${entry.op}: no scope (missing linkage) — no-op`)
					return null
				}
				if (!ctx.milestone_id || !ctx.phase_id) {
					logv(`  ${entry.op}: missing milestone_id/phase_id — no-op`)
					return null
				}
				// Delegate to the shared helper. It mirrors the legacy behavior —
				// from-state guard, saveRoadmapForScope, syncRoadmapInProcess — and
				// returns null on any no-op branch. We just add verbose logging
				// around the delegation so `--verbose` drains keep reading like the
				// old inlined flow.
				const task = {
					scope,
					milestone_id: ctx.milestone_id,
					phase_id: ctx.phase_id,
				}
				const result = applyLifecyclePhaseSideEffect(targetDir, task, spec, entry.op, {
					dryRun,
				})
				if (!result) {
					// Re-derive the diagnostic the old inlined version printed.
					const roadmap = loadRoadmapForScope(targetDir, scope)
					if (!roadmap) {
						logv(`  ${entry.op}: no roadmap for scope "${scope}" — no-op`)
					} else {
						const milestone = (roadmap.milestones || []).find((m) => m.id === ctx.milestone_id)
						if (!milestone) {
							logv(`  ${entry.op}: milestone "${ctx.milestone_id}" not found — no-op`)
						} else {
							const phase = (milestone.phases || []).find((p) => p.id === ctx.phase_id)
							if (!phase) {
								logv(`  ${entry.op}: phase "${ctx.phase_id}" not found — no-op`)
							} else if (phase.status !== spec.from) {
								logv(
									`  ${entry.op}: phase "${ctx.phase_id}" status="${phase.status}" (expected "${spec.from}") — no-op`,
								)
							}
						}
					}
					return null
				}
				if (result.commit) commits.push(result.commit)
				return result.transition
			}

			function applyDecisionRecorded(entry) {
				const ctx = entry.context || {}
				const indexPath = join(targetDir, '.aperant', 'decisions', 'INDEX.md')
				const taskId = ctx.task_id || '-'
				const source = entry.mode || '-'
				const summary = (ctx.summary || 'decision recorded').replace(/\|/g, '\\|')
				const artifactPath = ctx.artifact_path || '-'
				const date = new Date().toISOString().slice(0, 10)
				const row = `| ${date} | ${taskId} | ${source} | ${summary} | ${artifactPath} |`

				if (dryRun) return

				mkdirSync(dirname(indexPath), { recursive: true })
				let body
				if (existsSync(indexPath)) {
					body = readFileSync(indexPath, 'utf-8')
					// Idempotent: dedupe by (task_id, artifact_path) pair.
					const dedupeKey = `| ${taskId} |`
					const artifactKey = `| ${artifactPath} |`
					const existingLines = body.split('\n')
					const hit = existingLines.find((l) => l.includes(dedupeKey) && l.includes(artifactKey))
					if (hit) {
						logv(`  decision.recorded: already in ledger — no-op`)
						return
					}
					if (!body.endsWith('\n')) body += '\n'
					body += row + '\n'
				} else {
					body =
						'# Decisions Ledger\n\n| Date | Task | Source | Summary | Artifact |\n|---|---|---|---|---|\n'
					body += row + '\n'
				}
				writeFileSync(indexPath, body, 'utf-8')
			}

			// ── Drain loop under the effects-file lock ──────────────────────
			withFileLock(
				filePath,
				() => {
					const entries = readEffects(filePath)
					const remaining = []
					const deadQueue = []

					const budget = limit > 0 ? Math.min(limit, entries.length) : entries.length

					for (let i = 0; i < entries.length; i++) {
						const entry = entries[i]
						if (i >= budget) {
							// --limit: leave the rest in the queue for the next pass.
							remaining.push(entry)
							continue
						}
						logv(`processing ${entry.id} op=${entry.op}`)
						try {
							if (ACK_ONLY_OPS.has(entry.op)) {
								drained += 1
								continue
							}
							const transSpec = PHASE_TRANSITIONS[entry.op]
							if (transSpec) {
								const t = applyPhaseTransition(entry, transSpec)
								if (t) transitions.push(t)
								drained += 1
								continue
							}
							if (entry.op === 'decision.recorded') {
								applyDecisionRecorded(entry)
								drained += 1
								continue
							}
							// Unknown op — ack and move on (forward-compat).
							logv(`  unknown op "${entry.op}" — ack-only`)
							drained += 1
						} catch (err) {
							failed += 1
							const attempts = (entry.attempts || 0) + 1
							entry.attempts = attempts
							entry.last_error = String(err && err.message ? err.message : err)
							if (attempts >= 3) {
								parked += 1
								deadQueue.push({
									...entry,
									error: entry.last_error,
									parked_at: new Date().toISOString(),
								})
								logv(`  parked after ${attempts} attempts: ${entry.last_error}`)
							} else {
								remaining.push(entry)
								logv(`  retry queued (attempt ${attempts}/3): ${entry.last_error}`)
							}
						}
					}

					if (dryRun) {
						// --dry-run: report but do not persist drained/parked state.
						return
					}

					writeEffects(filePath, remaining)

					if (deadQueue.length > 0) {
						const deadPath = join(targetDir, '.aperant', 'pending-effects.dead.jsonl')
						mkdirSync(dirname(deadPath), { recursive: true })
						const deadBody =
							(existsSync(deadPath) ? readFileSync(deadPath, 'utf-8') : '') +
							deadQueue.map((e) => JSON.stringify(e)).join('\n') +
							'\n'
						writeFileSync(deadPath, deadBody, 'utf-8')
					}
				},
				{ projectRoot: targetDir },
			)

			// Compute pending_after post-drain (outside the lock — informational).
			const pendingAfter = existsSync(filePath) ? readEffects(filePath).length : 0

			return ok({
				status: 'ok',
				command: 'effects-drain',
				drained,
				failed,
				parked,
				pending_after: pendingAfter,
				transitions,
				commits,
				dry_run: dryRun || undefined,
			})
		}

		default:
			return err(`Unknown effects subcommand: ${subcommand}`)
	}
}
