#!/usr/bin/env bash
#
# THE SEVEN GATES, SHARDED AND PARALLEL — the fast inner loop.
#
# Round 717. Measured: the suites dominate every round's cost and they were
# always run serially.
#
#   Unit        2,569 tests   67s serial  ->  28s across 3 shards
#   Functional     50 tests   72s serial  ->  26s across 3 shards
#
# Run concurrently that is ~139s of waiting reduced to ~30s, on the thing a
# round pays for two to four times over. Same tests, same assertions: the shard
# counts sum to the serial totals exactly (857+857+855 = 2,569; 17+17+16 = 50),
# which was verified before this script was written rather than assumed.
#
# WHAT IS AND IS NOT AUTHORITATIVE, BECAUSE A FAST GATE THAT QUIETLY DIFFERS
# FROM THE REAL ONE IS WORSE THAN NO FAST GATE.
#
#   * CI is the authority. `.github/workflows/ci.yml` runs every gate serially
#     on every push, including the browser suite this script does not run.
#   * This script is the inner loop. It must never check LESS than `composer
#     quality` does, and `TheFastGateChecksEverythingTheSlowOneDoesTest`
#     fails the build if it starts to.
#
# EVERY SHARD'S EXIT CODE IS CHECKED, NOT ITS OUTPUT. A shard that dies before
# printing anything prints no failure either, so grepping for "OK" would read a
# crashed shard as a passing one — which is the shape of every green-for-the-
# wrong-reason finding in this register.
#
# Usage:
#   bin/gates.sh            all gates, suites sharded and parallel
#   bin/gates.sh --serial   same gates, unsharded (what CI does; for confirming
#                           a sharding-related suspicion)

set -uo pipefail

cd "$(dirname "$0")/.." || exit 1

SHARDS=3
SERIAL=0
[ "${1:-}" = "--serial" ] && SERIAL=1

FAILED=()
started=$(date +%s)

note() { printf '\n\033[1m== %s\033[0m\n' "$1"; }
ran() { [ "$2" -eq 0 ] && printf '   \033[32mok\033[0m   %s\n' "$1" || { printf '   \033[31mFAIL\033[0m %s\n' "$1"; FAILED+=("$1"); }; }

# --- static gates, in parallel: they share no state and none writes ----------
note "static gates"
# The cs-fixer cache is cleared first, always. A warm cache reports a file as
# clean because it was clean last time, which is how a style regression reaches
# CI from a locally green tree.
rm -f runtime/cache/.php-cs-fixer.cache

# Round 720: `-q` was here, and it made a FAILING phpstan gate print an empty
# log. The verdict was right and there was nothing to read — the reader is sent
# to /tmp/g-phpstan.log and finds nought bytes. `--no-progress` is what this
# actually wanted: no spinner, but the error table kept.
vendor/bin/phpstan analyse --no-progress > /tmp/g-phpstan.log 2>&1 &
p1=$!
vendor/bin/deptrac analyse --no-progress > /tmp/g-deptrac.log 2>&1 &
p2=$!
PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run > /tmp/g-csfixer.log 2>&1 &
p3=$!
php bin/readability-lint.php > /tmp/g-readability.log 2>&1 &
p4=$!
php bin/accessibility-lint.php > /tmp/g-accessibility.log 2>&1 &
p5=$!
php bin/floor-syntax-lint.php > /tmp/g-floor.log 2>&1 &
p6=$!

wait $p1; ran "phpstan (level 8)" $?
wait $p2; ran "deptrac" $?
wait $p3; ran "php-cs-fixer (PSR-12, cold cache)" $?
wait $p4; ran "readability lint" $?
wait $p5; ran "accessibility lint" $?
wait $p6; ran "floor syntax lint" $?

# --- suites ------------------------------------------------------------------
if [ "$SERIAL" -eq 1 ]; then
    note "suites (serial — as CI runs them)"
    vendor/bin/codecept run Unit -x mysql -x s3 -x smtp > /tmp/g-unit.log 2>&1; ran "Unit" $?
    vendor/bin/codecept run Console > /tmp/g-console.log 2>&1;       ran "Console" $?
    vendor/bin/codecept run Functional -x mysql > /tmp/g-functional.log 2>&1; ran "Functional" $?
