/**
 * commands/plan-approval.mjs — C21 plan-approval artifact CLI.
 *
 * Subcommands:
 *   record       — write {task_dir}/plan_approval.json with the human decision.
 *   read         — emit the existing plan_approval.json (or status:'missing').
 *   verify-hash  — recompute sha256 of spec.md + implementation_plan.json and
 *                  compare against the stored hashes; report drift.
 *
 * Schema (v1):
 *   {
 *     "$schema": "plan_approval.v1",
 *     "task_id": string,
 *     "decision": "approved" | "rejected" | "revise",
 *     "actor":     string,            // user id, or "human" as default
 *     "timestamp": string,            // ISO8601
 *     "plan_hash": string,            // sha256(implementation_plan.json bytes)
 *     "spec_hash": string,            // sha256(spec.md bytes)
 *     "note":      string | null,     // optional reason / feedback
 *     "iteration": number             // 1-based; auto-increments on re-record
 *   }
 *
 * Atomic write — we route through util/io.mjs::atomicWriteJson so a crash
 * mid-write cannot leave a partial file (R3 / AC-R4c).
 */

import { createHash } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'

import { createCoordinationStore } from '../coordination/store.mjs'
import { getTaskDir } from '../task/ids.mjs'
import { parseFlags } from '../util/args.mjs'
import { atomicWriteJson } from '../util/io.mjs'
import { err, exitWith, ok, UsageError } from '../util/result.mjs'

// Defense-in-depth: reject task-id strings that could escape the tasks dir
// when joined into a filesystem path. The legitimate generator
// (generateTaskId in task/ids.mjs) only emits `[a-z0-9-_]` segments.
const TASK_ID_PATTERN = /^[A-Za-z0-9._-]+$/

const VALID_DECISIONS = new Set(['approved', 'rejected', 'revise'])

/**
 * Sentinel error class for helper-level usage failures inside cmdPlanApproval.
 */
class PlanApprovalUsageError extends UsageError {
	constructor(message) {
		super(message)
		this.name = 'PlanApprovalUsageError'
	}
}

export function cmdPlanApproval(subcommand, projectDir, extraArgs) {
	if (!subcommand) {
		return err('Usage: apt-tools plan-approval <record|read|verify-hash> <project-dir> [flags]')
	}
	if (!projectDir) {
		return err(`Usage: apt-tools plan-approval ${subcommand} <project-dir> [flags]`)
	}
	const targetDir = resolve(projectDir)
	const flags = parseFlags(extraArgs ?? [])
	const taskId = flags.get('task')
	if (!taskId || typeof taskId !== 'string') {
		return err('Required: --task <task-id>')
	}
	// S3 — reject task-ids that contain path-traversal characters or separators
	// before they reach `join()`. The generator only emits `[a-z0-9-_.]`.
	if (!TASK_ID_PATTERN.test(taskId)) {
		return err(`Invalid --task value: "${taskId}" (must match /^[A-Za-z0-9._-]+$/)`)
	}

	// L5 — resolve to the SHARED root (main repo's `.aperant/`) so that callers
	// running inside a per-task worktree write/read the same plan_approval.json
	// that `consistency check` reads. Without this, a worktree-resident
	// `plan-approval record` would land under `worktree/.aperant/tasks/{id}/`
	// while consistency reads from `mainRepo/.aperant/tasks/{id}/`.
	const sharedRoot = createCoordinationStore(targetDir).resolveSharedRoot()
	const taskDir = getTaskDir(sharedRoot, taskId)
	if (!existsSync(taskDir)) {
		return err(`task not found: ${taskId}`)
	}

	try {
		switch (subcommand) {
			case 'record':
				return doRecord(taskDir, taskId, flags)
			case 'read':
				return doRead(taskDir, taskId)
			case 'verify-hash':
				return doVerifyHash(taskDir, taskId)
			default:
				return err(
					`Unknown plan-approval subcommand: ${subcommand}. Expected: record, read, verify-hash`,
				)
		}
	} catch (e) {
		if (e instanceof PlanApprovalUsageError) return err(e.message)
		throw e
	}
}

