#!/usr/bin/env bash
# ============================================================
# voice-author-key-audit.sh
#
# Purpose
# -------
# Task 1701 — find voice-mirror keys that name no :AdminUser on their own
# account. Two distinct findings, do not conflate them:
#
#   1. :VoiceProfile nodes whose userId matches no :AdminUser on the profile's
#      accountId. Such a profile is unreachable by any seat that resolves
#      identity properly: it is silent dead data that looks, in the graph, like
#      a real operator profile.
#   2. Content nodes carrying a voiceAuthor that matches no :AdminUser on the
#      node's accountId. The corpus is attributed to an identity that does not
#      exist, and personal distillation can never read it back.
#
# Finding (2) is the expected shape of the known Ecolec 'rob-mead' case: the
# tag path was the one write path with no identity check before Task 1701.
# Finding (1) should be empty on any install — both :VoiceProfile write paths
# have anchored on (:AdminUser {accountId, userId}) since Task 676. A hit there
# means something created an :AdminUser for an invented key, which is a
# different defect from the one Task 1701 closes; report it, do not assume it.
#
# '__org__' is the reserved org key (Task 676). It legitimately names no
# :AdminUser and anchors on :LocalBusiness, so it is excluded from both checks.
#
# READ-ONLY. This script never deletes and never re-keys. Findings sit on live
# client accounts, so disposition is an operator decision, not an agent one.
#
# Usage
# -----
#   bash voice-author-key-audit.sh [--brand=NAME]
#
#   --brand=NAME   Brand name (e.g. "maxy-code", "sitedesk-code"). Auto-detected
#                  when exactly one ~/.<brand>/.neo4j-password exists.
#
# Reads:
#   ~/.<brand>/.neo4j-password   Neo4j password
#   ~/.<brand>/.env              NEO4J_URI (override with the env var)
#
# Exit codes
# ----------
#   0  no unmatched keys
#   1  unmatched keys found (details on stdout)
#   2  usage, credential, or connection error
# ============================================================
set -euo pipefail

BRAND=""
for arg in "$@"; do
  case "$arg" in
    --brand=*) BRAND="${arg#*=}" ;;
    -h|--help) sed -n '2,45p' "$0"; exit 0 ;;
    *)
      echo "[voice-author-key-audit] ERROR unknown argument: $arg" >&2
      exit 2
      ;;
  esac
done

# Brand auto-detection mirrors dedupe-userprofile-ghosts.sh: exactly one
# ~/.<brand>/.neo4j-password means the brand is unambiguous. Zero or several
# means the operator must say which install to audit.
if [ -z "$BRAND" ]; then
  candidates=()
  for pw in "$HOME"/.*/.neo4j-password; do
    [ -f "$pw" ] && candidates+=("$pw")
  done
  if [ "${#candidates[@]}" -ne 1 ]; then
    echo "[voice-author-key-audit] ERROR cannot auto-detect brand (${#candidates[@]} candidates); pass --brand=NAME" >&2
    exit 2
  fi
  BRAND="$(basename "$(dirname "${candidates[0]}")")"
  BRAND="${BRAND#.}"
fi

BRAND_DIR="$HOME/.$BRAND"
PASSWORD_FILE="$BRAND_DIR/.neo4j-password"
if [ ! -f "$PASSWORD_FILE" ]; then
  echo "[voice-author-key-audit] ERROR no password file at $PASSWORD_FILE" >&2
  exit 2
fi
NEO4J_PASSWORD="$(tr -d '\n' < "$PASSWORD_FILE")"

if [ -z "${NEO4J_URI:-}" ] && [ -f "$BRAND_DIR/.env" ]; then
  NEO4J_URI="$(grep -E '^NEO4J_URI=' "$BRAND_DIR/.env" | head -1 | cut -d= -f2- | tr -d '"' || true)"
fi
if [ -z "${NEO4J_URI:-}" ]; then
  echo "[voice-author-key-audit] ERROR NEO4J_URI unset and not found in $BRAND_DIR/.env" >&2
  exit 2
fi

if ! command -v cypher-shell >/dev/null 2>&1; then
  echo "[voice-author-key-audit] ERROR cypher-shell not on PATH" >&2
  exit 2
fi

run_cypher() {
  if ! cypher-shell -a "$NEO4J_URI" -u "${NEO4J_USER:-neo4j}" -p "$NEO4J_PASSWORD" \
      --format plain "$1"; then
    echo "[voice-author-key-audit] ERROR cypher query failed against $NEO4J_URI" >&2
    exit 2
  fi
}

echo "[voice-author-key-audit] brand=$BRAND uri=$NEO4J_URI"

# 1. :VoiceProfile keys naming no :AdminUser on the profile's own account.
PROFILES="$(run_cypher "
MATCH (p:VoiceProfile)
WHERE p.userId <> '__org__'
  AND NOT EXISTS {
    MATCH (au:AdminUser {accountId: p.accountId, userId: p.userId})
  }
RETURN p.accountId AS accountId, p.userId AS userId, p.format AS format,
       coalesce(p.scope, 'personal') AS scope
ORDER BY accountId, userId, format;
")"

# 2. voiceAuthor stamps naming no :AdminUser on the node's own account,
#    grouped by (account, key) with the node count so the blast radius of each
#    invented key is visible.
STAMPS="$(run_cypher "
MATCH (n)
WHERE n.voiceAuthor IS NOT NULL
  AND n.voiceAuthor <> '__org__'
  AND NOT EXISTS {
    MATCH (au:AdminUser {accountId: n.accountId, userId: n.voiceAuthor})
  }
RETURN n.accountId AS accountId, n.voiceAuthor AS voiceAuthor, count(n) AS nodes
ORDER BY accountId, voiceAuthor;
")"

# cypher-shell --format plain emits a header row; data rows are everything after.
count_rows() {
  printf '%s\n' "$1" | tail -n +2 | grep -c . || true
}
profile_rows="$(count_rows "$PROFILES")"
stamp_rows="$(count_rows "$STAMPS")"

echo "[voice-author-key-audit] --- :VoiceProfile keys naming no :AdminUser ---"
if [ "$profile_rows" -eq 0 ]; then echo "(none)"; else printf '%s\n' "$PROFILES"; fi

echo "[voice-author-key-audit] --- voiceAuthor stamps naming no :AdminUser ---"
if [ "$stamp_rows" -eq 0 ]; then echo "(none)"; else printf '%s\n' "$STAMPS"; fi

echo "[voice-author-key-audit] brand=$BRAND unmatched_profiles=$profile_rows unmatched_stamp_keys=$stamp_rows"

if [ "$profile_rows" -gt 0 ] || [ "$stamp_rows" -gt 0 ]; then
  echo "[voice-author-key-audit] FAIL unmatched keys found — disposition (delete or re-key) is an operator decision; this script changes nothing" >&2
  exit 1
fi

echo "[voice-author-key-audit] OK no unmatched keys"
