"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.HostInstaller = exports.HostNotYetSupportedError = void 0;
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const ToolsetGuard_1 = require("../orchestrator/toolset/ToolsetGuard");
class HostNotYetSupportedError extends Error {
    host;
    code = 'HOST_NOT_YET_SUPPORTED';
    constructor(host) {
        super(`HOST_NOT_YET_SUPPORTED: install for "${host}" is not implemented yet. ` +
            `Story 3.3 ships claude-code only; gemini-cli and codex follow in a later v1.1 story. ` +
            `Use --host claude-code for now, or set CLAUDECODE=1 to auto-detect.`);
        this.host = host;
    }
}
exports.HostNotYetSupportedError = HostNotYetSupportedError;
class HostInstaller {
    templatesRoot;
    constructor(opts = {}) {
        this.templatesRoot = opts.templatesRoot || HostInstaller.defaultTemplatesRoot();
    }
    /**
     * Resolves the dist-templates/ directory relative to this compiled file.
     * Source: src/cli/HostInstaller.ts → compiled: dist/cli/HostInstaller.js
     * Going up twice from dist/cli/ lands on the package root where dist-templates/ lives.
     * Works in both repo-dev (npm run build) and consumer-installed (node_modules/@openlife/cli/) layouts.
     */
    static defaultTemplatesRoot() {
        return path.resolve(__dirname, '..', '..', 'dist-templates');
    }
    /**
     * Dispatch on validated Host. Throws HostNotYetSupportedError for hosts without an installer.
     */
    install(host, options) {
        (0, ToolsetGuard_1.assertToolsetAllowed)('file', 'HostInstaller.install');
        switch (host) {
            case 'claude-code':
                return this.installForClaudeCode(options);
            case 'gemini-cli':
                return this.installForGeminiCli(options);
            case 'codex':
                return this.installForCodex(options);
            default:
                // Should be unreachable because Host is a validated union; defensive throw.
                throw new HostNotYetSupportedError(host);
        }
    }
    installForClaudeCode(options) {
        const targetRoot = options.targetRoot;
        const sourceRoot = path.join(this.templatesRoot, 'claude-code');
        if (!fs.existsSync(sourceRoot)) {
            throw new Error(`DIST_TEMPLATES_MISSING: expected templates at ${sourceRoot}. ` +
                `Run "npm run build" or reinstall @openlife/cli.`);
        }
        const files = [];
        const notes = [];
        // 1. Agents → <root>/.claude/agents/openlife-*.md
        const agentsSrc = path.join(sourceRoot, 'agents');
        const agentsDst = path.join(targetRoot, '.claude', 'agents');
        files.push(...this.copyDir(agentsSrc, agentsDst));
        // 2. Slash commands → <root>/.claude/commands/openlife/*.md
        const commandsSrc = path.join(sourceRoot, 'commands', 'openlife');
        const commandsDst = path.join(targetRoot, '.claude', 'commands', 'openlife');
        files.push(...this.copyDir(commandsSrc, commandsDst));
        // Slash commands written — prompt reload so autocomplete picks them up.
        notes.push(`Reload your Claude Code session so /openlife commands appear in autocomplete.`);
        // 3. MCP manifest — staged only when OPENLIFE_PREVIEW_MCP=1 (not yet implemented).
        const mcpSrc = path.join(sourceRoot, 'mcp', 'openlife-orchestrator.json');
        let mcpSnippetPath;
        if (fs.existsSync(mcpSrc) && process.env.OPENLIFE_PREVIEW_MCP === '1') {
            const stateDir = path.join(targetRoot, '.openlife');
            if (!fs.existsSync(stateDir))
                fs.mkdirSync(stateDir, { recursive: true });
            mcpSnippetPath = path.join(stateDir, 'install-mcp-snippet.json');
            const action = this.copyFile(mcpSrc, mcpSnippetPath);
            files.push(action);
            notes.push(`[preview] MCP manifest staged at ${mcpSnippetPath}. ` +
                `Merge its "mcpServers" entry into ~/.claude.json to enable.`);
        }
        return {
            host: 'claude-code',
            targetRoot,
            files,
            mcpSnippetPath,
            notes
        };
    }
    /**
     * Story 9.3 — Real implementation for the `gemini-cli` host.
     *
     * Mirrors `installForClaudeCode` exactly, swapping `.claude/` for
     * `.gemini/` and the source template directory for
     * `dist-templates/gemini-cli/`. Same uninstall pattern.
     */
    installForGeminiCli(options) {
        return this.installForHost('gemini-cli', '.gemini', options);
    }
    /**
     * Story 9.4 — Real implementation for the `codex` host.
     *
     * Mirrors `installForClaudeCode`, swapping `.claude/` for `.codex/`
     * and template root for `dist-templates/codex/`.
     */
    installForCodex(options) {
        return this.installForHost('codex', '.codex', options);
    }
    /**
     * Shared implementation extracted so gemini-cli and codex don't drift
     * from the claude-code pattern. Same shape as installForClaudeCode.
     */
    installForHost(host, hostDirName, options) {
        const targetRoot = options.targetRoot;
        const sourceRoot = path.join(this.templatesRoot, host);
        if (!fs.existsSync(sourceRoot)) {
            throw new Error(`DIST_TEMPLATES_MISSING: expected templates at ${sourceRoot}. ` +
                `Run "npm run build" or reinstall @openlife/cli.`);
        }
        const files = [];
        const notes = [];
        // 1. Agents → <root>/<hostDir>/agents/openlife-*.md
        const agentsSrc = path.join(sourceRoot, 'agents');
        if (fs.existsSync(agentsSrc)) {
            const agentsDst = path.join(targetRoot, hostDirName, 'agents');
            files.push(...this.copyDir(agentsSrc, agentsDst));
        }
        // 2. Slash commands → <root>/<hostDir>/commands/openlife/*.md
        const commandsSrc = path.join(sourceRoot, 'commands', 'openlife');
        if (fs.existsSync(commandsSrc)) {
            const commandsDst = path.join(targetRoot, hostDirName, 'commands', 'openlife');
            files.push(...this.copyDir(commandsSrc, commandsDst));
        }
        // 3. MCP manifest staged for manual merge
        const mcpSrc = path.join(sourceRoot, 'mcp', 'openlife-orchestrator.json');
        let mcpSnippetPath;
        if (fs.existsSync(mcpSrc)) {
            const stateDir = path.join(targetRoot, '.openlife');
            if (!fs.existsSync(stateDir))
                fs.mkdirSync(stateDir, { recursive: true });
            mcpSnippetPath = path.join(stateDir, `install-mcp-snippet-${host}.json`);
            const action = this.copyFile(mcpSrc, mcpSnippetPath);
            files.push(action);
            notes.push(`MCP manifest staged at ${mcpSnippetPath}. ` +
                `To enable, merge its "mcpServers" entry into the ${host} configuration file.`);
        }
        return {
            host,
            targetRoot,
            files,
            mcpSnippetPath,
            notes
        };
    }
    // --- Story 3.4: reversible uninstall ---
    /**
     * Dispatch on validated Host for uninstall. Throws HostNotYetSupportedError for hosts
     * without an uninstaller (currently only claude-code is supported, mirroring install).
     */
    uninstall(host, options) {
        switch (host) {
            case 'claude-code':
                return this.uninstallForClaudeCode(options);
            case 'gemini-cli':
                return this.uninstallForGeminiCli(options);
            case 'codex':
                return this.uninstallForCodex(options);
            default:
                throw new HostNotYetSupportedError(host);
        }
    }
    uninstallForClaudeCode(options) {
        const targetRoot = options.targetRoot;
        const files = [];
        const notes = [];
        // 1. Agents — remove only files prefixed with "openlife-" (user files untouched).
        //    If a .bak exists next to it, the user had edited the file before reinstall:
        //    restore the .bak by renaming it back over the destination.
        const agentsDir = path.join(targetRoot, '.claude', 'agents');
        if (fs.existsSync(agentsDir)) {
            // Snapshot entries first — removeFileWithBakRestore mutates the directory
            // (rename .bak → base) which could race with a live readdirSync iterator.
            // Skip .bak entries: they're handled implicitly when we process the base file.
            const entries = fs.readdirSync(agentsDir);
            for (const entry of entries) {
                if (!entry.startsWith('openlife-'))
                    continue;
                if (entry.endsWith('.bak'))
                    continue;
                const dst = path.join(agentsDir, entry);
                if (!fs.existsSync(dst))
                    continue;
                const stat = fs.statSync(dst);
                if (!stat.isFile())
                    continue;
                files.push(this.removeFileWithBakRestore(dst));
            }
        }
        else {
            files.push({ destination: agentsDir, action: 'skipped-not-found' });
        }
        // 2. Commands namespace — the whole .claude/commands/openlife/ tree is ours,
        //    including nested agents/ and flow/ subnamespaces created by the recursive
        //    installer. Remove recursively (with .bak restore) and rmdir empty dirs.
        const commandsDir = path.join(targetRoot, '.claude', 'commands', 'openlife');
        if (fs.existsSync(commandsDir)) {
            const removedDir = this.cleanCommandsNamespace(commandsDir, files);
            if (removedDir) {
                notes.push(`Removed empty namespace directory: ${commandsDir}`);
            }
            else {
                notes.push(`Kept ${commandsDir} — still contains restored user edits or unexpected entries.`);
            }
        }
        else {
            files.push({ destination: commandsDir, action: 'skipped-not-found' });
        }
        // 3. MCP snippet — single file in .openlife/. Do NOT remove .openlife/ itself
        //    (it holds runtime state: governance consents, mission state, etc.).
        const mcpSnippet = path.join(targetRoot, '.openlife', 'install-mcp-snippet.json');
        if (fs.existsSync(mcpSnippet)) {
            fs.unlinkSync(mcpSnippet);
            files.push({ destination: mcpSnippet, action: 'removed' });
        }
        else {
            files.push({ destination: mcpSnippet, action: 'skipped-not-found' });
        }
        notes.push(`.openlife/ runtime state directory intentionally preserved (governance consents, mission state, etc.). ` +
            `Use a future --purge flag to remove it.`);
        return {
            host: 'claude-code',
            targetRoot,
            files,
            notes
        };
    }
    /** Story 9.3 uninstall — mirrors uninstallForClaudeCode for `.gemini/`. */
    uninstallForGeminiCli(options) {
        return this.uninstallForHost('gemini-cli', '.gemini', options);
    }
    /** Story 9.4 uninstall — mirrors uninstallForClaudeCode for `.codex/`. */
    uninstallForCodex(options) {
        return this.uninstallForHost('codex', '.codex', options);
    }
    uninstallForHost(host, hostDirName, options) {
        const targetRoot = options.targetRoot;
        const files = [];
        const notes = [];
        // 1. Agents — only files prefixed with "openlife-".
        const agentsDir = path.join(targetRoot, hostDirName, 'agents');
        if (fs.existsSync(agentsDir)) {
            const entries = fs.readdirSync(agentsDir);
            for (const entry of entries) {
                if (!entry.startsWith('openlife-'))
                    continue;
                if (entry.endsWith('.bak'))
                    continue;
                const dst = path.join(agentsDir, entry);
                if (!fs.existsSync(dst))
                    continue;
                const stat = fs.statSync(dst);
                if (!stat.isFile())
                    continue;
                files.push(this.removeFileWithBakRestore(dst));
            }
        }
        else {
            files.push({ destination: agentsDir, action: 'skipped-not-found' });
        }
        // 2. Commands namespace (recursive — see cleanCommandsNamespace).
        const commandsDir = path.join(targetRoot, hostDirName, 'commands', 'openlife');
        if (fs.existsSync(commandsDir)) {
            const removedDir = this.cleanCommandsNamespace(commandsDir, files);
            if (removedDir) {
                notes.push(`Removed empty namespace directory: ${commandsDir}`);
            }
            else {
                notes.push(`Kept ${commandsDir} — still contains restored user edits.`);
            }
        }
        else {
            files.push({ destination: commandsDir, action: 'skipped-not-found' });
        }
        // 3. MCP snippet (host-specific name).
        const mcpSnippet = path.join(targetRoot, '.openlife', `install-mcp-snippet-${host}.json`);
        if (fs.existsSync(mcpSnippet)) {
            fs.unlinkSync(mcpSnippet);
            files.push({ destination: mcpSnippet, action: 'removed' });
        }
        else {
            files.push({ destination: mcpSnippet, action: 'skipped-not-found' });
        }
        notes.push('.openlife/ runtime state directory intentionally preserved.');
        return { host, targetRoot, files, notes };
    }
    // --- file ops (private) ---
    /**
     * Recursively remove our slash-command files under a commands/openlife/ tree
     * and rmdir any now-empty directories — including the nested agents/ and flow/
     * subnamespaces the recursive installer (copyDir) now creates. Symmetric with
     * install: what copyDir writes, this removes. User-edited files restored from
     * .bak keep their directory alive (so the namespace dir is only removed when
     * fully ours). Pushes one action per file into `files`. Returns whether the
     * top-level namespace directory ended up removed.
     */
    cleanCommandsNamespace(commandsDir, files) {
        const sweep = (dir) => {
            // Pass 1: recurse into subdirs and remove .md files (honoring .bak restore).
            for (const entry of fs.readdirSync(dir)) {
                if (entry.endsWith('.bak'))
                    continue; // restored when its base file is processed
                const full = path.join(dir, entry);
                const stat = fs.statSync(full);
                if (stat.isDirectory()) {
                    sweep(full);
                }
                else if (stat.isFile()) {
                    files.push(this.removeFileWithBakRestore(full));
                }
            }
            // Pass 2: sweep orphan files (e.g. a .bak whose base file was already gone).
            for (const entry of fs.readdirSync(dir)) {
                const full = path.join(dir, entry);
                if (fs.statSync(full).isFile()) {
                    fs.unlinkSync(full);
                    files.push({ destination: full, action: 'removed' });
                }
            }
            // rmdir if empty (restored user edits keep nested dirs — and thus the parent — alive).
            if (fs.readdirSync(dir).length === 0)
                fs.rmdirSync(dir);
        };
        sweep(commandsDir);
        return !fs.existsSync(commandsDir);
    }
    copyDir(src, dst) {
        if (!fs.existsSync(src)) {
            return [{ source: src, destination: dst, action: 'skipped-not-found' }];
        }
        if (!fs.existsSync(dst))
            fs.mkdirSync(dst, { recursive: true });
        const actions = [];
        for (const entry of fs.readdirSync(src)) {
            const srcFile = path.join(src, entry);
            const dstFile = path.join(dst, entry);
            const stat = fs.statSync(srcFile);
            if (stat.isFile()) {
                actions.push(this.copyFile(srcFile, dstFile));
            }
            else if (stat.isDirectory()) {
                // Recurse into subdirs so nested namespaces (e.g. commands/openlife/agents,
                // commands/openlife/flow) install as `/openlife:agents:*` and `/openlife:flow:*`.
                actions.push(...this.copyDir(srcFile, dstFile));
            }
        }
        return actions;
    }
    /**
     * Story 3.4 — Remove a file installed by Story 3.3, restoring the user's edit
     * from .bak if one exists. Returns the action descriptor for the result envelope.
     *
     * Behavior:
     *   - dst exists, dst.bak exists: rename dst.bak → dst (user's edit survives), action='restored-from-bak'
     *   - dst exists, no .bak: unlink dst, action='removed'
     *   - dst missing: action='skipped-not-found'
     */
    removeFileWithBakRestore(dst) {
        if (!fs.existsSync(dst)) {
            return { destination: dst, action: 'skipped-not-found' };
        }
        const backupPath = `${dst}.bak`;
        if (fs.existsSync(backupPath)) {
            // Restore user's edit. fs.renameSync overwrites the target atomically on POSIX/NTFS.
            fs.unlinkSync(dst);
            fs.renameSync(backupPath, dst);
            return { destination: dst, action: 'restored-from-bak', backupPath };
        }
        fs.unlinkSync(dst);
        return { destination: dst, action: 'removed' };
    }
    copyFile(src, dst) {
        const srcContent = fs.readFileSync(src, 'utf-8');
        if (!fs.existsSync(dst)) {
            fs.writeFileSync(dst, srcContent, 'utf-8');
            return { source: src, destination: dst, action: 'created' };
        }
        const existing = fs.readFileSync(dst, 'utf-8');
        if (existing === srcContent) {
            return { source: src, destination: dst, action: 'skipped-identical' };
        }
        // Different content — back up the existing file before overwriting.
        const backupPath = `${dst}.bak`;
        fs.writeFileSync(backupPath, existing, 'utf-8');
        fs.writeFileSync(dst, srcContent, 'utf-8');
        return { source: src, destination: dst, action: 'updated', backupPath };
    }
}
exports.HostInstaller = HostInstaller;
