#!/usr/bin/env bash
# scripts/ledger-scan.sh -- machine-derived "what is still OPEN" view of the repo.
#
# The session ledger (docs/session-ledger.md) is a human narrative; it can drift.
# This script is the DRIFT CHECK: it greps the repo for the open-work markers that
# can be reliably derived -- PROPOSED/OPEN decisions, OPEN security-ledger rows,
# and the next-free D/DOCFIX/BUNDLEFIX numbers -- so the narrative ledger can be
# reconciled against ground truth at session start and before every handoff.
# Read-only; mutates nothing. Exit: 0 always (it reports; it does not judge).
#
# Reconcile rule: the narrative ledger must not claim CLOSED anything this scan
# shows OPEN, and must not omit a decision/number this scan surfaces.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="${VR_REPO:-$(cd "$HERE/.." && pwd)}"
DD="$REPO/docs/design-decisions.md"
SL="$REPO/docs/security-ledger.md"
SESSL="$REPO/docs/session-ledger.md"

# --- fence validator (delimited-section convention) --------------------------
# Validates STRUCTURAL integrity of the ledger ownership fences:
#   <!-- SECTION: <name> | OWNER: <owner> -->  ...  <!-- END: <name> -->
# FATAL: unmatched / overlapping (no nesting) / duplicate-name / unclosed.
# WARN (non-fatal): unknown section name, missing/empty OWNER. No fences = OK
# (transition state). Separate --fences MODE; default report below is unchanged.
_vf_trim(){ local x="$1"; x="${x#"${x%%[![:space:]]*}"}"; x="${x%"${x##*[![:space:]]}"}"; printf '%s' "$x"; }
validate_fences() {
  local f="$1"
  [ -r "$f" ] || { echo "  HOLD: session-ledger not readable: $f"; return 2; }
  local errs=0 warns=0 nfence=0 ln=0
  local open_name="" open_line=0 seen=" " known=" machine-derived main-chat jumphost shared "
  local line inner name owner ename
  while IFS= read -r line || [ -n "$line" ]; do
    ln=$((ln+1))
    case "$line" in
      *'<!-- SECTION:'*)
        nfence=$((nfence+1))
        inner="${line#*<!-- SECTION:}"; inner="${inner%%-->*}"
        name="$(_vf_trim "${inner%%|*}")"
        if [ "$inner" != "${inner#*OWNER:}" ]; then owner="$(_vf_trim "${inner#*OWNER:}")"; else owner="__none__"; fi
        if [ -n "$open_name" ]; then echo "  ERROR line $ln: SECTION $name opened while $open_name (line $open_line) still open -- no nesting"; errs=$((errs+1)); fi
        if [ -z "$name" ]; then echo "  ERROR line $ln: SECTION marker with unparseable name"; errs=$((errs+1))
        else
          case "$seen" in *" $name "*) echo "  ERROR line $ln: duplicate SECTION name $name"; errs=$((errs+1));; esac
          case "$known" in *" $name "*) : ;; *) echo "  WARN  line $ln: unknown section name $name (known: main-chat jumphost machine-derived shared)"; warns=$((warns+1));; esac
          if [ "$owner" = "__none__" ] || [ -z "$owner" ]; then echo "  WARN  line $ln: SECTION $name has missing/empty OWNER"; warns=$((warns+1)); fi
          open_name="$name"; open_line="$ln"; seen="$seen$name "
        fi
        ;;
      *'<!-- END:'*)
        inner="${line#*<!-- END:}"; ename="$(_vf_trim "${inner%%-->*}")"
        if [ -z "$open_name" ]; then echo "  ERROR line $ln: END $ename with no open SECTION"; errs=$((errs+1))
        elif [ "$ename" != "$open_name" ]; then echo "  ERROR line $ln: END $ename does not match open SECTION $open_name (line $open_line)"; errs=$((errs+1)); open_name=""
        else open_name=""; fi
        ;;
    esac
  done < "$f"
  [ -n "$open_name" ] && { echo "  ERROR: unclosed SECTION $open_name opened at line $open_line"; errs=$((errs+1)); }
  echo "  fences: $nfence  errors: $errs  warnings: $warns"
  if [ "$errs" -gt 0 ]; then echo "  FENCE VALIDATION: FAIL"; return 1; fi
  [ "$nfence" -eq 0 ] && echo "  (no fences yet -- partial/unfenced ledger is OK during transition)"
  echo "  FENCE VALIDATION: OK"; return 0
}

