/**
 * release-notes/compile.mjs — tag-time narrative compiler.
 *
 * Reads every `<output_dir>/_unreleased/PR-*.md` fragment, classifies each
 * against the project's persona roster + filter mode, and writes
 * `<output_dir>/<tag>.md`. Atomic-rename pattern: the fragments dir is
 * cleared only AFTER the compiled file lands on disk, so a crash mid-write
 * cannot orphan fragments without their compiled output.
 *
 * Non-negotiable invariant (ID-6): unmapped fragments (no persona tag, or
 * a tag the roster doesn't recognize) MUST appear in a `## Needs Triage`
 * section. Never silent-drop. The triage section is the human curator's
 * safety net — its presence is asserted by TD-3.
 */

import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs'
import { join } from 'node:path'

import { loadProjectConfig } from '../config/load.mjs'
import { readPersonas } from '../personas/sidecar.mjs'
import { atomicWriteText } from '../util/atomic-write.mjs'
import { resolveOutputDir, validateTag } from './output-dir.mjs'
import { buildPersonaTierMap, classifyFragment, isNoneSentinel } from './persona-filter.mjs'

const VALID_MODES = new Set(['primary', 'primary+secondary', 'all'])

/** Pre-compiled at module level to avoid recompilation in the sort comparator (PRF-001). */
const PR_FILENAME_RE = /^PR-(\d+)\.md$/

/**
 * Parse a single fragment file. Tolerant: missing keys default to empty
 * strings; the body after `## Release note` is read verbatim.
 *
 * @param {string} text
 * @returns {{ pr: string, date: string, persona: string, note: string, raw: string }}
 */
function parseFragment(text) {
	const lines = text.split('\n')
	let pr = ''
	let date = ''
	let persona = ''
	let note = ''
	let noteStart = -1
	for (let i = 0; i < lines.length; i++) {
		const line = lines[i]
		if (line.startsWith('pr: ')) pr = line.slice(4).trim()
		else if (line.startsWith('date: ')) date = line.slice(6).trim()
		else if (line.startsWith('persona: ')) persona = line.slice(9).trim()
		else if (line.startsWith('## Release note')) {
			noteStart = i
			break
		}
	}
	if (noteStart >= 0) {
		note = lines
			.slice(noteStart + 1)
			.join('\n')
			.trim()
	}
	return { pr, date, persona, note, raw: text }
}

/**
 * Resolve the output dir and persona filter mode from config + flag override.
 *
 * @param {string} projectDir
 * @param {string | null} flagOverride
 * @returns {{ outputDir: string, personaFilter: 'primary' | 'primary+secondary' | 'all' }}
 */
function resolveContext(projectDir, flagOverride) {
	const config = loadProjectConfig(projectDir)
	const outputDir = resolveOutputDir(projectDir, flagOverride)
	const filterRaw = config?.changelog?.release_notes?.persona_filter
	const personaFilter =
		typeof filterRaw === 'string' && VALID_MODES.has(filterRaw)
			? /** @type {'primary' | 'primary+secondary' | 'all'} */ (filterRaw)
			: 'primary+secondary'
	return { outputDir, personaFilter }
}

/**
 * @typedef {Object} CompileInput
 * @property {string} projectDir
 * @property {string} tag
 * @property {string | null} outputDir
 */

/**
 * @typedef {Object} CompileResult
 * @property {boolean} written
 * @property {string} path
 * @property {number} fragmentsTotal
 * @property {number} fragmentsInBody
 * @property {number} fragmentsInTriage
 * @property {number} fragmentsFiltered
 * @property {boolean} clearedUnreleased
 */

/**
 * Compile every `_unreleased/PR-*.md` fragment into `<output_dir>/<tag>.md`.
 *
 * @param {CompileInput} input
 * @returns {CompileResult}
 */
