/**
 * team.mjs — team-mode enablement + team.json + sync helpers (C29).
 */

import { execFileSync, execSync, spawnSync } from 'node:child_process'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { getGitIdentity } from '../git/identity.mjs'
import { findOutputsViolations } from '../task/outputs-guard.mjs'
import { withFileLock } from '../util/fs-lock.mjs'
import { atomicWriteJson } from '../util/io.mjs'

/**
 * Check if team mode is enabled for this project.
 */
export function isTeamEnabled(targetDir) {
	const teamPath = join(targetDir, '.aperant', 'team.json')
	if (!existsSync(teamPath)) return false
	try {
		const team = JSON.parse(readFileSync(teamPath, 'utf-8'))
		return team.enabled === true
	} catch {
		return false
	}
}

/**
 * Auto-register the current user as a team member (called on every task op).
 * Safe no-op if team mode is not enabled.
 *
 * Lock-protected: concurrent wave-mode agents may call this simultaneously;
 * without the lock the read-modify-write clobbers sibling members. Same
 * pattern as state.json writers (withFileLock + atomicWriteJson).
 */
export function updateTeamMember(targetDir) {
	if (!isTeamEnabled(targetDir)) return
	const identity = getGitIdentity(targetDir)
	if (!identity) return

	const teamPath = join(targetDir, '.aperant', 'team.json')

	try {
		withFileLock(teamPath, () => {
			let team
			try {
				team = JSON.parse(readFileSync(teamPath, 'utf-8'))
			} catch {
				return
			}
			if (!team.members) team.members = {}

			const existing = team.members[identity.email]
			if (existing) {
				if (existing.name === identity.name) return
				existing.name = identity.name
			} else {
				team.members[identity.email] = {
					name: identity.name,
					email: identity.email,
					joined_at: new Date().toISOString(),
				}
			}

			atomicWriteJson(teamPath, team)
		})
	} catch {
		// Lock acquisition failed — silent skip.
	}
}

/**
 * Allowlist of `.aperant/` paths that `team sync` is permitted to stage.
 * Any path outside this set — including source code — is NEVER staged by sync,
 * even if dirty. The `.aperant/roadmap/` glob is expanded at runtime from the
 * actual tracked file list (picks up per-scope milestones.json + ROADMAP.md).
 */
const TEAM_SYNC_ALLOWLIST = ['.aperant/tasks/INDEX.md', '.aperant/team.json']

/**
 * Compute the set of files that `team sync` should stage. Returns a sorted
 * array of repo-relative paths (forward-slash). Scans `git status --porcelain`
 * for any modified/untracked files under `.aperant/` and intersects with the
 * allowlist (plus any tracked roadmap markdown/json).
 *
 * Pure function over git output so it's easy to reason about in tests.
 */
export function computeTeamSyncFiles(targetDir, opts = {}) {
	const gitCwd = opts.cwd ?? targetDir
	let porcelain = ''
	try {
		porcelain = execSync('git status --porcelain -- .aperant 2>/dev/null', {
			cwd: gitCwd,
			encoding: 'utf-8',
		})
	} catch {
		return []
	}

	// Parse porcelain lines. Format: "XY path" where XY is 2-char status.
	// Rename lines look like "R  old -> new"; we only care about the new path.
	const dirty = new Set()
	for (const rawLine of porcelain.split('\n')) {
		if (!rawLine) continue
		const line = rawLine.slice(3) // strip "XY "
		const arrowIdx = line.indexOf(' -> ')
		const path = arrowIdx >= 0 ? line.slice(arrowIdx + 4) : line
		const clean = path.replace(/^"(.*)"$/, '$1').trim()
		if (clean.startsWith('.aperant/')) dirty.add(clean)
	}

	// Expand allowlist: static list plus any tracked roadmap .md / .json files.
	const allowed = new Set(TEAM_SYNC_ALLOWLIST)
	try {
		const tracked = execSync('git ls-files -- .aperant/roadmap 2>/dev/null', {
			cwd: gitCwd,
			encoding: 'utf-8',
		})
		for (const line of tracked.split('\n')) {
			const f = line.trim()
			if (!f) continue
			if (f.endsWith('.md') || f.endsWith('.json')) allowed.add(f)
		}
	} catch {
		/* not in git, or no roadmap */
	}

	const staged = []
	for (const f of dirty) {
		if (allowed.has(f)) staged.push(f)
	}
	staged.sort()
	return staged
}

