/**
 * install/manifest.mjs — read/write/validate `apt-file-manifest.json`.
 *
 * One manifest is written per runtime install root (e.g.
 * `.claude/apt-file-manifest.json`, `.cursor/apt-file-manifest.json`). The
 * manifest is the authoritative record of what the installer wrote for that
 * runtime — `check-version` walks it to detect drift.
 *
 * Schema:
 *   {
 *     version: 1,                       // manifest schema version
 *     installed_at: ISO8601,
 *     runtime: 'claude' | 'cursor' | ...,
 *     framework_version: '0.1.0',
 *     files: [
 *       {
 *         path: 'skills/apt-foo/SKILL.md',   // relative to install root
 *         sourceHash: '<sha256 of canonical pre-transform>',
 *         installedHash: '<sha256 of post-transform output>',
 *         sourcePath: 'packages/framework/skills/apt-foo/SKILL.md',
 *         transformVersion: '1'
 *       }
 *     ]
 *   }
 */

import { createHash } from 'node:crypto'
import { existsSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { atomicWriteJson } from '../util/atomic-write.mjs'

export const MANIFEST_FILENAME = 'apt-file-manifest.json'
export const MANIFEST_SCHEMA_VERSION = 1

/**
 * @typedef {Object} ManifestFileEntry
 * @property {string} path
 * @property {string} sourceHash
 * @property {string} installedHash
 * @property {string} sourcePath
 * @property {string} transformVersion
 * @property {boolean} [headerless]  true when the installed file legitimately
 *   carries no apt-skill-version/apt-hook-version header (e.g. Codex strips it).
 *   Drift detection skips the missing_header check for these — installedHash is
 *   the sole drift signal. Absent (undefined) on pre-0.8.6 manifests → treated
 *   as header-expected, preserving old behavior until re-install.
 */

/**
 * @typedef {Object} Manifest
 * @property {number} version
 * @property {string} installed_at
 * @property {string} runtime
 * @property {string} framework_version
 * @property {ManifestFileEntry[]} files
 */

/**
 * Compute a sha256 hex digest for a string or buffer.
 *
 * @param {string|Buffer} data
 * @returns {string}
 */
export function sha256(data) {
	return createHash('sha256').update(data).digest('hex')
}

/**
 * Build a fresh manifest object. Files start empty — the pipeline appends
 * via `addManifestEntry` as it writes outputs.
 *
 * @param {{ runtime: string, frameworkVersion: string }} args
 * @returns {Manifest}
 */
export function newManifest({ runtime, frameworkVersion }) {
	return {
		version: MANIFEST_SCHEMA_VERSION,
		installed_at: new Date().toISOString(),
		runtime,
		framework_version: frameworkVersion,
		files: [],
	}
}

/**
 * Append a file entry to a manifest. Mutates in place + returns the
 * manifest for chaining.
 *
 * @param {Manifest} manifest
 * @param {ManifestFileEntry} entry
 * @returns {Manifest}
 */
export function addManifestEntry(manifest, entry) {
	manifest.files.push(entry)
	return manifest
}

/**
 * Atomically persist a manifest under `<installRoot>/apt-file-manifest.json`.
 * Reuses the framework's atomicWriteJson (tmp + fsync + rename) so partial
 * writes are not observable.
 *
 * @param {string} installRoot
 * @param {Manifest} manifest
 */
export function writeManifest(installRoot, manifest) {
	atomicWriteJson(join(installRoot, MANIFEST_FILENAME), manifest)
}

/**
 * Load a manifest from disk. Returns null when missing or unreadable
 * (callers decide whether that's an error).
 *
 * @param {string} installRoot
 * @returns {Manifest|null}
 */
export function readManifest(installRoot) {
	const path = join(installRoot, MANIFEST_FILENAME)
	if (!existsSync(path)) return null
	try {
		const data = JSON.parse(readFileSync(path, 'utf-8'))
		if (typeof data !== 'object' || data === null) return null
		if (typeof data.runtime !== 'string') return null
		if (typeof data.framework_version !== 'string') return null
		if (!Array.isArray(data.files)) return null
		return /** @type {Manifest} */ (data)
	} catch {
		return null
	}
}

/**
 * Sanity-check a parsed manifest. Returns an array of human-readable
 * error strings; empty array means valid.
 *
 * @param {unknown} m
 * @returns {string[]}
 */
export function validateManifest(m) {
	const errors = []
	if (typeof m !== 'object' || m === null) {
		return ['manifest is not an object']
	}
	const obj = /** @type {Record<string, unknown>} */ (m)
	if (typeof obj.version !== 'number') errors.push('missing or invalid `version`')
	if (typeof obj.runtime !== 'string') errors.push('missing or invalid `runtime`')
	if (typeof obj.framework_version !== 'string') errors.push('missing `framework_version`')
	if (typeof obj.installed_at !== 'string') errors.push('missing `installed_at`')
	if (!Array.isArray(obj.files)) {
		errors.push('missing `files` array')
		return errors
	}
	for (const [i, f] of obj.files.entries()) {
		if (typeof f !== 'object' || f === null) {
			errors.push(`files[${i}] is not an object`)
			continue
		}
		const fe = /** @type {Record<string, unknown>} */ (f)
		for (const k of ['path', 'sourceHash', 'installedHash', 'sourcePath', 'transformVersion']) {
			if (typeof fe[k] !== 'string') errors.push(`files[${i}].${k} missing or not string`)
		}
	}
	return errors
}
