/**
 * @internal
 * G10 r-persona-alignment — persona-aware planning gate (C50).
 *
 * Checks that a planned task's `declared_persona_tier` (from
 * `implementation_plan.json`) aligns with its milestone's `focus_tier` list
 * (from `.aperant/roadmap/{scope}/milestones.json`). Fires at stage=execute
 * AND stage=ship (mirrors consistency-check placement — see C50 D-09: gate
 * runs AFTER plan-mode approval, alongside the other consistency rules).
 *
 * Skip cases (C50 locked decisions):
 *   - D-06: `.aperant/personas.json` is absent → skip with hint pointing at
 *           /apt:personas. No enforcement until personas exist.
 *   - D-07: `implementation_plan.json` has no `declared_persona_tier` →
 *           skip as "pre-C50 task". Backward-compat for plans built before
 *           C50 shipped.
 *   - Task has no `milestone_id` (unlinked task) → skip; no milestone to
 *           resolve focus_tier against.
 *   - Milestone cannot be found in the linked scope → skip (roadmap drift
 *           is a separate concern, handled by `roadmap validate-refs`).
 *   - Milestone has no `focus_tier` field set → skip; the milestone has
 *           opted out of gating (D-01 backward-compat: absence = no gating).
 *
 * Fail / warn cases:
 *   - declared_persona_tier ∉ milestone.focus_tier → mismatch. Severity is
 *     resolved per D-05:
 *       - `focus_tier_enforcement === "blocking"` on the milestone → fail.
 *       - otherwise (absent or `"warn"`) → warn.
 *
 * Multi-persona handling (D-04): when the plan declares multiple tiers via
 * `declared_persona_tiers: string[]` (plural), the effective tier is the
 * MINIMUM tier covered (primary > secondary > tertiary). As long as one of
 * the declared tiers appears in `focus_tier`, the task is in-scope. This
 * matches how teams reason about work that spans tiers ("this helps primary
 * AND while we're here, helps tertiary").
 *
 * Rejection evidence carries the structured 4-option picker keys per D-08
 * (`[D]efer`, `[R]eassign`, `[P]romote`, `[F]orce`) so the apt-plan SKILL.md
 * UX layer can render the picker without re-deriving options — the gate
 * encodes the menu, the skill surfaces it.
 */

import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { loadRoadmapForScope } from '../../roadmap/io.mjs'
import { roadmapScopePaths } from '../../roadmap/paths.mjs'
import { registerGate } from '../registry.mjs'

const TIER_PRIORITY = { primary: 0, secondary: 1, tertiary: 2 }

