/**
 * commands/features-audit.mjs — CLI entry point for the features-registry
 * audit (C33).
 *
 * Modes:
 *   - report only (default): print `new_components`, `unregistered`, `stub_patches`
 *   - `--apply-stubs`:        additionally write the stub patches to disk.
 *                             This is what the /apt:execute post-step calls.
 *
 * Diff source precedence:
 *   1. `--diff-files <a,b,c>` explicit CSV (highest priority)
 *   2. `--since <ref>` → runs `git diff --name-only <ref>..HEAD`
 *   3. fallback: `git diff HEAD --name-only` (current WIP)
 *
 * BUG-025b worktree-redirect: features-audit historically wrote to the
 * invoking cwd's `.aperant/features/`, leaking generated `*.json` into
 * main when the executor invoked it from a worktree-isolated task. The
 * fix applies a 4-step resolution order (see resolveWriteRoot below):
 *   (1) cwd IS a worktree root → write there (current behaviour)
 *   (2) --task-id maps to an active non-terminal worktree task →
 *       redirect writes to that worktree's `.aperant/features/`
 *   (3) multiple active worktree-isolated tasks + no --task-id → error
 *   (4) `--no-worktree-redirect` opt-out preserves layer (1) behaviour
 */
import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join, resolve, sep } from 'node:path';
import { loadDocsPolicy, matchSurfaceToPolicy } from '../docs-audit/policy.mjs';
import { applyStubPatches, auditFeatureRegistry } from '../features/registry-audit.mjs';
import { parseFlags } from '../util/args.mjs';
import { err, ok } from '../util/result.mjs';
const TERMINAL_PHASES = new Set(['completed', 'cancelled', 'closed', 'shipped', 'merged']);
/**
 * BUG-025b — resolve where features-audit should write `*.json`.
 * See module header for the 4-step order. Returns `{ writeRoot, redirect_notice }`
 * on success or a synthetic error envelope (`{ error: { ... } }`) on the
 * ambiguous-worktree case.
 *
 * @internal — exported for unit testing.
 *
 * @param {{projectDir: string, taskId?: string | null, noRedirect?: boolean}} args
 * @returns {{writeRoot?: string, redirect_notice?: string | null, error?: {kind: string, message: string, active_worktree_tasks: string[]}}}
 */
