/**
 * bootstrap/recon.mjs — deterministic repo recon for apt:bootstrap (C47).
 *
 * Reads a FIXED allowlist (D-02) of well-known repo-root signals and returns a
 * structured object. No LLM calls, no network, no filesystem discovery beyond
 * the allowlist. Graceful on missing files — each field is independently
 * nullable so the SKILL.md body can feed whatever is present into the LLM.
 *
 * Allowlist (D-02):
 *   - README.md (first 8KB)
 *   - CLAUDE.md (first 8KB)
 *   - AGENTS.md (first 8KB)
 *   - package.json — ONLY description / keywords / repository / name
 *   - top-level folder names, one level deep (skip hidden + ignored)
 *   - git remote url (origin)
 *   - first commit message (oldest)
 *
 * The "first 8KB" cap on markdown reads is intentional — recon is a signal
 * extractor, not a documentation parser. Anything past 8KB of prose rarely
 * adds inference signal and growing the byte cap trades latency for noise.
 */

import { execFileSync } from 'node:child_process'
import { closeSync, existsSync, openSync, readdirSync, readFileSync, readSync } from 'node:fs'
import { join, resolve } from 'node:path'

const READ_CAP_BYTES = 8 * 1024

const IGNORE_DIRS = new Set([
	'node_modules',
	'.git',
	'.aperant',
	'dist',
	'build',
	'.next',
	'.turbo',
	'.vercel',
	'.cache',
	'target',
	'vendor',
	'__pycache__',
	'coverage',
])

/**
 * @typedef {Object} ReconPackageJson
 * @property {string | null} name
 * @property {string | null} description
 * @property {string[]} keywords
 * @property {string | null} repository  Normalized URL (.url if object form) or raw string.
 */

/**
 * @typedef {Object} ReconMarkdown
 * @property {string} path
 * @property {boolean} present
 * @property {string | null} body
 * @property {boolean} truncated
 */

/**
 * @typedef {Object} ReconResult
 * @property {string} projectDir              Absolute resolved path.
 * @property {ReconMarkdown} readme
 * @property {ReconMarkdown} claudeMd
 * @property {ReconMarkdown} agentsMd
 * @property {{ path: string, present: boolean, data: ReconPackageJson | null }} packageJson
 * @property {string[]} topLevelFolders
 * @property {string | null} gitRemoteUrl     origin URL if available.
 * @property {string | null} firstCommitMessage  Subject of the oldest commit, or null.
 * @property {string[]} notes                 Any non-fatal diagnostics (e.g., "git missing").
 */

/**
 * Read a text file capped at READ_CAP_BYTES. Returns { body, truncated } or
 * null on any failure. We use openSync + readSync (not readFileSync with a
 * buffer slice) so we can correctly flag truncation — a file might be shorter
 * than the cap, longer, or exactly the cap.
 *
 * @param {string} path
 * @returns {{ body: string, truncated: boolean } | null}
 */
function readCapped(path) {
	let fd = null
	try {
		fd = openSync(path, 'r')
		const buf = Buffer.alloc(READ_CAP_BYTES + 1)
		const bytesRead = readSync(fd, buf, 0, buf.length, 0)
		const truncated = bytesRead > READ_CAP_BYTES
		const slice = buf.subarray(0, Math.min(bytesRead, READ_CAP_BYTES))
		return { body: slice.toString('utf-8'), truncated }
	} catch {
		return null
	} finally {
		if (fd !== null) {
			try {
				closeSync(fd)
			} catch {
				/* best-effort */
			}
		}
	}
}

/**
 * Shape a capped-read result into the recon envelope's markdown shape.
 *
 * @param {string} path
 */
function readMarkdown(path) {
	if (!existsSync(path)) {
		return { path, present: false, body: null, truncated: false }
	}
	const res = readCapped(path)
	if (res === null) {
		return { path, present: true, body: null, truncated: false }
	}
	return { path, present: true, body: res.body, truncated: res.truncated }
}

