import { mkdir } from 'node:fs/promises';
import path from 'node:path';
import type { Readable } from 'node:stream';
import Docker from 'dockerode';
import { execa } from 'execa';

import { env } from '../env';

type CommandResult = {
  exitCode: number;
  output: string;
};

let dockerClient: Docker | undefined;

export const getDockerClient = () => {
  dockerClient ??= new Docker();
  return dockerClient;
};

const environmentArgs = (environment: Record<string, string>) =>
  Object.entries(environment).flatMap(([name, value]) => [
    '-e',
    `${name}=${value}`,
  ]);

const networkArgs = () => (env.network ? ['--network', env.network] : []);

const containerRuntime = () => env.containerRuntime;

// `docker run` reuses a stale local `:latest` forever, so without an explicit
// pull the job image never updates in production. Pull once per worker process
// (i.e. once per deploy/restart) so a fresh worker always runs a fresh job
// image. Best-effort: if the registry is unreachable we fall back to whatever
// image is present locally rather than failing the job.
let jobImagePullPromise: Promise<void> | undefined;
export const ensureJobImagePulled = () => {
  jobImagePullPromise ??= (async () => {
    try {
      await execa(containerRuntime(), ['pull', env.jobImage], {
        reject: false,
        stdin: 'ignore',
      });
    } catch {
      // Ignore: keep running with the locally cached image.
    }
  })();
  return jobImagePullPromise;
};

// execa with `reject: false` resolves (does not throw) even when the runtime
// binary is missing (ENOENT) — `exitCode` is then `undefined`. Coercing that to
// 0 makes a failed spawn look like a successful empty run, which is exactly how
// a worker image without a `docker` CLI silently produced empty agent
// responses. Normalize so any spawn failure is a non-zero exit carrying the
// real reason.
export const normalizeRunResult = (
  // Declared nullable on purpose: execa's types claim these are always present,
  // but on a spawn failure (e.g. missing `docker` binary) `exitCode`/`all` are
  // actually undefined at runtime.
  result: { exitCode?: number; shortMessage?: string },
  output: string | undefined,
  redact: (value: string) => string,
): CommandResult => {
  const text = output ?? '';
  if (result.exitCode == null) {
    const reason = result.shortMessage ?? 'container runtime failed to start';
    return {
      exitCode: 1,
      output: redact(`${text}${text ? '\n' : ''}${reason}`),
    };
  }
  return { exitCode: result.exitCode, output: redact(text) };
};

const hostWorkspacePath = (workdir: string) => {
  if (!env.hostWorkdir) return workdir;
  const workerRoot = path.resolve(env.workdir);
  const resolvedWorkdir = path.resolve(workdir);
  const relative = path.relative(workerRoot, resolvedWorkdir);
  if (relative.startsWith('..') || path.isAbsolute(relative)) {
    return workdir;
  }
  return path.join(env.hostWorkdir, relative);
};

export const containerVolumeSuffix = () =>
  env.containerVolumeOptions ??
  (containerRuntime().endsWith('podman') ? 'Z' : undefined);

export { hostWorkspacePath };

export const jobWorkspaceVolumeSpec = (
  workdir: string,
  containerHome = '/workspace',
) => {
  const volumeOptions =
    env.containerVolumeOptions ??
    (containerRuntime().endsWith('podman') ? 'Z' : undefined);
  const source = hostWorkspacePath(workdir);
  return volumeOptions
    ? `${source}:${containerHome}:${volumeOptions}`
    : `${source}:${containerHome}`;
};

// Shared line-streaming + result normalization for a started subprocess
// (used by both `docker run` and `docker exec` paths).
type StreamingSubprocess = {
  stdout: Readable | null;
  stderr: Readable | null;
} & Promise<{ exitCode?: number; shortMessage?: string; all?: string }>;

