#!/usr/bin/env bash
# The knowledge base's one maintenance tool. Replaces the Prolog MCP cycle:
# adding a clause costs the clause, not the whole predicate.
set -euo pipefail

here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "$here/.." && pwd)"
KB="${KB_FILE:-$repo/prolog-sessions/kb.pl}"
# Test seam: kb-test.sh injects a pre-fix kb-merge.pl to prove the arithmetic
# check catches a merge that drops a clause.
MERGE="${KB_MERGE:-$here/kb-merge.pl}"

# trealla comes from the dev shell, which direnv puts on PATH. Reaching it
# through `nix shell` per invocation cost more than the work it did: this
# tool's first real use timed out past two minutes on a half-second write.
if ! command -v tpl >/dev/null 2>&1; then
  echo "kb: tpl is not on PATH -- run inside the dev shell (direnv allow, or nix develop)" >&2
  exit 127
fi

# stdin is closed on every call: an uncaught Prolog error falls back to the
# REPL, which hangs on a terminal and exits 0 without one -- a refusal that
# reads as acceptance. kb-merge.pl catches parse errors; this is the backstop.
tpl() { command tpl "$@" </dev/null; }

# The goal that runs kb-merge.pl. `-g main,halt` is NOT enough: measured on
# trealla 3.10.3 it exits 0 on a throw and on a failure alike, so a crash reads
# as success to `if ! tpl ...`. This propagates both.
MERGE_GOAL="(catch(main,E,(write(user_error,E),nl(user_error),halt(2))) -> halt(0) ; halt(1))"
merge() { tpl -q "$MERGE" -g "$MERGE_GOAL" -- "$@"; }

# kb_consistent is the gate CI runs. Anything that writes runs it on the
# CANDIDATE, before the candidate becomes the knowledge base.
# The goal reaches a halt on EVERY path, for the same reason MERGE_GOAL does: a
# goal that throws never reaches the halt beside it and tpl exits 0. Without the
# catch, a write that REMOVED kb_consistent made the gate pass by not existing.
CHECK_GOAL="(catch(kb_consistent,E,(write(user_error,E),nl(user_error),halt(2))) -> halt(0) ; (kb_report, halt(1)))"
check_file() {
  tpl "$1" -g "$CHECK_GOAL" 2>/dev/null
}

# A content stamp of the knowledge base as it was READ. Two agents running this
# concurrently would otherwise both read the same file, both validate, and the
# later mv would discard the earlier addition -- the lost-update class this tool
# exists to avoid, and waves here run agents in parallel.
stamp_of() {
  if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | cut -d' ' -f1
  elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | cut -d' ' -f1
  else cksum "$1" | tr -d ' '
  fi
}

# mkdir is atomic everywhere; flock is absent on macOS. The lock makes a
# collision rare, the stamp makes one that happens anyway LOUD rather than silent.
acquire_lock() {
  local tries=0
  until mkdir "$KB.lock" 2>/dev/null; do
    tries=$((tries + 1))
    if [ "$tries" -ge 300 ]; then
      echo "kb: could not acquire $KB.lock after 30s; remove it if stale" >&2
      exit 1
    fi
    sleep 0.1
  done
  trap 'rmdir "$KB.lock" 2>/dev/null || true' EXIT
}

# Test seam: simulate another writer landing between the read and the commit.
race_hook() {
  [ -n "${KB_TEST_RACE:-}" ] || return 0
  cp -f "$KB_TEST_RACE" "$KB"
}

usage() {
  cat >&2 <<'EOF'
usage: kb <command>

  check [--with <d.pl>] run kb_consistent, optionally over a proposed design
  count                 clauses per keyed predicate
  query '<goal>'        run a goal against the knowledge base
  fmt                   rewrite in canonical form (idempotent)
  add <delta.pl>        add clauses; refuses if any key already exists
  replace <delta.pl>    add clauses, overwriting existing keys
  rm <keys.pl>          remove clauses by key; refuses if any key is absent

Every writing command verifies the result reads back as the clauses intended
and passes kb_consistent before it replaces the knowledge base.
EOF
  exit 2
}

commit() { # $1 = candidate file, $2 = stamp of the kb before it was read
  local cand="$1" was="${2:-}"
  race_hook
  if [ -n "$was" ] && [ "$(stamp_of "$KB")" != "$was" ]; then
    rm -f "$cand"
    echo "kb: REFUSED -- $KB changed while this write was being prepared." >&2
    echo "kb: another writer won; re-run and your delta applies on top." >&2
    exit 1
  fi
  if ! check_file "$cand"; then
    rm -f "$cand"
    echo "kb: REFUSED -- kb_consistent fails on the result; $KB is unchanged" >&2
    exit 1
  fi
  mv -f "$cand" "$KB"
}

[ $# -ge 1 ] || usage
cmd="$1"; shift

case "$cmd" in
  check)
    # `check --with <design.pl>` asks whether a design the tree does not yet
    # have is one the model accepts: the facts are merged into a throwaway
    # candidate -- which keeps each predicate contiguous -- and kb_consistent
    # runs over the union. Nothing is ever committed.
    if [ "${1:-}" = "--with" ]; then
      [ $# -eq 2 ] || usage
      cand="$(mktemp)"
      if ! merge replace "$KB" "$2" "$cand" >/dev/null; then
        rm -f "$cand"; echo "kb: the proposal is not well-formed" >&2; exit 1
      fi
      if check_file "$cand"; then
        rm -f "$cand"; echo "kb_consistent with $2: TRUE"
      else
        rm -f "$cand"; echo "kb: the model REFUSES this design (above)" >&2; exit 1
      fi
    elif check_file "$KB"; then echo "kb_consistent: TRUE"; else exit 1; fi
    ;;
  count)
    merge count "$KB" 2>/dev/null
    ;;
  query)
    [ $# -eq 1 ] || usage
    tpl "$KB" -g "($1 -> halt(0) ; (write('false'), nl, halt(1)))" 2>/dev/null
    ;;
  fmt)
    acquire_lock
    was="$(stamp_of "$KB")"
    cand="$(mktemp)"
    : > "${cand}.delta"
    if ! merge add "$KB" "${cand}.delta" "$cand" >/dev/null 2>&1; then
      rm -f "$cand" "${cand}.delta"
      echo "kb: REFUSED -- $KB is unchanged" >&2
      exit 1
    fi
    rm -f "${cand}.delta"
    commit "$cand" "$was"
    echo "kb: canonicalized"
    ;;
  rm)
    [ $# -eq 1 ] || usage
    acquire_lock
    was="$(stamp_of "$KB")"
    cand="$(mktemp)"
    if ! merge remove "$KB" "$1" "$cand"; then
      rm -f "$cand"
      echo "kb: REFUSED -- $KB is unchanged" >&2
      exit 1
    fi
    commit "$cand" "$was"
    echo "kb: $KB updated"
    ;;
  add|replace)
    [ $# -eq 1 ] || usage
    acquire_lock
    was="$(stamp_of "$KB")"
    cand="$(mktemp)"
    if ! merge "$cmd" "$KB" "$1" "$cand"; then
      rm -f "$cand"
      echo "kb: REFUSED -- $KB is unchanged" >&2
      exit 1
    fi
    commit "$cand" "$was"
    echo "kb: $KB updated"
    ;;
  *) usage ;;
esac
