#!/usr/bin/env bash
# scripts/test — run the suite (or a subset) in the precis-dev container
# against THIS worktree, with the RAM test DB wired, and terse "token-killer"
# output (warnings suppressed, short tracebacks). The canonical way to check
# tests while iterating.
#
# Why a wrapper: the host venv is torch-free, so a bare `uv run pytest` on the
# host fails with spurious ModuleNotFoundError; and `scripts/dev` bind-mounts
# MAIN, not your worktree, so it can't see your edits. This runs in the
# container over a bind-mount of the CURRENT worktree, exactly like the
# scripts/ship gate — so it tests what you're actually editing.
#
# The ship gate (scripts/ship, run by /land and /go) is still the
# authoritative pre-merge run; this is the fast iteration loop.
#
# Usage:
#   scripts/test                              # full suite (-n6)
#   scripts/test tests/test_foo.py            # one file
#   scripts/test -k "registry or env"         # a -k expression
#   scripts/test tests/test_foo.py::test_bar -x
#   scripts/test --fast                       # no-DB fast set (-m 'not db')
#   scripts/test --coverage [pytest args…]    # opt-in: run under pytest-cov with
#                                             # per-test contexts, leaving
#                                             # .coverage + .coverage.precis.json
#                                             # at the worktree root (feeds the
#                                             # python `tests` view's measured tier)
#   scripts/test --impacted                   # ONLY tests your changes affect
#   scripts/test --typecheck                  # `uv run mypy src tests` in the container
#   scripts/test --bg <args…>                 # detach the run; prints an --await line
#   scripts/test --await <run-id>             # bounded wait: exits with the run's
#                                             # code, or 124 = still running (re-run)
#
# Refuses with exit 3 (named message) when the docker VM disk has less than
# PRECIS_DOCKER_MIN_FREE_GB (default 10) free; PRECIS_DOCKER_DISK_CHECK=0 skips.
#
# Everything after the script name is passed straight to pytest. Default
# parallelism is -n6 (the measured sweet spot); pass your own -n0/-nN to
# override for a single fast test.
#
# PRECIS_TEST_KEEP_DB=1 : in a subagent worktree (basename `agent-*`) this
# script `compose down -v`s the tree's compose project at exit, so an idle
# subagent test-db stops holding one of docker's ~31 network address pools
# (see agent_db_teardown below). Set it to keep the warm db between runs.
#
# --impacted (-i): impact selection via pytest-testmon. Runs only the tests
# affected by your working-tree changes — the inner-loop accelerator when the
# full 140s suite is mostly per-worker DB-clone setup for tests you didn't
# touch. The FIRST run builds the test↔code map (a full run); after that each
# run selects just the affected tests (sub-second when nothing changed). Forces
# -n0 (testmon is per-process) and keeps a per-worktree map at ./.testmondata
# (gitignored). Still not authoritative — scripts/ship runs the full gate.
#
# NOTE: the dev image bakes all extras, so you never need `--with`/`--extra`
# here — that's only for the torch-free host (and the testmon plugin, which
# --impacted pulls in on the fly so no image rebuild is needed).
#
# --typecheck : the gate's mypy check (`uv run mypy src tests`), through the
# same container path as pytest — same compose project, same worktree bind
# mount — so it sees the SAME tree the rest of this script tests, and the
# baked-image extras (fastapi/rdkit/torch) that a bare host `uv run mypy`
# lacks. Skips the DB startup and the gate-slot wait: mypy touches no
# service and is light next to a full pytest run, so it doesn't need to
# queue behind the shared-VM gate-slot cap (gr202193/gr339251). Optional
# trailing args override the default `src tests` target.
set -euo pipefail
cd "$(dirname "$0")/.."
WORKTREE="$PWD"

