import { type SpawnOptions, spawn as spawnOg } from "node:child_process";
import * as sfs from "node:fs";
import * as fs from "node:fs/promises";
import * as path from "node:path";

import Debug from "debug";
import { LRUCache } from "lru-cache";
import type { Observer, Subject } from "rxjs";
import { AsyncSubject, merge, Observable, of, Subscription, timer } from "rxjs";
import { map, reduce, retry as rxRetry } from "rxjs/operators";

const isWindows = process.platform === "win32";

const d = Debug("spawn-rx"); // tslint:disable-line:no-var-requires

/**
 * Custom error class for spawn operations with additional metadata
 */
export class SpawnError extends Error {
  public readonly exitCode: number;
  public readonly code: number;
  public readonly stdout?: string;
  public readonly stderr?: string;
  public readonly command: string;
  public readonly args: string[];

  constructor(message: string, exitCode: number, command: string, args: string[], stdout?: string, stderr?: string) {
    super(message);
    this.name = "SpawnError";
    this.exitCode = exitCode;
    this.code = exitCode;
    this.stdout = stdout;
    this.stderr = stderr;
    this.command = command;
    this.args = args;

    // Maintains proper stack trace for where our error was thrown (only available on V8)
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    if ((Error as any).captureStackTrace) {
      // eslint-disable-next-line @typescript-eslint/no-explicit-any
      (Error as any).captureStackTrace(this, SpawnError);
    }
  }
}

/**
 * Process metadata tracked during execution
 */
export interface ProcessMetadata {
  pid: number;
  startTime: number;
  command: string;
  args: string[];
}

/**
 * stat a file but don't throw if it doesn't exist
 *
 * @param  {string} file The path to a file
 * @return {Stats}       The stats structure
 *
 * @private
 */
export function statSyncNoException(file: string): sfs.Stats | null {
  try {
    return sfs.statSync(file);
  } catch {
    return null;
  }
}

/**
 * stat a file but don't throw if it doesn't exist
 *
 * @param  {string} file The path to a file
 * @return {Stats}       The stats structure
 *
 * @private
 */
export function statNoException(file: string): Promise<sfs.Stats | null> {
  return fs.stat(file).catch(() => null);
}

/**
 * Cache for resolved executable paths
 */
const pathCache = new LRUCache<string, string>({ max: 512 });

/**
 * Search PATH to see if a file exists in any of the path folders.
 *
 * @param  {string} exe The file to search for
 * @return {string}     A fully qualified path, or the original path if nothing
 *                      is found
 *
 * @private
 */
function runDownPath(exe: string): string {
  // Check cache first
  const cached = pathCache.get(exe);
  if (cached !== undefined) {
    d(`Cache hit for executable: ${exe} -> ${cached}`);
    return cached;
  }

  // NB: Windows won't search PATH looking for executables in spawn like
  // Posix does

  // Files with any directory path don't get this applied
  if (exe.match(/[\\/]/)) {
    d("Path has slash in directory, bailing");
    pathCache.set(exe, exe);
    return exe;
  }

  const target = path.join(".", exe);
  if (statSyncNoException(target)) {
    d(`Found executable in current directory: ${target}`);

    // XXX: Some very Odd programs decide to use args[0] as a parameter
    // to determine what to do, and also symlink themselves, so we can't
    // use realpathSync here like we used to
    pathCache.set(exe, target);
    return target;
  }

  const haystack = process.env.PATH?.split(isWindows ? ";" : ":");
  if (haystack) {
    for (const p of haystack) {
      const needle = path.join(p, exe);
      if (statSyncNoException(needle)) {
        // NB: Same deal as above
        pathCache.set(exe, needle);
        return needle;
      }
    }
  }

  d("Failed to find executable anywhere in path");
  pathCache.set(exe, exe);
  return exe;
}

export type CmdWithArgs = {
  cmd: string;
  args: string[];
};

/**
 * Finds the actual executable and parameters to run on Windows. This method
 * mimics the POSIX behavior of being able to run scripts as executables by
 * replacing the passed-in executable with the script runner, for PowerShell,
 * CMD, and node scripts.
 *
 * This method also does the work of running down PATH, which spawn on Windows
 * also doesn't do, unlike on POSIX.
 *
 * @param  {string} exe           The executable to run
 * @param  {string[]} args   The arguments to run
 *
 * @return {Object}               The cmd and args to run
 * @property {string} cmd         The command to pass to spawn
 * @property {string[]} args The arguments to pass to spawn
 */
