/**
 * @internal
 * gate/cache.mjs — per-task gate result cache with input hashing (C2).
 *
 * Stored at .aperant/tasks/{task-id}/.gate-cache.json as:
 *
 *   {
 *     "<gate_id>": {
 *       "key":    "<sha256 of {gate_id, inputs[{path, mtime_ns, size, content_hash?}]}>",
 *       "verdict": { status, reason, evidence?, inputs? },
 *       "ts":     "<ISO timestamp>"
 *     }
 *   }
 *
 * Input hashing prefers (path, mtime_ns, size) for speed. When mtime is
 * clamped to low resolution (some filesystems return only second precision,
 * so a sub-second edit can be invisible), we fall back to a SHA-256 of the
 * file contents via `content_hash`. The fallback fires when either:
 *   - `APT_GATE_CACHE_CONTENT_HASH=1` is set (test-only full content mode), or
 *   - the underlying platform reports 0 for the fs.Stats.mtimeNs field.
 *
 * Atomic writes via the shared atomicWriteJson helper — no partial state
 * ever visible to a concurrent reader.
 *
 * Force overrides NEVER write cache entries (that contract is enforced in
 * commands/gate.mjs, not here — this module only stores what it's asked to).
 */

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

const CACHE_FILENAME = '.gate-cache.json'

/**
 * Read and return the entire cache map for a task directory. Returns an
 * empty object when the cache file is missing or malformed (the caller
 * treats every miss as a cache miss — no error, no throw).
 *
 * @internal
 */
export function readCache(taskDir) {
	const path = join(taskDir, CACHE_FILENAME)
	if (!existsSync(path)) return {}
	try {
		const parsed = JSON.parse(readFileSync(path, 'utf-8'))
		return typeof parsed === 'object' && parsed !== null ? parsed : {}
	} catch {
		return {}
	}
}

/**
 * Persist a single gate verdict to the per-task cache file. Reads the
 * existing file, splices the new entry in, and atomic-writes back.
 *
 * @internal
 */
export function writeCache(taskDir, gateId, key, verdict) {
	const path = join(taskDir, CACHE_FILENAME)
	const existing = readCache(taskDir)
	existing[gateId] = {
		key,
		verdict,
		ts: new Date().toISOString(),
	}
	atomicWriteJson(path, existing)
}

/**
 * Drop a single gate's entry from the cache (used by explicit invalidations).
 * Silently no-ops when the cache or the entry is missing.
 *
 * @internal
 */
export function invalidateCache(taskDir, gateId) {
	const path = join(taskDir, CACHE_FILENAME)
	const existing = readCache(taskDir)
	if (!(gateId in existing)) return
	delete existing[gateId]
	atomicWriteJson(path, existing)
}

/**
 * Describe an input file in a way that detects both coarse (path/size) and
 * fine-grained (mtime or content-hash) changes. Returns null for paths that
 * do not exist on disk (gates that read non-existent files — e.g. a missing
 * spec.md — should still be cacheable, so we record a sentinel instead of
 * skipping the input).
 */
function describeInput(path) {
	try {
		const st = statSync(path)
		const descriptor = {
			path,
			size: st.size,
			mtime_ns:
				typeof st.mtimeNs === 'bigint'
					? st.mtimeNs.toString()
					: // Round to ns to avoid "1.71e21"-style exponent strings from floating
						// multiplication — the stable integer form stays stable under JSON.
						String(Math.round(st.mtimeMs * 1_000_000)),
		}
		const useContentHash =
			process.env.APT_GATE_CACHE_CONTENT_HASH === '1' ||
			descriptor.mtime_ns === '0' ||
			descriptor.mtime_ns === 'NaN'
		if (useContentHash) {
			try {
				descriptor.content_hash = createHash('sha256').update(readFileSync(path)).digest('hex')
			} catch {
				/* leave mtime_ns as the only signal */
			}
		}
		return descriptor
	} catch {
		return { path, missing: true }
	}
}

/**
 * Compute a deterministic SHA-256 over {gate_id, inputs}. Inputs may be
 * either already-described records `{path, size, mtime_ns, ...}` or raw
 * path strings (gates return raw paths; we describe them here).
 *
 * @internal
 */
export function computeInputHash(gateId, inputs) {
	const described = inputs.map((input) =>
		typeof input === 'string' ? describeInput(input) : input,
	)
	const payload = JSON.stringify({ gate_id: gateId, inputs: described })
	return createHash('sha256').update(payload).digest('hex')
}
