/**
 * commands/triage.mjs — `apt-tools triage`.
 *
 * 5-state triage state machine with extensible metadata for v2 OSS
 * contributor primitives. Backend-configured: writes to
 * `.aperant/tasks/{task-id}/triage.json` by default (local-only). The
 * `github-issues` backend mirrors triage state to a real GitHub issue via
 * the `gh` CLI (create on first mirror, update/close on transition); the
 * `app-inbox` backend remains a not-implemented stub (post-app-launch).
 *
 * Subcommands:
 *   apt-tools triage init <project-dir> --id <task-id> [--category bug|enhancement] [--extra '{...}']
 *   apt-tools triage transition <project-dir> --id <task-id> --to <state> [--reason <text>] [--by user|agent]
 *   apt-tools triage get <project-dir> --id <task-id>
 *
 * States: needs-triage | needs-info | ready-for-agent | ready-for-human | wontfix
 * Categories: bug | enhancement
 *
 * Fast Path Guarantee (ID-05): QUICK-routed tasks NEVER enter triage.
 * The CLI does NOT enforce this directly — the apt:triage SKILL.md
 * routes around QUICK tasks before invoking the CLI. The CLI is the
 * mechanical state-machine surface; the routing is the skill body's job.
 */

import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { parseFlags } from '../util/args.mjs'
import { err, exitWith, ok } from '../util/result.mjs'

const STATES = ['needs-triage', 'needs-info', 'ready-for-agent', 'ready-for-human', 'wontfix']

const CATEGORIES = ['bug', 'enhancement']

// The 8 valid transitions per spec.md AC9. Anything else is rejected.
const VALID_TRANSITIONS = new Set([
	'needs-triage->needs-info',
	'needs-triage->ready-for-agent',
	'needs-triage->ready-for-human',
	'needs-triage->wontfix',
	'needs-info->ready-for-agent',
	'needs-info->ready-for-human',
	'needs-info->wontfix',
	'needs-info->needs-triage',
])

const BACKENDS = new Set(['local-only', 'github-issues', 'app-inbox'])

// Two task-id shapes coexist:
//   - Legacy date-first: YY-MM-DD_slug-XXXX (preserved so the ~159
//     pre-slug-first tasks already on disk keep resolving).
//   - Current slug-first: slug_YY-MM-DD with optional -N counter
//     emitted by generateTaskId since the slug-first rename.
const TASK_ID_RE_LEGACY = /^[0-9]{2}-[0-9]{2}-[0-9]{2}_[a-z0-9-]+-[a-z0-9]{4}$/
const TASK_ID_RE_SLUG_FIRST = /^[a-z0-9][a-z0-9-]*_[0-9]{2}-[0-9]{2}-[0-9]{2}(-\d+)?$/

/**
 * Read .aperant/config.json and extract task_tracking.backend.
 * Defaults to 'local-only' if the file or the key is missing.
 *
 * @param {string} absProjectDir
 * @returns {string}
 */
function readBackend(absProjectDir) {
	const configPath = join(absProjectDir, '.aperant', 'config.json')
	if (!existsSync(configPath)) return 'local-only'
	try {
		const parsed = JSON.parse(readFileSync(configPath, 'utf-8'))
		const backend = parsed?.task_tracking?.backend
		if (typeof backend === 'string' && BACKENDS.has(backend)) return backend
		return 'local-only'
	} catch {
		return 'local-only'
	}
}

/**
 * Read task_tracking.tracker_label_vocabulary from .aperant/config.json.
 * Absent or "default" → use the `triage:<state>` prefix; any other
 * non-empty string → that string is used as the label prefix instead
 * (`<vocab>:<state>`). Per spec ID-03 / AC5. This is the ONLY label
 * configurability — no speculative label-map.
 *
 * @param {string} absProjectDir
 * @returns {string} the label prefix (default 'triage')
 */