export function findActualExecutable(exe: string, args: string[]): CmdWithArgs {
  // POSIX can just execute scripts directly, no need for silly goosery
  if (process.platform !== "win32") {
    return { cmd: runDownPath(exe), args: args };
  }

  if (!sfs.existsSync(exe)) {
    // NB: When you write something like `surf-client ... -- surf-build` on Windows,
    // a shell would normally convert that to surf-build.cmd, but since it's passed
    // in as an argument, it doesn't happen
    const possibleExts = [".exe", ".bat", ".cmd", ".ps1"];
    for (const ext of possibleExts) {
      const possibleFullPath = runDownPath(`${exe}${ext}`);

      if (sfs.existsSync(possibleFullPath)) {
        return findActualExecutable(possibleFullPath, args);
      }
    }
  }

  if (exe.match(/\.ps1$/i)) {
    const cmd = path.join(process.env.SYSTEMROOT!, "System32", "WindowsPowerShell", "v1.0", "PowerShell.exe");
    const psargs = ["-ExecutionPolicy", "Unrestricted", "-NoLogo", "-NonInteractive", "-File", exe];

    return { cmd: cmd, args: psargs.concat(args) };
  }

  if (exe.match(/\.(bat|cmd)$/i)) {
    const cmd = path.join(process.env.SYSTEMROOT!, "System32", "cmd.exe");
    const cmdArgs = ["/C", exe, ...args];

    return { cmd: cmd, args: cmdArgs };
  }

  if (exe.match(/\.(js)$/i)) {
    const cmd = process.execPath;
    const nodeArgs = [exe];

    return { cmd: cmd, args: nodeArgs.concat(args) };
  }

  // Dunno lol
  return { cmd: exe, args: args };
}

export type SpawnRxExtras = {
  stdin?: Observable<string>;
  echoOutput?: boolean;
  split?: boolean;
  encoding?: BufferEncoding;
  /**
   * Timeout in milliseconds. If the process doesn't complete within this time,
   * it will be killed and the observable will error with a TimeoutError.
   */
  timeout?: number;
  /**
   * Number of retry attempts if the process fails (non-zero exit code).
   * Defaults to 0 (no retries).
   */
  retries?: number;
  /**
   * Delay in milliseconds between retry attempts. Defaults to 1000ms.
   */
  retryDelay?: number;
};

export type OutputLine = {
  source: "stdout" | "stderr";
  text: string;
};

/**
 * Utility type to extract the return type based on split option
 */
export type SpawnResult<T extends SpawnRxExtras> = T extends { split: true }
  ? Observable<OutputLine>
  : Observable<string>;

/**
 * Utility type to extract the promise return type based on split option
 */
export type SpawnPromiseResult<T extends SpawnRxExtras> = T extends {
  split: true;
}
  ? Promise<[string, string]>
  : Promise<string>;

/**
 * Spawns a process attached as a child of the current process.
 *
 * @param  {string} exe               The executable to run
 * @param  {string[]} params     The parameters to pass to the child
 * @param  {SpawnOptions & SpawnRxExtras} opts              Options to pass to spawn.
 *
 * @return {Observable<OutputLine>}       Returns an Observable that when subscribed
 *                                    to, will create a child process. The
 *                                    process output will be streamed to this
 *                                    Observable, and if unsubscribed from, the
 *                                    process will be terminated early. If the
 *                                    process terminates with a non-zero value,
 *                                    the Observable will terminate with onError.
 */
export function spawn(
  exe: string,
  params: string[],
  opts: SpawnOptions & SpawnRxExtras & { split: true },
): Observable<OutputLine>;

/**
 * Spawns a process attached as a child of the current process.
 *
 * @param  {string} exe               The executable to run
 * @param  {string[]} params     The parameters to pass to the child
 * @param  {SpawnOptions & SpawnRxExtras} opts              Options to pass to spawn.
 *
 * @return {Observable<string>}       Returns an Observable that when subscribed
 *                                    to, will create a child process. The
 *                                    process output will be streamed to this
 *                                    Observable, and if unsubscribed from, the
 *                                    process will be terminated early. If the
 *                                    process terminates with a non-zero value,
 *                                    the Observable will terminate with onError.
 */
export function spawn(
  exe: string,
  params: string[],
  opts?: SpawnOptions & SpawnRxExtras & { split: false | undefined },
): Observable<string>;