const gate = {
	id: 'r-persona-alignment',
	description:
		'Task persona tier aligns with milestone focus_tier (C50). Skipped when personas.json is absent or the task predates C50.',
	// Registered as 'blocking' so that milestones that opt into enforcement
	// (focus_tier_enforcement: "blocking") can actually block the skill. The
	// DEFAULT verdict for a mismatch is 'warn' per D-05; the gate escalates
	// to 'fail' only when the milestone promoted itself. A 'warn' verdict
	// from any gate never blocks the CLI (see commands/gate.mjs line ~185),
	// so warn-severity-by-default is preserved at the effect level even
	// though the registration slot is 'blocking'.
	severity: 'blocking',
	skills: ['execute', 'ship'],
	async check(ctx) {
		const personasPath = join(ctx.sharedRoot, '.aperant', 'personas.json')
		const planPath = join(ctx.taskDir, 'implementation_plan.json')
		// inputs starts with the two task-level files. The roadmap path is
		// appended below once the scope is resolved — this ensures the gate
		// cache hash covers all three files and a roadmap change invalidates
		// a cached pass/fail result (PRF-001).
		const inputs = [personasPath, planPath]

		// D-06: personas.json absent → skip with hint.
		if (!existsSync(personasPath)) {
			return {
				status: 'skip',
				reason:
					'personas.json not present — run /apt:personas first to enable persona-aware planning',
				inputs,
			}
		}

		if (!existsSync(planPath)) {
			return {
				status: 'skip',
				reason: 'implementation_plan.json not present — nothing to align',
				inputs,
			}
		}

		let plan
		try {
			plan = JSON.parse(readFileSync(planPath, 'utf-8'))
		} catch (err) {
			return {
				status: 'skip',
				reason: `implementation_plan.json does not parse (${err.message}) — r-persona-alignment defers to plan-exists`,
				inputs,
			}
		}

		// D-07: pre-C50 task (no declared_persona_tier / declared_persona_tiers).
		const declaredTiers = normalizeDeclaredTiers(plan)
		if (declaredTiers.length === 0) {
			return {
				status: 'skip',
				reason: 'pre-C50 task: no declared_persona_tier — gate does not apply',
				inputs,
			}
		}

		const taskRecord = ctx.taskRecord ?? null
		const milestoneId = taskRecord?.milestone_id ?? null
		if (!milestoneId) {
			return {
				status: 'skip',
				reason: 'task has no milestone_id — no focus_tier boundary to check',
				inputs,
			}
		}

		const scope = taskRecord?.scope || 'project'
		// Add roadmap path to inputs so the gate cache is invalidated when
		// the roadmap changes (PRF-001 fix). We use the legacy milestones.json
		// path — the phases/ layout is covered transitively because
		// loadRoadmapForScope re-reads from disk on every cache miss.
		const { jsonPath: roadmapJsonPath } = roadmapScopePaths(ctx.sharedRoot, scope)
		inputs.push(roadmapJsonPath)
		const roadmap = loadRoadmapForScope(ctx.sharedRoot, scope)
		if (!roadmap) {
			return {
				status: 'skip',
				reason: `no roadmap for scope "${scope}" — cannot resolve milestone focus_tier`,
				inputs,
			}
		}

		const milestone = (roadmap.milestones || []).find((m) => m?.id === milestoneId)
		if (!milestone) {
			return {
				status: 'skip',
				reason: `milestone "${milestoneId}" not found in scope "${scope}" — roadmap drift is outside the gate's concern`,
				inputs,
			}
		}

		// D-01: absent focus_tier = legacy behavior (no gating).
		const focusTier = Array.isArray(milestone.focus_tier) ? milestone.focus_tier : null
		if (!focusTier || focusTier.length === 0) {
			return {
				status: 'skip',
				reason: `milestone "${milestoneId}" has no focus_tier — opted out of persona gating`,
				inputs,
			}
		}

		// D-04: multi-persona → effective tier is the minimum (strongest) tier
		// covered. If any declared tier is in focus_tier, the task is in-scope.
		const focusSet = new Set(focusTier)
		const matched = declaredTiers.find((t) => focusSet.has(t))
		const effectiveTier = minimumTier(declaredTiers)

		// SEC-004: cross-reference declared_personas[] IDs against the enrolled
		// persona roster in personas.json. Phantom IDs (declared but not enrolled)
		// are surfaced in evidence so the skill layer can warn the user; they do
		// not change the tier-match result but pair with the consistency rule
		// (r7-persona-alignment.mjs) which covers the same gap at plan stage.
		const phantomPersonas = collectPhantomPersonas(plan, personasPath)

		if (matched) {
			return {
				status: 'pass',
				// Cite `matched` (the tier actually found in focus_tier), not
				// `effectiveTier` (the minimum/strongest declared tier). When
				// declaredTiers = ["primary", "tertiary"] and focusTier = ["tertiary"],
				// matched = "tertiary" and effectiveTier = "primary" — the reason
				// must reflect which tier satisfied the focus constraint. (LOG-002)
				reason: `declared tier "${matched}" is in milestone "${milestoneId}" focus_tier [${focusTier.join(', ')}]`,
				evidence: {
					milestone_id: milestoneId,
					scope,
					focus_tier: focusTier,
					declared_tiers: declaredTiers,
					effective_tier: effectiveTier,
					matched_tier: matched,
					...(phantomPersonas.length > 0 ? { phantom_personas: phantomPersonas } : {}),
				},
				inputs,
			}
		}

		// D-05: severity resolution. Per-milestone promotion via
		// focus_tier_enforcement; default is warn.
		const enforcement = milestone.focus_tier_enforcement === 'blocking' ? 'blocking' : 'warn'
		const status = enforcement === 'blocking' ? 'fail' : 'warn'

		// D-08: structured 4-option picker carried in evidence. The apt-plan
		// SKILL.md layer reads `rejection_options[]` to render the picker.
		const rejectionOptions = [
			{
				key: 'D',
				label: 'Defer to later milestone',
				description: 'Park this task until a milestone with matching focus_tier is active.',
			},
			{
				key: 'R',
				label: 'Reassign to persona-aligned milestone',
				description: `Move the task to a milestone whose focus_tier includes "${effectiveTier}".`,
			},
			{
				key: 'P',
				label: `Promote "${effectiveTier}" into milestone focus_tier`,
				description: `Edit milestone "${milestoneId}" to add "${effectiveTier}" to focus_tier (expands scope).`,
			},
			{
				key: 'F',
				label: 'Force-accept (logs override to events)',
				description:
					'Proceed anyway — records an override entry in .aperant/events/{date}.jsonl with reason.',
			},
		]

		return {
			status,
			reason: `declared tier "${effectiveTier}" is not in milestone "${milestoneId}" focus_tier [${focusTier.join(', ')}] (enforcement: ${enforcement})`,
			evidence: {
				milestone_id: milestoneId,
				scope,
				focus_tier: focusTier,
				declared_tiers: declaredTiers,
				effective_tier: effectiveTier,
				enforcement,
				rejection_options: rejectionOptions,
				...(phantomPersonas.length > 0 ? { phantom_personas: phantomPersonas } : {}),
			},
			inputs,
		}
	},
}

