/**
 * @internal
 * consistency/rules/r7-persona-alignment.mjs — r7-persona-alignment (C50).
 *
 * Cross-artifact consistency rule checking that `implementation_plan.json`
 * is internally consistent with `.aperant/personas.json`:
 *
 *   - Every `declared_personas[]` entry in the plan resolves to a persona
 *     present in `.aperant/personas.json` (by persona `id`, or by `ui_json.id`
 *     / `ui_json.name` when the envelope stores persona identity inside the
 *     JSON blob).
 *   - `declared_persona_tier` (and the plural `declared_persona_tiers[]`) are
 *     one of the three known tier strings: primary, secondary, tertiary.
 *
 * This is a **different concern** from the r-persona-alignment gate:
 *
 *   - The gate checks plan ↔ milestone focus alignment (is the declared
 *     tier in the milestone's in-scope list?).
 *   - This rule checks plan ↔ personas.json internal consistency (do the
 *     personas the plan references actually exist?).
 *
 * Both are needed — a plan can declare "primary" tier that's in-scope for
 * the milestone (gate passes) while citing a non-existent persona id (rule
 * fails). The rule runs earlier in the pipeline (stage: plan, execute)
 * because plan ↔ personas drift is cheap to detect and expensive to ship.
 *
 * Skip cases:
 *   - `.aperant/personas.json` absent → skip (mirrors gate D-06: no
 *     persona enforcement until personas exist).
 *   - Plan has no declared_persona* fields → skip (pre-C50 plan, D-07).
 *
 * Severity: warn. Stages: plan, execute, ship.
 *   - Rationale: we don't want this to block `ship` when a persona was
 *     renamed between plan and ship (real-world case; breaking ship for
 *     persona-rename drift is too strict). The gate covers the harder
 *     enforcement axis (milestone focus).
 */

import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { registerRule } from '../registry.mjs'

const KNOWN_TIERS = new Set(['primary', 'secondary', 'tertiary'])

const rule = {
	id: 'r7-persona-alignment',
	severity: 'warn',
	stages: ['plan', 'execute', 'ship'],
	description:
		'Plan-declared personas exist in .aperant/personas.json and declared tier is one of {primary, secondary, tertiary}.',
	async check({ taskDir, parsed }) {
		// Resolve shared-root path to .aperant/personas.json. The task dir is
		// `<sharedRoot>/.aperant/tasks/<taskId>`; walk up three segments.
		if (!taskDir || typeof taskDir !== 'string') {
			return {
				status: 'skip',
				drift: [],
				reason: 'taskDir not supplied — rule cannot resolve personas.json',
			}
		}
		const personasPath = join(taskDir, '..', '..', '..', '.aperant', 'personas.json')
		if (!existsSync(personasPath)) {
			return {
				status: 'skip',
				drift: [],
				reason: '.aperant/personas.json not present — persona-aware planning not enabled',
			}
		}

		const planRaw = readPlanRaw(taskDir)
		const declaredTiers = extractDeclaredTiers(planRaw)
		const declaredPersonas = extractDeclaredPersonas(planRaw)
		if (declaredTiers.length === 0 && declaredPersonas.length === 0) {
			return {
				status: 'skip',
				drift: [],
				reason: 'pre-C50 plan: no declared_persona_tier / declared_personas — rule does not apply',
			}
		}

		let personasEnv
		try {
			personasEnv = JSON.parse(readFileSync(personasPath, 'utf-8'))
		} catch (err) {
			return {
				status: 'skip',
				drift: [],
				reason: `.aperant/personas.json does not parse (${err.message}) — rule defers to personas tooling`,
			}
		}

		const knownPersonas = collectPersonaIdentities(personasEnv)
		const drift = []

		// Unknown tier strings.
		for (const t of declaredTiers) {
			if (!KNOWN_TIERS.has(t)) {
				drift.push({
					kind: 'unknown-tier',
					value: t,
					reason: `declared tier "${t}" is not one of {primary, secondary, tertiary}`,
				})
			}
		}

		// Personas referenced by id / name that don't appear in personas.json.
		for (const p of declaredPersonas) {
			if (!knownPersonas.has(p.toLowerCase())) {
				drift.push({
					kind: 'unknown-persona',
					value: p,
					reason: `persona "${p}" referenced in plan.declared_personas is not in .aperant/personas.json`,
				})
			}
		}

		// plan parsed pointer for extended consumers — reference via parsed so
		// future rules can share the extracted shape without re-parsing.
		if (parsed && typeof parsed === 'object' && parsed.plan) {
			parsed.plan._declared_persona_tiers = declaredTiers
			parsed.plan._declared_personas = declaredPersonas
		}

		if (drift.length > 0) {
			return {
				status: 'fail',
				drift,
				reason: `${drift.length} persona-alignment drift entry(ies) between plan and personas.json`,
			}
		}

		return {
			status: 'pass',
			drift: [],
			reason: `${declaredTiers.length} declared tier(s) + ${declaredPersonas.length} declared persona(s) resolve cleanly`,
		}
	},
}

/**
 * Read the raw implementation_plan.json for the task. We duplicate the read
 * here rather than depending on `parsed.plan` because the parser strips the
 * fields we care about (declared_persona_tier, declared_personas) — those
 * are plan-level metadata, not per-subtask content.
 */
function readPlanRaw(taskDir) {
	const planPath = join(taskDir, 'implementation_plan.json')
	if (!existsSync(planPath)) return null
	try {
		return JSON.parse(readFileSync(planPath, 'utf-8'))
	} catch {
		return null
	}
}

function extractDeclaredTiers(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.length > 0) out.push(t)
		}
	}
	if (typeof plan.declared_persona_tier === 'string' && plan.declared_persona_tier.length > 0) {
		out.push(plan.declared_persona_tier)
	}
	// Deduplicate while preserving first-occurrence order. Without this, a plan
	// that sets both `declared_persona_tier: "primary"` and
	// `declared_persona_tiers: ["primary"]` would report two "primary" entries,
	// inflating the declared tier count in the pass reason. Mirrors the gate
	// version's dedup (r-persona-alignment.mjs normalizeDeclaredTiers). (LOG-004)
	const seen = new Set()
	return out.filter((t) => {
		if (seen.has(t)) return false
		seen.add(t)
		return true
	})
}

function extractDeclaredPersonas(plan) {
	if (!plan || typeof plan !== 'object') return []
	if (!Array.isArray(plan.declared_personas)) return []
	return plan.declared_personas.filter((p) => typeof p === 'string' && p.length > 0)
}

/**
 * Build a lowercased set of known persona identities from personas.json. We
 * accept persona identity in three places per the framework envelope:
 *   - persona.id (top-level field, optional)
 *   - persona.ui_json.id (parsed from the JSON string, the app's canonical
 *     identity)
 *   - persona.ui_json.name (display name; useful when plans reference
 *     personas by name like "Mobile Developer")
 */
function collectPersonaIdentities(env) {
	const out = new Set()
	if (!env || typeof env !== 'object') return out
	const personas = Array.isArray(env.personas) ? env.personas : []
	for (const p of personas) {
		if (!p || typeof p !== 'object') continue
		if (typeof p.id === 'string' && p.id.length > 0) out.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) out.add(ui.id.toLowerCase())
					if (typeof ui.name === 'string' && ui.name.length > 0) out.add(ui.name.toLowerCase())
				}
			} catch {
				/* ui_json is not always JSON-parseable; that's fine */
			}
		}
	}
	return out
}

registerRule(rule)
export default rule
