/**
 * commands/uninstall.mjs — reverse what `apt-tools init` wrote.
 *
 * Reads `.aperant/install/last-install.json` and each runtime's
 * `apt-file-manifest.json`, then deletes exactly the files the installer
 * tracked. Does NOT touch files the user created inside a runtime install
 * root — unknown files are left alone and the directory is preserved if
 * it would otherwise be non-empty.
 *
 * The APT section in `CLAUDE.md` / `AGENTS.md` / `GEMINI.md` is stripped
 * between the `<!-- APT:framework-start -->` / `<!-- APT:framework-end -->`
 * markers (same injection surface as `injectInstructionFile`). If the file
 * becomes empty after the strip, the file is removed too.
 *
 * Flags:
 *   --purge               Also delete `.aperant/` state. Default: preserve
 *                         so tasks / roadmap / proof reports survive.
 *   --yes | -y            Skip the TTY confirmation prompt.
 *   --dry-run             List what would be removed. Write nothing.
 *   --runtime <id>        Only uninstall one runtime. Default: every
 *                         runtime in the last-install manifest.
 */
import { existsSync, readdirSync, readFileSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
import { join, resolve } from 'node:path';
import { MANIFEST_FILENAME } from '../install/manifest.mjs';
import { RUNTIMES } from '../install/runtime-detect.mjs';
import { APT_SECTION_STRIP_RE as APT_SECTION_RE } from '../util/aperant-section.mjs';
import { err, ok } from '../util/result.mjs';
const APT_GITIGNORE_BLOCK_RE = /# APT:gitignore-start[\s\S]*?# APT:gitignore-end\n?/;
/**
 * Files inside the project root that may carry the APT-injected section.
 * Derived from RUNTIMES.nativeInstructionFile so the list stays in sync with
 * whatever targets `init` writes — the same drift class as the bug this PR
 * fixes cannot occur here.
 */
const INSTRUCTION_FILES = [...new Set(RUNTIMES.map((r) => r.nativeInstructionFile).filter(Boolean))];
/**
 * @typedef {Object} UninstallFlags
 * @property {boolean} purge
 * @property {boolean} yes
 * @property {boolean} dryRun
 * @property {string|null} runtime
 */
/**
 * Parse CLI flags into a normalized struct.
 *
 * @param {string[]} args
 * @returns {UninstallFlags}
 */
function parseFlags(args) {
    const flags = { purge: false, yes: false, dryRun: false, runtime: null };
    for (let i = 0; i < args.length; i++) {
        const a = args[i];
        if (a === '--purge')
            flags.purge = true;
        else if (a === '--yes' || a === '-y')
            flags.yes = true;
        else if (a === '--dry-run')
            flags.dryRun = true;
        else if (a === '--runtime' && args[i + 1] && !args[i + 1].startsWith('--')) {
            flags.runtime = args[++i];
        }
    }
    return flags;
}
/**
 * Try to read + parse a JSON file. Returns null on any failure.
 *
 * @param {string} path
 * @returns {any | null}
 */
function readJsonSafe(path) {
    try {
        return JSON.parse(readFileSync(path, 'utf-8'));
    }
    catch {
        return null;
    }
}
/**
 * Collect the list of runtime install roots to clean from the
 * last-install summary. Falls back to scanning all known runtime dirs
 * if the summary is missing — covers the case where a user deleted the
 * `.aperant/` directory manually before trying to uninstall.
 *
 * @param {string} targetDir
 * @param {string|null} runtimeFilter
 * @returns {Array<{runtime: string, installRoot: string}>}
 */
function resolveRuntimeRoots(targetDir, runtimeFilter) {
    const summary = readJsonSafe(join(targetDir, '.aperant', 'install', 'last-install.json'));
    if (summary?.runtimes?.length) {
        return summary.runtimes
            .filter((r) => !runtimeFilter || r.runtime === runtimeFilter)
            .map((r) => ({ runtime: r.runtime, installRoot: resolve(targetDir, r.install_root) }));
    }
    // Fallback — scan known runtime dirs for our manifest file.
    const found = [];
    for (const r of RUNTIMES) {
        if (runtimeFilter && r.id !== runtimeFilter)
            continue;
        const root = join(targetDir, r.installRoot);
        if (existsSync(join(root, MANIFEST_FILENAME))) {
            found.push({ runtime: r.id, installRoot: root });
        }
    }
    return found;
}
/**
 * Delete a file if it exists. Returns true when a deletion occurred.
 *
 * @param {string} path
 * @param {UninstallFlags} flags
 * @returns {boolean}
 */
function removeFile(path, flags) {
    if (!existsSync(path))
        return false;
    if (flags.dryRun)
        return true;
    try {
        unlinkSync(path);
        return true;
    }
    catch {
        return false;
    }
}
/**
 * Post-pass empty-directory sweep. Walks `root` bottom-up and removes
 * any directory whose subtree contains no files. Stops at `root` itself
 * (the caller decides whether to remove the root).
 *
 * @param {string} root
 * @param {UninstallFlags} flags
 * @returns {boolean}  True when the tree rooted at `root` is now empty.
 */
function sweepEmptyDirs(root, flags) {
    let entries;
    try {
        entries = readdirSync(root, { withFileTypes: true });
    }
    catch {
        return false;
    }
    let allEmpty = true;
    for (const entry of entries) {
        const child = join(root, entry.name);
        if (entry.isDirectory()) {
            const childEmpty = sweepEmptyDirs(child, flags);
            if (childEmpty) {
                if (!flags.dryRun) {
                    try {
                        rmdirSync(child);
                    }
                    catch {
                        allEmpty = false;
                    }
                }
            }
            else {
                allEmpty = false;
            }
        }
        else {
            allEmpty = false;
        }
    }
    return allEmpty;
}
/**
 * Strip APT-owned hook entries from `.claude/settings.local.json`. The
 * installer injects `PreCompact` + `SessionStart` hooks that reference
 * `apt-tools` / `apt-check-update` commands. We remove exactly those
 * entries, leaving any other user-configured hooks intact. If the
 * resulting file has no other content, it's deleted.
 *
 * @param {string} installRoot  e.g. `<target>/.claude`
 * @param {UninstallFlags} flags
 * @returns {'stripped' | 'deleted' | 'unchanged' | 'absent'}
 */
function stripClaudeHooks(installRoot, flags) {
    const path = join(installRoot, 'settings.local.json');
    if (!existsSync(path))
        return 'absent';
    let data;
    try {
        data = JSON.parse(readFileSync(path, 'utf-8'));
    }
    catch {
        return 'unchanged';
    }
    if (!data || typeof data !== 'object' || !data.hooks)
        return 'unchanged';
    let changed = false;
    for (const eventName of Object.keys(data.hooks)) {
        const entries = Array.isArray(data.hooks[eventName]) ? data.hooks[eventName] : [];
        const kept = [];
        for (const group of entries) {
            const hooks = Array.isArray(group?.hooks) ? group.hooks : [];
            const filtered = hooks.filter((h) => {
                const cmd = typeof h?.command === 'string' ? h.command : '';
                return !/apt-tools\b|apt-check-update\b|aperant\//i.test(cmd);
            });
            if (filtered.length > 0) {
                kept.push({ ...group, hooks: filtered });
            }
            else {
                changed = true;
            }
        }
        if (kept.length === 0) {
            delete data.hooks[eventName];
            changed = true;
        }
        else if (kept.length !== entries.length) {
            data.hooks[eventName] = kept;
            changed = true;
        }
    }
    if (!changed)
        return 'unchanged';
    if (Object.keys(data.hooks).length === 0)
        delete data.hooks;
    const remainingKeys = Object.keys(data);
    if (remainingKeys.length === 0) {
        if (!flags.dryRun)
            unlinkSync(path);
        return 'deleted';
    }
    if (!flags.dryRun)
        writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, 'utf-8');
    return 'stripped';
}
/**
 * Remove a runtime's tracked files using its apt-file-manifest.json.
 * Leaves untracked files alone. Returns a result describing what happened.
 *
 * @param {{runtime: string, installRoot: string}} target
 * @param {UninstallFlags} flags
 */