export function compileTag(input) {
	validateTag(input.tag)
	const { outputDir, personaFilter } = resolveContext(input.projectDir, input.outputDir)
	const fragmentsDir = join(outputDir, '_unreleased')
	const targetPath = join(outputDir, `${input.tag}.md`)

	const rawFiles = existsSync(fragmentsDir)
		? readdirSync(fragmentsDir).filter((f) => PR_FILENAME_RE.test(f))
		: []
	// Pre-compute PR numbers once to avoid re-running the regex in the comparator (PRF-001).
	const withNumbers = rawFiles.map((f) => ({
		f,
		n: Number.parseInt(PR_FILENAME_RE.exec(f)?.[1] ?? '0', 10),
	}))
	withNumbers.sort((a, b) => a.n - b.n)
	const fragments = withNumbers.map(({ f }) => ({
		filename: f,
		...parseFragment(readFileSync(join(fragmentsDir, f), 'utf-8')),
	}))

	const personasEnvelope = readPersonas(input.projectDir)
	const tierMap = buildPersonaTierMap(personasEnvelope)

	/** @type {Array<{ pr: string, date: string, persona: string, note: string, tier: 'primary' | 'secondary' | 'tertiary' }>} */
	const bodyEntries = []
	/** @type {Array<{ pr: string, date: string, persona: string, note: string, reason: string }>} */
	const triageEntries = []
	let filteredCount = 0

	for (const frag of fragments) {
		const { bucket, resolvedTier } = classifyFragment(frag.persona, tierMap, personaFilter)
		if (bucket === 'body' && resolvedTier !== null && resolvedTier !== 'unknown') {
			bodyEntries.push({
				pr: frag.pr,
				date: frag.date,
				persona: frag.persona,
				note: frag.note,
				tier: resolvedTier,
			})
		} else if (bucket === 'triage' || resolvedTier === 'unknown') {
			// Use the shared sentinel predicate so this label stays in lockstep
			// with persona-filter.mjs's NONE_SENTINELS set. Without it, the
			// classifier and labeller drift (e.g. literal "null" persona tags
			// land in triage as 'unknown-persona' instead of 'no-persona-tag').
			// 'unknown' tier (malformed tier value in personas.json) is also
			// routed here — never silent-drop (ID-6 / LOG-001).
			const reason = isNoneSentinel(frag.persona)
				? 'no-persona-tag'
				: resolvedTier === 'unknown'
					? 'unknown-tier'
					: 'unknown-persona'
			triageEntries.push({
				pr: frag.pr,
				date: frag.date,
				persona: frag.persona,
				note: frag.note,
				reason,
			})
		} else {
			filteredCount++
		}
	}

	const compiled = renderCompiled({
		tag: input.tag,
		body: bodyEntries,
		triage: triageEntries,
		filter: personaFilter,
	})

	atomicWriteText(targetPath, compiled)

	// Clear _unreleased only AFTER the compiled file is durably written.
	let clearedUnreleased = false
	if (existsSync(fragmentsDir) && fragments.length > 0) {
		for (const frag of fragments) {
			rmSync(join(fragmentsDir, frag.filename), { force: true })
		}
		clearedUnreleased = true
	}

	return {
		written: true,
		path: targetPath,
		fragmentsTotal: fragments.length,
		fragmentsInBody: bodyEntries.length,
		fragmentsInTriage: triageEntries.length,
		fragmentsFiltered: filteredCount,
		clearedUnreleased,
	}
}

/**
 * Render the per-tag compiled body.
 *
 * Layout:
 *   # Release <tag>
 *
 *   (per-tier sections — primary first, then secondary, then tertiary
 *    when filter==='all'. Each section's entries sorted by PR number.)
 *
 *   ## Needs Triage   (only when triage[] non-empty)
 *
 * @param {{
 *   tag: string,
 *   body: Array<{ pr: string, date: string, persona: string, note: string, tier: 'primary' | 'secondary' | 'tertiary' }>,
 *   triage: Array<{ pr: string, date: string, persona: string, note: string, reason: string }>,
 *   filter: 'primary' | 'primary+secondary' | 'all',
 * }} input
 * @returns {string}
 */
function renderCompiled(input) {
	const lines = []
	lines.push(`# Release ${input.tag}`)
	lines.push('')
	lines.push(`<!-- persona_filter: ${input.filter} -->`)
	lines.push('')

	const tiersInOrder = /** @type {const} */ (['primary', 'secondary', 'tertiary'])
	for (const tier of tiersInOrder) {
		const rows = input.body.filter((e) => e.tier === tier)
		if (rows.length === 0) continue
		lines.push(`## ${capitalize(tier)} personas`)
		lines.push('')
		for (const row of rows) {
			lines.push(`- **PR #${row.pr}** (${row.persona}): ${row.note}`)
		}
		lines.push('')
	}

	if (input.triage.length > 0) {
		lines.push('## Needs Triage')
		lines.push('')
		lines.push('_These fragments lack a recognized persona tag. Curate before publishing._')
		lines.push('')
		for (const row of input.triage) {
			const personaLabel = row.persona && row.persona !== '(none)' ? row.persona : '(no tag)'
			lines.push(`- **PR #${row.pr}** (${personaLabel}, ${row.reason}): ${row.note}`)
		}
		lines.push('')
	}

	if (input.body.length === 0 && input.triage.length === 0) {
		lines.push('_No fragments to compile._')
		lines.push('')
	}

	return lines.join('\n')
}

/**
 * @param {string} s
 * @returns {string}
 */
function capitalize(s) {
	return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1)
}
