import {combineErrors, log, type Logger} from '@augment-vir/common';
import {
    type MaybePromise,
    type PartialWithUndefined,
    type RequiredAndNotNull,
} from '@augment-vir/core';
import {type ChildProcess, type ExecException, spawn} from 'node:child_process';
import {defineTypedCustomEvent, ListenTarget} from 'typed-event-target';

/**
 * All output from {@link runShellCommand}.
 *
 * @category Node : Terminal : Util
 * @category Package : @augment-vir/node
 * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
 */
export type ShellOutput = {
    error: undefined | Error;
    stderr: string;
    stdout: string;
    exitCode: number | undefined;
    exitSignal: NodeJS.Signals | undefined;
};

/**
 * An event that indicates that the shell command just wrote to stdout.
 *
 * @category Node : Terminal : Util
 * @category Package : @augment-vir/node
 * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
 */
export class ShellStdoutEvent extends defineTypedCustomEvent<string | Buffer>()('shell-stdout') {}
/**
 * An event that indicates that the shell command just wrote to stderr.
 *
 * @category Node : Terminal : Util
 * @category Package : @augment-vir/node
 * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
 */
export class ShellStderrEvent extends defineTypedCustomEvent<string | Buffer>()('shell-stderr') {}
/**
 * An event that indicates that the shell command is finished. This contains an exit code or an exit
 * signal. Based on the Node.js documentation, either one or the other is defined, never both at the
 * same time.
 *
 * @category Node : Terminal : Util
 * @category Package : @augment-vir/node
 * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
 */
export class ShellDoneEvent extends defineTypedCustomEvent<{
    exitCode: number | undefined;
    exitSignal: NodeJS.Signals | undefined;
}>()('shell-done') {}
/**
 * An event that indicates that the shell command errored.
 *
 * @category Node : Terminal : Util
 * @category Package : @augment-vir/node
 * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
 */
export class ShellErrorEvent extends defineTypedCustomEvent<Error>()('shell-error') {}

/**
 * A shell command listen target that emits events.
 *
 * @category Node : Terminal : Util
 * @category Package : @augment-vir/node
 * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
 */
export class ShellTarget extends ListenTarget<
    ShellStdoutEvent | ShellStderrEvent | ShellDoneEvent | ShellErrorEvent
> {
    constructor(public readonly childProcess: ChildProcess) {
        super();
    }
}

/**
 * Options for {@link streamShellCommand}.
 *
 * @category Node : Terminal : Util
 * @category Package : @augment-vir/node
 * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
 */
export type StreamShellCommandOptions = PartialWithUndefined<{
    cwd: string;
    env: NodeJS.ProcessEnv;
    shell: string;
    /** Automatically hook up stdout and stderr printing to the caller's console methods. */
    hookUpToConsole: boolean;
}>;

/**
 * Runs a shell command and returns a {@link ShellTarget} instance for directly hooking into shell
 * events. This allows instant reactions to shell events but in a less convenient API compared to
 * {@link runShellCommand}.
 *
 * @category Node : Terminal
 * @category Package : @augment-vir/node
 * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
 * @see
 * - {@link runShellCommand}: a higher level and more succinct way of running a shell command.
 */
export function streamShellCommand(
    command: string,
    {
        cwd,
        env = process.env,
        shell = 'bash',
        hookUpToConsole = false,
    }: StreamShellCommandOptions = {},
): ShellTarget {
    const stdio = hookUpToConsole ? [process.stdin] : undefined;

    const childProcess = spawn(command, {
        shell,
        cwd,
        env,
        stdio,
    });
    const shellTarget = new ShellTarget(childProcess);

    /** Type guards. */
    /* node:coverage ignore next 5 */
    if (!childProcess.stdout) {
        throw new Error('stdout emitter was not created by exec for some reason.');
    } else if (!childProcess.stderr) {
        throw new Error('stderr emitter was not created by exec for some reason.');
    }

    childProcess.stdout.on('data', (chunk) => {
        shellTarget.dispatch(
            new ShellStdoutEvent({
                detail: chunk,
            }),
        );
    });
    childProcess.stderr.on('data', (chunk) => {
        shellTarget.dispatch(
            new ShellStderrEvent({
                detail: chunk,
            }),
        );
    });

    /** Idk how to trigger the 'error' event. */
    /* node:coverage ignore next 7 */
    childProcess.on('error', (error) => {
        shellTarget.dispatch(
            new ShellErrorEvent({
                detail: error,
            }),
        );
    });
    /**
     * Based on the Node.js documentation, we should listen to "close" instead of "exit" because the
     * io streams will be finished when "close" emits. Also "close" always emits after "exit"
     * anyway.
     */
    childProcess.on('close', (inputExitCode, inputExitSignal) => {
        /** Idk how to control exitCode or exitSignal being null or not-null. */
        /* node:coverage ignore next 2 */
        const exitCode: number | undefined = inputExitCode ?? undefined;
        const exitSignal: NodeJS.Signals | undefined = inputExitSignal ?? undefined;

        if ((exitCode !== undefined && exitCode !== 0) || exitSignal !== undefined) {
            const execException: ExecException & {cwd?: string | undefined} = Object.assign(
                new Error(`Command failed: ${command}`),
                {
                    cmd: command,
                },
            );
            if (exitCode != undefined) {
                execException.code = exitCode;
            }
            /* node:coverage ignore next 3: idk how to get this to trigger */
            if (exitSignal != undefined) {
                execException.signal = exitSignal;
            }
            execException.killed = childProcess.killed;
            execException.cwd = cwd;
            shellTarget.dispatch(
                new ShellErrorEvent({
                    detail: execException,
                }),
            );
        }
        shellTarget.dispatch(
            new ShellDoneEvent({
                detail: {
                    exitCode,
                    exitSignal,
                },
            }),
        );
    });

    return shellTarget;
}