/**
 * Pull declared tiers from implementation_plan.json. Accepts both:
 *   - `declared_persona_tier: "primary"` (single-tier, the common case)
 *   - `declared_persona_tiers: ["primary", "tertiary"]` (multi-persona task)
 * Filters out anything that isn't one of the three known tier strings.
 */
function normalizeDeclaredTiers(plan) {
	if (!plan || typeof plan !== 'object') return []
	const out = []
	if (Array.isArray(plan.declared_persona_tiers)) {
		for (const t of plan.declared_persona_tiers) {
			if (typeof t === 'string' && t in TIER_PRIORITY) out.push(t)
		}
	}
	if (
		typeof plan.declared_persona_tier === 'string' &&
		plan.declared_persona_tier in TIER_PRIORITY
	) {
		out.push(plan.declared_persona_tier)
	}
	// Dedupe while preserving first occurrence order (primary-first if present).
	const seen = new Set()
	return out.filter((t) => {
		if (seen.has(t)) return false
		seen.add(t)
		return true
	})
}

/**
 * SEC-004: cross-reference `plan.declared_personas[]` against the enrolled
 * persona roster in `personas.json`. Returns the subset of declared persona
 * IDs/names that do NOT appear in the roster — "phantom" references.
 *
 * Accepts three persona identity surfaces (mirrors the consistency rule):
 *   - `persona.id` (top-level field)
 *   - `persona.ui_json.id` (app-canonical identity)
 *   - `persona.ui_json.name` (display name)
 *
 * @param {unknown} plan
 * @param {string} personasPath
 * @returns {string[]}
 */
function collectPhantomPersonas(plan, personasPath) {
	if (!plan || typeof plan !== 'object') return []
	const declared = Array.isArray(plan.declared_personas)
		? plan.declared_personas.filter((p) => typeof p === 'string' && p.length > 0)
		: []
	if (declared.length === 0) return []

	let personasEnv
	try {
		personasEnv = JSON.parse(readFileSync(personasPath, 'utf-8'))
	} catch {
		return []
	}
	const known = new Set()
	const personas = Array.isArray(personasEnv?.personas) ? personasEnv.personas : []
	for (const p of personas) {
		if (!p || typeof p !== 'object') continue
		if (typeof p.id === 'string' && p.id.length > 0) known.add(p.id.toLowerCase())
		if (typeof p.ui_json === 'string' && p.ui_json.length > 0) {
			try {
				const ui = JSON.parse(p.ui_json)
				if (ui && typeof ui === 'object') {
					if (typeof ui.id === 'string' && ui.id.length > 0) known.add(ui.id.toLowerCase())
					if (typeof ui.name === 'string' && ui.name.length > 0) known.add(ui.name.toLowerCase())
				}
			} catch {
				/* ui_json may not be valid JSON — skip */
			}
		}
	}
	return declared.filter((id) => !known.has(id.toLowerCase()))
}

/**
 * D-04 helper: minimum (strongest) tier covered. primary < secondary < tertiary.
 */
function minimumTier(tiers) {
	let best = null
	let bestRank = Infinity
	for (const t of tiers) {
		const rank = TIER_PRIORITY[t]
		if (rank === undefined) continue
		if (rank < bestRank) {
			best = t
			bestRank = rank
		}
	}
	return best
}

registerGate(gate)
export default gate