function doRecord(taskDir, taskId, flags) {
	const decision = flags.get('decision')
	if (!decision || typeof decision !== 'string' || !VALID_DECISIONS.has(decision)) {
		throw new PlanApprovalUsageError(
			`Required: --decision <${Array.from(VALID_DECISIONS).join('|')}> (got: ${
				decision ?? 'unset'
			})`,
		)
	}
	const actorRaw = flags.get('actor')
	const actor = typeof actorRaw === 'string' && actorRaw.trim() ? actorRaw.trim() : 'human'
	const noteRaw = flags.get('note')
	const note = typeof noteRaw === 'string' && noteRaw.length > 0 ? noteRaw : null

	const planPath = join(taskDir, 'implementation_plan.json')
	const specPath = join(taskDir, 'spec.md')
	const planHash = sha256File(planPath)
	const specHash = sha256File(specPath)

	const approvalPath = join(taskDir, 'plan_approval.json')
	let iteration = 1
	if (existsSync(approvalPath)) {
		try {
			const prev = JSON.parse(readFileSync(approvalPath, 'utf-8'))
			if (typeof prev?.iteration === 'number' && prev.iteration > 0) {
				iteration = prev.iteration + 1
			}
		} catch {
			// Corrupt previous file — treat as iteration 1.
		}
	}

	const record = {
		$schema: 'plan_approval.v1',
		task_id: taskId,
		decision,
		actor,
		timestamp: new Date().toISOString(),
		plan_hash: planHash,
		spec_hash: specHash,
		note,
		iteration,
	}

	atomicWriteJson(approvalPath, record)

	return ok({
		status: 'ok',
		command: 'plan-approval-record',
		path: approvalPath,
		task_id: taskId,
		decision,
		actor,
		iteration,
		plan_hash: planHash,
		spec_hash: specHash,
	})
}

function doRead(taskDir, taskId) {
	const approvalPath = join(taskDir, 'plan_approval.json')
	if (!existsSync(approvalPath)) {
		return ok({
			status: 'missing',
			command: 'plan-approval-read',
			task_id: taskId,
			path: approvalPath,
		})
	}
	let record = null
	try {
		record = JSON.parse(readFileSync(approvalPath, 'utf-8'))
	} catch (e) {
		throw new PlanApprovalUsageError(`Failed to parse plan_approval.json: ${e.message}`)
	}
	return ok({
		status: 'ok',
		command: 'plan-approval-read',
		task_id: taskId,
		path: approvalPath,
		record,
	})
}

function doVerifyHash(taskDir, taskId) {
	const approvalPath = join(taskDir, 'plan_approval.json')
	if (!existsSync(approvalPath)) {
		return ok({
			status: 'missing',
			command: 'plan-approval-verify-hash',
			task_id: taskId,
			path: approvalPath,
		})
	}
	let record
	try {
		record = JSON.parse(readFileSync(approvalPath, 'utf-8'))
	} catch (e) {
		throw new PlanApprovalUsageError(`Failed to parse plan_approval.json: ${e.message}`)
	}
	const planPath = join(taskDir, 'implementation_plan.json')
	const specPath = join(taskDir, 'spec.md')
	const currentPlanHash = sha256File(planPath)
	const currentSpecHash = sha256File(specPath)
	const planMatch = currentPlanHash === record?.plan_hash
	const specMatch = currentSpecHash === record?.spec_hash
	const drift = []
	if (!planMatch) {
		drift.push({
			file: 'implementation_plan.json',
			stored: record?.plan_hash ?? null,
			current: currentPlanHash,
		})
	}
	if (!specMatch) {
		drift.push({
			file: 'spec.md',
			stored: record?.spec_hash ?? null,
			current: currentSpecHash,
		})
	}
	const envelope = {
		status: drift.length === 0 ? 'ok' : 'drift',
		command: 'plan-approval-verify-hash',
		task_id: taskId,
		path: approvalPath,
		plan_match: planMatch,
		spec_match: specMatch,
		drift,
	}
	return drift.length === 0 ? ok(envelope) : exitWith(envelope, 1)
}

/**
 * sha256 of a file's raw bytes, hex-encoded. Returns null when the file is
 * missing — callers decide whether that is a fatal condition.
 */
function sha256File(path) {
	if (!existsSync(path)) return null
	const buf = readFileSync(path)
	return createHash('sha256').update(buf).digest('hex')
}
