/**
 * commands/health-check.mjs — health-check command (C29).
 */
import { execFileSync } from 'node:child_process';
import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { getSharedLockDir } from '../util/project.mjs';
import { err, ok } from '../util/result.mjs';
import { readSkillInstallState } from '../util/skill-installs.mjs';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
/**
 * SECURITY: validate a component path string from `.aperant/features/*.json`
 * before passing it to git. The registry is on-disk JSON authored by
 * features-audit; pre-fix the orphan walker spliced this string into a
 * shell command (`execSync(\`git rev-parse HEAD:${comp} 2>/dev/null\`)`),
 * making a malicious entry like `foo.ts; rm -rf $HOME` a command-injection
 * vector against any user who ran `apt-tools health-check`. The fix
 * combines `execFileSync` (argv-form, no shell) with a strict path
 * whitelist: relative posix paths only, no NUL / control bytes / shell
 * metacharacters / leading `/` / `..` segments.
 *
 * @internal — exported for unit testing.
 * @param {unknown} value
 * @returns {boolean}
 */
export function isSafeComponentPath(value) {
    if (typeof value !== 'string' || value.length === 0 || value.length > 512)
        return false;
    // Reject NUL, control bytes, and shell metacharacters that could escape
    // argv interpretation if a downstream caller ever reverts to shell mode.
    // biome-ignore lint/suspicious/noControlCharactersInRegex: rejecting control bytes IS the security guard — see isSafeComponentPath docstring + test.
    if (/[\x00-\x1f;&|`$<>"\\]/.test(value))
        return false;
    if (value.startsWith('/'))
        return false;
    if (value.startsWith('-'))
        return false; // would be parsed as a flag
    if (value.split('/').some((seg) => seg === '..' || seg === '.' || seg === ''))
        return false;
    return true;
}
export function cmdHealthCheck(projectDir) {
    if (!projectDir)
        return err('Usage: apt-tools health-check <project-dir>');
    const targetDir = resolve(projectDir);
    const aperantDir = join(targetDir, '.aperant');
    const issues = [];
    // 1. Check for uncommitted changes in working tree
    let dirtyFiles = [];
    try {
        const status = execFileSync('git', ['status', '--porcelain'], {
            cwd: targetDir,
            encoding: 'utf-8',
            stdio: ['ignore', 'pipe', 'ignore'],
        }).trim();
        if (status) {
            dirtyFiles = status.split('\n').filter(Boolean);
            if (dirtyFiles.length > 0) {
                issues.push({
                    type: 'dirty-workdir',
                    severity: 'warning',
                    detail: `${dirtyFiles.length} uncommitted file(s) in working tree`,
                    files: dirtyFiles.slice(0, 10),
                });
            }
        }
    }
    catch {
        /* no git */
    }
    // 2. Check for stale active_task in state.json
    const statePath = join(aperantDir, 'state.json');
    if (existsSync(statePath)) {
        try {
            const state = JSON.parse(readFileSync(statePath, 'utf-8'));
            if (state.active_task) {
                const lastActivity = new Date(state.last_activity);
                const staleMinutes = (Date.now() - lastActivity.getTime()) / 60000;
                if (staleMinutes > 30) {
                    issues.push({
                        type: 'stale-active-task',
                        severity: 'warning',
                        detail: `Active task "${state.active_task.description}" last active ${Math.round(staleMinutes)}m ago`,
                        task: state.active_task,
                    });
                }
            }
        }
        catch {
            /* malformed state */
        }
    }
    // 3. Check for stale locks
    const locksDir = getSharedLockDir(targetDir);
    if (existsSync(locksDir)) {
        try {
            const lockFiles = readdirSync(locksDir).filter((f) => f.endsWith('.lock'));
            for (const lockFile of lockFiles) {
                const lockPath = join(locksDir, lockFile);
                const lockData = JSON.parse(readFileSync(lockPath, 'utf-8'));
                const heartbeat = new Date(lockData.heartbeat);
                const staleSec = (Date.now() - heartbeat.getTime()) / 1000;
                let pidAlive = false;
                try {
                    process.kill(lockData.worker_pid, 0);
                    pidAlive = true;
                }
                catch {
                    pidAlive = false;
                }
                if (staleSec > lockData.ttl_seconds && !pidAlive) {
                    issues.push({
                        type: 'stale-lock',
                        severity: 'error',
                        detail: `Stale lock: ${lockFile} (PID ${lockData.worker_pid} dead, heartbeat ${Math.round(staleSec)}s ago)`,
                        lock_file: lockFile,
                        lock_data: lockData,
                    });
                }
            }
        }
        catch {
            /* no locks dir */
        }
    }
    // 4. Check for orphaned events (claimed but never completed/failed)
    const eventsDir = join(aperantDir, 'events');
    if (existsSync(eventsDir)) {
        try {
            const eventFiles = readdirSync(eventsDir)
                .filter((f) => f.endsWith('.json'))
                .sort();
            const claimed = new Map();
            const resolved = new Set();
            for (const ef of eventFiles) {
                const event = JSON.parse(readFileSync(join(eventsDir, ef), 'utf-8'));
                if (event.type === 'task-claimed')
                    claimed.set(event.subtask, ef);
                if (event.type === 'task-completed' || event.type === 'task-failed')
                    resolved.add(event.subtask);
            }
            for (const [subtask, eventFile] of claimed) {
                if (!resolved.has(subtask)) {
                    issues.push({
                        type: 'orphaned-event',
                        severity: 'warning',
                        detail: `Subtask "${subtask}" was claimed (${eventFile}) but never completed or failed`,
                        subtask,
                    });
                }
            }
        }
        catch {
            /* no events */
        }
    }
    // 5. Check skill installs for each supported host surface
    const pkgPath = resolve(__dirname, '..', '..', '..', 'package.json');
    if (existsSync(pkgPath)) {
        const frameworkVersion = JSON.parse(readFileSync(pkgPath, 'utf-8')).version;
        const installs = readSkillInstallState(targetDir, frameworkVersion);
        const installed = installs.filter((surface) => surface.installed);
        const staleInstalls = installed.filter((surface) => surface.stale);
        if (installed.length === 0) {
            issues.push({
                type: 'missing-skills',
                severity: 'warning',
                detail: 'No apt install manifests or version stamps found for any supported runtime',
                install_surfaces: installs,
            });
        }
        if (staleInstalls.length > 0) {
            issues.push({
                type: 'stale-skills',
                severity: 'warning',
                detail: `Installed skills differ from framework (${frameworkVersion}) on ${staleInstalls
                    .map((surface) => surface.host)
                    .join(', ')}`,
                install_surfaces: staleInstalls,
            });
        }
    }
    // 6. BUG-025b — orphaned-feature-component warning. Walk
    // `.aperant/features/*.json`; for each cited component path, verify
    // `git rev-parse HEAD:<path>` succeeds on the main repo's HEAD. A path
    // that resolves on a worktree branch but is missing on main is a
    // candidate orphan (the registry entry leaked from a worktree-isolated
    // task and was never followed by the actual component landing on main).
    // Severity `warn` — the registry is best-effort metadata, not a build
    // blocker. Only fires on the main repo (skipped when health-check is
    // invoked from a worktree).
    try {
        const featuresDir = join(aperantDir, 'features');
        if (existsSync(featuresDir)) {
            // SECURITY: argv-form (no shell) — see isSafeComponentPath comment.
            const dirRes = execFileSync('git', ['rev-parse', '--git-dir'], {
                cwd: targetDir,
                encoding: 'utf-8',
                stdio: ['ignore', 'pipe', 'ignore'],
            }).trim();
            const commonRes = execFileSync('git', ['rev-parse', '--git-common-dir'], {
                cwd: targetDir,
                encoding: 'utf-8',
                stdio: ['ignore', 'pipe', 'ignore'],
            }).trim();
            if (dirRes === commonRes) {
                const orphans = [];
                // PRF-001: one subprocess total instead of one per component.
                // Build a Set of all tracked paths from a single ls-tree call.
                const lsOut = execFileSync('git', ['ls-tree', '-r', 'HEAD', '--name-only'], {
                    cwd: targetDir,
                    encoding: 'utf-8',
                    stdio: ['ignore', 'pipe', 'ignore'],
                }).trim();
                const trackedPaths = new Set(lsOut
                    .split('\n')
                    .map((s) => s.trim())
                    .filter(Boolean));
                const files = readdirSync(featuresDir).filter((f) => f.endsWith('.json') && f !== 'REGISTRY.md');
                // QUA-006: cap total component iterations to guard against
                // adversarially large or malformed registry files.
                const MAX_ORPHAN_ITERATIONS = 10_000;
                let iterCount = 0;
                let cappedAt = null;
                outer: for (const f of files) {
                    let area;
                    try {
                        area = JSON.parse(readFileSync(join(featuresDir, f), 'utf-8'));
                    }
                    catch {
                        continue;
                    }
                    const components = Array.isArray(area?.components) ? area.components : [];
                    for (const comp of components) {
                        if (iterCount >= MAX_ORPHAN_ITERATIONS) {
                            cappedAt = f;
                            break outer;
                        }
                        iterCount++;
                        // SECURITY: reject obviously-unsafe component strings.
                        if (!isSafeComponentPath(comp))
                            continue;
                        if (!trackedPaths.has(comp))
                            orphans.push({ area_file: f, component: comp });
                    }
                }
                if (cappedAt !== null) {
                    orphans.push({
                        area_file: cappedAt,
                        component: `(orphan scan capped at ${MAX_ORPHAN_ITERATIONS} entries — remaining components skipped)`,
                    });
                }
                if (orphans.length > 0) {
                    issues.push({
                        type: 'orphan-feature-components',
                        severity: 'warning',
                        detail: `${orphans.length} feature component(s) cited in .aperant/features/*.json are missing on HEAD — candidates leaked from worktree-isolated tasks`,
                        orphans: orphans.slice(0, 20),
                    });
                }
            }
        }
    }
    catch {
        /* git not available or worktree — skip silently */
    }
    return ok({
        status: 'ok',
        command: 'health-check',
        healthy: issues.length === 0,
        issue_count: issues.length,
        issues,
    });
}
//# sourceMappingURL=health-check.mjs.map