const streamSubprocess = async (
  subprocess: StreamingSubprocess,
  redact: (value: string) => string,
  onStdoutLine?: (line: string) => Promise<void>,
  onStderrLine?: (line: string) => Promise<void>,
): Promise<CommandResult> => {
  let stdoutBuffer = '';
  let stderrBuffer = '';
  const output: string[] = [];
  let lineHandlers = Promise.resolve();
  const consume = async (
    chunk: Buffer,
    source: 'stdout' | 'stderr',
    handler?: (line: string) => Promise<void>,
  ) => {
    output.push(chunk.toString('utf8'));
    const next = `${source === 'stdout' ? stdoutBuffer : stderrBuffer}${chunk.toString('utf8')}`;
    const lines = next.split(/\r?\n/);
    const remainder = lines.pop() ?? '';
    if (source === 'stdout') stdoutBuffer = remainder;
    else stderrBuffer = remainder;
    for (const line of lines) {
      if (handler) await handler(redact(line));
    }
  };
  subprocess.stdout?.on('data', (chunk: Buffer) => {
    lineHandlers = lineHandlers.then(() =>
      consume(chunk, 'stdout', onStdoutLine),
    );
  });
  subprocess.stderr?.on('data', (chunk: Buffer) => {
    lineHandlers = lineHandlers.then(() =>
      consume(chunk, 'stderr', onStderrLine),
    );
  });
  let result: Awaited<StreamingSubprocess>;
  try {
    result = await subprocess;
  } catch (error) {
    await lineHandlers;
    const outputText = output.join('');
    const message =
      error instanceof Error ? error.message : 'Container command failed.';
    return {
      exitCode: 1,
      output: redact(`${outputText}${outputText ? '\n' : ''}${message}`),
    };
  }
  await lineHandlers;
  if (stdoutBuffer && onStdoutLine) await onStdoutLine(redact(stdoutBuffer));
  if (stderrBuffer && onStderrLine) await onStderrLine(redact(stderrBuffer));
  return normalizeRunResult(result, output.join(''), redact);
};

// Per-user persistent "box" container that all of a user's threads exec into
// (Phase 2). Started once, reused; the home volume persists state across stops.
export const userContainerName = (username: string) =>
  `spoon-box-${username.replace(/[^a-zA-Z0-9_.-]/g, '-')}`;

export const ensureUserContainer = async (args: {
  username: string;
  workdir: string;
  containerHome: string;
}): Promise<string> => {
  await ensureJobImagePulled();
  const name = userContainerName(args.username);
  const inspect = await execa(
    containerRuntime(),
    ['inspect', '-f', '{{.State.Running}}', name],
    { reject: false, stdin: 'ignore' },
  );
  if (inspect.exitCode === 0 && inspect.stdout.trim() === 'true') return name;
  // The box mounts the per-user home, but it's created before the thread's clone
  // populates it — ensure it exists first, since podman (unlike docker) refuses to
  // bind-mount a missing source directory (statfs: no such file or directory).
  await mkdir(args.workdir, { recursive: true });
  // Not running: remove any stale container, then start fresh.
  await execa(containerRuntime(), ['rm', '-f', name], { reject: false });
  await execa(
    containerRuntime(),
    [
      'run',
      '-d',
      '--init',
      '--name',
      name,
      '--memory',
      '4g',
      '--cpus',
      '2',
      ...networkArgs(),
      '-v',
      jobWorkspaceVolumeSpec(args.workdir, args.containerHome),
      '-w',
      args.containerHome,
      env.jobImage,
      'sleep',
      'infinity',
    ],
    { stdin: 'ignore' },
  );
  return name;
};

// Inspect the per-user box in one shot. Non-zero exit (no such container) yields
// an all-null/false status. `--memory 0` means unlimited, which the Go template
// prints as `0`; we normalize that (and any unparseable value) to null.
export const inspectUserBoxStatus = async (
  username: string,
): Promise<{
  running: boolean;
  image: string | null;
  startedAt: string | null;
  memoryLimitBytes: number | null;
}> => {
  const result = await execa(
    containerRuntime(),
    [
      'inspect',
      '--format',
      '{{.State.Running}}|{{.Config.Image}}|{{.State.StartedAt}}|{{.HostConfig.Memory}}',
      userContainerName(username),
    ],
    { reject: false, stdin: 'ignore' },
  );
  if (result.exitCode !== 0) {
    return { running: false, image: null, startedAt: null, memoryLimitBytes: null };
  }
  const [runningField, imageField, startedAtField, memoryField] = result.stdout
    .trim()
    .split('|');
  const running = runningField === 'true';
  return {
    running,
    // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty field means "no image"
    image: imageField || null,
    // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- empty field means "not started"
    startedAt: running ? startedAtField || null : null,
    memoryLimitBytes: Number(memoryField) || null,
  };
};

const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;

// The marker is embedded literally into a `# comment` line and into a `pgrep`
// pattern, so it must not contain shell metacharacters or a newline (either could
// break out of the comment or the pattern). Callers generate it, but validate
// defensively so a malformed marker fails loudly instead of silently corrupting
// the script.
const MARKER_PATTERN = /^[A-Za-z0-9_-]+$/;
const assertMarker = (marker: string) => {
  if (!MARKER_PATTERN.test(marker)) {
    throw new Error(
      `Invalid process marker ${JSON.stringify(marker)}: must match ${String(MARKER_PATTERN)}.`,
    );
  }
};

