#!/usr/bin/env bash
# darnlink-gate — the ONE generic darnlink quality-gate recipe for every repo that uses darnlink.
#
# WHY THIS EXISTS. darnlink is a pure link tool (it checks/reports; it deliberately knows nothing
# about gates, git, excludes-policy, or CI — see its Constitution). Every consumer repo needs the
# SAME orchestration around it, and until now each repo re-implemented it in its own `*_gate.sh`,
# so the wrappers drifted (the "strict ⊇ repair" myth, ignore-file vs ignore-links, un-pinned refs).
# This is that orchestration in ONE place. A consumer repo carries only a tiny config + a 3-line hook.
#
# WHAT IT DOES (all read-only — it never writes):
#   • runs darnlink at a PINNED ref (deterministic); fails OPEN if uv/uvx is missing (never
#     bootstraps) — unless fail-closed is on, see below;
#   • MODE picks which axes gate — three rungs of a one-way ratchet (each is a superset of the one
#     above, so raising MODE can only tighten the gate, never loosen it):
#     mode=repair → integrity only — a strict-only failure (3) is treated as clean (for repos that
#                   don't robustify their links yet);
#     mode=check  → integrity + strict (the default). Runs `darnlink check` (stable 0/2/3 contract);
#     mode=max    → integrity + strict + create-frontmatter = FAIL-CLOSED links: a link to a file
#                   that has no `uuid` fails the gate (see docs/elevating-your-link-gate.md). `check`
#                   has no create-frontmatter axis and the bare `--robustify --create-frontmatter` has
#                   no integrity axis, so max runs BOTH dry-run passes (check UNION create-frontmatter)
#                   and fails if either does — a true superset of check. WHOLE-REPO ONLY — the staged
#                   pre-commit stays at strict on purpose (fast, "is what I commit clean?"); the
#                   whole-repo wall (pre-push / CI) is where max is enforced.
#   • scope=repo  → judge the whole tree (the wall — use in CI);
#     scope=staged→ judge only the files you're committing (use in a multi-session pre-commit, so a
#                   teammate's in-flight plain link doesn't block your commit). The repo-wide wall
#                   stays in CI. [Option B of darnlink spec 008: git lives HERE, not in darnlink.]
#   • fails OPEN on a network/uvx error (offline commit isn't bricked) — UNLESS fail-closed is on.
#     ⚠️ FAIL-CLOSED (`DARNLINK_GATE_FAIL_CLOSED=1`, or `"fail_closed": true` in the json): in CI the
#     gate IS the wall, and failing open there means a GREEN BUILD WITH ZERO FILES VALIDATED on a
#     transient network/PyPI hiccup. Turn it ON in CI. It exits with code 4 (distinguishable from the
#     findings: 2 integrity / 3 strict).
#
# CONFIG. Reads `darnlink-gate.json` at the repo root (all keys optional):
#   { "ref": "git+https://github.com/txemi/darnlink@v0.7.0",
#     "excludes": ["secrets","external_repos"], "ignore_blocks": ["txmd-autogrid"],
#     "mode": "check", "scope": "repo", "fail_closed": true }
#   mode ∈ { repair | check | max } — see WHAT IT DOES. Raising it is a one-way ratchet: only up.
# ⚠️ In CI prefer the ENV VAR over the json key: reading the json needs python3, so if python3 is
#    missing the key is silently lost — and "python3 missing" is one of the very cases fail-closed
#    exists to catch. `DARNLINK_GATE_FAIL_CLOSED=1` is read by the shell and always applies.
# Env overrides (so one config serves both surfaces): DARNLINK_REF, DARNLINK_GATE_MODE,
# DARNLINK_GATE_SCOPE, DARNLINK_GATE_FAIL_CLOSED. Typical wiring: config says scope=repo; the
# pre-commit hook exports DARNLINK_GATE_SCOPE=staged; CI leaves it repo and sets FAIL_CLOSED=1.
#
# EXIT: 0 clean · 2 integrity failure · 3 strict-only failure · 1 usage / max-mode findings ·
#       4 could-not-gate (fail-closed only) · 0 + a stderr warning if it skips (fail-open, the default).
#       (mode=max reports findings via the dry-run's own non-zero exit — any non-zero fails the gate.)
set -euo pipefail

