#!/bin/bash
#
#  File: cov-analysis
#
#  Version: 1.1
#
#  Purpose: Generate LLVM source-based code coverage reports from AFL++ corpus.
#           Replays queue/crash/timeout files through a coverage-instrumented
#           binary, merges .profraw profiles, and produces HTML/text/JSON
#           reports via llvm-profdata and llvm-cov.
#
#  Copyright (c) 2026 by Marc "vanHauser" Heuse (vh@thc.org)
#
#  License: GNU Affero General Public License 3 (or any later version)
#

set -euo pipefail

VERSION="1.1"
REPORT_MARKER_NAME=".cov-analysis-report"

# ── helpers ───────────────────────────────────────────────────────────────────

QUIET=0
VERBOSE=0
FUZZER_LAYOUT=""   # set by cmd_report; one of: afl | flat

log()  { test "$QUIET" -eq 0 && echo "[+] $*" || true; }
logv() { test "$VERBOSE" -eq 1 && echo "[*] $*" || true; }
err()  { echo "[-] $*" >&2; }
# Like log() but writes to stderr. Used by commands (e.g. search) whose stdout
# must stay machine-readable so it can be piped.
loge() { test "$QUIET" -eq 0 && echo "[+] $*" >&2 || true; }
logerr() { test "$QUIET" -eq 0 && echo "[-] $*" >&2 || true; }

# Use TMPDIR when provided, otherwise fallback to /tmp default.
tmp_base_dir() {
  printf '%s' "${TMPDIR:-/tmp}"
}

require_command() {
  local cmd="$1" purpose="$2"
  if ! command -v "$cmd" >/dev/null 2>&1; then
    err "$purpose requires '$cmd', but it was not found on PATH."
    return 1
  fi
}

require_gnu_command() {
  local cmd="$1" package="$2" purpose="$3" version
  require_command "$cmd" "$purpose" || return 1
  version="$("$cmd" --version 2>/dev/null || true)"
  case "$version" in
    *GNU*) ;;
    *) err "$purpose requires GNU $cmd from $package."; return 1 ;;
  esac
}

require_coreutils_command() {
  local cmd="$1" purpose="$2" version
  require_command "$cmd" "$purpose" || return 1
  version="$("$cmd" --version 2>/dev/null || true)"
  case "$version" in
    *"GNU coreutils"*|*"uutils coreutils"*) ;;
    *) err "$purpose requires '$cmd' from GNU coreutils or uutils coreutils."; return 1 ;;
  esac
}

require_replay_commands() {
  local purpose="$1"
  require_gnu_command find "GNU findutils" "$purpose" || return 1
  require_gnu_command xargs "GNU findutils" "$purpose" || return 1
  require_coreutils_command timeout "$purpose" || return 1
  local cmd
  for cmd in mktemp realpath sort tr wc; do
    require_command "$cmd" "$purpose" || return 1
  done
}

run_with_timeout() {
  local seconds="$1" pidfile rc pgid=""
  shift
  pidfile="$(mktemp "${TMPDIR:-/tmp}/cov-analysis-timeout.XXXXXX")" || return 1
  if timeout --signal=TERM --kill-after=1s "${seconds}s" \
    bash -c 'pgid="$BASHPID"; read -r _ _ _ _ pgid _ < "/proc/$BASHPID/stat" 2>/dev/null || true; printf "%s\n" "$pgid" > "$1"; shift; exec "$@"' \
    _ "$pidfile" "$@"; then
    rc=0
  else
    rc=$?
  fi
  test -s "$pidfile" && read -r pgid < "$pidfile"
  if test -n "$pgid"; then
    kill -KILL -- "-$pgid" 2>/dev/null || true
  fi
  rm -f -- "$pidfile"
  return "$rc"
}

command_for_input() {
  local cmd="$1" input="$2" quoted
  printf -v quoted '%q' "$input"
  printf '%s' "${cmd/@@/$quoted}"
}

run_input_command() {
  local seconds="$1" cmd="$2" input="$3" rendered
  case "$cmd" in
    *@@*)
      rendered="$(command_for_input "$cmd" "$input")"
      run_with_timeout "$seconds" bash -c "$rendered"
      ;;
    *)
      run_with_timeout "$seconds" bash -c "$cmd" < "$input"
      ;;
  esac
}

run_input_command_unbounded() {
  local cmd="$1" input="$2" rendered
  case "$cmd" in
    *@@*)
      rendered="$(command_for_input "$cmd" "$input")"
      bash -c "$rendered"
      ;;
    *)
      bash -c "$cmd" < "$input"
      ;;
  esac
}

write_profile_manifest() {
  local dir="$1" manifest="$2"
  find "$dir" -type f -name '*.profraw' -print | sort > "$manifest"
}

merge_profiles() {
  local tool="$1" dir="$2" output="$3" failure_mode="${4-}"
  local manifest="$dir/profiles.manifest"
  write_profile_manifest "$dir" "$manifest"
  test -s "$manifest" || return 1
  if test -n "$failure_mode"; then
    "$tool" merge -sparse "--failure-mode=$failure_mode" "--input-files=$manifest" -o "$output"
  else
    "$tool" merge -sparse "--input-files=$manifest" -o "$output"
  fi
}

paired_clang() {
  local compiler="$1" direction="$2" base dir paired
  base="$(basename -- "$compiler" 2>/dev/null)"
  dir="${compiler%/*}"
  test "$dir" = "$compiler" && dir=""
  if test "$direction" = "cxx" && test "$base" = "clang"; then
    paired="clang++"
  elif test "$direction" = "cxx" && [[ "$base" =~ ^clang-([0-9]+)$ ]]; then
    paired="clang++-${BASH_REMATCH[1]}"
  elif test "$direction" = "cc" && test "$base" = "clang++"; then
    paired="clang"
  elif test "$direction" = "cc" && [[ "$base" =~ ^clang\+\+-([0-9]+)$ ]]; then
    paired="clang-${BASH_REMATCH[1]}"
  else
    return 1
  fi
  if test -n "$dir"; then
    printf '%s/%s\n' "$dir" "$paired"
  else
    printf '%s\n' "$paired"
  fi
}

append_env_flags() {
  local name="$1" addition="$2" current="${!1-}"
  if test -n "$current"; then
    printf -v "$name" '%s %s' "$current" "$addition"
  else
    printf -v "$name" '%s' "$addition"
  fi
  export "$name"
}

directory_nonempty() {
  test -d "$1" && test -n "$(find "$1" -mindepth 1 -print -quit 2>/dev/null)"
}

valid_legacy_report() {
  local dir="$1"
  test -s "$dir/coverage.json" \
    && test -s "$dir/coverage.profdata" \
    && test -s "$dir/summary.txt" \
    && test -s "$dir/html/index.html" \
    && test -d "$dir/text"
}

owned_report() {
  local marker="$1/$REPORT_MARKER_NAME" first=""
  test -f "$marker" && ! test -L "$marker" || return 1
  IFS= read -r first < "$marker" || true
  test "$first" = "cov-analysis-report-v1"
}

validate_staged_report() {
  local dir="$1" reachability="$2"
  owned_report "$dir" || return 1
  test -s "$dir/coverage.profdata" || return 1
  test -s "$dir/coverage.json" || return 1
  test -s "$dir/summary.txt" || return 1
  test -s "$dir/html/index.html" || return 1
  test -d "$dir/text" || return 1
  grep -q '"data"' "$dir/coverage.json" || return 1
  if test -n "$reachability"; then
    grep -q 'reach-banner' "$dir/html/index.html" || return 1
    grep -q '^Reachable-only coverage' "$dir/summary.txt" || return 1
    grep -q '^== Reachability ' "$dir/summary.txt" || return 1
  fi
}

publish_report() {
  local stage="$1" destination="$2" parent base rollback=""
  parent="$(dirname -- "$destination")"
  base="$(basename -- "$destination")"
  if test -e "$destination"; then
    rollback="$parent/.${base}.cov-analysis.rollback.$$.${RANDOM}"
    while test -e "$rollback"; do
      rollback="$parent/.${base}.cov-analysis.rollback.$$.${RANDOM}"
    done
    if ! mv -T -- "$destination" "$rollback"; then
      err "Could not move the previous report aside for publication."
      return 1
    fi
  fi
  if ! mv -T -- "$stage" "$destination"; then
    err "Could not publish the staged report."
    if test -n "$rollback"; then
      if ! mv -T -- "$rollback" "$destination"; then
        err "Could not restore the previous report from: $rollback"
      fi
    fi
    return 1
  fi
  if test -n "$rollback"; then
    if ! rm -rf -- "$rollback"; then
      err "Report published, but the previous report could not be removed: $rollback"
    fi
  fi
  return 0
}

COV_REPORT_CLEAN_PROFRAW=""
COV_REPORT_CLEAN_STAGE=""

cleanup_report_work() {
  test -z "$COV_REPORT_CLEAN_PROFRAW" || rm -rf -- "$COV_REPORT_CLEAN_PROFRAW"
  test -z "$COV_REPORT_CLEAN_STAGE" || rm -rf -- "$COV_REPORT_CLEAN_STAGE"
}

# Validate an option's value. Prints error and exits 1 if $2 is missing or
# begins with '-'. Usage: need_arg <opt-name> <value-or-empty>
need_arg() {
  local opt="$1" val="${2-}"
  if test -z "$val"; then
    err "Option $opt requires an argument"
    exit 1
  fi
  case "$val" in
    -*) err "Option $opt requires an argument (got '$val' which looks like a flag)"
        exit 1 ;;
  esac
}

# Echo the LLVM major version that tool selection should match, or nothing.
# LLVM tools must match the clang that produced the .profraw, otherwise
# `llvm-profdata merge` fails with a version mismatch. We derive the version
# from the selected compiler so e.g. CC=clang-22 maps to llvm-profdata-22:
#   1. an explicit version suffix in CC/CXX  (clang-22  -> 22)
#   2. the version reported by the selected clang (CC, else plain `clang`)
llvm_version_hint() {
  local c base v out
  for c in "${CC-}" "${CXX-}"; do
    test -n "$c" || continue
    base="$(basename -- "$c" 2>/dev/null)"
    if [[ "$base" =~ -([0-9]+)$ ]]; then
      echo "${BASH_REMATCH[1]}"
      return 0
    fi
  done
  c="${CC:-clang}"
  command -v "$c" >/dev/null 2>&1 || return 0
  out="$("$c" --version 2>/dev/null || true)"
  case "$out" in
    *'Apple clang'*) return 0 ;;
  esac
  v="$(printf '%s\n' "$out" | grep -oiE 'clang version [0-9]+' | grep -oE '[0-9]+' | head -n1)"
  test -n "$v" && echo "$v"
  return 0
}

# Find an LLVM tool, preferring the version matching the selected clang
# (see llvm_version_hint), then the bare name, then versioned (26 down to 11).
find_tool() {
  local tool="$1"
  local ver hint
  hint="$(llvm_version_hint)"
  if test -n "$hint" && command -v "${tool}-${hint}" >/dev/null 2>&1; then
    echo "${tool}-${hint}"
    return 0
  fi
  if command -v "$tool" >/dev/null 2>&1; then
    echo "$tool"
    return 0
  fi
  for ver in 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11; do
    if command -v "${tool}-${ver}" >/dev/null 2>&1; then
      echo "${tool}-${ver}"
      return 0
    fi
  done
  return 1
}

# Extract the instrumented binary path from a coverage command string.
# Skips leading KEY=VALUE env assignments and @@ placeholders.
extract_binary() {
  local cmd="$1"
  local token candidate="" rc=1
  local -a tokens=()
  [[ "$cmd" == *$'\n'* || "$cmd" == *$'\r'* ]] && return 1
  case "$cmd" in
    *[\'\"\\\;\|\&\<\>\(\)\{\}\[\]\$\`\!]* ) return 1 ;;
  esac
  read -r -a tokens <<< "$cmd"
  for token in "${tokens[@]}"; do
    if test "$token" = "@@"; then
      continue
    fi
    if [[ "$token" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
      test -n "$candidate" && return 1
      continue
    fi
    case "$token" in
      *)
        if test -z "$candidate"; then
          case "$(basename -- "$token" 2>/dev/null)" in
            env|command|exec|nice|nohup|setsid|stdbuf|sudo|doas|chroot|timeout|bash|sh|dash|zsh|fish|python|python3|perl|ruby|libtool|valgrind|rr|gdb|qemu-*) return 1 ;;
          esac
          candidate="$token"
          rc=0
        elif test -x "$token"; then
          return 1
        fi
        ;;
    esac
  done
  test -n "$candidate" && printf '%s\n' "$candidate"
  return $rc
}

resolve_coverage_binary() {
  local cmd="$1" explicit="$2" resolved=""
  if test -n "$explicit"; then
    resolved="$explicit"
  elif ! resolved="$(extract_binary "$cmd")"; then
    err "Could not safely infer the coverage binary from: $cmd"
    err "Use --binary <path> for quoted paths, wrappers, or shell syntax."
    return 1
  fi
  if ! test -f "$resolved" || ! test -x "$resolved"; then
    err "Coverage binary not found or not executable: $resolved"
    return 1
  fi
  printf '%s\n' "$resolved"
}

# Return 0 if the binary was built from the cov-analysis driver
# (coverage_driver.c). Such binaries embed a fixed signature string and read
# their inputs from file arguments (the @@ placeholder), never from stdin.
is_cov_driver_binary() {
  local bin="$1"
  test -n "$bin" || return 1
  grep -qaF '###SIGNATURE_LLVMFUZZERTESTONEINPUT_COVERAGE###' "$bin" 2>/dev/null
}

# If the coverage command has no @@ placeholder but the binary is a cov-analysis
# driver (argv-only input), append @@ so the forgotten placeholder is supplied
# automatically. Echoes the (possibly adjusted) command. A trailing @@ also lets
# the replay use fast batch mode.
add_at_if_driver() {
  local cmd="$1" bin="$2"
  case "$cmd" in
    *@@*) printf '%s' "$cmd"; return 0 ;;
  esac
  if is_cov_driver_binary "$bin"; then
    printf '%s @@' "$cmd"
  else
    printf '%s' "$cmd"
  fi
}

# Classify $AFL_DIR as one of:
#   afl    — AFL++ layout: a queue/crashes/timeouts directory exists
#   flat   — libFuzzer/libafl/honggfuzz flat corpus or crash dir (≥1 regular file)
#   empty  — directory is empty or unreadable
detect_fuzzer_layout() {
  if test -d "$AFL_DIR/queue" || test -d "$AFL_DIR/crashes" || test -d "$AFL_DIR/timeouts"; then
    echo "afl"; return 0
  fi
  local p
  shopt -s nullglob
  for p in "$AFL_DIR"/*/queue "$AFL_DIR"/*/crashes "$AFL_DIR"/*/timeouts; do
    if test -d "$p"; then
      shopt -u nullglob
      echo "afl"; return 0
    fi
  done
  shopt -u nullglob
  if find "$AFL_DIR" -maxdepth 1 -type f -print -quit 2>/dev/null | grep -q .; then
    echo "flat"; return 0
  fi
  echo "empty"
}

# Emit null-delimited queue/corpus file paths for the detected layout.
# Reads globals: AFL_DIR, FUZZER_LAYOUT
find_queue_files() {
  case "${FUZZER_LAYOUT:-afl}" in
    afl)
      local paths=()
      test -d "$AFL_DIR/queue" && paths+=("$AFL_DIR/queue")
      local p
      shopt -s nullglob
      for p in "$AFL_DIR"/*/queue; do paths+=("$p"); done
      shopt -u nullglob
      if test "${#paths[@]}" -gt 0; then
        find "${paths[@]}" -maxdepth 1 -type f -name 'id:*' -print0 2>/dev/null || true
      fi
      ;;
    flat)
      # libFuzzer/libafl/honggfuzz flat corpus: every regular file that isn't a
      # known crash artifact or metadata file.
      find "$AFL_DIR" -maxdepth 1 -type f \
        ! -name 'crash-*' \
        ! -name 'leak-*' \
        ! -name 'oom-*' \
        ! -name 'timeout-*' \
        ! -name 'slow-unit-*' \
        ! -name 'SIG*.fuzz' \
        ! -name 'HONGGFUZZ.REPORT.TXT' \
        -print0 2>/dev/null || true
      ;;
  esac
}

