#!/usr/bin/env bash
# wake-install.sh — A10 of the wake canon (EPIC #892, W7): the idempotent,
# fail-closed COMPONENT INSTALLER for the wake component.
#
# This is the ENFORCEMENT-PATH installer (#869 publish-gate discipline): every
# path is fail-closed, idempotent, and never silently falls through. It is driven
# by `framework/install.sh --component wake`, and every subcommand is also invoked
# directly by the red-first harness (test-wake-install.sh).
#
# CONTRACT ANCHORS (PACKAGING-PLAN §3/§5 + CONVERGED-DESIGN):
#   (i)   Idempotent component install + Gate A. The wake component ships its own
#         manifest.txt as VERSION METADATA ONLY. Path-ownership stays the SINGLE
#         SSOT framework-manifest.txt (consumed by BOTH bash + TS). The component
#         file set is INTERSECTED-AND-VALIDATED against framework-manifest
#         ownership: the wake manifest MUST NOT independently authorize any
#         write/prune outside framework-manifest ownership. Re-running is a no-op.
#   (iii) Blank-reset idiom on the LEGACY mosaic-heartbeat@<agent>.timer cadence
#         drop-in during the §5 overlap->retire lifecycle: SNAPSHOT the legacy
#         unit; write any per-class fallback-cadence drop-in in the blank-reset
#         form (an empty OnUnitActiveSec= reset line before the new value, so
#         exactly ONE OnUnitActiveUSec results); on §4-vector pass, REMOVE the
#         legacy mosaic-heartbeat@ units (retire-LAST). Post-apply verify = exactly
#         one OnUnitActiveUSec.
#   (iv)  snapshot-guard: block any reap / clean-checkout of a deployed unit
#         WITHOUT a snapshot first (the deployed-from-uncommitted failure class
#         must not recur). Fail-closed: no snapshot => refuse to reap.
#   (v)   Fail-closed alarm-target + HMAC-key install-validation (G1/G2a): the
#         operator's W6 alarm-sink target (resolved BY NAME via load_credentials)
#         must be CONFIGURED + reachable at install; the W3/W7 HMAC key must
#         resolve BY NAME. Missing/unreachable => FAIL LOUD. This installer ships
#         NO endpoint value and NO secret, and echoes neither.
#
# Operator-agnostic (framework firewall): no operator paths/names/secrets/hosts.
# All state via XDG/env; the HMAC key + alarm endpoint are resolved BY NAME.
set -Eeuo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# ─── paths (all overridable so the harness can isolate every run) ────────────
# Source framework tree (the checkout being installed FROM).
WAKE_INSTALL_SOURCE="${WAKE_INSTALL_SOURCE:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
# Target mosaic home (installed INTO).
WAKE_INSTALL_TARGET="${WAKE_INSTALL_TARGET:-${MOSAIC_HOME:-$HOME/.config/mosaic}}"
# framework-manifest.txt SSOT (Gate A). Override lets the harness prove a
# de-authorizing manifest makes the install REFUSE (no write outside ownership).
WAKE_INSTALL_MANIFEST="${WAKE_INSTALL_MANIFEST:-$WAKE_INSTALL_SOURCE/framework-manifest.txt}"
# systemd user unit dir the legacy timer / new units are deployed under.
WAKE_SYSTEMD_USER_DIR="${WAKE_SYSTEMD_USER_DIR:-$HOME/.config/systemd/user}"
# Retained snapshot store for the snapshot-guard (iv) — outside any repo.
WAKE_SNAPSHOT_DIR="${WAKE_SNAPSHOT_DIR:-${XDG_STATE_HOME:-$HOME/.local/state}/mosaic/wake/unit-snapshots}"

# systemd user unit NAME this installer deploys + links (overridable for the harness).
WAKE_UNIT_NAME="${WAKE_UNIT_NAME:-mosaic-wake.service}"
# The framework-shipped canon FALLBACK WAKE units (F7, #925): a low-frequency
# SAFETY drain (oneshot service) driven by a per-class cadence timer, INDEPENDENT
# of the event-driven detector. Both are framework-owned via the existing
# `systemd/**` glob in framework-manifest.txt (Gate A) — NO new owned path, NO
# second ownership authority (#869 additive). Overridable for the harness.
WAKE_FALLBACK_TIMER_NAME="${WAKE_FALLBACK_TIMER_NAME:-mosaic-wake-fallback.timer}"
WAKE_FALLBACK_SERVICE_NAME="${WAKE_FALLBACK_SERVICE_NAME:-mosaic-wake-fallback.service}"
# The shared framework-manifest reader this installer needs for Gate A ownership
# validation. Overridable so the red-first harness can point it at a missing path
# to prove the fail-loud without disturbing the real tree (#913a).
WAKE_MANIFEST_LIB="${WAKE_MANIFEST_LIB:-$SCRIPT_DIR/../_lib/manifest.sh}"

# ─── output helpers (defined BEFORE the dependency check so it can fail loud) ──
if [[ -t 2 ]]; then
  _C_G='\033[0;32m' _C_Y='\033[0;33m' _C_R='\033[0;31m' _C_0='\033[0m'
else
  _C_G='' _C_Y='' _C_R='' _C_0=''
fi
wi_ok()   { printf "  ${_C_G}OK${_C_0} %s\n" "$1" >&2; }
wi_warn() { printf "  ${_C_Y}WARN${_C_0} %s\n" "$1" >&2; }
wi_fail() { printf "  ${_C_R}FAIL${_C_0} %s\n" "$1" >&2; }

