/**
 * @internal
 * build-progress-sync.mjs — atomic plan ↔ build-progress sync (C33).
 *
 * Closes the gap from the 19-04-26 pipeline-harness task where
 * `build-progress.json.completed_subtasks` was updated but
 * `implementation_plan.json.subtasks[].status` was not, so the hard-gate
 * `all-subtasks-complete` check had to be force-bypassed to ship.
 *
 * Public surface:
 *   - `parseConsolidatedId(id)`: expands an id like "E1+E2+F3" into
 *     ["E1","E2","F3"]. Validates each fragment against id patterns seen
 *     across the framework + throws on empty/malformed fragments.
 *   - `completeSubtask({projectDir, taskId, subtaskId, commit, notes})`:
 *     atomically appends to build-progress.json AND flips plan subtask
 *     status(es) to "completed". Idempotent — re-running for an
 *     already-complete subtask returns `{status:'ok', already_complete:true}`.
 *
 * Atomicity: the write holds a file lock on the build-progress.json path
 * (matches the existing task.mjs `withFileLock(statePath)` pattern) and uses
 * `atomicWriteJson` for tmp-file + fsync + rename on both JSON files. The
 * plan file is NOT locked separately — locking the same path across both
 * writes would require a second lock (with deadlock risk); instead, we write
 * plan → progress last so an interruption leaves progress file unchanged and
 * the operation is safe to retry.
 */

import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { withFileLock } from '../util/fs-lock.mjs'
import { atomicWriteJson } from '../util/io.mjs'

// Accept the id patterns we've seen in real plans:
//   - `A1`, `E12`, `H1`    (letter prefix + digits)
//   - `s1`, `s-1`, `sub-1`, `sub-1-2`  (legacy / generator-specific)
//   - `1`, `12`            (bare digits)
const ID_FRAGMENT_REGEX = /^(?:[A-Za-z]+\d+|\d+|s-?\d+|sub-?\d+(?:-\d+)*)$/

/**
 * Split a subtask id on `+` into its constituent ids. Deduplicates, preserves
 * order. Throws on empty-string input, empty fragments, or fragments that
 * don't match a known id shape.
 */
export function parseConsolidatedId(id) {
	if (typeof id !== 'string') {
		throw new Error(`parseConsolidatedId: expected string, got ${typeof id}`)
	}
	const trimmed = id.trim()
	if (trimmed === '') {
		throw new Error('parseConsolidatedId: empty id')
	}
	const parts = trimmed.split('+').map((s) => s.trim())
	const seen = new Set()
	const out = []
	for (const part of parts) {
		if (part === '') {
			throw new Error(`parseConsolidatedId: empty fragment in "${id}"`)
		}
		if (!ID_FRAGMENT_REGEX.test(part)) {
			throw new Error(`parseConsolidatedId: invalid fragment "${part}" in "${id}"`)
		}
		if (seen.has(part)) continue
		seen.add(part)
		out.push(part)
	}
	return out
}

/**
 * Atomic: append to build-progress.json + flip plan subtasks to completed.
 *
 * @param {object} args
 * @param {string} args.projectDir
 * @param {string} args.taskId
 * @param {string} args.subtaskId          may be "E1+E2" consolidated
 * @param {string} [args.commit]           commit sha
 * @param {string} [args.notes]
 * @param {string} [args.completedAt]      test hook; overrides new Date().toISOString()
 *
 * @returns {{
 *   status: 'ok',
 *   task_id: string,
 *   subtask_id: string,
 *   constituent_ids: string[],
 *   plan_updates: number,
 *   progress_updates: number,
 *   already_complete?: boolean
 * }}
 */