/**
 * Extract the D-02-allowlisted fields from package.json. Tolerant of either
 * the string or object form of `repository`. Unknown keys are ignored.
 *
 * @param {string} path
 * @returns {ReconPackageJson | null}
 */
function readPackageJson(path) {
	if (!existsSync(path)) return null
	let parsed
	try {
		parsed = JSON.parse(readFileSync(path, 'utf-8'))
	} catch {
		return null
	}
	if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
		return null
	}
	let repository = null
	if (typeof parsed.repository === 'string') {
		repository = parsed.repository
	} else if (
		parsed.repository !== null &&
		typeof parsed.repository === 'object' &&
		typeof parsed.repository.url === 'string'
	) {
		repository = parsed.repository.url
	}
	return {
		name: typeof parsed.name === 'string' ? parsed.name : null,
		description: typeof parsed.description === 'string' ? parsed.description : null,
		keywords: Array.isArray(parsed.keywords)
			? parsed.keywords.filter((k) => typeof k === 'string')
			: [],
		repository,
	}
}

/**
 * List top-level directories in `projectDir`, skipping hidden + ignored. One
 * level deep — D-02 explicitly forbids recursive discovery here.
 *
 * @param {string} projectDir
 * @returns {string[]}
 */
function listTopLevelFolders(projectDir) {
	try {
		return readdirSync(projectDir, { withFileTypes: true })
			.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !IGNORE_DIRS.has(e.name))
			.map((e) => e.name)
			.sort()
	} catch {
		return []
	}
}

/**
 * Best-effort git remote URL. Prefers `origin`; returns null on any failure.
 *
 * @param {string} projectDir
 * @returns {string | null}
 */
function readGitRemoteUrl(projectDir) {
	try {
		const url = execFileSync('git', ['config', '--get', 'remote.origin.url'], {
			cwd: projectDir,
			encoding: 'utf-8',
			stdio: ['ignore', 'pipe', 'ignore'],
		}).trim()
		return url.length > 0 ? url : null
	} catch {
		return null
	}
}

/**
 * Best-effort first (oldest) commit subject. Returns null on any failure,
 * including repos with zero commits.
 *
 * @param {string} projectDir
 * @returns {string | null}
 */
function readFirstCommitMessage(projectDir) {
	try {
		const subject = execFileSync('git', ['log', '--reverse', '--format=%s', '-1'], {
			cwd: projectDir,
			encoding: 'utf-8',
			stdio: ['ignore', 'pipe', 'ignore'],
		}).trim()
		return subject.length > 0 ? subject : null
	} catch {
		return null
	}
}

/**
 * Run the deterministic recon pass. Safe to call on any directory — missing
 * files produce `present: false` markers rather than throwing.
 *
 * @param {string} projectDir
 * @returns {ReconResult}
 */
export function runRecon(projectDir) {
	const resolved = resolve(projectDir)
	const notes = []

	const readme = readMarkdown(join(resolved, 'README.md'))
	const claudeMd = readMarkdown(join(resolved, 'CLAUDE.md'))
	const agentsMd = readMarkdown(join(resolved, 'AGENTS.md'))

	const pkgPath = join(resolved, 'package.json')
	const packageJson = {
		path: pkgPath,
		present: existsSync(pkgPath),
		data: readPackageJson(pkgPath),
	}

	const topLevelFolders = listTopLevelFolders(resolved)
	const gitRemoteUrl = readGitRemoteUrl(resolved)
	const firstCommitMessage = readFirstCommitMessage(resolved)

	if (!existsSync(join(resolved, '.git'))) {
		notes.push('no .git directory — git-derived signals skipped')
	}

	return {
		projectDir: resolved,
		readme,
		claudeMd,
		agentsMd,
		packageJson,
		topLevelFolders,
		gitRemoteUrl,
		firstCommitMessage,
		notes,
	}
}

export { READ_CAP_BYTES }
