#!/usr/bin/env bash

# Copyright 2001-2026, Paul Johnson (paul@pjcj.net)

# This software is free.  It is licensed under the same terms as Perl itself.

# The latest version of this software should be available from my homepage:
# https://pjcj.net

if ((BASH_VERSINFO[0] < 5)); then
  echo "❌ bash version $BASH_VERSION is too old. Please install v5 or higher."
  exit 1
fi

set -eEuo pipefail
shopt -s inherit_errexit

_p() {
  __l="$(hostname): $1"
  shift
  echo "$__l $script: $*" | tee -a "$LOG_FILE" >&2
}
pt() { _p "[TRACE]  " "$*"; }
pd() { _p "[DEBUG]  " "$*"; }
pi() { _p "[INFO]   " "$*"; }
pw() { _p "[WARNING]" "$*"; }
pe() { _p "[ERROR]  " "$*"; }
pf() {
  _p "[FATAL]  " "$*"
  exit 1
}

usage() {
  cat <<EOT
$script --help
$script --trace --verbose
$script --env=dev cpancover-controller-run-once
$script --results_dir=/cover/dev --image=pjcj/cpancover_dev cpancover-run
EOT
  exit 0
}

cleanup() {
  declare -r res=$?
  # ((verbose)) && pi "Cleaning up"
  exit "$res"
}

parse_options() {
  while [[ $# -gt 0 ]]; do
    case "$1" in
    -d | --dryrun)
      dryrun=1
      shift
      ;;
    -e | --env)
      shift
      env="$1"
      case "$env" in
      prod)
        results_dir=~/cover/staging
        docker_image=pjcj/cpancover
        ;;
      dev)
        results_dir=~/cover/staging_dev
        docker_image=pjcj/cpancover_dev
        ;;
      *)
        pf "Unrecognised environment: $1"
        ;;
      esac
      shift
      if grep -q "/docker/" /proc/self/mountinfo 2>/dev/null; then
        # we're inside a docker container so use the remote staging directory
        results_dir=/remote_staging
      fi
      ;;
    -f | --force)
      force=1
      shift
      ;;
    -h | --help)
      usage
      ;;
    -i | --image)
      docker_image="$2"
      shift 2
      ;;
    -r | --results_dir)
      results_dir="$2"
      shift 2
      ;;
    -t | --trace)
      set -x
      shift
      ;;
    -v | --verbose)
      verbose=1
      shift
      ;;
    *)
      recipe="$1"
      shift
      args=("$@")
      break
      ;;
    esac
  done
}

recipe_options() {
  echo "-d --dryrun"
  echo "-e --env"
  echo "-f --force"
  echo "-h --help"
  echo "-i --image"
  echo "-r --results_dir"
  echo "-t --trace"
  echo "-v --verbose"
  declare -F | perl -nE 'say $1 if /recipe_(.+)/'
}

setup() {
  script=$(basename "$0")
  readl=readlink
  if command -v greadlink >&/dev/null; then readl=greadlink; fi
  srcdir=$("$readl" -f "$(dirname "$0")")
  readonly LOG_FILE="/tmp/$script.log"

  export AUTOMATED_TESTING=1
  export NONINTERACTIVE_TESTING=1
  export EXTENDED_TESTING=1

  PATH="$srcdir:$PATH"

  docker=docker
  docker_image=pjcj/cpancover
  dryrun=0
  env=prod
  force=0
  results_dir=~/cover/staging
  verbose=0

  parse_options "$@"
}

nice_cpus() {
  perl -Iutils -MDevel::Cover::BuildUtils=nice_cpus -e "print nice_cpus"
}

recipe_nice-cpus() {
  nice_cpus
}

recipe_update-copyright() {
  local from="${1:-$(date +'%Y' --date='last year')}"
  local to="${2:-$(date +'%Y')}"
  pi "Updating copyright from $from to $to"
  local me="Paul Johnson"
  local files
  files=$(git ls-files)
  # shellcheck disable=SC2086
  perl -pi -e "s/Copyright \\d+-\\K$from(, $me)/$to\$1/i" $files
  # shellcheck disable=SC2086
  perl -pi -e "s/Copyright $from\\K(, $me)/-$to\$1/i" $files
}

