#!/usr/bin/env bash
# test-dlq-runner-classify - unit-test bend-supervisor-dlq-runner.sh's
# classify_reason() function. Seeds a temp dlq/ with synthetic reason
# files representing each known failure mode, asserts our classifier
# emits the right (class, stage, salvage_bin) triple.
#
# Independent of live LUMBDA_QUEUE_DIR; runs in mktemp -d. Safe on any
# host.
#
# Env contract per ~/git/lumbda/factory/CONTRACT.md:
#   LUMBDA_FACTORY_DIR   where bend-supervisor-dlq-runner.sh lives
#                        (default $HOME/git/lumbda/factory).
#
# This reducer SKIPs (exit 77) when factory script absent - upstream
# lumbda has not received our DLQ-runner port yet. After our port lands,
# this gate fires.
set -u

LUMBDA_FACTORY_DIR="${LUMBDA_FACTORY_DIR:-$HOME/git/lumbda/factory}"
DLQ_RUNNER="$LUMBDA_FACTORY_DIR/bend-supervisor-dlq-runner.sh"

if [ ! -f "$DLQ_RUNNER" ]; then
  echo "[dlq-runner-classify] SKIP - $DLQ_RUNNER not yet present"
  exit 77
fi

# Strip main loop so we can source classify_reason cleanly.
STUB=$(mktemp /tmp/dlq-runner-stub-XXXXXX.sh)
test_queue_dir=$(mktemp -d /tmp/lumbda-dlq-runner-test-XXXXXX)
mkdir -p "$test_queue_dir/dlq" "$test_queue_dir/rdlq"
export LUMBDA_QUEUE_DIR="$test_queue_dir"
export QUEUE_DIR="$test_queue_dir"
trap 'rm -f "$STUB"; rm -rf "$test_queue_dir"' EXIT

awk '/^log "bend-supervisor-dlq-runner start/ {exit} {print}' "$DLQ_RUNNER" > "$STUB"
# Drop EXIT/INT/TERM trap installers - they would interfere.
sed -i '/^trap on_exit EXIT$/d; /^trap .EXIT_REASON=signal-int/d; /^trap .EXIT_REASON=signal-term/d' "$STUB"

# shellcheck source=/dev/null
. "$STUB" 2>/dev/null

fails=0
pass() { echo "PASS $*"; }
fail() { echo "FAIL $*"; fails=$((fails + 1)); }

# assert_classify <label> <reason-text> <expected-class> <expected-stage> <expected-salvage>
assert_classify() {
    local label="$1" content="$2" exp_class="$3" exp_stage="$4" exp_salv="$5"
    local f="$test_queue_dir/dlq/__test_$$_$RANDOM.reason"
    printf '%s' "$content" > "$f"
    local got
    got=$(classify_reason "$f")
    local got_class got_stage got_salv
    got_class=$(echo "$got" | awk '{print $1}')
    got_stage=$(echo "$got" | awk '{print $2}')
    got_salv=$(echo "$got"  | awk '{print $3}')
    if [ "$got_class" = "$exp_class" ] && [ "$got_stage" = "$exp_stage" ] && [ "$got_salv" = "$exp_salv" ]; then
        pass "$label -> $got_class/$got_stage/$got_salv"
    else
        fail "$label expected=$exp_class/$exp_stage/$exp_salv got=$got_class/$got_stage/$got_salv"
    fi
    rm -f "$f"
}

echo "── classifier tests ──"

assert_classify "cuda-oom (typed Phase 7 portal)" \
    '(cuda-sim-error (code cuda-oom) (reason "out of memory at sim_gpu_packed.cu:179"))' \
    "cuda-oom" "sim" "yes"

assert_classify "cuda-oom (legacy stderr leak)" \
    '# dispatch foo.bin (0.5 GB) × 141 batches
wall_s=42
raw_resp=(error (no-portal "/tmp/bend-sim-ops-X.portal"))
--- bend worker log context ---
CUDA error cudaMalloc(&c->d_bits, ...) at sim_gpu_packed.cu:179: out of memory' \
    "cuda-oom" "sim" "yes"

assert_classify "cuda-illegal-addr (typed)" \
    '(cuda-sim-error (code cuda-illegal-addr) (reason "..."))' \
    "cuda-illegal-addr" "sim" "yes"