/**
 * Filter the staging list through `git check-ignore --stdin` so files matched
 * by the project's `.gitignore` (e.g. a repo that ignores `.aperant/roadmap/`)
 * are silently skipped instead of crashing the downstream `git add`. Returns
 * `{ kept, skipped }` — both arrays of repo-relative paths, preserving order.
 *
 * Conservative fallback: if `git check-ignore` exits with a status other than
 * 0 (ignored matches) or 1 (no ignored matches) — e.g. git not in PATH, repo
 * corruption, or the binary unavailable — we return every file as kept so the
 * existing `git add` path can run and surface its own error if needed.
 */
export function filterGitignored(files, gitCwd) {
	if (!Array.isArray(files) || files.length === 0) {
		return { kept: [], skipped: [] }
	}
	let proc
	try {
		// `--no-index` is the key: without it, check-ignore returns exit 1 for
		// paths that are tracked by the index even when they match a gitignore
		// pattern (because `git` respects "already tracked" as override). But
		// `git add` on the same tracked+ignored path STILL fails with "paths
		// are ignored", so we need the gitignore rule check to win here. This
		// is the PR #63 regression's actual shape: `.aperant/roadmap/foo.json`
		// was tracked before `.aperant/roadmap/` was added to .gitignore.
		proc = spawnSync('git', ['-C', gitCwd, 'check-ignore', '--no-index', '--stdin'], {
			input: files.join('\n'),
			encoding: 'utf-8',
		})
	} catch {
		return { kept: [...files], skipped: [] }
	}
	// check-ignore: 0 = some ignored, 1 = none ignored, anything else = error.
	if (!proc || (proc.status !== 0 && proc.status !== 1)) {
		return { kept: [...files], skipped: [] }
	}
	const ignored = new Set(
		(proc.stdout || '')
			.split('\n')
			.map((s) => s.trim())
			.filter(Boolean),
	)
	const kept = []
	const skipped = []
	for (const f of files) {
		if (ignored.has(f)) skipped.push(f)
		else kept.push(f)
	}
	return { kept, skipped }
}

/**
 * team sync implementation — shared entry used by cmdTeam and the task-close
 * hook. Returns the result object (does not emit output).
 *
 * options: { push?: boolean, message?: string, cwd?: string }
 *
 * `options.cwd` controls where git runs — defaults to `targetDir`. Lifecycle
 * handlers running against a worktree task pass the worktree path so the
 * bookkeeping commit lands on the worktree's feature branch (R7 — fixes the
 * C29 leak that landed bookkeeping on the main repo's branch).
 */
