/**
 * Process plumbing for the local bash executor: spawn, output collection
 * with tail-keep + spill-to-disk truncation, and process-group kill with
 * SIGTERM→SIGKILL escalation.
 *
 * Everything here is deliberately free of Cordis concepts so it can be unit
 * tested in isolation; `LocalBashExecutor` owns lifecycle and configuration.
 *
 * runBash owns NO timing: it kills the process group when its `spec.signal`
 * fires and does not distinguish a timeout from a cancel. The executor fuses
 * timeout + upstream cancellation into that one signal via
 * `@deepseek-ai/dsh-timeout`'s `deadline`, and classifies the outcome from the
 * signal afterward — the timing/classification half is shared, the kill is not.
 *
 * Design notes (surveyed against Claude Code, OpenCode, Codex, and pi — see
 * the package README): spawn-per-call with `detached: true` so the child
 * leads its own process group; kills target the group (`kill(-pid)`) so
 * pipelines and subshells die with the parent. SIGTERM first, SIGKILL after a
 * grace period (OpenCode's escalation; Codex/pi jump straight to SIGKILL).
 *
 * @module dsh-bash-local/run
 */

import { type ChildProcessByStdio, spawn } from 'node:child_process'
import type { Readable, Writable } from 'node:stream'
import { randomBytes } from 'node:crypto'
import { closeSync, mkdtempSync, openSync, writeSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import type { CollectedOutput } from '@deepseek-ai/dsh-bash'

/**
 * Model-friendly environment overrides: disable colors, pagers, and
 * interactive terminal features that would garble tool output (the same set
 * Codex hardcodes; Claude Code achieves it via TERM=dumb).
 */
export const ENV_OVERRIDES = {
  NO_COLOR: '1',
  TERM: 'dumb',
  PAGER: 'cat',
  GIT_PAGER: 'cat',
} as const

/**
 * Credential-shaped env vars are NOT forwarded to commands (the harness's
 * own DEEPSEEK_API_KEY must not leak into `env` output, tool results, or
 * spill files). Same default pattern as Codex's env policy; a future config
 * can whitelist specific vars when a workflow genuinely needs one.
 */
export const SENSITIVE_ENV_PATTERN = /KEY|SECRET|TOKEN/i

/**
 * `process.env` minus credential-shaped vars, plus the model-friendly
 * overrides, plus any caller-supplied `extra` entries.
 *
 * Layering matters: the scrub drops `process.env` credentials, then
 * `ENV_OVERRIDES` forces the model-friendly terminal vars, then `extra` is
 * merged LAST so an explicit caller entry wins even when its name matches the
 * scrub pattern (the scrub is the control that stops the HARNESS's ambient
 * credentials leaking into a spawned command; a caller that explicitly sets a
 * var named a value it already holds, not that ambient secret). `extra` is set
 * by in-process plugins (the hooks bridges), not the model — `dsh-tool-bash`
 * builds its request from named fields only and does not forward model input
 * here (see its README, § "The tool builds its request from named args only").
 * @param extra - caller-supplied entries merged last; an explicit entry wins even against the scrub and the overrides.
 * @returns the environment to hand to `spawn` for the child process.
 */
export function childEnv(extra?: Record<string, string>): NodeJS.ProcessEnv {
  const env: NodeJS.ProcessEnv = {}
  for (const [key, value] of Object.entries(process.env)) {
    if (!SENSITIVE_ENV_PATTERN.test(key)) env[key] = value
  }
  return { ...env, ...ENV_OVERRIDES, ...extra }
}

/** What to run and under which limits (resolved — no defaults in here). */
export interface SpawnSpec {
  command: string
  cwd: string
  /** Per-stream in-memory cap; overflow spills to disk (tail kept in memory). */
  maxOutputBytes: number
  /** Grace period between the SIGTERM and the SIGKILL escalation on a kill. */
  graceMs: number
  /**
   * Abort signal — kills the process group when it fires. The executor owns
   * timing: `run()` passes a fused timeout/cancel deadline signal (see
   * `@deepseek-ai/dsh-timeout`), `start()` passes the bare upstream signal.
   * runBash only listens and kills; it does NOT classify why (the executor
   * reads the signal's reason afterward).
   */
  signal?: AbortSignal | undefined
  /**
   * Bytes to write to the child's stdin, then close it. Absent (or empty)
   * leaves stdin closed/empty. Set by in-process plugins (the hooks bridges);
   * the model-facing `dsh-tool-bash` tool does not thread model input here.
   */
  stdin?: string | undefined
  /**
   * Extra environment entries, merged onto the scrubbed env AFTER the
   * credential scrub and the model-friendly overrides (so an explicit entry
   * wins). Set by in-process plugins; the model-facing tool does not forward
   * model input here.
   */
  env?: Record<string, string> | undefined
}

/**
 * Raw outcome of one closed process (before result shaping). Deliberately
 * carries NO timeout/cancel classification: runBash kills on abort but does not
 * decide why — the executor's `run()`/`start()` reads the deadline signal it
 * owns to classify `timedOut`/`aborted` (see the package README).
 */
export interface SpawnOutcome {
  exitCode: number | null
  signal: NodeJS.Signals | null
  stdout: CollectedOutput
  stderr: CollectedOutput
}

/** Injectable knobs so tests can exercise spill behavior without the OS tmpdir. */
export interface RunInternals {
  /** Directory for spill files (defaults to the OS temp dir). */
  spillDir?: string
}

/** Default SIGTERM→SIGKILL grace period (the `graceMs` config; matches OpenCode's 3s). */
export const DEFAULT_GRACE_MS = 3_000

let spillCounter = 0
let defaultSpillDir: string | undefined

/**
 * The default spill location: a private (0700) per-process directory under
 * the OS tmpdir, created lazily. Predictable world-readable paths would let
 * other local users read command output or pre-create symlinks.
 */
function privateSpillDir(): string {
  defaultSpillDir ??= mkdtempSync(join(tmpdir(), 'dsh-bash-'))
  return defaultSpillDir
}

/**
 * Collects one stream with a bounded in-memory tail. The FULL stream is
 * always recoverable: on first overflow a spill file is created and every
 * chunk (including those already collected) is appended there.
 *
 * Tail-keep rationale (pi/OpenCode): errors and final results cluster at the
 * end of command output; the spill file covers the head.
 */
export class OutputCollector {
  private chunks: Buffer[] = []
  private bytes = 0
  private dropped = false
  private spillFd: number | undefined
  private spillFile: string | undefined
  /** Total bytes ever pushed (not just retained). */
  private total = 0

  constructor(
    private readonly maxBytes: number,
    private readonly label: string,
    private readonly spillDir: string,
  ) {}

  /**
   * Ingest one stream chunk, counting it toward the whole-stream total. On
   * first overflow of the in-memory cap a spill file is opened and every chunk
   * (already-collected ones included) is appended there from then on; the
   * in-memory tail then drops whole chunks from its head (or the head of a
   * single over-cap chunk) until it fits the cap again.
   * @param chunk - the raw bytes from one stream 'data' event.
   */
  push(chunk: Buffer): void {
    this.total += chunk.length
    const overflows = this.bytes + chunk.length > this.maxBytes
    if (overflows || this.spillFd !== undefined) this.spillAll(chunk)
    this.chunks.push(chunk)
    this.bytes += chunk.length
    while (this.bytes > this.maxBytes && this.chunks.length > 1) {
      // Drop whole chunks from the head; pipe chunks are small (≤64KiB), so
      // the retained tail tracks the cap closely enough for a model-facing
      // truncation boundary. (length > 1 was just checked — shift() returns.)
      const head = this.chunks.shift() as Buffer
      this.bytes -= head.length
      this.dropped = true
    }
    if (this.bytes > this.maxBytes && this.chunks.length === 1) {
      // A single chunk larger than the cap: keep its tail.
      const only = this.chunks[0] as Buffer
      this.chunks[0] = only.subarray(only.length - this.maxBytes)
      this.bytes = this.maxBytes
      this.dropped = true
    }
  }

  /** Open the spill file lazily and append `chunk` (and any prior chunks once). */
  private spillAll(chunk: Buffer): void {
    if (this.spillFd === undefined) {
      // Random suffix + O_EXCL + no-follow-equivalent ('wx' fails on any
      // existing path, symlink or not) + owner-only mode: defeats spill-path
      // prediction and symlink planting in shared tmp dirs.
      this.spillFile = join(
        this.spillDir,
        `dsh-bash-${process.pid}-${++spillCounter}-${randomBytes(6).toString('hex')}-${this.label}.log`,
      )
      this.spillFd = openSync(this.spillFile, 'wx', 0o600)
      for (const prior of this.chunks) writeSync(this.spillFd, prior)
    }
    writeSync(this.spillFd, chunk)
  }

  // TODO(snapshot-scope): `snapshot()` has one internal caller (`finalize()` at
  // the bottom of this file) and `totalBytes` is read only by a test. The live
  // background-poll path goes through `readFrom()`, so inline snapshot() into
  // finalize() and drop or privatize the totalBytes getter.
  /**
   * Read the collected tail without finalizing (the final-result snapshot).
   * @returns the retained tail text, the truncation flag, and the spill path when one was created.
   */
  snapshot(): CollectedOutput {
    return {
      text: Buffer.concat(this.chunks).toString('utf8'),
      truncated: this.dropped,
      ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
    }
  }

  /** Total bytes ever pushed (including bytes dropped from memory). */
  get totalBytes(): number {
    return this.total
  }

  /**
   * Incremental read in whole-stream byte coordinates: returns everything
   * pushed since `fromByte`. When `fromByte` has already slid out of the
   * in-memory tail window, the read is `lossy` — it returns the whole
   * retained tail and the gap is only recoverable from the spill file.
   * @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 for the first read).
   * @returns the delta text, the offset for the next read, the `lossy` flag, and the spill path when one was created.
   */
  readFrom(fromByte: number): { text: string; nextOffset: number; lossy: boolean; spillPath?: string } {
    const windowStart = this.total - this.bytes
    const buffer = Buffer.concat(this.chunks)
    const lossy = fromByte < windowStart
    const slice = lossy ? buffer : buffer.subarray(fromByte - windowStart)
    return {
      text: slice.toString('utf8'),
      nextOffset: this.total,
      lossy,
      ...this.spillFile !== undefined ? { spillPath: this.spillFile } : {},
    }
  }

  /**
   * Close the spill file (if any) and return the final output. A failed close
   * (delayed writeback fault) stops advertising the spill path — the file may
   * be missing its tail — but still returns the in-memory result.
   * @returns the final collected output: tail text, truncation flag, and the spill path when intact.
   */
  finalize(): CollectedOutput {
    if (this.spillFd !== undefined) {
      try {
        closeSync(this.spillFd)
      } catch {
        // close can surface delayed writeback failures (for example EIO/ENOSPC)
        // after writeSync appeared to succeed. Keep finalize total so runBash's
        // close handler still resolves, but stop advertising a spill file that
        // may be missing its tail.
        this.spillFile = undefined
      }
      this.spillFd = undefined
    }
    return this.snapshot()
  }
}

/**
 * Send `sig` to the process GROUP led by `pid` (requires the child to have
 * been spawned with `detached: true`). NEVER throws: kills race process exit
 * by design (ESRCH), and the other failure modes (EPERM from setuid
 * children, …) fire inside timer callbacks where a throw would crash the
 * host process — a kill that cannot be delivered is reported by the process
 * NOT dying, which callers already handle via escalation/timeouts. No-op for
 * non-positive pids (spawn never started a process).
 * @param pid - the group leader's pid; non-positive means the spawn failed and the call is a no-op.
 * @param sig - the signal to deliver to the whole group.
 */
export function killGroup(pid: number, sig: NodeJS.Signals): void {
  if (pid <= 0) return
  try {
    process.kill(-pid, sig)
  } catch {
    // Swallow: see contract above.
  }
}

/**
 * A live bash child process: the promise resolves when the process closes;
 * `kill()` starts the SIGTERM→grace→SIGKILL escalation on its group.
 */
export interface RunningBash {
  /** Process id (group leader); -1 when the spawn itself failed. */
  readonly pid: number
  /** stdout/stderr collectors (live — background polling reads incrementally). */
  readonly stdout: OutputCollector
  readonly stderr: OutputCollector
  /** Resolves when the process closes; rejects only for spawn-level failures. */
  readonly done: Promise<SpawnOutcome>
  /** Begin SIGTERM→grace→SIGKILL on the process group. Idempotent. */
  kill(): void
}

/**
 * Spawn `bash -c <command>` in its own process group and collect output.
 *
 * Outcome semantics: the returned promise REJECTS only for spawn-level
 * failures (bad cwd → ENOENT, missing binary, pre-aborted signal); every
 * runtime outcome — nonzero exit, timeout kill, abort kill, signal death —
 * RESOLVES with a {@link SpawnOutcome} describing what happened, so callers
 * shape one consistent report for the model.
 *
 * XXX(stateful-shell): per the agent-tool survey there are two proven
 * stateful designs worth revisiting — Claude Code persists ONLY cwd between
 * calls (captures `pwd -P` after each command), and Codex keeps whole PTY
 * exec sessions addressable via session ids + stdin writes. We deliberately
 * spawn a fresh non-login `bash -c` per call for determinism (no rc files,
 * no inherited shell state); revisit when real workflows demand it.
 * @param spec - the fully-resolved run (command, cwd, limits); no defaulting happens here.
 * @param internals - test-only knobs; omitted fields fall back to the private per-process spill dir.
 * @returns the live handle: pid, the two live collectors, the outcome promise, and `kill()`.
 */
export function runBash(spec: SpawnSpec, internals: RunInternals = {}): RunningBash {
  const spillDir = internals.spillDir ?? privateSpillDir()

  if (spec.signal?.aborted) {
    throw new Error(`aborted before spawn: ${String(spec.signal.reason ?? 'aborted')}`)
  }

  // stdin is a pipe ONLY when the caller supplied bytes; with none it is `ignore`
  // (fd 0 → /dev/null) — the exact pre-seam default. This matters: a spawn pipe
  // and /dev/null are NOT observationally identical (node's pipe is an AF_UNIX
  // socket, so a command that probes stdin's type — `test -c /dev/stdin`, `stat
  // /proc/self/fd/0` — sees a char device vs a socket), so the no-stdin path
  // (every model-driven call) must keep /dev/null rather than regress to a socket.
  // Two LITERAL `stdio` tuples (not one variable tuple): only a literal lets the
  // typed `spawn` overload infer non-null stdout/stderr, which the
  // `ChildProcessByStdio` annotation captures (stdin `Writable | null`; stdout/
  // stderr the non-null `Readable` the collectors attach to without a cast).
  const env = childEnv(spec.env)
  const child: ChildProcessByStdio<Writable | null, Readable, Readable> = spec.stdin !== undefined
    ? spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['pipe', 'pipe', 'pipe'], detached: true })
    : spawn('bash', ['-c', spec.command], { cwd: spec.cwd, env, stdio: ['ignore', 'pipe', 'pipe'], detached: true })

  const stdout = new OutputCollector(spec.maxOutputBytes, 'stdout', spillDir)
  const stderr = new OutputCollector(spec.maxOutputBytes, 'stderr', spillDir)
  child.stdout.on('data', (chunk: Buffer) => { stdout.push(chunk) })
  child.stderr.on('data', (chunk: Buffer) => { stderr.push(chunk) })

  let graceTimer: NodeJS.Timeout | undefined

  // pid is undefined when the spawn itself fails (bad cwd, missing binary);
  // the 'error' handler rejects `done` and kills become no-ops via pid -1.
  const pid = child.pid ?? -1

  const kill = (): void => {
    if (graceTimer !== undefined) return // escalation already in flight
    killGroup(pid, 'SIGTERM')
    graceTimer = setTimeout(() => { killGroup(pid, 'SIGKILL') }, spec.graceMs)
  }

  // runBash owns no timer: the executor's `run()` fuses timeout+cancel into one
  // deadline signal (`@deepseek-ai/dsh-timeout`) and passes it here; we only
  // listen and run the SIGTERM→grace→SIGKILL kill. Whether the abort was a
  // timeout or an upstream cancel is classified by the executor from that
  // signal, not tracked here.
  const onAbort = (): void => { kill() }
  spec.signal?.addEventListener('abort', onAbort, { once: true })

  // Write stdin and close it, but ONLY when the caller supplied bytes — with no
  // stdin, fd 0 is `ignore` (/dev/null) and `child.stdin` is null. The error
  // handler must exist whenever we write: an unhandled 'error' on the stream
  // would throw and crash the host. We swallow the error rather than reject
  // `done`, and that is correct for ANY stdin-write error, not just the common
  // one — the stdin write is BEST-EFFORT, while the command's authoritative
  // outcome is its exit code + captured output, which the `close` handler reports
  // regardless of whether the write landed. The expected case is EPIPE (the child
  // exited without reading, so closing our end of a still-full pipe fails); a rare
  // non-EPIPE pipe fault means the command ran with incomplete stdin, and it
  // surfaces that itself through its own exit/output (e.g. a hook that gets
  // truncated JSON errors out) — rejecting here would instead discard that real
  // output and turn it into an opaque infrastructure error, which is worse.
  if (child.stdin !== null) {
    child.stdin.on('error', () => { /* stdin write is best-effort; outcome rides on exit/output. */ })
    child.stdin.end(spec.stdin)
  }

  const done = new Promise<SpawnOutcome>((resolve, reject) => {
    child.on('error', (error) => {
      // Spawn-level failure (ENOENT cwd, EACCES, …): no close event with
      // meaningful output follows; clean up and reject.
      cleanup()
      reject(error)
    })
    child.on('close', (exitCode, signal) => {
      cleanup()
      resolve({
        exitCode,
        signal,
        stdout: stdout.finalize(),
        stderr: stderr.finalize(),
      })
    })
    function cleanup(): void {
      if (graceTimer !== undefined) clearTimeout(graceTimer)
      spec.signal?.removeEventListener('abort', onAbort)
    }
  })

  return { pid, stdout, stderr, done, kill }
}
