/**
 * bootstrap/product-context-sidecar.mjs — PROJECT.md → product-context.json
 * derivation for apt:bootstrap (D-01).
 *
 * PROJECT.md is the single source of truth (prose). product-context.json is a
 * DERIVED sidecar, regenerated from PROJECT.md on every write. The flow is
 * strictly one-way here — this module never reads product-context.json and
 * never edits PROJECT.md.
 *
 * The parser is shape-aware, not prose-aware: it splits on PROJECT.md's
 * D-06 fixed headings and captures the body of each section as free-form
 * text. The only structured extraction is the Features sub-list under "Scope"
 * (when present) — PROJECT.md may list in/out scope items as bullets, and
 * we split those into scopeIn[] / scopeOut[] arrays so downstream consumers
 * (C49 apt:create-docs) can iterate them directly.
 *
 * Confidence markers (D-09) — HTML comments of the form
 *   `<!-- confidence: medium | evidence: README.md:1-20 -->`
 * — are stripped from the prose bodies but preserved on a per-section
 * basis inside the sidecar's `_confidence` block, so downstream tooling
 * can reason about freshness without re-parsing the markdown.
 *
 * Output shape is additive-compatible with
 * packages/framework/templates/product-context.schema.json (C44) — every
 * required key is present. Optional enrichments (scopeIn/scopeOut/context/
 * constraints/keyDecisions/personasRef/_confidence) are additive and pass
 * the schema's `additionalProperties: true` on the root.
 *
 * No LLM calls. Deterministic, testable.
 */

/** The seven canonical sections (D-06). Ordered. */
export const CANONICAL_SECTIONS = [
	'What This Is',
	'Core Value',
	'Scope',
	'Context',
	'Personas',
	'Constraints',
	'Key Decisions',
]

const CONFIDENCE_COMMENT_RE =
	/<!--\s*confidence:\s*([^|]+?)\s*(?:\|\s*evidence:\s*([^>]+?)\s*)?-->/i
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g
// Separator class is `[:—]` (colon + em-dash only) — do NOT include plain
// ASCII `-` here. A trailing `-` in the class would eagerly consume the
// newline + next bullet's leading `-`, dropping the first scope item.
const IN_SCOPE_RE = /^#{2,4}[ \t]*In(?:[ \t]*[:—]|[ \t]*$)/im
const OUT_SCOPE_RE = /^#{2,4}[ \t]*Out(?:[ \t]*[:—]|[ \t]*$)/im

/**
 * @typedef {Object} ConfidenceMarker
 * @property {'high' | 'medium' | 'low' | string} level   (normalized lowercase; unknown passes through)
 * @property {string | null} evidence
 */

/**
 * @typedef {Object} ParsedSection
 * @property {string} title     Canonical title from CANONICAL_SECTIONS.
 * @property {string} body      Prose with HTML comments stripped.
 * @property {ConfidenceMarker | null} confidence   First confidence comment found in the section.
 */

/**
 * @typedef {Object} SidecarResult
 * @property {string} productName
 * @property {string} productDescription
 * @property {string} targetAudience
 * @property {Array<{ id: string, name: string, description?: string }>} features
 * @property {string[]} scopeIn
 * @property {string[]} scopeOut
 * @property {string} context
 * @property {string[]} constraints
 * @property {string[]} keyDecisions
 * @property {string | null} personasRef   e.g., ".aperant/personas.json"
 * @property {Record<string, ConfidenceMarker>} _confidence  keyed by section title slug
 * @property {{ source: 'PROJECT.md', updatedAt: string }} _meta
 */

/**
 * Extract the title text and body of every ## section in a PROJECT.md body.
 * Only titles that case-insensitively match a CANONICAL_SECTIONS entry are
 * retained — stray sections are dropped (D-06's kill criteria speaks to
 * future custom-section allowlisting; today we just ignore them).
 *
 * @param {string} markdown
 * @returns {ParsedSection[]}
 */
function splitSections(markdown) {
	const lines = markdown.split(/\r?\n/)
	/** @type {ParsedSection[]} */
	const sections = []
	let current = null

	/** Push the in-flight section if it matches a canonical title. */
	const flush = () => {
		if (!current) return
		const canonical = matchCanonical(current.rawTitle)
		if (canonical !== null) {
			const rawBody = current.bodyLines.join('\n').trim()
			const confidence = extractConfidence(rawBody)
			const cleanBody = rawBody
				.replace(HTML_COMMENT_RE, '')
				.replace(/\n{3,}/g, '\n\n')
				.trim()
			sections.push({ title: canonical, body: cleanBody, confidence })
		}
	}

	for (const line of lines) {
		const m = /^##\s+(.+?)\s*$/.exec(line)
		if (m) {
			flush()
			current = { rawTitle: m[1], bodyLines: [] }
			continue
		}
		if (current) {
			current.bodyLines.push(line)
		}
	}
	flush()

	return sections
}

