import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
import { closeSync, createWriteStream, existsSync, openSync, readSync, statSync } from "node:fs";

export interface CommandResult {
  command: string[];
  cwd: string;
  exitCode: number | null;
  stdout: string;
  stderr: string;
  signal: NodeJS.Signals | null;
  timedOut: boolean;
  durationMs?: number;
  stdoutBytes?: number;
  stderrBytes?: number;
  stdoutTruncated?: boolean;
  stderrTruncated?: boolean;
  aborted?: boolean;
  terminationReason?: "timeout" | "abort" | null;
  processGroupId?: number | null;
  processGroupAliveAfterCleanup?: boolean | null;
}

export interface CommandProgress {
  elapsedMs: number;
  timeoutMs: number | null;
  pid: number | null;
  stdoutBytes: number;
  stderrBytes: number;
  lastOutputAgeMs: number | null;
}

export interface ObservedCommandOptions {
  timeoutMs?: number;
  heartbeatMs?: number;
  killAfterMs?: number;
  env?: NodeJS.ProcessEnv;
  input?: string;
  maxCaptureChars?: number;
  signal?: AbortSignal;
  onStderr?: (text: string) => void;
  onProgress?: (progress: CommandProgress) => void;
}

export function runCommand(command: string[], cwd: string, options: { timeoutMs?: number; env?: NodeJS.ProcessEnv; input?: string } = {}): CommandResult {
  const result = spawnSync(command[0], command.slice(1), {
    cwd,
    encoding: "utf8",
    env: options.env,
    input: options.input,
    maxBuffer: 1024 * 1024 * 8,
    timeout: options.timeoutMs,
  });
  const error = result.error as (Error & { code?: string }) | undefined;
  const stderr = result.stderr ?? error?.message ?? "";
  return {
    command,
    cwd,
    exitCode: result.status,
    stdout: result.stdout ?? "",
    stderr,
    signal: result.signal,
    timedOut: error?.code === "ETIMEDOUT",
  };
}

export async function runCommandObserved(command: string[], cwd: string, options: ObservedCommandOptions = {}): Promise<CommandResult> {
  const startedAt = Date.now();
  const timeoutMs = options.timeoutMs;
  const heartbeatMs = Math.max(0, options.heartbeatMs ?? 0);
  const maxCaptureChars = Math.max(1, options.maxCaptureChars ?? 1024 * 1024);
  let stdout = "";
  let stderr = "";
  let stdoutBytes = 0;
  let stderrBytes = 0;
  let stdoutTruncated = false;
  let stderrTruncated = false;
  let lastOutputAt: number | null = null;
  let timedOut = false;
  let aborted = false;
  let terminationReason: "timeout" | "abort" | null = null;
  let timeoutTimer: NodeJS.Timeout | undefined;
  let killTimer: NodeJS.Timeout | undefined;
  let heartbeatTimer: NodeJS.Timeout | undefined;
  let abortListener: (() => void) | undefined;

  const child = spawn(command[0], command.slice(1), {
    cwd,
    env: options.env,
    detached: process.platform !== "win32",
    stdio: ["pipe", "pipe", "pipe"],
  });

  child.stdout.on("data", (chunk: Buffer | string) => {
    const text = chunk.toString();
    stdoutBytes += Buffer.byteLength(text);
    lastOutputAt = Date.now();
    const captured = appendBounded(stdout, text, maxCaptureChars);
    stdout = captured.text;
    stdoutTruncated = stdoutTruncated || captured.truncated;
  });
  child.stderr.on("data", (chunk: Buffer | string) => {
    const text = chunk.toString();
    options.onStderr?.(text);
    stderrBytes += Buffer.byteLength(text);
    lastOutputAt = Date.now();
    const captured = appendBounded(stderr, text, maxCaptureChars);
    stderr = captured.text;
    stderrTruncated = stderrTruncated || captured.truncated;
  });

  child.stdin.end(options.input ?? "");

  if (heartbeatMs > 0) {
    heartbeatTimer = setInterval(() => {
      options.onProgress?.({
        elapsedMs: Date.now() - startedAt,
        timeoutMs: timeoutMs ?? null,
        pid: child.pid ?? null,
        stdoutBytes,
        stderrBytes,
        lastOutputAgeMs: lastOutputAt === null ? null : Date.now() - lastOutputAt,
      });
    }, heartbeatMs);
  }

  const terminate = (reason: "timeout" | "abort") => {
    if (terminationReason !== null) return;
    terminationReason = reason;
    timedOut = reason === "timeout";
    aborted = reason === "abort";
    killProcessTree(child, "SIGTERM");
    killTimer = setTimeout(() => {
      killProcessTree(child, "SIGKILL");
      killTimer = undefined;
    }, Math.max(100, options.killAfterMs ?? 5_000));
  };

  if (timeoutMs !== undefined) timeoutTimer = setTimeout(() => terminate("timeout"), timeoutMs);
  if (options.signal !== undefined) {
    abortListener = () => terminate("abort");
    options.signal.addEventListener("abort", abortListener, { once: true });
    if (options.signal.aborted) abortListener();
  }

  const completion = await new Promise<{ exitCode: number | null; signal: NodeJS.Signals | null; error?: Error }>((resolve) => {
    let resolved = false;
    const finish = (result: { exitCode: number | null; signal: NodeJS.Signals | null; error?: Error }) => {
      if (resolved) return;
      resolved = true;
      resolve(result);
    };
    child.on("error", (error) => finish({ exitCode: 127, signal: null, error }));
    child.on("close", (code, signal) => finish({ exitCode: code, signal }));
  });

  if (timeoutTimer !== undefined) clearTimeout(timeoutTimer);
  if (killTimer !== undefined) {
    clearTimeout(killTimer);
    killTimer = undefined;
  }
  if (heartbeatTimer !== undefined) clearInterval(heartbeatTimer);
  if (abortListener !== undefined) options.signal?.removeEventListener("abort", abortListener);

  const processGroupId = process.platform === "win32" ? null : child.pid ?? null;
  let processGroupAliveAfterCleanup: boolean | null = null;
  if (terminationReason !== null && processGroupId !== null) {
    killProcessTree(child, "SIGKILL");
    await new Promise((resolve) => setTimeout(resolve, 25));
    processGroupAliveAfterCleanup = processGroupExists(processGroupId);
  }

  if (completion.error !== undefined && stderr.length === 0) {
    stderr = completion.error.message;
    stderrBytes = Buffer.byteLength(stderr);
  }

  return {
    command,
    cwd,
    exitCode: completion.exitCode,
    stdout,
    stderr,
    signal: completion.signal,
    timedOut,
    durationMs: Date.now() - startedAt,
    stdoutBytes,
    stderrBytes,
    stdoutTruncated,
    stderrTruncated,
    aborted,
    terminationReason,
    processGroupId,
    processGroupAliveAfterCleanup,
  };
}