# ─── required framework library — fail LOUD if a stale host seed lacks it (#913a) ──
# The wake component installer sources the shared framework-manifest reader
# (_lib/manifest.sh) for Gate A ownership validation. Host seeds that predate that
# helper would otherwise abort here with a bare, obscure
# `source: No such file or directory` and no guidance. This dependency is GENUINELY
# REQUIRED (Gate A cannot be skipped on an enforcement path), so fail LOUD — naming
# the missing file AND the remedy — instead of degrading: a not-yet-updated host
# must be told exactly how to become installable.
if [[ ! -r "$WAKE_MANIFEST_LIB" ]]; then
  wi_fail "wake-install: required framework library missing — $WAKE_MANIFEST_LIB"
  {
    printf '         %s\n' "This host's framework seed predates the shared manifest reader (_lib/manifest.sh)"
    printf '         %s\n' "that the wake component installer needs for Gate A ownership validation (#913)."
    printf '         %s\n' "REMEDY: re-seed the framework first, then retry the component install:"
    printf '         %s\n' "  mosaic update            # or:  bash <framework>/install.sh"
    printf '         %s\n' "  install.sh --component wake"
  } >&2
  exit 1
fi
# shellcheck source=../_lib/manifest.sh disable=SC1091
. "$WAKE_MANIFEST_LIB"

# ═══════════════════════════════════════════════════════════════════════════════
# (i) Idempotent component-manifest install + Gate A
# ═══════════════════════════════════════════════════════════════════════════════

# The wake component's candidate file set, as source-relative paths. It is DERIVED
# (from the shipped filesystem under tools/wake/ + the two cross-subtree files the
# component owns) — the wake VERSION manifest authorizes NOTHING here. Every
# candidate is intersected-and-validated against framework-manifest ownership in
# wi_install before a single byte is written (Gate A).
_wi_component_candidates() {
  local src="$WAKE_INSTALL_SOURCE" abs
  if [[ -d "$src/tools/wake" ]]; then
    while IFS= read -r -d '' abs; do
      printf '%s\n' "${abs#"$src"/}"
    done < <(find "$src/tools/wake" -type f -print0 | sort -z)
  fi
  # Cross-subtree files that logically belong to the wake component but live in
  # shared framework subtrees. Listed here for ENUMERATION only — ownership is
  # still decided solely by framework-manifest.txt in wi_install.
  printf '%s\n' "systemd/user/mosaic-wake.service"
  # The canon FALLBACK WAKE units (F7, #925). Both resolve framework-owned via the
  # existing `systemd/**` glob — enumerated here so wi_install COPIES them; still
  # Gate-A-validated against the framework-manifest SSOT before any byte is written.
  printf '%s\n' "systemd/user/$WAKE_FALLBACK_TIMER_NAME"
  printf '%s\n' "systemd/user/$WAKE_FALLBACK_SERVICE_NAME"
  printf '%s\n' "defaults/wake-watch-list.schema.json"
}

