import { spawn } from 'node:child_process';
import { EventEmitter } from 'node:events';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const DEFAULT_OPTIONS = {
    args: [],
    env: {},
    timeout: 30000,
    cwd: process.cwd(),
    inheritEnv: true,
    nodeArgs: [],
};
/**
 * Gets the path to the CLI entry point.
 * Prefers the compiled dist/cli.js, falls back to source/cli.tsx.
 * @returns Absolute path to the CLI entry point
 * @throws Error if neither path exists
 */
export function getCLIPath() {
    const distPath = path.resolve(__dirname, '../../dist/cli.js');
    if (fs.existsSync(distPath)) {
        return distPath;
    }
    const sourcePath = path.resolve(__dirname, '../cli.tsx');
    if (fs.existsSync(sourcePath)) {
        return sourcePath;
    }
    throw new Error('CLI entry point not found. Please build the project first with `pnpm build`.');
}
/**
 * Checks if the CLI path requires tsx to execute (TypeScript source files).
 * @param cliPath - Path to the CLI file
 * @returns true if the file is .ts or .tsx
 */
export function needsTsx(cliPath) {
    return cliPath.endsWith('.tsx') || cliPath.endsWith('.ts');
}
/**
 * A test harness for spawning and controlling CLI processes.
 * Extends EventEmitter and emits 'stdout', 'stderr', 'exit', and 'signal-sent' events.
 *
 * @example
 * ```typescript
 * const harness = createCLITestHarness();
 * const result = await harness.run({ args: ['run', 'hello world'] });
 * assertExitCode(result, 0);
 * ```
 */