root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
cfg="$root/darnlink-gate.json"

# --- read config (JSON, all optional) via python; env wins over file ---
read_cfg() { python3 - "$cfg" "$1" "$2" <<'PY' 2>/dev/null || printf '%s' "$2"
import json, sys
cfg, key, default = sys.argv[1], sys.argv[2], sys.argv[3] if len(sys.argv) > 3 else ""
try:
    d = json.load(open(cfg, encoding="utf-8"))
except Exception:
    d = {}
v = d.get(key, default)
print("\n".join(v) if isinstance(v, list) else (v if v is not None else default))
PY
}

REF="${DARNLINK_REF:-$(read_cfg ref 'git+https://github.com/txemi/darnlink@v0.7.0')}"
MODE="${DARNLINK_GATE_MODE:-$(read_cfg mode check)}"
SCOPE="${DARNLINK_GATE_SCOPE:-$(read_cfg scope repo)}"
# FAIL-CLOSED. By default this recipe fails OPEN (don't brick an offline commit — see below). That
# is right for pre-commit and DANGEROUS in CI: there the gate IS the wall, and a transient network or
# PyPI hiccup would give a GREEN build with zero files validated. Turn it on in CI.
# Normalise first: `read_cfg` prints the raw Python value, so a JSON `false` arrives as the STRING
# "False" — a naive `!= "0"` test would read that as ON and brick the very consumer that asked to
# turn it OFF. Accept the obvious spellings on both sides.
FAIL_CLOSED="${DARNLINK_GATE_FAIL_CLOSED:-$(read_cfg fail_closed "")}"
case "${FAIL_CLOSED,,}" in ""|0|false|no|off) FAIL_CLOSED="" ;; *) FAIL_CLOSED=1 ;; esac
mapfile -t EXCLUDES < <(read_cfg excludes "")
mapfile -t IGNORE_BLOCKS < <(read_cfg ignore_blocks "")

# --- guard: this recipe is READ-ONLY. Never let a --write slip through it. ---
for a in "$@"; do
  case "$a" in
    --write) echo "darnlink-gate: refusing --write (this gate is read-only; robustify by hand: 'uvx --from $REF darnlink . --robustify --write')." >&2; exit 1;;
  esac
done

# ONE place decides what to do when the gate could NOT run: skip (default, pre-commit) or abort red
# (CI). Never "green without validating", which is the expensive silent failure.
bail() {  # $1 = reason
  if [ -n "$FAIL_CLOSED" ]; then
    echo "darnlink-gate: $1 -> FAILING (fail-closed is on: nothing was validated)." >&2
    exit 4
  fi
  echo "darnlink-gate: $1 -> SKIP; CI covers the wall." >&2
  exit 0
}

# --- build darnlink args (excludes + ignore-blocks) ---
DL_ARGS=()
for e in "${EXCLUDES[@]}";      do [ -n "$e" ] && DL_ARGS+=(--exclude "$e"); done
for b in "${IGNORE_BLOCKS[@]}"; do [ -n "$b" ] && DL_ARGS+=(--ignore-block "$b"); done

# --- fail OPEN if uv/uvx unreachable (don't brick offline commits; CI covers it) ---
if ! command -v uvx >/dev/null 2>&1; then bail "uvx not found"; fi

cd "$root"
run() { uvx --from "$REF" darnlink "$@"; }   # single source of the darnlink invocation

# Pre-flight: can uvx actually BUILD+RUN darnlink at this ref? darnlink's own exit codes (0/1/2/3)
# overlap a uvx fetch failure (bad ref / no network also exits low), so we can't tell "darnlink ran
# and found issues" from "couldn't run darnlink" by the check's exit code alone. `--help` runs iff
# darnlink is reachable. If it isn't → fail OPEN (don't brick a commit; CI covers the wall).
if ! run --help >/dev/null 2>&1; then bail "can't run darnlink at $REF (bad ref / no network)"; fi