// Wraps a CLI argv so the process runs as a new session/process-group leader whose
// bash parent carries the marker in its argv (matchable by `pgrep -f`). We do NOT
// `exec` the CLI: bash tail-exec's a final simple command, which would replace the
// marker-carrying bash with the CLI and make `pgrep -f <marker>` miss the running
// turn. Keeping a trailing `rc=$?; exit "$rc"` after the CLI means the CLI is no
// longer the script's final statement, so bash survives as the group leader while
// still propagating the CLI's exit code (downstream `normalizeRunResult` relies on
// it — a bare trailing `wait` would mask a non-zero CLI failure as 0). The CLI runs
// in the same process group as this bash, so the kill helper's group signal reaches
// it. stdout/stderr fds are inherited by the CLI so streaming still works.
export const buildMarkedCommand = (
  marker: string,
  command: string[],
): string[] => {
  assertMarker(marker);
  const script = [
    `# ${marker}`,
    'exec 0</dev/null',
    command.map(shellQuote).join(' '),
    'rc=$?',
    'exit "$rc"',
  ].join('\n');
  return ['setsid', 'bash', '-lc', script];
};

// Kills every process group whose bash parent matches the marker, TERM then KILL.
// The negative pid (`kill -TERM -"$pgid"`) targets the whole process group, so the
// CLI and any children it spawned die together.
//
// pgrep self-match: this kill script runs via `bash -lc <script>`, whose OWN argv
// contains the marker literal, so `pgrep -f <marker>` matches the kill script (and
// its command-substitution subshells) too. Left unguarded it would `kill -TERM` its
// own process group and die on iteration one, leaking the real target. So we compute
// this script's pgid up front and skip any match sharing it, killing only the
// target's process group.
export const buildKillScript = (marker: string): string => {
  assertMarker(marker);
  return [
    `self_pgid=$(ps -o pgid= -p $$ | tr -d ' ')`,
    `pids=$(pgrep -f ${shellQuote(marker)} || true)`,
    `for pid in $pids; do`,
    `  pgid=$(ps -o pgid= -p "$pid" | tr -d ' ')`,
    `  [ -z "$pgid" ] && continue`,
    `  [ "$pgid" = "$self_pgid" ] && continue`,
    `  kill -TERM -"$pgid" 2>/dev/null || true`,
    `done`,
    `sleep 2`,
    `for pid in $pids; do`,
    `  pgid=$(ps -o pgid= -p "$pid" | tr -d ' ')`,
    `  [ -z "$pgid" ] && continue`,
    `  [ "$pgid" = "$self_pgid" ] && continue`,
    `  kill -KILL -"$pgid" 2>/dev/null || true`,
    `done`,
  ].join('\n');
};

export const killBoxProcessesByMarker = async (args: {
  containerName: string;
  marker: string;
}): Promise<void> => {
  await execa(
    containerRuntime(),
    ['exec', args.containerName, 'bash', '-lc', buildKillScript(args.marker)],
    { reject: false, stdin: 'ignore' },
  );
};

export const streamExecInContainer = async (args: {
  containerName: string;
  command: string[];
  environment: Record<string, string>;
  containerCwd: string;
  redact: (value: string) => string;
  timeoutMs: number;
  onStdoutLine?: (line: string) => Promise<void>;
  onStderrLine?: (line: string) => Promise<void>;
}): Promise<CommandResult> => {
  const subprocess = execa(
    containerRuntime(),
    [
      'exec',
      ...environmentArgs(args.environment),
      '-w',
      args.containerCwd,
      args.containerName,
      ...args.command,
    ],
    { all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
  );
  return streamSubprocess(
    subprocess,
    args.redact,
    args.onStdoutLine,
    args.onStderrLine,
  );
};

export const runExecInContainer = async (args: {
  containerName: string;
  command: string[];
  environment: Record<string, string>;
  containerCwd: string;
  redact: (value: string) => string;
  timeoutMs: number;
}): Promise<CommandResult> => {
  const result = await execa(
    containerRuntime(),
    [
      'exec',
      ...environmentArgs(args.environment),
      '-w',
      args.containerCwd,
      args.containerName,
      ...args.command,
    ],
    { all: true, reject: false, stdin: 'ignore', timeout: args.timeoutMs },
  );
  return normalizeRunResult(result, result.all, args.redact);
};

export const stopWorkspaceContainer = async (containerName: string) => {
  await execa(containerRuntime(), ['rm', '-f', containerName], {
    reject: false,
  });
};

export const listWorkspaceContainerNames = async (prefix: string) => {
  const result = await execa(
    containerRuntime(),
    ['ps', '-a', '--format', '{{.Names}}'],
    { all: true, reject: false },
  );
  if (result.exitCode !== 0) return [];
  return result.all
    .split('\n')
    .map((line) => line.trim())
    .filter((line) => line.startsWith(prefix));
};
