/**
 * @internal
 * self-stage.mjs — self-staging wrapper for committable .aperant/ writes (C56 B4).
 *
 * Intended consumers: state-mutating CLI verbs in roadmap.mjs, task.mjs,
 * pr-review.mjs, features-audit.mjs. Call this helper just before returning
 * the success envelope so the caller's next commit picks up the writes.
 *
 * Contract:
 *   - Intersect changed paths with classification.filterCommittable.
 *   - Enforce the task's outputs allowlist (R11). Paths outside the allowlist
 *     are dropped with reason='outputs-violation'.
 *   - Dedupe against `git diff --cached --name-only` so we never re-stage.
 *   - Shell `git add -f <paths>` scoped to projectDir (not worktree — the
 *     writes land under `.aperant/` which always lives in the main repo).
 *   - Best-effort: any git failure is caught and surfaced as a warning. The
 *     helper NEVER throws; callers keep working.
 *
 * Why `-f`? Some `.aperant/*` paths are covered by a `.gitignore` rule today
 * (e.g. `.aperant/tasks/**` is gitignored by design). When a subset of those
 * paths is committable (e.g. a round snapshot under pr-reviews), `-f` is
 * required or `git add` refuses silently.
 */

import { execFileSync } from 'node:child_process'
import { isAbsolute, relative, resolve } from 'node:path'

import { findOutputsViolations } from '../task/outputs-guard.mjs'
import { ARTIFACT_CLASS, classifyArtifactPath } from './classification.mjs'

/**
 * Self-stage the committable subset of `changedPaths`.
 *
 * @param {string} projectDir absolute path to repo root where `.aperant/` lives.
 * @param {string[]} changedPaths repo-relative paths that were just written
 *   by the caller. Absolute paths are tolerated — they're relativized here.
 * @param {{ outputs?: readonly string[] | null }} [opts]
 * @returns {{ staged: string[], skipped: Array<{ path: string, reason: string }>, warnings: string[] }}
 * @internal
 */
export function selfStageCommittableWrites(projectDir, changedPaths, opts = {}) {
	const warnings = []
	const skipped = []
	const staged = []

	if (!projectDir || typeof projectDir !== 'string') {
		return { staged, skipped, warnings: ['self-stage: missing projectDir'] }
	}
	if (!Array.isArray(changedPaths) || changedPaths.length === 0) {
		return { staged, skipped, warnings }
	}

	const absRoot = resolve(projectDir)
	const rels = []
	for (const raw of changedPaths) {
		if (typeof raw !== 'string' || raw.length === 0) continue
		const rel = toRepoRelative(raw, absRoot)
		if (!rel) {
			skipped.push({ path: raw, reason: 'outside-project' })
			continue
		}
		rels.push(rel)
	}

	// Step 1: committable filter via classification.
	const committable = []
	for (const p of rels) {
		const cls = classifyArtifactPath(p)
		if (cls === ARTIFACT_CLASS.COMMITTABLE) committable.push(p)
		else if (cls === ARTIFACT_CLASS.EPHEMERAL) skipped.push({ path: p, reason: 'ephemeral' })
		else skipped.push({ path: p, reason: 'unclassified' })
	}

	if (committable.length === 0) {
		return { staged, skipped, warnings }
	}

	// Step 2: enforce outputs allowlist (R11). Missing/empty allowlist means
	// "no restriction" here (the roadmap sync helper treats it differently —
	// `['**']` as explicit unlimited; empty means reject. Self-stage is
	// called post-mutation by callers that may not know the task allowlist,
	// so we take a gentler stance: when `outputs` is falsy, pass through).
	let afterOutputs = committable
	if (Array.isArray(opts.outputs) && opts.outputs.length > 0) {
		const violations = new Set(findOutputsViolations(opts.outputs, committable))
		afterOutputs = []
		for (const p of committable) {
			if (violations.has(p)) skipped.push({ path: p, reason: 'outputs-violation' })
			else afterOutputs.push(p)
		}
	}

	if (afterOutputs.length === 0) {
		return { staged, skipped, warnings }
	}

	// Step 3: dedupe against already-staged index snapshot.
	let alreadyStaged = new Set()
	try {
		const out = execFileSync('git', ['diff', '--cached', '--name-only', '-z'], {
			cwd: absRoot,
			encoding: 'utf-8',
			stdio: ['ignore', 'pipe', 'pipe'],
		})
		alreadyStaged = new Set(out.split('\0').filter((s) => s.length > 0))
	} catch (err) {
		warnings.push(`self-stage: git diff --cached failed (${stringifyErr(err)})`)
	}

	const toStage = []
	for (const p of afterOutputs) {
		if (alreadyStaged.has(p)) skipped.push({ path: p, reason: 'already-staged' })
		else toStage.push(p)
	}

	if (toStage.length === 0) {
		return { staged, skipped, warnings }
	}

	// Step 4: `git add -f <paths>`. Scoped to projectDir. Batch-adds in one call.
	try {
		execFileSync('git', ['add', '-f', '--', ...toStage], {
			cwd: absRoot,
			stdio: ['ignore', 'pipe', 'pipe'],
		})
		for (const p of toStage) staged.push(p)
	} catch (err) {
		warnings.push(
			`self-stage: git add -f failed for ${toStage.length} path(s): ${stringifyErr(err)}`,
		)
		for (const p of toStage) skipped.push({ path: p, reason: 'git-add-failed' })
	}

	return { staged, skipped, warnings }
}

/**
 * Relativize a path against the project root. Returns `null` when the path
 * lives outside the project (we refuse to stage those).
 *
 * @param {string} raw
 * @param {string} absRoot
 * @returns {string | null}
 * @internal
 */
function toRepoRelative(raw, absRoot) {
	let rel = raw
	if (isAbsolute(raw)) {
		const r = relative(absRoot, raw)
		if (r.startsWith('..')) return null
		rel = r
	}
	// Normalize `./` and backslashes.
	rel = rel.replace(/\\/g, '/').replace(/^\.\//, '')
	if (rel.startsWith('../')) return null
	if (rel.length === 0) return null
	return rel
}

/**
 * @param {unknown} err
 * @returns {string}
 * @internal
 */
function stringifyErr(err) {
	if (err && typeof err === 'object' && 'message' in err) return String(err.message)
	return String(err)
}