if [ "$SCOPE" != "staged" ]; then
  # ---- whole-repo (the wall). darnlink's own exit code is the gate. ----
  # set +e around the run: darnlink exits 0/1/2/3 (findings ARE non-zero) — set -e must not kill us
  # before we read rc and run the rc>3 fail-open.
  set +e
  if [ "$MODE" = "max" ]; then
    # LEVEL 3 = `check` (integrity + strict) UNION the create-frontmatter axis. Neither half alone is
    # a superset of check: `check` runs plan_repairs+plan_robustify but has no create-frontmatter axis;
    # the bare `--robustify --create-frontmatter` ADDS create-frontmatter but DROPS integrity (it never
    # runs plan_repairs — a broken/moved robust link sails through). So run BOTH dry-run passes and
    # fail if either does. This makes max a TRUE superset of check (the ratchet holds). Both read-only.
    run check . "${DL_ARGS[@]}"; rc=$?
    if [ "$rc" -eq 0 ]; then run . --robustify --create-frontmatter "${DL_ARGS[@]}"; rc=$?; fi
  else
    run check . "${DL_ARGS[@]}"; rc=$?   # `darnlink check` → stable 0/2/3 contract (mode=check|repair)
  fi
  set -e
  # rc>3 (e.g. 127 network) → fail open + warn; darnlink's own low exit codes pass through.
  if [ "$rc" -gt 3 ]; then bail "darnlink unreachable (rc=$rc)"; fi
  # mode=repair gates on integrity only: a strict-only failure (3) is clean.
  if [ "$MODE" = "repair" ] && [ "$rc" -eq 3 ]; then exit 0; fi
  exit "$rc"
fi

# ---- staged scope (Option B): darnlink judges the whole tree; WE filter findings to staged files.
#      darnlink stays git-agnostic; the git lives here. Only fail on findings in files you're committing.
#      NOTE mode=max here behaves as strict (level 2), by design: the create-frontmatter axis needs
#      whole-tree reasoning, and per the wall architecture the staged pre-commit stays fast — max is
#      enforced at the whole-repo wall (pre-push / CI). See docs/elevating-your-link-gate.md §7.
# python3 does the filtering — fail OPEN if it's missing (don't brick a commit; CI covers the wall).
if ! command -v python3 >/dev/null 2>&1; then
  bail "(staged) python3 not found"
fi
mapfile -t STAGED < <(git diff --cached --name-only --diff-filter=ACMR -- '*.md' 2>/dev/null || true)
[ "${#STAGED[@]}" -eq 0 ] && { echo "darnlink-gate (staged): no staged .md — nothing to judge."; exit 0; }

# darnlink check exits 0/2/3 on findings (expected — we still get JSON); only rc>3 (e.g. 127) is
# "unreachable". Don't let set -e or a findings-exit trip the fail-open path.
set +e
DL_JSON="$(run check . --json "${DL_ARGS[@]}" 2>/dev/null)"; rc=$?
set -e
if [ "$rc" -gt 3 ] || [ -z "$DL_JSON" ]; then
  bail "(staged) darnlink unreachable (rc=$rc)"
fi
export DL_JSON DL_ROOT="$root" DL_REF="$REF" DL_MODE="$MODE"
export DL_STAGED="$(printf '%s\n' "${STAGED[@]}")"

# Pass everything via env (no interpolation into the script → no injection from finding text).
python3 <<'PY'
import json, os, sys
root = os.environ["DL_ROOT"]
mode = os.environ.get("DL_MODE", "check")
# realpath both sides: darnlink emits resolved absolute paths, so resolve symlinks here too to match.
staged = {os.path.realpath(os.path.join(root, p)) for p in os.environ["DL_STAGED"].split("\n") if p.strip()}
data = json.loads(os.environ["DL_JSON"])
def hits(axis):
    return [f for f in data.get(axis, {}).get("findings", []) if os.path.realpath(f["file"]) in staged]
integ = hits("integrity")
strict = [] if mode == "repair" else hits("strict")   # mode=repair gates on integrity only
for f in integ:  print(f"  [integrity/{f['kind']}] {f['file']}: {f['detail']}")
for f in strict: print(f"  [strict/{f['kind']}] {f['file']}: {f['detail']}")
if integ:  print("darnlink-gate (staged): integrity failure in a file you're committing."); sys.exit(2)
if strict: print("darnlink-gate (staged): un-anchored plain link in a file you're committing "
                 f"(anchor it: uvx --from {os.environ['DL_REF']} darnlink . --robustify --write)."); sys.exit(3)
print("darnlink-gate (staged): clean."); sys.exit(0)
PY