# wi_install — idempotent component install. Gate A: refuse to write any candidate
# the framework-manifest SSOT does not own. Idempotent: an unchanged file is
# skipped (no rewrite, no mtime churn), so a second run produces no diff.
wi_install() {
  local src="$WAKE_INSTALL_SOURCE" dst="$WAKE_INSTALL_TARGET"
  # Load + validate the SSOT ownership manifest (fail-closed on missing/empty).
  manifest_load "$WAKE_INSTALL_MANIFEST" || {
    wi_fail "framework-manifest.txt failed to load ($WAKE_INSTALL_MANIFEST) — refusing to install (fail-closed)."
    return 1
  }

  local rel written=0 skipped=0 missing=0
  # PASS 1 — Gate A validation FIRST, before any write. If ANY candidate resolves
  # outside framework-manifest ownership, refuse the WHOLE install (no partial
  # write): the wake component must never author a write the SSOT does not own.
  while IFS= read -r rel; do
    [[ -n "$rel" ]] || continue
    if ! manifest_is_framework "$rel"; then
      wi_fail "Gate A VIOLATION — wake candidate '$rel' is NOT framework-owned per framework-manifest.txt. The wake component manifest must not authorize a write outside framework-manifest ownership. Refusing the entire component install (fail-closed)."
      return 1
    fi
  done < <(_wi_component_candidates)

  # PASS 2 — copy. Every path is already proven framework-owned above.
  while IFS= read -r rel; do
    [[ -n "$rel" ]] || continue
    if [[ ! -f "$src/$rel" ]]; then
      # A cross-subtree candidate that isn't shipped in this source is not an
      # error (it may not exist yet); note and continue. tools/wake/** entries
      # always exist because they were enumerated from the filesystem.
      missing=$((missing + 1))
      continue
    fi
    if [[ -f "$dst/$rel" ]] && cmp -s "$src/$rel" "$dst/$rel"; then
      skipped=$((skipped + 1))
      continue
    fi
    [[ "$rel" == */* ]] && mkdir -p "$dst/${rel%/*}"
    cp "$src/$rel" "$dst/$rel"
    case "$rel" in *.sh) chmod +x "$dst/$rel" 2>/dev/null || true ;; esac
    written=$((written + 1))
  done < <(_wi_component_candidates)

  wi_ok "wake component install: $written written, $skipped unchanged, $missing not-shipped (Gate A: all writes framework-owned)."

  # (b #913) A10 canon step: place the deployed unit into the USER SYSTEMD SEARCH
  # PATH and validate it resolves. wi_install copies the unit under mosaic home
  # (systemd/user/, framework-owned) — but `systemctl --user` searches
  # ~/.config/systemd/user/, so without this link the service can never be
  # enabled/started. Both steps are idempotent; either failing fails the install.
  wi_link_systemd_unit || return 1
  wi_validate_systemd_path || return 1

  # (#925) A10 canon step: place the canon FALLBACK WAKE units (timer + oneshot
  # service) into the user systemd search path and validate they resolve + parse.
  # Same link-back-to-SSOT, idempotent, fail-closed pattern as the detector unit
  # above. The per-class cadence drop-in (blank-reset) and the F7 proven-live gate
  # are separate steps (write-fallback-cadence / reset-verify-retire).
  wi_link_fallback_units || return 1
  wi_validate_fallback_units || return 1

  # Machine-readable summary for the harness (idempotency assertion keys off it).
  printf 'wake-install: written=%s skipped=%s missing=%s\n' "$written" "$skipped" "$missing"
}

# ═══════════════════════════════════════════════════════════════════════════════
# (b #913) systemd user-search-path link + post-install validate
# ═══════════════════════════════════════════════════════════════════════════════
# ADDITIVE / #869: the link target (~/.config/systemd/user/<unit>) lives OUTSIDE
# mosaic home, so it is not a framework-manifest path (the manifest is
# mosaic-home-relative). Ownership of the SSOT unit stays `systemd/**` in the single
# framework-manifest.txt authority — this step adds NO new owned path and creates NO
# second ownership authority. The link points BACK to the mosaic-home SSOT copy, so
# a later framework upgrade of the unit propagates with no re-link.

# wi_link_systemd_unit — idempotently place the deployed wake unit into the user
# systemd search path so `systemctl --user` can resolve it. Symlinks to the
# mosaic-home SSOT copy installed by wi_install. Idempotent: an already-correct link
# (or a byte-identical regular file) is left untouched; a stale entry is replaced.
wi_link_systemd_unit() {
  local unit="$WAKE_UNIT_NAME"
  local ssot="$WAKE_INSTALL_TARGET/systemd/user/$unit"
  local link="$WAKE_SYSTEMD_USER_DIR/$unit"
  if [[ ! -f "$ssot" ]]; then
    wi_fail "systemd-link — the deployed unit is missing at $ssot; run 'wake-install.sh install' first (fail-closed)."
    return 1
  fi
  mkdir -p "$WAKE_SYSTEMD_USER_DIR"
  # Idempotent no-op if the search-path entry already resolves to the SSOT copy.
  if [[ -L "$link" ]]; then
    if [[ "$(readlink "$link")" == "$ssot" ]]; then
      wi_ok "systemd-link — '$unit' already linked into the search path ($link -> $ssot)."
      return 0
    fi
  elif [[ -f "$link" ]] && cmp -s "$ssot" "$link"; then
    wi_ok "systemd-link — '$unit' already present in the search path ($link, byte-identical)."
    return 0
  fi
  # Replace whatever is there (stale link / old copy) with a fresh symlink to SSOT.
  if ! ln -sfn "$ssot" "$link"; then
    wi_fail "systemd-link — could not link '$unit' into the user systemd search path ($link). FAIL LOUD (fail-closed)."
    return 1
  fi
  wi_ok "systemd-link — '$unit' linked into the user systemd search path ($link -> $ssot)."
  return 0
}

# wi_validate_systemd_path — post-install validate that the unit RESOLVES in the
# user systemd search path. The installer may run where the user systemd manager is
# NOT live (containers, no login session), so the AUTHORITATIVE check is
# path + well-formedness: the search-path entry exists, dereferences to a readable
# file, and parses as a unit ([Unit]/[Service]/[Install] + an ExecStart). A live
# `systemctl --user cat` probe runs ONLY opportunistically behind a guard.
wi_validate_systemd_path() {
  local unit="$WAKE_UNIT_NAME"
  local link="$WAKE_SYSTEMD_USER_DIR/$unit" resolved
  if [[ ! -e "$link" ]]; then
    wi_fail "systemd-validate — '$unit' is NOT in the user systemd search path ($link). systemctl --user cannot resolve it, so the wake service cannot be enabled/started. Run 'wake-install.sh link-systemd-unit' (part of install) (fail-closed)."
    return 1
  fi
  if [[ -L "$link" ]]; then
    resolved="$(readlink -f "$link" 2>/dev/null || true)"
  else
    resolved="$link"
  fi
  if [[ -z "$resolved" || ! -r "$resolved" ]]; then
    wi_fail "systemd-validate — '$unit' search-path entry ($link) does not dereference to a readable unit file. FAIL LOUD (fail-closed)."
    return 1
  fi
  # Well-formed check: a wake detector unit must carry the core sections + ExecStart.
  local -a miss=()
  grep -q '^\[Unit\]'    "$resolved" || miss+=("[Unit]")
  grep -q '^\[Service\]' "$resolved" || miss+=("[Service]")
  grep -q '^\[Install\]' "$resolved" || miss+=("[Install]")
  grep -q '^ExecStart='  "$resolved" || miss+=("ExecStart=")
  if [[ ${#miss[@]} -ne 0 ]]; then
    wi_fail "systemd-validate — '$unit' ($resolved) is malformed: missing ${miss[*]}. Refusing to certify an ill-formed unit (fail-closed)."
    return 1
  fi
  # Opportunistic live probe ONLY when explicitly requested AND a user manager is
  # reachable — never a hard requirement (the installer often runs without one).
  if [[ "${WAKE_VERIFY_USE_SYSTEMCTL:-0}" == "1" ]] && command -v systemctl >/dev/null 2>&1; then
    if systemctl --user cat "$unit" >/dev/null 2>&1; then
      wi_ok "systemd-validate — 'systemctl --user cat $unit' resolves (live user manager confirmed)."
    else
      wi_warn "systemd-validate — file is in the search path + well-formed, but 'systemctl --user cat $unit' did not resolve (no live user manager / not daemon-reloaded). Non-fatal: run 'systemctl --user daemon-reload' in a live session."
    fi
  fi
  wi_ok "systemd-validate — '$unit' resolves in the user systemd search path ($link -> $resolved, well-formed)."
  return 0
}

# ═══════════════════════════════════════════════════════════════════════════════
# (#925) canon FALLBACK WAKE units — link + validate + per-class cadence + F7 gate
# ═══════════════════════════════════════════════════════════════════════════════
# ADDITIVE / #869: exactly as for the detector unit, the link target lives OUTSIDE
# mosaic home, so it is NOT a framework-manifest path. Ownership of the SSOT units
# stays the single `systemd/**` glob in framework-manifest.txt — NO new owned path,
# NO second ownership authority. The link points BACK to the mosaic-home SSOT copy.

# _wi_link_one UNIT — idempotently place ONE deployed unit into the user systemd
# search path (symlink to the mosaic-home SSOT copy). Shared idiom with the
# detector's wi_link_systemd_unit; an already-correct link / byte-identical file is
# left untouched, a stale entry is replaced. Fail-closed on a missing SSOT copy.
_wi_link_one() {
  local unit="$1"
  local ssot="$WAKE_INSTALL_TARGET/systemd/user/$unit"
  local link="$WAKE_SYSTEMD_USER_DIR/$unit"
  if [[ ! -f "$ssot" ]]; then
    wi_fail "systemd-link — the deployed unit is missing at $ssot; run 'wake-install.sh install' first (fail-closed)."
    return 1
  fi
  mkdir -p "$WAKE_SYSTEMD_USER_DIR"
  if [[ -L "$link" ]]; then
    if [[ "$(readlink "$link")" == "$ssot" ]]; then
      wi_ok "systemd-link — '$unit' already linked into the search path ($link -> $ssot)."
      return 0
    fi
  elif [[ -f "$link" ]] && cmp -s "$ssot" "$link"; then
    wi_ok "systemd-link — '$unit' already present in the search path ($link, byte-identical)."
    return 0
  fi
  if ! ln -sfn "$ssot" "$link"; then
    wi_fail "systemd-link — could not link '$unit' into the user systemd search path ($link). FAIL LOUD (fail-closed)."
    return 1
  fi
  wi_ok "systemd-link — '$unit' linked into the user systemd search path ($link -> $ssot)."
  return 0
}

# wi_link_fallback_units — link BOTH canon fallback units (timer + oneshot service)
# into the user systemd search path so `systemctl --user` can resolve+enable them.
wi_link_fallback_units() {
  _wi_link_one "$WAKE_FALLBACK_TIMER_NAME" || return 1
  _wi_link_one "$WAKE_FALLBACK_SERVICE_NAME" || return 1
  return 0
}

# _wi_validate_one UNIT KIND — post-install validate that ONE unit resolves in the
# user systemd search path AND is well-formed for its KIND. KIND=timer requires
# [Timer] + a base OnUnitActiveSec (the blank-reset target); KIND=service requires
# [Service] + ExecStart. As with the detector, the AUTHORITATIVE floor is
# path + well-formedness (the installer often runs with no live user manager); a
# live `systemctl --user cat` probe runs ONLY opportunistically behind the guard.
_wi_validate_one() {
  local unit="$1" kind="$2"
  local link="$WAKE_SYSTEMD_USER_DIR/$unit" resolved
  if [[ ! -e "$link" ]]; then
    wi_fail "fallback-validate — '$unit' is NOT in the user systemd search path ($link). systemctl --user cannot resolve it, so the canon fallback wake cannot be enabled/started. Run 'wake-install.sh link-fallback-units' (part of install) (fail-closed)."
    return 1
  fi
  if [[ -L "$link" ]]; then
    resolved="$(readlink -f "$link" 2>/dev/null || true)"
  else
    resolved="$link"
  fi
  if [[ -z "$resolved" || ! -r "$resolved" ]]; then
    wi_fail "fallback-validate — '$unit' search-path entry ($link) does not dereference to a readable unit file. FAIL LOUD (fail-closed)."
    return 1
  fi
  local -a miss=()
  grep -q '^\[Unit\]' "$resolved" || miss+=("[Unit]")
  case "$kind" in
    timer)
      grep -q '^\[Timer\]'         "$resolved" || miss+=("[Timer]")
      grep -q '^\[Install\]'       "$resolved" || miss+=("[Install]")
      grep -q '^OnUnitActiveSec='  "$resolved" || miss+=("OnUnitActiveSec=")
      ;;
    service)
      grep -q '^\[Service\]' "$resolved" || miss+=("[Service]")
      grep -q '^ExecStart='  "$resolved" || miss+=("ExecStart=")
      ;;
    *)
      wi_fail "fallback-validate — internal: unknown unit kind '$kind'."; return 2 ;;
  esac
  if [[ ${#miss[@]} -ne 0 ]]; then
    wi_fail "fallback-validate — '$unit' ($resolved) is malformed: missing ${miss[*]}. Refusing to certify an ill-formed unit (fail-closed)."
    return 1
  fi
  if [[ "${WAKE_VERIFY_USE_SYSTEMCTL:-0}" == "1" ]] && command -v systemctl >/dev/null 2>&1; then
    if systemctl --user cat "$unit" >/dev/null 2>&1; then
      wi_ok "fallback-validate — 'systemctl --user cat $unit' resolves (live user manager confirmed)."
    else
      wi_warn "fallback-validate — file is in the search path + well-formed, but 'systemctl --user cat $unit' did not resolve (no live user manager / not daemon-reloaded). Non-fatal: run 'systemctl --user daemon-reload' in a live session."
    fi
  fi
  wi_ok "fallback-validate — '$unit' resolves in the user systemd search path ($link -> $resolved, well-formed $kind)."
  return 0
}

# wi_validate_fallback_units — both canon fallback units resolve + are well-formed.
wi_validate_fallback_units() {
  _wi_validate_one "$WAKE_FALLBACK_TIMER_NAME"   timer   || return 1
  _wi_validate_one "$WAKE_FALLBACK_SERVICE_NAME" service || return 1
  return 0
}

# wi_write_fallback_cadence CADENCE — write the per-class cadence for the fallback
# TIMER as a BLANK-RESET drop-in (mosaic-wake-fallback.timer.d/cadence.conf), then
# verify exactly ONE effective OnUnitActiveUSec. CADENCE is the operator's per-class
# `fallback_cadence` from the watch-list (config, not code). Same blank-reset idiom
# as the legacy-timer cadence: an empty OnUnitActiveSec= reset line CLEARS the base
# value, then the new value sets exactly one — so the base placeholder in the shipped
# timer can never accumulate into a second effective cadence.
wi_write_fallback_cadence() {
  local cadence="${1:-}"
  [[ -n "$cadence" ]] || { wi_fail "write-fallback-cadence: CADENCE (per-class fallback_cadence) required."; return 2; }
  local timer="$WAKE_FALLBACK_TIMER_NAME"
  wi_write_blank_reset_dropin "$WAKE_SYSTEMD_USER_DIR/$timer.d/cadence.conf" "$cadence" || return 1
  wi_verify_single_active "$timer" || {
    wi_fail "write-fallback-cadence — blank-reset verify failed for '$timer'; the fallback cadence did not collapse to exactly one OnUnitActiveUSec (fail-closed)."
    return 1
  }
  wi_ok "write-fallback-cadence — '$timer' cadence set to $cadence (blank-reset, exactly one OnUnitActiveUSec)."
  return 0
}

# wi_fallback_proven_live — the F7 PRECONDITION check (replacement-before-retirement,
# #925). The legacy reap MUST NOT proceed unless the canon fallback wake is live +
# proven-firing, so there is never a coverage gap. Layered, fail-closed:
#   FLOOR (always, even with no live user manager): both fallback units resolve in
#     the user systemd search path AND are well-formed (SCHEDULABLE/enabled floor) —
#     i.e. the fallback is INSTALLED and CAN fire. A missing/ill-formed unit => REFUSE.
#   LIVE (opportunistic, WAKE_VERIFY_USE_SYSTEMCTL=1 + a reachable user manager,
#     mirroring #913): additionally require the TIMER be ENABLED and PROVEN-FIRING —
#     LastTriggerUSec is set (it has fired at least once) OR NextElapse is armed
#     (it is scheduled to fire). A dead/never-armed timer => REFUSE.
# Returns 0 iff the fallback is proven live to the strongest tier available.
wi_fallback_proven_live() {
  local timer="$WAKE_FALLBACK_TIMER_NAME" service="$WAKE_FALLBACK_SERVICE_NAME"
  # FLOOR — installed + well-formed (schedulable). Reuses the validate above.
  if ! wi_validate_fallback_units >/dev/null 2>&1; then
    wi_fail "F7 fallback-proven-live — the canon FALLBACK WAKE ('$timer' + '$service') is NOT installed/schedulable in the user systemd search path. Replacement-before-retirement (F7) FORBIDS reaping the legacy timer without a live fallback (fail-closed). Run 'wake-install.sh install' + 'write-fallback-cadence <fallback_cadence>' first."
    return 1
  fi
  # LIVE — opportunistic proven-firing when a real user manager is reachable.
  if [[ "${WAKE_VERIFY_USE_SYSTEMCTL:-0}" == "1" ]] && command -v systemctl >/dev/null 2>&1; then
    local enabled last next
    enabled="$(systemctl --user is-enabled "$timer" 2>/dev/null || true)"
    if [[ "$enabled" != "enabled" && "$enabled" != "static" ]]; then
      wi_fail "F7 fallback-proven-live — '$timer' is not enabled (systemctl --user is-enabled => '${enabled:-unknown}'). A disabled fallback cannot fire; refusing the legacy reap (F7, fail-closed)."
      return 1
    fi
    last="$(systemctl --user show "$timer" -p LastTriggerUSec --value 2>/dev/null || true)"
    next="$(systemctl --user show "$timer" -p NextElapseUSecRealtime --value 2>/dev/null || true)"
    # Proven-firing: it has already fired (LastTrigger set) OR it is armed to fire
    # (NextElapse set). A timer that is neither is dead => refuse.
    if _wi_usec_set "$last" || _wi_usec_set "$next"; then
      wi_ok "F7 fallback-proven-live — '$timer' is enabled and proven-firing (LastTrigger='${last:-n/a}', NextElapse='${next:-n/a}'). Legacy reap is unblocked (F7 satisfied)."
      return 0
    fi
    wi_fail "F7 fallback-proven-live — '$timer' is enabled but has NEITHER fired (LastTriggerUSec unset) NOR is armed (NextElapseUSecRealtime unset): it is not proven-firing. Refusing the legacy reap (F7, fail-closed) — start the timer and confirm it fires first."
    return 1
  fi
  wi_ok "F7 fallback-proven-live — canon FALLBACK WAKE is installed + schedulable (no live user manager to probe firing; schedulable/enabled floor satisfied, mirroring #913). Legacy reap is unblocked at the schedulable floor."
  return 0
}

# _wi_usec_set VALUE — rc 0 iff VALUE is a set systemd USec timestamp: non-empty,
# not the "unset" sentinels systemd prints for a never-fired / never-armed timer.
_wi_usec_set() {
  local v="$1"
  [[ -n "$v" ]] || return 1
  case "$v" in
    0|n/a|'-'|'') return 1 ;;
    *) return 0 ;;
  esac
}

# ═══════════════════════════════════════════════════════════════════════════════
# (v) Fail-closed alarm-target + HMAC-key install-validation (G1/G2a)
# ═══════════════════════════════════════════════════════════════════════════════

# Resolve the credential store the same way load_credentials / sign.sh do, WITHOUT
# ever echoing a resolved value. Sourcing credentials.sh only sets
# MOSAIC_CREDENTIALS_FILE; we never call load_credentials for a secret here.
_wi_cred_file() {
  local cred_lib="$SCRIPT_DIR/../_lib/credentials.sh"
  if [[ -f "$cred_lib" ]]; then
    # shellcheck source=../_lib/credentials.sh disable=SC1091
    . "$cred_lib"
  fi
  printf '%s' "${MOSAIC_CREDENTIALS_FILE:-$HOME/.config/mosaic/credentials.json}"
}

# wi_validate_hmac_key — the W3/W7 HMAC key MUST resolve BY NAME
# (.wake.hmac_keys.<name>) in the operator credential store. Missing => FAIL LOUD:
# the installer must never deploy a config that would emit UNSIGNED wakes. Only a
# boolean/verdict is printed — the key material is NEVER echoed.
wi_validate_hmac_key() {
  command -v jq >/dev/null 2>&1 || { wi_fail "jq is required for install-validate."; return 3; }
  local name="${WAKE_HMAC_KEY_NAME:-default}" cred_file present
  cred_file="$(_wi_cred_file)"
  if [[ ! -f "$cred_file" ]]; then
    wi_fail "HMAC-key validate — credential store not found ($cred_file): cannot resolve key name '$name'. Refusing to deploy a config that would emit UNSIGNED wakes (fail-closed)."
    return 1
  fi
  # jq -e sets exit status; we capture ONLY presence, never the value.
  if present="$(jq -re --arg n "$name" '(.wake.hmac_keys[$n] // "") | if . == "" then empty else "present" end' "$cred_file" 2>/dev/null)" && [[ "$present" == present ]]; then
    wi_ok "HMAC key '$name' resolves by-name in the credential store (value NOT read/echoed)."
    return 0
  fi
  wi_fail "HMAC-key validate — key name '$name' is NOT configured in the credential store (.wake.hmac_keys). A missing key would emit UNSIGNED wakes. FAIL LOUD (fail-closed)."
  return 1
}

# wi_validate_alarm_target — the operator's W6 alarm-sink target must be
# CONFIGURED (WAKE_ALARM_SINK_CMD set) and REACHABLE (a probe payload through the
# pluggable adapter exits 0). The adapter resolves its endpoint BY NAME internally
# (load_credentials); this installer ships/writes NO endpoint value. An
# unconfigured OR unreachable target FAILS LOUD — no silent no-alarm host (G1/G2a).
wi_validate_alarm_target() {
  if [[ -z "${WAKE_ALARM_SINK_CMD:-}" ]]; then
    wi_fail "alarm-target validate — WAKE_ALARM_SINK_CMD is UNSET: no off-host alarm route is configured. A host with no alarm sink is a silent no-alarm host. FAIL LOUD (G1/G2a, fail-closed)."
    return 1
  fi
  # Reachability probe: send a benign install-probe payload through the adapter.
  # A non-zero exit = UNREACHABLE target => fail loud. Adapter output is
  # discarded so no resolved endpoint value can leak into installer output.
  local probe='{"kind":"wake-install-probe"}'
  if ! printf '%s\n' "$probe" | sh -c "$WAKE_ALARM_SINK_CMD" >/dev/null 2>&1; then
    wi_fail "alarm-target validate — the alarm sink is UNREACHABLE (WAKE_ALARM_SINK_CMD probe exited non-zero): the alarm cannot route to a human/other-host. FAIL LOUD (G1/G2a, fail-closed)."
    return 1
  fi
  wi_ok "alarm-sink target is configured + reachable (endpoint resolved by-name by the adapter; NOT read/echoed here)."
  return 0
}

# wi_validate_targets — both gates. Either failure fails the whole validate loud.
wi_validate_targets() {
  local rc=0
  wi_validate_hmac_key || rc=1
  wi_validate_alarm_target || rc=1
  if [[ "$rc" -ne 0 ]]; then
    wi_fail "install-validate FAILED — refusing to enable the wake service with an unconfigured signing key or a silent no-alarm host (fail-closed)."
    return 1
  fi
  wi_ok "install-validate PASSED — HMAC key + alarm sink are both fail-closed-ready."
  return 0
}

# ═══════════════════════════════════════════════════════════════════════════════
# (iv) snapshot-guard — never reap a deployed unit without a snapshot first
# ═══════════════════════════════════════════════════════════════════════════════

_wi_snapshot_path() { printf '%s/%s' "$WAKE_SNAPSHOT_DIR" "$1"; }

# wi_snapshot_unit UNIT — retain a byte copy (+ the whole .d drop-in tree) of a
# deployed unit BEFORE any reap/clean-checkout. Idempotent (re-snapshotting an
# unchanged unit just refreshes it). Private (0700/0600): a unit can reference
# operator paths.
wi_snapshot_unit() {
  local unit="$1"
  [[ -n "$unit" ]] || { wi_fail "snapshot-unit: UNIT name required."; return 2; }
  local src="$WAKE_SYSTEMD_USER_DIR/$unit" snap
  snap="$(_wi_snapshot_path "$unit")"
  if [[ ! -e "$src" ]]; then
    wi_fail "snapshot-unit — deployed unit '$unit' not found under $WAKE_SYSTEMD_USER_DIR; nothing to snapshot."
    return 1
  fi
  local old_umask; old_umask="$(umask)"; umask 077
  mkdir -p "$(dirname "$snap")"
  cp "$src" "$snap"
  # Capture the drop-in dir too (cadence drop-ins live in <unit>.d/).
  if [[ -d "$WAKE_SYSTEMD_USER_DIR/$unit.d" ]]; then
    rm -rf "$snap.d"; mkdir -p "$snap.d"
    cp -a "$WAKE_SYSTEMD_USER_DIR/$unit.d/." "$snap.d/"
  fi
  umask "$old_umask"
  wi_ok "snapshot-unit — '$unit' snapshotted to $snap (reap is now unblocked for this unit)."
  return 0
}

# wi_has_snapshot UNIT — rc 0 iff a snapshot for UNIT exists.
wi_has_snapshot() {
  [[ -f "$(_wi_snapshot_path "$1")" ]]
}

# wi_reap_unit UNIT — remove a deployed unit (+ its .d drop-ins). FAIL-CLOSED: a
# reap with NO prior snapshot is REFUSED. This is the guard against the
# deployed-from-uncommitted failure class recurring: you cannot clean-checkout /
# reap a deployed-but-uncommitted unit until it is snapshotted.
wi_reap_unit() {
  local unit="$1"
  [[ -n "$unit" ]] || { wi_fail "reap-unit: UNIT name required."; return 2; }
  if ! wi_has_snapshot "$unit"; then
    wi_fail "snapshot-guard — REFUSING to reap '$unit': no snapshot exists (a reap/clean-checkout of a deployed-but-uncommitted unit without a snapshot is the exact failure class this guard forbids). Run 'wake-install.sh snapshot-unit $unit' first."
    return 1
  fi
  rm -f "$WAKE_SYSTEMD_USER_DIR/$unit"
  rm -rf "$WAKE_SYSTEMD_USER_DIR/$unit.d"
  wi_ok "reap-unit — '$unit' reaped (snapshot retained at $(_wi_snapshot_path "$unit"))."
  return 0
}

# ═══════════════════════════════════════════════════════════════════════════════
# (iii) blank-reset idiom + exactly-one-OnUnitActiveUSec verify + retire
# ═══════════════════════════════════════════════════════════════════════════════

# wi_write_blank_reset_dropin DROPIN_FILE INTERVAL — write a [Timer] cadence
# drop-in in the BLANK-RESET form: an empty OnUnitActiveSec= reset line CLEARS any
# previously accumulated (list-valued) cadence, then the new value sets exactly
# one. This is byte-identical to the installer-7 idiom and guarantees exactly ONE
# effective OnUnitActiveUSec after systemd merges the base unit + all drop-ins.
wi_write_blank_reset_dropin() {
  local file="$1" interval="$2"
  [[ -n "$file" && -n "$interval" ]] || { wi_fail "blank-reset: DROPIN_FILE and INTERVAL required."; return 2; }
  mkdir -p "$(dirname "$file")"
  # The empty reset line MUST precede the new value (order is load-bearing).
  cat >"$file" <<EOF
[Timer]
OnUnitActiveSec=
OnUnitActiveSec=$interval
EOF
  wi_ok "blank-reset drop-in written: $file (OnUnitActiveSec reset -> $interval)."
  return 0
}

# _wi_effective_onunitactive UNIT_FILE DROPIN_DIR — echo, one per line, the
# EFFECTIVE OnUnitActiveSec values after simulating systemd's list-merge: process
# the base unit then every drop-in in the .d dir in lexical (systemd) order; an
# EMPTY assignment RESETS the accumulated list, a non-empty one APPENDS. Mirrors
# `systemctl show -p OnUnitActiveUSec` semantics without needing a live manager.
_wi_effective_onunitactive() {
  local unit_file="$1" dropin_dir="$2"
  local -a eff=()
  _wi_merge_file() {
    local f="$1" line val
    [[ -f "$f" ]] || return 0
    while IFS= read -r line || [[ -n "$line" ]]; do
      line="${line#"${line%%[![:space:]]*}"}"
      case "$line" in
        OnUnitActiveSec=*)
          val="${line#OnUnitActiveSec=}"
          val="${val#"${val%%[![:space:]]*}"}"
          val="${val%"${val##*[![:space:]]}"}"
          if [[ -z "$val" ]]; then
            eff=()            # empty assignment RESETS the list
          else
            eff+=("$val")     # non-empty APPENDS
          fi
          ;;
      esac
    done <"$f"
  }
  _wi_merge_file "$unit_file"
  if [[ -d "$dropin_dir" ]]; then
    local d
    while IFS= read -r d; do
      [[ -n "$d" ]] && _wi_merge_file "$d"
    done < <(find "$dropin_dir" -maxdepth 1 -type f -name '*.conf' | LC_ALL=C sort)
  fi
  local v
  for v in ${eff[@]+"${eff[@]}"}; do printf '%s\n' "$v"; done
}

# wi_verify_single_active UNIT — post-apply verify: exactly ONE effective
# OnUnitActiveUSec. Prefers a live `systemctl --user show` when available +
# loaded; otherwise falls back to the deterministic merge simulation above.
# rc 0 = exactly one; rc 1 = zero or more-than-one (fail loud).
wi_verify_single_active() {
  local unit="$1"
  [[ -n "$unit" ]] || { wi_fail "verify-single: UNIT name required."; return 2; }
  local count=""
  if [[ "${WAKE_VERIFY_USE_SYSTEMCTL:-0}" == "1" ]] && command -v systemctl >/dev/null 2>&1; then
    # Live path: systemd already merged base+drop-ins. One line, one value.
    local shown
    if shown="$(systemctl --user show "$unit" -p OnUnitActiveUSec --value 2>/dev/null)"; then
      # --value prints the merged value (may be a space-joined list if >1).
      # shellcheck disable=SC2086
      set -- $shown
      count=$#
    fi
  fi
  if [[ -z "$count" ]]; then
    count="$(_wi_effective_onunitactive "$WAKE_SYSTEMD_USER_DIR/$unit" "$WAKE_SYSTEMD_USER_DIR/$unit.d" | grep -c .)"
  fi
  if [[ "$count" -eq 1 ]]; then
    wi_ok "verify-single — '$unit' resolves EXACTLY ONE OnUnitActiveUSec (blank-reset idiom held)."
    return 0
  fi
  wi_fail "verify-single — '$unit' resolves $count OnUnitActiveUSec values (expected exactly 1). The blank-reset idiom did not collapse the cadence. FAIL LOUD."
  return 1
}

# wi_reset_verify_retire AGENT [--interval V] [--vector-passed] — the §5
# reset->verify->retire lifecycle for the legacy mosaic-heartbeat@<agent> timer.
# This is the TESTABLE acceptance path:
#   1. SNAPSHOT the legacy timer (snapshot-guard precondition for any later reap).
#   2. IF --interval is given, write the fallback-cadence drop-in in blank-reset
#      form, then VERIFY exactly-one-OnUnitActiveUSec.
#   3. ONLY on --vector-passed (the §4-vector pass) REMOVE the legacy units, and
#      only via the snapshot-guarded reap (retire-LAST).
wi_reset_verify_retire() {
  local agent="" interval="" vector_passed=0
  agent="${1:-}"; shift || true
  [[ -n "$agent" ]] || { wi_fail "reset-verify-retire: AGENT required."; return 2; }
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --interval) interval="${2:-}"; shift 2 ;;
      --vector-passed) vector_passed=1; shift ;;
      *) wi_fail "reset-verify-retire: unknown option '$1'."; return 2 ;;
    esac
  done
  local legacy_timer="mosaic-heartbeat@$agent.timer"
  local legacy_service="mosaic-heartbeat@$agent.service"

  # 1) SNAPSHOT FIRST — unconditionally, before touching or retiring anything.
  wi_snapshot_unit "$legacy_timer" || {
    wi_fail "reset-verify-retire — could not snapshot the legacy timer '$legacy_timer'; refusing to proceed (snapshot-guard, fail-closed)."
    return 1
  }

  # 2) blank-reset the fallback cadence (if a per-class fallback timer is used),
  #    then verify exactly one effective OnUnitActiveUSec.
  if [[ -n "$interval" ]]; then
    wi_write_blank_reset_dropin "$WAKE_SYSTEMD_USER_DIR/$legacy_timer.d/interval.conf" "$interval" || return 1
    wi_verify_single_active "$legacy_timer" || {
      wi_fail "reset-verify-retire — blank-reset verify failed for '$legacy_timer'; refusing to retire on an unverified cadence (fail-closed)."
      return 1
    }
  fi

  # 3) RETIRE LAST — only on the §4-vector pass, and only via the snapshot-guarded
  #    reap. Without --vector-passed the legacy units are LEFT RUNNING (overlap).
  if [[ "$vector_passed" -eq 1 ]]; then
    # F7 PRECONDITION (#925) — replacement-before-retirement. The reap MUST NOT
    # proceed unless the canon FALLBACK WAKE is proven live (installed + schedulable
    # at the floor; enabled + proven-firing when a live user manager is probeable).
    # This encodes F7 into the installer, not operator memory: there is never a
    # window where the legacy net is reaped before its replacement is carrying load.
    if [[ "${WAKE_REQUIRE_FALLBACK_LIVE:-1}" == "1" ]]; then
      wi_fallback_proven_live || {
        wi_fail "reset-verify-retire — REFUSING to reap the legacy '$agent' heartbeat: the canon FALLBACK WAKE is not proven live (F7 replacement-before-retirement, fail-closed). The legacy net stays UP until the fallback is carrying load."
        return 1
      }
    fi
    wi_reap_unit "$legacy_timer" || return 1
    # The paired service may not be independently deployed; reap it best-effort
    # but still snapshot-guarded if present.
    if [[ -e "$WAKE_SYSTEMD_USER_DIR/$legacy_service" ]]; then
      wi_snapshot_unit "$legacy_service" && wi_reap_unit "$legacy_service" || return 1
    fi
    wi_ok "reset-verify-retire — legacy '$agent' heartbeat units RETIRED (post §4-vector pass, snapshot-guarded)."
  else
    wi_ok "reset-verify-retire — overlap phase: cadence reset+verified, legacy '$agent' units LEFT RUNNING (retire withheld until §4-vector pass)."
  fi
  return 0
}

# ═══════════════════════════════════════════════════════════════════════════════
# CLI dispatch — only when executed directly, never when sourced.
# ═══════════════════════════════════════════════════════════════════════════════
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
  cmd="${1:-}"; shift || true
  case "$cmd" in
    install)              wi_install "$@" ;;
    link-systemd-unit)    wi_link_systemd_unit "$@" ;;
    validate-systemd-path) wi_validate_systemd_path "$@" ;;
    link-fallback-units)  wi_link_fallback_units "$@" ;;
    validate-fallback-units) wi_validate_fallback_units "$@" ;;
    write-fallback-cadence) wi_write_fallback_cadence "$@" ;;
    fallback-proven-live) wi_fallback_proven_live "$@" ;;
    validate-targets)     wi_validate_targets "$@" ;;
    validate-hmac-key)    wi_validate_hmac_key "$@" ;;
    validate-alarm-target) wi_validate_alarm_target "$@" ;;
    snapshot-unit)        wi_snapshot_unit "$@" ;;
    reap-unit)            wi_reap_unit "$@" ;;
    blank-reset)          wi_write_blank_reset_dropin "$@" ;;
    verify-single)        wi_verify_single_active "$@" ;;
    reset-verify-retire)  wi_reset_verify_retire "$@" ;;
    -h | --help | help | '')
      cat >&2 <<'EOF'
Usage: wake-install.sh <command> [args]

Commands:
  install                              Idempotent component-manifest install + Gate A,
                                       then link the unit into the user systemd search
                                       path and validate it resolves (b, #913).
  link-systemd-unit                    Link mosaic-wake.service into ~/.config/systemd/user/.
  validate-systemd-path                Validate the unit resolves in the user systemd search path.
  link-fallback-units                  Link the canon fallback wake timer+service into the search path (#925).
  validate-fallback-units              Validate the fallback timer+service resolve + are well-formed (#925).
  write-fallback-cadence CADENCE       Write the per-class fallback timer cadence as a blank-reset drop-in (#925).
  fallback-proven-live                 F7 gate: the canon fallback wake is proven live (schedulable floor / firing) (#925).
  validate-targets                     Fail-closed HMAC-key + alarm-target validate (v).
  validate-hmac-key                    HMAC key resolves by-name, else fail loud.
  validate-alarm-target                Alarm sink configured + reachable, else fail loud.
  snapshot-unit UNIT                   Snapshot a deployed unit (snapshot-guard precondition).
  reap-unit UNIT                       Reap a deployed unit; REFUSES without a snapshot.
  blank-reset DROPIN_FILE INTERVAL     Write a blank-reset cadence drop-in.
  verify-single UNIT                   Verify exactly one effective OnUnitActiveUSec.
  reset-verify-retire AGENT [--interval V] [--vector-passed]
                                       The §5 reset->verify->retire lifecycle.
EOF
      [[ "$cmd" == -h || "$cmd" == --help || "$cmd" == help ]] && exit 0
      exit 2
      ;;
    *)
      wi_fail "unknown command '$cmd'"
      exit 2
      ;;
  esac
fi