get_cpm() {
  cpanm --notest App::cpm
  plenv rehash
  cpm=$(plenv which cpm)
}

install_dependencies() {
  get_cpm
  pi "Installing dependencies with $cpm"
  $cpm install --workers="$(nice_cpus)" --global \
    Sereal Digest::MD5 Template Pod::Coverage::CountParents \
    Capture::Tiny Parallel::Iterator Template Class::XSAccessor
}

install_development_dependencies() {
  get_cpm
  $cpm install --workers="$(nice_cpus)" --global \
    Dist::Zilla Perl::Critic Perl::Tidy App::perlimports \
    Archive::Tar::Wrapper Perl::Critic::PJCJ App::Yath UUID
  plenv rehash
  dzil authordeps --missing |
    xargs "$cpm" install --workers="$(nice_cpus)" --global
  dzil listdeps --missing |
    xargs "$cpm" install --workers="$(nice_cpus)" --global
}

install_test_dependencies() {
  get_cpm
  $cpm install --workers="$(nice_cpus)" --global \
    DBM::Deep
}

recipe_install-dependencies() {
  install_dependencies
}

recipe_install-development-dependencies() {
  install_development_dependencies
}

recipe_install-test-dependencies() {
  install_test_dependencies
}

install_perl() {
  local name="${1:?No name specified}"
  local version="${2:?No version specified}"
  yes | plenv uninstall "$name" || true
  plenv install --as "$name" -j 32 -D usedevel --noman "$version"
  export PLENV_VERSION="$name"
  plenv install-cpanm
  install_dependencies
}

recipe_install-perl() {
  local name="${1:?No name specified}"
  local version="${2:?No version specified}"
  install_perl "$name" "$version"
}

recipe_install-cpancover-perl() {
  local version="${1:?No version specified}"
  install_perl cpancover "$version"
}

recipe_install-dc-dev-perl() {
  local version="${1:?No version specified}"
  install_perl dc-dev "$version"
  install_development_dependencies
}

recipe_all-versions() {
  ./utils/all_versions "$@"
}

recipe_build-and-test-all() {
  perl Makefile.PL
  make clean
  perl Makefile.PL
  make at
}

run_cpancover() {
  mkdir -p "$results_dir"
  local cpancover=cpancover
  if [[ $(pwd) != /dc ]]; then
    local root=
    [[ -d /dc ]] && root=/dc/
    PATH="./utils:./bin:$PATH"
    perl Makefile.PL && make
    cpancover="perl -Mblib=$root ${root}bin/cpancover --local"
  fi
  ((verbose)) && cpancover="$cpancover --verbose"
  ((force)) && cpancover="$cpancover --force"
  ((dryrun)) && cpancover="$cpancover --dryrun"
  local cmd
  cmd="$cpancover --env $env --results_dir $results_dir"
  cmd="$cmd --workers $(nice_cpus) $*"
  ((verbose)) && pi "$cmd"
  $cmd || true
}

recipe_cpancover() {
  run_cpancover "${args[@]:-}"
}

cpancover_docker_ps() {
  local name="${docker_image//[^a-zA-Z0-9_.]/-}"
  $docker ps -a | tail -n +2 | grep "$name-" | grep -vw "$(hostname)"
}

recipe_cpancover-docker-ps() {
  cpancover_docker_ps
}

cpancover_docker_ps_ids() {
  cpancover_docker_ps | awk '{ print $1 }' || true
}

recipe_cpancover-docker-kill() {
  cpancover_docker_ps_ids | xargs -r "$docker" kill
}

cpancover_docker_rm() {
  cpancover_docker_ps_ids | xargs -r "$docker" rm -f
  $docker system prune --force
}

recipe_cpancover-docker-rm() {
  cpancover_docker_rm
}