export function resolveWriteRoot({ projectDir, taskId, noRedirect }) {
    const root = resolve(projectDir);
    if (noRedirect === true) {
        return { writeRoot: root, redirect_notice: null };
    }
    // Layer (1): cwd IS a worktree root. Worktrees have `git rev-parse
    // --git-dir` != `git rev-parse --git-common-dir`. Falling through to
    // the existing behaviour writes to this worktree's .aperant/features.
    // Fast path: git sets GIT_DIR and GIT_COMMON_DIR in the environment
    // when running inside a worktree hook context — if both are set and
    // differ, we are in a worktree. This env-var check is ONLY valid at
    // Layer (1) where we ask "is this process in a worktree?"; it must
    // NOT be used to validate an untrusted external path (Layer 2 below).
    const envDir = process.env.GIT_DIR;
    const envCommonDir = process.env.GIT_COMMON_DIR;
    if (envDir && envCommonDir) {
        if (resolve(envDir) !== resolve(envCommonDir)) {
            return { writeRoot: root, redirect_notice: null };
        }
    }
    else if (isWorktreeRoot(root)) {
        return { writeRoot: root, redirect_notice: null };
    }
    // Layer (2): --task-id maps to an active non-terminal worktree-isolated
    // task → redirect.
    const stateActive = readActiveTasks(root);
    if (taskId) {
        const task = stateActive[taskId];
        if (task &&
            !TERMINAL_PHASES.has(task.lifecycle_phase) &&
            typeof task.worktree_path === 'string' &&
            existsSync(task.worktree_path)) {
            const resolvedWorktree = resolve(task.worktree_path);
            const worktreesDir = resolve(root, '.aperant', 'worktrees') + sep;
            const isUnderProject = resolvedWorktree.startsWith(root + sep) || resolvedWorktree === root;
            const isUnderWorktrees = resolvedWorktree.startsWith(worktreesDir);
            // Accept only paths under the project root, under .aperant/worktrees/,
            // or confirmed as a real git worktree by git itself. Rejects arbitrary
            // paths injected via a tampered state.json while preserving test
            // fixtures that use ad-hoc worktree paths outside the project tree.
            if (!isUnderProject && !isUnderWorktrees && !isWorktreeRoot(resolvedWorktree)) {
                return { writeRoot: root, redirect_notice: null };
            }
            return {
                writeRoot: resolvedWorktree,
                redirect_notice: `features-audit redirected writes to worktree ${resolvedWorktree} (--task-id ${taskId})`,
            };
        }
    }
    // Layer (3): multiple active worktree-isolated tasks AND no --task-id
    // → actionable error. Single-worktree case is OK to default to root
    // (the executor will set up the worktree's registry on first scan).
    const activeWorktreeTasks = Object.entries(stateActive).filter(([_id, t]) => {
        return (t &&
            !TERMINAL_PHASES.has(t.lifecycle_phase) &&
            typeof t.worktree_path === 'string' &&
            existsSync(t.worktree_path));
    });
    if (!taskId && activeWorktreeTasks.length > 1) {
        return {
            error: {
                kind: 'ambiguous-worktree-context',
                message: 'ambiguous worktree context; pass --task-id <id> or --no-worktree-redirect',
                active_worktree_tasks: activeWorktreeTasks.map(([id]) => id),
            },
        };
    }
    // Default: current behaviour (write into cwd).
    return { writeRoot: root, redirect_notice: null };
}
function isWorktreeRoot(projectDir) {
    try {
        const dirRes = spawnSync('git', ['rev-parse', '--git-dir'], {
            cwd: projectDir,
            encoding: 'utf-8',
            stdio: ['ignore', 'pipe', 'ignore'],
        });
        const commonRes = spawnSync('git', ['rev-parse', '--git-common-dir'], {
            cwd: projectDir,
            encoding: 'utf-8',
            stdio: ['ignore', 'pipe', 'ignore'],
        });
        if (dirRes.status !== 0 || commonRes.status !== 0)
            return false;
        return (dirRes.stdout || '').trim() !== (commonRes.stdout || '').trim();
    }
    catch {
        return false;
    }
}
function readActiveTasks(projectDir) {
    const statePath = join(projectDir, '.aperant', 'state.json');
    if (!existsSync(statePath))
        return {};
    try {
        const parsed = JSON.parse(readFileSync(statePath, 'utf-8'));
        return parsed?.active_tasks ?? {};
    }
    catch {
        return {};
    }
}
export function cmdFeaturesAudit(projectDir, extraArgs) {
    if (!projectDir)
        return err('Usage: apt-tools features-audit <project-dir> [flags]');
    const root = resolve(projectDir);
    const flags = parseFlags(extraArgs);
    // BUG-025b: redirect writes to the active task's worktree when the
    // caller is in a non-worktree cwd. See resolveWriteRoot for the
    // 4-step resolution order.
    const taskIdFlag = flags.get('task-id');
    const taskId = typeof taskIdFlag === 'string' && taskIdFlag.trim() ? taskIdFlag.trim() : null;
    const noRedirect = flags.get('no-worktree-redirect') === true;
    const writeRootResult = resolveWriteRoot({ projectDir: root, taskId, noRedirect });
    if (writeRootResult.error) {
        return err(writeRootResult.error.message, 1, {
            kind: writeRootResult.error.kind,
            active_worktree_tasks: writeRootResult.error.active_worktree_tasks,
        });
    }
    const writeRoot = writeRootResult.writeRoot;
    const redirectNotice = writeRootResult.redirect_notice;
    if (redirectNotice) {
        // Surface the redirect once on stderr so the executor's log carries
        // the audit trail without polluting stdout JSON.
        process.stderr.write(`[features-audit] ${redirectNotice}\n`);
    }
    const diffFiles = resolveDiffFiles(root, flags);
    const applyStubs = flags.has('apply-stubs');
    const format = (flags.get('format') || 'json').toLowerCase();
    const result = auditFeatureRegistry({ projectDir: writeRoot, diffFiles });
    // Honor `.aperant/config.json.docs_policy`: only apply stubs for targets
    // whose resolved policy is `auto-update`. This closes the skill-vs-code
    // gap where `apt-execute` §3e-features promised `--apply-stubs` would
    // no-op on surfaces the user opted out of. Patches whose target matches
    // `skip` / `flag-drift` (or nothing) are surfaced in `stub_patches` but
    // never written to disk.
    let applied_patches = null;
    let skipped_patches = null;
    if (applyStubs && result.stub_patches.length > 0) {
        const policy = loadDocsPolicy(root);
        const eligible = [];
        const skipped = [];
        for (const patch of result.stub_patches) {
            const policyValue = matchSurfaceToPolicy(patch.target, policy);
            if (policyValue === 'auto-update') {
                eligible.push(patch);
            }
            else {
                skipped.push({ target: patch.target, policy: policyValue });
            }
        }
        if (eligible.length > 0) {
            // BUG-025b: stubs are written to the resolved `writeRoot` (which
            // may be the active task's worktree) rather than the invoking cwd.
            applied_patches = applyStubPatches(writeRoot, eligible);
        }
        if (skipped.length > 0) {
            skipped_patches = skipped;
        }
    }
    if (format === 'md') {
        return ok({
            status: 'ok',
            command: 'features-audit',
            format: 'md',
            markdown: renderMarkdown(result, applied_patches, skipped_patches),
            write_root: writeRoot,
            ...(redirectNotice ? { redirect_notice: redirectNotice } : {}),
            ...result,
            ...(applied_patches ? { applied_patches } : {}),
            ...(skipped_patches ? { skipped_patches } : {}),
        });
    }
    return ok({
        status: 'ok',
        command: 'features-audit',
        write_root: writeRoot,
        ...(redirectNotice ? { redirect_notice: redirectNotice } : {}),
        ...result,
        ...(applied_patches ? { applied_patches } : {}),
        ...(skipped_patches ? { skipped_patches } : {}),
    });
}
function resolveDiffFiles(root, flags) {
    const explicit = flags.get('diff-files');
    if (explicit && typeof explicit === 'string') {
        return explicit
            .split(',')
            .map((s) => s.trim())
            .filter(Boolean);
    }
    const sinceRaw = flags.get('since');
    const since = typeof sinceRaw === 'string' && /^[A-Za-z0-9/_.-]+$/.test(sinceRaw) && !sinceRaw.startsWith('-')
        ? sinceRaw
        : null;
    try {
        const args = since ? ['diff', '--name-only', `${since}..HEAD`] : ['diff', 'HEAD', '--name-only'];
        const out = execFileSync('git', args, {
            cwd: root,
            encoding: 'utf-8',
            stdio: ['ignore', 'pipe', 'ignore'],
        });
        return out
            .split('\n')
            .map((s) => s.trim())
            .filter(Boolean);
    }
    catch {
        // Not a git repo or HEAD doesn't exist — clean slate.
        return [];
    }
}
function renderMarkdown(result, applied, skipped) {
    const lines = ['### Features audit', ''];
    if (result.new_components.length === 0) {
        lines.push('_No new feature-qualifying files in diff._');
    }
    else {
        lines.push('**New candidates:**');
        for (const c of result.new_components) {
            lines.push(`- \`${c.file}\` — kind: \`${c.kind}\`, area: \`${c.area}\``);
        }
    }
    if (result.unregistered.length > 0) {
        lines.push('', '**Unregistered:**');
        for (const u of result.unregistered) {
            lines.push(`- \`${u.file}\` (area: \`${u.area}\`)`);
        }
    }
    if (applied && applied.length > 0) {
        lines.push('', '**Applied patches:**');
        for (const p of applied) {
            lines.push(`- ${p.target} — ${p.applied ? p.reason : `FAILED: ${p.reason}`}`);
        }
    }
    if (skipped && skipped.length > 0) {
        lines.push('', '**Skipped patches (policy opt-out):**');
        for (const s of skipped) {
            lines.push(`- ${s.target} — policy: \`${s.policy ?? 'unmatched'}\``);
        }
    }
    return lines.join('\n');
}
//# sourceMappingURL=features-audit.mjs.map