#!/usr/bin/env bash
# fn-oracle.sh — A6 of the wake canon (EPIC #892, W5): the FN-oracle /
# synthetic-canary.
#
# CONTRACT ANCHORS (docs/scratchpads/heartbeat-planning/CONVERGED-DESIGN.md):
#   §4 vector  { synthetic-canary FN-rate = 0 }  — the oracle injects a KNOWN
#              synthetic-canary delta into the wake pipeline and ASSERTS it
#              reaches CONSUMED within its per-class SLO; it measures the
#              FALSE-NEGATIVE rate. FN-rate MUST be 0 for the timer to retire.
#   §4/§7-res5 The FN-oracle is a real, NON-OPTIONAL operating cost — the price
#              of being allowed to retire the fixed timer, not a free extra.
#   §1.4       Framework component, operator-agnostic (~/.config/mosaic/tools/).
#
# OFF-DOMAIN / DETECTOR-INDEPENDENT (the §4/A8 false-negative-blindspot killer):
#   The injection and the CONSUMED-assertion do NOT depend on the detector's
#   own hashing / poll internals. The oracle injects a delta at the SOURCE
#   boundary (the bytes an operator adapter reports), drives the pipeline
#   through the detector's PUBLIC API only (poll-once — a black box), and then
#   renders its verdict SOLELY from the terminal store/ack state (did a CONSUMED
#   ack advance the cursor to cover the canary's observed_seq?). It never reads a
#   detector hash-file, poll counter, or self-report. CONSEQUENCE: a detector
#   that silently DROPS changes — including a FULLY-DISABLED detector at a
#   "perfect" no-op rate (0 wakes/day, which looks great on the wakes metric) —
#   still FAILS the oracle, because the canary never reaches CONSUMED. That is
#   the whole point: the success metric is a VECTOR, not a wake-count.
#
# ISOLATION: the probe runs in its OWN XDG state namespace (WAKE_ORACLE_HOME),
# NOT the operator's live queue, so a synthetic canary never delivers a fake
# wake to a real agent. It exercises the REAL detector.sh / store.sh / ack.sh
# CODE paths against isolated synthetic data — the standard synthetic-canary
# shape (synthetic transaction, real code, isolated data).
#
# SCOPE: calls the PUBLIC APIs of detector.sh (poll-once), store.sh
# (cursors/drain), and ack.sh (received/consumed). It does NOT reimplement or
# modify any of them.
#
# Operator-agnostic: all state via XDG/env; no operator paths/names/secrets.
set -uo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=./_wake-common.sh disable=SC1091
. "$SCRIPT_DIR/_wake-common.sh"

DETECTOR_SH="$SCRIPT_DIR/detector.sh"
STORE_SH="$SCRIPT_DIR/store.sh"
ACK_SH="$SCRIPT_DIR/ack.sh"

# Isolated oracle state root (XDG). NEVER the operator's live wake queue.
ORACLE_HOME="${WAKE_ORACLE_HOME:-${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/wake/fn-oracle}"
# The canary's own store namespace lives UNDER the oracle root.
CANARY_STATE_HOME="$ORACLE_HOME/canary-store"
CANARY_AGENT="canary"
CANARY_SRC="$ORACLE_HOME/canary.src"
CANARY_ADAPTER="$ORACLE_HOME/canary-adapter.sh"
CANARY_WATCHLIST="$ORACLE_HOME/canary-watch-list.json"
METRICS="$ORACLE_HOME/metrics.jsonl"

_need_jq() {
  command -v jq >/dev/null 2>&1 || {
    echo "fn-oracle.sh: jq is required" >&2
    exit 3
  }
}