export class CLITestHarness extends EventEmitter {
    process = null;
    startTime = 0;
    result = null;
    stdoutChunks = [];
    stderrChunks = [];
    timeoutId = null;
    signalTimeoutId = null;
    _timedOut = false;
    stdoutListener = null;
    stderrListener = null;
    /**
     * Spawns the CLI process with the given options and waits for it to exit.
     * @param options - Configuration options for the CLI execution
     * @returns Promise that resolves with the test result
     * @throws Error if called while a process is already running
     */
    async run(options = {}) {
        if (this.isRunning()) {
            throw new Error('CLITestHarness: Cannot call run() while a process is already running. ' +
                'Create a new harness instance or wait for the current process to complete.');
        }
        const opts = { ...DEFAULT_OPTIONS, ...options };
        const cliPath = getCLIPath();
        let command;
        let args;
        if (needsTsx(cliPath)) {
            command = 'npx';
            args = ['tsx', ...opts.nodeArgs, cliPath, ...opts.args];
        }
        else {
            command = 'node';
            args = [...opts.nodeArgs, cliPath, ...opts.args];
        }
        const env = {
            NODE_ENV: 'test',
            FORCE_COLOR: '0',
            NO_COLOR: '1',
            ...(opts.inheritEnv ? process.env : {}),
            ...opts.env,
        };
        const spawnOptions = {
            cwd: opts.cwd,
            env,
            stdio: ['pipe', 'pipe', 'pipe'],
        };
        return new Promise((resolve, reject) => {
            this.startTime = Date.now();
            this.stdoutChunks = [];
            this.stderrChunks = [];
            this._timedOut = false;
            try {
                this.process = spawn(command, args, spawnOptions);
            }
            catch (error) {
                reject(new Error(`Failed to spawn process: ${error}`));
                return;
            }
            if (opts.timeout && opts.timeout > 0) {
                this.timeoutId = setTimeout(() => {
                    if (this.process && !this.process.killed) {
                        this._timedOut = true;
                        this.process.kill('SIGKILL');
                    }
                }, opts.timeout);
            }
            if (opts.sendSignal) {
                const { signal, delayMs } = opts.sendSignal;
                this.signalTimeoutId = setTimeout(() => {
                    if (this.process && !this.process.killed) {
                        this.process.kill(signal);
                        this.emit('signal-sent', signal);
                    }
                }, delayMs);
            }
            if (opts.stdin !== undefined && this.process.stdin) {
                this.process.stdin.write(opts.stdin);
                this.process.stdin.end();
            }
            else if (this.process.stdin) {
                this.process.stdin.end();
            }
            if (this.process.stdout) {
                this.stdoutListener = (chunk) => {
                    this.stdoutChunks.push(chunk);
                    this.emit('stdout', chunk.toString());
                };
                this.process.stdout.on('data', this.stdoutListener);
            }
            if (this.process.stderr) {
                this.stderrListener = (chunk) => {
                    this.stderrChunks.push(chunk);
                    this.emit('stderr', chunk.toString());
                };
                this.process.stderr.on('data', this.stderrListener);
            }
            this.process.on('exit', (code, signal) => {
                this.cleanup();
                this.result = this.buildResult(code, signal, this._timedOut);
                this.emit('exit', this.result);
                resolve(this.result);
            });
            this.process.on('error', error => {
                this.cleanup();
                reject(error);
            });
        });
    }
    /**
     * Sends a signal to the running process.
     * @param signal - The signal to send (e.g., 'SIGINT', 'SIGTERM')
     * @returns true if the signal was sent, false if no process is running
     */
    sendSignal(signal) {
        if (this.process && !this.process.killed) {
            return this.process.kill(signal);
        }
        return false;
    }
    /**
     * Writes data to the process's stdin.
     * @param data - The string data to write
     * @returns true if data was written, false if no process or stdin is unavailable
     */
    writeToStdin(data) {
        if (this.process?.stdin && !this.process.stdin.destroyed) {
            this.process.stdin.write(data);
            return true;
        }
        return false;
    }
    /**
     * Closes the process's stdin stream.
     * @returns true if stdin was closed, false if no process or stdin is unavailable
     */
    closeStdin() {
        if (this.process?.stdin && !this.process.stdin.destroyed) {
            this.process.stdin.end();
            return true;
        }
        return false;
    }
    /**
     * Kills the running process with the specified signal.
     * @param signal - The signal to use (default: 'SIGTERM')
     * @returns true if the process was killed, false if no process is running
     */
    kill(signal = 'SIGTERM') {
        if (this.process && !this.process.killed) {
            return this.process.kill(signal);
        }
        return false;
    }
    /**
     * Checks if a process is currently running.
     * @returns true if a process is running and has not exited
     */
    isRunning() {
        return (this.process !== null &&
            !this.process.killed &&
            this.process.exitCode === null);
    }
    /**
     * Gets the current accumulated stdout output.
     * @returns The stdout output collected so far
     */
    getCurrentStdout() {
        const length = this.stdoutChunks.length;
        if (length === 0) {
            return '';
        }
        if (length === 1) {
            return this.stdoutChunks[0].toString();
        }
        return Buffer.concat(this.stdoutChunks).toString();
    }
    getCurrentStderr() {
        const length = this.stderrChunks.length;
        if (length === 0) {
            return '';
        }
        if (length === 1) {
            return this.stderrChunks[0].toString();
        }
        if (this.stderrChunks.length === 0)
            return '';
        if (this.stderrChunks.length === 1)
            return this.stderrChunks[0].toString();
        return Buffer.concat(this.stderrChunks).toString();
    }
    /**
     * Waits for output matching a pattern to appear in the process output.
     * @param pattern - String or RegExp to match against output
     * @param options - Options for timeout and which stream(s) to check
     * @returns Promise that resolves with the matched string
     * @throws Error if timeout is reached before pattern is found
     */
    async waitForOutput(pattern, options = {}) {
        const { timeout = 10000, stream = 'both' } = options;
        // Pattern is provided by test code, not user input - ReDoS is not a concern here
        const regex = typeof pattern === 'string'
            ? new RegExp(pattern) /* nosemgrep */
            : pattern;
        return new Promise((resolve, reject) => {
            const timeoutId = setTimeout(() => {
                reject(new Error(`Timed out waiting for output matching: ${pattern}`));
            }, timeout);
            const checkOutput = () => {
                const stdoutText = this.getCurrentStdout();
                const stderrText = this.getCurrentStderr();
                let textToCheck = '';
                if (stream === 'stdout') {
                    textToCheck = stdoutText;
                }
                else if (stream === 'stderr') {
                    textToCheck = stderrText;
                }
                else {
                    textToCheck = stdoutText + stderrText;
                }
                const match = regex.exec(textToCheck);
                if (match) {
                    clearTimeout(timeoutId);
                    resolve(match[0]);
                }
            };
            checkOutput();
            const onStdout = () => {
                if (stream === 'stdout' || stream === 'both')
                    checkOutput();
            };
            const onStderr = () => {
                if (stream === 'stderr' || stream === 'both')
                    checkOutput();
            };
            this.on('stdout', onStdout);
            this.on('stderr', onStderr);
            setTimeout(() => {
                this.off('stdout', onStdout);
                this.off('stderr', onStderr);
            }, timeout + 100);
        });
    }
    cleanup() {
        if (this.process?.stdout && this.stdoutListener) {
            this.process.stdout.off('data', this.stdoutListener);
            this.stdoutListener = null;
        }
        if (this.process?.stderr && this.stderrListener) {
            this.process.stderr.off('data', this.stderrListener);
            this.stderrListener = null;
        }
        if (this.timeoutId) {
            clearTimeout(this.timeoutId);
            this.timeoutId = null;
        }
        if (this.signalTimeoutId) {
            clearTimeout(this.signalTimeoutId);
            this.signalTimeoutId = null;
        }
        this.process = null;
    }
    buildResult(exitCode, signal, timedOut) {
        return {
            exitCode,
            signal,
            stdout: Buffer.concat(this.stdoutChunks).toString(),
            stderr: Buffer.concat(this.stderrChunks).toString(),
            timedOut,
            duration: Date.now() - this.startTime,
            killed: this.process?.killed ?? false,
        };
    }
}
/**
 * Creates a new CLITestHarness instance.
 * @returns A new CLITestHarness ready to run tests
 */