function removeRuntime(target, flags) {
    const manifestPath = join(target.installRoot, MANIFEST_FILENAME);
    const manifest = readJsonSafe(manifestPath);
    const removed = [];
    const missing = [];
    if (manifest?.files?.length) {
        for (const entry of manifest.files) {
            const full = join(target.installRoot, entry.path);
            if (removeFile(full, flags)) {
                removed.push(entry.path);
            }
            else {
                missing.push(entry.path);
            }
        }
    }
    // Remove the manifest itself — always before the empty-dir sweep so the
    // runtime root doesn't look "non-empty" purely because of our own manifest.
    removeFile(manifestPath, flags);
    // Claude runtime carries a hook config in settings.local.json. Strip our
    // entries so the uninstall leaves user-authored hooks intact.
    const hooksResult = target.runtime === 'claude' ? stripClaudeHooks(target.installRoot, flags) : 'absent';
    // Post-pass sweep — clean up all directories that are empty after the
    // file-by-file deletions above. Then remove the runtime root itself if
    // the sweep reports the whole tree empty.
    let rootRemoved = false;
    if (existsSync(target.installRoot)) {
        const treeEmpty = sweepEmptyDirs(target.installRoot, flags);
        if (treeEmpty) {
            if (!flags.dryRun) {
                try {
                    rmdirSync(target.installRoot);
                }
                catch {
                    /* non-empty after all — leave it */
                }
            }
            rootRemoved = true;
        }
    }
    return {
        runtime: target.runtime,
        installRoot: target.installRoot,
        removed,
        missing,
        rootRemoved,
        claudeHooks: hooksResult,
    };
}
/**
 * Strip the APT-injected section from CLAUDE.md / AGENTS.md / GEMINI.md. If
 * the remaining file is empty, the file itself is removed.
 *
 * @param {string} targetDir
 * @param {UninstallFlags} flags
 * @returns {Array<{path: string, action: 'stripped'|'deleted'|'skipped'}>}
 */
