import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { appendFileSync } from "node:fs";
import { join } from "node:path";
import kill from "tree-kill";
import { platform } from "node:os";

import { sleep } from "../utils/helpers.js";
import { sanitizeCliArgs } from "./spawnArgs.js";

/**
 * Represents a local node for running a localnet environment.
 * This class provides methods to start, stop, and check the status of the localnet process.
 * It manages the lifecycle of the node process and ensures that it is operational before executing tests.
 * @group Implementation
 * @category CLI
 */
export class LocalNode {
  readonly MAXIMUM_WAIT_TIME_SEC = 75;

  readonly READINESS_ENDPOINT = "http://127.0.0.1:8070/";

  showStdout: boolean = true;

  process: ChildProcessWithoutNullStreams | null = null;

  extraArgs: string[] = [];

  constructor(args?: { showStdout?: boolean; extraArgs?: string[] }) {
    this.showStdout = args?.showStdout ?? true;
    this.extraArgs = args?.extraArgs ?? [];
  }

  /**
   * Kills the current process and all its descendant processes.
   *
   * @returns {Promise<void>} A promise that resolves to true if the process was successfully killed.
   * @throws {Error} If there is an error while attempting to kill the process.
   * @group Implementation
   * @category CLI
   */
  async stop(): Promise<void> {
    await new Promise((resolve, reject) => {
      if (!this.process?.pid) return;

      /**
       * Terminates the process associated with the given process ID.
       *
       * @param pid - The process ID of the process to be terminated.
       * @param callback - A function that is called after the termination attempt is complete.
       * @param callback.err - An error object if the termination failed; otherwise, null.
       * @param callback.resolve - A boolean indicating whether the termination was successful.
       * @group Implementation
       * @category CLI
       */
      kill(this.process.pid, (err) => {
        if (err) {
          reject(err);
        } else {
          resolve(true);
        }
      });
    });
  }

  /**
   * Runs a localnet and waits for the process to be up.
   * If the local node process is already running, it returns without starting the process.
   *
   * @returns {Promise<void>} A promise that resolves when the process is up.
   * @group Implementation
   * @category CLI
   */
  async run(): Promise<void> {
    const nodeIsUp = await this.checkIfProcessIsUp();
    if (nodeIsUp) {
      return;
    }
    this.start();
    await this.waitUntilProcessIsUp();
  }

  /**
   * Starts the localnet by running the Aptos node with the specified command-line arguments.
   *
   * @returns {void}
   *
   * @throws {Error} If there is an issue starting the localnet.
   * @group Implementation
   * @category CLI
   */
  start(): void {
    // Reject shell-metacharacter-bearing extras up front and pass the scrubbed
    // copy to `spawn`. On Windows we have to spawn with `shell: true` (npx is a
    // .cmd shim and Node refuses to spawn .cmd/.bat without the shell since
    // CVE-2024-27980), so unsafe characters in extraArgs would otherwise be
    // interpreted by cmd.exe.
    const cliCommand = "npx";
    const cliArgs = sanitizeCliArgs([
      "aptos",
      "node",
      "run-localnet",
      "--force-restart",
      "--assume-yes",
      "--with-indexer-api",
      ...this.extraArgs,
    ]);

    const currentPlatform = platform();
    const spawnConfig = {
      env: { ...process.env, ENABLE_KEYLESS_DEFAULT: "1" },
      ...(currentPlatform === "win32" && { shell: true }),
    };

    this.process = spawn(cliCommand, cliArgs, spawnConfig);

    // CI prints `${{ runner.temp }}/local-testnet-logs.txt` on failure (TMPDIR).
    const logPath = process.env.TMPDIR ? join(process.env.TMPDIR, "local-testnet-logs.txt") : undefined;

    const handleOutput = (data: any) => {
      const str = data.toString();
      if (this.showStdout) {
        console.log(str);
      }
      if (logPath) {
        try {
          appendFileSync(logPath, str);
        } catch {
          // Best-effort logging only — never fail the node start on log I/O.
        }
      }
    };

    this.process.stdout?.on("data", handleOutput);
    this.process.stderr?.on("data", handleOutput);
  }

  /**
   * Waits for the localnet process to be operational within a specified maximum wait time.
   * This function continuously checks if the process is up and will throw an error if it fails to start.
   *
   * @returns Promise<boolean> - Resolves to true if the process is up, otherwise throws an error.
   * @group Implementation
   * @category CLI
   */
  async waitUntilProcessIsUp(): Promise<boolean> {
    let operational = await this.checkIfProcessIsUp();
    const start = Date.now() / 1000;
    let last = start;

    while (!operational && start + this.MAXIMUM_WAIT_TIME_SEC > last) {
      await sleep(1000);

      operational = await this.checkIfProcessIsUp();
      last = Date.now() / 1000;
    }

    // If we are here it means something blocks the process to start.
    // Might worth checking if another process is running on port 8080
    if (!operational) {
      throw new Error("Process failed to start");
    }

    return true;
  }

  /**
   * Checks if the localnet is up by querying the readiness endpoint.
   *
   * @returns Promise<boolean> - A promise that resolves to true if the localnet is up, otherwise false.
   * @group Implementation
   * @category CLI
   */
  async checkIfProcessIsUp(): Promise<boolean> {
    try {
      // Query readiness endpoint
      const data = await fetch(this.READINESS_ENDPOINT);
      if (data.status === 200) {
        return true;
      }
      return false;
    } catch {
      return false;
    }
  }
}