recipe_cpancover-docker-rm-image() {
  $docker ps -q --filter ancestor="$docker_image" | xargs -r "$docker" stop
  $docker ps -aq --filter ancestor="$docker_image" | xargs -r "$docker" rm
  $docker rmi "$docker_image"
}

recipe_docker-build() {
  local build="$srcdir/../docker/BUILD"
  "$build" -e "$env" "${args[@]:+${args[@]}}"
}

# docker push authenticates via `docker login` credentials stored in
# ~/.docker/config.json. The Docker Hub *web API* (hub.docker.com/v2),
# used for listing and deleting tags, has its own auth — POST username +
# PAT to /v2/users/login to obtain a JWT. The CLI credentials cannot be
# reused for this.
#
# HUB_USERNAME: your Docker Hub username
# HUB_TOKEN:    a Personal Access Token created at
#               https://hub.docker.com/settings/security
#               (needs Read & Write & Delete permissions)

Docker_hub_repo="pjcj/cpancover"

# Authenticate with Docker Hub and print a JWT.
docker_hub_auth() {
  if [[ -z "${HUB_USERNAME:-}" || -z "${HUB_TOKEN:-}" ]]; then
    pf "HUB_USERNAME and HUB_TOKEN environment variables are required"
  fi

  local token
  token=$(curl -sf "https://hub.docker.com/v2/users/login" \
    -H "Content-Type: application/json" \
    -d "{\"username\":\"$HUB_USERNAME\",\"password\":\"$HUB_TOKEN\"}" |
    perl -MJSON::PP -e 'print decode_json(do { local $/; <STDIN> })->{token}')

  if [[ -z "$token" ]]; then
    pf "Failed to authenticate with Docker Hub"
  fi

  echo "$token"
}

