/**
 * lifecycle.mjs — shared lifecycle-phase side-effect helper (C29).
 *
 * FRAMEWORK-BUG-033 defect 1/5: when `spec.to === 'completed'`, the helper
 * enumerates sibling tasks in the same scope+milestone+phase and aborts
 * the flip (returns null — idempotent no-op shape) when any sibling
 * remains open (lifecycle_phase !== 'closed'). The guard lives inside the
 * helper so every caller (`task update --lifecycle-phase`, `task close
 * --verdict approved`, the drain wrapper, and `task close-merged`
 * transitively via its `task close` child) inherits it.
 */

import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { loadRoadmapForScope, saveRoadmapForScope } from './io.mjs'
import { applyRollup } from './rollup.mjs'
import { syncRoadmapInProcess } from './sync.mjs'

/**
 * Shared lifecycle phase side-effect helper.
 *
 * Applies a phase-status transition to a task's linked roadmap phase, mirroring
 * what the effects drain used to do — from-state guarded, idempotent, commits
 * via `syncRoadmapInProcess`.
 *
 * Callers:
 *   - `task update --lifecycle-phase` (mapping lifecycle → phase spec inline)
 *   - `task close --verdict approved` ({from: 'in-progress', to: 'completed'})
 *   - The drain's `applyPhaseTransition` wrapper (legacy ops / in-flight queue)
 *
 * Contract:
 *   - No-op (returns null) when `task.milestone_id` / `task.phase_id` missing,
 *     when the roadmap / milestone / phase can't be located, or when the phase
 *     is not currently at `spec.from`.
 *   - On success, mutates `phase.status` (and `phase.completed_at` when
 *     transitioning to `completed`), writes via `saveRoadmapForScope`, and
 *     runs `syncRoadmapInProcess` with reason `${reasonPrefix}:${phase_id}`.
 *
 * Returns: `{ transition: {op, scope, milestone_id, phase_id, from, to}, commit: string|null } | null`
 *   - `commit` is the short sha from `syncRoadmapInProcess`, or null when
 *     the sync was skipped (team mode off / clean / etc).
 */
export function applyLifecyclePhaseSideEffect(targetDir, task, spec, reasonPrefix, opts = {}) {
	if (!task || !task.milestone_id || !task.phase_id) return null
	const scope = task.scope || 'project'
	// R7 — when a task carries `worktree_path`, the durable/reviewable roadmap
	// file lives in the worktree's working tree (so the commit can snapshot it
	// onto the worktree's feature branch). When no worktree is present, the
	// roadmap lives in the project root.
	//
	// Shared/ephemeral state (state.json, team-status/, locks, events) is
	// unaffected — it always resolves against `targetDir` via the surrounding
	// call site. This split mirrors spec §5's three-class model:
	//   durable/reviewable → worktree tree
	//   ephemeral/coordination → main repo `.aperant/`
	const roadmapDir = task.worktree_path || targetDir
	const roadmap = loadRoadmapForScope(roadmapDir, scope)
	if (!roadmap) return null
	const milestone = (roadmap.milestones || []).find((m) => m.id === task.milestone_id)
	if (!milestone) return null
	const phase = (milestone.phases || []).find((p) => p.id === task.phase_id)
	if (!phase) return null
	// From-state guard: idempotent no-op when phase is not at `spec.from`.
	if (phase.status !== spec.from) return null

	// FRAMEWORK-BUG-033 defect 1/5 — sibling-count guard. Only gates the
	// `→ completed` direction (other transitions like `→ in-progress` are
	// unaffected per ID-02 / AC7). Enumerates state.active_tasks filtered by
	// scope+milestone+phase. Any open sibling (lifecycle_phase !== 'closed'
	// and id !== task.id) blocks the flip with the same idempotent no-op
	// shape as the from-state guard above.
	//
	// State source: caller may pass `opts.stateBlob` when already holding
	// `withFileLock(state.json)` to avoid a re-read race; otherwise we read
	// state.json from disk once. On the `task close` path the task has
	// already been deleted from state.active_tasks before this helper runs,
	// so the `t.id !== task.id` filter is a defensive no-op there.
	if (spec.to === 'completed') {
		let stateBlob = opts.stateBlob
		if (!stateBlob) {
			const statePath = join(targetDir, '.aperant', 'state.json')
			if (existsSync(statePath)) {
				try {
					stateBlob = JSON.parse(readFileSync(statePath, 'utf-8'))
				} catch {
					// Fail-secure: cannot verify sibling state → assume siblings might exist → abort the flip.
					return null
				}
			}
		}
		const activeTasks = stateBlob?.active_tasks || {}
		for (const sibling of Object.values(activeTasks)) {
			if (!sibling || typeof sibling !== 'object') continue
			if (sibling.scope !== scope) continue
			if (sibling.milestone_id !== task.milestone_id) continue
			if (sibling.phase_id !== task.phase_id) continue
			if (sibling.id === task.id) continue
			if (sibling.lifecycle_phase === 'closed') continue
			// Open sibling — abort the flip.
			return null
		}
	}

	const transition = {
		op: reasonPrefix,
		scope,
		milestone_id: task.milestone_id,
		phase_id: task.phase_id,
		from: spec.from,
		to: spec.to,
	}

	if (opts.dryRun) {
		return { transition, commit: null }
	}

	phase.status = spec.to
	if (spec.to === 'completed') {
		phase.completed_at = new Date().toISOString()
	}
	// C33 — after the phase-status flip, recompute the milestone rollup in
	// memory so the saved roadmap reflects both the phase change AND any
	// derived milestone status flip (e.g. last phase → milestone completed).
	// Idempotent: applyRollup is a no-op when nothing changed.
	const rollup = applyRollup(roadmap, task.milestone_id)
	saveRoadmapForScope(roadmapDir, scope, roadmap)

	// R7 — git runs against the worktree so the commit lands on the feature
	// branch (fixes the C29 leak where bookkeeping landed on the main repo's
	// branch instead).
	//
	// R11 — forward the task's declared outputs allowlist so syncRoadmapInProcess
	// can refuse to stage any dirty path outside of it. `task.outputs` is the
	// source of truth; `opts.outputs` overrides for test/debugging callers.
	let commit = null
	const outputs = Array.isArray(opts.outputs)
		? opts.outputs
		: Array.isArray(task.outputs)
			? task.outputs
			: undefined
	// C33 — when rollup also mutated, tag the commit reason so logs show both
	// the phase change and the milestone rollup occurred in a single write.
	const reasonSuffix = rollup?.changed
		? `${reasonPrefix}:${task.phase_id}+rollup`
		: `${reasonPrefix}:${task.phase_id}`
	const sync = syncRoadmapInProcess(roadmapDir, reasonSuffix, {
		cwd: roadmapDir,
		outputs,
	})
	if (sync.committed && sync.sha) commit = sync.sha

	return { transition, commit, rollup }
}