export function completeSubtask({ projectDir, taskId, subtaskId, commit, notes, completedAt }) {
	if (!projectDir) throw new Error('completeSubtask: projectDir required')
	if (!taskId) throw new Error('completeSubtask: taskId required')
	if (!subtaskId) throw new Error('completeSubtask: subtaskId required')

	const taskDir = join(projectDir, '.aperant', 'tasks', taskId)
	const planPath = join(taskDir, 'implementation_plan.json')
	const progressPath = join(taskDir, 'build-progress.json')

	if (!existsSync(planPath)) {
		throw new Error(`completeSubtask: plan not found at ${planPath}`)
	}

	const constituents = parseConsolidatedId(subtaskId)
	const isConsolidated = constituents.length > 1 || subtaskId.includes('+')

	return withFileLock(
		progressPath,
		() => {
			const plan = JSON.parse(readFileSync(planPath, 'utf-8'))
			const progress = existsSync(progressPath)
				? JSON.parse(readFileSync(progressPath, 'utf-8'))
				: {
						task_id: taskId,
						completed_subtasks: [],
						pending_subtasks: [],
					}
			if (!Array.isArray(progress.completed_subtasks)) progress.completed_subtasks = []

			// Idempotency: if the subtask id is already in completed_subtasks,
			// don't double-append. Accept either the literal consolidated id OR
			// the case where all constituents already appear as individual ids.
			const alreadyLiteral = progress.completed_subtasks.some((e) => e && e.id === subtaskId)
			const alreadyAllConstituents =
				constituents.length > 0 &&
				constituents.every((cid) =>
					progress.completed_subtasks.some((e) => {
						if (!e) return false
						if (e.id === cid) return true
						const cids = Array.isArray(e.constituent_ids) ? e.constituent_ids : []
						return cids.includes(cid)
					}),
				)
			if (alreadyLiteral || alreadyAllConstituents) {
				return {
					status: 'ok',
					task_id: taskId,
					subtask_id: subtaskId,
					constituent_ids: constituents,
					plan_updates: 0,
					progress_updates: 0,
					already_complete: true,
				}
			}

			// Flip matching plan subtasks to completed. Unknown ids fail loud —
			// don't silently skip; a mistyped id should surface immediately.
			if (!Array.isArray(plan.subtasks)) {
				throw new Error(`completeSubtask: plan.subtasks is not an array in ${planPath}`)
			}
			const planById = new Map(plan.subtasks.map((st) => [st.id, st]))
			const unknown = constituents.filter((cid) => !planById.has(cid))
			if (unknown.length > 0) {
				throw new Error(
					`completeSubtask: constituent id(s) not in plan: ${unknown.join(', ')} (plan=${planPath})`,
				)
			}
			let planUpdates = 0
			for (const cid of constituents) {
				const st = planById.get(cid)
				if (st.status !== 'completed') {
					st.status = 'completed'
					planUpdates += 1
				}
			}

			// Append the build-progress entry.
			const at = completedAt || new Date().toISOString()
			const entry = {
				id: subtaskId,
				completed_at: at,
			}
			if (commit) entry.commit = commit
			if (notes) entry.notes = notes
			if (isConsolidated) entry.constituent_ids = constituents
			progress.completed_subtasks.push(entry)
			progress.task_id = progress.task_id || taskId
			progress.last_updated = at

			// Maintain pending_subtasks if the caller tracks it.
			if (Array.isArray(progress.pending_subtasks)) {
				const pending = new Set(progress.pending_subtasks)
				for (const cid of constituents) pending.delete(cid)
				// Consolidated literal too (so the progress list stays clean).
				pending.delete(subtaskId)
				progress.pending_subtasks = Array.from(pending)
			}

			// Write plan first, then progress. An interruption between the two
			// leaves the plan ahead of progress; the next call is safe because
			// the idempotency check catches the constituents already flipped.
			atomicWriteJson(planPath, plan)
			atomicWriteJson(progressPath, progress)

			return {
				status: 'ok',
				task_id: taskId,
				subtask_id: subtaskId,
				constituent_ids: constituents,
				plan_updates: planUpdates,
				progress_updates: 1,
			}
		},
		{ projectRoot: projectDir },
	)
}
