/**
 * install/claude-hooks.mjs — generic Claude Code hook merger.
 *
 * Extracted from precompact-hook.mjs so PreCompact and SessionStart (and
 * any future events) can share the same merge logic. The merge invariants:
 *
 *   - Read .claude/settings.local.json if it exists; if it's malformed,
 *     return 'skipped (settings.local.json is not valid JSON)' without
 *     clobbering user state.
 *   - settings.hooks[event] is an array of { matcher?, hooks: [...] } groups.
 *   - We identify our entry by a substring match against the command (e.g.
 *     'apt-tools.mjs precompact-hook' or 'apt-check-update.js') so stale
 *     installs get upgraded in place rather than duplicated.
 *   - Returns one of: 'installed' | 'updated' | 'already present' | 'skipped (...)'.
 */

import { existsSync, lstatSync, mkdirSync, readFileSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { atomicWriteText } from '../util/atomic-write.mjs'

/**
 * @typedef {Object} HookEntry
 * @property {string} type     usually 'command'
 * @property {string} command  the full shell command Claude Code will run
 */

/**
 * @typedef {Object} HookGroup
 * @property {string} matcher
 * @property {HookEntry[]} hooks
 */

/**
 * Merge a hook entry under settings.hooks[event] in
 * .claude/settings.local.json. Returns a status string.
 *
 * @param {Object} args
 * @param {string} args.projectDir
 * @param {string} args.event           e.g. 'PreCompact', 'SessionStart'
 * @param {string} args.command         full shell command
 * @param {string} args.identitySubstring  substring used to find our existing entry
 * @param {string} [args.matcher]       defaults to '*' (matches all triggers)
 * @returns {'installed'|'updated'|'already present'|string}
 */
export function mergeClaudeHook({ projectDir, event, command, identitySubstring, matcher = '*' }) {
	const claudeDir = join(projectDir, '.claude')
	const settingsPath = join(claudeDir, 'settings.local.json')

	mkdirSync(claudeDir, { recursive: true })

	let settings = /** @type {Record<string, unknown>} */ ({})
	if (existsSync(settingsPath)) {
		try {
			settings = JSON.parse(readFileSync(settingsPath, 'utf-8'))
		} catch {
			return 'skipped (settings.local.json is not valid JSON)'
		}
	}

	if (!settings.hooks || typeof settings.hooks !== 'object') {
		settings.hooks = {}
	}
	const hooks = /** @type {Record<string, unknown>} */ (settings.hooks)

	const desiredEntry = /** @type {HookGroup} */ ({
		matcher,
		hooks: [{ type: 'command', command }],
	})
	const existing = Array.isArray(hooks[event]) ? /** @type {HookGroup[]} */ (hooks[event]) : []

	const isOurs = (group) =>
		Array.isArray(group?.hooks) &&
		group.hooks.some((h) => typeof h?.command === 'string' && h.command.includes(identitySubstring))

	const idx = existing.findIndex(isOurs)
	let status
	if (idx === -1) {
		existing.push(desiredEntry)
		status = 'installed'
	} else {
		const prev = JSON.stringify(existing[idx])
		existing[idx] = desiredEntry
		status = prev === JSON.stringify(desiredEntry) ? 'already present' : 'updated'
	}

	hooks[event] = existing

	mkdirSync(dirname(settingsPath), { recursive: true })

	// Symlink guard: refuse to follow a symlink at settingsPath to prevent
	// overwriting arbitrary files via a hostile symlink planted at that path.
	if (existsSync(settingsPath)) {
		const stat = lstatSync(settingsPath)
		if (stat.isSymbolicLink()) {
			return 'skipped (settings.local.json is a symlink — refusing to follow)'
		}
	}

	atomicWriteText(settingsPath, JSON.stringify(settings, null, 2) + '\n')

	return status
}