/**
 * @param {string} raw
 * @returns {string | null}
 */
function matchCanonical(raw) {
	const norm = raw.trim().toLowerCase()
	for (const title of CANONICAL_SECTIONS) {
		const canonNorm = title.toLowerCase()
		if (norm === canonNorm) return title
		// Allow "Scope (In/Out)" variants to match "Scope".
		if (title === 'Scope' && norm.startsWith('scope')) return 'Scope'
	}
	return null
}

/**
 * @param {string} body
 * @returns {ConfidenceMarker | null}
 */
function extractConfidence(body) {
	const m = CONFIDENCE_COMMENT_RE.exec(body)
	if (!m) return null
	const level = m[1].trim().toLowerCase()
	const evidence = m[2] ? m[2].trim() : null
	return { level, evidence }
}

/**
 * @param {string} title
 * @returns {string}
 */
function slugifyTitle(title) {
	return title
		.toLowerCase()
		.replace(/[^a-z0-9]+/g, '_')
		.replace(/^_|_$/g, '')
}

/**
 * Pull markdown bullets out of a section body. Matches `- `, `* `, and
 * numbered `1. ` styles. Blank lines end a list; nested items are flattened.
 *
 * @param {string} body
 * @returns {string[]}
 */
function extractBullets(body) {
	/** @type {string[]} */
	const bullets = []
	for (const raw of body.split(/\r?\n/)) {
		const m = /^\s*(?:[-*]|\d+\.)\s+(.+?)\s*$/.exec(raw)
		if (m) {
			const text = m[1].trim()
			if (text.length > 0) bullets.push(text)
		}
	}
	return bullets
}

/**
 * Split a Scope section into scopeIn / scopeOut arrays. The template admits
 * either a single bullet list with `In:` / `Out:` prefixes or two sub-headings
 * (### In / ### Out). This handler covers both.
 *
 * @param {string} body
 * @returns {{ scopeIn: string[], scopeOut: string[] }}
 */
function splitScope(body) {
	const inMatch = body.match(IN_SCOPE_RE)
	const outMatch = body.match(OUT_SCOPE_RE)
	if (inMatch && outMatch) {
		const inIdx = inMatch.index ?? 0
		const outIdx = outMatch.index ?? 0
		let inBody = ''
		let outBody = ''
		if (inIdx < outIdx) {
			inBody = body.slice(inIdx + inMatch[0].length, outIdx)
			outBody = body.slice(outIdx + outMatch[0].length)
		} else {
			outBody = body.slice(outIdx + outMatch[0].length, inIdx)
			inBody = body.slice(inIdx + inMatch[0].length)
		}
		return { scopeIn: extractBullets(inBody), scopeOut: extractBullets(outBody) }
	}

	// Flat list with inline prefix ("- In: foo" / "- Out: bar").
	/** @type {string[]} */
	const scopeIn = []
	/** @type {string[]} */
	const scopeOut = []
	for (const bullet of extractBullets(body)) {
		const inPrefix = /^in\s*[:—-]\s*(.+)$/i.exec(bullet)
		const outPrefix = /^out\s*[:—-]\s*(.+)$/i.exec(bullet)
		if (inPrefix) scopeIn.push(inPrefix[1].trim())
		else if (outPrefix) scopeOut.push(outPrefix[1].trim())
		else scopeIn.push(bullet)
	}
	return { scopeIn, scopeOut }
}

/**
 * Derive a stable feature id from a human-facing name.
 *
 * @param {string} name
 * @param {Set<string>} used   Already-emitted ids to de-duplicate.
 * @returns {string}
 */
function featureIdFrom(name, used) {
	const base =
		name
			.toLowerCase()
			.replace(/[^a-z0-9]+/g, '-')
			.replace(/^-|-$/g, '')
			.slice(0, 40) || 'feature'
	let id = base
	let i = 2
	while (used.has(id)) {
		id = `${base}-${i}`
		i += 1
	}
	used.add(id)
	return id
}

/**
 * Pull a one-line "product name" out of the "What This Is" section. Prefers
 * a leading bold line or a bare first line; falls back to a supplied
 * packageName when PROJECT.md has only prose.
 *
 * @param {string} whatThisIs
 * @param {string | null} packageName
 */
