#!/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,
#     "web": true, "create_readme": true, "create_readme_excludes": ["mirrors"] }
#   mode ∈ { repair | check | max } — see WHAT IT DOES. Raising it is a one-way ratchet: only up.
#   web (mode=max only): add a `web-check --online` pass — cross-repo links to OTHER GitHub repos must
#     resolve to the destination's uuid (read online). Public targets need no token; private ones send
#     $GITHUB_TOKEN if set, else `web_unverifiable` (a warning, never a failure). Fail-closed on a broken
#     public web link.
#   create_readme (ANY mode, since the mirror update): also run `--create-readme` — a directory link whose
#     target folder has no README (no uuid to anchor to) FAILS the gate (dry-run detects it; fix with
#     `--create-readme --write`). It runs as its OWN dry-run pass, filtered to the `create_readme` findings,
#     so it adds the folder axis on top of mode=check / mode=repair too — not only mode=max. (In mode=max
#     with NO create_readme_excludes it stays FOLDED into the max robustify pass, exactly as before — that
#     path is untouched.) The reason for the own-pass: it lets create_readme_excludes bite only this axis.
#   create_readme_excludes (default []): extra directory-name globs applied ONLY to the create-readme pass,
#     LAYERED ON TOP of `excludes`. The point for a repo with a big `mirrors/` tree: skip README-creation
#     under the mirror (we do not invent a README for an external system's export) WITHOUT excluding it from
#     the integrity/robustify axes — inbound links INTO the mirror must still validate. Absent = empty =
#     old behavior. When non-empty in mode=max, create-readme moves out to the separate pass so the excludes
#     never leak into robustify (folding it in would drop mirror dir-links from robustify too — wrong).
# ⚠️ 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 or create-readme
#       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; the create-readme axis fails with 1 unless a higher code already applies.)
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
# WEB (opt-in): when on, mode=max adds a 3rd whole-repo pass — `web-check --online` — that verifies
# cross-repo Markdown links to OTHER GitHub repos still resolve to the destination file's uuid (read
# online, anchored with `<!-- web-uuid: X -->`). Public destinations need NO token; private ones send
# $GITHUB_TOKEN if set, else are reported `web_unverifiable` (a warning, NOT a failure — never a crash).
# Fail-closed only on a genuinely broken/moved public web link. Same normalise-the-string dance as above.
WEB="${DARNLINK_GATE_WEB:-$(read_cfg web "")}"
case "${WEB,,}" in ""|0|false|no|off) WEB="" ;; *) WEB=1 ;; esac
# CREATE_README (opt-in): when on, mode=max also runs `--create-readme` — a directory link whose target
# folder has no README (so no uuid to anchor to) FAILS the gate (dry-run: it's DETECTED, not written).
# Robustify it by hand with `--create-readme --write`. Raises the max ceiling from "files" to "folders".
CREATE_README="${DARNLINK_GATE_CREATE_README:-$(read_cfg create_readme "")}"
case "${CREATE_README,,}" in ""|0|false|no|off) CREATE_README="" ;; *) CREATE_README=1 ;; esac
mapfile -t EXCLUDES < <(read_cfg excludes "")
mapfile -t IGNORE_BLOCKS < <(read_cfg ignore_blocks "")
# CREATE_README_EXCLUDES: extra directory globs that apply ONLY to the create-readme pass (see the
# create_readme_excludes note in CONFIG). Absent = one empty element → treated as empty everywhere.
mapfile -t CREATE_README_EXCLUDES < <(read_cfg create_readme_excludes "")

# --- 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