export function createCLITestHarness() {
    return new CLITestHarness();
}
/**
 * Asserts that the process exited with the expected exit code.
 * @param result - The CLI test result to check
 * @param expectedCode - The expected exit code
 * @throws Error if the exit code doesn't match
 */
export function assertExitCode(result, expectedCode) {
    if (result.exitCode !== expectedCode) {
        throw new Error(`Expected exit code ${expectedCode}, but got ${result.exitCode}.\n` +
            `stdout: ${result.stdout}\n` +
            `stderr: ${result.stderr}`);
    }
}
/**
 * Asserts that the process was terminated by the expected signal.
 * @param result - The CLI test result to check
 * @param expectedSignal - The expected termination signal
 * @throws Error if the signal doesn't match
 */
export function assertSignal(result, expectedSignal) {
    if (result.signal !== expectedSignal) {
        throw new Error(`Expected signal ${expectedSignal}, but got ${result.signal}.\n` +
            `stdout: ${result.stdout}\n` +
            `stderr: ${result.stderr}`);
    }
}
/**
 * Asserts that the process timed out.
 * @param result - The CLI test result to check
 * @throws Error if the process did not time out
 */
export function assertTimedOut(result) {
    if (!result.timedOut) {
        throw new Error(`Expected process to time out, but it exited with code ${result.exitCode}.\n` +
            `stdout: ${result.stdout}\n` +
            `stderr: ${result.stderr}`);
    }
}
/**
 * Asserts that stdout contains the expected pattern.
 * @param result - The CLI test result to check
 * @param pattern - String or RegExp to match against stdout
 * @throws Error if the pattern is not found in stdout
 */
export function assertStdoutContains(result, pattern) {
    const matches = typeof pattern === 'string'
        ? result.stdout.includes(pattern)
        : pattern.test(result.stdout);
    if (!matches) {
        const patternStr = typeof pattern === 'string' ? `"${pattern}"` : pattern.toString();
        throw new Error(`Expected stdout to contain ${patternStr}, but it was:\n${result.stdout}`);
    }
}
/**
 * Asserts that stderr contains the expected pattern.
 * @param result - The CLI test result to check
 * @param pattern - String or RegExp to match against stderr
 * @throws Error if the pattern is not found in stderr
 */
export function assertStderrContains(result, pattern) {
    const matches = typeof pattern === 'string'
        ? result.stderr.includes(pattern)
        : pattern.test(result.stderr);
    if (!matches) {
        const patternStr = typeof pattern === 'string' ? `"${pattern}"` : pattern.toString();
        throw new Error(`Expected stderr to contain ${patternStr}, but it was:\n${result.stderr}`);
    }
}
/**
 * Asserts that the process completed within the specified time.
 * @param result - The CLI test result to check
 * @param maxDurationMs - Maximum allowed duration in milliseconds
 * @throws Error if the process took longer than the specified time
 */
export function assertCompletedWithin(result, maxDurationMs) {
    if (result.duration > maxDurationMs) {
        throw new Error(`Expected process to complete within ${maxDurationMs}ms, ` +
            `but it took ${result.duration}ms.`);
    }
}
//# sourceMappingURL=cli-test-harness.js.map