#!/usr/bin/env raku

use v6.d;
use lib 'lib';
use BDD::Behave::Runner;
use BDD::Behave::SpecRegistry;
use BDD::Behave::Files;
use BDD::Behave::Bisect;
use BDD::Behave::Formatter::Registry;
use BDD::Behave::Configuration;
use BDD::Behave::DocExtractor;
use BDD::Behave::Coverage;
use BDD::Behave::FailureStore;
use BDD::Behave::SpecLoader;

constant CLI-CONFIG = BDD::Behave::Configuration::Configuration;

my @specs;
my $bisect      = False;
my $bisect-data = False;
my $config-path-arg;
my $no-config         = False;
my $no-user-config    = False;
my $no-project-config = False;
my $doc-mode    = False;
my $doc-format  = 'markdown';
my $doc-output;
my $parallel-workers = 0;
my $worker-manifest-path;
my $queue-worker-mode = False;
my $watch-mode  = False;
my @watch-paths;
my $dry-run-mode    = False;
my $list-examples   = False;
my $list-format     = 'text';
my $cli-config = CLI-CONFIG.new;

sub usage() {
  say qq:to/HERE/;
  Usage: behave [options...] [specs/spec_file.raku[:LINE]]
    Runs behave in the local `specs` directory or against a list of spec files
    provided on the command line. A positional argument of the form
    FILE:LINE is shorthand for "load FILE, then run only the example at
    LINE" (equivalent to passing FILE plus --only-example FILE:LINE);
    repeatable, so `behave spec.raku:42 spec.raku:88` runs both examples.

  Options:
    --help                   Displays how to run behave.
    --verbose                Display verbose output during the specs execution.
    --tag NAME               Run only examples tagged NAME (repeatable; OR semantics).
    --exclude-tag NAME       Skip examples tagged NAME (repeatable).
    --example PATTERN        Run only examples whose full nested description
                             matches PATTERN (substring; or /regex/ when
                             wrapped in slashes). Repeatable; OR semantics.
    -e PATTERN               Alias for --example.
    --aggregate-failures     Wrap each example in aggregate-failures
                             semantics (no label).
    --aggregate-failures=LABEL
                             Same as above but use LABEL as the default
                             aggregation label. Per-example or per-group
                             :aggregate-failures metadata overrides this.
    --order ORDER            Example execution order: 'random' (default) or
                             'defined' (declaration order). Random order
                             shuffles children of every group and the suite.
    --seed N                 Seed for the random-order RNG. Ignored when
                             --order=defined. Auto-generated when omitted
                             and --order=random; the seed is printed at the
                             end of the run for reproduction.
    --fail-fast              Stop after the first failed example.
    --fail-fast=N            Stop after N failed examples (N must be >= 1).
    --retry N                Retry failing examples up to N additional times
                             (total of N+1 attempts). N must be a non-negative
                             integer. Per-example `:retry(M)` metadata
                             overrides this default.
    --only-failures          Run only examples that failed in the previous run.
                             Reads FILE:LINE list from .behave-failures (or
                             --failures-path PATH). Combines with other filters
                             via AND. If the file is missing or empty, all
                             examples run.
    --failures-path=PATH     Path used to persist (and read, with
                             --only-failures) the list of failed examples.
                             Defaults to './.behave-failures'.
    --only-example LOC       Run only the example whose file:line matches LOC
                             (repeatable; OR semantics). LOC is FILE:LINE
                             (FILE may be absolute, relative, or basename).
    --bisect                 Bisect mode: find the minimal set of examples
                             that, when run in declared order before each
                             failing example, still reproduces the failure.
                             Uses subprocesses so user-code state cannot
                             leak across iterations.
    --bisect-data            Machine-readable output for use by --bisect.
                             Suppresses normal output; emits one
                             'behave-executed: FILE:LINE' line per executed
                             example and one 'behave-failed: FILE:LINE' line
                             per failed example.
    --profile                Print the 10 slowest examples after the run.
    --profile=N              Print the N slowest examples after the run.
                             N must be a positive integer.
    --slow-threshold=SECONDS Print an inline 'SLOW' warning for any example
                             whose body takes at least SECONDS seconds.
                             SECONDS may be fractional (e.g. 0.075).
    --memory-profile         Track per-example RSS deltas and print the 10
                             memory-heaviest examples after the run.
    --memory-profile=N       Same as above but show the top N. N must be a
                             positive integer.
    --memory-threshold=KB    Print an inline 'MEMORY' warning for any
                             example whose RSS delta meets or exceeds KB
                             kilobytes. Enables measurement on its own.
    --benchmark              Enable benchmark mode: aggregate per-example
                             `benchmark \{ ... \}` results into a Benchmarks
                             section at the end of the run.
    --benchmark-iterations=N Re-run each benchmarked example N times to
                             collect more samples. N must be a positive
                             integer (default 1, no re-runs).
    --benchmark-baseline=PATH
                             Compare current benchmark medians against
                             PATH (a baseline file produced by
                             --benchmark-save). Implies --benchmark.
    --benchmark-save=PATH    Save current benchmark medians to PATH.
                             Implies --benchmark.
    --benchmark-threshold=PCT
                             Regression threshold as a decimal fraction
                             (default 0.10 = 10%). A current median is
                             flagged when it exceeds baseline by more
                             than PCT.
    --benchmark-format=FORMAT
                             Output format for the Benchmarks section.
                             FORMAT is 'text' (default; pretty table
                             with comparison arrows) or 'json' (single
                             JSON object for CI dashboards).
    --benchmark-output=PATH  Write the Benchmarks section to PATH
                             instead of stdout. Useful with --benchmark-
                             format=json for CI ingestion.
    --format NAME            Output formatter to use for the run. NAME is
                             a registered formatter (default: 'progress').
                             Available: {BDD::Behave::Formatter::Registry.names.join(', ')}.
    --config PATH            Load Raku-based config from PATH. Skips the
                             default ~/.behave and ./.behave lookups.
    --no-config              Skip all config files (both user and project).
    --no-user-config         Skip ~/.behave but still read ./.behave.
    --no-project-config      Skip ./.behave but still read ~/.behave.
    --doc                    Documentation mode: load specs but skip
                             execution, then emit a hierarchical document
                             of describe/context/it descriptions to stdout
                             (or to --doc-output PATH). Honors --tag,
                             --exclude-tag, and --example.
    --doc-format=FORMAT      Output format for --doc. FORMAT is 'markdown'
                             (default), 'html', or 'json'.
    --doc-output=PATH        Write --doc output to PATH instead of stdout.
    --coverage               Enable line-level code coverage tracking for the
                             run. Re-executes specs as a subprocess with
                             MoarVM coverage logging enabled, then prints
                             a coverage report at the end. Defaults to
                             tracking files under lib/.
    --coverage-minimum=PCT   Fail the run when overall line coverage is
                             below PCT (a number 0..100). Implies --coverage.
    --coverage-include=PATH  Restrict coverage to source files whose path
                             starts with PATH (repeatable; OR semantics).
                             Defaults to 'lib/' when no includes are given,
                             which matches user code loaded via -Ilib but
                             excludes Rakudo/NQP internals that show up as
                             absolute paths in the coverage log.
                             Implies --coverage.
    --coverage-exclude=PATH  Exclude source files whose path contains PATH
                             from the coverage report (repeatable;
                             substring match). Implies --coverage.
    --coverage-format=FORMAT Format for the coverage report.
                             FORMAT is 'html' (default), 'text', 'json',
                             'lcov', or 'cobertura'. Implies --coverage.
    --coverage-output=PATH   Write the coverage report to PATH instead of
                             stdout. For --coverage-format=html, PATH is a
                             directory (default: ./coverage/) and Behave
                             writes index.html, style.css, and one HTML
                             page per source file inside it.
                             Implies --coverage.
    --coverage-baseline=PATH Compare current line coverage against the JSON
                             baseline at PATH (produced by a previous
                             --coverage --coverage-format=json run) and
                             print a per-file diff. Implies --coverage.
    --coverage-branch        Also track branch coverage (if/unless/given/
                             when/while/until). Reported alongside line
                             coverage. Implies --coverage.
    --parallel N             Run specs across N worker subprocesses
                             (group-affinity LPT distribution). N must be
                             a positive integer. Default (omitted) keeps
                             single-process serial execution. Mutually
                             exclusive with --bisect / --bisect-data /
                             --coverage. Ignored under --doc.
    --progress-total         Append a running ` (N/TOTAL)` counter after
                             each example char emitted by the `progress`
                             formatter under --parallel. TOTAL is the
                             example count discovered after applying tag /
                             example / location filters. No-op without
                             --parallel.
    --seed-mode MODE         How --seed combines with --parallel N.
                             'xor' (default): each worker uses
                             (parent-seed XOR worker-index); LPT bucket
                             assignment depends on N, so the same seed
                             reproduces only when N matches.
                             'stable': bucket → worker assignment is a
                             deterministic hash of (file, group-path,
                             seed) mod N, and the global hash-sorted
                             order is identical regardless of N. Forces
                             --order=defined per worker.
    --parallel-mode MODE     Bucket distribution strategy under
                             --parallel N. 'lpt' (default): static
                             longest-processing-time-first assignment
                             based on example count. 'queue': workers
                             dynamically pull buckets from a parent-
                             managed queue, useful when bucket
                             runtimes are wildly uneven and example
                             count is a poor cost proxy. Queue mode
                             forces --order=defined per worker; the
                             dispatch order is cost-desc.
    --parallel-retry N       When a worker subprocess crashes (exits
                             with code > 1: signal, OOM, uncaught
                             exception in the runner itself), re-spawn
                             it with the same manifest up to N
                             additional times. Default 0. Per-example
                             flake retries (--retry) compose inside
                             each worker. Only effective under
                             --parallel-mode=lpt; queue mode treats a
                             worker crash as a hard failure.
    --watch                  Watch source and spec files; re-run affected
                             specs whenever a file changes. Reads
                             interactive commands from stdin:
                               r/<Enter>  rerun last selection
                               a          rerun all specs
                               f          rerun only previously-failed
                                          examples (uses .behave-failures)
                               q          quit
                             Mutually exclusive with --bisect /
                             --bisect-data / --coverage / --doc / --parallel.
    --watch-path PATH        Add PATH to the watched roots (repeatable).
                             Defaults to ./lib and ./specs when omitted.
    --dry-run                Load specs but skip execution. Prints the
                             hierarchical list of examples that would run
                             (honoring --tag, --exclude-tag, --example,
                             --only-example, focus mode, and skipped
                             examples) followed by an "N example(s)" count.
                             Pair with --verbose to also print each
                             example's file:line and effective tags.
                             Exit 0 when at least one spec loaded; 1
                             only on load errors.
    --list-examples          Emit the metadata query result for editor
                             integrations (run-this-test,
                             jump-to-failure, etc). Defaults to plain
                             text "FILE:LINE  full description" lines.
                             Honors --tag, --exclude-tag, --example,
                             and --only-example.
    --list-examples-format=FORMAT
                             Output format for --list-examples. FORMAT
                             is 'text' (default) or 'json'.

  Tags inherit from enclosing describe/context blocks. --exclude-tag wins over
  --tag when both match the same example. --tag, --exclude-tag, --example,
  --only-example, and focused examples combine with AND semantics.

  Configuration precedence (highest wins):
    CLI flags > ./.behave (project) > ~/.behave (user) > built-in defaults.
  HERE
}

