/**
 * commands/check-version.mjs — per-file drift detection (multi-runtime
 * rewrite, 2026-04). Replaces the old single-string `.apt-version` compare
 * with a per-runtime walker that compares each installed file's hash AND
 * declared `apt-skill-version` / `apt-hook-version` header against the
 * runtime's manifest.
 *
 * Output schema:
 *   {
 *     status: 'ok' | 'stale',
 *     command: 'check-version',
 *     framework_version: '0.1.0',
 *     runtimes: [{
 *       runtime: 'claude',
 *       installed_version: '0.1.0',
 *       stale_files: [{ path, reason }],
 *       update_available: false
 *     }],
 *     legacy_install: false,  // true when only the old .apt-version exists
 *     // === 0.6.7 cache-merge fields (Bug 1 fix) ===
 *     // Merged from the SessionStart worker's cache at
 *     // ~/.cache/aperant/apt-update-check.json when fresh (< 1 h,
 *     // schema_version=1). When stale, missing, or malformed, the same
 *     // field names are present with null/false/0 sentinel values so
 *     // the skill's parser stays single-shape.
 *     //
 *     // Note: framework.installed_version is intentionally absent.
 *     // The live kernel version is always in the top-level
 *     // `framework_version` field (read from package.json, never from
 *     // the cache).
 *     framework: {
 *       latest_version: '0.6.7' | null,
 *       // Bundle A (0.8.3) — additive nullable field. Set when a workspace
 *       // `packages/framework/package.json` is found above the project root
 *       // and its `name === '@aperant/framework'`. When workspace_version
 *       // is ahead of the running kernel, `update_available` flips true
 *       // regardless of cache state (workspace wins on self-host).
 *       workspace_version: '0.8.3' | null
 *     },
 *     update_available: false,
 *     dev_install_ahead: false,
 *     not_published: false,
 *     stale_files_total: 0,
 *     lookup_error: null
 *   }
 *
 * Per-file `reason` values:
 *   - 'header_mismatch'  — manifest version != installed file's apt-skill-version header
 *   - 'hash_mismatch'    — manifest installedHash != actual sha256(file bytes)
 *   - 'missing_header'   — installed file no longer carries an apt-skill-version line
 *   - 'missing_file'     — manifest entry references a file that no longer exists
 */