function stripInstructionFiles(targetDir, flags) {
    const results = [];
    for (const name of INSTRUCTION_FILES) {
        const path = join(targetDir, name);
        if (!existsSync(path))
            continue;
        try {
            const contents = readFileSync(path, 'utf-8');
            if (!APT_SECTION_RE.test(contents)) {
                results.push({ path: name, action: 'skipped' });
                continue;
            }
            const stripped = contents
                .replace(APT_SECTION_RE, '')
                .replace(/\n{3,}$/, '\n')
                .trimEnd();
            if (stripped.length === 0) {
                if (!flags.dryRun)
                    unlinkSync(path);
                results.push({ path: name, action: 'deleted' });
            }
            else {
                if (!flags.dryRun)
                    writeFileSync(path, `${stripped}\n`, 'utf-8');
                results.push({ path: name, action: 'stripped' });
            }
        }
        catch {
            results.push({ path: name, action: 'skipped' });
        }
    }
    return results;
}
/**
 * Strip the `# APT:gitignore-start … # APT:gitignore-end` block from the
 * project-root `.gitignore`. Leaves every other rule alone. If the file
 * becomes empty (edge case — user had nothing else there), delete it.
 *
 * @param {string} targetDir
 * @param {UninstallFlags} flags
 * @returns {{ path: string, action: 'stripped' | 'deleted' | 'skipped' | 'absent' }}
 */