function inferProductName(whatThisIs, packageName) {
	if (!whatThisIs) return packageName || 'Unnamed Project'
	// Leading bold line: **Name** — rest of description.
	const bold = /^\*\*([^*]+)\*\*/.exec(whatThisIs)
	if (bold) return bold[1].trim()
	// Leading "Name: description" pattern.
	const colon = /^([^:\n]{1,80}):/.exec(whatThisIs)
	if (colon) return colon[1].trim()
	// Fall back to first line.
	const firstLine = whatThisIs.split(/\r?\n/)[0].trim()
	if (firstLine.length > 0 && firstLine.length <= 80) return firstLine
	return packageName || 'Unnamed Project'
}

/**
 * Derive product-context.json fields from a parsed PROJECT.md. Returns the
 * full sidecar envelope ready for `atomicWriteJson`.
 *
 * @param {string} markdown
 * @param {{ packageName?: string | null, packageDescription?: string | null, now?: string } | undefined} opts
 * @returns {SidecarResult}
 */
export function deriveSidecar(markdown, opts = {}) {
	const sections = splitSections(markdown)
	/** @type {Record<string, ParsedSection>} */
	const byTitle = {}
	for (const s of sections) byTitle[s.title] = s

	// Warn on stderr for any canonical section that is absent from the parsed
	// PROJECT.md. Missing sections produce empty fields downstream — silently
	// receiving empty strings for productDescription, context, etc. makes it
	// hard for callers to distinguish a blank section from a missing one. We
	// do NOT fail here; the sidecar is still written with empty-string defaults
	// (the schema allows it), but callers and operators deserve to know. (QUA-007)
	const missingSections = CANONICAL_SECTIONS.filter((t) => !(t in byTitle))
	if (missingSections.length > 0) {
		process.stderr.write(
			`[bootstrap/derive-sidecar] WARN: PROJECT.md is missing ${missingSections.length} canonical section(s): ${missingSections.join(', ')}. Corresponding sidecar fields will be empty.\n`,
		)
	}

	const whatThisIsBody = byTitle['What This Is']?.body ?? ''
	const coreValueBody = byTitle['Core Value']?.body ?? ''
	const scopeBody = byTitle['Scope']?.body ?? ''
	const contextBody = byTitle['Context']?.body ?? ''
	const personasBody = byTitle['Personas']?.body ?? ''
	const constraintsBody = byTitle['Constraints']?.body ?? ''
	const keyDecisionsBody = byTitle['Key Decisions']?.body ?? ''

	const productName = inferProductName(whatThisIsBody, opts.packageName ?? null)
	const productDescription =
		coreValueBody.trim() || whatThisIsBody.trim() || opts.packageDescription?.trim() || ''

	// Target audience — heuristic: first non-empty line of Personas that looks
	// like prose (not a ref link). If Personas is empty, fall back to the
	// Context section first paragraph.
	let targetAudience = ''
	for (const line of personasBody.split(/\r?\n/)) {
		const trimmed = line.trim()
		if (trimmed.length === 0) continue
		if (trimmed.startsWith('See ') || trimmed.startsWith('Reference:')) continue
		targetAudience = trimmed
		break
	}
	if (!targetAudience) {
		const firstPara = contextBody.split(/\n\s*\n/)[0]?.trim() ?? ''
		targetAudience = firstPara
	}

	// Features — the template doesn't carry a Features section; pull bullets
	// from scopeIn as the closest canonical "things this product does". If
	// there's no Scope, emit an empty array (schema allows it).
	const { scopeIn, scopeOut } = splitScope(scopeBody)
	const usedIds = new Set()
	const features = scopeIn.map((item) => {
		const name = item.replace(/\s*[—-]\s*.+$/, '').trim() || item
		const description = item.includes('—')
			? item.split('—', 2)[1].trim()
			: item.includes(' - ')
				? item.split(' - ', 2)[1].trim()
				: ''
		return {
			id: featureIdFrom(name, usedIds),
			name,
			...(description ? { description } : {}),
		}
	})

	// Personas reference — look for a `.aperant/personas.json` mention or
	// default to the standard path if the section is non-empty.
	let personasRef = null
	const personasRefMatch = /(\.aperant\/personas\.json)/.exec(personasBody)
	if (personasRefMatch) {
		personasRef = personasRefMatch[1]
	} else if (personasBody.trim().length > 0) {
		personasRef = '.aperant/personas.json'
	}

	const constraints = extractBullets(constraintsBody)
	const keyDecisions = extractBullets(keyDecisionsBody)

	/** @type {Record<string, ConfidenceMarker>} */
	const confidence = {}
	for (const s of sections) {
		if (s.confidence) confidence[slugifyTitle(s.title)] = s.confidence
	}

	const now = opts.now ?? new Date().toISOString()

	return {
		productName,
		productDescription,
		targetAudience,
		features,
		scopeIn,
		scopeOut,
		context: contextBody,
		constraints,
		keyDecisions,
		personasRef,
		_confidence: confidence,
		_meta: { source: 'PROJECT.md', updatedAt: now },
	}
}