/**
 * Spawns a process attached as a child of the current process.
 *
 * @param  {string} exe               The executable to run
 * @param  {string[]} params     The parameters to pass to the child
 * @param  {SpawnOptions & SpawnRxExtras} opts              Options to pass to spawn.
 *
 * @return {Observable<string>}       Returns an Observable that when subscribed
 *                                    to, will create a child process. The
 *                                    process output will be streamed to this
 *                                    Observable, and if unsubscribed from, the
 *                                    process will be terminated early. If the
 *                                    process terminates with a non-zero value,
 *                                    the Observable will terminate with onError.
 */
export function spawn(
  exe: string,
  params: string[],
  opts?: SpawnOptions & SpawnRxExtras,
): Observable<string> | Observable<OutputLine> {
  opts = opts ?? {};
  const spawnObs: Observable<OutputLine> = new Observable((subj: Observer<OutputLine>) => {
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    const { encoding, timeout, ...spawnOpts } = opts;
    const { cmd, args } = findActualExecutable(exe, params);
    d(`spawning process: ${cmd} ${args.join()}, ${JSON.stringify(spawnOpts)}`);

    const proc = spawnOg(cmd, args, spawnOpts);
    // Process metadata is tracked but not currently exposed
    // Could be added to SpawnError or returned in a future enhancement
    // const _processMetadata: ProcessMetadata = {
    //   pid: proc.pid ?? 0,
    //   startTime: Date.now(),
    //   command: cmd,
    //   args: args,
    // };

    // Set up timeout if specified
    let timeoutHandle: NodeJS.Timeout | null = null;
    if (timeout && timeout > 0) {
      timeoutHandle = setTimeout(() => {
        d(`Process timeout reached: ${cmd} ${args.join()}`);
        if (!proc.killed) {
          proc.kill();
        }
        const error = new SpawnError(`Process timed out after ${timeout}ms`, -1, cmd, args);
        subj.error(error);
      }, timeout);
    }

    const bufHandler = (source: "stdout" | "stderr") => (b: string | Buffer) => {
      if (b.length < 1) {
        return;
      }

      if (opts.echoOutput) {
        (source === "stdout" ? process.stdout : process.stderr).write(b);
      }

      let chunk = "<< String sent back was too long >>";
      try {
        if (typeof b === "string") {
          chunk = b.toString();
        } else {
          chunk = b.toString(encoding || "utf8");
        }
      } catch {
        chunk = `<< Lost chunk of process output for ${exe} - length was ${b.length}>>`;
      }

      subj.next({ source: source, text: chunk });
    };

    const ret = new Subscription();

    if (opts.stdin) {
      if (proc.stdin) {
        const stdin = proc.stdin;
        ret.add(
          opts.stdin.subscribe({
            next: (x) => stdin.write(x),
            error: subj.error.bind(subj),
            complete: () => stdin.end(),
          }),
        );
      } else {
        subj.error(new Error(`opts.stdio conflicts with provided spawn opts.stdin observable, 'pipe' is required`));
      }
    }

    let stderrCompleted: Subject<boolean> | Observable<boolean> | null = null;
    let stdoutCompleted: Subject<boolean> | Observable<boolean> | null = null;
    let noClose = false;

    if (proc.stdout) {
      stdoutCompleted = new AsyncSubject<boolean>();
      proc.stdout.on("data", bufHandler("stdout"));
      proc.stdout.on("close", () => {
        (stdoutCompleted as Subject<boolean>).next(true);
        (stdoutCompleted as Subject<boolean>).complete();
      });
    } else {
      stdoutCompleted = of(true);
    }

    if (proc.stderr) {
      stderrCompleted = new AsyncSubject<boolean>();
      proc.stderr.on("data", bufHandler("stderr"));
      proc.stderr.on("close", () => {
        (stderrCompleted as Subject<boolean>).next(true);
        (stderrCompleted as Subject<boolean>).complete();
      });
    } else {
      stderrCompleted = of(true);
    }

    proc.on("error", (e: Error) => {
      noClose = true;
      if (timeoutHandle) {
        clearTimeout(timeoutHandle);
      }
      subj.error(e);
    });

    proc.on("close", (code: number) => {
      noClose = true;
      if (timeoutHandle) {
        clearTimeout(timeoutHandle);
      }
      const pipesClosed = merge(stdoutCompleted, stderrCompleted).pipe(reduce((_acc: boolean) => true, true));

      if (code === 0) {
        pipesClosed.subscribe(() => subj.complete());
      } else {
        pipesClosed.subscribe(() => {
          const error = new SpawnError(`Process failed with exit code: ${code}`, code, cmd, args);
          subj.error(error);
        });
      }
    });

    ret.add(
      new Subscription(() => {
        if (noClose) {
          return;
        }

        if (timeoutHandle) {
          clearTimeout(timeoutHandle);
        }

        d(`Killing process: ${cmd} ${args.join()}`);
        proc.kill();
      }),
    );

    return ret;
  });

  let resultObs: Observable<OutputLine> = spawnObs;

  // Apply retry logic if specified
  if (opts.retries && opts.retries > 0) {
    const retryCount = opts.retries;
    const delay = opts.retryDelay ?? 1000;
    resultObs = resultObs.pipe(
      rxRetry({
        count: retryCount,
        delay: (error: unknown, retryIndex: number) => {
          // Only retry on SpawnError with non-zero exit codes
          if (error instanceof SpawnError && error.exitCode !== 0) {
            d(`Retrying process (attempt ${retryIndex + 1}/${retryCount}): ${exe}`);
            return timer(delay);
          }
          // Don't retry on other errors
          throw error;
        },
      }),
    );
  }

  return opts.split ? resultObs : resultObs.pipe(map((x: OutputLine) => x?.text));
}