function stripProjectGitignoreBlock(targetDir, flags) {
    const path = join(targetDir, '.gitignore');
    if (!existsSync(path))
        return { path: '.gitignore', action: 'absent' };
    try {
        const contents = readFileSync(path, 'utf-8');
        if (!APT_GITIGNORE_BLOCK_RE.test(contents)) {
            return { path: '.gitignore', action: 'skipped' };
        }
        const stripped = contents
            .replace(APT_GITIGNORE_BLOCK_RE, '')
            .replace(/\n{3,}$/, '\n')
            .trimEnd();
        if (stripped.length === 0) {
            if (!flags.dryRun)
                unlinkSync(path);
            return { path: '.gitignore', action: 'deleted' };
        }
        if (!flags.dryRun)
            writeFileSync(path, `${stripped}\n`, 'utf-8');
        return { path: '.gitignore', action: 'stripped' };
    }
    catch {
        return { path: '.gitignore', action: 'skipped' };
    }
}
/**
 * Interactive confirmation using the host TTY. Skipped for --yes / non-TTY.
 *
 * @param {string[]} toRemove  Human-readable paths.
 * @param {UninstallFlags} flags
 * @returns {Promise<boolean>}
 */
async function confirmIfNeeded(toRemove, flags) {
    if (flags.yes || flags.dryRun || !process.stdin.isTTY)
        return true;
    const clack = await import('@clack/prompts');
    clack.intro('Aperant uninstall');
    clack.note(toRemove.map((p) => `  • ${p}`).join('\n'), `About to remove`);
    const go = await clack.confirm({
        message: 'Proceed with uninstall?',
        initialValue: false,
    });
    if (clack.isCancel(go) || go !== true) {
        clack.cancel('Aborted — nothing removed.');
        return false;
    }
    return true;
}
/**
 * CLI entrypoint.
 *
 * @param {string} projectDir
 * @param {string[]} extraArgs
 */
export async function cmdUninstall(projectDir, extraArgs = []) {
    const resolvedDir = projectDir && projectDir.length > 0 ? projectDir : '.';
    const targetDir = resolve(resolvedDir);
    if (!existsSync(targetDir) || !statSync(targetDir).isDirectory()) {
        return err(`Not a directory: ${targetDir}`);
    }
    const flags = parseFlags(extraArgs);
    const targets = resolveRuntimeRoots(targetDir, flags.runtime);
    const aperantDir = join(targetDir, '.aperant');
    if (targets.length === 0 && !existsSync(aperantDir)) {
        return ok({
            status: 'ok',
            command: 'uninstall',
            message: 'Nothing to remove.',
            removed: [],
        });
    }
    const previewPaths = targets.map((t) => t.installRoot);
    for (const n of INSTRUCTION_FILES) {
        const p = join(targetDir, n);
        if (existsSync(p) && APT_SECTION_RE.test(readFileSync(p, 'utf-8')))
            previewPaths.push(p);
    }
    if (flags.purge && existsSync(aperantDir))
        previewPaths.push(aperantDir);
    const proceed = await confirmIfNeeded(previewPaths, flags);
    if (!proceed) {
        return ok({ status: 'ok', command: 'uninstall', message: 'Cancelled.', removed: [] });
    }
    const runtimeResults = targets.map((t) => removeRuntime(t, flags));
    const instructionResults = stripInstructionFiles(targetDir, flags);
    const gitignoreResult = stripProjectGitignoreBlock(targetDir, flags);
    let aperantRemoved = false;
    if (flags.purge && existsSync(aperantDir)) {
        if (!flags.dryRun)
            rmSync(aperantDir, { recursive: true, force: true });
        aperantRemoved = true;
    }
    return ok({
        status: 'ok',
        command: 'uninstall',
        dry_run: flags.dryRun,
        purge: flags.purge,
        runtimes: runtimeResults,
        instruction_files: instructionResults,
        project_gitignore: gitignoreResult,
        aperant_dir_removed: aperantRemoved,
        message: flags.dryRun
            ? 'Dry run complete — no files changed.'
            : `Removed ${runtimeResults.reduce((n, r) => n + r.removed.length, 0)} runtime files across ${runtimeResults.length} runtime(s).`,
    });
}
//# sourceMappingURL=uninstall.mjs.map