# --help / -h : print usage and exit BEFORE any side effect (gr343798). This
# must stay the first thing checked — no gate slot, no tmpfs test-db, no
# container, no lock — so `scripts/test --help` is instant and never touches
# the fleet-wide gate.
usage() {
    cat <<'USAGE'
scripts/test — run the suite (or a subset) in the precis-dev container
against THIS worktree, with the RAM test DB wired.

Usage:
  scripts/test                              # full suite (-n6)
  scripts/test tests/test_foo.py            # one file
  scripts/test -k "registry or env"         # a -k expression
  scripts/test tests/test_foo.py::test_bar -x
  scripts/test --fast | -f                  # no-DB fast set (-m 'not db and not slow')
  scripts/test --coverage [pytest args…]    # per-test .coverage + manifest
  scripts/test --impacted | -i              # ONLY tests your changes affect (testmon)
  scripts/test --bg <args…>                 # detach the run; prints an --await line
  scripts/test --await <run-id>             # bounded wait: exits with the run's
                                             # code, or 124 = still running (re-run)
  scripts/test --help | -h                  # this message; no gate slot, no container

Everything after the script name (other than the flags above) is passed
straight to pytest. Default parallelism is -n6; pass your own -n0/-nN to
override for a single fast test.

Env: PRECIS_TEST_KEEP_DB=1 keeps the test-db up after the run. Without it, a
subagent worktree (basename agent-*) tears its compose project down at exit.

Two-call background protocol for capped foreground shells: `--bg <args>`
detaches the run and prints its run-id; poll with `--await <run-id>`
repeatedly (each call blocks up to PRECIS_TEST_AWAIT_BUDGET seconds, default
480, and exits 124 while still running — re-run the same command until it
returns the run's real exit code).

See the header comment in this script for the full rationale (why the
wrapper exists, --impacted/testmon details, the UV_WITH escape hatch).
USAGE
}
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
    usage
    exit 0
fi

# --bg / --await : two-call protocol for agent harnesses whose foreground
# shell calls are hard-capped (~10 min). A gate-congested run can't fit one
# call, and the cap KILLS the run mid-flight (a re-run starts over at the
# back of the gate queue) — which is what pushed subagents onto background-
# task notifications and the park-mid-verification stall. --bg detaches the
# run into its own session (survives the caller's shell exiting; the gate
# slot self-heals via the pid-dead steal if the run is abandoned); --await
# blocks in chunks that fit under the cap, so a long run is a sequence of
# short foreground calls with the agent active throughout. One run at a
# time — a second --bg queues behind the same gate slots and doubles load.
RUNS_DIR="${TMPDIR:-/tmp}/precis-test-runs"
if [[ "${1:-}" == "--bg" ]]; then
    shift
    mkdir -p "$RUNS_DIR"
    RUN_ID="$(date -u +%Y%m%d-%H%M%S)-$$"
    python3 - "$0" "${RUNS_DIR}/${RUN_ID}" "$@" <<'PY'
import os
import subprocess
import sys

script, base = sys.argv[1:3]
args = sys.argv[3:]
if os.fork() == 0:
    os.setsid()
    # Detach from the caller's stdio: the forked child inherits the
    # parent's fds, so a piped invocation (`--bg ... | tail`) would
    # otherwise never see EOF until the whole run finished.
    devnull = os.open(os.devnull, os.O_RDWR)
    for fd in (0, 1, 2):
        os.dup2(devnull, fd)
    with open(f"{base}.log", "wb") as out:
        try:
            rc = subprocess.call(
                [script, *args],
                stdin=subprocess.DEVNULL,
                stdout=out,
                stderr=subprocess.STDOUT,
            )
        except Exception:
            rc = 127
    with open(f"{base}.exit", "w", encoding="utf-8") as f:
        f.write(f"{rc}\n")
    os._exit(0)
PY
    echo "test run ${RUN_ID} detached (log: ${RUNS_DIR}/${RUN_ID}.log)"
    echo "poll it with:  scripts/test --await ${RUN_ID}"
    exit 0
fi
if [[ "${1:-}" == "--await" ]]; then
    RUN_ID="${2:?usage: scripts/test --await <run-id>}"
    LOG="${RUNS_DIR}/${RUN_ID}.log"
    EXITF="${RUNS_DIR}/${RUN_ID}.exit"
    [[ -f "$LOG" ]] || { echo "ERR: no such test run ${RUN_ID} (no log at ${LOG})" >&2; exit 1; }
    BUDGET="${PRECIS_TEST_AWAIT_BUDGET:-480}"
    START=$SECONDS
    while (( SECONDS - START < BUDGET )); do
        if [[ -f "$EXITF" ]]; then
            rc="$(tr -dc '0-9' <"$EXITF")"
            echo "── last 40 log lines (full log: ${LOG}) ──"
            tail -n 40 "$LOG"
            echo "test run ${RUN_ID} finished — exit ${rc:-1}"
            exit "${rc:-1}"
        fi
        sleep 5
    done
    echo "test run ${RUN_ID} STILL RUNNING after ${BUDGET}s — poll again with the same command:"
    echo "  scripts/test --await ${RUN_ID}"
    tail -n 2 "$LOG" 2>/dev/null || true
    exit 124
fi

# --impacted / -i : testmon impact-selection mode (see header). Strip the
# sentinel before the rest is forwarded to pytest.
# --fast / -f : the fast set — `-m 'not db and not slow'`: skips every test that
# needs Postgres (auto-marked `db` in tests/conftest.py) AND the heavy `slow`
# compute cluster. No Postgres is started, so it runs in seconds and is
# independent of the test DB. The right gate for pure-logic / docs / config
# changes; NOT authoritative before a deploy (/go still runs the full suite).
IMPACTED=0
FAST=0
# --coverage : opt-in per-test-context coverage (NOT the default; the ship
# gate keeps its own coverage path). Passes pytest-cov's
# `--cov=src --cov-context=test` (dynamic_context=test_function under the
# hood) so .coverage gains a per-test context dimension, then writes
# .coverage.precis.json — a sha256-per-measured-file manifest the python
# `tests` view verifies against indexed bytes before trusting the DB.
COVERAGE=0
if [[ "${1:-}" == "--coverage" ]]; then COVERAGE=1; shift; fi
if [[ "${1:-}" == "--impacted" || "${1:-}" == "-i" ]]; then IMPACTED=1; shift; fi
if [[ "${1:-}" == "--fast" || "${1:-}" == "-f" ]]; then FAST=1; shift; fi

INFRA_COMPOSE="${PRECIS_COMPOSE:-${PWD}/docker/dev/compose.yaml}"
[[ -f "$INFRA_COMPOSE" ]] || {
    echo "ERR: compose file not found at ${INFRA_COMPOSE} (set PRECIS_COMPOSE)" >&2
    exit 1
}
# Per-worktree compose project so this worktree's precis-test-db is isolated
# from every sibling's, instead of all colliding on the default project `dev`
# (gr176375). See scripts/lib/compose-project.sh.
source "${WORKTREE}/scripts/lib/compose-project.sh"
COMPOSE_PROJECT="$(compose_project_for "$WORKTREE")"
# Gate-slot admission (gr202193): the test container runs against one shared
# Docker VM ceiling with every sibling worktree's — cap concurrency so runs
# queue briefly instead of OOM-killing each other at random (exit 137).
source "${WORKTREE}/scripts/lib/gate-slot.sh"
# Disk preflight: a docker VM disk near full dies later as a raw ENOSPC with no
# test output, which reads as a red run. Refuse now (exit 3), before any compose
# call, naming the cause. Unknown free space only warns; PRECIS_DOCKER_DISK_CHECK=0
# skips. --typecheck is checked too (mypy writes caches).
source "${WORKTREE}/scripts/lib/docker-disk.sh"
docker_disk_preflight "scripts/test" || exit $?
# TESTMON_LOG stays empty outside --impacted mode; cleanup() no-ops on it
# then (see the testmon-exit-guard block near the bottom for why impacted
# mode captures pytest's output to a file instead of streaming it raw).
TESTMON_LOG=""
# Subagent trees (basename `agent-*`, the key compose-project.sh uses) never
# resume, and every compose project holds one docker network address pool
# (~31 on this host) for as long as its test-db exists — idle subagent dbs ran
# the pool dry fleet-wide on 2026-10-03. So tear the project down at exit
# unless PRECIS_TEST_KEEP_DB=1 (keep the warm db) or another container of the
# project (a concurrent scripts/test run, a gate) is still RUNNING — the same
# guard scripts/reap-test-dbs uses. Best-effort: never fails, and cleanup()
# re-raises the script's real exit status around it.
#
# A running container alone misses a second run of the same tree that is
# between `compose up --wait precis-test-db` and starting its own container
# (round-3 review of ae5084ae3): `down -v` would delete the db under it. So a
# db-using run leaves a pid marker in the tree's git dir BEFORE it brings the
# db up, and the teardown also stands down while any other marker's pid is
# alive (EPERM from kill -0 = alive). The marker scan runs last, right before
# the `down`, which leaves only that gap open.
RUN_MARKER_DIR=""
RUN_MARKER=""
run_marker_write() {
    case "${WORKTREE##*/}" in agent-*) ;; *) return 0 ;; esac
    local gd
    gd="$(git -C "$WORKTREE" rev-parse --absolute-git-dir 2>/dev/null)" || return 0
    RUN_MARKER_DIR="$gd/precis-test-runs"
    mkdir -p "$RUN_MARKER_DIR" 2>/dev/null || return 0
    RUN_MARKER="$RUN_MARKER_DIR/$$"
    : >"$RUN_MARKER" 2>/dev/null || RUN_MARKER=""
}
pid_alive() {
    local err
    kill -0 "$1" 2>/dev/null && return 0
    err="$(kill -0 "$1" 2>&1)"
    [[ "$err" == *"ermitted"* ]]
}
other_run_alive() {
    [[ -n "$RUN_MARKER_DIR" && -d "$RUN_MARKER_DIR" ]] || return 1
    local m pid
    for m in "$RUN_MARKER_DIR"/*; do
        [[ -e "$m" ]] || continue
        pid="${m##*/}"
        [[ "$pid" == "$$" ]] && continue
        case "$pid" in ''|*[!0-9]*) continue ;; esac
        if pid_alive "$pid"; then return 0; fi
        rm -f "$m"
    done
    return 1
}
agent_db_teardown() {
    [[ -n "$RUN_MARKER" ]] && rm -f "$RUN_MARKER"
    [[ "$FAST" == "1" ]] && return 0
    [[ -n "${PRECIS_TEST_KEEP_DB:-}" ]] && return 0
    case "${WORKTREE##*/}" in agent-*) ;; *) return 0 ;; esac
    local svc services
    services="$(docker ps --filter "label=com.docker.compose.project=${COMPOSE_PROJECT}" \
        --format '{{.Label "com.docker.compose.service"}}' 2>/dev/null)" || return 0
    while IFS= read -r svc; do
        [[ -z "$svc" || "$svc" == "precis-test-db" ]] && continue
        return 0
    done <<<"$services"
    other_run_alive && return 0
    env UID="$(id -u)" GID="$(id -g)" docker compose -f "$INFRA_COMPOSE" \
        -p "$COMPOSE_PROJECT" --profile dev down -v >/dev/null 2>&1 || true
    return 0
}
cleanup() {
    # Capture the real exit status up front and re-raise it at the end: the
    # best-effort teardown below must never change what the caller sees.
    local rc=$?
    # --coverage: fingerprint the measured bytes alongside the DB so the
    # python `tests` view can verify the DB against its index. Best-effort,
    # host-side (stdlib only); a failed write just means the view reports
    # the DB as unverifiable instead of measured.
    if [[ "$COVERAGE" == "1" && -f .coverage ]]; then
        python3 "${WORKTREE}/scripts/lib/coverage_manifest.py" .coverage \
            || echo "WARNING: .coverage.precis.json not written — the tests view will report the DB as unverified" >&2
    fi
    gate_slot_release
    # `if`, not `[[ ]] && …`: an EXIT trap's own exit status becomes the
    # script's exit status, and `&&` on a false test would silently turn
    # every non-impacted (TESTMON_LOG unset) run's real 0 into a bogus 1.
    if [[ -n "$TESTMON_LOG" && "${KEEP_TESTMON_LOG:-0}" != "1" ]]; then
        rm -f "$TESTMON_LOG"
    fi
    agent_db_teardown || true
    exit "$rc"
}
trap cleanup EXIT
compose() { env UID="$(id -u)" GID="$(id -g)" docker compose -f "$INFRA_COMPOSE" -p "$COMPOSE_PROJECT" --profile dev "$@"; }