usage() {
  cat >&2 <<'EOF'
Usage: fn-oracle.sh run --slo-seconds N [options]

Commands:
  run --slo-seconds N [--count K] [--class C]
        Run K synthetic-canary cycles (default 1). Each cycle injects a KNOWN
        delta at the source boundary, drives the pipeline through the detector's
        public poll-once, then asserts the canary reaches CONSUMED within the
        per-class SLO (N seconds). Emits per-canary PASS/FN-DETECTED, the
        FN-rate metric, and an overall VERDICT. Exit 0 IFF FN-rate == 0.
  status
        Print the tail of the recorded metrics ledger.

Options:
  --slo-seconds N   REQUIRED for run. The per-class SLO (operator-tuned; symbolic
                    tiers per §4 — no default is invented). event->CONSUMED must
                    land within N seconds or the canary counts as a false-negative.
  --count K         Number of canary cycles this run (default 1).
  --class C         Wake class the canary flows as (default digest — the primary
                    machine-wake path §4 certifies). digest|actionable|human.

Environment:
  WAKE_ORACLE_HOME         Isolated oracle state root (XDG by default). NEVER the
                           operator's live queue.
  WAKE_ORACLE_DETECTOR_CMD Command the oracle runs to advance ONE observe cycle
                           of the pipeline (default: detector.sh poll-once). The
                           canary env (watch-list/adapter/state) is exported to
                           it. Pluggable so a disabled/dropping detector can be
                           exercised (it MUST then be caught as FN-DETECTED).
  WAKE_ORACLE_SLO_SECONDS  Fallback for --slo-seconds.
EOF
}

# --- one-time synthetic-canary scaffold (idempotent) -----------------------
# The oracle SUPPLIES its own source adapter + watch-list so it needs no
# operator config and stays fully self-contained. The adapter simply echoes the
# current canary source bytes — the synthetic "source of truth" the oracle
# controls end-to-end.
_write_scaffold() {
  local class="$1"
  mkdir -p "$ORACLE_HOME"

  cat >"$CANARY_ADAPTER" <<EOF
#!/usr/bin/env bash
# Synthetic-canary source adapter (fn-oracle). Reports the current canary bytes.
set -u
[ -f "$CANARY_SRC" ] && cat "$CANARY_SRC"
exit 0
EOF
  chmod +x "$CANARY_ADAPTER"

  # A minimal, schema-valid watch-list with EXACTLY ONE synthetic source, no
  # anchor. One source in an isolated store => any enqueue is unambiguously the
  # canary. The class is carried so the canary flows on the class under test.
  jq -n --arg class "$class" '{
    schema_version: 1,
    repos: [ { id: "fn-canary", remote: "synthetic/fn-oracle-canary", class: $class } ],
    watches: [ { lane: "fn-oracle", sources: [ { kind: "repo", id: "fn-canary" } ] } ]
  }' >"$CANARY_WATCHLIST"
}

# _canary_env — export the isolated canary namespace + adapter + watch-list for
# a child detector/store/ack invocation.
_canary_env() {
  export WAKE_STATE_HOME="$CANARY_STATE_HOME"
  export WAKE_AGENT="$CANARY_AGENT"
  export WAKE_WATCH_LIST="$CANARY_WATCHLIST"
  export WAKE_DETECTOR_SOURCE_CMD="$CANARY_ADAPTER"
}

# _store_observed / _store_consumed — read the terminal cursors (public API).
_store_observed() { "$STORE_SH" cursors 2>/dev/null | sed -n 's/^observed_seq=//p'; }
_store_consumed() { "$STORE_SH" cursors 2>/dev/null | sed -n 's/^consumed_seq=//p'; }

# _drive_pipeline — advance ONE observe cycle. Black-box: whatever
# WAKE_ORACLE_DETECTOR_CMD is (default: the real detector's public poll-once).
# A source failure inside the detector is loud there; the oracle judges only the
# terminal state, so a drop of ANY kind surfaces as an unmet CONSUMED.
_drive_pipeline() {
  local cmd="${WAKE_ORACLE_DETECTOR_CMD:-"$DETECTOR_SH poll-once"}"
  sh -c "$cmd" >/dev/null 2>&1 || true
}