else
    note "suites (${SHARDS} shards each, parallel)"
    pids=()
    names=()
    for suite in Unit Functional; do
        for i in $(seq 1 $SHARDS); do
            # EACH SHARD GETS ITS OWN OUTPUT DIRECTORY, AND THIS IS A FIX
            # RATHER THAN TIDINESS. Sharing tests/_output/ across concurrent
            # runs loses results: on one run two Functional shards exited 0
            # having written no summary at all, which the count assertion below
            # then caught. Separate directories remove the race instead of
            # detecting it.
            # `-x mysql -x s3 -x smtp`: the tests that need a MySQL server
            # (round 800), an S3 server (802) or a mail relay (803) are left out here rather than
            # skipped, so a skip still means something went wrong and the
            # count below stays strict. CI runs each group in a job of its own.
            vendor/bin/codecept run "$suite" -x mysql -x s3 -x smtp --shard="$i/$SHARDS" \
                -o "paths: output: tests/_output/shard-$suite-$i" \
                > "/tmp/g-$suite-$i.log" 2>&1 &
            pids+=($!)
            names+=("$suite shard $i/$SHARDS")
        done
    done
    # Console is 6 tests in 8s. Sharding it would cost more in process startup
    # than it saves, so it runs whole — alongside the shards above.
    vendor/bin/codecept run Console > /tmp/g-console.log 2>&1 &
    pids+=($!)
    names+=("Console")

    for idx in "${!pids[@]}"; do
        wait "${pids[$idx]}"
        ran "${names[$idx]}" $?
    done

    # THE COUNT IS AN ASSERTION, NOT A PRINTOUT, AND THAT IS A CORRECTION.
    #
    # The first version printed the total and moved on. On one run it printed
    # "Unit: 0 tests across 3 shards" and still declared every gate passed —
    # every shard had exited 0, so the exit-code check was satisfied while the
    # arithmetic said nothing had run.
    #
    # It reproduced once this check existed: a later run reported two Functional
    # shards as "exited 0, no parseable result" and failed. Cause: concurrent
    # runs shared tests/_output/, so shards clobbered each other's results. The
    # shards now write to separate directories (above) and three consecutive
    # runs were stable at 2,582 and 50.
    #
    # THE CHECK STAYS ANYWAY, and that is the point. The race is fixed, but
    # "I could not tell how many tests ran" and "the tests passed" must never be
    # the same outcome again — whatever the next cause turns out to be.
    for suite in Unit Functional; do
        total=0
        for i in $(seq 1 $SHARDS); do
            n=$(grep -oE 'OK \(([0-9]+) tests' "/tmp/g-$suite-$i.log" 2>/dev/null | grep -oE '[0-9]+' | head -1)
            # Round 1095: a shard that holds a skipped test passes as "OK, but
            # incomplete, skipped, or useless tests!" with its count on the
            # next line. Read that too; it was taken for no result at all.
            [ -z "${n:-}" ] && n=$(grep -A1 'OK, but incomplete' "/tmp/g-$suite-$i.log" 2>/dev/null | grep -oE 'Tests: [0-9]+' | grep -oE '[0-9]+' | head -1)
            if [ -z "${n:-}" ]; then
                printf '   \033[31mFAIL\033[0m %s shard %s/%s exited 0 but printed no result — see /tmp/g-%s-%s.log\n' \
                    "$suite" "$i" "$SHARDS" "$suite" "$i"
                FAILED+=("$suite shard $i/$SHARDS (exited 0, no parseable result)")
                continue
            fi
            total=$((total + n))
        done
        # A floor rather than an exact number: the suites only grow, and an
        # exact expectation here would need editing every round, which is how
        # an expectation stops being read.
        floor=$([ "$suite" = "Unit" ] && echo 2500 || echo 45)
        if [ "$total" -lt "$floor" ]; then
            printf '   \033[31mFAIL\033[0m %s summed to %s tests, below the floor of %s\n' "$suite" "$total" "$floor"
            FAILED+=("$suite ran only $total tests")
        else
            printf '   %s: %s tests across %s shards\n' "$suite" "$total" "$SHARDS"
        fi
    done
fi

elapsed=$(( $(date +%s) - started ))

if [ ${#FAILED[@]} -gt 0 ]; then
    printf '\n\033[31m%s gate(s) failed in %ss:\033[0m\n' "${#FAILED[@]}" "$elapsed"
    for f in "${FAILED[@]}"; do printf '  - %s\n' "$f"; done
    printf '\nLogs are in /tmp/g-*.log\n'
    exit 1
fi

printf '\n\033[32mall gates passed in %ss\033[0m  (CI remains the authority — it also runs the browser suite)\n' "$elapsed"