import { existsSync, readFileSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { findWorkspaceFrameworkVersion } from '../install/find-workspace-framework-version.mjs'
import { readManifest } from '../install/manifest.mjs'
import { detectInstalledRuntimes } from '../install/runtime-detect.mjs'
import { readFreshUpdateCache } from '../install/update-chips.mjs'
import { inspectManifestFiles } from '../install/version-header.mjs'
import { err, ok } from '../util/result.mjs'
import { compareVersions } from '../util/semver.mjs'

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

/**
 * Inspect a single runtime's install root and produce a per-file
 * stale_files report. Delegates the per-file walk to the shared
 * `inspectManifestFiles` helper so worker and check-version stay in
 * lock-step (path-traversal guard, header/hash precedence, mtime
 * short-circuit semantics all live in one place).
 *
 * @param {string} targetDir
 * @param {import('../install/runtime-detect.mjs').RuntimeDescriptor} descriptor
 * @returns {{ runtime: string, installed_version: string|null, stale_files: Array<{path: string, reason: string}>, update_available: boolean }}
 */
function inspectRuntime(targetDir, descriptor) {
	const installRoot = join(targetDir, descriptor.installRoot)
	const manifest = readManifest(installRoot)
	if (!manifest) {
		return {
			runtime: descriptor.id,
			installed_version: null,
			stale_files: [],
			// 0.6.7 — per-runtime `update_available` is retained as `false` for
			// backward shape compatibility with pre-0.6.7 callers. The real
			// npm-bump signal now lives on the TOP-LEVEL `update_available`
			// field (merged from the SessionStart worker's cache below).
			update_available: false,
		}
	}
	// `check-version` is invoked manually by the user and should always do
	// the full per-file check (no mtime short-circuit) — any drift since the
	// last install is what we're explicitly asked to surface.
	const stale = inspectManifestFiles(manifest, installRoot)
	return {
		runtime: descriptor.id,
		installed_version: manifest.framework_version,
		stale_files: stale,
		// See note above — top-level `update_available` carries the real signal.
		update_available: false,
	}
}

/** Cache freshness window — 1 hour, matches the worker's CACHE_TTL_MS. */
const CACHE_TTL_MS = 60 * 60 * 1_000

/**
 * @param {string} projectDir
 */
export function cmdCheckVersion(projectDir) {
	if (!projectDir) return err('Usage: apt-tools check-version <project-dir>')
	const targetDir = resolve(projectDir)
	const pkgPath = resolve(__dirname, '..', '..', '..', 'package.json')
	const frameworkVersion = JSON.parse(readFileSync(pkgPath, 'utf-8')).version

	const installed = detectInstalledRuntimes(targetDir)
	const runtimes = installed.map((d) => inspectRuntime(targetDir, d))

	// Backward-compat shim: the old installer wrote
	// `.claude/skills/.apt-version`; its presence WITHOUT a manifest means
	// the user is on the legacy install scheme and should re-run init.
	const legacyVersionFile = join(targetDir, '.claude', 'skills', '.apt-version')
	const legacyInstall = runtimes.length === 0 && existsSync(legacyVersionFile)

	const totalStale = runtimes.reduce((acc, r) => acc + r.stale_files.length, 0)
	const status = totalStale === 0 ? 'ok' : 'stale'
	const messageParts = []
	if (legacyInstall) {
		messageParts.push(
			'Legacy single-string install detected (.apt-version without manifest). Re-run `apt-tools init <dir>` to migrate.',
		)
	}
	if (runtimes.length === 0 && !legacyInstall) {
		messageParts.push('No apt-installed runtimes detected at this project root.')
	}
	if (totalStale > 0) {
		messageParts.push(`${totalStale} stale file(s) across ${runtimes.length} runtime(s).`)
	} else if (runtimes.length > 0) {
		messageParts.push(`Skills are up to date for ${runtimes.length} runtime(s).`)
	}

	// 0.6.7 (Bug 1 fix) — merge the SessionStart worker's cache fields when
	// the cache file is fresh (< 1 h, schema_version=1). On a stale / missing
	// / malformed cache the same field names are emitted with null/false/0
	// sentinel values so the skill's parser stays single-shape (ID-03).
	//
	// LOG-001: stale_files_total is always derived from the live per-runtime
	// walk (totalStale). The cache value is intentionally NOT merged — it is
	// up to 1 h old and would hide drift that occurred since the last worker
	// run.
	//
	// LOG-002: installed_version always comes from the kernel's own
	// package.json (frameworkVersion). The cache's installed_version is
	// stale by definition and is NOT merged into the envelope. Consumers
	// that need the live kernel version use the top-level `framework_version`
	// field, which is identical.
	const cacheResult = readFreshUpdateCache(CACHE_TTL_MS)
	let latestVersion = null
	let updateAvailable = false
	let devInstallAhead = false
	let notPublished = false
	let lookupError = null
	if (cacheResult.fresh) {
		const c = cacheResult.cache
		const fw = c.framework && typeof c.framework === 'object' ? c.framework : {}
		// latest_version is semver-validated inside readFreshUpdateCache (SEC-001).
		if (typeof fw.latest_version === 'string') latestVersion = fw.latest_version
		if (typeof c.update_available === 'boolean') updateAvailable = c.update_available
		if (typeof c.dev_install_ahead === 'boolean') devInstallAhead = c.dev_install_ahead
		if (typeof c.not_published === 'boolean') notPublished = c.not_published
		// stale_files_total intentionally NOT taken from cache (LOG-001).
		if (typeof c.lookup_error === 'string') lookupError = c.lookup_error
	}

	// Bundle A — additive `framework.workspace_version` field. When the
	// workspace package.json is ahead of the running kernel, flip
	// `update_available` true regardless of cache state (the workspace is
	// the source of truth on self-host). schema_version stays at 1 (D-01).
	const workspace = findWorkspaceFrameworkVersion(targetDir)
	const workspaceVersion = workspace?.source_version ?? null
	if (workspaceVersion && compareVersions(workspaceVersion, frameworkVersion) > 0) {
		updateAvailable = true
	}

	return ok({
		status,
		command: 'check-version',
		framework_version: frameworkVersion,
		runtimes,
		legacy_install: legacyInstall,
		message: messageParts.join(' '),
		// Cache-merge fields (additive, nullable). Documented in the
		// file-header schema comment.
		framework: {
			latest_version: latestVersion,
			workspace_version: workspaceVersion,
		},
		update_available: updateAvailable,
		dev_install_ahead: devInstallAhead,
		not_published: notPublished,
		// stale_files_total: always the live sum from per-runtime walks (LOG-001).
		stale_files_total: totalStale,
		lookup_error: lookupError,
	})
}