function processGroupExists(processGroupId: number): boolean {
  try {
    process.kill(-processGroupId, 0);
    return true;
  } catch {
    return false;
  }
}

function appendBounded(current: string, next: string, maxChars: number): { text: string; truncated: boolean } {
  const combined = `${current}${next}`;
  if (combined.length <= maxChars) return { text: combined, truncated: false };
  return { text: combined.slice(-maxChars), truncated: true };
}

function killProcessTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): boolean {
  if (child.pid === undefined) return false;
  try {
    if (process.platform === "win32") return child.kill(signal);
    process.kill(-child.pid, signal);
    return true;
  } catch {
    try {
      return child.kill(signal);
    } catch {
      return false;
    }
  }
}

export function commandOk(command: string[], cwd: string): boolean {
  return runCommand(command, cwd).exitCode === 0;
}

export async function runCommandToFiles(command: string[], cwd: string, stdoutFile: string, stderrFile: string): Promise<number | null> {
  const stdout = createWriteStream(stdoutFile, { flags: "a" });
  const stderr = createWriteStream(stderrFile, { flags: "a" });
  stdout.write(`$ ${command.map((part) => JSON.stringify(part)).join(" ")}\n`);
  const child = spawn(command[0], command.slice(1), { cwd, env: { ...process.env, UNIDESK_JOB_STDOUT_FILE: stdoutFile, UNIDESK_JOB_STDERR_FILE: stderrFile } });
  child.stdout.pipe(stdout, { end: false });
  child.stderr.pipe(stderr, { end: false });
  const exitCode = await new Promise<number | null>((resolve) => {
    child.on("close", (code) => resolve(code));
    child.on("error", (error) => {
      stderr.write(`${error.stack ?? error.message}\n`);
      resolve(127);
    });
  });
  stdout.write(`\n[exit_code=${exitCode}]\n`);
  stdout.end();
  stderr.end();
  return exitCode;
}

export function tailFile(path: string, maxBytes = 8192): string {
  if (!existsSync(path)) return "";
  const safeMaxBytes = Math.max(0, Math.floor(maxBytes));
  if (safeMaxBytes === 0) return "";
  const size = statSync(path).size;
  const bytesToRead = Math.min(size, safeMaxBytes);
  const buffer = Buffer.alloc(bytesToRead);
  const fd = openSync(path, "r");
  try {
    readSync(fd, buffer, 0, bytesToRead, size - bytesToRead);
  } finally {
    closeSync(fd);
  }
  return buffer.toString("utf8");
}