if [ "${1:-}" = "--fences" ]; then
  echo "=== ledger-scan --fences: session-ledger fence integrity ($SESSL) ==="
  validate_fences "$SESSL"; exit $?
fi

echo "=== ledger-scan: open-work ground truth (repo: $REPO) ==="

echo "-- PROPOSED / OPEN decisions --"
if [ -r "$DD" ]; then
  # a decision header line whose status line (within the next few lines) is PROPOSED/OPEN.
  # A decision is OPEN iff the LAST Status line in its block is PROPOSED/OPEN.
  # (A later RESOLVED/CLOSED/SUPERSEDED amendment closes it -- so we track the last
  # status per block, not the first; this is the D-063-amendment drift the first
  # real run surfaced.)
  # PARTIAL is included because a "PARTIALLY RULED" decision still has open
  # sub-decisions (the D-131 blind spot, found 2026-07-21: its Status line
  # begins "PARTIALLY RULED" and the word OPEN falls on a continuation line
  # this per-line match never sees).
  # Attribution is by D-NUMBER, not by physical position: ANY D-numbered header
  # (definition "## D-NNN: ..." OR detached amendment "## D-NNN -- AMENDMENT ...")
  # switches the CURRENT decision, so an amendment Status line updates ITS OWN
  # decision -- never whatever definition block physically precedes it. Found live
  # at the 2026-07-23 session close: D-068 amendments appended at the file tail
  # hijacked D-132 last-status and FALSE-CLOSED it off this scan. Titles and
  # listing order still come from definition (colon) headers only.
  OPEN_DEC="$(awk '
    match($0, /^##+ D-[0-9]+/) {
      num = substr($0, RSTART, RLENGTH); sub(/^#+[[:space:]]*/, "", num)
      cur = num
      if ($0 ~ /^##+ D-[0-9]+:/) {
        t = $0; sub(/^#+[[:space:]]*/, "", t)
        if (!(num in seen)) { seen[num]=1; order[++n]=num }
        title[num] = t
      }
    }
    /\*\*Status:\*\*/ { if (cur != "") last[cur] = $0 }
    END {
      for (i=1; i<=n; i++) { k=order[i]; if (last[k] ~ /PROPOSED|OPEN|PARTIAL/) print "  " title[k] }
    }
  ' "$DD")"
  if [ -n "$OPEN_DEC" ]; then printf '%s\n' "$OPEN_DEC"; else echo "  (none)"; fi
else echo "  (design-decisions.md not found)"; fi

# SEC reporting is pointer + count (GA-R4 amendment F3): the register of
# record is docs/security-ledger.md and row-level DISPOSITIONS live there
# ONLY. This scan surfaces the open count + IDs for reconciliation; the
# session ledger's machine-derived block carries pointer + count, never rows.
echo "-- OPEN security-ledger rows (register of record: docs/security-ledger.md) --"
if [ -r "$SL" ]; then
  # FAIL-OPEN is firewall terminology, not a status: strip it before matching, or a
  # CLOSED row whose disposition explains a fail-open guard counts as OPEN forever
  # (hit for real on SEC-010's close, 2026-07-20 -- same false-positive class as the
  # GA-F15 wrap trap).
  OPEN_SEC="$(grep -E '^\|[[:space:]]*SEC-[0-9]' "$SL" \
    | awk -F'|' '{cell=toupper($(NF-1)); gsub(/FAIL-OPEN/,"",cell)} cell ~ /OPEN|PENDING/ {gsub(/^ *| *$/,"",$2); print $2}')"
  if [ -n "$OPEN_SEC" ]; then
    printf '  %s open: %s\n' "$(printf '%s\n' "$OPEN_SEC" | wc -l | tr -d ' ')" \
                             "$(printf '%s\n' "$OPEN_SEC" | paste -sd' ' -)"
    echo "  (dispositions live in docs/security-ledger.md ONLY -- GA-R4/F3)"
  else echo "  (none open)"; fi
else echo "  (security-ledger.md not found)"; fi

echo "-- next-free numbers (highest assigned + 1) --"
# Exclude "next-free" pointer lines from the count -- a "next-free D-072" pointer in
# prose must NOT inflate the highest-assigned (the first real run counted exactly that).
# D is AUTHORITATIVE from decision headers in design-decisions.md, not prose mentions.
if [ -r "$DD" ]; then
  DHI="$(grep -oE '^## D-[0-9]+' "$DD" | grep -oE '[0-9]+' | sort -n | tail -1)"
  if [ -n "$DHI" ]; then printf '  D: highest-header=%s  next-free=%03d\n' "$DHI" "$((10#$DHI + 1))"; else echo "  D: none"; fi
else echo "  D: design-decisions.md not found"; fi
# [0-9]{3,}: the old 0[0-9]{2} pattern went blind at the 100 boundary
# (DOCFIX-100+ invisible -> next-free frozen at 100; hit live 2026-07-06,
# fixed as DOCFIX-105). FIXED 2026-07-10 (full-project sweep, DOCFIX-173):
# a fixed [0-9]{3} (not {3,}) is the SAME bug class one boundary further
# out -- a 4-digit identifier would truncate to its first 3 digits,
# silently proposing an already-used next-free number. Not yet reachable
# (repo is at DOCFIX-16x as of this fix), but a real, confirmed-via-
# reproduction dormant landmine of the exact class already fixed once for
# the D-series (repo_lint.py's L5 check already uses [0-9]{3,}) -- this
# file's own DOCFIX/BUNDLEFIX patterns were never updated to match.
#
# CAUTION (found 2026-07-10, the very next sweep after the fix above): this
# scan greps ALL prose under docs/ and runbooks/, not just real assignment
# sites -- a changelog narrating the fix above by literally spelling out an
# illustrative 4-digit example number (this repo's real prefix + digits)
# is itself a false-positive MENTION, inflating next-free far past the true
# highest. Do not write a literal "<PREFIX>-<digits>"-shaped illustrative
# example in docs/runbooks prose; describe it in words instead (as this
# comment now does), or the "reproduc*" exclusion below. Confirmed live:
# DOCFIX-173's own changelog did exactly this and pushed next-free to 1005.
#
# HARDENED 2026-07-19 (GA-F15): the exclusion is WRAP-AWARE -- an excluded
# line ("next-free" / "reproduc*") suppresses ITSELF and the IMMEDIATELY
# FOLLOWING line, because a quoted next-free value whose hyphenated token
# wraps onto the next line otherwise counts as a real assignment. Happened
# twice live inside the 2026-07-18 audit (CURRENT-STATE.md section 9): a
# wrapped env-snapshot line inflated BUNDLEFIX, and CURRENT-STATE's own
# first draft inflated DOCFIX the same way. Window is ONE line, reset at
# every file boundary (FNR==1), so exclusion cannot leak across files.
nextfree_mentions() { # <regex> <label> -- highest MENTION, wrap-aware exclusion
  local pat="$1" label="$2" hi
  hi="$(find "$REPO/docs" "$REPO/runbooks" -type f -print0 2>/dev/null \
        | xargs -0 -r awk '
            FNR==1 { prev=0 }
            { excl = (tolower($0) ~ /next[- ]free/ || tolower($0) ~ /reproduc/)
              if (!excl && !prev) print
              prev = excl }' \
        | grep -oE "$pat" | grep -oE '[0-9]+' | sort -n | tail -1)"
  if [ -n "$hi" ]; then printf '  %s: highest=%s  next-free=%03d\n' "$label" "$hi" "$((10#$hi + 1))"
  else printf '  %s: none found\n' "$label"; fi
}
nextfree_mentions 'DOCFIX-[0-9]{3,}' "DOCFIX"
nextfree_mentions 'BUNDLEFIX-[0-9]{3,}' "BUNDLEFIX"

echo "-- reconcile against docs/session-ledger.md --"
if [ -r "$REPO/docs/session-ledger.md" ]; then
  echo "  ledger present; compare the sections above against its narrative"
else
  echo "  NO docs/session-ledger.md -- create/update it (this session's continuity record)"
fi
exit 0