function wrapObservableInPromise(obs: Observable<string>) {
  return new Promise<string>((res, rej) => {
    let out = "";

    obs.subscribe({
      next: (x: string) => {
        out += x;
      },
      error: (e: unknown) => {
        if (e instanceof SpawnError) {
          const err = new SpawnError(`${out}\n${e.message}`, e.exitCode, e.command, e.args, out, e.stderr);
          rej(err);
        } else {
          const err = new Error(`${out}\n${e instanceof Error ? e.message : String(e)}`);
          rej(err);
        }
      },
      complete: () => res(out),
    });
  });
}

function wrapObservableInSplitPromise(obs: Observable<OutputLine>) {
  return new Promise<[string, string]>((res, rej) => {
    let out = "";
    let err = "";

    obs.subscribe({
      next: (x: OutputLine) => {
        if (x.source === "stdout") {
          out += x.text;
        } else {
          err += x.text;
        }
      },
      error: (e: unknown) => {
        if (e instanceof SpawnError) {
          const error = new SpawnError(`${out}\n${e.message}`, e.exitCode, e.command, e.args, out, err);
          rej(error);
        } else {
          const error = new Error(`${out}\n${e instanceof Error ? e.message : String(e)}`);
          rej(error);
        }
      },
      complete: () => res([out, err]),
    });
  });
}

/**
 * Spawns a process as a child process.
 *
 * @param  {string} exe               The executable to run
 * @param  {string[]} params     The parameters to pass to the child
 * @param  {SpawnOptions & SpawnRxExtras} opts              Options to pass to spawn.
 *
 * @return {Promise<[string, string]>}       Returns an Promise that represents a child
 *                                 process. The value returned is the process
 *                                 output. If the process terminates with a
 *                                 non-zero value, the Promise will resolve with
 *                                 an Error.
 */
export function spawnPromise(
  exe: string,
  params: string[],
  opts: SpawnOptions & SpawnRxExtras & { split: true },
): Promise<[string, string]>;

/**
 * Spawns a process as a child process.
 *
 * @param  {string} exe               The executable to run
 * @param  {string[]} params     The parameters to pass to the child
 * @param  {SpawnOptions & SpawnRxExtras} opts              Options to pass to spawn.
 *
 * @return {Promise<string>}       Returns an Promise that represents a child
 *                                 process. The value returned is the process
 *                                 output. If the process terminates with a
 *                                 non-zero value, the Promise will resolve with
 *                                 an Error.
 */
export function spawnPromise(exe: string, params: string[], opts?: SpawnOptions & SpawnRxExtras): Promise<string>;

/**
 * Spawns a process as a child process.
 *
 * @param  {string} exe               The executable to run
 * @param  {string[]} params     The parameters to pass to the child
 * @param  {Object} opts              Options to pass to spawn.
 *
 * @return {Promise<string>}       Returns an Promise that represents a child
 *                                 process. The value returned is the process
 *                                 output. If the process terminates with a
 *                                 non-zero value, the Promise will resolve with
 *                                 an Error.
 */
export function spawnPromise(
  exe: string,
  params: string[],
  opts?: SpawnOptions & SpawnRxExtras,
): Promise<string> | Promise<[string, string]> {
  if (opts?.split) {
    return wrapObservableInSplitPromise(spawn(exe, params, { ...(opts ?? {}), split: true }));
  }
  return wrapObservableInPromise(spawn(exe, params, { ...(opts ?? {}), split: false }));
}