sub split-eq(Str $arg) {
  my $eq = $arg.index('=');
  $eq.defined ?? ($arg.substr(0, $eq), $arg.substr($eq + 1)) !! ($arg, Str);
}

my %keyword-line-cache;

sub scan-keyword-lines(IO::Path $file --> List) {
  my $key = $file.absolute;
  return %keyword-line-cache{$key}.list if %keyword-line-cache{$key}:exists;
  my @lines;
  my $rx = / ^ \h* [
    'describe' | 'context' | 'fdescribe' | 'fcontext' | 'xdescribe' | 'xcontext'
    | 'it' | 'fit' | 'xit' | 'pending'
  ] <[\s'"(]> /;
  for $file.lines.kv -> $idx, $line {
    @lines.push: $idx + 1 if $line ~~ $rx;
  }
  %keyword-line-cache{$key} = @lines.List;
  @lines.list;
}

sub maybe-snap-location(Str $loc --> Str) {
  return $loc unless $loc ~~ / ^ (.+) ':' (\d+) $ /;
  my $file-part = ~$0;
  my $line-part = (~$1).Int;
  return $loc unless $file-part.IO.e;
  my @keyword-lines = scan-keyword-lines($file-part.IO);
  return $loc if @keyword-lines.first(* == $line-part).defined;
  my @candidates = @keyword-lines.grep(* <= $line-part);
  return $loc unless @candidates.elems;
  "$file-part:{@candidates.max}";
}

my @args = @*ARGS;
while @args {
  my $arg = @args.shift;

  if $arg eq '--help' {
    usage;
    exit;
  } elsif $arg eq '--verbose' {
    $cli-config.verbose = True;
  } elsif $arg eq '--tag' {
    die "--tag requires a value" unless @args;
    $cli-config.include-tag(@args.shift);
  } elsif $arg.starts-with('--tag=') {
    $cli-config.include-tag($arg.substr('--tag='.chars));
  } elsif $arg eq '--exclude-tag' {
    die "--exclude-tag requires a value" unless @args;
    $cli-config.exclude-tag(@args.shift);
  } elsif $arg.starts-with('--exclude-tag=') {
    $cli-config.exclude-tag($arg.substr('--exclude-tag='.chars));
  } elsif $arg eq '--example' | '-e' {
    die "$arg requires a value" unless @args;
    $cli-config.example-pattern(@args.shift);
  } elsif $arg.starts-with('--example=') {
    $cli-config.example-pattern($arg.substr('--example='.chars));
  } elsif $arg.starts-with('-e=') {
    $cli-config.example-pattern($arg.substr('-e='.chars));
  } elsif $arg eq '--aggregate-failures' {
    $cli-config.aggregate-failures = True;
  } elsif $arg.starts-with('--aggregate-failures=') {
    $cli-config.aggregate-failures = $arg.substr('--aggregate-failures='.chars);
  } elsif $arg eq '--order' {
    die "--order requires a value" unless @args;
    $cli-config.order = @args.shift;
  } elsif $arg.starts-with('--order=') {
    $cli-config.order = $arg.substr('--order='.chars);
  } elsif $arg eq '--seed' {
    die "--seed requires a value" unless @args;
    $cli-config.seed = @args.shift.Int;
  } elsif $arg.starts-with('--seed=') {
    $cli-config.seed = $arg.substr('--seed='.chars).Int;
  } elsif $arg eq '--seed-mode' {
    die "--seed-mode requires a value" unless @args;
    $cli-config.seed-mode = @args.shift;
  } elsif $arg.starts-with('--seed-mode=') {
    $cli-config.seed-mode = $arg.substr('--seed-mode='.chars);
  } elsif $arg eq '--progress-total' {
    $cli-config.progress-total = True;
  } elsif $arg eq '--no-progress-total' {
    $cli-config.progress-total = False;
  } elsif $arg eq '--fail-fast' {
    $cli-config.fail-fast = 1;
  } elsif $arg.starts-with('--fail-fast=') {
    my $value = $arg.substr('--fail-fast='.chars);
    unless $value ~~ /^ \d+ $/ && $value.Int >= 1 {
      note "Error: --fail-fast=N requires a positive integer (got: '$value')";
      exit 2;
    }
    $cli-config.fail-fast = $value.Int;
  } elsif $arg eq '--retry' {
    die "--retry requires a value" unless @args;
    my $value = @args.shift;
    unless $value ~~ /^ \d+ $/ {
      note "Error: --retry requires a non-negative integer (got: '$value')";
      exit 2;
    }
    $cli-config.retry = $value.Int;
  } elsif $arg.starts-with('--retry=') {
    my $value = $arg.substr('--retry='.chars);
    unless $value ~~ /^ \d+ $/ {
      note "Error: --retry=N requires a non-negative integer (got: '$value')";
      exit 2;
    }
    $cli-config.retry = $value.Int;
  } elsif $arg eq '--only-failures' {
    $cli-config.only-failures = True;
  } elsif $arg eq '--failures-path' {
    die "--failures-path requires a value" unless @args;
    $cli-config.failures-path = @args.shift.IO;
  } elsif $arg.starts-with('--failures-path=') {
    $cli-config.failures-path = $arg.substr('--failures-path='.chars).IO;
  } elsif $arg eq '--only-example' {
    die "--only-example requires a value" unless @args;
    $cli-config.only-location(maybe-snap-location(@args.shift));
  } elsif $arg.starts-with('--only-example=') {
    $cli-config.only-location(maybe-snap-location($arg.substr('--only-example='.chars)));
  } elsif $arg eq '--bisect' {
    $bisect = True;
  } elsif $arg eq '--bisect-data' {
    $bisect-data = True;
  } elsif $arg eq '--profile' {
    $cli-config.profile-limit = 10;
  } elsif $arg.starts-with('--profile=') {
    my $value = $arg.substr('--profile='.chars);
    unless $value ~~ /^ \d+ $/ && $value.Int >= 1 {
      note "Error: --profile=N requires a positive integer (got: '$value')";
      exit 2;
    }
    $cli-config.profile-limit = $value.Int;
  } elsif $arg eq '--slow-threshold' {
    die "--slow-threshold requires a value" unless @args;
    my $value = @args.shift;
    unless $value ~~ /^ \d+ [ '.' \d+ ]? $/ && $value.Num > 0 {
      note "Error: --slow-threshold requires a positive number (got: '$value')";
      exit 2;
    }
    $cli-config.slow-threshold = $value.Real;
  } elsif $arg.starts-with('--slow-threshold=') {
    my $value = $arg.substr('--slow-threshold='.chars);
    unless $value ~~ /^ \d+ [ '.' \d+ ]? $/ && $value.Num > 0 {
      note "Error: --slow-threshold requires a positive number (got: '$value')";
      exit 2;
    }
    $cli-config.slow-threshold = $value.Real;
  } elsif $arg eq '--memory-profile' {
    $cli-config.memory-profile-limit = 10;
  } elsif $arg.starts-with('--memory-profile=') {
    my $value = $arg.substr('--memory-profile='.chars);
    unless $value ~~ /^ \d+ $/ && $value.Int >= 1 {
      note "Error: --memory-profile=N requires a positive integer (got: '$value')";
      exit 2;
    }
    $cli-config.memory-profile-limit = $value.Int;
  } elsif $arg eq '--memory-threshold' {
    die "--memory-threshold requires a value" unless @args;
    my $value = @args.shift;
    unless $value ~~ /^ \d+ $/ && $value.Int >= 1 {
      note "Error: --memory-threshold requires a positive integer (got: '$value')";
      exit 2;
    }
    $cli-config.memory-threshold = $value.Int;
  } elsif $arg.starts-with('--memory-threshold=') {
    my $value = $arg.substr('--memory-threshold='.chars);
    unless $value ~~ /^ \d+ $/ && $value.Int >= 1 {
      note "Error: --memory-threshold requires a positive integer (got: '$value')";
      exit 2;
    }
    $cli-config.memory-threshold = $value.Int;
  } elsif $arg eq '--benchmark' {
    $cli-config.benchmark-mode = True;
  } elsif $arg eq '--benchmark-iterations' {
    die "--benchmark-iterations requires a value" unless @args;
    my $value = @args.shift;
    unless $value ~~ /^ \d+ $/ && $value.Int >= 1 {
      note "Error: --benchmark-iterations requires a positive integer (got: '$value')";
      exit 2;
    }
    $cli-config.benchmark-iterations = $value.Int;
  } elsif $arg.starts-with('--benchmark-iterations=') {
    my $value = $arg.substr('--benchmark-iterations='.chars);
    unless $value ~~ /^ \d+ $/ && $value.Int >= 1 {
      note "Error: --benchmark-iterations requires a positive integer (got: '$value')";
      exit 2;
    }
    $cli-config.benchmark-iterations = $value.Int;
  } elsif $arg eq '--benchmark-baseline' {
    die "--benchmark-baseline requires a value" unless @args;
    $cli-config.benchmark-baseline = @args.shift.IO;
  } elsif $arg.starts-with('--benchmark-baseline=') {
    $cli-config.benchmark-baseline = $arg.substr('--benchmark-baseline='.chars).IO;
  } elsif $arg eq '--benchmark-save' {
    die "--benchmark-save requires a value" unless @args;
    $cli-config.benchmark-save = @args.shift.IO;
  } elsif $arg.starts-with('--benchmark-save=') {
    $cli-config.benchmark-save = $arg.substr('--benchmark-save='.chars).IO;
  } elsif $arg eq '--benchmark-threshold' {
    die "--benchmark-threshold requires a value" unless @args;
    my $value = @args.shift;
    unless $value ~~ /^ \d+ [ '.' \d+ ]? $/ {
      note "Error: --benchmark-threshold requires a non-negative number (got: '$value')";
      exit 2;
    }
    $cli-config.benchmark-threshold = $value.Real;
  } elsif $arg.starts-with('--benchmark-threshold=') {
    my $value = $arg.substr('--benchmark-threshold='.chars);
    unless $value ~~ /^ \d+ [ '.' \d+ ]? $/ {
      note "Error: --benchmark-threshold requires a non-negative number (got: '$value')";
      exit 2;
    }
    $cli-config.benchmark-threshold = $value.Real;
  } elsif $arg eq '--benchmark-format' {
    die "--benchmark-format requires a value" unless @args;
    $cli-config.benchmark-format = @args.shift;
  } elsif $arg.starts-with('--benchmark-format=') {
    $cli-config.benchmark-format = $arg.substr('--benchmark-format='.chars);
  } elsif $arg eq '--benchmark-output' {
    die "--benchmark-output requires a value" unless @args;
    $cli-config.benchmark-output = @args.shift.IO;
  } elsif $arg.starts-with('--benchmark-output=') {
    $cli-config.benchmark-output = $arg.substr('--benchmark-output='.chars).IO;
  } elsif $arg eq '--format' {
    die "--format requires a value" unless @args;
    $cli-config.format = @args.shift;
  } elsif $arg.starts-with('--format=') {
    $cli-config.format = $arg.substr('--format='.chars);
  } elsif $arg eq '--config' {
    die "--config requires a value" unless @args;
    $config-path-arg = @args.shift;
  } elsif $arg.starts-with('--config=') {
    $config-path-arg = $arg.substr('--config='.chars);
  } elsif $arg eq '--no-config' {
    $no-config = True;
  } elsif $arg eq '--no-user-config' {
    $no-user-config = True;
  } elsif $arg eq '--no-project-config' {
    $no-project-config = True;
  } elsif $arg eq '--doc' {
    $doc-mode = True;
  } elsif $arg eq '--doc-format' {
    die "--doc-format requires a value" unless @args;
    $doc-format = @args.shift;
  } elsif $arg.starts-with('--doc-format=') {
    $doc-format = $arg.substr('--doc-format='.chars);
  } elsif $arg eq '--doc-output' {
    die "--doc-output requires a value" unless @args;
    $doc-output = @args.shift.IO;
  } elsif $arg.starts-with('--doc-output=') {
    $doc-output = $arg.substr('--doc-output='.chars).IO;
  } elsif $arg eq '--coverage' {
    $cli-config.coverage = True;
  } elsif $arg eq '--coverage-minimum' {
    die "--coverage-minimum requires a value" unless @args;
    my $value = @args.shift;
    unless $value ~~ /^ \d+ [ '.' \d+ ]? $/ && $value.Real >= 0 && $value.Real <= 100 {
      note "Error: --coverage-minimum requires a number between 0 and 100 (got: '$value')";
      exit 2;
    }
    $cli-config.coverage-minimum = $value.Real;
    $cli-config.coverage = True;
  } elsif $arg.starts-with('--coverage-minimum=') {
    my $value = $arg.substr('--coverage-minimum='.chars);
    unless $value ~~ /^ \d+ [ '.' \d+ ]? $/ && $value.Real >= 0 && $value.Real <= 100 {
      note "Error: --coverage-minimum requires a number between 0 and 100 (got: '$value')";
      exit 2;
    }
    $cli-config.coverage-minimum = $value.Real;
    $cli-config.coverage = True;
  } elsif $arg eq '--coverage-include' {
    die "--coverage-include requires a value" unless @args;
    $cli-config.coverage-include-path(@args.shift);
    $cli-config.coverage = True;
  } elsif $arg.starts-with('--coverage-include=') {
    $cli-config.coverage-include-path($arg.substr('--coverage-include='.chars));
    $cli-config.coverage = True;
  } elsif $arg eq '--coverage-exclude' {
    die "--coverage-exclude requires a value" unless @args;
    $cli-config.coverage-exclude-path(@args.shift);
    $cli-config.coverage = True;
  } elsif $arg.starts-with('--coverage-exclude=') {
    $cli-config.coverage-exclude-path($arg.substr('--coverage-exclude='.chars));
    $cli-config.coverage = True;
  } elsif $arg eq '--coverage-format' {
    die "--coverage-format requires a value" unless @args;
    $cli-config.coverage-format = @args.shift;
    $cli-config.coverage = True;
  } elsif $arg.starts-with('--coverage-format=') {
    $cli-config.coverage-format = $arg.substr('--coverage-format='.chars);
    $cli-config.coverage = True;
  } elsif $arg eq '--coverage-output' {
    die "--coverage-output requires a value" unless @args;
    $cli-config.coverage-output = @args.shift.IO;
    $cli-config.coverage = True;
  } elsif $arg.starts-with('--coverage-output=') {
    $cli-config.coverage-output = $arg.substr('--coverage-output='.chars).IO;
    $cli-config.coverage = True;
  } elsif $arg eq '--coverage-baseline' {
    die "--coverage-baseline requires a value" unless @args;
    $cli-config.coverage-baseline = @args.shift.IO;
    $cli-config.coverage = True;
  } elsif $arg.starts-with('--coverage-baseline=') {
    $cli-config.coverage-baseline = $arg.substr('--coverage-baseline='.chars).IO;
    $cli-config.coverage = True;
  } elsif $arg eq '--coverage-branch' {
    $cli-config.coverage-branch = True;
    $cli-config.coverage = True;
  } elsif $arg eq '--parallel' {
    die "--parallel requires a value" unless @args;
    my $value = @args.shift;
    unless $value ~~ /^ \d+ $/ && $value.Int >= 1 {
      note "Error: --parallel N requires a positive integer (got: '$value')";
      exit 2;
    }
    $cli-config.parallel = $value.Int;
  } elsif $arg.starts-with('--parallel=') {
    my $value = $arg.substr('--parallel='.chars);
    unless $value ~~ /^ \d+ $/ && $value.Int >= 1 {
      note "Error: --parallel N requires a positive integer (got: '$value')";
      exit 2;
    }
    $cli-config.parallel = $value.Int;
  } elsif $arg eq '--worker-manifest' {
    die "--worker-manifest requires a value" unless @args;
    $worker-manifest-path = @args.shift;
  } elsif $arg.starts-with('--worker-manifest=') {
    $worker-manifest-path = $arg.substr('--worker-manifest='.chars);
  } elsif $arg eq '--queue-worker' {
    $queue-worker-mode = True;
  } elsif $arg eq '--parallel-mode' {
    die "--parallel-mode requires a value" unless @args;
    $cli-config.parallel-mode = @args.shift;
  } elsif $arg.starts-with('--parallel-mode=') {
    $cli-config.parallel-mode = $arg.substr('--parallel-mode='.chars);
  } elsif $arg eq '--parallel-retry' {
    die "--parallel-retry requires a value" unless @args;
    my $value = @args.shift;
    unless $value ~~ /^ \d+ $/ {
      note "Error: --parallel-retry requires a non-negative integer (got: '$value')";
      exit 2;
    }
    $cli-config.parallel-retry = $value.Int;
  } elsif $arg.starts-with('--parallel-retry=') {
    my $value = $arg.substr('--parallel-retry='.chars);
    unless $value ~~ /^ \d+ $/ {
      note "Error: --parallel-retry=N requires a non-negative integer (got: '$value')";
      exit 2;
    }
    $cli-config.parallel-retry = $value.Int;
  } elsif $arg eq '--watch' {
    $watch-mode = True;
  } elsif $arg eq '--watch-path' {
    die "--watch-path requires a value" unless @args;
    @watch-paths.push: @args.shift;
  } elsif $arg.starts-with('--watch-path=') {
    @watch-paths.push: $arg.substr('--watch-path='.chars);
  } elsif $arg eq '--dry-run' {
    $dry-run-mode = True;
  } elsif $arg eq '--list-examples' {
    $list-examples = True;
  } elsif $arg eq '--list-examples-format' {
    die "--list-examples-format requires a value" unless @args;
    $list-format = @args.shift;
  } elsif $arg.starts-with('--list-examples-format=') {
    $list-format = $arg.substr('--list-examples-format='.chars);
  } elsif $arg ~~ / ^ (.+) ':' (\d+) $ / && ~$0.IO.e {
    my $file-part = ~$0;
    @specs.push: $file-part unless @specs.first(* eq $file-part);
    $cli-config.only-location(maybe-snap-location($arg));
  } else {
    @specs.push: $arg;
  }
}

my $disable-config = $no-config || %*ENV<BEHAVE_DISABLE_CONFIG>;

my $user-config    = CLI-CONFIG.new;
my $project-config = CLI-CONFIG.new;

if $config-path-arg.defined {
  my $path = $config-path-arg.IO;
  unless $path.e {
    note "Error: --config path '$config-path-arg' does not exist";
    exit 2;
  }
  $project-config = BDD::Behave::Configuration::load-file($path);
} elsif !$disable-config {
  unless $no-user-config {
    my $up = BDD::Behave::Configuration::user-config-path;
    $user-config = BDD::Behave::Configuration::load-file($up) if $up.defined && $up.e;
  }
  unless $no-project-config {
    my $pp = BDD::Behave::Configuration::project-config-path;
    $project-config = BDD::Behave::Configuration::load-file($pp) if $pp.defined && $pp.e;
  }
}

my $final-config = BDD::Behave::Configuration::defaults()
  .merge($user-config)
  .merge($project-config)
  .merge($cli-config);

my $format               = $final-config.format;
my $order                = $final-config.order;
my $seed                 = $final-config.seed;
my $seed-mode            = $final-config.seed-mode // 'xor';
my $parallel-mode        = $final-config.parallel-mode // 'lpt';
my $parallel-retry       = $final-config.parallel-retry // 0;
$parallel-workers        = $final-config.parallel // 0;
my $progress-total       = ?$final-config.progress-total;
my $fail-fast            = $final-config.fail-fast;
my $retry-default        = $final-config.retry // 0;
my $only-failures        = ?$final-config.only-failures;
my $failures-path        = $final-config.failures-path // $*CWD.IO.add('.behave-failures');
my $verbose              = ?$final-config.verbose;
my $aggregate-failures   = $final-config.aggregate-failures;
my $profile-limit        = $final-config.profile-limit;
my $slow-threshold       = $final-config.slow-threshold;
my $memory-profile-limit = $final-config.memory-profile-limit;
my $memory-threshold     = $final-config.memory-threshold;
my $benchmark-mode       = ?$final-config.benchmark-mode;
my $benchmark-iterations = $final-config.benchmark-iterations;
my $benchmark-baseline   = $final-config.benchmark-baseline;
my $benchmark-save       = $final-config.benchmark-save;
my $benchmark-threshold  = $final-config.benchmark-threshold;
my $benchmark-format     = $final-config.benchmark-format;
my $benchmark-output     = $final-config.benchmark-output;
my @include-tags         = $final-config.include-tags.list;
my @exclude-tags         = $final-config.exclude-tags.list;
my @example-patterns     = $final-config.example-patterns.list;
my @only-locations       = $final-config.only-locations.list;

if $only-failures {
  if $failures-path.defined && $failures-path.e {
    my @entries = $failures-path.slurp.lines.grep({ .chars && !.starts-with('#') });
    if @entries.elems {
      for @entries -> $loc {
        @only-locations.push: maybe-snap-location($loc);
      }
    } else {
      note "--only-failures: no entries in '{$failures-path}'; running all examples";
    }
  } else {
    note "--only-failures: '{$failures-path}' not found; running all examples";
  }
}
my $coverage-enabled     = ?$final-config.coverage;
my $coverage-minimum     = $final-config.coverage-minimum // 0.Real;
my $coverage-format      = $final-config.coverage-format // 'text';
my $coverage-output      = $final-config.coverage-output;
my $coverage-baseline    = $final-config.coverage-baseline;
my $coverage-branch      = ?$final-config.coverage-branch;
my @coverage-include     = $final-config.coverage-include.list;
my @coverage-exclude     = $final-config.coverage-exclude.list;

unless BDD::Behave::Formatter::Registry.registered($format) {
  note "Error: unknown --format '$format' (available: "
  ~ BDD::Behave::Formatter::Registry.names.join(', ') ~ ')';
  exit 2;
}

unless $order eq 'random' | 'defined' {
  note "Error: --order must be 'random' or 'defined' (got: '$order')";
  exit 2;
}

unless $seed-mode eq 'xor' | 'stable' {
  note "Error: --seed-mode must be 'xor' or 'stable' (got: '$seed-mode')";
  exit 2;
}

unless $parallel-mode eq 'lpt' | 'queue' {
  note "Error: --parallel-mode must be 'lpt' or 'queue' (got: '$parallel-mode')";
  exit 2;
}

if $benchmark-baseline.defined || $benchmark-save.defined || $benchmark-iterations > 1
|| $benchmark-format ne 'text' || $benchmark-output.defined {
  $benchmark-mode = True;
}

unless $benchmark-format eq 'text' | 'json' {
  note "Error: --benchmark-format must be 'text' or 'json' (got: '$benchmark-format')";
  exit 2;
}

if $coverage-enabled && !BDD::Behave::Coverage::valid-format($coverage-format) {
  note "Error: --coverage-format must be 'text', 'html', 'json', 'lcov', or 'cobertura' (got: '$coverage-format')";
  exit 2;
}

if $bisect && $bisect-data {
  note "Error: --bisect and --bisect-data are mutually exclusive";
  exit 2;
}

if $parallel-workers > 0 && ($bisect || $bisect-data) {
  note "Error: --parallel is mutually exclusive with --bisect / --bisect-data";
  exit 2;
}

if $watch-mode && ($bisect || $bisect-data || $coverage-enabled
                || $doc-mode || $parallel-workers > 0) {
  note "Error: --watch is mutually exclusive with --bisect / --bisect-data / --coverage / --doc / --parallel";
  exit 2;
}

my $coverage-running = %*ENV<BEHAVE_COVERAGE_LOG>.defined;

# Under --parallel, coverage is collected per-worker (each worker gets its
# own MVM_COVERAGE_LOG) and merged in the parallel branch below, so we skip
# the outer re-exec wrapper.
if $coverage-enabled && !$coverage-running && $parallel-workers == 0 {
  my @effective-include = @coverage-include.elems ?? @coverage-include !! ('lib/',);
  my $root = $*CWD.IO;

  my $stamp = sprintf '%d-%d', $*PID, (now * 1e6).Int;
  my $raw-path      = $*TMPDIR.add("behave-coverage-$stamp.raw");
  my $filtered-path = $*TMPDIR.add("behave-coverage-$stamp.filtered");
  $raw-path.spurt('');

  my %child-env = |%*ENV;
  %child-env<MVM_COVERAGE_LOG>      = $raw-path.absolute;
  %child-env<MVM_COVERAGE_CONTROL>  = '2';
  %child-env<BEHAVE_COVERAGE_LOG>   = $raw-path.absolute;

  my @child-cmd = 'raku', '-Ilib', $*PROGRAM-NAME, |@*ARGS;
  my $proc = run(|@child-cmd, :env(|%child-env));
  my $child-exit = $proc.exitcode;

  # Best-effort: kill direct descendants that may still be writing to the
  # log via inherited MVM_COVERAGE_LOG.
  if $proc.pid.defined {
    my $pgrep = run('pgrep', '-P', $proc.pid.Str, :out, :!err);
    my @descendants = $pgrep.out.slurp(:close).lines.grep(*.chars);
    for @descendants -> $pid {
      try { run('kill', $pid, :!out, :!err) }
    }
  }

  my sub shq(Str $s) {
    q{'} ~ $s.subst(q{'}, q{'\''}, :g) ~ q{'};
  }
  # Anchor each include pattern against the HIT-line prefix so that
  # `lib/` matches `HIT  lib/...` but not `HIT  /Users/.../rakudo/.../lib/...`.
  # Both relative and absolute path forms appear in MVM logs depending on
  # how a module was resolved.
  my @grep-prefixes;
  for @effective-include -> $p {
    @grep-prefixes.push: 'HIT  ' ~ $p;
    unless $p.starts-with('/') {
      @grep-prefixes.push: 'HIT  ' ~ $root.absolute ~ '/' ~ $p;
    }
  }
  # awk-dedupe the grep output. The raw MVM log writes a fresh HIT line
  # for every line transition, so a single executed line in a hot loop can
  # show up millions of times. Without dedup, Raku-side parsing of the
  # filtered log (often >1 GB on the full spec suite) takes hours; with
  # dedup it becomes tens of thousands of unique lines and parses in
  # seconds.
  my $grep-cmd = 'grep -aF '
               ~ @grep-prefixes.map({ '-e ' ~ shq($_) }).join(' ')
               ~ ' ' ~ shq($raw-path.absolute)
               ~ q{ | awk '!seen[$0]++' }
               ~ '> ' ~ shq($filtered-path.absolute);
  my $grep-rc = run('sh', '-c', $grep-cmd, :!out, :!err).exitcode;
  if $grep-rc >= 2 {
    note "Error: coverage filter (grep) exited $grep-rc";
    $raw-path.unlink if $raw-path.e;
    $filtered-path.unlink if $filtered-path.e;
    exit 2;
  }
  $raw-path.unlink if $raw-path.e;

  my @raku-include-paths;
  for @effective-include -> $p {
    @raku-include-paths.push: $p;
    @raku-include-paths.push: $root.absolute ~ '/' ~ $p unless $p.starts-with('/');
  }

  my %hits = BDD::Behave::Coverage::parse-coverage-log(
    $filtered-path,
    :include-paths(@raku-include-paths),
    :exclude-paths(@coverage-exclude),
  );
  $filtered-path.unlink if $filtered-path.e;

  my $opts = BDD::Behave::Coverage::CoverageOptions.new(
    :enabled,
    :minimum($coverage-minimum),
    :format($coverage-format),
    :output($coverage-output),
    :baseline($coverage-baseline),
    :branch($coverage-branch),
  );
  $opts.include-path(|@effective-include);
  $opts.exclude-path(|@coverage-exclude) if @coverage-exclude.elems;

  my $report = BDD::Behave::Coverage::build-report-from-hits(%hits, $opts, $root);

  if $coverage-format eq 'html' {
    my $out-dir = $coverage-output // $*CWD.IO.add('coverage');
    if $out-dir.e && $out-dir.d {
      # Clean stale files from the previous run so renamed or deleted
      # source files don't leave dangling per-file pages behind.
      for $out-dir.dir -> $entry {
        $entry.unlink if $entry.f;
      }
    }
    BDD::Behave::Coverage::write-html-tree($report, $out-dir);
    say '';
    say "Coverage html report written to {$out-dir.absolute}/index.html";
  } else {
    my $color = !$coverage-output.defined;
    my $rendered = BDD::Behave::Coverage::render-report(
      $report, $coverage-format, :$color,
    );

    if $coverage-output.defined {
      $coverage-output.spurt($rendered);
      say '';
      say "Coverage $coverage-format report written to {$coverage-output.absolute}";
    } else {
      say '';
      print $rendered;
    }
  }

  my $coverage-failed = False;
  if $coverage-minimum > 0 && $report.overall-percentage + 0.001 < $coverage-minimum {
    note sprintf "Error: coverage %.2f%% is below the required minimum %.2f%%",
      $report.overall-percentage, $coverage-minimum;
    $coverage-failed = True;
  }

  if $coverage-baseline.defined {
    my $diff = BDD::Behave::Coverage::compute-diff($report, $coverage-baseline);
    print BDD::Behave::Coverage::render-diff($diff);
  }

  exit($child-exit || ($coverage-failed ?? 1 !! 0));
}

if !$seed.defined && !$bisect-data
   && ($order eq 'random' || $seed-mode eq 'stable') {
  $seed = (1 .. 2_147_483_646).pick;
}

if $queue-worker-mode {
  use BDD::Behave::Formatter::Registry;
  use BDD::Behave::Worker;
  use BDD::Behave::Parallel::Queue;

  $format = 'json-events';

  # Disable stdout buffering for the entire worker lifetime: every event
  # crosses a pipe to the parent process and any buffered tail can be
  # dropped if the process exits before the buffer flushes.
  $*OUT.out-buffer = False;

  if $seed.defined && $seed-mode eq 'xor' {
    $seed = $seed +^ BDD::Behave::Worker.id;
  }

  my $formatter = BDD::Behave::Formatter::Registry.create($format);
  my $registry  = BDD::Behave::SpecRegistry::registry();
  my %loaded-files;

  sub json-escape(Str $s --> Str) {
    $s.subst('\\', '\\\\', :g)
      .subst('"', '\\"', :g)
      .subst("\n", '\\n', :g)
      .subst("\r", '\\r', :g)
      .subst("\t", '\\t', :g);
  }

  # Hand the original stdin off to the queue control channel and replace
  # the user-visible $*IN with /dev/null. Otherwise any spec that reads
  # stdin (e.g. the Watch UI session tests, which spawn a reader thread
  # that calls $*IN.get) races with our control loop and can steal the
  # next BUCKET / SHUTDOWN command, losing examples or hanging the worker.
  my $control-in = $*IN;
  my $devnull-in = open($*SPEC.devnull, :r);

  $*OUT.say('{"type":"worker-ready"}');
  $*OUT.flush;

  for $control-in.lines -> $line {
    my %cmd = BDD::Behave::Parallel::Queue::parse-bucket-command($line);

    if %cmd<type> eq 'shutdown' {
      last;
    } elsif %cmd<type> eq 'bucket' {
      my $bucket-id = (%cmd<id>   // '').Str;
      my $bucket-file = (%cmd<file> // '').Str;
      my @locations = %cmd<locations>.list;

      unless %loaded-files{$bucket-file}:exists {
        my $load-err;
        try {
          my $*IN = $devnull-in;
          BDD::Behave::SpecLoader::load-spec-file($bucket-file.IO);
          CATCH { default { $load-err = .message } }
        }
        if $load-err.defined {
          my $esc-file = json-escape($bucket-file);
          my $esc-msg  = json-escape($load-err);
          $*OUT.say(qq[\{"type":"load-error","file":"$esc-file","message":"$esc-msg"\}]);
          $*OUT.say(qq[\{"type":"bucket-done","id":"{json-escape($bucket-id)}"\}]);
          $*OUT.flush;
          next;
        }
        %loaded-files{$bucket-file} = True;
      }

      my $suite = $registry.suite-for-file($bucket-file.IO);
      if $suite.defined {
        my $runner = BDD::Behave::Runner::Runner.new(
          :$formatter,
          :only-locations(@locations),
          :aggregate-failures($aggregate-failures),
          :order('defined'),
          :fail-fast(0),
          :retry($retry-default),
          :profile-limit($profile-limit),
          :memory-profile-limit($memory-profile-limit),
          :$memory-threshold,
          :memory-profile($memory-profile-limit > 0 || $memory-threshold > 0),
          :$slow-threshold,
          :benchmark-mode($benchmark-mode),
          :benchmark-iterations($benchmark-iterations),
          :benchmark-threshold($benchmark-threshold),
          :benchmark-format($benchmark-format),
          :config($final-config),
        );
        my $*IN = $devnull-in;
        $runner.run($suite);
      }

      $*OUT.say(qq[\{"type":"bucket-done","id":"{json-escape($bucket-id)}"\}]);
      $*OUT.flush;
    }
  }
  exit 0;
}

if $worker-manifest-path.defined {
  use BDD::Behave::Parallel::Manifest;
  use BDD::Behave::Worker;
  my $mpath = $worker-manifest-path.IO;
  unless $mpath.e {
    note "Error: --worker-manifest path '$worker-manifest-path' does not exist";
    exit 2;
  }
  my @manifest-locations = read-manifest($mpath);
  exit 0 unless @manifest-locations.elems;
  for @manifest-locations -> $loc {
    @only-locations.push: $loc;
  }
  $format = 'json-events';

  if $seed.defined && $seed-mode eq 'xor' {
    $seed = $seed +^ BDD::Behave::Worker.id;
  }
}

my @config-spec-paths = $final-config.spec-paths.list;
my @effective-specs = @specs.elems ?? @specs !! @config-spec-paths;

my $files-obj = Files.new;
my @files = $files-obj.list(@effective-specs);

if $dry-run-mode || $list-examples {
  unless $list-format eq 'text' | 'json' {
    note "Error: --list-examples-format must be 'text' or 'json' (got: '$list-format')";
    exit 2;
  }

  use BDD::Behave::DryRun;

  my $registry = BDD::Behave::SpecRegistry::registry();
  my @load-errors;
  my @loaded-suites;

  for @files -> $file {
    my $load-failed = False;
    try {
      BDD::Behave::SpecLoader::load-spec-file($file);
      CATCH {
        default {
          @load-errors.push: %( :$file, :message(.message) );
          note "Error: Could not load $file: {.message}";
          $load-failed = True;
        }
      }
    }
    next if $load-failed;
    my $suite = $registry.suite-for-file($file.IO);
    @loaded-suites.push: $suite if $suite.defined;
  }

  my $opts = BDD::Behave::DryRun::FilterOptions.new(
    include-tags     => @include-tags.list,
    exclude-tags     => @exclude-tags.list,
    example-patterns => @example-patterns.list,
    only-locations   => @only-locations.list,
  );

  if $list-examples {
    if $list-format eq 'json' {
      print BDD::Behave::DryRun::render-json(@loaded-suites, $opts, :@load-errors);
      say '';
    } else {
      for BDD::Behave::DryRun::matching-examples(@loaded-suites, $opts) -> $ex {
        my @parts = $ex.ancestry.grep(BDD::Behave::SpecTree::ExampleGroup).map(*.description);
        @parts.push: $ex.description;
        say "{$ex.file}:{$ex.line}\t{@parts.join(' ')}";
      }
    }
  } else {
    print BDD::Behave::DryRun::render-text(@loaded-suites, $opts, :$verbose);
  }

  exit(@load-errors.elems == 0 ?? 0 !! 1);
}

if $watch-mode {
  use BDD::Behave::Watch;
  use BDD::Behave::Watch::Watcher;
  use BDD::Behave::Watch::SmartSelector;
  use BDD::Behave::Watch::UI;
  use BDD::Behave::Watch::Session;

  my @roots;
  if @watch-paths.elems {
    for @watch-paths -> $p {
      my $io = $p.IO;
      unless $io.e {
        note "Error: --watch-path '$p' does not exist";
        exit 2;
      }
      @roots.push: $io;
    }
  } else {
    @roots = BDD::Behave::Watch::default-paths(:base($*CWD.IO));
  }

  my $watcher  = BDD::Behave::Watch::default-watcher(@roots);
  my $lib-root = $*CWD.IO.add('lib');
  my $selector = BDD::Behave::Watch::default-selector(:$lib-root);
  my $ui       = BDD::Behave::Watch::UI::UI.new;

  my @forward-args;
  @forward-args.push: '--no-config';
  @forward-args.push: '--format', $format if $format ne 'progress';
  @forward-args.push: '--order', $order   if $order ne 'random';
  @forward-args.push: '--seed',  $seed.Str if $seed.defined && $order eq 'random';
  @forward-args.push: '--verbose' if $verbose;
  @forward-args.push: '--retry', $retry-default.Str if $retry-default > 0;
  for @include-tags     -> $t { @forward-args.append: '--tag', $t }
  for @exclude-tags     -> $t { @forward-args.append: '--exclude-tag', $t }
  for @example-patterns -> $p { @forward-args.append: '--example', $p }

  my @base-argv = 'raku', "-I{$*CWD.IO.add('lib').absolute}",
                  $*PROGRAM-NAME, |@forward-args;

  my &runner = BDD::Behave::Watch::make-subprocess-runner(
    :@base-argv,
    :failures-path($failures-path),
  );

  my $session = BDD::Behave::Watch::Session::Session.new(
    :$watcher,
    :$selector,
    :$ui,
    :all-specs(@files.map(*.IO)),
    :&runner,
  );

  $session.run;
  exit 0;
}

if $parallel-workers >= 1 && !$worker-manifest-path.defined {
  use BDD::Behave::Parallel;
  use BDD::Behave::Colors;

  my @worker-argv = 'raku', '-Ilib', $*PROGRAM-NAME;

  my $worker-order = $seed-mode eq 'stable' ?? 'defined' !! $order;
  if $worker-order eq 'defined' {
    @worker-argv.push: '--order', 'defined';
  }

  @worker-argv.push: '--seed-mode', $seed-mode;

  if $seed.defined {
    @worker-argv.push: '--seed', $seed.Str;
  }

  @worker-argv.push: '--no-config';
  @worker-argv.push: '--format', 'json-events';
  if $retry-default > 0 {
    @worker-argv.push: '--retry', $retry-default.Str;
  }

  if $profile-limit > 0 {
    @worker-argv.push: '--profile=' ~ $profile-limit.Str;
  }
  if $memory-profile-limit > 0 {
    @worker-argv.push: '--memory-profile=' ~ $memory-profile-limit.Str;
  }
  if $memory-threshold > 0 {
    @worker-argv.push: '--memory-threshold=' ~ $memory-threshold.Str;
  }
  if $benchmark-mode {
    @worker-argv.push: '--benchmark';
    if $benchmark-iterations > 1 {
      @worker-argv.push: '--benchmark-iterations=' ~ $benchmark-iterations.Str;
    }
    if $benchmark-threshold.defined {
      @worker-argv.push: '--benchmark-threshold=' ~ $benchmark-threshold.Str;
    }
  }

  my $parent-formatter = BDD::Behave::Formatter::Registry.create($format);

  my %base-env = |%*ENV;
  %base-env<BEHAVE_DISABLE_CONFIG> = '1';
  # Make sure inherited MVM coverage env can't redirect worker hits to a
  # single shared file or trigger coverage in the parent process.
  %base-env<MVM_COVERAGE_LOG>:delete;
  %base-env<MVM_COVERAGE_CONTROL>:delete;
  %base-env<BEHAVE_COVERAGE_LOG>:delete;

  my $par-coverage-dir;
  if $coverage-enabled {
    my $stamp = sprintf '%d-%d', $*PID, (now * 1e6).Int;
    $par-coverage-dir = $*TMPDIR.add("behave-coverage-parallel-$stamp");
    $par-coverage-dir.mkdir;
  }

  my $opts = $par-coverage-dir.defined
    ?? BDD::Behave::Parallel::ParallelRunOptions.new(
         :worker-count($parallel-workers),
         :spec-files(@files.map(*.Str).List),
         :@worker-argv,
         :%base-env,
         :formatter($parent-formatter),
         :verbose($verbose),
         :seed($seed),
         :order($order),
         :seed-mode($seed-mode),
         :parallel-mode($parallel-mode),
         :parallel-retry($parallel-retry),
         :progress-total($progress-total),
         :include-tags(@include-tags),
         :exclude-tags(@exclude-tags),
         :example-patterns(@example-patterns),
         :only-locations(@only-locations),
         :coverage-log-dir($par-coverage-dir),
       )
    !! BDD::Behave::Parallel::ParallelRunOptions.new(
         :worker-count($parallel-workers),
         :spec-files(@files.map(*.Str).List),
         :@worker-argv,
         :%base-env,
         :formatter($parent-formatter),
         :verbose($verbose),
         :seed($seed),
         :order($order),
         :seed-mode($seed-mode),
         :parallel-mode($parallel-mode),
         :parallel-retry($parallel-retry),
         :progress-total($progress-total),
         :include-tags(@include-tags),
         :exclude-tags(@exclude-tags),
         :example-patterns(@example-patterns),
         :only-locations(@only-locations),
       );

  my $par-result = run-parallel($opts);

  my $rr = BDD::Behave::Runner::RunResult.new(
    :total($par-result.total),
    :passed($par-result.passed),
    :failed($par-result.failed),
    :pending($par-result.pending),
    :skipped($par-result.skipped),
  );

  say '';
  if @files.elems > 1 {
    $parent-formatter.multi-file-overall($rr, :$order, :$seed);
  } else {
    $parent-formatter.run-summary($rr, :$order, :$seed);
  }

  if $par-result.load-errors.elems {
    my $n = $par-result.load-errors.elems;
    my $word = $n == 1 ?? 'spec file' !! 'spec files';
    say red("  $n $word failed to load (their examples did not run)");
  }

  $parent-formatter.retry-summary($par-result.retry-records)
    if $par-result.retry-records.elems;

  if $par-result.shard-retry-records.elems {
    say '';
    say "Shard retries: {$par-result.shard-retry-records.elems}";
    for $par-result.shard-retry-records.list -> $rec {
      my $crashes = $rec.crash-codes.join(', ');
      my $tag = $rec.outcome eq 'recovered'
        ?? green('recovered')
        !! red('crashed');
      say "  worker {$rec.worker}: $tag after {$rec.attempts} attempts (crash exit codes: $crashes; final exit: {$rec.final-exit})";
    }
  }

  if $profile-limit > 0 && $par-result.timed-examples.elems {
    $parent-formatter.multi-file-profile(
      Nil, $par-result.timed-examples.list, :limit($profile-limit));
  }

  if $memory-profile-limit > 0 && $par-result.memory-records.elems {
    $parent-formatter.multi-file-memory-profile(
      Nil, $par-result.memory-records.list, :limit($memory-profile-limit));
  }

  if $benchmark-mode && $par-result.benchmark-summaries.elems {
    my $agg-runner = BDD::Behave::Runner::Runner.new(
      :formatter($parent-formatter),
      :benchmark-mode(True),
      :benchmark-threshold($benchmark-threshold),
      :benchmark-format($benchmark-format),
    );
    my @summaries = $par-result.benchmark-summaries.list;
    my @regressions;
    if $benchmark-baseline.defined {
      @regressions = $agg-runner.compare-with-baseline(@summaries, $benchmark-baseline);
    }
    if $benchmark-save.defined {
      $agg-runner.save-benchmark-baseline(@summaries, $benchmark-save);
    }
    $parent-formatter.multi-file-benchmark(
      $agg-runner,
      @summaries, @regressions,
      :threshold($benchmark-threshold),
      :format($benchmark-format),
      :output($benchmark-output),
    );
  }

  $parent-formatter.load-errors($par-result.load-errors.list);

  for $par-result.load-errors -> %err {
    note red("Error: Could not load {%err<file>}: {%err<message>}");
  }

  BDD::Behave::FailureStore::write-failures(
    $failures-path,
    $par-result.executed-locations.list,
    $par-result.failed-locations.list,
  );

  my $coverage-failed = False;
  if $coverage-enabled && $par-coverage-dir.defined {
    my @effective-include = @coverage-include.elems ?? @coverage-include !! ('lib/',);
    my $root = $*CWD.IO;

    my @worker-raw-paths;
    if $par-coverage-dir.e && $par-coverage-dir.d {
      for $par-coverage-dir.dir.sort -> $entry {
        next unless $entry.f;
        next unless $entry.basename.ends-with('.raw');
        @worker-raw-paths.push: $entry;
      }
    }

    my $merged-path = $par-coverage-dir.add('merged.filtered');

    if @worker-raw-paths.elems {
      sub shq(Str $s) {
        q{'} ~ $s.subst(q{'}, q{'\''}, :g) ~ q{'};
      }
      my @grep-prefixes;
      for @effective-include -> $p {
        @grep-prefixes.push: 'HIT  ' ~ $p;
        unless $p.starts-with('/') {
          @grep-prefixes.push: 'HIT  ' ~ $root.absolute ~ '/' ~ $p;
        }
      }
      # `grep -h` suppresses the filename prefix that grep adds when
      # searching multiple files; without it, lines come out as
      # `path/worker-0.raw:HIT  …` and parse-coverage-log ignores them.
      my $grep-cmd = 'grep -ahF '
                   ~ @grep-prefixes.map({ '-e ' ~ shq($_) }).join(' ')
                   ~ ' ' ~ @worker-raw-paths.map({ shq(.absolute) }).join(' ')
                   ~ q{ | awk '!seen[$0]++' }
                   ~ '> ' ~ shq($merged-path.absolute);
      my $grep-rc = run('sh', '-c', $grep-cmd, :!out, :!err).exitcode;
      if $grep-rc >= 2 {
        note "Error: coverage filter (grep) exited $grep-rc";
        for @worker-raw-paths -> $p { $p.unlink if $p.e }
        $merged-path.unlink if $merged-path.e;
        $par-coverage-dir.rmdir if $par-coverage-dir.e && $par-coverage-dir.d;
        exit 2;
      }
    } else {
      $merged-path.spurt('');
    }

    for @worker-raw-paths -> $p { $p.unlink if $p.e }

    my @raku-include-paths;
    for @effective-include -> $p {
      @raku-include-paths.push: $p;
      @raku-include-paths.push: $root.absolute ~ '/' ~ $p unless $p.starts-with('/');
    }

    my %hits = BDD::Behave::Coverage::parse-coverage-log(
      $merged-path,
      :include-paths(@raku-include-paths),
      :exclude-paths(@coverage-exclude),
    );
    $merged-path.unlink if $merged-path.e;
    $par-coverage-dir.rmdir if $par-coverage-dir.e && $par-coverage-dir.d;

    my $opts = BDD::Behave::Coverage::CoverageOptions.new(
      :enabled,
      :minimum($coverage-minimum),
      :format($coverage-format),
      :output($coverage-output),
      :baseline($coverage-baseline),
      :branch($coverage-branch),
    );
    $opts.include-path(|@effective-include);
    $opts.exclude-path(|@coverage-exclude) if @coverage-exclude.elems;

    my $report = BDD::Behave::Coverage::build-report-from-hits(%hits, $opts, $root);

    if $coverage-format eq 'html' {
      my $out-dir = $coverage-output // $*CWD.IO.add('coverage');
      if $out-dir.e && $out-dir.d {
        for $out-dir.dir -> $entry {
          $entry.unlink if $entry.f;
        }
      }
      BDD::Behave::Coverage::write-html-tree($report, $out-dir);
      say '';
      say "Coverage html report written to {$out-dir.absolute}/index.html";
    } else {
      my $color = !$coverage-output.defined;
      my $rendered = BDD::Behave::Coverage::render-report(
        $report, $coverage-format, :$color,
      );

      if $coverage-output.defined {
        $coverage-output.spurt($rendered);
        say '';
        say "Coverage $coverage-format report written to {$coverage-output.absolute}";
      } else {
        say '';
        print $rendered;
      }
    }

    if $coverage-minimum > 0 && $report.overall-percentage + 0.001 < $coverage-minimum {
      note sprintf "Error: coverage %.2f%% is below the required minimum %.2f%%",
        $report.overall-percentage, $coverage-minimum;
      $coverage-failed = True;
    }

    if $coverage-baseline.defined {
      my $diff = BDD::Behave::Coverage::compute-diff($report, $coverage-baseline);
      print BDD::Behave::Coverage::render-diff($diff);
    }
  }

  my $par-failed = !($par-result.success && $par-result.failed == 0);
  exit(($par-failed || $coverage-failed) ?? 1 !! 0);
}

if $doc-mode {
  unless $doc-format eq 'markdown' | 'html' | 'json' {
    note "Error: --doc-format must be 'markdown', 'html', or 'json' (got: '$doc-format')";
    exit 2;
  }

  my $registry = BDD::Behave::SpecRegistry::registry();
  my @doc-load-errors;
  my @doc-suites;
  for @files -> $file {
    my $load-failed = False;
    try {
      BDD::Behave::SpecLoader::load-spec-file($file);
      CATCH {
        default {
          @doc-load-errors.push: %( :$file, :message(.message) );
          note "Error: Could not load $file: {.message}";
          $load-failed = True;
        }
      }
    }
    next if $load-failed;
    my $suite = $registry.suite-for-file($file.IO);
    @doc-suites.push: $suite if $suite.defined;
  }

  my $extractor = BDD::Behave::DocExtractor::DocExtractor.new(
    :format($doc-format),
    :include-tags(@include-tags),
    :exclude-tags(@exclude-tags),
    :example-patterns(@example-patterns),
  );
  my $text = $extractor.extract(@doc-suites);

  if $doc-output.defined {
    $doc-output.spurt($text);
  } else {
    print $text;
  }

  exit(@doc-load-errors.elems == 0 ?? 0 !! 1);
}

if $bisect {
  my @extra-args;
  for @include-tags -> $t   { @extra-args.append: '--tag', $t }
  for @exclude-tags -> $t   { @extra-args.append: '--exclude-tag', $t }
  for @example-patterns -> $p { @extra-args.append: '--example', $p }
  if $aggregate-failures {
    if $aggregate-failures ~~ Str {
      @extra-args.push: "--aggregate-failures=$aggregate-failures";
    } else {
      @extra-args.push: '--aggregate-failures';
    }
  }
  @extra-args.push: '--no-config';

  my $bisector = BDD::Behave::Bisect::Bisector.new(
    :spec-files(@files.map(*.Str).List),
    :@extra-args,
    :$verbose,
  );
  my $result = $bisector.run;
  exit($result.had-failures ?? 1 !! 0);
}

# Load and eval spec files
use BDD::Behave::Colors;

my $registry = BDD::Behave::SpecRegistry::registry();
my $total-result = BDD::Behave::Runner::RunResult.new;
my $multi = @files.elems > 1;
my $suites-run = 0;
my @load-errors;
my @all-execution-order;
my @all-failures;
my @all-retry-records;
my @all-timings;
my @all-memory-records;
my @all-benchmark-summaries;
my $last-runner;

# In --bisect-data mode, suppress normal output during runs.
my $null-handle = $bisect-data ?? open('/dev/null', :w) !! Nil;

my $formatter = BDD::Behave::Formatter::Registry.create($format);

# Signals Runner.run() that this is the user-facing top-level run, so
# exception-based example failures should be recorded in the global
# Failures.list (and therefore rendered in the end-of-run "Failures:" section).
# Nested runners spun up inside spec bodies as fixtures see this as undefined
# and stay out of the global list to avoid polluting outer accounting.
my $*BEHAVE-TOP-LEVEL-RUN = True;

for @files -> $file {
  $formatter.suite-loading(:$file) if $verbose && !$bisect-data;
  my $load-failed = False;
  try {
    BDD::Behave::SpecLoader::load-spec-file($file);
    CATCH {
      default {
        @load-errors.push: %( :$file, :message(.message) );
        note red("Error: Could not load $file: {.message}") unless $bisect-data;
        $load-failed = True;
      }
    }
  }
  next if $load-failed;

  my $suite = $registry.suite-for-file($file.IO);
  next unless $suite.defined;

  $formatter.suite-start($suite, :multi-file($multi)) unless $bisect-data;

  my $remaining-fail-fast = 0;
  if $fail-fast > 0 {
    $remaining-fail-fast = $fail-fast - $total-result.failed;
    last if $remaining-fail-fast <= 0;
  }

  my $runner-profile-limit = $multi ?? 0 !! $profile-limit;
  my $runner-memory-profile-limit = $multi ?? 0 !! $memory-profile-limit;

  my $runner = BDD::Behave::Runner::Runner.new(
    :$formatter,
    :include-tags(@include-tags),
    :exclude-tags(@exclude-tags),
    :example-patterns(@example-patterns),
    :only-locations(@only-locations),
    :aggregate-failures($aggregate-failures),
    :$order,
    :$seed,
    :fail-fast($remaining-fail-fast),
    :retry($retry-default),
    :profile-limit($runner-profile-limit),
    :memory-profile-limit($runner-memory-profile-limit),
    :$memory-threshold,
    :memory-profile($memory-profile-limit > 0 || $memory-threshold > 0),
    :$slow-threshold,
    :benchmark-mode($benchmark-mode),
    :benchmark-quiet($multi),
    :benchmark-iterations($benchmark-iterations),
    :benchmark-baseline($multi ?? IO::Path !! $benchmark-baseline),
    :benchmark-save($multi ?? IO::Path !! $benchmark-save),
    :benchmark-threshold($benchmark-threshold),
    :benchmark-format($benchmark-format),
    :benchmark-output($multi ?? IO::Path !! $benchmark-output),
    :config($final-config),
  );
  $last-runner = $runner;

  my $result;
  if $bisect-data {
    my $*OUT = $null-handle;
    $result = $runner.run($suite);
  } else {
    $result = $runner.run($suite);
  }
  $suites-run++;

  @all-execution-order.append: $runner.execution-order;
  for $runner.result.errors -> $err {
    @all-failures.push("{$err<file>}:{$err<line>}");
  }
  for $runner.result.retry-records.list -> $rec {
    @all-retry-records.push($rec);
  }

  @all-timings.append: $runner.timed-examples;
  @all-memory-records.append: $runner.memory-records;
  @all-benchmark-summaries.append: $runner.benchmark-summaries;

  $total-result = BDD::Behave::Runner::RunResult.new(
    :total($total-result.total + $result.total),
    :passed($total-result.passed + $result.passed),
    :failed($total-result.failed + $result.failed),
    :pending($total-result.pending + $result.pending),
    :skipped($total-result.skipped + $result.skipped),
  );

  last if $fail-fast > 0 && $total-result.failed >= $fail-fast;
}

$null-handle.close if $null-handle;

if $bisect-data {
  for @all-execution-order -> $loc {
    say "behave-executed: $loc";
  }
  for @all-failures -> $loc {
    say "behave-failed: $loc";
  }
  for @load-errors -> $err {
    say "behave-load-error: $err<file>";
  }
  exit(($total-result.success && @load-errors.elems == 0) ?? 0 !! 1);
}

if $multi {
  $formatter.multi-file-overall($total-result, :$order, :$seed);

  $formatter.retry-summary(@all-retry-records) if @all-retry-records.elems;

  if $profile-limit > 0 && $last-runner.defined && !$bisect-data {
    $formatter.multi-file-profile($last-runner, @all-timings, :limit($profile-limit));
  }

  if $memory-profile-limit > 0 && $last-runner.defined && !$bisect-data {
    $formatter.multi-file-memory-profile(
      $last-runner, @all-memory-records, :limit($memory-profile-limit));
  }

  if $benchmark-mode && $last-runner.defined && !$bisect-data {
    my @regressions;
    if $benchmark-baseline.defined {
      @regressions =
      $last-runner.compare-with-baseline(@all-benchmark-summaries, $benchmark-baseline);
    }
    if $benchmark-save.defined {
      $last-runner.save-benchmark-baseline(@all-benchmark-summaries, $benchmark-save);
    }
    $formatter.multi-file-benchmark(
      $last-runner,
      @all-benchmark-summaries, @regressions,
      :threshold($benchmark-threshold),
      :format($benchmark-format),
      :output($benchmark-output),
    );
  }
}

$formatter.load-errors(@load-errors);

unless $bisect || $bisect-data {
  BDD::Behave::FailureStore::write-failures(
    $failures-path,
    @all-execution-order,
    @all-failures,
  );
}

# Exit non-zero if any examples failed or any spec file failed to load
exit(($total-result.success && @load-errors.elems == 0) ?? 0 !! 1);