# --typecheck : reuses the compose project/mount above but skips the test-db
# startup and the gate-slot wait entirely (see header) — runs mypy and exits
# with its status, never falling through to the pytest path below.
if [[ "${1:-}" == "--typecheck" ]]; then
    shift
    MYPY_ARGS=("$@")
    [[ ${#MYPY_ARGS[@]} -eq 0 ]] && MYPY_ARGS=(src tests)
    compose run --rm --no-deps -v "${WORKTREE}":/app precis-dev \
        bash -lc 'uv run --no-sync mypy "$@"' _ "${MYPY_ARGS[@]}"
    exit $?
fi

# Co-located RAM test DB (the same service scripts/ship's gate uses). If it
# can't start, fall back to whatever PRECIS_TEST_PG_URL the container carries.
# --fast runs `-m 'not db'`, so no DB test is selected — skip the DB startup
# entirely (a real time saver and it makes the fast path Docker-DB-independent).
# One BLAS/OpenMP thread per xdist worker. Uncapped, every worker that
# touches torch/numpy sizes its pool to all container cores (15 on the M-series
# hosts), so `-n 6` runs ~190 runnable threads on 15 cores and a torch test
# that takes seconds alone crawls for tens of minutes under coverage tracing —
# the "wedged gate" that never names a wedged test (gr345784). Set here, in
# the container env, so every runtime (libgomp, OpenBLAS, MKL, numexpr) reads
# it at load, before any test module imports torch. The same keys sit on the
# warm precis-gate service in docker/dev/compose.yaml.
THREAD_CAP_ENV=(
    -e OMP_NUM_THREADS=1 -e MKL_NUM_THREADS=1 -e OPENBLAS_NUM_THREADS=1
    -e NUMEXPR_NUM_THREADS=1 -e VECLIB_MAXIMUM_THREADS=1
)

TEST_DB_ENV=()
if [[ "$FAST" == "1" ]]; then
    :  # no DB tests selected — don't pay for Postgres
elif run_marker_write; DB_UP_OUT="$(compose up -d --wait precis-test-db 2>&1)"; then
    TEST_DB_ENV=(-e "PRECIS_TEST_PG_URL=postgresql://postgres@precis-test-db:5432/precis_test")
else
    echo "WARNING: precis-test-db didn't start — falling back to the container's default test DB" >&2
    # Every worktree's project owns one compose network; idle ones used up
    # docker's address pools on 2026-10-03 and failed every scripts/test in the
    # fleet with only this compose error. Name the remedy.
    if [[ "$DB_UP_OUT" == *"fully subnetted"* ]]; then
        echo "docker is out of network address pools (idle worktree test-dbs hold them): run scripts/reap-test-dbs; PRECIS_REAP_DB_MIN_AGE_SECONDS=7200 widens it to thread trees idle 2 h" >&2
    fi
fi

# Default -n6 unless the caller set their own -n (single fast test → pass -n0).
NPROC=(-n6)
for a in "$@"; do [[ "$a" == -n* ]] && NPROC=(); done

# Impacted mode: pull in the testmon plugin on the fly (no image rebuild),
# enable selection, force -n0 (testmon is per-process — xdist fragments the
# map), and persist the map per-worktree at /app/.testmondata (the bind-mount,
# so it survives on the host between runs). UV_WITH is expanded unquoted inside
# the container so an empty value adds nothing in normal mode.
#
# A caller-set UV_WITH passes through — the escape hatch for a worktree that
# ADDS a core dependency the baked image doesn't have yet:
#   UV_WITH="--with newdep" scripts/test tests/test_newdep.py
# (the ship gate itself runs `uv run` WITH sync, so it resolves new deps from
# uv.lock without an image rebuild; this passthrough gives the fast loop the
# same reach).
UV_WITH="${UV_WITH:-}"
# --coverage: no report artifacts (just the sqlite DB + manifest); pytest-cov
# is a baked dev dep. Works with or without --impacted.
COV_ARGS=()
if [[ "$COVERAGE" == "1" ]]; then
    COV_ARGS=(--cov=src "--cov-context=test" "--cov-report=")
fi
TESTMON_ARGS=()
TESTMON_ENV=()
if [[ "$IMPACTED" == "1" ]]; then
    # gr451018: a first-time map build runs the whole suite serial under
    # tracing and has wedged for hours with no test name. pytest-timeout's
    # thread method (signal can't interrupt threaded tests) dumps every
    # thread's stack + the test id, then exits non-zero — a real failure to
    # testmon-exit-guard (no INTERNALERROR, so no rc-75 plain re-run).
    UV_WITH="${UV_WITH:+${UV_WITH} }--with pytest-testmon --with pytest-timeout"
    TESTMON_ARGS=(--testmon "--timeout=${PRECIS_IMPACTED_TEST_TIMEOUT:-900}" --timeout_method=thread)
    TESTMON_ENV=(-e "TESTMON_DATAFILE=/app/.testmondata")
    NPROC=(-n0)
    # pytest-testmon's own bookkeeping can INTERNALERROR on an extensionless
    # file that reached its fingerprint set (get_file, testmon_core.py ~93)
    # — sometimes AFTER pytest already printed a clean "N passed" summary,
    # so the raw exit code stops meaning "tests failed" (gr450298, gr449802).
    # Capture the run to a file (still streamed live via tee) so
    # testmon-exit-guard.py can tell a real failure from testmon's own crash.
    TESTMON_LOG="$(mktemp "${TMPDIR:-/tmp}/precis-test-testmon.XXXXXX")"
fi

# Fast mode: deselect the whole DB suite (auto-applied `db` marker) AND the
# heavy `slow` cluster. Both matter: the slowest two tests in the suite are
# no-DB compute tests in the `slow` pathway file, so `not db` alone would let
# them leak into the "fast" set. See docs/conventions/testing.md.
FAST_ARGS=()
if [[ "$FAST" == "1" ]]; then FAST_ARGS=(-m "not db and not slow"); fi

# gr261537: testmon's affected-tests selection doesn't know about the `slow`
# marker at all — a central-module diff can pull the 81 heavy real-EMT
# autocatpath sim tests (tests/test_pathway_plugin.py, already marked `slow`)
# into a selection that's ALSO forced to -n0, so impacted mode ends up slower
# than a plain full `scripts/test -n6`. Exclude `slow` by default under
# --testmon, same as --fast does, unless the caller passed their own `-m`
# (checked the same way NPROC checks for a caller-supplied `-n` above) or is
# already running --fast (whose `-m` already excludes `slow`).
TESTMON_MARKER_ARGS=()
if [[ "$IMPACTED" == "1" && "$FAST" != "1" ]]; then
    HAS_CALLER_MARKER=0
    for a in "$@"; do [[ "$a" == -m* ]] && HAS_CALLER_MARKER=1; done
    if [[ "$HAS_CALLER_MARKER" == "0" ]]; then
        TESTMON_MARKER_ARGS=(-m "not slow")
    fi
fi

# Take a fleet-wide gate slot just before the heavyweight container run (the
# tiny tmpfs test-db above doesn't need one).
gate_slot_acquire

# Artifact-writing tests (the PCB fab render, chiefly) need to be TOLD where to
# put their output, and nothing but UV_WITH used to cross the container
# boundary — so producing a board to look at meant hand-editing a default path
# into the test and reverting it afterwards. The path is interpreted INSIDE the
# container, where this worktree is mounted at /app, so a repo-relative path
# lands on the host.
ARTIFACT_ENV=()
for _render_var in PRECIS_PCB_RENDER_OUT PRECIS_PCB_RENDER_SEED PRECIS_PCB_RENDER_FIXTURE PRECIS_PCB_RENDER_ITERS PRECIS_WATCH_SEED PRECIS_UPDATE_KINETICS_GOLDEN PRECIS_EPRO_FIXTURE PRECIS_PCB_REPLAY_FIXTURE; do
    if [[ -n "${!_render_var:-}" ]]; then
        ARTIFACT_ENV+=(-e "${_render_var}=${!_render_var}")
    fi
done

# --no-sync: the image already bakes the venv and installs /app editable, so
# the bind-mounted source is live without a per-run reinstall (that reinstall
# was pure noise + latency — the reason sessions hand-rolled `--no-sync`).
# -p no:warnings drops the warnings summary (noise while iterating); --tb=short
# keeps tracebacks compact; -q drops per-test verbosity. Pass pytest args as
# inner positional params ("$@") so no cross-shell re-quoting is needed.
#
# Preflight (gr286507): a stale baked venv (new core dep promoted, image not
# yet rebuilt) otherwise surfaces as ModuleNotFoundError red tests in whatever
# file pytest collects first — unrelated to the change under test, and
# expensive to root-cause from cold. check-core-deps.py imports every
# `[project] dependencies` entry under the SAME `uv run --no-sync ${UV_WITH}`
# prefix as pytest (so a UV_WITH bridge is honoured here too) and fails loud,
# before pytest starts, naming the missing module(s) and the fix. One extra
# `uv run` in the same container — no second container spin-up.
#
# check-entry-points.py (gr453828) is its sibling for the same stale-baked-venv
# failure mode: `importlib.metadata.entry_points()` reads the STATIC
# `entry_points.txt` baked into the venv at image-build time, which the
# source-only bind-mount + `--no-sync` never regenerate. An image built before
# an entry-points change (e.g. the nm->se merge) hands wrong answers to any
# test reading the real `entry_points(group="precis.handlers")` while every
# import still succeeds — no red module error to point at. It diffs the
# worktree's `[project.entry-points.*]` against the installed dist-info;
# `--heal` reinstalls into this run's throwaway container (~3 s) and fails
# loud only if drift survives that. UV_WITH can't bridge metadata.
# All three steps run inside ONE `uv run` (gr462134): with UV_WITH set,
# every `uv run --with` builds a fresh overlay env, so a heal in its own
# `uv run` patched an env pytest never saw and plugin migrations (se)
# silently went undiscovered. `-P` keeps `-m` from prepending the cwd
# (/app) to sys.path, so imports resolve exactly as bare `pytest`'s did;
# bare `pytest` itself would run the base venv's interpreter, not the
# healed overlay.
# One pytest container run. $1 = "testmon" adds the testmon args, anything
# else runs plain (the gr471933 fallback); remaining args are the caller's.
run_pytest() {
    local mode="$1"; shift
    local -a tm_args=()
    if [[ "$mode" == "testmon" ]]; then
        tm_args=("${TESTMON_ARGS[@]+"${TESTMON_ARGS[@]}"}")
    fi
    compose run --rm --no-deps -e UV_LINK_MODE=copy -e UV_WITH="$UV_WITH" \
        "${THREAD_CAP_ENV[@]}" \
        "${TEST_DB_ENV[@]+"${TEST_DB_ENV[@]}"}" "${TESTMON_ENV[@]+"${TESTMON_ENV[@]}"}" \
        "${ARTIFACT_ENV[@]+"${ARTIFACT_ENV[@]}"}" \
        -v "${WORKTREE}":/app precis-dev \
        bash -lc 'uv run --no-sync ${UV_WITH} bash -c "python3 scripts/lib/check-core-deps.py && python3 scripts/lib/check-entry-points.py --heal && exec python3 -P -m pytest -q --no-header --tb=short -p no:warnings \"\$@\"" _ "$@"' \
        _ "${NPROC[@]+"${NPROC[@]}"}" "${tm_args[@]+"${tm_args[@]}"}" \
        "${FAST_ARGS[@]+"${FAST_ARGS[@]}"}" "${TESTMON_MARKER_ARGS[@]+"${TESTMON_MARKER_ARGS[@]}"}" \
        "${COV_ARGS[@]+"${COV_ARGS[@]}"}" "$@"
}

if [[ -n "$TESTMON_LOG" ]]; then
    # --impacted: stream to the terminal AND capture, so a post-run guard can
    # see the same text a human would tail (see the TESTMON_LOG comment
    # above). set +e/-e brackets the pipeline: `pipefail` makes a nonzero
    # pytest exit fail the whole statement, which would otherwise trip
    # `set -e` before PYTEST_RC is read.
    set +e
    run_pytest testmon "$@" 2>&1 | tee "$TESTMON_LOG"
    PYTEST_RC=${PIPESTATUS[0]}
    python3 "${WORKTREE}/scripts/lib/testmon-exit-guard.py" "$TESTMON_LOG" "$PYTEST_RC" "$WORKTREE"
    GUARD_RC=$?
    set -e
    # gr471933: rc 75 = testmon crashed with no pytest summary (the guard has
    # already printed the traceback tail + log path; keep that log). Degrade
    # to a plain run of the same files in this call when the caller named
    # any; otherwise say how.
    if [[ "$GUARD_RC" == "75" ]]; then
        KEEP_TESTMON_LOG=1
        HAS_PATHS=0
        for a in "$@"; do
            if [[ "$a" != -* && ( "$a" == *.py* || "$a" == *::* || -e "$a" ) ]]; then HAS_PATHS=1; fi
        done
        if [[ "$HAS_PATHS" == "1" ]]; then
            echo "testmon crashed; re-running the same tests once WITHOUT --testmon (plain mode)" >&2
            set +e
            run_pytest plain "$@"
            GUARD_RC=$?
            set -e
        else
            echo "testmon crashed and no test paths were given; run the fallback yourself: scripts/test <test files>   (or plain, no impact selection: scripts/test)" >&2
        fi
    fi
    exit "$GUARD_RC"
else
    run_pytest testmon "$@"
fi