# Fetch versioned tags from Docker Hub into the Hub_tags array (sorted
# newest-first). Sets Hub_token for callers that need it afterwards.
docker_hub_fetch_tags() {
  Hub_token=$(docker_hub_auth)
  Hub_tags=()

  local base="https://hub.docker.com/v2/repositories"
  local url="$base/$Docker_hub_repo/tags?page_size=100"
  while [[ -n "$url" && "$url" != "null" ]]; do
    local response
    response=$(curl -sf "$url" -H "Authorization: Bearer $Hub_token")
    local parsed
    parsed=$(echo "$response" | perl -MJSON::PP -e '
      my $d = decode_json(do { local $/; <STDIN> });
      for (@{$d->{results}}) {
        my $n = $_->{name};
        print "$n\n" if $n =~ /^\d{4}-\d{2}-\d{2}-\d{4}-/;
      }
      print "---NEXT---\n";
      print $d->{next} // "null", "\n";
    ')
    while IFS= read -r t; do
      [[ "$t" == "---NEXT---" ]] && break
      [[ -n "$t" ]] && Hub_tags+=("$t")
    done <<<"$parsed"
    url=$(tail -1 <<<"$parsed")
  done

  # Sort newest-first (the tag format is naturally sortable)
  mapfile -t Hub_tags < <(printf '%s\n' "${Hub_tags[@]}" | sort -r)
}

docker_show_tags() {
  docker_hub_fetch_tags

  pi "Found ${#Hub_tags[@]} versioned tags for $Docker_hub_repo:"
  for t in "${Hub_tags[@]}"; do
    pi "  $t"
  done
}

recipe_docker-show-tags() {
  docker_show_tags
}

docker_prune_tags() {
  local keep="${1:-20}"

  docker_show_tags

  if ((${#Hub_tags[@]} <= keep)); then
    pi "Nothing to prune (${#Hub_tags[@]} tags <= $keep)"
    return
  fi

  local to_delete=("${Hub_tags[@]:$keep}")

  pi "Deleting ${#to_delete[@]} old tags (keeping $keep)"

  for tag in "${to_delete[@]}"; do
    if ((dryrun)); then
      pi "[dry run] would delete $Docker_hub_repo:$tag"
    else
      if curl -sf -X DELETE \
        "https://hub.docker.com/v2/repositories/$Docker_hub_repo/tags/$tag/" \
        -H "Authorization: Bearer $Hub_token" >/dev/null; then
        pi "Deleted $Docker_hub_repo:$tag"
      else
        pe "Failed to delete $Docker_hub_repo:$tag"
      fi
    fi
  done

  pi "Done"
}

recipe_docker-prune-tags() {
  docker_prune_tags "${args[@]:-}"
}

cpancover_latest() {
  if [[ -n "${CPANCOVER_TEST_MODULES:-}" ]]; then
    echo "$CPANCOVER_TEST_MODULES"
  elif [[ -n "${CPANCOVER_TEST_REGEX:-}" ]]; then
    run_cpancover --latest | grep -E "$CPANCOVER_TEST_REGEX"
  else
    run_cpancover --latest
  fi
}

recipe_cpancover-latest() {
  cpancover_latest
}

recipe_cpancover-build-module() {
  local module="${1:?No module specified}"
  local v=
  ((verbose)) && v=--verbose
  run_cpancover "$v" --local_build --docker "$docker" --workers 1 "$module"
}

docker_name() {
  local name="${1:?No name specified}"
  name="$docker_image-$name-$(date +%s+%N)"
  echo "${name//[^a-zA-Z0-9_.]/-}"
}

recipe_docker-name() {
  docker_name "${args[@]:-}"
}

cpancover_controller_command() {
  local name="${1:?No name specified}"
  shift
  local cmd=("$@")
  mkdir -p "$results_dir"
  local sock=/var/run/docker.sock
  local tty_flag=""
  [[ -t 0 ]] && tty_flag="-t"
  local env_flags=()
  while IFS='=' read -r var value; do
    env_flags+=(--env "$var=$value")
  done < <(env | grep -E '^(CPANCOVER_|DEVEL_COVER_)' || true)
  "$docker" run -i ${tty_flag:+"$tty_flag"} \
    --mount "type=bind,source=$sock,target=$sock" \
    --mount "type=bind,source=$results_dir,target=/remote_staging" \
    ${env_flags[@]:+"${env_flags[@]}"} \
    --rm=false --memory=1g --name="$(docker_name "$name")" \
    "$docker_image" "${cmd[@]}"
}

recipe_cpancover-controller-shell() {
  cpancover_controller_command controller_bash "/bin/bash"
}

recipe_cpancover-docker-shell() {
  local staging="${1:-$results_dir}"
  mkdir -p "$staging"
  $docker run -it \
    --mount type=bind,source="$staging",target=/remote_staging \
    --rm=false --memory=1g --name="$(docker_name docker)" \
    "$docker_image" /bin/bash
}

# Called from Collection.pm
recipe_cpancover-docker-module() {
  local module="${1:?No module specified}"
  local name="${2:?No name specified}"
  local staging="${3:-$results_dir}"
  local module_timeout="${CPANCOVER_TIMEOUT:-1800}" # half an hour

  local log_name="$name"
  name=$(docker_name "$name")
  mkdir -p "$staging"
  ((verbose)) && pi "module: $module"
  local v=
  ((verbose)) && v=--verbose
  container=$($docker run -d \
    --rm=false --memory=1g --name="$name" \
    "$docker_image" \
    dc $v cpancover-build-module "$module")
  ((verbose)) && pi "container is $container"
  if timeout "$module_timeout" "$docker" wait "$name" >/dev/null 2>&1; then
    $docker logs "$name" | tee "$staging/$log_name.out"
    local_staging="$staging/$name"
    mkdir -p "$local_staging"
    $docker cp "$name:/root/cover/staging" "$local_staging"
    if [[ -d $local_staging ]]; then
      chmod -R 755 "$local_staging"
      find "$local_staging" -type f -exec chmod 644 {} \;
      chown -R "$(id -un):$(id -gn)" "$local_staging"
      cd "$local_staging"/* || exit
      for f in *; do
        if [[ -d $f && ! -d "$staging/$f" ]]; then
          echo "$log_name.out.gz" >"$f/.log_ref"
          mv "$f" "$staging"
        fi
      done
      rm -r "$local_staging"
    fi
  else
    pi "module timed out after ${module_timeout}s, killing container: $module"
    $docker logs "$name" | tee "$staging/$log_name.out" 2>/dev/null || true
    $docker kill "$name" >/dev/null 2>&1 || true
  fi
  $docker rm "$name" >/dev/null 2>&1 || true
}

cpancover_compress_dir() {
  local dir="$1" do_sidecars="${2:-}" prefix="${3:-}"
  shift 3 2>/dev/null || shift $#
  local exclude=("$@")

  rm -rf "$dir/runs" "$dir/structure"
  rm -f "$dir"/digests "$dir"/digests.gz "$dir"/cover.[0-9]* \
    "$dir"/cover.[0-9]*.gz "$dir"/*.lock "$dir"/*.lock.gz

  local find_args=("$dir" -maxdepth 1
    -type f -not -name '*.gz' -not -name '*.xz' -not -name '*.json'
    -not -name 'virtual_unzipped' -not -name '.log_ref')
  local name
  for name in "${exclude[@]}"; do
    find_args+=(-not -name "$name")
  done
  if find "${find_args[@]}" -print -quit | read -r; then
    pi "${prefix}compressing"
    find "${find_args[@]}" -print0 | xargs -0 pigz -f9
  fi

  [[ -n "$do_sidecars" ]] || return 0

  local marker="$dir/virtual_unzipped" created=false gzfile basefile
  for gzfile in "$dir"/*.gz; do
    [[ -f "$gzfile" ]] || continue
    [[ -f "$marker" ]] || touch "$marker"
    basefile="${gzfile%.gz}"
    if [[ ! -e "$basefile" ]]; then
      ln "$marker" "$basefile"
      created=true
    fi
  done
  if [[ "$created" == true ]]; then
    pi "${prefix}created sidecars"
  fi
}

cpancover_compress_dirs() {
  local only_new="$1"
  local max_jobs="${CPANCOVER_COMPRESS_JOBS:-$(nice_cpus)}"
  local jobs=0 dir name prefix

  rm -f "$results_dir"/about.html.gz "$results_dir"/index.html.gz \
    "$results_dir"/collection.css.gz "$results_dir"/cpancover.json.gz
  cpancover_compress_dir "$results_dir" "" "top-level: " \
    about.html index.html collection.css

  # dist/ contains generated HTML that must be served uncompressed;
  # delete stale .gz files so Caddy serves the fresh versions.
  rm -f "$results_dir"/dist/*.gz

  for dir in "$results_dir"/*/; do
    [[ -d "$dir" ]] || continue
    name="$(basename "$dir")"
    [[ "$name" == __failed__ || "$name" == dist ]] && continue
    [[ "$only_new" == true && -f "$dir/virtual_unzipped" ]] && continue
    printf -v prefix "%-40.40s " "$name"
    cpancover_compress_dir "$dir" sidecars "$prefix" &
    ((++jobs >= max_jobs)) && {
      wait -n
      ((jobs--))
    }
  done
  wait
}

cpancover_compress() {
  pi "compressing $results_dir"
  cpancover_compress_dirs false
}

cpancover_compress_new() {
  pi "compressing new directories in $results_dir"
  cpancover_compress_dirs true
}

recipe_cpancover-compress() {
  cpancover_compress
}

recipe_cpancover-compress-new() {
  cpancover_compress_new
}

cpancover_serve() {
  local www_dir="$results_dir/../www_${env}"
  local latest="$www_dir/latest"
  mkdir -p "$www_dir"
  if [[ ! -L "$latest" ]]; then
    ln -s "$results_dir" "$latest"
  fi
  local port="${1:-8080}"
  local caddyfile
  caddyfile="$(mktemp)"
  cat >"$caddyfile" <<EOF
:${port} {
  root * $www_dir
  redir / /latest/ 301
  file_server {
    precompressed gzip
  }
}
EOF
  trap 'rm -f "$caddyfile"; cleanup' EXIT
  pi "serving $results_dir at http://localhost:$port/latest/"
  caddy run --adapter caddyfile --config "$caddyfile"
}

recipe_cpancover-serve() {
  cpancover_serve "${args[@]:+${args[@]}}"
}

cpancover_caddyfile() {
  local www_dir="$results_dir/../www"
  cat <<EOF
cpancover.com, www.cpancover.com {
  root * $www_dir
  redir / /latest/ 301
  encode zstd gzip
  file_server {
    precompressed gzip
  }
  tls paul@pjcj.net
  log {
    output file /var/log/caddy/cpancover.com.log
    format console
  }
}
EOF
}

cpancover_validated_caddyfile() {
  local tmpfile
  tmpfile="$(mktemp)"
  cpancover_caddyfile >"$tmpfile"
  if ! caddy validate --adapter caddyfile --config "$tmpfile"; then
    rm -f "$tmpfile"
    pf "Caddyfile validation failed"
  fi
  echo "$tmpfile"
}

cpancover_configure_caddy() {
  local caddyfile="/etc/caddy/Caddyfile"
  local tmpfile
  tmpfile="$(cpancover_validated_caddyfile)"
  trap 'rm -f "$tmpfile"' RETURN
  if diff -q "$caddyfile" "$tmpfile" >/dev/null 2>&1; then
    pi "Caddyfile is already up to date"
    return
  fi
  sudo install -m 644 -o root -g root "$tmpfile" "$caddyfile"
  sudo systemctl reload caddy
  pi "Caddyfile updated and Caddy reloaded"
}

recipe_cpancover-configure-caddy() {
  cpancover_configure_caddy
}

cpancover_check_caddy() {
  local caddyfile="/etc/caddy/Caddyfile"
  local tmpfile
  tmpfile="$(cpancover_validated_caddyfile)"
  trap 'rm -f "$tmpfile"' RETURN
  pi "Generated config validates OK"
  local differ="diff -u"
  command -v delta >/dev/null && differ="delta"
  if diff -q "$caddyfile" "$tmpfile" >/dev/null 2>&1; then
    pi "Caddyfile is up to date"
  else
    pw "Caddyfile differs from generated config:"
    $differ "$caddyfile" "$tmpfile"
  fi
}

recipe_cpancover-check-caddy() {
  cpancover_check_caddy
}

recipe_cpancover-uncompress-dir() {
  subdir="${1:?No subdir specified}"
  find "$results_dir/$subdir/" -name __failed__ -prune -o \
    -type f -name '*.gz' \
    -exec gzip -df {} \;
}

cpancover_compress_old_versions() {
  keep="${1:-3}"
  run_cpancover --nobuild --compress_old_versions "$keep"
}

recipe_cpancover-compress-old-versions() {
  cpancover_compress_old_versions "${args[@]:-}"
}

cpancover_generate_html() {
  pi "Generating HTML at $(date)"
  cpancover_compress_old_versions
  run_cpancover --generate_html
  cpancover_compress_new
  local json=$results_dir/cpancover.json
  local tmp=$json-tmp-$$.gz
  pi "Compressing $json"
  pigz <"$json" >"$tmp" && mv "$tmp" "$json.gz"
  pi "Done"
}

recipe_cpancover-generate-html() {
  cpancover_generate_html
}

cpancover_build() {
  pi "Starting cpancover build at $(date) on $(nice_cpus) cpus"
  cpancover_docker_rm
  run_cpancover --build
  cpancover_generate_html
  pi "Finished cpancover build at $(date)"
}

cpancover_run_once() {
  cpancover_latest | cpancover_build
}

recipe_cpancover-build-stdin() {
  cpancover_build
}

cpancover_run_loop() {
  while true; do
    cpancover_run_once
    sleep 600 # 10 minutes
  done
}

recipe_cpancover-run-once() {
  cpancover_run_once
}

recipe_cpancover-run-loop() {
  cpancover_run_loop
}

recipe_cpancover-controller-run() {
  local o=(--env "$env")
  ((verbose)) && o+=("--verbose")
  cpancover_controller_command controller dc "${o[@]}" cpancover-run-loop
}

recipe_cpancover-controller-run-once() {
  local o=(--env "$env")
  ((verbose)) && o+=("--verbose")
  cpancover_controller_command controller dc "${o[@]}" cpancover-run-once
}

# Default test module - small, stable, predictable
cpancover_default_test_module="P/PJ/PJCJ/Perl-Critic-PJCJ-v0.2.4.tar.gz"

recipe_cpancover-test() {
  : "${CPANCOVER_TEST_MODULES:=$cpancover_default_test_module}"
  export CPANCOVER_TEST_MODULES
  pi "Testing with modules: $CPANCOVER_TEST_MODULES"
  cpancover_run_once
}

recipe_cpancover-controller-test() {
  : "${CPANCOVER_TEST_MODULES:=$cpancover_default_test_module}"
  pi "Testing with modules: $CPANCOVER_TEST_MODULES"
  local o=(--env "$env")
  ((verbose)) && o+=("--verbose")
  echo "$CPANCOVER_TEST_MODULES" |
    cpancover_controller_command controller dc "${o[@]}" cpancover-build-stdin
}

recipe_cpancover-start-queue() {
  COVER_DEBUG=1 perl bin/queue minion worker -j 4
}

recipe_cpancover-start-minion() {
  COVER_DEBUG=1 perl bin/queue daemon -l http://\*:30000 -m production
}

recipe_cpancover-add() {
  module="${1:?No module specified}"
  COVER_DEBUG=1 perl bin/queue add "$module"
}

generate_gcov_fixtures() {
  if ! command -v gcc >/dev/null; then
    pf "gcc is required to generate gcov fixtures"
  fi
  if ! command -v gcov >/dev/null; then
    pf "gcov is required to generate gcov fixtures"
  fi

  local fixtures_dir="t/fixtures/gcov2perl"
  pi "Generating gcov fixtures in $fixtures_dir"
  pushd "$fixtures_dir" >/dev/null

  for fixture in *.c.fixture; do
    [[ -f "$fixture" ]] || continue
    local src="${fixture%.fixture}"
    local base="${src%.c}"
    pi "Processing $fixture"

    # gcc needs a .c file
    cp "$fixture" "$src"

    # Compile with gcov instrumentation
    gcc -fprofile-arcs -ftest-coverage -o "$base" "$src"

    # Run to generate .gcda coverage data
    ./"$base" || true # Allow non-zero exit for branch coverage

    # Generate .gcov file
    # On macOS/clang, gcno/gcda files are named <output>-<source>.gc*
    # so we call gcov with the object prefix, not the source file
    if [[ -f "$base-$base.gcno" ]]; then
      gcov "$base-$base" >/dev/null
    else
      gcov "$src" >/dev/null
    fi

    # Rename to .fixture so cover's *.gcov scan won't find it
    mv "$src.gcov" "$src.gcov.fixture"

    # Clean up intermediates, keep only .c.fixture and .gcov.fixture
    rm -f "$src" "$base" "$base.gcno" "$base.gcda" \
      "$base-$base.gcno" "$base-$base.gcda"

    pi "Generated $src.gcov.fixture"
  done

  popd >/dev/null
}

recipe_generate-gcov-fixtures() {
  generate_gcov_fixtures
}

run_recipe() {
  recipe="recipe_$recipe"
  shift
  if declare -F "$recipe" >/dev/null 2>&1; then
    "$recipe" "${args[@]:-}"
  else
    pf "Unknown recipe: $recipe"
  fi
}

main() {
  setup "$@"
  ((verbose)) && pi "Running $recipe ${args[*]:-}"
  [[ ${recipe:-} == "" ]] && pf "Missing recipe"
  run_recipe "${args[@]:-}"
}

if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
  trap cleanup EXIT INT
  main "$@"
fi