assert_classify "memory-cap-refused" \
    '(cuda-sim-error (code memory-cap-refused) (reason "peak host+device memory ~22.4 GB exceeds 16 GB cap"))' \
    "memory-cap-refused" "sim" "yes"

assert_classify "vram-budget-refused (Phase 8 fast-fail)" \
    '(cuda-sim-error (code vram-budget-refused) (reason "predicted 5.4 GB > free 1.2 GB at sim_gpu_packed_alloc; reduce n_batches"))' \
    "vram-budget-refused" "sim" "yes"

assert_classify "bin-load-fail" \
    '(cuda-sim-error (code bin-load-fail) (reason "load_ops_bin returned non-zero"))' \
    "bin-load-fail" "emit" "no"

assert_classify "no-portal (pre-Phase-7)" \
    'raw_resp=(error (no-portal "/tmp/bend-sim-ops-X.portal"))' \
    "no-portal" "sim" "yes"

# Note: queue path inside reason body deliberately reads /tmp/lumbda-queue
# (CONTRACT default) - classifier matches on `missing:` prefix not on path
# substring, so this reads cleanly under upstream's generic env.
assert_classify "missing-bin" \
    'missing: /tmp/lumbda-queue/foo-cell.bin' \
    "missing-bin" "dispatch" "yes"

assert_classify "tier=zero-magic (emit interrupted)" \
    'tier=zero-magic bin_bytes=146800656
reason: emit-interrupted' \
    "emit-broken" "emit" "no"

assert_classify "tier=zero-n_ops (emit-finalize never fired)" \
    'tier=zero-n_ops bin_bytes=146800656
reason: emit-finalize-never-fired' \
    "emit-broken" "emit" "no"

assert_classify "tier=above-max" \
    'tier=above-max bin_bytes=8144699544
reason: bin-too-large (over tier-huge max ...)' \
    "tier-classify" "emit" "no"

assert_classify "BISECT-DIAG (pool finalize race)" \
    'BISECT-DIAG: ipmul step6 swap target<->tmp
BISECT-DIAG: ipmul step7 free tmp
BISECT-DIAG: exit dgcd-raw-pa! dispatch arm' \
    "bisect-pool-race" "emit" "no"

assert_classify "transient cuda-error (catchall typed)" \
    '(cuda-sim-error (code cuda-error) (reason "transient driver"))' \
    "cuda-error-transient" "sim" "yes"

assert_classify "unknown (catchall)" \
    'completely unrelated text' \
    "unknown" "unknown" "no"

# 2026-06-13: empty / whitespace-only .reason files used to land in
# "unknown" class + rDLQ. Now classified as transient-empty-reason
# (auto-retry up to AUTO_RETRY_MAX before escalating). Catches our
# dispatcher socket-disconnect / minimal-output failure mode. Detection
# stays whitespace-aware so short-but-meaningful reasons (like
# "missing: ...foo.bin") never get swept into transient class.
assert_classify "empty .reason (0 bytes)" \
    '' \
    "transient-empty-reason" "sim" "yes"

assert_classify "whitespace-only .reason" \
    '

	' \
    "transient-empty-reason" "sim" "yes"

# Class-set checks - partitioning by is_auto_class / is_escalate_class
# must cover every named class exactly once.
echo ""
echo "── class-partition tests ──"

for c in missing-bin bisect-pool-race cuda-error-transient no-portal transient-empty-reason; do
    if is_auto_class "$c" && ! is_escalate_class "$c"; then
        pass "$c is auto (not escalate)"
    else
        fail "$c partitioning wrong"
    fi
done

for c in cuda-oom cuda-illegal-addr memory-cap-refused vram-budget-refused bin-load-fail tier-classify emit-broken unknown; do
    if is_escalate_class "$c" && ! is_auto_class "$c"; then
        pass "$c is escalate (not auto)"
    else
        fail "$c partitioning wrong"
    fi
done

echo ""
if [ "$fails" -gt 0 ]; then
    echo "[dlq-runner-classify] FAIL - $fails assertion(s) failed"
    exit 1
fi
echo "[dlq-runner-classify] PASS - all assertions OK"
exit 0
