/**
 * schema-migrate.mjs — detect drift between an existing
 * `.aperant/config.json` and the shipped `templates/config.json`, and
 * plan the merge. Pure function; no disk I/O.
 *
 * Per spec R3, the helper walks the template's leaf paths and buckets
 * every absent-or-stale key into one of three lists:
 *
 *   - `applied_defaults` — Class B keys + auto_added internal keys that
 *     can be filled silently. (We split internal keys into a separate
 *     `auto_added` list because R3 calls them out as informational.)
 *   - `requires_explicit` — Class A keys that demand a user decision in
 *     non-TTY OR a clack prompt in upgrade-TTY. Includes keys whose
 *     existing value is invalid under the new schema (e.g. an enum
 *     dropped an option).
 *   - `auto_added` — template-internal additions (e.g.
 *     risk_map.elevated_paths growing) that aren't classified.
 *
 * `migrated.version` only bumps to template.version when
 * `requires_explicit` is empty — otherwise the user still owes us a
 * decision and bumping the version would lose the "needs review" signal.
 *
 * Mirrors the upgrade-gitignore.mjs split: data-only function here,
 * I/O lives in the caller (commands/init.mjs).
 */

import {
	CLASS_A_SETTINGS,
	CLASS_B_SETTINGS,
	findClassDescriptor,
} from '../install/setting-class.mjs'

/**
 * @typedef {Object} MigrationResult
 * @property {Record<string, unknown>} migrated   New config object (existing + filled defaults).
 * @property {Array<{ key: string, value: unknown, why: string, source?: string }>} applied_defaults
 * @property {Array<{ key: string, why: string, options?: unknown[]|null, default?: unknown }>} requires_explicit
 * @property {Array<{ key: string, value: unknown }>} auto_added
 */

/**
 * Read a dot-path from an object. Returns `undefined` when any segment
 * is absent. Treats arrays as opaque leaves (we don't index into them).
 */
function getAt(obj, path) {
	if (!obj || typeof obj !== 'object') return undefined
	const parts = path.split('.')
	let cur = obj
	for (const p of parts) {
		if (cur === null || cur === undefined || typeof cur !== 'object') return undefined
		cur = cur[p]
	}
	return cur
}

/**
 * Set a dot-path on an object, creating intermediate plain objects as
 * needed. Mutates `obj` in place.
 *
 * Defends against prototype-pollution by rejecting `__proto__`,
 * `constructor`, and `prototype` segments. Although every current caller
 * passes paths derived from the trusted template / Class A descriptors,
 * the guard is cheap and survives future callers.
 *
 * Exported (in addition to internal use) so init.mjs can write descriptor
 * defaults into a `MigrationResult.migrated` payload via the same helper
 * — keeps the dot-path semantics in one place.
 *
 * @param {Record<string, unknown>} obj
 * @param {string} path Dot-notation key (e.g. `share.visibility`).
 * @param {unknown} value
 */
export function setAt(obj, path, value) {
	const parts = path.split('.')
	for (const seg of parts) {
		if (seg === '__proto__' || seg === 'constructor' || seg === 'prototype') {
			throw new Error(`setAt: refusing to write reserved key segment "${seg}" in path "${path}"`)
		}
	}
	let cur = /** @type {Record<string, unknown>} */ (obj)
	for (let i = 0; i < parts.length - 1; i++) {
		const p = parts[i]
		if (cur[p] === null || cur[p] === undefined || typeof cur[p] !== 'object') {
			cur[p] = {}
		}
		cur = /** @type {Record<string, unknown>} */ (cur[p])
	}
	cur[parts[parts.length - 1]] = value
}

/**
 * Walk a template object, yielding `{ path, value }` for every leaf —
 * scalars, arrays, and `null`. Skips `$comment` keys (template-only
 * documentation that never needs migrating).
 */
function* walkLeaves(obj, prefix = '') {
	if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) {
		yield { path: prefix, value: obj }
		return
	}
	for (const [k, v] of Object.entries(obj)) {
		if (k.startsWith('$')) continue // $comment, $trusted_skill_sources_comment, etc.
		const next = prefix === '' ? k : `${prefix}.${k}`
		if (v === null || typeof v !== 'object' || Array.isArray(v)) {
			yield { path: next, value: v }
		} else {
			yield* walkLeaves(v, next)
		}
	}
}

/**
 * Deep-clone `obj` via JSON. Sufficient for config payloads (no
 * functions, no Dates, no circular refs).
 */
function clone(obj) {
	return JSON.parse(JSON.stringify(obj))
}

/**
 * Plan + apply the migration of `existingConfig` toward `templateConfig`.
 *
 * @param {Record<string, unknown>} existingConfig
 * @param {Record<string, unknown>} templateConfig
 * @returns {MigrationResult}
 */
export function migrateConfigSchema(existingConfig, templateConfig) {
	const migrated = clone(existingConfig ?? {})
	/** @type {MigrationResult['applied_defaults']} */
	const applied_defaults = []
	/** @type {MigrationResult['requires_explicit']} */
	const requires_explicit = []
	/** @type {MigrationResult['auto_added']} */
	const auto_added = []

	// (1) Walk template leaves; for each, decide what to do based on
	//     classification + presence in existing.
	for (const { path, value } of walkLeaves(templateConfig)) {
		// Skip the `version` leaf — handled at the end based on
		// requires_explicit.length so we don't promise a clean migrated
		// version when the user still owes us a decision.
		if (path === 'version') continue

		const existingValue = getAt(migrated, path)
		const desc = findClassDescriptor(path)

		if (existingValue === undefined) {
			if (!desc) {
				// Template-internal addition — not classified. Apply silently.
				setAt(migrated, path, value)
				auto_added.push({ key: path, value })
				continue
			}
			if (CLASS_A_SETTINGS.includes(desc)) {
				requires_explicit.push({
					key: desc.shortKey,
					why: desc.why,
					options: desc.options,
					default: desc.default,
				})
				continue
			}
			if (CLASS_B_SETTINGS.includes(desc)) {
				setAt(migrated, path, value)
				applied_defaults.push({
					key: desc.shortKey,
					value,
					why: desc.why,
				})
			}
		} else {
			// Present in existing — only flag invalid values for classified keys.
			// Unclassified keys are left alone (user may have customized them).
			if (desc && desc.classify(existingValue) === 'invalid') {
				if (CLASS_A_SETTINGS.includes(desc)) {
					requires_explicit.push({
						key: desc.shortKey,
						why: 'value invalid under new schema',
						options: desc.options,
						default: desc.default,
					})
				} else {
					// Class B with an invalid value — overwrite with template default
					// to keep the install moving (Class B is reversible).
					setAt(migrated, path, value)
					applied_defaults.push({
						key: desc.shortKey,
						value,
						why: 'value invalid under new schema — reset to template default',
					})
				}
			}
		}
	}

	// (2) Bump version only when nothing is blocked. The user's existing
	//     config-with-blockers stays at its old version so a follow-up run
	//     still detects it as "needs migration".
	if (requires_explicit.length === 0) {
		const templateVersion = templateConfig?.version
		if (templateVersion !== undefined) {
			migrated.version = templateVersion
		}
	}

	return { migrated, applied_defaults, requires_explicit, auto_added }
}