# Emit null-delimited crash/timeout file paths for the detected layout.
# Reads globals: AFL_DIR, FUZZER_LAYOUT
find_crash_timeout_files() {
  case "${FUZZER_LAYOUT:-afl}" in
    afl)
      local paths=()
      test -d "$AFL_DIR/crashes"  && paths+=("$AFL_DIR/crashes")
      test -d "$AFL_DIR/timeouts" && paths+=("$AFL_DIR/timeouts")
      local p
      shopt -s nullglob
      for p in "$AFL_DIR"/*/crashes  ; do paths+=("$p"); done
      for p in "$AFL_DIR"/*/timeouts ; do paths+=("$p"); done
      shopt -u nullglob
      if test "${#paths[@]}" -gt 0; then
        find "${paths[@]}" -maxdepth 1 -type f -name 'id:*' -print0 2>/dev/null || true
      fi
      ;;
    flat)
      # libFuzzer artifacts (crash-*/leak-*/oom-*/timeout-*/slow-unit-*)
      # and honggfuzz crash files (SIG*.fuzz).
      find "$AFL_DIR" -maxdepth 1 -type f \
        \( -name 'crash-*' \
           -o -name 'leak-*' \
           -o -name 'oom-*' \
           -o -name 'timeout-*' \
           -o -name 'slow-unit-*' \
           -o -name 'SIG*.fuzz' \) \
        -print0 2>/dev/null || true
      ;;
  esac
}

# Count null-delimited records from a file-list function
count_files() {
  "$@" | tr -cd '\0' | wc -c
}

# Parse a "FILE:LINE" target spec. Splits on the LAST ':' so paths that contain
# colons still parse. Prints "FILE<TAB>LINE" and returns 0 on success; prints an
# error and returns 1 on a malformed spec.
parse_target_spec() {
  local spec="$1" file line
  case "$spec" in
    *:*) ;;
    *)   err "Target must be FILE:LINE (got '$spec')"; return 1 ;;
  esac
  file="${spec%:*}"
  line="${spec##*:}"
  if test -z "$file"; then
    err "Target FILE part is empty in '$spec'"; return 1
  fi
  case "$line" in
    ''|*[!0-9]*) err "Target LINE must be a positive integer (got '$line')"; return 1 ;;
  esac
  if test "$line" -eq 0; then
    err "Target LINE must be >= 1 (got '$line')"; return 1
  fi
  printf '%s\t%s\n' "$file" "$line"
}

# Classify a source line's coverage from LCOV text on stdin.
# Args: FILE LINE. Prints exactly one of: covered | uncovered | absent.
#   covered   — a matching DA:LINE,COUNT has COUNT > 0
#   uncovered — a matching DA:LINE,COUNT exists with COUNT == 0
#   absent    — no DA:LINE record in any matching SF: block
# An SF: path matches FILE when it equals FILE or ends with "/FILE" (suffix
# match with a path boundary). Returns 0 (state is on stdout).
lcov_line_state() {
  local file="$1" line="$2"
  awk -v tf="$file" -v tl="$line" '
    function endswith(s, suf,   ls, lsuf) {
      ls = length(s); lsuf = length(suf);
      return (ls >= lsuf && substr(s, ls - lsuf + 1) == suf);
    }
    /^SF:/ {
      sf = substr($0, 4);
      inmatch = (sf == tf) || endswith(sf, "/" tf);
      next;
    }
    /^end_of_record/ { inmatch = 0; next }
    inmatch && /^DA:/ {
      split(substr($0, 4), a, ",");
      if (a[1] == tl) {
        seen = 1;
        if (a[2] + 0 > 0) covered = 1;
      }
    }
    END {
      if (covered)    print "covered";
      else if (seen)  print "uncovered";
      else            print "absent";
    }
  '
}