# _seat_baseline — seat the source's first-seen baseline ONCE (detector
# first-seen is silent by design). Every subsequent canary token is then a
# genuine DELTA off the prior token, so no spurious baseline wakes are produced.
_seat_baseline() {
  _canary_env
  printf '%s' "FN-ORACLE-BASELINE-$$-$(date +%s)" >"$CANARY_SRC"
  _drive_pipeline
}

# --- one canary cycle -------------------------------------------------------
# Returns 0 = PASS (reached CONSUMED within SLO), 1 = FN-DETECTED.
# Prints a one-line verdict for the cycle.
_run_one() {
  local idx="$1" slo="$2" class="$3"
  _canary_env

  local nonce token inject_ts
  nonce="$$-$idx-$(date +%s%N 2>/dev/null || date +%s)"
  token="FN-ORACLE-CANARY-$nonce"

  local obs_before
  obs_before="$(_store_observed)"
  case "$obs_before" in '' | *[!0-9]*) obs_before=0 ;; esac

  # INJECT the known delta at the source boundary + stamp the clock.
  inject_ts="$(date +%s)"
  printf '%s' "$token" >"$CANARY_SRC"

  # Drive the pipeline (through the detector's public API — a black box).
  _drive_pipeline

  local obs_after
  obs_after="$(_store_observed)"
  case "$obs_after" in '' | *[!0-9]*) obs_after=0 ;; esac

  # ---- OFF-DOMAIN verdict, part 1: was the delta OBSERVED at all? ----------
  # In an isolated single-source store, any observed_seq advance is the canary.
  # NO advance => the pipeline (a dropping / disabled detector) swallowed a KNOWN
  # real change => FALSE NEGATIVE. This is the killer: a perfect no-op rate does
  # not rescue a detector that fails to see a real delta.
  if [ "$obs_after" -le "$obs_before" ]; then
    printf '  canary %s: FN-DETECTED (injected delta never OBSERVED — pipeline dropped a known change; observed_seq %s unchanged)\n' \
      "$idx" "$obs_before" >&2
    return 1
  fi

  # The delta was observed and durably enqueued. Simulate the consumer contract
  # (§2.2): deliver (drain), RECEIVED-ack, capture, then CONSUMED-ack the
  # contiguous prefix. All via the PUBLIC ack/store API.
  local delivered
  delivered="$("$STORE_SH" drain 2>/dev/null | grep -c '.' || echo 0)"
  if [ "${delivered:-0}" -lt 1 ]; then
    printf '  canary %s: FN-DETECTED (observed but NOT delivered — drain returned nothing to consume)\n' "$idx" >&2
    return 1
  fi

  "$ACK_SH" received --wake-id "fn-oracle-$nonce" >/dev/null 2>&1 || true
  # CONSUMED over the contiguous prefix up to the canary's observed_seq.
  "$ACK_SH" consumed --upto "$obs_after" --no-sync >/dev/null 2>&1 || true

  # ---- OFF-DOMAIN verdict, part 2: did it reach CONSUMED, within SLO? ------
  local consumed elapsed now
  consumed="$(_store_consumed)"
  case "$consumed" in '' | *[!0-9]*) consumed=0 ;; esac
  now="$(date +%s)"
  elapsed=$((now - inject_ts))

  if [ "$consumed" -lt "$obs_after" ]; then
    printf '  canary %s: FN-DETECTED (delivered but consumed_seq=%s never reached the canary observed_seq=%s)\n' \
      "$idx" "$consumed" "$obs_after" >&2
    return 1
  fi
  if [ "$elapsed" -gt "$slo" ]; then
    printf '  canary %s: FN-DETECTED (reached CONSUMED but in %ss > per-class SLO %ss)\n' \
      "$idx" "$elapsed" "$slo" >&2
    return 1
  fi

  printf '  canary %s: PASS (event->CONSUMED in %ss <= SLO %ss; observed_seq=%s consumed_seq=%s class=%s)\n' \
    "$idx" "$elapsed" "$slo" "$obs_after" "$consumed" "$class"
  return 0
}