# --- create-readme axis (opt-in, ANY mode) ---------------------------------------------------------
# Runs `--create-readme` (a dry-run) and reports ONLY the `create_readme` findings — a directory link
# whose target folder has no README. It takes its OWN excludes (CREATE_README_EXCLUDES) layered ON TOP
# of the global ones, so a repo can skip README-creation under `mirrors/` (external export — we don't
# invent a README for it) WITHOUT dropping the mirror from integrity/robustify (inbound links into the
# mirror must still validate). This is the SAME JSON-by-kind filter the staged scope uses below, reused
# here to filter by kind on a whole-repo pass. Prints the offender count on stdout ("ERR" if it could
# not evaluate); the offending files go to stderr for the human. `--create-readme` implies
# `--create-frontmatter`, so the JSON also carries `robustify` findings — we deliberately keep only
# `create_readme`, which is what keeps the mirror's dir-links from flooding in as robustify offenders.
create_readme_offenders() {
  command -v python3 >/dev/null 2>&1 || { echo ERR; return 0; }
  local cr_args=("${DL_ARGS[@]}") e
  for e in "${CREATE_README_EXCLUDES[@]}"; do [ -n "$e" ] && cr_args+=(--exclude "$e"); done
  local json jrc tmp
  set +e
  # `--create-readme` only fires under `--robustify` (without it, darnlink runs the repair/integrity path
  # and never plans a README — see cli.py dispatch). `--create-readme` implies `--create-frontmatter`; we
  # pass it explicitly for clarity. We keep ONLY the `create_readme` findings from the JSON, so carrying
  # `--robustify` here does NOT add robustify offenders to this axis — the caller's robustify axis is the
  # separate `check` (or max) pass.
  json="$(run . --robustify --create-frontmatter --create-readme "${cr_args[@]}" --json 2>/dev/null)"; jrc=$?
  set -e
  # rc>3 (e.g. 127 network) or empty output = couldn't run darnlink → let the caller bail, never green.
  if [ "$jrc" -gt 3 ] || [ -z "$json" ]; then echo ERR; return 0; fi
  # Pass the JSON via a TEMP FILE, not an env var / argv — a whole-repo create-readme dump carries every
  # ignore-links finding too and can run to megabytes, well past ARG_MAX (a big mirror is exactly the case
  # this feature exists for). The path is short; python reads the file.
  tmp="$(mktemp)"; printf '%s' "$json" > "$tmp"
  python3 - "$tmp" <<'PY'
import json, sys
try:
    with open(sys.argv[1], encoding="utf-8") as fh:
        d = json.load(fh)
except Exception:
    print("ERR"); sys.exit(0)
off = [f for f in d.get("findings", []) if f.get("kind") == "create_readme"]
for f in off:
    sys.stderr.write(f"  [create-readme] {f.get('file')}: {f.get('detail')}\n")
print(len(off))
PY
  rm -f "$tmp"
}

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.
    # create_readme raises the ceiling: directory targets must have a README too (dry-run = detect it).
    MAX_ROBUSTIFY=(--robustify --create-frontmatter)
    # create_readme FOLDS into the max robustify pass ONLY in the legacy shape (no create_readme_excludes):
    # keeps existing mode=max behavior byte-for-byte. WITH create_readme_excludes it moves to the separate
    # create-readme pass below, so those excludes hit README-creation ONLY (never robustify/integrity — a
    # mirror must still have its inbound links validated). FOLDED_CR marks that the axis is already covered
    # here, so the fall-through does not run it a second time.
    if [ -n "$CREATE_README" ] && [ -z "${CREATE_README_EXCLUDES[*]}" ]; then
      MAX_ROBUSTIFY+=(--create-readme); FOLDED_CR=1
    fi
    run check . "${DL_ARGS[@]}"; rc=$?
    if [ "$rc" -eq 0 ]; then run . "${MAX_ROBUSTIFY[@]}" "${DL_ARGS[@]}"; rc=$?; fi
    # WEB pass (opt-in, mode=max only): cross-repo web-link robustness, only if the core passed (so a
    # core failure surfaces first). web-check takes the SAME `--exclude` and `--ignore-block` as the core
    # (since 0.12.0) — pass both so it skips vendored clones / mirrors instead of fetching and anchoring
    # web links INSIDE someone else's checkout. Fail-closed on a broken public web link; web_unverifiable
    # (private w/o token, or offline) is exit 0. Needs a ref with web-check + its --exclude (v0.12.0+).
    if [ "$rc" -eq 0 ] && [ -n "$WEB" ]; then
      # web-check needs $GITHUB_TOKEN for PRIVATE destinations (public are tokenless). If it's not
      # already exported, read it from a read-only PAT FILE (default ~/.config/github_token_ro; override
      # with DARNLINK_GATE_TOKEN_FILE) — a git hook's env usually lacks it. Without a token a private
      # destination 404s and reads as broken; with it, it verifies. Missing file → stays unverifiable.
      if [ -z "${GITHUB_TOKEN:-}" ]; then
        _tf="${DARNLINK_GATE_TOKEN_FILE:-$HOME/.config/github_token_ro}"
        # -f: a REGULAR file (not a directory — `-r` alone passes on a dir, then `< dir` fails).
        [ -f "$_tf" ] && [ -r "$_tf" ] && { GITHUB_TOKEN="$(tr -d '\r\n' < "$_tf")"; export GITHUB_TOKEN; }
      fi
      WEB_ARGS=()
      for e in "${EXCLUDES[@]}";      do [ -n "$e" ] && WEB_ARGS+=(--exclude "$e"); done
      for b in "${IGNORE_BLOCKS[@]}"; do [ -n "$b" ] && WEB_ARGS+=(--ignore-block "$b"); done
      run web-check . --online "${WEB_ARGS[@]}"; rc=$?
      set -e
      # web-check's contract (all in 0..4, none of them "unreachable"): 0 = clean (a network/token
      # failure is reported web_unverifiable, still exit 0 — it NEVER exits high); 3 = a plain web link
      # not yet anchored (dry-run: it COULD be robustified) — a real gate failure, which is the point
      # (detect un-robustified web links); 4 = a broken/moved/mismatched public web link. So EVERY
      # non-zero is fail-closed, NOT the core's rc>3 "unreachable" case — exit it DIRECTLY, bypassing the
      # bail heuristic below (which would swallow a genuine 4 as "network hiccup" and go green).
      exit "$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. (Set rc=0 rather than exit
  # so the create-readme axis below still runs — it is an independent axis, not part of strict.)
  if [ "$MODE" = "repair" ] && [ "$rc" -eq 3 ]; then rc=0; fi
  # create-readme axis: works under check/repair too (and mode=max WITH create_readme_excludes). Skipped
  # when already folded into the max robustify pass above (legacy max, no create_readme_excludes).
  if [ -n "$CREATE_README" ] && [ -z "${FOLDED_CR:-}" ]; then
    cr="$(create_readme_offenders)"
    if [ "$cr" = "ERR" ]; then
      # Couldn't evaluate the OPTIONAL create-readme axis. Never turn an ALREADY-red gate green: if the
      # core check/max pass already failed (rc!=0 — integrity/strict), keep that failure. Only when the
      # core was clean do we defer to the could-not-gate policy (bail: fail-open skip / fail-closed 4).
      if [ "$rc" -ne 0 ]; then
        echo "darnlink-gate: create-readme axis could not run, but the core gate is already failing (rc=$rc) — keeping it." >&2
        exit "$rc"
      fi
      bail "create-readme pass could not run (darnlink unreachable or python3 missing)"
    fi
    if [ "$cr" -gt 0 ]; then
      echo "darnlink-gate: $cr directory link(s) point at a folder with no README (create-readme axis; fix: 'uvx --from $REF darnlink . --create-readme --write')." >&2
      [ "$rc" -eq 0 ] && rc=1
    fi
  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