# Emit the coverage replay driver C source
emit_driver() {
  cat << 'DRIVER_EOF'
/* coverage_driver.c - Replay driver for LLVMFuzzerTestOneInput harnesses.
 * Reads files from command-line arguments and calls LLVMFuzzerTestOneInput.
 * Crash handler flushes coverage data on signals so crashing inputs still
 * contribute to the report.
 *
 * Compile and link example:
 *   clang -fprofile-instr-generate -fcoverage-mapping \
 *     -c coverage_driver.c -o coverage_driver.o
 *   clang -fprofile-instr-generate \
 *     coverage_driver.o -L./build -ltarget -o cov
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>

int LLVMFuzzerInitialize(int *argc, char ***argv) __attribute__((weak));
int LLVMFuzzerTestOneInput(const unsigned char*, size_t);

extern int __llvm_profile_write_file(void);

static void crash_handler(int sig) {
    __llvm_profile_write_file();
    raise(sig);
}

__attribute__((constructor))
static void install_crash_handlers(void) {
    const int sigs[] = { SIGABRT, SIGSEGV, SIGBUS, SIGFPE, SIGILL, SIGTERM };
    struct sigaction sa = {
        .sa_handler = crash_handler,
        .sa_flags   = SA_RESETHAND,
    };
    sigemptyset(&sa.sa_mask);
    for (int i = 0; i < (int)(sizeof(sigs) / sizeof(sigs[0])); i++)
        sigaction(sigs[i], &sa, NULL);
}

int main(int argc, char **argv) {
    // needed for auto-detection in compiled binaries:
    if (argc == 2 && strcmp(argv[1], "--printsignature") == 0) {
        printf("###SIGNATURE_LLVMFUZZERTESTONEINPUT_COVERAGE###\n");
    }

    if (LLVMFuzzerInitialize) {
        fprintf(stderr, "Running LLVMFuzzerInitialize ...\n");
        LLVMFuzzerInitialize(&argc, &argv);
    }

    for (int i = 1; i < argc; i++) {
        FILE *f = fopen(argv[i], "rb");
        if (f) {
            fseek(f, 0, SEEK_END);
            long len = ftell(f);
            if (len > 0) {
                fseek(f, 0, SEEK_SET);
                unsigned char *buf = (unsigned char *)malloc((size_t)len);
                if (buf) {
                    size_t n_read = fread(buf, 1, (size_t)len, f);
                    if (n_read > 0) {
                        fprintf(stderr, "Running: %s (%d/%d) %zu bytes\n",
                                argv[i], i, argc - 1, n_read);
                        LLVMFuzzerTestOneInput((const unsigned char*)buf, n_read);
                    } else {
                        fprintf(stderr, "Error: Read failed for %s\n", argv[i]);
                    }
                    free(buf);
                }
            }
            fclose(f);
        }
    }

    fprintf(stderr, "Done.\n");
    return 0;
}
DRIVER_EOF
}

# ── usage ─────────────────────────────────────────────────────────────────────

usage_main() {
  cat << 'EOF'
Usage: cov-analysis [command] [options]

Commands:
  report      Generate coverage report from AFL++ corpus (DEFAULT)
  build       Build target with LLVM coverage instrumentation
  driver      Emit coverage_driver.c for LLVMFuzzerTestOneInput harnesses
  diff        Compare coverage between two llvm-cov JSON exports
  stability   Analyze corpus stability (per-line non-deterministic hit counts)
  search      List corpus entries that reach a given FILE:LINE

Run 'cov-analysis <command> --help' for command-specific options.

Global options:
  -V          Print version and exit
  -h, --help  Print this help and exit
EOF

  echo ""
  echo "Help for default command \"report\" (does not need to be specified):"
  echo ""
  usage_report
}

usage_report() {
  cat << 'EOF'
Usage: cov-analysis [report] [options]

Required:
  -d <dir>    Fuzzing output directory (AFL++, libFuzzer, libafl, or honggfuzz)
  -e <cmd>    Coverage command. Use @@ as input file placeholder.
              Omit @@ to feed input via stdin instead. For a cov-analysis
              driver binary (which reads files, not stdin), @@ is appended
              automatically when omitted.

Optional:
  -o <dir>           Report output directory (default: <afl-dir>/cov)
  -t <num>           Parallel replay workers/forks (default: 1)
  -T <secs>          Timeout in seconds for crash/timeout replay (default: 5)
                     Timed targets receive TERM, then KILL after 1 second.
  --binary <path>    Instrumented binary used by LLVM and driver detection.
                     Required when -e contains a quoted binary path, wrapper,
                     or other shell syntax that cannot be inferred safely.
  --layout <kind>    Force layout: 'afl' or 'flat' (default: auto-detect)
  --ignore-regex <r> Filename regex to exclude from llvm-cov reports
                     (default: /usr/include/)
  --reachability <p> Cross-reference fuzz-reachability output and annotate the
                     HTML and text reports in place. <p> is its JSON report, its
                     output directory (reachability.json when present, else
                     reached.txt/not_reached.txt), or a single sancov
                     allow/ignore .txt list. In the HTML file view each
                     function is tinted: dark grey = statically unreachable
                     (expected dead), amber = reachable but not reached (the
                     actionable gap), purple = covered yet flagged unreachable;
                     covered code keeps llvm-cov's coloring. The text source view
                     gets a per-line U/R/A marker column, and summary.txt gains a
                     reachability tally plus the list of functions to cover.
                     The Function/Line/Region/Branch coverage numbers in
                     index.html and summary.txt are recomputed to EXCLUDE
                     statically-unreachable functions, so dead code no longer
                     drags the percentages down. (The HTML index is rendered
                     flat — without directory grouping — so its numbers can be
                     rewritten reliably.) Invalid input, per-function metrics,
                     or annotation failures make the report command fail.
  --migrate-existing-report
                     Replace a complete pre-marker cov-analysis report after a
                     successful staged run. Without this explicit option, a
                     non-empty output directory must contain
                     .cov-analysis-report. New and empty directories are safe.
  -v                 Verbose output
  -q                 Quiet mode (suppress all [+] output)
  -V                 Print version and exit
  -h, --help         Print this help and exit

Examples:
  # Standard AFL++ replay with file-based input
  cov-analysis -d out -e "./cov @@"

  # Cross-reference reachability so unreached-but-reachable functions stand out
  cov-analysis -d out -e "./cov @@" --reachability ../reachability/test.json

  # With env vars, custom report dir, 1s timeout
  cov-analysis -d out -e "LD_LIBRARY_PATH=./lib ./cov @@" -o coverage -T 1

  cov-analysis -d out -e 'env MODE=cov "/opt/my app/cov" @@' \
    --binary "/opt/my app/cov"

  # Replay coverage with 8 parallel workers
  cov-analysis -d out -e "./cov @@" -t 8

  # stdin input (binary reads from stdin)
  cov-analysis -d out -e "./cov"

  # Exclude test files and system headers from report
  cov-analysis -d out -e "./cov @@" --ignore-regex '(/usr/include/|/test/)'

  # libFuzzer corpus (flat directory of corpus files)
  cov-analysis -d ./corpus -e "./cov @@"

  # libafl corpus (flat directory of corpus files), stdin method
  cov-analysis -d ./corpus -e "./cov"

  # honggfuzz workspace (corpus + SIG*.fuzz crash files)
  cov-analysis -d ./hfuzz-workdir -e "./cov @@"
EOF
}

usage_build() {
  cat << 'EOF'
Usage: cov-analysis build <build-command> [args...]

  Resolves CC/CXX, appends LLVM coverage options to existing CFLAGS,
  CXXFLAGS, CPPFLAGS, and LDFLAGS, then runs the build command, e.g.:
    cov-analysis build ./configure --disable-shared
    cov-analysis build make -j$(nproc)

  Set CC and/or CXX to override auto-detection. A missing counterpart is
  derived for clang, clang-N, clang++, or clang++-N, including absolute paths.
  Set both variables when using custom wrappers.

  The report/search/stability commands pick llvm-profdata / llvm-cov to match
  the selected clang version (e.g. CC=clang-22 -> llvm-profdata-22), so raw
  profiles merge without a version mismatch. Keep CC/CXX consistent between
  building and reporting, or set CC to the versioned clang when reporting.

Options:
  -h, --help    Show this help
  -V            Print version and exit
EOF
}

usage_driver() {
  cat << 'EOF'
Usage: cov-analysis driver [-o output.c]

  Emits coverage_driver.c source to stdout (or to -o FILE).
  Use this for LLVMFuzzerTestOneInput harnesses to replay corpus files.

  The driver loops over all file arguments, calls LLVMFuzzerTestOneInput
  for each, and installs a crash handler that flushes profiling data so
  crashing inputs can contribute to the coverage report. Crash-time flushing
  is best effort: __llvm_profile_write_file() is not async-signal-safe and may
  fail after severe memory corruption. The original signal is re-raised.

Options:
  -o <file>     Write driver source to FILE instead of stdout
  -h, --help    Show this help
  -V            Print version and exit

Example:
  cov-analysis driver -o coverage_driver.c
  clang -fprofile-instr-generate -fcoverage-mapping \
    coverage_driver.c -L./build -ltarget -o cov
EOF
}

usage_diff() {
  cat << 'EOF'
Usage: cov-analysis diff [-o <report-dir>] [--only-changed] [--reachability <path>] [<OLD_JSON> <NEW_JSON>]

  Compare coverage between two llvm-cov JSON exports and generate an
  HTML diff report showing newly covered, lost, and still-uncovered
  lines and functions.

  Arguments:
    OLD_JSON  Path to the baseline coverage JSON
              (default: <report-dir>/coverage_old.json)
    NEW_JSON  Path to the updated coverage JSON
              (default: <report-dir>/coverage.json)

Options:
  -o <dir>           Report directory for default-path lookups and HTML output
                     (default: .). The HTML is written to <dir>/coverage_diff.html.
  --reachability <p> Cross-reference fuzz-reachability output (its JSON report,
                     its output directory — reachability.json when present,
                     else reached.txt/not_reached.txt — or a sancov
                     allow/ignore .txt list). Splits the "still uncovered
                     functions" list into reachable (amber, actionable) and
                     unreachable (grey, expected dead).
  --only-changed     Omit unchanged files. By default the full report includes
                     unchanged files and their still-uncovered lines/functions.
  -h, --help         Print this help and exit
EOF
}

usage_stability() {
  cat << 'EOF'
Usage: cov-analysis stability [options]

  Run each corpus input N times with LLVM coverage, collect per-line hit
  counts, and flag lines where counts vary across runs as "unstable."
  Reports a stability percentage. If instability is found with the default
  4 runs, reruns for a total of 8 to confirm.

  Resilient to flaky passes: a pass whose profiles cannot be collected or
  merged (e.g. a crashing input that left a truncated .profraw behind) is
  skipped and the run continues with the remaining passes, as long as at
  least 2 passes succeed.

Required:
  -d <dir>    Fuzzing output directory (AFL++, libFuzzer, libafl, or honggfuzz)
  -e <cmd>    Coverage command. Use @@ as input file placeholder.
              Omit @@ to feed input via stdin instead. For a cov-analysis
              driver binary (which reads files, not stdin), @@ is appended
              automatically when omitted.

Optional:
  -n <num>           Number of runs per corpus pass (default: 4)
  -s <prefix>        Only consider source lines whose file path contains
                     this prefix (e.g. -s src/)
  -t <num>           Parallel replay workers (default: 1)
  -T <secs>          Per-input timeout in seconds (default: 5)
                     Timed targets receive TERM, then KILL after 1 second.
  --binary <path>    Instrumented binary used by LLVM and driver detection;
                     required for ambiguous -e shell commands.
  --layout <kind>    Force layout: 'afl' or 'flat' (default: auto-detect)
  -v                 Verbose output
  -q                 Quiet mode (suppress all [+] output)
  -V                 Print version and exit
  -h, --help         Print this help and exit

Examples:
  cov-analysis stability -d out -e "./cov @@"
  cov-analysis stability -d out -e "./cov @@" -n 8 -s src/
  cov-analysis stability -d ./corpus -e "./cov @@" -t 4
EOF
}

usage_search() {
  cat << 'EOF'
Usage: cov-analysis search FILE:LINE -d <dir> -e "<cmd>" [options]

  Report which corpus entries reach a given source line. Each input is replayed
  in isolation through the coverage binary; an input "reaches" FILE:LINE when its
  line-execution count for that line is > 0.

  Matching input paths are printed to stdout (one per line, sorted), so the
  output pipes cleanly. Progress and the summary go to stderr.

Required:
  FILE:LINE   Source location, e.g. src/foo.c:123 (single line; split on last ':')
  -d <dir>    Fuzzing output directory (AFL++, libFuzzer, libafl, or honggfuzz)
  -e <cmd>    Coverage command. Use @@ as input file placeholder.
              Omit @@ to feed input via stdin instead. For a cov-analysis
              driver binary (which reads files, not stdin), @@ is appended
              automatically when omitted.

Optional:
  --crashes          Also scan crash and timeout inputs (default: corpus only)
  -t <num>           Parallel workers for the per-input scan (default: 1)
  -T <secs>          Per-input replay timeout in seconds (default: 5)
                     Applies to every selected input in both union and isolated
                     replay; targets receive TERM, then KILL after 1 second.
  --binary <path>    Instrumented binary used by LLVM and driver detection;
                     required for ambiguous -e shell commands.
  --layout <kind>    Force layout: 'afl' or 'flat' (default: auto-detect)
  -v                 Verbose output
  -q                 Quiet mode (suppress all [+] output)
  -V                 Print version and exit
  -h, --help         Print this help and exit

Examples:
  cov-analysis search src/parser.c:142 -d out -e "./cov @@"
  cov-analysis search src/parser.c:142 -d out -e "./cov @@" --crashes -t 8
  cov-analysis search src/parser.c:142 -d ./corpus -e "./cov"     # stdin input

  # Feed the reaching inputs into another tool:
  cov-analysis search src/parser.c:142 -d out -e "./cov @@" | xargs -I{} cp {} ./hits/
EOF
}

# ── command: build ────────────────────────────────────────────────────────────

cmd_build() {
  if test $# -eq 0; then usage_build; exit 1; fi
  case "$1" in
    -h|--help) usage_build; exit 0 ;;
    -V)        echo "cov-analysis-$VERSION"; exit 0 ;;
  esac

  # Build mode: refuse if an AFL++ compiler is already set. Match on basename
  # against the known afl-* wrappers rather than a substring search, so paths
  # like /opt/waffle/bin/clang do not trigger a false positive.
  local _cc_base _cxx_base
  _cc_base="$(basename -- "${CC-}" 2>/dev/null || true)"
  _cxx_base="$(basename -- "${CXX-}" 2>/dev/null || true)"
  case "$_cc_base $_cxx_base" in
    *afl-clang*|*afl-gcc*|*afl-g++*|*afl-cc*|*afl-c++*)
      err "AFL++ compiler is set in CC/CXX — unset it before building a coverage binary."
      exit 1
      ;;
  esac

  if test -z "${CC-}" && test -z "${CXX-}"; then
    if command -v clang >/dev/null 2>&1 && command -v clang++ >/dev/null 2>&1; then
      CC=clang
      CXX=clang++
    else
      local ver
      for ver in 26 25 24 23 22 21 20 19 18 17 16 15 14 13 12 11; do
        if command -v "clang-$ver" >/dev/null 2>&1 \
           && command -v "clang++-$ver" >/dev/null 2>&1; then
          CC="clang-$ver"
          CXX="clang++-$ver"
          break
        fi
      done
    fi
  elif test -n "${CC-}" && test -z "${CXX-}"; then
    if ! CXX="$(paired_clang "$CC" cxx)"; then
      err "Cannot derive CXX from CC='$CC'. Set both CC and CXX for custom compiler wrappers."
      exit 1
    fi
  elif test -z "${CC-}" && test -n "${CXX-}"; then
    if ! CC="$(paired_clang "$CXX" cc)"; then
      err "Cannot derive CC from CXX='$CXX'. Set both CC and CXX for custom compiler wrappers."
      exit 1
    fi
  fi

  if test -z "${CC-}" || test -z "${CXX-}"; then
    err "A complete Clang compiler pair was not found. Install clang/clang++ or set both CC and CXX."
    exit 1
  fi
  if ! command -v "$CC" >/dev/null 2>&1; then
    err "C compiler not found or not executable: $CC"
    exit 1
  fi
  if ! command -v "$CXX" >/dev/null 2>&1; then
    err "C++ compiler not found or not executable: $CXX"
    exit 1
  fi
  export CC CXX
  echo "[+] Using compiler: ${CC-} / ${CXX-}" >&2

  append_env_flags CFLAGS "-fprofile-instr-generate -fcoverage-mapping"
  append_env_flags CXXFLAGS "-fprofile-instr-generate -fcoverage-mapping"
  append_env_flags CPPFLAGS "-DFUZZING_BUILD_MODE_UNSAFE_FOR_PRODUCTION=1"
  append_env_flags LDFLAGS "-fprofile-instr-generate"

  exec "$@"
}

# ── command: driver ──────────────────────────────────────────────────────────

cmd_driver() {
  case "${1-}" in
    -h|--help) usage_driver; exit 0 ;;
    -V)        echo "cov-analysis-$VERSION"; exit 0 ;;
  esac

  if test "${1-}" = "-o"; then
    need_arg "-o" "${2-}"
    emit_driver > "$2"
    echo "[+] coverage_driver.c written to: $2" >&2
  else
    emit_driver
  fi
  exit 0
}

# ── command: stability ───────────────────────────────────────────────────────

cmd_stability() {
  local AFL_DIR=""
  local COVERAGE_CMD=""
  local RUNS=4
  local SOURCE_FILTER=""
  local FORKS=1
  local TIMEOUT=5
  local LLVM_PROFDATA=""
  local LLVM_COV=""
  local STAB_DIR=""
  local COV_BINARY=""
  local BINARY_OVERRIDE=""
  local USER_RUNS=""   # set if -n was explicitly provided
  local FUZZER_LAYOUT="${FUZZER_LAYOUT:-}"
  local GAWK=""

  if test $# -eq 0; then usage_stability; exit 1; fi

  while [ $# -gt 0 ]; do
    case "$1" in
      -d)        need_arg "-d" "${2-}"; AFL_DIR="$2";                  shift 2 ;;
      -e)        need_arg "-e" "${2-}"; COVERAGE_CMD="$2";             shift 2 ;;
      -n)        need_arg "-n" "${2-}"; RUNS="$2"; USER_RUNS="1";      shift 2 ;;
      -s)        need_arg "-s" "${2-}"; SOURCE_FILTER="$2";            shift 2 ;;
      -t)        need_arg "-t" "${2-}"; FORKS="$2";                    shift 2 ;;
      -T)        need_arg "-T" "${2-}"; TIMEOUT="$2";                  shift 2 ;;
      --binary)  need_arg "--binary" "${2-}"; BINARY_OVERRIDE="$2";   shift 2 ;;
      -v)        VERBOSE=1;                     shift   ;;
      -q)        QUIET=1;                       shift   ;;
      -V)        echo "cov-analysis-$VERSION"; exit 0 ;;
      -h|--help) usage_stability; exit 0 ;;
      --layout)  need_arg "--layout" "${2-}"; FUZZER_LAYOUT="$2";      shift 2 ;;
      *) err "Unknown option: $1"; echo "" >&2; usage_stability >&2; exit 1 ;;
    esac
  done

  # ── validate inputs ────────────────────────────────────────────────────────
  if test -z "$AFL_DIR"; then
    err "Must specify input directory with -d"
    exit 1
  fi
  if test -z "$COVERAGE_CMD"; then
    err "Must specify coverage command with -e"
    exit 1
  fi
  if ! test -d "$AFL_DIR"; then
    err "Input directory does not exist: $AFL_DIR"
    exit 1
  fi
  case "$RUNS" in
    ''|*[!0-9]*|0|1)
      err "Run count (-n) must be a positive integer >= 2: $RUNS"
      exit 1
      ;;
  esac
  case "$FORKS" in
    ''|*[!0-9]*|0)
      err "Replay worker count must be a positive integer: $FORKS"
      exit 1
      ;;
  esac
  case "$TIMEOUT" in
    ''|*[!0-9]*|0)
      err "Timeout (-T) must be a positive integer in seconds: $TIMEOUT"
      exit 1
      ;;
  esac

  if test -n "$FUZZER_LAYOUT"; then
    case "$FUZZER_LAYOUT" in
      afl|flat) ;;
      *) err "--layout must be 'afl' or 'flat' (got '$FUZZER_LAYOUT')"; exit 1 ;;
    esac
  fi

  require_replay_commands "cov-analysis stability" || exit 1

  COV_BINARY="$(resolve_coverage_binary "$COVERAGE_CMD" "$BINARY_OVERRIDE")" || exit 1
  logv "Coverage binary : $COV_BINARY"

  # Forgotten @@ on a driver binary → supply it automatically (argv-only input).
  COVERAGE_CMD="$(add_at_if_driver "$COVERAGE_CMD" "$COV_BINARY")"
  logv "Replay command  : $COVERAGE_CMD"

  # ── detect LLVM tools ──────────────────────────────────────────────────────
  LLVM_PROFDATA="$(find_tool llvm-profdata)" || {
    err "llvm-profdata not found. Install LLVM (apt install llvm / dnf install llvm)."
    exit 1
  }
  LLVM_COV="$(find_tool llvm-cov)" || {
    err "llvm-cov not found. Install LLVM (apt install llvm / dnf install llvm)."
    exit 1
  }
  log "LLVM tools      : $LLVM_PROFDATA, $LLVM_COV"

  # Stability aggregation needs gawk (multi-dim arrays, ARGIND). mawk and
  # busybox awk will not work. Prefer an explicit `gawk` binary if the
  # system `awk` is not gawk.
  if awk --version 2>/dev/null | grep -qi '^GNU Awk'; then
    GAWK="awk"
  elif command -v gawk >/dev/null 2>&1; then
    GAWK="gawk"
  else
    err "cov-analysis stability requires GNU awk (gawk)."
    err "Install it (apt install gawk / dnf install gawk)."
    exit 1
  fi
  logv "Using gawk      : $GAWK"

  # ── detect or honor fuzzer layout ──────────────────────────────────────────
  if test -z "$FUZZER_LAYOUT"; then
    FUZZER_LAYOUT="$(detect_fuzzer_layout)"
  fi
  case "$FUZZER_LAYOUT" in
    afl)   log "Fuzzer layout   : AFL++ (queue/crashes/timeouts)" ;;
    flat)  log "Fuzzer layout   : flat (libFuzzer/libafl/honggfuzz)" ;;
    empty)
      err "No input files found in $AFL_DIR"
      err "Expected one of: AFL++ out dir, libFuzzer/libafl corpus, honggfuzz workspace."
      err "Override detection with --layout afl|flat if needed."
      exit 1
      ;;
  esac

  # ── prepare workspace ──────────────────────────────────────────────────────
  STAB_DIR="$(mktemp -d "$(tmp_base_dir)/cov-analysis-stability.XXXXXX")"
  trap "rm -rf '$STAB_DIR'" EXIT INT TERM

  local CORPUS_SIZE
  CORPUS_SIZE=$(count_files find_queue_files)
  if test "$CORPUS_SIZE" -eq 0; then
    err "No corpus files found in $AFL_DIR"
    exit 1
  fi

  log "Input directory : $AFL_DIR"
  log "Corpus size     : $CORPUS_SIZE inputs"
  log "Coverage binary : $COV_BINARY"
  log "Runs            : $RUNS"
  test -n "$SOURCE_FILTER" && log "Source filter   : $SOURCE_FILTER"

  # ── per-run coverage collection ────────────────────────────────────────────
  # Collect one pass: replay all corpus, merge profraw, export LCOV, parse hits.
  # Accepts run index as $1; reads globals COVERAGE_CMD, FORKS, STAB_DIR,
  # COV_BINARY, SOURCE_FILTER, LLVM_PROFDATA, LLVM_COV, RUNS.
  _stab_collect_pass() {
    local idx="$1"
    local run_dir="$STAB_DIR/run_${idx}"
    mkdir -p "$run_dir"

    log "Collecting pass $idx/$RUNS..."
    export LLVM_PROFILE_FILE="$run_dir/cov-%p.profraw"
    export -f run_with_timeout command_for_input run_input_command
    find_queue_files | xargs -0 -r -n 1 -P "$FORKS" \
      bash -c 'run_input_command "$2" "$1" "$3"' _ "$COVERAGE_CMD" "$TIMEOUT" \
      >/dev/null 2>&1 || true

    # A single failed pass must never abort the whole run: stability is a
    # multi-sample measurement, so we skip a bad pass (return 1) and let the
    # caller carry on with the rest. The most common cause is a crashing or
    # timed-out input that left a truncated .profraw behind.
    local profraw_count
    profraw_count=$(find "$run_dir" -name '*.profraw' -printf . 2>/dev/null | wc -c)
    if test "$profraw_count" -eq 0; then
      err "No .profraw files generated in pass $idx; skipping it."
      return 1
    fi
    logv "Pass $idx: merging $profraw_count profraw file(s)..."

    # --failure-mode=all: tolerate individual corrupt/truncated .profraw files
    # (drop them, keep the valid ones); only fail if every profile is invalid.
    merge_profiles "$LLVM_PROFDATA" "$run_dir" \
      "$STAB_DIR/merged_run_${idx}.profdata" all 2>/dev/null || {
      err "llvm-profdata merge failed for pass $idx; skipping it."
      return 1
    }

    logv "Pass $idx: exporting LCOV..."
    "$LLVM_COV" export "$COV_BINARY" \
      --format=lcov \
      "-instr-profile=$STAB_DIR/merged_run_${idx}.profdata" \
      > "$STAB_DIR/run_${idx}.lcov" 2>/dev/null || {
      err "llvm-cov export (lcov) failed for pass $idx; skipping it."
      return 1
    }

    logv "Pass $idx: parsing hit counts..."
    awk -v filter="$SOURCE_FILTER" '
      /^SF:/ { file = substr($0, 4) }
      /^DA:/ {
        if (filter != "" && index(file, filter) == 0) next
        split(substr($0, 4), a, ",")
        print file ":" a[1] "\t" a[2]
      }
    ' "$STAB_DIR/run_${idx}.lcov" | sort > "$STAB_DIR/run_${idx}.hits"
    return 0
  }

  # stab_passes holds the run indices that produced usable per-line hit counts.
  # Failed passes are skipped (see _stab_collect_pass) instead of aborting.
  local stab_passes=()
  local run_idx
  for run_idx in $(seq 1 "$RUNS"); do
    if _stab_collect_pass "$run_idx"; then
      stab_passes+=("$run_idx")
    fi
  done

  log "Collected all data, analyzing ..."

  # Comparing hit counts across runs needs at least two successful passes.
  if test "${#stab_passes[@]}" -lt 2; then
    err "Only ${#stab_passes[@]} of $RUNS pass(es) produced coverage data — need at least 2 to assess stability."
    err "Check that the binary is instrumented with -fprofile-instr-generate (see 'cov-analysis build')."
    exit 1
  fi
  if test "${#stab_passes[@]}" -lt "$RUNS"; then
    err "$((RUNS - ${#stab_passes[@]})) of $RUNS pass(es) failed and were skipped; analyzing the ${#stab_passes[@]} that succeeded."
  fi

  # ── stability analysis ─────────────────────────────────────────────────────
  # Args: the run indices of the successful passes (e.g. 1 3 4). Reads the
  # matching run_<idx>.hits files from $STAB_DIR. Uses gawk ARGIND (1..n in
  # argument order) to track which file each record belongs to.
  # Outputs: one "file:linenum" per unstable line, then "STABILITY_STATS S T".
  _stab_analyze() {
    local indices=("$@")
    local n="${#indices[@]}"
    local hit_files=()
    local i
    for i in "${indices[@]}"; do
      hit_files+=("$STAB_DIR/run_${i}.hits")
    done
    "$GAWK" -F '\t' -v n="$n" '
      { counts[$1][ARGIND] = $2 }
      END {
        total = 0; stable_count = 0
        for (key in counts) {
          any = 0
          for (i = 1; i <= n; i++) {
            val = (i in counts[key]) ? counts[key][i] + 0 : 0
            if (val > 0) any = 1
          }
          if (!any) continue
          total++
          ref = (1 in counts[key]) ? counts[key][1] + 0 : 0
          stable = 1
          for (i = 2; i <= n; i++) {
            val = (i in counts[key]) ? counts[key][i] + 0 : 0
            if (val != ref) { stable = 0; break }
          }
          if (stable) { stable_count++ }
          else { print key }
        }
        printf "STABILITY_STATS %d %d\n", stable_count, total
      }
    ' "${hit_files[@]}"
  }

  log "Analyzing stability across ${#stab_passes[@]} runs..."
  local analysis_out
  analysis_out="$(_stab_analyze "${stab_passes[@]}")"

  local stable_count total_lines unstable_lines
  stable_count=$(printf '%s\n' "$analysis_out" | awk '/^STABILITY_STATS/{print $2}')
  total_lines=$(printf '%s\n'  "$analysis_out" | awk '/^STABILITY_STATS/{print $3}')
  # grep -v exits 1 (and, under pipefail, aborts) when every line is the
  # STABILITY_STATS record — i.e. when coverage is perfectly stable. Tolerate it.
  unstable_lines="$(printf '%s\n' "$analysis_out" | grep -v '^STABILITY_STATS' | sort -V || true)"
  : "${stable_count:=0}" "${total_lines:=0}"

  # ── extend to double the runs if instability found (default -n only) ───────
  if test -n "$unstable_lines" && test -z "$USER_RUNS"; then
    local extra_end=$((RUNS * 2))
    log "Instability detected, extending from $RUNS to $extra_end runs..."
    for run_idx in $(seq $((RUNS + 1)) "$extra_end"); do
      if _stab_collect_pass "$run_idx"; then
        stab_passes+=("$run_idx")
      fi
    done
    RUNS="$extra_end"

    log "Re-analyzing stability across ${#stab_passes[@]} runs..."
    analysis_out="$(_stab_analyze "${stab_passes[@]}")"
    stable_count=$(printf '%s\n' "$analysis_out" | awk '/^STABILITY_STATS/{print $2}')
    total_lines=$(printf '%s\n'  "$analysis_out" | awk '/^STABILITY_STATS/{print $3}')
    unstable_lines="$(printf '%s\n' "$analysis_out" | grep -v '^STABILITY_STATS' | sort -V || true)"
    : "${stable_count:=0}" "${total_lines:=0}"
  fi

  # ── collapse consecutive unstable lines into ranges ────────────────────────
  local ranges=""
  if test -n "$unstable_lines"; then
    ranges="$(printf '%s\n' "$unstable_lines" | awk '
      BEGIN { prev_file = ""; prev_line = -999; range_start = -999 }
      {
        n = split($0, parts, ":")
        linenum = parts[n] + 0
        file = $0; sub(":" parts[n] "$", "", file)

        if (file != prev_file || linenum != prev_line + 1) {
          if (prev_file != "") {
            if (range_start == prev_line) printf "  %s:%d\n", prev_file, range_start
            else printf "  %s:%d-%d\n", prev_file, range_start, prev_line
          }
          prev_file = file
          range_start = linenum
        }
        prev_line = linenum
      }
      END {
        if (prev_file != "") {
          if (range_start == prev_line) printf "  %s:%d\n", prev_file, range_start
          else printf "  %s:%d-%d\n", prev_file, range_start, prev_line
        }
      }
    ')"
  fi

  # ── print report ───────────────────────────────────────────────────────────
  local unstable_count=$((total_lines - stable_count))
  local pct
  if test "$total_lines" -gt 0; then
    pct="$(awk -v s="$stable_count" -v t="$total_lines" \
      'BEGIN { printf "%.1f%%", 100.0 * s / t }')"
  else
    pct="n/a"
  fi

  if test "$QUIET" -eq 0; then
    echo ""
    echo "Stability Report"
    echo "--------------------------------------------------------"
    printf "Corpus size : %s inputs\n" "$CORPUS_SIZE"
    if test "${#stab_passes[@]}" -eq "$RUNS"; then
      printf "Runs        : %s\n" "$RUNS"
    else
      printf "Runs        : %d analyzed (%d of %d failed and were skipped)\n" \
        "${#stab_passes[@]}" "$((RUNS - ${#stab_passes[@]}))" "$RUNS"
    fi
    printf "Stability   : %s (%d/%d executed lines stable)\n" \
      "$pct" "$stable_count" "$total_lines"

    if test -n "$unstable_lines"; then
      echo ""
      printf "~~ Variable-count lines (%d lines):\n" "$unstable_count"
      echo "   Lines with varying hit counts:"
      echo ""
      printf '%s\n' "$ranges"
      echo ""
      echo "[!] Unstable coverage detected."
    else
      echo ""
      echo "[+] All executed lines are perfectly stable."
    fi
  fi
}

# ── command: search ────────────────────────────────────────────────────────

# Replay ALL selected inputs into one profraw dir for the union pre-check.
# Every queue/corpus/crash/timeout input gets the same -T hard deadline.
# Reads globals: COVERAGE_CMD, FORKS, TIMEOUT, INCLUDE_CRASHES.
_search_replay_union() {
  local outdir="$1"
  export LLVM_PROFILE_FILE="$outdir/cov-%p.profraw"
  export -f run_with_timeout command_for_input run_input_command
  find_queue_files | xargs -0 -r -n 1 -P "$FORKS" \
    bash -c 'run_input_command "$2" "$1" "$3"' _ "$COVERAGE_CMD" "$TIMEOUT" \
    >/dev/null 2>&1 || true

  if test "$INCLUDE_CRASHES" -eq 1; then
    find_crash_timeout_files | xargs -0 -r -n 1 -P "$FORKS" \
      bash -c 'run_input_command "$2" "$1" "$3"' _ "$COVERAGE_CMD" "$TIMEOUT" \
      >/dev/null 2>&1 || true
  fi
}

# Per-input worker for the parallel scan. Replays ONE input in isolation, merges
# its profile, and prints the input path iff the target line is `covered`.
# The input path is $1; all other config is read from exported COV_SEARCH_* env
# vars (cmd_search exports them before the xargs fan-out). This function and
# lcov_line_state are `export -f`'d so the xargs `bash -c` subshells can call them.
# Always best-effort: never aborts on a single bad input.
_search_one_input() {
  local in="$1" work state="absent"
  work="$(mktemp -d "${COV_SEARCH_WORKROOT}/w.XXXXXX")" || return 0
  export LLVM_PROFILE_FILE="$work/cov-%p.profraw"

  run_input_command "$COV_SEARCH_TIMEOUT" "$COV_SEARCH_CMD" "$in" >/dev/null 2>&1 || true

  if compgen -G "$work/cov-"'*.profraw' >/dev/null 2>&1; then
    if merge_profiles "$COV_SEARCH_PROFDATA" "$work" "$work/m.profdata" 2>/dev/null; then
      state="$("$COV_SEARCH_COV" export "$COV_SEARCH_BIN" --format=lcov \
                 "-instr-profile=$work/m.profdata" 2>/dev/null \
               | lcov_line_state "$COV_SEARCH_FILE" "$COV_SEARCH_LINE")"
    fi
  fi

  rm -rf "$work"
  test "$state" = "covered" && printf '%s\n' "$in"
  return 0
}

cmd_search() {
  local AFL_DIR=""
  local COVERAGE_CMD=""
  local TARGET_SPEC=""
  local FORKS=1
  local TIMEOUT=5
  local INCLUDE_CRASHES=0
  local LLVM_PROFDATA=""
  local LLVM_COV=""
  local COV_BINARY=""
  local BINARY_OVERRIDE=""
  local FUZZER_LAYOUT="${FUZZER_LAYOUT:-}"
  local TARGET_FILE="" TARGET_LINE=""

  if test $# -eq 0; then usage_search; exit 1; fi

  while [ $# -gt 0 ]; do
    case "$1" in
      -d)        need_arg "-d" "${2-}"; AFL_DIR="$2";       shift 2 ;;
      -e)        need_arg "-e" "${2-}"; COVERAGE_CMD="$2";  shift 2 ;;
      -t)        need_arg "-t" "${2-}"; FORKS="$2";         shift 2 ;;
      -T)        need_arg "-T" "${2-}"; TIMEOUT="$2";       shift 2 ;;
      --binary)  need_arg "--binary" "${2-}"; BINARY_OVERRIDE="$2"; shift 2 ;;
      --crashes) INCLUDE_CRASHES=1;                         shift   ;;
      -v)        VERBOSE=1;                                 shift   ;;
      -q)        QUIET=1;                                   shift   ;;
      -V)        echo "cov-analysis-$VERSION"; exit 0 ;;
      -h|--help) usage_search; exit 0 ;;
      --layout)  need_arg "--layout" "${2-}"; FUZZER_LAYOUT="$2"; shift 2 ;;
      -*)        err "Unknown option: $1"; echo "" >&2; usage_search >&2; exit 1 ;;
      *)
        if test -n "$TARGET_SPEC"; then
          err "Unexpected extra argument: $1 (only one FILE:LINE target is allowed)"
          exit 1
        fi
        TARGET_SPEC="$1"; shift ;;
    esac
  done

  # ── validate inputs ──────────────────────────────────────────────────────
  if test -z "$TARGET_SPEC"; then
    err "Must specify a FILE:LINE target (e.g. src/foo.c:123)"; exit 1
  fi
  if test -z "$AFL_DIR"; then
    err "Must specify input directory with -d"; exit 1
  fi
  if test -z "$COVERAGE_CMD"; then
    err "Must specify coverage command with -e"; exit 1
  fi
  if ! test -d "$AFL_DIR"; then
    err "Input directory does not exist: $AFL_DIR"; exit 1
  fi
  case "$FORKS" in
    ''|*[!0-9]*|0) err "Replay worker count must be a positive integer: $FORKS"; exit 1 ;;
  esac
  case "$TIMEOUT" in
    ''|*[!0-9]*|0) err "Timeout (-T) must be a positive integer in seconds: $TIMEOUT"; exit 1 ;;
  esac
  if test -n "$FUZZER_LAYOUT"; then
    case "$FUZZER_LAYOUT" in
      afl|flat) ;;
      *) err "--layout must be 'afl' or 'flat' (got '$FUZZER_LAYOUT')"; exit 1 ;;
    esac
  fi

  # Parse and split the target spec.
  local parsed
  parsed="$(parse_target_spec "$TARGET_SPEC")" || exit 1
  TARGET_FILE="${parsed%$'\t'*}"
  TARGET_LINE="${parsed##*$'\t'}"

  require_replay_commands "cov-analysis search" || exit 1
  COV_BINARY="$(resolve_coverage_binary "$COVERAGE_CMD" "$BINARY_OVERRIDE")" || exit 1

  # Forgotten @@ on a driver binary → supply it automatically (argv-only input).
  COVERAGE_CMD="$(add_at_if_driver "$COVERAGE_CMD" "$COV_BINARY")"
  logv "Replay command  : $COVERAGE_CMD"

  # ── detect LLVM tools ────────────────────────────────────────────────────
  LLVM_PROFDATA="$(find_tool llvm-profdata)" || {
    err "llvm-profdata not found. Install LLVM (apt install llvm / dnf install llvm)."; exit 1
  }
  LLVM_COV="$(find_tool llvm-cov)" || {
    err "llvm-cov not found. Install LLVM (apt install llvm / dnf install llvm)."; exit 1
  }
  loge "LLVM tools      : $LLVM_PROFDATA, $LLVM_COV"

  # ── detect or honor fuzzer layout ────────────────────────────────────────
  if test -z "$FUZZER_LAYOUT"; then
    FUZZER_LAYOUT="$(detect_fuzzer_layout)"
  fi
  case "$FUZZER_LAYOUT" in
    afl)  loge "Fuzzer layout   : AFL++ (queue/crashes/timeouts)" ;;
    flat) loge "Fuzzer layout   : flat (libFuzzer/libafl/honggfuzz)" ;;
    empty)
      err "No input files found in $AFL_DIR"
      err "Expected one of: AFL++ out dir, libFuzzer/libafl corpus, honggfuzz workspace."
      err "Override detection with --layout afl|flat if needed."
      exit 1 ;;
  esac

  # ── count selected inputs ────────────────────────────────────────────────
  local QUEUE_COUNT CRASH_COUNT=0 TOTAL
  QUEUE_COUNT=$(count_files find_queue_files)
  if test "$INCLUDE_CRASHES" -eq 1; then
    CRASH_COUNT=$(count_files find_crash_timeout_files)
  fi
  TOTAL=$((QUEUE_COUNT + CRASH_COUNT))
  if test "$TOTAL" -eq 0; then
    if test "$INCLUDE_CRASHES" -eq 1; then
      err "No selected regular input files found in $AFL_DIR"
    else
      err "No corpus files found in $AFL_DIR"
    fi
    exit 1
  fi

  loge "Target          : $TARGET_FILE:$TARGET_LINE"
  loge "Inputs to scan  : $TOTAL (queue=$QUEUE_COUNT, crashes/timeouts=$CRASH_COUNT)"

  # ── workspace ──────────────────────────────────────────────────────────────
  local SEARCH_DIR
  SEARCH_DIR="$(mktemp -d "$(tmp_base_dir)/cov-analysis-search.XXXXXX")"
  trap "rm -rf '$SEARCH_DIR'" EXIT INT TERM

  # ── union pre-check ────────────────────────────────────────────────────────
  loge "Union pre-check : replaying $TOTAL input(s) to test reachability..."
  local UNION_DIR="$SEARCH_DIR/union"
  mkdir -p "$UNION_DIR"
  _search_replay_union "$UNION_DIR"

  local praw_count
  praw_count=$(find "$UNION_DIR" -name 'cov-*.profraw' -printf . 2>/dev/null | wc -c)
  if test "$praw_count" -eq 0; then
    err "No .profraw files generated."
    err "Check that the binary is instrumented with -fprofile-instr-generate."
    err "Use 'cov-analysis build' to build the coverage binary."
    exit 1
  fi

  local union_state="absent"
  if merge_profiles "$LLVM_PROFDATA" "$UNION_DIR" "$UNION_DIR/m.profdata" 2>/dev/null; then
    union_state="$("$LLVM_COV" export "$COV_BINARY" --format=lcov \
                     "-instr-profile=$UNION_DIR/m.profdata" 2>/dev/null \
                   | lcov_line_state "$TARGET_FILE" "$TARGET_LINE")"
  fi

  if test "$union_state" != "covered"; then
    if test "$union_state" = "uncovered"; then
      logerr "$TARGET_FILE:$TARGET_LINE is executable but no selected input reaches it."
      test "$INCLUDE_CRASHES" -eq 0 && \
        logerr "(retry with --crashes to also scan crash/timeout inputs)"
    else
      logerr "$TARGET_FILE:$TARGET_LINE is not in the coverage data."
      logerr "The path or line number may be wrong, or the line is non-executable."
    fi
    logerr "0 of $TOTAL inputs reach $TARGET_FILE:$TARGET_LINE"
    exit 0
  fi

  # ── per-input scan ───────────────────────────────────────────────────────
  loge "Reachable in union; scanning $TOTAL input(s) individually (workers=$FORKS)..."
  export COV_SEARCH_CMD="$COVERAGE_CMD" \
         COV_SEARCH_BIN="$COV_BINARY" \
         COV_SEARCH_PROFDATA="$LLVM_PROFDATA" \
         COV_SEARCH_COV="$LLVM_COV" \
         COV_SEARCH_TIMEOUT="$TIMEOUT" \
         COV_SEARCH_FILE="$TARGET_FILE" \
         COV_SEARCH_LINE="$TARGET_LINE" \
         COV_SEARCH_WORKROOT="$SEARCH_DIR"
  export -f _search_one_input lcov_line_state run_with_timeout command_for_input \
    run_input_command write_profile_manifest merge_profiles

  {
    find_queue_files
    test "$INCLUDE_CRASHES" -eq 1 && find_crash_timeout_files
  } | xargs -0 -r -n 1 -P "$FORKS" \
        bash -c '_search_one_input "$1"' _ \
    | sort > "$SEARCH_DIR/matches.txt" || true

  local match_count
  # grep -c prints 0 and exits 1 on an empty file; `|| true` keeps that single
  # "0" without appending a second one (which `|| echo 0` would do).
  match_count=$(grep -c . "$SEARCH_DIR/matches.txt" 2>/dev/null || true)
  : "${match_count:=0}"

  cat "$SEARCH_DIR/matches.txt"
  loge "$match_count of $TOTAL inputs reach $TARGET_FILE:$TARGET_LINE"
  exit 0
}

reach_py_lib() {
  cat <<'REACHPY'
import os
import re
import sys
from typing import Dict, Set, Tuple

_RUST_DISAMBIG = re.compile(r'17h[0-9a-f]{16}E$')


def _reach_key(entry):
    if entry.endswith('*'):
        return entry[:-1]
    if len(entry) > 20:
        return _RUST_DISAMBIG.sub('', entry)
    return entry


def _reach_int(value):
    try:
        n = int(value)
    except (TypeError, ValueError):
        return None
    return n if n > 0 else None


def parse_reachability(path):
    # path: a .json report, a directory (preferring a reachability.json inside
    # it, falling back to reached.txt/not_reached.txt), or a single
    # allow/ignore .txt list. Returns (reachable, unreachable, conf,
    # reach_keys, unreach_keys, loc, loc_base, reachable_qualified,
    # unreachable_qualified) sets of function names; loc maps (full path, line)
    # -> 'reachable'/'unreachable' for json-mode fallback, loc_base is the same
    # keyed on basename(path) as a secondary fallback. reachable_qualified /
    # unreachable_qualified map (basename(file), mangled) -> present. conf maps
    # name -> the analyzer's 'high'/'medium'/'low' reachability confidence
    # (JSON mode only; empty for txt/dir-of-txt-lists mode).
    reachable: Set[str] = set()
    unreachable: Set[str] = set()
    conf: Dict[str, str] = {}
    reach_keys: Set[str] = set()
    unreach_keys: Set[str] = set()
    loc: Dict[Tuple[str, int], str] = {}
    loc_base: Dict[Tuple[str, int], str] = {}
    reachable_qualified: Set[Tuple[str, str]] = set()
    unreachable_qualified: Set[Tuple[str, str]] = set()
    if not path:
        return (reachable, unreachable, conf, reach_keys, unreach_keys,
                loc, loc_base, reachable_qualified, unreachable_qualified)

    def parse_txt(p):
        names: Set[str] = set()
        keys: Set[str] = set()
        kind = None
        with open(p, 'r', encoding='utf-8', errors='replace') as fh:
            for line in fh:
                s = line.strip()
                if not s:
                    continue
                if s.startswith('#'):
                    low = s.lower()
                    if 'allowlist' in low:
                        kind = 'allow'
                    elif 'ignorelist' in low:
                        kind = 'ignore'
                    continue
                if s.startswith('fun:'):
                    names.add(s[4:].strip())
                    keys.add(_reach_key(s[4:].strip()))
        return kind, names, keys

    if not os.path.exists(path):
        raise ValueError('reachability path does not exist: %s' % path)
    if os.path.isdir(path):
        jpath = os.path.join(path, 'reachability.json')
        if os.path.isfile(jpath):
            print('using reachability.json (richer than the txt lists)', file=sys.stderr)
            return parse_reachability(jpath)
        rp = os.path.join(path, 'reached.txt')
        npath = os.path.join(path, 'not_reached.txt')
        if not os.path.isfile(rp) and not os.path.isfile(npath):
            raise ValueError('reachability directory has no reachability.json, reached.txt, or not_reached.txt')
        if os.path.isfile(rp):
            _, reachable, reach_keys = parse_txt(rp)
        if os.path.isfile(npath):
            _, unreachable, unreach_keys = parse_txt(npath)
    elif path.lower().endswith('.json'):
        import json
        with open(path, 'r', encoding='utf-8') as f:
            data = json.load(f)
        if not isinstance(data, dict):
            raise ValueError('reachability JSON root must be an object')
        if not isinstance(data.get('reachable', []), list):
            raise ValueError('reachability JSON field "reachable" must be an array')
        if not isinstance(data.get('unreachable_defined', []), list):
            raise ValueError('reachability JSON field "unreachable_defined" must be an array')
        for fn in data.get('reachable', []) or []:
            if not isinstance(fn, dict):
                raise ValueError('reachability JSON function entries must be objects')
            mangled = fn.get('mangled')
            demangled = fn.get('demangled')
            names = [n for n in (mangled, demangled) if n]
            if not names:
                continue
            for name in names:
                reachable.add(name)
                reach_keys.add(fn.get('key') or _reach_key(name))
            c = fn.get('confidence')
            if c:
                for name in names:
                    conf[name] = c
            _ln = _reach_int(fn.get('line'))
            if fn.get('file') and _ln is not None:
                loc[(fn['file'], _ln)] = 'reachable'
                loc_base[(os.path.basename(fn['file']), _ln)] = 'reachable'
            if fn.get('file') and mangled:
                reachable_qualified.add((os.path.basename(fn['file']), mangled))
        for fn in data.get('unreachable_defined', []) or []:
            if not isinstance(fn, dict):
                raise ValueError('reachability JSON function entries must be objects')
            mangled = fn.get('mangled')
            demangled = fn.get('demangled')
            names = [n for n in (mangled, demangled) if n]
            if not names:
                continue
            for name in names:
                unreachable.add(name)
                unreach_keys.add(fn.get('key') or _reach_key(name))
            _ln = _reach_int(fn.get('line'))
            if fn.get('file') and _ln is not None:
                loc[(fn['file'], _ln)] = 'unreachable'
                loc_base[(os.path.basename(fn['file']), _ln)] = 'unreachable'
            if fn.get('file') and mangled:
                unreachable_qualified.add((os.path.basename(fn['file']), mangled))
    elif path.lower().endswith('.txt') and os.path.isfile(path):
        kind, names, keys = parse_txt(path)
        if kind == 'ignore':
            unreachable = names
            unreach_keys = keys
        else:
            reachable = names
            reach_keys = keys
    else:
        raise ValueError('reachability input must be a JSON report, report directory, or .txt list')
    return (reachable, unreachable, conf, reach_keys, unreach_keys,
            loc, loc_base, reachable_qualified, unreachable_qualified)


def reach_state(name: str, reachable: Set[str], unreachable: Set[str],
                 reach_keys: Set[str], unreach_keys: Set[str],
                 reachable_qualified: Set[Tuple[str, str]] = frozenset(),
                 unreachable_qualified: Set[Tuple[str, str]] = frozenset()):
    # llvm-cov records local-linkage functions as "<src>:<symbol>".
    n = name.rsplit(':', 1)[-1]
    if ':' in name:
        qk = (os.path.basename(name.rsplit(':', 1)[0]), n)
        if qk in reachable_qualified:
            return 'reachable'
        if qk in unreachable_qualified:
            return 'unreachable'
    if name in reachable or n in reachable:
        return 'reachable'
    if name in unreachable or n in unreachable:
        return 'unreachable'
    k = _reach_key(n)
    if k in reach_keys:
        return 'reachable'
    if k in unreach_keys:
        return 'unreachable'
    return None
REACHPY
}

validate_reachability() {
  local path="$1"
  { reach_py_lib; printf '%s\n' \
      'parse_reachability(sys.argv[1])' \
      'print("valid")'; } \
    | python3 - "$path" >/dev/null
}

write_source_response() {
  local covjson="$1" response="$2"
  python3 - "$covjson" "$response" <<'PYEOF'
import json
import sys

data = json.load(open(sys.argv[1], encoding='utf-8'))
paths = sorted({f['filename'] for obj in data.get('data', [])
                for f in obj.get('files', []) if f.get('filename')})
if not paths:
    raise SystemExit('coverage JSON contains no source files')
with open(sys.argv[2], 'w', encoding='utf-8') as out:
    for path in paths:
        if '\n' in path or '\r' in path:
            raise SystemExit('source paths containing newlines are not supported')
        out.write('"' + path.replace('\\', '\\\\').replace('"', '\\"') + '"\n')
PYEOF
}

# ── command: diff ────────────────────────────────────────────────────────────

cmd_diff() {
  local REPORT_DIR="."
  # With no arguments and no default reports to diff in the current directory,
  # there is nothing to do — print help instead of erroring (as if -h).
  if test $# -eq 0 \
     && ! test -s "./coverage.json" \
     && ! test -s "./coverage_old.json"; then
    usage_diff
    exit 0
  fi
  local REACHABILITY=""
  local ONLY_CHANGED=0
  local -a POS=()
  while [ $# -gt 0 ]; do
    case "$1" in
      -o)             need_arg "-o" "${2-}"; REPORT_DIR="$2"; shift 2 ;;
      --reachability) need_arg "--reachability" "${2-}"; REACHABILITY="$2"; shift 2 ;;
      --only-changed) ONLY_CHANGED=1; shift ;;
      -h|--help)      usage_diff; exit 0 ;;
      -V)             echo "cov-analysis-$VERSION"; exit 0 ;;
      --)             shift; while [ $# -gt 0 ]; do POS+=("$1"); shift; done ;;
      -*)             err "Unknown option: $1"; echo "" >&2; usage_diff >&2; exit 1 ;;
      *)              POS+=("$1"); shift ;;
    esac
  done
  local OLD="${POS[0]-}"
  local NEW="${POS[1]-}"
  test -z "$OLD" && OLD="$REPORT_DIR/coverage_old.json"
  test -z "$NEW" && NEW="$REPORT_DIR/coverage.json"
  test -s "$OLD" || { err "The old JSON report does not exist: $OLD"; exit 1; }
  test -s "$NEW" || { err "The new JSON report does not exist: $NEW"; exit 1; }
  if test -n "$REACHABILITY" && ! test -e "$REACHABILITY"; then
    err "--reachability path does not exist: $REACHABILITY"; exit 1
  fi
  require_command python3 "cov-analysis diff" || exit 1
  if test -n "$REACHABILITY" && ! validate_reachability "$REACHABILITY"; then
    err "Invalid reachability input: $REACHABILITY"
    exit 1
  fi
  local -a PYARGS=(-o "$REPORT_DIR/coverage_diff.html")
  test "$ONLY_CHANGED" -eq 1 && PYARGS+=(--only-changed)
  test -n "$REACHABILITY" && PYARGS+=(--reachability "$REACHABILITY")
  PYARGS+=("$OLD" "$NEW")
  { reach_py_lib; cat << 'PYEOF'
#
# (c) 2026 Marc "vanHauser" Heuse
#
# License: GNU Affero General Public License 3
#

import argparse
import html
import json
import os
import re
from pathlib import Path
from typing import Dict, List, Tuple, Set


def load_json(path: str):
    with open(path, 'r', encoding='utf-8') as f:
        return json.load(f)


def merge_line_state(current: int, new: int) -> int:
    # 0 = unknown/non-executable, 1 = uncovered, 2 = covered
    if current == 2 or new == 2:
        return 2
    if current == 1 or new == 1:
        return 1
    return 0


def extract_file_entries(root) -> Dict[str, List[dict]]:
    entries: Dict[str, List[dict]] = {}
    for obj in root.get('data', []):
        for f in obj.get('files', []):
            name = f.get('filename')
            if not name:
                continue
            entries.setdefault(name, []).append(f)
    return entries


def line_states_from_segments(file_entry: dict, source_line_count: int = 0) -> Dict[int, int]:
    # Reconstruct per-line executable/covered state from half-open file segments.
    # Segment format is documented by llvm-cov export JSON tests as a list of
    # [line, col, count, hasCount, isRegionEntry]. Newer LLVM may append fields,
    # so we only use the first five when present.
    segments = file_entry.get('segments', []) or []
    states: Dict[int, int] = {}

    segment_max = max((int(seg[0]) for seg in segments if len(seg) >= 1), default=0)
    max_line = source_line_count or segment_max

    for idx, cur in enumerate(segments):
        if len(cur) < 4:
            continue

        if idx + 1 < len(segments):
            nxt = segments[idx + 1]
            if len(nxt) < 2:
                continue
            end_line = int(nxt[0])
            end_col = int(nxt[1])
        else:
            if max_line <= 0:
                continue
            end_line = max_line + 1
            end_col = 1

        start_line = int(cur[0])
        start_col = int(cur[1])
        count = int(cur[2])
        has_count = bool(cur[3])

        if not has_count or start_line <= 0 or max_line <= 0:
            continue
        if (end_line, end_col) <= (start_line, start_col):
            continue

        new_state = 2 if count > 0 else 1
        last_line = end_line if end_col > 1 else end_line - 1
        last_line = min(last_line, max_line)
        for line in range(start_line, last_line + 1):
            states[line] = merge_line_state(states.get(line, 0), new_state)

    return states


def merge_line_states(file_entries: List[dict], source_line_count: int = 0) -> Dict[int, int]:
    merged: Dict[int, int] = {}
    for entry in file_entries:
        for line, state in line_states_from_segments(entry, source_line_count).items():
            merged[line] = merge_line_state(merged.get(line, 0), state)
    return merged


def extract_functions(root) -> Dict[str, Dict[str, bool]]:
    # filename -> function name -> covered?
    out: Dict[str, Dict[str, bool]] = {}
    for obj in root.get('data', []):
        for fn in obj.get('functions', []) or []:
            name = fn.get('name', '<unknown>')
            covered = int(fn.get('count', 0) or 0) > 0
            filenames = fn.get('filenames', []) or []
            if not filenames:
                continue
            for filename in filenames:
                file_map = out.setdefault(filename, {})
                file_map[name] = file_map.get(name, False) or covered
    return out


def read_source_lines(filename: str) -> List[str]:
    try:
        with open(filename, 'r', encoding='utf-8', errors='replace') as f:
            return f.read().splitlines()
    except OSError:
        return []


def fmt_pct(numer: int, denom: int) -> str:
    if denom <= 0:
        return 'n/a'
    return f'{(100.0 * numer / denom):.1f}%'


def compute_file_diff(filename: str, base_lines: Dict[int, int], upd_lines: Dict[int, int],
                      base_funcs: Dict[str, bool], upd_funcs: Dict[str, bool]) -> dict:
    all_lines = sorted(set(base_lines) | set(upd_lines))
    newly_covered = [ln for ln in all_lines if base_lines.get(ln, 0) != 2 and upd_lines.get(ln, 0) == 2]
    no_longer_covered = [ln for ln in all_lines if base_lines.get(ln, 0) == 2 and upd_lines.get(ln, 0) != 2]
    still_uncovered = [ln for ln in all_lines if base_lines.get(ln, 0) == 1 and upd_lines.get(ln, 0) == 1]
    executable_now = [ln for ln in all_lines if upd_lines.get(ln, 0) in (1, 2)]
    covered_now = [ln for ln in all_lines if upd_lines.get(ln, 0) == 2]
    executable_before = [ln for ln in all_lines if base_lines.get(ln, 0) in (1, 2)]
    covered_before = [ln for ln in all_lines if base_lines.get(ln, 0) == 2]

    all_fn_names = sorted(set(base_funcs) | set(upd_funcs))
    newly_covered_fns = [n for n in all_fn_names if not base_funcs.get(n, False) and upd_funcs.get(n, False)]
    lost_fns = [n for n in all_fn_names if base_funcs.get(n, False) and not upd_funcs.get(n, False)]
    still_uncovered_fns = [n for n in all_fn_names if not base_funcs.get(n, False) and not upd_funcs.get(n, False)]
    covered_fns_before = sum(1 for n in all_fn_names if base_funcs.get(n, False))
    covered_fns_now = sum(1 for n in all_fn_names if upd_funcs.get(n, False))

    return {
        'filename': filename,
        'base_executable': len(executable_before),
        'base_covered': len(covered_before),
        'upd_executable': len(executable_now),
        'upd_covered': len(covered_now),
        'newly_covered': newly_covered,
        'no_longer_covered': no_longer_covered,
        'still_uncovered': still_uncovered,
        'all_lines': all_lines,
        'newly_covered_fns': newly_covered_fns,
        'lost_fns': lost_fns,
        'still_uncovered_fns': still_uncovered_fns,
        'fn_total': len(all_fn_names),
        'fn_covered_before': covered_fns_before,
        'fn_covered_now': covered_fns_now,
    }


def make_snippet_ranges(lines_of_interest: List[int], max_context: int = 2) -> List[Tuple[int, int]]:
    if not lines_of_interest:
        return []
    ranges: List[Tuple[int, int]] = []
    for line in sorted(set(lines_of_interest)):
        start = max(1, line - max_context)
        end = line + max_context
        if not ranges or start > ranges[-1][1] + 1:
            ranges.append((start, end))
        else:
            ranges[-1] = (ranges[-1][0], max(ranges[-1][1], end))
    return ranges


def render_code_snippets(filename: str, diff: dict, source_lines: List[str]) -> str:
    interesting = diff['newly_covered'] + diff['no_longer_covered'] + diff['still_uncovered']
    ranges = make_snippet_ranges(interesting)
    if not source_lines:
        if not interesting:
            return '<p class="muted">No line-level changes.</p>'
        lis = ''.join(f'<li>{ln}</li>' for ln in interesting)
        return f'<p class="muted">Source file not readable on disk. Interesting lines:</p><ul>{lis}</ul>'
    if not ranges:
        return '<p class="muted">No line-level changes.</p>'

    parts = []
    for start, end in ranges:
        parts.append('<div class="snippet">')
        parts.append(f'<div class="snippet-header">{html.escape(filename)}:{start}-{min(end, len(source_lines))}</div>')
        parts.append('<table class="code">')
        for ln in range(start, min(end, len(source_lines)) + 1):
            src = html.escape(source_lines[ln - 1])
            cls = []
            label = ''
            if ln in diff['newly_covered']:
                cls.append('new-covered')
                label = 'new'
            elif ln in diff['no_longer_covered']:
                cls.append('lost-covered')
                label = 'lost'
            elif ln in diff['still_uncovered']:
                cls.append('still-uncovered')
                label = 'still uncovered'
            state = ' '.join(cls)
            parts.append(
                f'<tr class="{state}"><td class="ln">{ln}</td><td class="tag">{label}</td>'
                f'<td class="src"><pre>{src}</pre></td></tr>'
            )
        parts.append('</table></div>')
    return ''.join(parts)


def render_html(diffs: List[dict], baseline_name: str, updated_name: str, output_path: str,
                reachable: Set[str] = frozenset(), unreachable: Set[str] = frozenset(),
                reach_keys: Set[str] = frozenset(),
                unreach_keys: Set[str] = frozenset(),
                reachable_qualified: Set[Tuple[str, str]] = frozenset(),
                unreachable_qualified: Set[Tuple[str, str]] = frozenset(),
                only_changed: bool = False):
    has_reach = bool(reachable or unreachable)
    total_new = sum(len(d['newly_covered']) for d in diffs)
    total_lost = sum(len(d['no_longer_covered']) for d in diffs)
    total_still = sum(len(d['still_uncovered']) for d in diffs)
    total_new_fns = sum(len(d['newly_covered_fns']) for d in diffs)
    total_lost_fns = sum(len(d['lost_fns']) for d in diffs)

    changed_files = [d for d in diffs if d['newly_covered'] or d['no_longer_covered'] or d['newly_covered_fns'] or d['lost_fns']]
    changed_files_count = len(changed_files)
    scope = 'changed files only' if only_changed else 'all files'

    rows = []
    for d in diffs:
        row_class = 'regressed' if d['no_longer_covered'] or d['lost_fns'] else ('improved' if d['newly_covered'] or d['newly_covered_fns'] else '')
        rows.append(
            '<tr class="%s">'
            '<td><a href="#file-%s">%s</a></td>'
            '<td>%s</td>'
            '<td>%s</td>'
            '<td>%d</td>'
            '<td>%d</td>'
            '<td>%d</td>'
            '<td>%d</td>'
            '<td>%d</td>'
            '</tr>' % (
                row_class,
                html.escape(d['filename'], quote=True).replace('/', '_').replace(' ', '_'),
                html.escape(d['filename']),
                fmt_pct(d['base_covered'], d['base_executable']),
                fmt_pct(d['upd_covered'], d['upd_executable']),
                len(d['newly_covered']),
                len(d['no_longer_covered']),
                len(d['still_uncovered']),
                len(d['newly_covered_fns']),
                len(d['lost_fns']),
            )
        )

    file_sections = []
    for d in diffs:
        file_id = html.escape(d['filename'], quote=True).replace('/', '_').replace(' ', '_')
        src_lines = read_source_lines(d['filename'])
        code = render_code_snippets(d['filename'], d, src_lines)

        def render_fn_list(title: str, items: List[str], cls: str) -> str:
            if not items:
                return ''
            chips = ''.join(f'<span class="chip {cls}">{html.escape(x)}</span>' for x in items[:200])
            return f'<div class="fn-group"><div class="fn-title">{html.escape(title)}</div><div class="chips">{chips}</div></div>'

        # Split "still uncovered" functions by reachability: reachable ones are
        # actionable gaps (amber); statically-unreachable ones are expected dead
        # weight (grey). Without reachability data, keep the single amber list.
        still = d['still_uncovered_fns']
        if has_reach:
            reach_uncov = [n for n in still if reach_state(n, reachable, unreachable, reach_keys, unreach_keys, reachable_qualified, unreachable_qualified) == 'reachable']
            dead_uncov = [n for n in still if reach_state(n, reachable, unreachable, reach_keys, unreach_keys, reachable_qualified, unreachable_qualified) == 'unreachable']
            other_uncov = [n for n in still if reach_state(n, reachable, unreachable, reach_keys, unreach_keys, reachable_qualified, unreachable_qualified) is None]
            still_html = (
                render_fn_list('Still uncovered — reachable (actionable)', reach_uncov, 'chip-amber')
                + render_fn_list('Still uncovered — unreachable (expected dead)', dead_uncov, 'chip-grey')
                + render_fn_list('Still uncovered — not in reachability set', other_uncov, 'chip-amber')
            )
        else:
            still_html = render_fn_list('Still uncovered functions', still, 'chip-amber')

        file_sections.append(f'''
<section id="file-{file_id}" class="file-card">
  <div class="file-head">
    <div>
      <h2>{html.escape(d['filename'])}</h2>
      <div class="muted">
        lines: {fmt_pct(d['base_covered'], d['base_executable'])} → {fmt_pct(d['upd_covered'], d['upd_executable'])}
        &nbsp;&nbsp; functions: {fmt_pct(d['fn_covered_before'], d['fn_total'])} → {fmt_pct(d['fn_covered_now'], d['fn_total'])}
      </div>
    </div>
    <div class="pill-row">
      <span class="pill green">+{len(d['newly_covered'])} new lines</span>
      <span class="pill red">-{len(d['no_longer_covered'])} lost lines</span>
      <span class="pill amber">{len(d['still_uncovered'])} still uncovered</span>
    </div>
  </div>
  {render_fn_list('Newly covered functions', d['newly_covered_fns'], 'chip-green')}
  {render_fn_list('No longer covered functions', d['lost_fns'], 'chip-red')}
  {still_html}
  {code}
</section>
''')

    html_doc = f'''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LLVM coverage diff</title>
<style>
:root {{
  --bg: #0b1020;
  --panel: #121933;
  --panel-2: #0f1530;
  --text: #e8ecf8;
  --muted: #9ea8c7;
  --border: #27304f;
  --green: #163a26;
  --green-2: #1e7a45;
  --red: #3d1720;
  --red-2: #b0465d;
  --amber: #413315;
  --amber-2: #ba8f2a;
  --blue: #4d8cff;
}}
* {{ box-sizing: border-box; }}
body {{ margin: 0; font-family: Inter, ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: linear-gradient(180deg, #0b1020, #09101b 30%, #081018); color: var(--text); }}
.container {{ max-width: 1400px; margin: 0 auto; padding: 28px; }}
header {{ display: flex; justify-content: space-between; gap: 16px; align-items: end; margin-bottom: 22px; }}
h1 {{ margin: 0; font-size: 32px; }}
.sub {{ color: var(--muted); margin-top: 8px; }}
.cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin: 20px 0 26px; }}
.card {{ background: rgba(18, 25, 51, 0.88); border: 1px solid var(--border); border-radius: 18px; padding: 18px; box-shadow: 0 10px 30px rgba(0,0,0,.25); }}
.card .k {{ color: var(--muted); font-size: 13px; text-transform: uppercase; letter-spacing: .08em; }}
.card .v {{ font-size: 28px; margin-top: 8px; font-weight: 700; }}
.table-wrap, .file-card {{ background: rgba(18, 25, 51, 0.88); border: 1px solid var(--border); border-radius: 18px; box-shadow: 0 10px 30px rgba(0,0,0,.25); }}
.table-wrap {{ overflow: hidden; margin-bottom: 26px; }}
table.summary {{ width: 100%; border-collapse: collapse; }}
table.summary th, table.summary td {{ padding: 12px 14px; border-bottom: 1px solid rgba(255,255,255,.06); text-align: left; font-variant-numeric: tabular-nums; }}
table.summary th {{ color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; background: rgba(255,255,255,.02); }}
table.summary tr:hover td {{ background: rgba(255,255,255,.025); }}
table.summary tr.improved td:first-child {{ border-left: 4px solid var(--green-2); }}
table.summary tr.regressed td:first-child {{ border-left: 4px solid var(--red-2); }}
a {{ color: #a8c7ff; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
.file-card {{ padding: 18px; margin: 18px 0; }}
.file-head {{ display: flex; justify-content: space-between; gap: 16px; align-items: start; margin-bottom: 16px; }}
.file-head h2 {{ margin: 0 0 8px; font-size: 20px; word-break: break-all; }}
.muted {{ color: var(--muted); }}
.pill-row {{ display: flex; gap: 8px; flex-wrap: wrap; }}
.pill {{ padding: 6px 10px; border-radius: 999px; font-size: 12px; border: 1px solid transparent; }}
.pill.green {{ background: rgba(30,122,69,.16); border-color: rgba(30,122,69,.45); }}
.pill.red {{ background: rgba(176,70,93,.16); border-color: rgba(176,70,93,.45); }}
.pill.amber {{ background: rgba(186,143,42,.16); border-color: rgba(186,143,42,.45); }}
.fn-group {{ margin: 14px 0; }}
.fn-title {{ color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; margin-bottom: 8px; }}
.chips {{ display: flex; gap: 8px; flex-wrap: wrap; }}
.chip {{ padding: 6px 10px; border-radius: 999px; font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }}
.chip-green {{ background: rgba(30,122,69,.18); border: 1px solid rgba(30,122,69,.45); }}
.chip-red {{ background: rgba(176,70,93,.18); border: 1px solid rgba(176,70,93,.45); }}
.chip-amber {{ background: rgba(186,143,42,.18); border: 1px solid rgba(186,143,42,.45); }}
.chip-grey {{ background: rgba(120,128,150,.16); border: 1px solid rgba(120,128,150,.4); color: var(--muted); }}
.snippet {{ margin-top: 16px; border: 1px solid rgba(255,255,255,.06); border-radius: 14px; overflow: hidden; }}
.snippet-header {{ padding: 10px 12px; background: rgba(255,255,255,.03); color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; }}
table.code {{ width: 100%; border-collapse: collapse; }}
table.code td {{ vertical-align: top; border-bottom: 1px solid rgba(255,255,255,.04); }}
table.code .ln {{ width: 72px; color: var(--muted); text-align: right; padding: 0 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; user-select: none; }}
table.code .tag {{ width: 120px; padding: 0 10px; color: var(--muted); text-transform: uppercase; font-size: 11px; letter-spacing: .08em; }}
table.code .src {{ width: auto; }}
table.code pre {{ margin: 0; padding: 0 12px 0 0; white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; line-height: 1.45; }}
tr.new-covered td {{ background: linear-gradient(90deg, rgba(30,122,69,.36), rgba(30,122,69,.12)); }}
tr.lost-covered td {{ background: linear-gradient(90deg, rgba(176,70,93,.34), rgba(176,70,93,.10)); }}
tr.still-uncovered td {{ background: linear-gradient(90deg, rgba(186,143,42,.25), rgba(186,143,42,.08)); }}
footer {{ color: var(--muted); margin-top: 24px; font-size: 12px; }}
@media (max-width: 920px) {{
  .file-head {{ flex-direction: column; }}
  table.summary {{ display: block; overflow-x: auto; }}
}}
</style>
</head>
<body>
<div class="container">
  <header>
    <div>
      <h1>LLVM coverage diff</h1>
      <div class="sub">baseline: {html.escape(baseline_name)} &nbsp;→&nbsp; updated: {html.escape(updated_name)}</div>
      <div class="sub">Aggregate scope: {scope}.</div>
      {'<div class="sub">Reachability cross-reference active: still-uncovered functions are split into <b>reachable</b> (amber, actionable) and <b>unreachable</b> (grey, expected dead).</div>' if has_reach else ''}
    </div>
  </header>

  <section class="cards">
    <div class="card"><div class="k">Files with changes</div><div class="v">{changed_files_count}</div></div>
    <div class="card"><div class="k">Newly covered lines — {scope}</div><div class="v">{total_new}</div></div>
    <div class="card"><div class="k">No longer covered lines — {scope}</div><div class="v">{total_lost}</div></div>
    <div class="card"><div class="k">Still uncovered lines — {scope}</div><div class="v">{total_still}</div></div>
    <div class="card"><div class="k">Newly covered functions</div><div class="v">{total_new_fns}</div></div>
    <div class="card"><div class="k">No longer covered functions</div><div class="v">{total_lost_fns}</div></div>
  </section>

  <div class="table-wrap">
    <table class="summary">
      <thead>
        <tr>
          <th>file</th>
          <th>baseline lines</th>
          <th>updated lines</th>
          <th>new</th>
          <th>lost</th>
          <th>still uncovered</th>
          <th>new fns</th>
          <th>lost fns</th>
        </tr>
      </thead>
      <tbody>
        {''.join(rows)}
      </tbody>
    </table>
  </div>

  {''.join(file_sections)}

  <footer>
    Generated from llvm-cov export JSON. Line-level status is reconstructed from file segment data and is intended for practical diffing.
  </footer>
</div>
</body>
</html>'''

    with open(output_path, 'w', encoding='utf-8') as f:
        f.write(html_doc)


def main():
    ap = argparse.ArgumentParser(description='Generate an HTML diff report from two llvm-cov export JSON files.')
    ap.add_argument('baseline_json')
    ap.add_argument('updated_json')
    ap.add_argument('-o', '--output', default='coverage_diff.html', help='HTML output filename, default is "coverage_diff.html".')
    ap.add_argument('--only-changed', action='store_true', help='Only include files with line/function coverage changes in the report.')
    ap.add_argument('--reachability', default=None, help='fuzz-reachability JSON, its output directory, or a sancov .txt list. Splits still-uncovered functions into reachable vs unreachable.')
    args = ap.parse_args()

    (reachable, unreachable, _, reach_keys, unreach_keys, _, _,
     reachable_qualified, unreachable_qualified) = parse_reachability(args.reachability)

    baseline = load_json(args.baseline_json)
    updated = load_json(args.updated_json)

    base_file_entries = extract_file_entries(baseline)
    upd_file_entries = extract_file_entries(updated)
    base_funcs = extract_functions(baseline)
    upd_funcs = extract_functions(updated)

    filenames = sorted(set(base_file_entries) | set(upd_file_entries) | set(base_funcs) | set(upd_funcs))

    diffs: List[dict] = []
    for filename in filenames:
        source_line_count = len(read_source_lines(filename))
        diff = compute_file_diff(
            filename,
            merge_line_states(base_file_entries.get(filename, []), source_line_count),
            merge_line_states(upd_file_entries.get(filename, []), source_line_count),
            base_funcs.get(filename, {}),
            upd_funcs.get(filename, {}),
        )
        if args.only_changed and not (
            diff['newly_covered'] or diff['no_longer_covered'] or diff['newly_covered_fns'] or diff['lost_fns']
        ):
            continue
        diffs.append(diff)

    diffs.sort(key=lambda d: (
        -(len(d['no_longer_covered']) + len(d['lost_fns']) > 0),
        -(len(d['newly_covered']) + len(d['newly_covered_fns'])),
        d['filename'],
    ))

    out_path = Path(args.output)
    out_path.parent.mkdir(parents=True, exist_ok=True)
    render_html(diffs, os.path.basename(args.baseline_json), os.path.basename(args.updated_json), str(out_path),
                reachable, unreachable, reach_keys, unreach_keys,
                reachable_qualified, unreachable_qualified, args.only_changed)


if __name__ == '__main__':
    main()
PYEOF
  } | python3 - "${PYARGS[@]}"
  log "Coverage difference report written to $REPORT_DIR/coverage_diff.html"
  exit 0
}

# ── reachability annotation of llvm-cov reports ──────────────────────────────
# annotate_reachability <coverage.json> <reachability-path> <html-dir> <text-dir> <summary.txt>
#
# Cross llvm-cov coverage with the fuzz-reachability tool's output and annotate
# the reports llvm-cov already produced, in place: tint each function's lines in
# the HTML file view (dark grey = unreachable, amber = reachable but not reached,
# purple = covered-yet-unreachable), add a per-line U/R/A marker column to the
# text source view, and append a tally + actionable list to summary.txt. Prints
# a one-line tally to stdout. <reachability-path> is a .json report, a directory
# (preferring reachability.json inside it, else reached.txt / not_reached.txt),
# or a single allow/ignore .txt list.
# Shares reach_py_lib's parse_reachability/reach_state with cmd_diff.
annotate_reachability() {
  local COVJSON="$1" REACH="$2" HTML_DIR="$3" TEXT_DIR="$4" SUMMARY="$5" PERFUNC="${6:-}"
  { reach_py_lib; cat << 'PYEOF'
#
# (c) 2026 Marc "vanHauser" Heuse
#
# License: GNU Affero General Public License 3
#

import html as htmlmod
import json
import os
import re
import sys

cov_path, reach_path, html_dir, text_dir, summary_path = sys.argv[1:6]
perfunc_path = sys.argv[6] if len(sys.argv) > 6 else ''


def load_json(path):
    with open(path, 'r', encoding='utf-8') as f:
        return json.load(f)


# ── parse the reachability input (json | dir | single .txt) ──────────────────
(reachable, unreachable, conf, reach_keys, unreach_keys, loc, loc_base,
 reachable_qualified, unreachable_qualified) = parse_reachability(reach_path)


# ── parse coverage functions and classify ────────────────────────────────────
def norm(name):
    # llvm-cov records local-linkage functions as "<src>:<symbol>".
    return name.rsplit(':', 1)[-1]


funcs = []
cov = load_json(cov_path)
for obj in cov.get('data', []) or []:
    for fn in obj.get('functions', []) or []:
        name = fn.get('name', '')
        filenames = fn.get('filenames', []) or []
        if not name or not filenames:
            continue
        regions = fn.get('regions', []) or []
        starts = [int(r[0]) for r in regions if len(r) >= 4]
        ends = [int(r[2]) for r in regions if len(r) >= 4]
        if not starts:
            continue
        own_code = [(int(r[0]), int(r[2])) for r in regions
                    if len(r) >= 4 and (len(r) < 8 or (int(r[7]) == 0 and int(r[5]) == 0))]
        funcs.append({
            'name': name,
            'norm': norm(name),
            'count': int(fn.get('count', 0) or 0),
            'file': filenames[0],
            'start': min(starts),
            'end': max(ends),
            'code_lines': own_code or [(min(starts), max(ends))],
        })

for fn in funcs:
    name, n = fn['name'], fn['norm']
    rs = reach_state(name, reachable, unreachable, reach_keys, unreach_keys,
                      reachable_qualified, unreachable_qualified)
    is_reach = rs == 'reachable'
    is_unreach = rs == 'unreachable'
    if not is_reach and not is_unreach:
        st = loc.get((fn['file'], fn['start']))
        if st is None:
            st = loc_base.get((os.path.basename(fn['file']), fn['start']))
        if st == 'reachable':
            is_reach = True
        elif st == 'unreachable':
            is_unreach = True
    covered = fn['count'] > 0
    fn['member'] = 'reachable' if is_reach else ('unreachable' if is_unreach else 'none')
    if covered and is_unreach and not is_reach:
        fn['state'] = 'anomaly'
    elif covered:
        fn['state'] = 'covered'
    elif is_reach:
        c = conf.get(name) or conf.get(n)
        if c == 'low':
            fn['state'] = 'reachable-unreached-low'
        elif c == 'medium':
            fn['state'] = 'reachable-unreached-indirect'
        else:
            fn['state'] = 'reachable-unreached'
    elif is_unreach:
        fn['state'] = 'unreachable'
    else:
        fn['state'] = 'unknown'

t_reachable = sum(1 for f in funcs if f['member'] == 'reachable')
t_unreachable = sum(1 for f in funcs if f['member'] == 'unreachable')
UNREACHED_STATES = ('reachable-unreached', 'reachable-unreached-indirect', 'reachable-unreached-low')
t_unreached = sum(1 for f in funcs if f['state'] in UNREACHED_STATES)
t_anomaly = sum(1 for f in funcs if f['state'] == 'anomaly')
actionable = sorted({f['name'] for f in funcs if f['state'] in UNREACHED_STATES})

# ── per-file line -> state map: own each line by the function whose SMALLEST
# code region contains it (llvm-cov's innermost-segment model), painting only a
# function's own-file code regions. NOT the min..max envelope over all regions:
# an inlined-macro expansion region is mapped to the macro's #define line, which
# would stretch a function's span across the whole file and mistint unrelated
# lines (e.g. a dead function shown amber because a live function's envelope
# overlapped it). Ties go to a covered (executed) owner so real coverage is not
# overpainted. ─────────────────────────────────────────────────────────────────
line_state = {}
_owner = {}
for fn in funcs:
    d = _owner.setdefault(fn['file'], {})
    rank = 1 if fn['state'] == 'covered' else 0
    for l0, l1 in fn['code_lines']:
        span = l1 - l0
        for ln in range(l0, l1 + 1):
            cur = d.get(ln)
            if cur is None or span < cur[0] or (span == cur[0] and rank > cur[1]):
                d[ln] = (span, rank, fn['state'])
for _f, _d in _owner.items():
    line_state[_f] = {ln: v[2] for ln, v in _d.items()}


def states_for(srcfile):
    return line_state.get(srcfile) or line_state.get(os.path.basename(srcfile))


HTML_CLASS = {
    'reachable-unreached': 'reach-amber',
    'reachable-unreached-indirect': 'reach-amber-indirect',
    'reachable-unreached-low': 'reach-amber-low',
    'unreachable': 'reach-grey',
    'anomaly': 'reach-anomaly',
}
TEXT_MARK = {
    'unreachable': 'U',
    'reachable-unreached': 'R',
    'reachable-unreached-indirect': 'R',
    'reachable-unreached-low': 'R',
    'anomaly': 'A',
}

# ── annotate the HTML file view ───────────────────────────────────────────────
covdir = os.path.join(html_dir, 'coverage')
if os.path.isdir(covdir):
    for root, _, files in os.walk(covdir):
        for fname in files:
            if not fname.endswith('.html'):
                continue
            path = os.path.join(root, fname)
            with open(path, 'r', encoding='utf-8', errors='replace') as f:
                doc = f.read()
            m = re.search(r"source-name-title'><pre>(.*?)</pre>", doc, re.S)
            if not m:
                continue
            states = states_for(htmlmod.unescape(m.group(1)).strip())
            if not states:
                continue

            def repl(mt, states=states):
                seg = mt.group(0)
                lm = re.search(r"name='L(\d+)'", seg)
                if not lm:
                    return seg
                cls = HTML_CLASS.get(states.get(int(lm.group(1))))
                if not cls:
                    return seg
                return seg.replace('<tr>', "<tr class='%s'>" % cls, 1)

            doc = re.sub(r"<tr>.*?</tr>", repl, doc, flags=re.S)
            with open(path, 'w', encoding='utf-8') as f:
                f.write(doc)

# Append the CSS rules once to the shared stylesheet.
css_path = os.path.join(html_dir, 'style.css')
css_marker = 'cov-analysis reachability annotation'
css_rules = """
/* %s */
tr.reach-grey td { background-color: #d0d0d0 !important; color: #777 !important; }
tr.reach-grey .red { background-color: #d0d0d0 !important; color: #777 !important; }
tr.reach-amber td.code { background-color: #fff3d6 !important; }
tr.reach-amber-indirect td.code { background-color: #fff8e8 !important; }
tr.reach-amber-low td.code { background-color: #fffdf5 !important; }
tr.reach-anomaly td.code { background-color: #f3e8fd !important; }
""" % css_marker
if os.path.isfile(css_path):
    with open(css_path, 'r', encoding='utf-8', errors='replace') as f:
        cur = f.read()
    if css_marker not in cur:
        with open(css_path, 'a', encoding='utf-8') as f:
            f.write(css_rules)

# Inject a tally banner into index.html.
idx = os.path.join(html_dir, 'index.html')
if os.path.isfile(idx):
    with open(idx, 'r', encoding='utf-8', errors='replace') as f:
        doc = f.read()
    if 'reach-banner' not in doc:
        banner = (
            "<div class='reach-banner' style='margin:10px;padding:8px 12px;"
            "border:1px solid #ccc;border-radius:6px;font-family:sans-serif'>"
            "<b>Reachability</b> (vs %s): %d reachable, "
            "<span style='background:#fff3d6'>%d not yet reached</span> (amber), "
            "<span style='background:#d0d0d0'>%d unreachable</span> (grey)%s"
            "</div>" % (
                htmlmod.escape(os.path.basename(reach_path)),
                t_reachable, t_unreached, t_unreachable,
                (", <span style='background:#f3e8fd'>%d covered-yet-unreachable</span>" % t_anomaly) if t_anomaly else "",
            )
        )
        doc = doc.replace('<body>', '<body>' + banner, 1)
        with open(idx, 'w', encoding='utf-8') as f:
            f.write(doc)

# ── annotate the text source view ─────────────────────────────────────────────
tcovdir = os.path.join(text_dir, 'coverage')
ansi_re = re.compile(r'\x1b\[[0-9;]*m')
src_re = re.compile(r'^(\s*\d+\|[^|]*\|)(.*)$')
legend = '# reachability:  U=unreachable (dead)   R=reachable, not reached   A=covered yet unreachable'
if os.path.isdir(tcovdir):
    for root, _, files in os.walk(tcovdir):
        for fname in files:
            if not fname.endswith('.txt'):
                continue
            path = os.path.join(root, fname)
            with open(path, 'r', encoding='utf-8', errors='replace') as f:
                lines = f.read().split('\n')
            out = []
            cur = None
            for line in lines:
                line = ansi_re.sub('', line)
                m = src_re.match(line)
                if m and cur is not None:
                    ln = int(line.split('|', 1)[0].strip())
                    mark = TEXT_MARK.get(cur.get(ln), ' ')
                    out.append(m.group(1) + mark + ' ' + m.group(2))
                    continue
                stripped = line.rstrip()
                head = stripped.lstrip()
                if stripped.endswith(':') and not (head[:1].isdigit()):
                    probe = stripped[:-1].strip()
                    if probe in line_state or os.path.basename(probe) in line_state:
                        cur = states_for(probe)
                        out.append(line)
                        if cur:
                            out.append(legend)
                    else:
                        out.append(line)
                    continue
                out.append(line)
            with open(path, 'w', encoding='utf-8') as f:
                f.write('\n'.join(out))

# ── recompute coverage numbers excluding unreachable functions ────────────────
# Per-function Regions/Lines/Branches come from `llvm-cov report -show-functions`
# (authoritative; they sum to llvm-cov's own totals). We re-sum over the kept
# functions (everything except those flagged unreachable) so every denominator
# drops the statically-dead code. file_metrics: absfile -> dict of totals.
def reach_member(name):
    n = name.rsplit(':', 1)[-1]
    if name in unreachable or n in unreachable:
        return 'unreachable'
    if name in reachable or n in reachable:
        return 'reachable'
    k = _reach_key(n)
    if k in reach_keys:
        return 'reachable'
    if k in unreach_keys:
        return 'unreachable'
    return 'none'


# The export functions (`funcs`) already carry the full layered join
# (file-qualified static match -> exact name -> key -> (file,line) loc/loc_base
# fallback) done in the classify loop above; index their outcome so
# parse_perfunc's exclusion decision agrees with that classification instead of
# re-deriving a thinner exact+key-only view via reach_member.
#
# `llvm-cov report -show-functions` groups its rows under `File '<path>':`
# section headers and prints the BARE symbol for statics, so the recompute
# disambiguates statics by (basename(file), symbol) via func_member_by_file_sym
# -- the SAME qualified key reach_state uses for the tally -- letting a covered
# reachable static and a dead static that merely share a bare name in different
# files get their own verdicts. The residual the basename key cannot split is
# same-named statics in different directories that share a basename; there, and
# on any bare-name (func_member_by_norm) collision, all three maps resolve
# reachable-wins so a live function is never dropped from the denominators. At
# worst a same-named dead static is kept in, which only under-reports coverage
# (matching the over-approximation philosophy).
func_member_by_file_sym = {}
func_member_by_full = {}
func_member_by_norm = {}
for _fn in funcs:
    _fk = (os.path.basename(_fn['file']), _fn['norm'])
    if func_member_by_file_sym.get(_fk) != 'reachable':
        func_member_by_file_sym[_fk] = _fn['member']
    if func_member_by_full.get(_fn['name']) != 'reachable':
        func_member_by_full[_fn['name']] = _fn['member']
    if func_member_by_norm.get(_fn['norm']) != 'reachable':
        func_member_by_norm[_fn['norm']] = _fn['member']


def perfunc_member(name, srcfile=None):
    n = name.rsplit(':', 1)[-1]
    if srcfile is not None:
        fk = (os.path.basename(srcfile), n)
        if fk in func_member_by_file_sym:
            return func_member_by_file_sym[fk]
    if name in func_member_by_full:
        return func_member_by_full[name]
    if n in func_member_by_norm:
        return func_member_by_norm[n]
    return reach_member(name)


file_metrics = {}


def blank_metric():
    return {'fn': 0, 'fn_cov': 0, 'reg': 0, 'reg_cov': 0,
            'line': 0, 'line_cov': 0, 'br': 0, 'br_cov': 0}


def parse_perfunc(path):
    cur = None
    with open(path, 'r', encoding='utf-8', errors='replace') as f:
        for line in f:
            s = line.rstrip('\n')
            mh = re.match(r"^File '(.*)':\s*$", s.strip())
            if mh:
                cur = mh.group(1)
                file_metrics.setdefault(cur, blank_metric())
                continue
            toks = s.split()
            if cur is None or len(toks) < 10:
                continue
            name = toks[0]
            if name in ('Name', 'TOTAL') or set(name) == {'-'}:
                continue
            try:
                reg, reg_m = int(toks[1]), int(toks[2])
                ln, ln_m = int(toks[4]), int(toks[5])
                br, br_m = int(toks[7]), int(toks[8])
            except (ValueError, IndexError):
                continue
            if perfunc_member(name, cur) == 'unreachable':
                continue  # exclude statically-dead functions from every metric
            m = file_metrics[cur]
            m['fn'] += 1
            m['fn_cov'] += 1 if (reg - reg_m) > 0 else 0   # executed iff a region ran
            m['reg'] += reg
            m['reg_cov'] += reg - reg_m
            m['line'] += ln
            m['line_cov'] += ln - ln_m
            m['br'] += br
            m['br_cov'] += br - br_m


have_numbers = bool(perfunc_path) and os.path.isfile(perfunc_path)
if have_numbers:
    parse_perfunc(perfunc_path)

grand = blank_metric()
for m in file_metrics.values():
    for k in grand:
        grand[k] += m[k]


def pct(cov, tot):
    return (100.0 * cov / tot) if tot else 0.0


def cov_color(cov, tot):
    if tot == 0:
        return 'column-entry-gray'
    if cov == tot:
        return 'column-entry-green'
    return 'column-entry-red' if pct(cov, tot) < 80.0 else 'column-entry-yellow'


def cell_text(cov, tot):
    if tot == 0:
        return '- (0/0)'
    return '%6.2f%% (%d/%d)' % (pct(cov, tot), cov, tot)


# ── reachability summary block (always) ───────────────────────────────────────
block = []
block.append('')
block.append('== Reachability (vs %s) ==' % os.path.basename(reach_path))
block.append('  reachable functions          : %d' % t_reachable)
block.append('    of which not yet reached   : %d   <- amber, cover these' % t_unreached)
block.append('  unreachable functions        : %d   <- dark grey, expected dead%s'
             % (t_unreachable, ' (EXCLUDED from the numbers above)' if have_numbers else ''))
if t_anomaly:
    block.append('  covered yet unreachable      : %d   <- anomaly, investigate' % t_anomaly)
block.append('')
if actionable:
    block.append('Reachable but NOT reached (actionable):')
    block.extend('  ' + name for name in actionable)
    block.append('')

if have_numbers and os.path.isfile(summary_path):
    # Replace summary.txt with a reachable-only table, then the block above.
    rows = []
    rows.append('Reachable-only coverage  (excludes %d statically-unreachable function(s); '
                'full numbers remain in coverage.json)' % t_unreachable)
    rows.append('')
    rows.append('%-50s %-18s %-18s %-18s %-18s'
                % ('Filename', 'Functions', 'Lines', 'Regions', 'Branches'))
    rows.append('-' * 122)

    def row_for(label, m):
        return '%-50s %-18s %-18s %-18s %-18s' % (
            label,
            cell_text(m['fn_cov'], m['fn']),
            cell_text(m['line_cov'], m['line']),
            cell_text(m['reg_cov'], m['reg']),
            cell_text(m['br_cov'], m['br']))

    for fname in sorted(file_metrics):
        rows.append(row_for(fname, file_metrics[fname]))
    rows.append('-' * 122)
    rows.append(row_for('TOTAL', grand))
    with open(summary_path, 'w', encoding='utf-8') as f:
        f.write('\n'.join(rows) + '\n' + '\n'.join(block) + '\n')
elif os.path.isfile(summary_path):
    with open(summary_path, 'a', encoding='utf-8') as f:
        f.write('\n'.join(block) + '\n')

# ── patch the (flat) HTML index tables with the reachable-only numbers ────────
def derive_absfile(href):
    # Top-level flat index links files as coverage/<abs-source-path>.html
    if href.startswith('coverage/') and href.endswith('.html'):
        return href[len('coverage'):-len('.html')]
    return None


METRIC_CELL = re.compile(r"<td class='column-entry-[^']*'><pre>[^<]*</pre></td>")


def patch_index(path):
    with open(path, 'r', encoding='utf-8', errors='replace') as f:
        doc = f.read()

    def row_repl(mt):
        row = mt.group(0)
        m = None
        am = re.search(r"<a href='([^']*)'>", row)
        if am:
            absf = derive_absfile(am.group(1))
            if absf and absf in file_metrics:
                m = file_metrics[absf]
        elif '<pre>Totals</pre>' in row or '<pre>Total</pre>' in row:
            m = grand
        if m is None:
            return row
        cells = [
            (m['fn_cov'], m['fn']), (m['line_cov'], m['line']),
            (m['reg_cov'], m['reg']), (m['br_cov'], m['br']),
        ]
        it = iter(cells)

        def cell_repl(cm):
            try:
                cov, tot = next(it)
            except StopIteration:
                return cm.group(0)
            return ("<td class='%s'><pre>%s</pre></td>"
                    % (cov_color(cov, tot), cell_text(cov, tot)))

        return METRIC_CELL.sub(cell_repl, row)

    doc = re.sub(r"<tr[^>]*>.*?</tr>", row_repl, doc, flags=re.S)
    with open(path, 'w', encoding='utf-8') as f:
        f.write(doc)


if have_numbers:
    for root, _, files in os.walk(html_dir):
        for fname in files:
            if fname == 'index.html':
                patch_index(os.path.join(root, fname))

print('reachable=%d not-reached=%d unreachable=%d anomaly=%d'
      % (t_reachable, t_unreached, t_unreachable, t_anomaly))
PYEOF
  } | python3 - "$COVJSON" "$REACH" "$HTML_DIR" "$TEXT_DIR" "$SUMMARY" "$PERFUNC"
}

# ── command: report ───────────────────────────────────────────────────────────

cmd_report() {
  # ── defaults ────────────────────────────────────────────────────────────────
  local AFL_DIR=""
  local COVERAGE_CMD=""
  local REPORT_DIR=""
  local TIMEOUT=5
  local FORKS=1
  local IGNORE_REGEX='/usr/include/'
  local PROFRAW_DIR=""
  local LLVM_PROFDATA=""
  local LLVM_COV=""
  local FUZZER_LAYOUT="${FUZZER_LAYOUT:-}"
  local REACHABILITY=""
  local BINARY_OVERRIDE=""
  local MIGRATE_EXISTING=0
  local STAGE_DIR=""

  # ── argument parsing ───────────────────────────────────────────────────────
  if test $# -eq 0; then usage_report; exit 1; fi

  while [ $# -gt 0 ]; do
    case "$1" in
      -d)            need_arg "-d" "${2-}"; AFL_DIR="$2";       shift 2 ;;
      -e)            need_arg "-e" "${2-}"; COVERAGE_CMD="$2";  shift 2 ;;
      -o)            need_arg "-o" "${2-}"; REPORT_DIR="$2";    shift 2 ;;
      -t)            need_arg "-t" "${2-}"; FORKS="$2";         shift 2 ;;
      -T)            need_arg "-T" "${2-}"; TIMEOUT="$2";       shift 2 ;;
      -v)            VERBOSE=1;          shift   ;;
      -q)            QUIET=1;            shift   ;;
      -V)            echo "cov-analysis-$VERSION"; exit 0 ;;
      -h|--help)     usage_report; exit 0 ;;
      --ignore-regex) need_arg "--ignore-regex" "${2-}"; IGNORE_REGEX="$2"; shift 2 ;;
      --layout)      need_arg "--layout" "${2-}"; FUZZER_LAYOUT="$2";       shift 2 ;;
      --reachability) need_arg "--reachability" "${2-}"; REACHABILITY="$2"; shift 2 ;;
      --binary)      need_arg "--binary" "${2-}"; BINARY_OVERRIDE="$2"; shift 2 ;;
      --migrate-existing-report) MIGRATE_EXISTING=1; shift ;;
      *) err "Unknown option: $1"; echo "" >&2; usage_report >&2; exit 1 ;;
    esac
  done

  # ── validate inputs ────────────────────────────────────────────────────────
  if test -z "$AFL_DIR"; then
    err "Must specify input directory with -d (AFL++, libFuzzer, libafl, or honggfuzz)"
    exit 1
  fi
  if test -z "$COVERAGE_CMD"; then
    err "Must specify coverage command with -e"
    exit 1
  fi
  if ! test -d "$AFL_DIR"; then
    err "Input directory does not exist: $AFL_DIR"
    exit 1
  fi
  case "$FORKS" in
    ''|*[!0-9]*|0)
      err "Replay worker count must be a positive integer: $FORKS"
      exit 1
      ;;
  esac
  case "$TIMEOUT" in
    ''|*[!0-9]*|0)
      err "Timeout (-T) must be a positive integer in seconds: $TIMEOUT"
      exit 1
      ;;
  esac

  # Validate explicit --layout override if supplied
  if test -n "$FUZZER_LAYOUT"; then
    case "$FUZZER_LAYOUT" in
      afl|flat) ;;
      *) err "--layout must be 'afl' or 'flat' (got '$FUZZER_LAYOUT')"; exit 1 ;;
    esac
  fi

  # Validate --reachability path (a .json, a dir with reachability.json or
  # reached.txt/not_reached.txt, or a single allow/ignore .txt list) if supplied.
  if test -n "$REACHABILITY" && ! test -e "$REACHABILITY"; then
    err "--reachability path does not exist: $REACHABILITY"
    err "Pass the fuzz-reachability JSON, its output directory, or a sancov .txt list."
    exit 1
  fi

  require_replay_commands "cov-analysis report" || exit 1
  require_coreutils_command mv "cov-analysis report" || exit 1
  require_coreutils_command realpath "cov-analysis report" || exit 1
  if test -n "$REACHABILITY"; then
    require_command python3 "report --reachability" || exit 1
    if ! validate_reachability "$REACHABILITY"; then
      err "Invalid reachability input: $REACHABILITY"
      exit 1
    fi
  fi

  local COV_BINARY
  COV_BINARY="$(resolve_coverage_binary "$COVERAGE_CMD" "$BINARY_OVERRIDE")" || exit 1
  logv "Coverage binary: $COV_BINARY"

  # Forgotten @@ on a driver binary → supply it automatically (argv-only input).
  COVERAGE_CMD="$(add_at_if_driver "$COVERAGE_CMD" "$COV_BINARY")"
  logv "Coverage command: $COVERAGE_CMD"

  test -z "$REPORT_DIR" && REPORT_DIR="$AFL_DIR/cov"
  REPORT_DIR="$(realpath -m -- "$REPORT_DIR")" || {
    err "Could not resolve report directory: $REPORT_DIR"
    exit 1
  }
  case "$REPORT_DIR" in
    /|""|/usr|/usr/local|/etc|/var|/opt|/home|/root)
      err "Refusing to use report directory: $REPORT_DIR"
      err "Pick a dedicated subdirectory with -o."
      exit 1
      ;;
  esac
  local REPORT_PARENT REPORT_BASE
  REPORT_PARENT="$(dirname -- "$REPORT_DIR")"
  REPORT_BASE="$(basename -- "$REPORT_DIR")"
  mkdir -p -- "$REPORT_PARENT"
  if test -e "$REPORT_DIR" && ! test -d "$REPORT_DIR"; then
    err "Report destination exists and is not a directory: $REPORT_DIR"
    exit 1
  fi
  if directory_nonempty "$REPORT_DIR" && ! owned_report "$REPORT_DIR"; then
    if test "$MIGRATE_EXISTING" -ne 1; then
      err "Refusing to replace unmarked non-empty directory: $REPORT_DIR"
      err "Use an empty directory, a marked cov-analysis report, or --migrate-existing-report for a verified pre-marker report."
      exit 1
    fi
    if ! valid_legacy_report "$REPORT_DIR"; then
      err "The requested migration directory is not a complete pre-marker cov-analysis report: $REPORT_DIR"
      exit 1
    fi
  fi

  # ── detect LLVM tools ──────────────────────────────────────────────────────
  LLVM_PROFDATA="$(find_tool llvm-profdata)" || {
    err "llvm-profdata not found. Install LLVM (apt install llvm / dnf install llvm)."
    exit 1
  }
  LLVM_COV="$(find_tool llvm-cov)" || {
    err "llvm-cov not found. Install LLVM (apt install llvm / dnf install llvm)."
    exit 1
  }
  log "LLVM tools: $LLVM_PROFDATA, $LLVM_COV"

  # ── detect or honor fuzzer layout ──────────────────────────────────────────
  if test -z "$FUZZER_LAYOUT"; then
    FUZZER_LAYOUT="$(detect_fuzzer_layout)"
  fi
  case "$FUZZER_LAYOUT" in
    afl)  log "Fuzzer layout   : AFL++ (queue/crashes/timeouts)" ;;
    flat) log "Fuzzer layout   : flat (libFuzzer/libafl/honggfuzz)" ;;
    empty)
      err "No input files found in $AFL_DIR"
      err "Expected one of: AFL++ out dir, libFuzzer/libafl corpus, honggfuzz workspace."
      err "Override detection with --layout afl|flat if needed."
      exit 1
      ;;
  esac

  # ── prepare workspace ──────────────────────────────────────────────────────
  PROFRAW_DIR="$(mktemp -d "$(tmp_base_dir)/cov-analysis-profraw.XXXXXX")"
  STAGE_DIR="$(mktemp -d "$REPORT_PARENT/.${REPORT_BASE}.cov-analysis.stage.XXXXXX")"
  COV_REPORT_CLEAN_PROFRAW="$PROFRAW_DIR"
  COV_REPORT_CLEAN_STAGE="$STAGE_DIR"
  trap cleanup_report_work EXIT INT TERM
  mkdir -p "$STAGE_DIR/html" "$STAGE_DIR/text"
  printf 'cov-analysis-report-v1\n' > "$STAGE_DIR/$REPORT_MARKER_NAME"
  if test -s "$REPORT_DIR/coverage.json"; then
    log "Preserving previous coverage.json as coverage_old.json"
    cp -- "$REPORT_DIR/coverage.json" "$STAGE_DIR/coverage_old.json"
  fi
  export LLVM_PROFILE_FILE="$PROFRAW_DIR/cov-%p.profraw"

  log "Input directory : $AFL_DIR"
  log "Report directory: $REPORT_DIR"
  log "Replay workers : $FORKS"
  logv "Profraw temp dir: $PROFRAW_DIR"

  # ── replay queue files ─────────────────────────────────────────────────────
  local QUEUE_COUNT
  QUEUE_COUNT=$(count_files find_queue_files)
  log "Replaying $QUEUE_COUNT queue files..."

  case "$COVERAGE_CMD" in
    *@@*)
      # Determine if @@ is the last token (enables fast batch mode)
      local LAST_TOKEN="${COVERAGE_CMD##* }"
      if test "$LAST_TOKEN" = "@@"; then
        if ! is_cov_driver_binary "$COV_BINARY"; then
          LAST_TOKEN="x"
        fi
      fi
      if test "$LAST_TOKEN" = "@@"; then
        # Fast batch: strip trailing @@ and let xargs append all files at once.
        # bash -c handles the same shell language as every other replay mode.
        local CMD_NO_AT="${COVERAGE_CMD% @@}"
        logv "Queue replay: batch (xargs, workers=$FORKS)"
        # shellcheck disable=SC2016
        find_queue_files | xargs -0 -r -n 128 -P "$FORKS" \
          bash -c "${CMD_NO_AT}"' "$@"' -- >/dev/null 2>&1 || true
      else
        # @@ is embedded mid-command (e.g. "./cov -f @@ -extra") — loop mode.
        logv "Queue replay: loop (mid-command @@, workers=$FORKS)"
        export -f command_for_input run_input_command_unbounded
        find_queue_files | xargs -0 -r -n 1 -P "$FORKS" \
          bash -c 'run_input_command_unbounded "$1" "$2"' _ "$COVERAGE_CMD" \
          >/dev/null 2>&1 || true
      fi
      ;;
    *)
      # No @@ — feed each file via stdin
      logv "Queue replay: stdin loop (workers=$FORKS)"
      export -f command_for_input run_input_command_unbounded
      find_queue_files | xargs -0 -r -n 1 -P "$FORKS" \
        bash -c 'run_input_command_unbounded "$1" "$2"' _ "$COVERAGE_CMD" \
        >/dev/null 2>&1 || true
      ;;
  esac

  # ── replay crashes and timeouts ────────────────────────────────────────────
  local CRASH_COUNT
  CRASH_COUNT=$(count_files find_crash_timeout_files)
  if test "$CRASH_COUNT" -gt 0; then
    log "Replaying $CRASH_COUNT crash/timeout files (timeout=${TIMEOUT}s each)..."
    export -f run_with_timeout command_for_input run_input_command
    find_crash_timeout_files | xargs -0 -r -n 1 -P "$FORKS" \
      bash -c 'run_input_command "$2" "$1" "$3"' _ "$COVERAGE_CMD" "$TIMEOUT" \
      >/dev/null 2>&1 || true
  fi

  # ── check profraw files were generated ────────────────────────────────────
  local PROFRAW_COUNT
  PROFRAW_COUNT=$(find "$PROFRAW_DIR" -name '*.profraw' -printf . 2>/dev/null | wc -c)
  if test "$PROFRAW_COUNT" -eq 0; then
    err "No .profraw files generated."
    err "Check that the binary is instrumented with -fprofile-instr-generate."
    err "Use 'cov-analysis build' to build the coverage binary."
    exit 1
  fi
  logv "Generated $PROFRAW_COUNT .profraw file(s)"

  # ── merge profiles ─────────────────────────────────────────────────────────
  log "Merging $PROFRAW_COUNT profile(s)..."
  merge_profiles "$LLVM_PROFDATA" "$PROFRAW_DIR" "$STAGE_DIR/coverage.profdata" || {
    err "llvm-profdata merge failed"
    exit 1
  }

  # ── generate reports ───────────────────────────────────────────────────────
  local SHOW_OPTS=(
    "$COV_BINARY"
    "-instr-profile=$STAGE_DIR/coverage.profdata"
    "-show-directory-coverage"
    "-show-line-counts-or-regions"
    "-show-branches=count"
    "-ignore-filename-regex=$IGNORE_REGEX"
  )

  # With --reachability the HTML index is rewritten with reachable-only numbers,
  # which needs the flat single-table index — so drop -show-directory-coverage
  # (its hierarchical multi-index layout) for the HTML output in that case.
  local HTML_SHOW_OPTS=("${SHOW_OPTS[@]}")
  if test -n "$REACHABILITY"; then
    local _o; local -a _filtered=()
    for _o in "${HTML_SHOW_OPTS[@]}"; do
      test "$_o" = "-show-directory-coverage" && continue
      _filtered+=("$_o")
    done
    HTML_SHOW_OPTS=("${_filtered[@]}")
  fi

  log "Generating HTML report..."
  "$LLVM_COV" show "${HTML_SHOW_OPTS[@]}" \
    -format=html \
    -output-dir="$STAGE_DIR/html" || {
    err "llvm-cov show (HTML) failed"
    exit 1
  }

  log "Generating text report..."
  "$LLVM_COV" show "${SHOW_OPTS[@]}" \
    -format=text \
    -use-color=false \
    -output-dir="$STAGE_DIR/text" || {
    err "llvm-cov show (text) failed"
    exit 1
  }

  log "Generating summary..."
  "$LLVM_COV" report "$COV_BINARY" \
    "-instr-profile=$STAGE_DIR/coverage.profdata" \
    "-ignore-filename-regex=$IGNORE_REGEX" \
    > "$STAGE_DIR/summary.txt" || {
    err "llvm-cov report failed"
    exit 1
  }

  log "Generating JSON export..."
  # llvm-cov quirk: --format=text emits JSON; --format=lcov emits LCOV.
  "$LLVM_COV" export "$COV_BINARY" \
    --format=text \
    "-instr-profile=$STAGE_DIR/coverage.profdata" \
    "-ignore-filename-regex=$IGNORE_REGEX" \
    > "$STAGE_DIR/coverage.json" || {
    err "llvm-cov export failed"
    exit 1
  }

  # ── reachability annotation (optional) ─────────────────────────────────────
  if test -n "$REACHABILITY"; then
    log "Annotating reports with reachability..."
    # Per-function metrics let the annotator recompute Function/Line/Region/
    # Branch coverage excluding statically-unreachable functions.
    local PERFUNC="$PROFRAW_DIR/perfunc.txt"
    local SRC_RESPONSE="$PROFRAW_DIR/sources.rsp"
    if ! write_source_response "$STAGE_DIR/coverage.json" "$SRC_RESPONSE"; then
      err "Could not build the source response file for reachable-only metrics."
      exit 1
    fi
    if ! "$LLVM_COV" report "$COV_BINARY" \
      "-instr-profile=$STAGE_DIR/coverage.profdata" \
      "-ignore-filename-regex=$IGNORE_REGEX" \
      -show-functions "@$SRC_RESPONSE" > "$PERFUNC"; then
      err "llvm-cov per-function report failed; reachability metrics were not generated."
      exit 1
    fi
    if ! test -s "$PERFUNC"; then
      err "llvm-cov per-function report was empty; reachability metrics were not generated."
      exit 1
    fi
    local REACH_TALLY=""
    if ! REACH_TALLY="$(annotate_reachability "$STAGE_DIR/coverage.json" "$REACHABILITY" \
         "$STAGE_DIR/html" "$STAGE_DIR/text" "$STAGE_DIR/summary.txt" "$PERFUNC")"; then
      err "Reachability annotation failed."
      exit 1
    fi
    log "Reachability: $REACH_TALLY"
  fi

  if ! validate_staged_report "$STAGE_DIR" "$REACHABILITY"; then
    err "The staged report is incomplete or invalid; the previous report was preserved."
    exit 1
  fi
  if ! publish_report "$STAGE_DIR" "$REPORT_DIR"; then
    exit 1
  fi
  STAGE_DIR=""
  COV_REPORT_CLEAN_STAGE=""

  # ── print summary ──────────────────────────────────────────────────────────
  if test "$QUIET" -eq 0; then
    echo ""
    if test "$VERBOSE" -eq 1; then
      echo "=== Coverage Summary ==="
      cat "$REPORT_DIR/summary.txt"
      echo ""
    fi
    echo "[+] HTML report  : file://$REPORT_DIR/html/index.html"
    echo "[+] Text report  : $REPORT_DIR/text/"
    echo "[+] Summary      : $REPORT_DIR/summary.txt"
    echo "[+] JSON export  : $REPORT_DIR/coverage.json"
    echo "[+] Profile data : $REPORT_DIR/coverage.profdata"
  fi
}

# ── main ──────────────────────────────────────────────────────────────────────

# Only run main logic when executed directly (not when sourced from tests).
if [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then
  test $# -eq 0 && { usage_main; exit 1; }

  # Global flags before command detection
  case "$1" in
    -V) echo "cov-analysis-$VERSION"; exit 0 ;;
    -h|--help) usage_main; exit 0 ;;
  esac

  # Detect optional command; default to "report"
  COMMAND="report"
  case "$1" in
    build|driver|report|diff|stability|search) COMMAND="$1"; shift ;;
  esac

  case "$COMMAND" in
    build)     cmd_build "$@" ;;
    driver)    cmd_driver "$@" ;;
    report)    cmd_report "$@" ;;
    diff)      cmd_diff "$@" ;;
    stability) cmd_stability "$@" ;;
    search)    cmd_search "$@" ;;
  esac
fi