function readLabelVocabulary(absProjectDir) {
	const configPath = join(absProjectDir, '.aperant', 'config.json')
	if (!existsSync(configPath)) return 'triage'
	try {
		const parsed = JSON.parse(readFileSync(configPath, 'utf-8'))
		const vocab = parsed?.task_tracking?.tracker_label_vocabulary
		if (typeof vocab === 'string' && vocab.length > 0 && vocab !== 'default') return vocab
		return 'triage'
	} catch {
		return 'triage'
	}
}

/**
 * Triage record file path.
 *
 * @param {string} absProjectDir
 * @param {string} taskId
 */
function triagePath(absProjectDir, taskId) {
	return join(absProjectDir, '.aperant', 'tasks', taskId, 'triage.json')
}

/**
 * Read the current triage record, or null if it doesn't exist.
 *
 * @param {string} path
 */
function readTriage(path) {
	if (!existsSync(path)) return null
	try {
		return JSON.parse(readFileSync(path, 'utf-8'))
	} catch {
		return null
	}
}

/**
 * Write a triage record.
 *
 * @param {string} path
 * @param {object} record
 */
function writeTriage(path, record) {
	const dir = dirname(path)
	if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
	writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`)
}

/**
 * Map a `gh` invocation's arg array to a stable subcommand key for the
 * test fixture (ID-07). The fixture maps these keys to {stdout, exitCode}.
 *
 * @param {string[]} args
 * @returns {string}
 */
function ghFixtureKey(args) {
	if (args[0] === '--version') return 'version'
	if (args[0] === 'auth' && args[1] === 'status') return 'auth-status'
	if (args[0] === 'repo' && args[1] === 'view') return 'repo-view'
	if (args[0] === 'issue' && args[1] === 'create') return 'issue-create'
	if (args[0] === 'issue' && args[1] === 'edit') return 'issue-edit'
	if (args[0] === 'issue' && args[1] === 'close') return 'issue-close'
	return args.slice(0, 2).join('-')
}

/**
 * The single injection seam for `gh` shell-outs (ID-07). When
 * process.env.APT_TRIAGE_GH_FIXTURE is set (a JSON map of subcommand key →
 * {stdout, exitCode}), it returns the scripted response instead of
 * shelling out — keeping unit tests hermetic (no network). Otherwise it
 * runs `execFileSync('gh', args, ...)` with an arg array (NEVER a shell
 * string — titles/bodies derive from task ids/reasons, ID-01).
 *
 * Returns a uniform `{ stdout, exitCode }`. A non-zero exit (or ENOENT) is
 * reported via the return shape, NOT thrown — the caller maps it to an
 * error envelope. The only thrown-then-caught path is ENOENT (gh missing),
 * surfaced as exitCode:127 with code:'ENOENT' on the result.
 *
 * @param {string[]} args
 * @param {string} cwd
 * @returns {{ stdout: string, stderr: string, exitCode: number, code?: string }}
 */
function runGh(args, cwd) {
	const fixtureRaw = process.env.APT_TRIAGE_GH_FIXTURE
	if (fixtureRaw) {
		let fixture
		try {
			fixture = JSON.parse(fixtureRaw)
		} catch {
			return { stdout: '', stderr: 'invalid APT_TRIAGE_GH_FIXTURE JSON', exitCode: 1 }
		}
		const key = ghFixtureKey(args)
		const scripted = fixture[key]
		if (!scripted) {
			// No scripted entry → treat as gh missing so tests must be explicit.
			return { stdout: '', stderr: `no fixture for ${key}`, exitCode: 127, code: 'ENOENT' }
		}
		return {
			stdout: typeof scripted.stdout === 'string' ? scripted.stdout : '',
			stderr: typeof scripted.stderr === 'string' ? scripted.stderr : '',
			exitCode: typeof scripted.exitCode === 'number' ? scripted.exitCode : 0,
			...(scripted.code ? { code: scripted.code } : {}),
		}
	}
	try {
		const stdout = execFileSync('gh', args, {
			cwd,
			encoding: 'utf-8',
			stdio: ['ignore', 'pipe', 'pipe'],
		})
		return { stdout: stdout ?? '', stderr: '', exitCode: 0 }
	} catch (e) {
		// execFileSync throws on non-zero exit AND on spawn failure (ENOENT).
		return {
			stdout: typeof e?.stdout === 'string' ? e.stdout : '',
			stderr: typeof e?.stderr === 'string' ? e.stderr : (e?.message ?? String(e)),
			exitCode: typeof e?.status === 'number' ? e.status : 1,
			...(e?.code ? { code: e.code } : {}),
		}
	}
}

/**
 * Truncate a gh stderr message to a plain ~200-char string for the error
 * envelope. NEVER returns an Error object / stacktrace (AC6).
 *
 * @param {string} stderr
 * @returns {string}
 */
function ghErrorMessage(stderr) {
	return String(stderr ?? '')
		.trim()
		.slice(0, 200)
}

/**
 * Build the structured `gh` error envelope (ID-04). The adapter NEVER
 * throws past its own boundary; every failure routes here.
 *
 * @param {string} reason
 * @param {string} message
 */
function ghError(reason, message) {
	return { status: 'error', backend: 'github-issues', reason, error: ghErrorMessage(message) }
}

/**
 * Preflight: gh present? authenticated? remote/repo resolvable? (ID-05).
 * Short-circuits to an error envelope on the first failure; on success
 * returns the resolved `owner/repo` for deterministic `--repo` passing.
 *
 * @param {string} cwd
 * @returns {{ ok: true, repo: string } | { ok: false, envelope: object }}
 */
function ghPreflight(cwd) {
	const version = runGh(['--version'], cwd)
	if (version.code === 'ENOENT' || version.exitCode === 127) {
		return { ok: false, envelope: ghError('gh-unavailable', 'gh CLI not found on PATH') }
	}
	if (version.exitCode !== 0) {
		return { ok: false, envelope: ghError('gh-unavailable', version.stderr) }
	}
	const auth = runGh(['auth', 'status'], cwd)
	if (auth.exitCode !== 0) {
		return { ok: false, envelope: ghError('gh-unauthenticated', auth.stderr) }
	}
	const repo = runGh(['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'], cwd)
	if (repo.exitCode !== 0) {
		return { ok: false, envelope: ghError('no-remote', repo.stderr) }
	}
	const nameWithOwner = repo.stdout.trim()
	if (!nameWithOwner) {
		return { ok: false, envelope: ghError('no-remote', 'gh repo view returned no nameWithOwner') }
	}
	return { ok: true, repo: nameWithOwner }
}

/**
 * Parse the issue number + url from `gh issue create` stdout. gh prints
 * the issue URL as the trailing line; the number is the path tail
 * (`.../issues/N`). Documented parse assumption (spec Further Notes).
 *
 * @param {string} stdout
 * @returns {{ issue_number: number, issue_url: string } | null}
 */
function parseCreatedIssue(stdout) {
	const url = String(stdout ?? '')
		.trim()
		.split('\n')
		.filter((l) => l.includes('/issues/'))
		.pop()
	if (!url) return null
	const m = url.match(/\/issues\/(\d+)\b/)
	if (!m) return null
	return { issue_number: Number(m[1]), issue_url: url.trim() }
}

/**
 * github-issues adapter (ID-01/02/03/04/05). Mirrors triage state to a
 * real GitHub issue via the `gh` CLI: first mirror CREATES an issue,
 * subsequent transitions UPDATE the SAME issue (label swap), and a
 * transition to `wontfix` CLOSES it as "not planned". Persists the issue
 * pointer onto `triage.json` (like local-only) so the pointer survives.
 *
 * Precedence (spec Further Notes): the local triage.json write is the
 * caller's responsibility for local-only; here, the github backend is the
 * sole persistence path, so this adapter writes triage.json itself with
 * the github block attached. Label ops are best-effort (AC8): a failed
 * label edit degrades to a warnings[] entry, the open/closed state change
 * still lands.
 *
 * @param {string} absProjectDir
 * @param {string} taskId
 * @param {object} record  the (init or post-transition) triage record; mutated to carry `record.github`
 * @returns {{ status: string, backend: 'github-issues', [k: string]: unknown }}
 */
function mirrorToGithubIssues(absProjectDir, taskId, record) {
	const cwd = absProjectDir
	const prefix = readLabelVocabulary(absProjectDir)
	const stateLabel = (state) => `${prefix}:${state}`

	const pre = ghPreflight(cwd)
	if (!pre.ok) return pre.envelope
	const repo = pre.repo

	const warnings = []
	const persist = () => writeTriage(triagePath(absProjectDir, taskId), record)

	// CREATE path: no issue yet on the record.
	if (!record.github || typeof record.github.issue_number !== 'number') {
		const labels = []
		if (typeof record.category === 'string' && record.category) labels.push(record.category)
		labels.push(stateLabel(record.state))
		const createArgs = [
			'issue',
			'create',
			'--title',
			`triage: ${taskId}`,
			'--body',
			`Triage state: ${record.state}${record.category ? ` (${record.category})` : ''}`,
			'--repo',
			repo,
		]
		for (const l of labels) createArgs.push('--label', l)
		let res = runGh(createArgs, cwd)
		if (res.exitCode !== 0) {
			// AC8: a label that doesn't exist in the repo fails the create.
			// Retry WITHOUT label flags so the issue still gets created.
			const noLabelArgs = createArgs.filter(
				(a, i) => a !== '--label' && createArgs[i - 1] !== '--label',
			)
			const retry = runGh(noLabelArgs, cwd)
			if (retry.exitCode !== 0) {
				return ghError('gh-command-failed', res.stderr || retry.stderr)
			}
			warnings.push(`label(s) not applied on create: ${ghErrorMessage(res.stderr)}`)
			res = retry
		}
		const parsed = parseCreatedIssue(res.stdout)
		if (!parsed) {
			return ghError('gh-command-failed', `could not parse issue URL from: ${res.stdout}`)
		}
		record.github = { issue_number: parsed.issue_number, issue_url: parsed.issue_url, repo }
		persist()
		return {
			status: 'ok',
			backend: 'github-issues',
			action: 'created',
			issue_number: parsed.issue_number,
			issue_url: parsed.issue_url,
			warnings,
		}
	}

	// UPDATE / CLOSE path: the record already points at an issue.
	const issueNumber = record.github.issue_number
	const issueRepo = typeof record.github.repo === 'string' ? record.github.repo : repo
	// The previous state is the `from` of the last transition (the one that
	// produced this record); on init there are no transitions.
	const txs = Array.isArray(record.transitions) ? record.transitions : []
	const lastTx = txs.length > 0 ? txs[txs.length - 1] : null
	const oldLabel = lastTx ? stateLabel(lastTx.from) : null
	const newLabel = stateLabel(record.state)

	const editArgs = ['issue', 'edit', String(issueNumber), '--repo', issueRepo]
	if (oldLabel) editArgs.push('--remove-label', oldLabel)
	editArgs.push('--add-label', newLabel)
	const edit = runGh(editArgs, cwd)
	if (edit.exitCode !== 0) {
		// AC8: label op failed (e.g. label not in repo). Degrade: skip the
		// label swap, still perform the open/closed state change, warn.
		warnings.push(`label swap skipped: ${ghErrorMessage(edit.stderr)}`)
	}

	if (record.state === 'wontfix') {
		const close = runGh(
			['issue', 'close', String(issueNumber), '--repo', issueRepo, '--reason', 'not planned'],
			cwd,
		)
		if (close.exitCode !== 0) {
			return ghError('gh-command-failed', close.stderr)
		}
		record.github = {
			issue_number: issueNumber,
			issue_url: record.github.issue_url,
			repo: issueRepo,
		}
		persist()
		return {
			status: 'ok',
			backend: 'github-issues',
			action: 'closed',
			issue_number: issueNumber,
			issue_url: record.github.issue_url,
			warnings,
		}
	}

	record.github = { issue_number: issueNumber, issue_url: record.github.issue_url, repo: issueRepo }
	persist()
	return {
		status: 'ok',
		backend: 'github-issues',
		action: 'updated',
		issue_number: issueNumber,
		issue_url: record.github.issue_url,
		warnings,
	}
}

/**
 * Apply the backend mirror step. local-only writes to disk; github-issues
 * dispatches to the gh-CLI adapter; app-inbox stays a not-implemented stub
 * so callers can handle it gracefully.
 *
 * @param {string} backend
 * @param {string} absProjectDir
 * @param {string} taskId
 * @param {object} record
 * @returns {null | { status: string, backend?: string, [k: string]: unknown }}
 */
function mirrorToBackend(backend, absProjectDir, taskId, record) {
	if (backend === 'local-only') {
		writeTriage(triagePath(absProjectDir, taskId), record)
		return null
	}
	if (backend === 'github-issues') {
		return mirrorToGithubIssues(absProjectDir, taskId, record)
	}
	// v1 stub: app-inbox returns not-implemented (out of scope per spec).
	return { status: 'not-implemented', backend, v2_issue: '#TBD' }
}

/**
 * Map a non-null mirror envelope to its exit code. github-issues errors
 * exit 1 (ID-04); the not-implemented stub and ok envelopes exit 0.
 *
 * @param {{ status: string, [k: string]: unknown }} mirror
 * @returns {number}
 */
function mirrorExitCode(mirror) {
	return mirror.status === 'error' ? 1 : 0
}

/**
 * Entry: `apt-tools triage <subcommand> <project-dir> [flags]`.
 */
export function cmdTriage(subcommand, projectDir, extraArgs) {
	if (!subcommand || !['init', 'transition', 'get'].includes(subcommand)) {
		return err(
			'Usage: apt-tools triage <init|transition|get> <project-dir> --id <task-id> [...]',
			1,
			{ command: 'triage' },
		)
	}
	if (!projectDir) {
		return err('project-dir is required', 1, { command: `triage-${subcommand}` })
	}
	const absProjectDir = resolve(projectDir)
	if (!existsSync(absProjectDir)) {
		return err(`project directory not found: ${absProjectDir}`, 1, {
			command: `triage-${subcommand}`,
		})
	}

	const flags = parseFlags(extraArgs ?? [])
	const taskId = flags.get('id')
	if (typeof taskId !== 'string' || taskId.trim() === '') {
		return err('--id <task-id> is required', 1, { command: `triage-${subcommand}` })
	}
	if (!TASK_ID_RE_LEGACY.test(taskId) && !TASK_ID_RE_SLUG_FIRST.test(taskId)) {
		return err(
			`--id must match pattern <slug_YY-MM-DD[-N]> (e.g. fix-login_24-01-15) or legacy <YY-MM-DD_slug-XXXX>`,
			1,
			{ command: `triage-${subcommand}` },
		)
	}
	const backend = readBackend(absProjectDir)

	if (subcommand === 'init') {
		// Optional --category, optional --extra (JSON string).
		const category = flags.get('category')
		if (category !== undefined && !CATEGORIES.includes(String(category))) {
			return err(`--category must be one of: ${CATEGORIES.join(' | ')}`, 1, {
				command: 'triage-init',
			})
		}
		let extra = {}
		const extraRaw = flags.get('extra')
		if (typeof extraRaw === 'string' && extraRaw.length > 0) {
			try {
				const parsed = JSON.parse(extraRaw)
				if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
					extra = parsed
				} else {
					return err('--extra must be a JSON object string', 1, { command: 'triage-init' })
				}
			} catch (e) {
				return err(`--extra is not valid JSON: ${e.message}`, 1, { command: 'triage-init' })
			}
		}

		const existing = readTriage(triagePath(absProjectDir, taskId))
		if (existing) {
			return err(`triage record already exists for ${taskId}`, 1, {
				command: 'triage-init',
				current_state: existing.state,
			})
		}

		const record = {
			task_id: taskId,
			state: 'needs-triage',
			category: typeof category === 'string' ? category : null,
			transitions: [],
			extra,
		}
		const mirror = mirrorToBackend(backend, absProjectDir, taskId, record)
		if (mirror) {
			// github-issues success merges its action/issue fields and echoes
			// the persisted record (which now carries `github`); errors + the
			// app-inbox not-implemented stub pass through as-is.
			const merged =
				mirror.status === 'ok'
					? { command: 'triage-init', ...mirror, record }
					: { command: 'triage-init', ...mirror }
			return exitWith(merged, mirrorExitCode(mirror))
		}
		return ok({ status: 'ok', command: 'triage-init', backend, record })
	}

	if (subcommand === 'transition') {
		const toState = flags.get('to')
		if (typeof toState !== 'string' || !STATES.includes(toState)) {
			return err(`--to <state> is required (one of: ${STATES.join(' | ')})`, 1, {
				command: 'triage-transition',
			})
		}
		// SEC-003: cap --reason to 1000 chars to prevent unbounded disk writes.
		const rawReason = flags.get('reason')
		const reason = typeof rawReason === 'string' ? rawReason.slice(0, 1000) : null
		const by =
			typeof flags.get('by') === 'string' && ['user', 'agent'].includes(String(flags.get('by')))
				? String(flags.get('by'))
				: 'agent'

		const current = readTriage(triagePath(absProjectDir, taskId))
		if (!current) {
			return err(`no triage record for ${taskId} — run \`triage init\` first`, 1, {
				command: 'triage-transition',
			})
		}

		const from = current.state
		const transitionKey = `${from}->${toState}`
		if (!VALID_TRANSITIONS.has(transitionKey)) {
			return exitWith(
				{
					status: 'rejected',
					command: 'triage-transition',
					reason: 'invalid-transition',
					from,
					to: toState,
					valid_from_current: STATES.filter((s) => VALID_TRANSITIONS.has(`${from}->${s}`)),
				},
				1,
			)
		}

		const transition = {
			from,
			to: toState,
			at: new Date().toISOString(),
			by,
			reason,
		}
		// SEC-002: Only carry known fields forward — do NOT spread the full
		// disk-backed `current` object, which could contain arbitrary unknown
		// keys from a hand-edited triage.json that would silently propagate.
		const next = {
			task_id: current.task_id,
			state: toState,
			category: current.category,
			extra: current.extra ?? {},
			transitions: [...(current.transitions || []), transition],
			// ID-02: carry the github issue pointer forward so subsequent
			// transitions target the SAME issue instead of creating a duplicate.
			// Only set when present (local-only records never carry it).
			...(current.github ? { github: current.github } : {}),
		}
		const mirror = mirrorToBackend(backend, absProjectDir, taskId, next)
		if (mirror) {
			const merged =
				mirror.status === 'ok'
					? { command: 'triage-transition', ...mirror, transition, record: next }
					: { command: 'triage-transition', ...mirror }
			return exitWith(merged, mirrorExitCode(mirror))
		}
		return ok({
			status: 'ok',
			command: 'triage-transition',
			backend,
			transition,
			record: next,
		})
	}

	// subcommand === 'get'
	const path = triagePath(absProjectDir, taskId)
	const record = readTriage(path)
	if (!record) {
		return exitWith({ status: 'not-found', command: 'triage-get', task_id: taskId, file: path }, 0)
	}
	return ok({ status: 'ok', command: 'triage-get', record, backend })
}

// Exported for testing.
export const _STATES = STATES
export const _CATEGORIES = CATEGORIES
export const _VALID_TRANSITIONS = VALID_TRANSITIONS