cmd_run() {
  _need_jq
  local slo="${WAKE_ORACLE_SLO_SECONDS:-}" count=1 class="digest"
  while [ $# -gt 0 ]; do
    case "$1" in
    --slo-seconds)
      slo="${2:-}"
      shift 2
      ;;
    --count)
      count="${2:-}"
      shift 2
      ;;
    --class)
      class="${2:-}"
      shift 2
      ;;
    *)
      echo "fn-oracle.sh run: unknown option '$1'" >&2
      exit 2
      ;;
    esac
  done

  # No invented numeric SLO (design law): the per-class SLO MUST be supplied.
  case "$slo" in
  '' | *[!0-9]*)
    echo "fn-oracle.sh run: --slo-seconds (or WAKE_ORACLE_SLO_SECONDS) is REQUIRED and must be a non-negative integer (per-class SLO is operator-tuned; no default is invented)" >&2
    exit 2
    ;;
  esac
  case "$count" in
  '' | *[!0-9]* | 0)
    echo "fn-oracle.sh run: --count must be a positive integer" >&2
    exit 2
    ;;
  esac
  case "$class" in
  digest | actionable | human) : ;;
  *)
    echo "fn-oracle.sh run: --class must be one of digest|actionable|human" >&2
    exit 2
    ;;
  esac

  # Fresh isolated probe state each run => a deterministic baseline. This is the
  # oracle's OWN synthetic store, never the operator's live queue.
  rm -rf "$CANARY_STATE_HOME"
  mkdir -p "$ORACLE_HOME"
  _write_scaffold "$class"

  echo "fn-oracle: synthetic-canary run (count=$count, class=$class, per-class SLO=${slo}s)"
  _seat_baseline # one-time first-seen baseline (silent by design)
  local fn=0 i
  i=1
  while [ "$i" -le "$count" ]; do
    if _run_one "$i" "$slo" "$class"; then
      :
    else
      fn=$((fn + 1))
    fi
    i=$((i + 1))
  done

  # FN-rate metric. §4 requires FN-rate == 0.
  local rate verdict rc
  if [ "$fn" -eq 0 ]; then
    verdict="PASS"
    rc=0
  else
    verdict="FN-DETECTED"
    rc=1
  fi
  # Rational FN-rate rendered without bc (portable): integer numerator/denominator
  # plus a scaled decimal.
  rate="$(awk -v f="$fn" -v n="$count" 'BEGIN { printf "%.4f", (n>0? f/n : 0) }')"

  local rec
  rec="$(jq -cn \
    --argjson ts "$(date +%s)" \
    --arg class "$class" \
    --argjson slo "$slo" \
    --argjson count "$count" \
    --argjson fn "$fn" \
    --arg rate "$rate" \
    --arg verdict "$verdict" \
    '{ts:$ts, class:$class, slo_seconds:$slo, count:$count, fn:$fn, fn_rate:($rate|tonumber), verdict:$verdict}')"
  { cat "$METRICS" 2>/dev/null; printf '%s\n' "$rec"; } | grep -v '^[[:space:]]*$' >"$METRICS.tmp" 2>/dev/null || true
  mv -f "$METRICS.tmp" "$METRICS" 2>/dev/null || true

  echo "fn-oracle: FN-RATE = $fn/$count = $rate  (class=$class, SLO=${slo}s)"
  echo "fn-oracle: VERDICT = $verdict"
  [ "$rc" -eq 0 ] || echo "fn-oracle: §4 vector FAILS — synthetic-canary FN-rate must be 0 to retire the timer." >&2
  return "$rc"
}

cmd_status() {
  echo "# fn-oracle metrics (tail)"
  tail -n 10 "$METRICS" 2>/dev/null || echo "(no runs recorded)"
}

main() {
  [ $# -ge 1 ] || {
    usage
    exit 2
  }
  local cmd="$1"
  shift
  case "$cmd" in
  run) cmd_run "$@" ;;
  status) cmd_status "$@" ;;
  -h | --help | help) usage ;;
  *)
    echo "fn-oracle.sh: unknown command '$cmd'" >&2
    usage
    exit 2
    ;;
  esac
}

main "$@"