export function teamSync(targetDir, options = {}) {
	const gitCwd = options.cwd ?? targetDir
	if (!isTeamEnabled(targetDir)) {
		return {
			status: 'skipped',
			reason: 'team mode disabled',
			committed: false,
			files: [],
			pushed: false,
			skipped_gitignored: 0,
		}
	}

	const candidateFiles = computeTeamSyncFiles(targetDir, { cwd: gitCwd })
	if (candidateFiles.length === 0) {
		return {
			status: 'ok',
			committed: false,
			files: [],
			pushed: false,
			skipped_gitignored: 0,
			reason: 'team bookkeeping clean',
		}
	}

	// Filter out anything the project-level .gitignore rejects. `git add` of
	// an ignored path crashes with "paths are ignored by one of your
	// .gitignore files" (seen on PR #63 where this repo ignores
	// `.aperant/roadmap/`). Skipping silently is the correct behavior —
	// ignored files by definition should never be staged.
	const { kept: files, skipped: skippedIgnored } = filterGitignored(candidateFiles, gitCwd)
	const skippedCount = skippedIgnored.length

	if (files.length === 0) {
		return {
			status: 'ok',
			committed: false,
			files: [],
			pushed: false,
			skipped_gitignored: skippedCount,
			reason: skippedCount > 0 ? 'all candidate files are gitignored' : 'team bookkeeping clean',
		}
	}

	// R11 outputs enforcement: if the caller declared a task output allowlist,
	// the final file list must be contained in it. A mismatch means the
	// task's lifecycle commits tried to sync a path outside the contract.
	if (Array.isArray(options.outputs)) {
		const violations = findOutputsViolations(options.outputs, files)
		if (violations.length > 0) {
			return {
				status: 'error',
				committed: false,
				files,
				pushed: false,
				skipped_gitignored: skippedCount,
				error: `outputs-violation: team sync refused ${violations.length} path(s) outside task outputs: ${violations.slice(0, 5).join(', ')}`,
				violations,
				outputs: options.outputs,
			}
		}
	}

	// Stage — pathspec-limited so we can never touch anything outside the allowlist.
	try {
		execFileSync('git', ['add', '--', ...files], { cwd: gitCwd, stdio: 'pipe' })
	} catch (err) {
		return {
			status: 'error',
			committed: false,
			files,
			pushed: false,
			skipped_gitignored: skippedCount,
			error: `git add failed: ${err.message}`,
		}
	}

	// Verify nothing outside the allowlist is staged. `git diff --cached --name-only`
	// limited to the files we asked for should match exactly.
	let stagedNames = ''
	try {
		stagedNames = execSync('git diff --cached --name-only -- .aperant', {
			cwd: gitCwd,
			encoding: 'utf-8',
		})
	} catch {
		/* ignore */
	}
	const actuallyStaged = stagedNames
		.split('\n')
		.map((s) => s.trim())
		.filter(Boolean)
	const outsideAllowlist = actuallyStaged.filter((f) => !files.includes(f))
	if (outsideAllowlist.length > 0) {
		// Defensive: unstage the stray files so we never commit them.
		try {
			execFileSync('git', ['reset', 'HEAD', '--', ...outsideAllowlist], {
				cwd: gitCwd,
				stdio: 'pipe',
			})
		} catch {
			/* ignore */
		}
	}

	// If after the guard there's nothing staged (e.g. all files had identical
	// content after normalization), bail out cleanly.
	if (actuallyStaged.filter((f) => files.includes(f)).length === 0) {
		return {
			status: 'ok',
			committed: false,
			files: [],
			pushed: false,
			skipped_gitignored: skippedCount,
			reason: 'nothing staged after filter',
		}
	}

	const message = options.message || 'chore(aperant): sync team bookkeeping'
	const fullMessage = `${message}\n\nCo-Authored-By: Aperant Framework <noreply@aperant.dev>\n`

	try {
		execFileSync('git', ['commit', '-m', fullMessage, '--', ...files], {
			cwd: gitCwd,
			stdio: 'pipe',
		})
	} catch (err) {
		return {
			status: 'error',
			committed: false,
			files,
			pushed: false,
			skipped_gitignored: skippedCount,
			error: `git commit failed: ${err.message}`,
		}
	}

	let sha = null
	try {
		sha = execSync('git rev-parse HEAD', { cwd: gitCwd, encoding: 'utf-8' }).trim()
	} catch {
		/* ignore */
	}

	let pushed = false
	let pushError = null
	if (options.push) {
		try {
			execFileSync('git', ['push'], { cwd: gitCwd, stdio: 'pipe' })
			pushed = true
		} catch (err) {
			pushError = `git push failed: ${err.message}`
		}
	}

	const result = {
		status: 'ok',
		committed: true,
		sha,
		files,
		pushed,
		skipped_gitignored: skippedCount,
	}
	if (pushError) result.push_error = pushError
	return result
}
