/**
 * release-notes/persona-filter.mjs — shared persona-filter logic.
 *
 * The compile + autodraft surfaces both need the same predicate: given a
 * persona id (or name) extracted from a fragment, the project's
 * `.aperant/personas.json` roster, and a filter mode, decide whether the
 * fragment belongs in the user-facing body, in the "Needs Triage" bucket,
 * or filtered out entirely.
 *
 * Three filter modes (config.changelog.release_notes.persona_filter):
 *
 *   - 'primary'             — only personas whose `tier === 'primary'`.
 *   - 'primary+secondary'   — primary OR secondary. DEFAULT.
 *   - 'all'                 — every tier passes (no filter).
 *
 * Triage rules (ID-6 in spec.md, never-silent-drop invariant):
 *
 *   - Fragment with no persona tag         → bucket = 'triage'.
 *   - Fragment with a tag that doesn't
 *     resolve to a known persona in the
 *     roster                              → bucket = 'triage'.
 *   - Fragment whose persona DOES resolve
 *     but is filtered out by the mode     → bucket = 'filtered'.
 *   - Fragment whose persona resolves AND
 *     passes the mode                     → bucket = 'body', with the
 *                                            resolved tier exposed for
 *                                            grouping in compile output.
 *
 * The matcher is case-insensitive on persona names. Empty string and the
 * literal sentinel `(none)` are both treated as "no tag" — the draft
 * renderer writes `persona: (none)` when no --persona flag is passed.
 */

/** @typedef {'primary' | 'secondary' | 'tertiary' | 'unknown'} PersonaTier */
/** @typedef {'body' | 'triage' | 'filtered'} FilterBucket */

const NONE_SENTINELS = new Set(['', '(none)', 'none', 'null', 'undefined'])

/**
 * True when the raw persona-tag string from a fragment should be treated as
 * "no tag" (and therefore routed to triage with reason `no-persona-tag`).
 * Mirrors `NONE_SENTINELS` so callers — notably `compile.mjs`'s triage-reason
 * labeller — stay in lockstep with the classifier. Case-insensitive.
 *
 * @param {string | null | undefined} raw
 * @returns {boolean}
 */
export function isNoneSentinel(raw) {
	const normalized = (raw ?? '').trim().toLowerCase()
	return NONE_SENTINELS.has(normalized)
}

/**
 * Read tier + name out of a persona envelope row (the shape personas.mjs
 * persists). Tier lives inside `ui_json` (a JSON string). Returns null
 * when the row is unusable.
 *
 * @param {{ ui_json?: unknown }} row
 * @returns {{ name: string, tier: PersonaTier } | null}
 */
export function extractPersonaIdentity(row) {
	if (!row || typeof row !== 'object') return null
	if (typeof row.ui_json !== 'string') return null
	let parsed
	try {
		parsed = JSON.parse(row.ui_json)
	} catch {
		return null
	}
	if (!parsed || typeof parsed !== 'object') return null
	const name = typeof parsed.name === 'string' ? parsed.name.trim() : ''
	const tierRaw = typeof parsed.tier === 'string' ? parsed.tier.trim().toLowerCase() : ''
	if (name.length === 0) return null
	/** @type {PersonaTier} */
	const tier =
		tierRaw === 'primary' || tierRaw === 'secondary' || tierRaw === 'tertiary' ? tierRaw : 'unknown'
	return { name, tier }
}

/**
 * Build a name → tier map from a personas envelope.
 *
 * @param {{ personas?: Array<{ ui_json?: unknown }> } | null | undefined} envelope
 * @returns {Map<string, PersonaTier>}
 */
export function buildPersonaTierMap(envelope) {
	/** @type {Map<string, PersonaTier>} */
	const map = new Map()
	if (!envelope || !Array.isArray(envelope.personas)) return map
	for (const row of envelope.personas) {
		const identity = extractPersonaIdentity(row)
		if (!identity) continue
		map.set(identity.name.toLowerCase(), identity.tier)
	}
	return map
}

/**
 * Classify a fragment's persona tag against the roster + filter mode.
 *
 * @param {string | null | undefined} personaTag   The raw tag from the fragment.
 * @param {Map<string, PersonaTier>} tierMap        From `buildPersonaTierMap`.
 * @param {'primary' | 'primary+secondary' | 'all'} mode
 * @returns {{ bucket: FilterBucket, resolvedName: string | null, resolvedTier: PersonaTier | null }}
 */
export function classifyFragment(personaTag, tierMap, mode) {
	const normalized = (personaTag ?? '').trim().toLowerCase()
	if (normalized.length === 0 || NONE_SENTINELS.has(normalized)) {
		return { bucket: 'triage', resolvedName: null, resolvedTier: null }
	}
	const tier = tierMap.get(normalized)
	if (tier === undefined) {
		// Tag was set but the roster doesn't recognize it — never silent-drop.
		return { bucket: 'triage', resolvedName: personaTag ?? null, resolvedTier: null }
	}
	if (mode === 'all') {
		return { bucket: 'body', resolvedName: personaTag ?? null, resolvedTier: tier }
	}
	if (mode === 'primary' && tier !== 'primary') {
		return { bucket: 'filtered', resolvedName: personaTag ?? null, resolvedTier: tier }
	}
	if (mode === 'primary+secondary' && tier !== 'primary' && tier !== 'secondary') {
		return { bucket: 'filtered', resolvedName: personaTag ?? null, resolvedTier: tier }
	}
	return { bucket: 'body', resolvedName: personaTag ?? null, resolvedTier: tier }
}