/**
 * Options for {@link runShellCommand}.
 *
 * @category Node : Terminal : Util
 * @category Package : @augment-vir/node
 * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
 */
export type RunShellCommandOptions = StreamShellCommandOptions &
    PartialWithUndefined<{
        /** @default false */
        rejectOnError: boolean;
        /** Callback to call whenever the shell logs to stdout. */
        stdoutCallback: (
            stdout: string,
            childProcess: ChildProcess,
        ) => MaybePromise<void> | undefined;
        /** Callback to call whenever the shell logs to stderr. */
        stderrCallback: (
            stderr: string,
            childProcess: ChildProcess,
        ) => MaybePromise<void> | undefined;
    }>;

/**
 * Runs a shell command and returns its output.
 *
 * @category Node : Terminal
 * @category Package : @augment-vir/node
 * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
 * @see
 * - {@link streamShellCommand}: a lower level way of running a shell command that allows instant reactions to shell events.
 */
export async function runShellCommand(
    command: string,
    options: RunShellCommandOptions = {},
): Promise<ShellOutput> {
    return new Promise<ShellOutput>((resolve, reject) => {
        let stdout = '';
        let stderr = '';
        const errors: Error[] = [];

        const shellTarget = streamShellCommand(command, {
            cwd: options.cwd,
            shell: options.shell,
            env: options.env,
            hookUpToConsole: options.hookUpToConsole,
        });

        shellTarget.listen(ShellStdoutEvent, ({detail: chunk}) => {
            if (options.stdoutCallback) {
                void options.stdoutCallback(chunk.toString(), shellTarget.childProcess);
            }
            if (options.hookUpToConsole) {
                process.stdout.write(chunk.toString());
            }
            stdout += String(chunk);
        });
        shellTarget.listen(ShellStderrEvent, ({detail: chunk}) => {
            if (options.stderrCallback) {
                void options.stderrCallback(chunk.toString(), shellTarget.childProcess);
            }
            if (options.hookUpToConsole) {
                process.stderr.write(chunk.toString());
            }
            stderr += String(chunk);
        });
        shellTarget.listen(ShellErrorEvent, ({detail: error}) => {
            errors.push(error);
            if (!options.rejectOnError) {
                return;
            }

            /** Covering edge cases. */
            /* node:coverage disable */
            if (shellTarget.childProcess.connected) {
                shellTarget.childProcess.disconnect();
            }
            if (
                shellTarget.childProcess.exitCode == null &&
                shellTarget.childProcess.signalCode == null &&
                !shellTarget.childProcess.killed
            ) {
                shellTarget.childProcess.kill();
            }
            /* node:coverage enable */

            shellTarget.destroy();

            const rejectionErrorMessage: Error = combineErrors([
                new Error(stderr),
                ...errors,
            ]);
            /** Reject now because the "done" listener won't get fired after killing the process. */
            reject(rejectionErrorMessage);
        });
        shellTarget.listen(ShellDoneEvent, ({detail: {exitCode, exitSignal}}) => {
            shellTarget.destroy();
            resolve({
                error: errors.length ? combineErrors(errors) : undefined,
                stdout,
                stderr,
                exitCode,
                exitSignal,
            });
        });
    });
}

/**
 * Options for {@link logShellOutput}.
 *
 * @category Node : Terminal : Util
 * @category Package : @augment-vir/node
 * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
 */
export type LogShellOutputOptions = PartialWithUndefined<{
    logger: Logger;
    withLabels: boolean;
    ignoreError: boolean;
}>;

const defaultLogShellOutputOptions: RequiredAndNotNull<LogShellOutputOptions> = {
    ignoreError: false,
    logger: log,
    withLabels: false,
};

/**
 * Log the output of running a shell command. This is useful for quick debugging of shell commands.
 *
 * @category Node : Terminal : Util
 * @category Package : @augment-vir/node
 * @package [`@augment-vir/node`](https://www.npmjs.com/package/@augment-vir/node)
 */
export function logShellOutput(
    shellOutput: PartialWithUndefined<Omit<ShellOutput, 'exitSignal'>>,
    {
        ignoreError = defaultLogShellOutputOptions.ignoreError,
        logger = defaultLogShellOutputOptions.logger,
        withLabels = defaultLogShellOutputOptions.withLabels,
    }: LogShellOutputOptions = defaultLogShellOutputOptions,
) {
    logger.if(withLabels).info('exit code');
    logger.if(shellOutput.exitCode != undefined || withLabels).plain(shellOutput.exitCode || 0);

    logger.if(withLabels).info('stdout');
    logger.if(!!shellOutput.stdout || withLabels).plain(shellOutput.stdout || '');

    logger.if(withLabels).info('stderr');
    logger.if(!!shellOutput.stderr || withLabels).error(shellOutput.stderr || '');

    logger.if(withLabels && !ignoreError).info('error');
    logger.if((!!shellOutput.error || withLabels) && !ignoreError).error(shellOutput.error);
}
