import { describe, test, expect } from 'bun:test';
import * as fs from 'fs';
import * as path from 'path';
import { execSync, spawnSync } from 'child_process';

/**
 * bash 5.2+ delivers a heredoc body of 64KiB or less through a pipe: the
 * forked child writes the whole body before exec, and nothing reads the other
 * end until the command starts. On macOS under pipe-KVA pressure the kernel
 * hands a fresh pipe a 512-byte buffer, so any body of 512 bytes or more
 * blocks write() forever — the script hangs at startup, silently, with no
 * output and no error. The runtime capacity check bash would need
 * (F_GETPIPE_SZ) is Linux-only.
 *
 * Compat level 50 restores the pre-5.2 tempfile path. Every script that ships
 * an in-window heredoc must set it, and this scanner fails the suite when a
 * new one appears without the guard.
 *
 * The guard is deliberately not a `#!/bin/bash` shebang swap: that pins the
 * script to whatever bash lives at /bin (3.2 on macOS, absent on some Linux
 * distributions) and is bypassed entirely by `bash script.sh` call sites.
 */

const ROOT = path.resolve(import.meta.dir, '..');

// Inclusive byte window where the pipe path is taken AND a starved pipe can
// block. Bodies over 64KiB fall back to a tempfile on their own.
const MIN_BODY = 512;
const MAX_BODY = 64 * 1024;

const GUARD_RE = /^\s*(?::\s*"\$\{)?BASH_COMPAT(?:[:=]|\}")/m;

function trackedShellScripts(): string[] {
  const out = execSync('git ls-files', { cwd: ROOT, encoding: 'utf-8', maxBuffer: 32 * 1024 * 1024 });
  return out
    .split('\n')
    .map((s) => s.trim())
    .filter(Boolean)
    .filter((f) => {
      const abs = path.join(ROOT, f);
      if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) return false;
      if (f.endsWith('.sh')) return true;
      const head = fs.readFileSync(abs).subarray(0, 64).toString('utf-8');
      return /^#!.*\b(bash|sh)\b/.test(head);
    });
}

/** Heredocs in `content` whose body lands inside the deadlock window. */
function inWindowHeredocs(content: string): { line: number; tag: string; bytes: number }[] {
  const lines = content.split('\n');
  const hits: { line: number; tag: string; bytes: number }[] = [];
  for (let i = 0; i < lines.length; i++) {
    const m = /<<-?\s*'?([A-Za-z_][A-Za-z0-9_]*)'?/.exec(lines[i]);
    if (!m) continue;
    const tag = m[1];
    let j = i + 1;
    const body: string[] = [];
    while (j < lines.length && lines[j].trim() !== tag) body.push(lines[j++]);
    const bytes = Buffer.byteLength(body.join('\n')) + 1;
    if (bytes >= MIN_BODY && bytes <= MAX_BODY) hits.push({ line: i + 1, tag, bytes });
    i = j;
  }
  return hits;
}

describe('heredoc pipe-deadlock guard', () => {
  test('every script with an in-window heredoc sets BASH_COMPAT', () => {
    const violations: string[] = [];
    for (const rel of trackedShellScripts()) {
      const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
      const hits = inWindowHeredocs(content);
      if (hits.length === 0) continue;
      if (GUARD_RE.test(content)) continue;
      for (const h of hits) violations.push(`${rel}:${h.line} <<${h.tag} body=${h.bytes}B`);
    }
    if (violations.length > 0) {
      throw new Error(
        `Heredoc bodies in the ${MIN_BODY}-${MAX_BODY}B pipe window without a BASH_COMPAT guard:\n  ` +
          violations.join('\n  ') +
          `\n\nFix: add \`BASH_COMPAT=50\` near the top of the script (below any ` +
          `\`--help\` sed range that reads $0), or shrink the body under ${MIN_BODY}B, ` +
          `or pipe it in with printf so a live reader exists.`,
      );
    }
    expect(violations).toEqual([]);
  });

  test('the guard actually moves the body off the pipe', () => {
    const bash = spawnSync('bash', ['-c', 'echo "${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]}"'], {
      encoding: 'utf-8',
    });
    const version = (bash.stdout ?? '').trim();
    const [maj, min] = version.split('.').map((n) => parseInt(n, 10));
    // Only 5.2+ takes the pipe path at all; older bash is already on tempfiles.
    if (!(maj > 5 || (maj === 5 && min >= 2))) {
      expect(version).toBeTruthy();
      return;
    }

    // Some sandboxes/containers ship a minimal /dev without /dev/stdin — the
    // probe medium itself is absent there, so -p/-f both report false and the
    // probe would answer OTHER for an unobservable fd. Skip rather than fail.
    const devStdin = spawnSync('bash', ['-c', '[ -e /dev/stdin ] && echo yes || echo no'], {
      encoding: 'utf-8',
    });
    if ((devStdin.stdout ?? '').trim() !== 'yes') return;

    const probe = (guard: string) => `#!/usr/bin/env bash
${guard}
body=$(printf 'x%.0s' $(seq 1 1000))
probe() { if [ -p /dev/stdin ]; then echo PIPE; elif [ -f /dev/stdin ]; then echo TEMPFILE; else echo OTHER; fi; }
probe <<EOF
$body
EOF
`;
    const run = (guard: string) =>
      (spawnSync('bash', ['-c', probe(guard)], { encoding: 'utf-8' }).stdout ?? '').trim();

    expect(run('')).toBe('PIPE');
    expect(run('BASH_COMPAT=50')).toBe('TEMPFILE');
  });
});
