#!/usr/bin/env bash
# ============================================================
# remediate-glsmith-identity.sh
#
# Purpose
# -------
# One-account identity-corner repair for G.L.Smith & Sons (accountId
# 2078cb54) on the SiteDesk Code install (Neo4j bolt://localhost:7689).
# Merges a fragmented owner/admin/person corner into one canonical named
# owner. Task 1569. Design + grounded live state:
#   maxy-code/.docs/migrations/2026-07-glsmith-identity-remediation.md
#
# What it does (each step idempotent; logs its post-condition):
#   rename-owner      3f81e7c2 "Owner" -> name "Dale Smith"
#   rehome-prefs      move 28 source:explicit prefs from stray profile
#                     (userId 436fdf2d, acct 2078cb54) to the owner profile,
#                     re-stamp pref.userId -> 3f81e7c2; delete the 4
#                     source:behavioural prefs; remove the emptied stray
#                     profile (clears its cross-account HAS_PROFILE edge)
#   merge-person      merge the second Dale Person into the owner's Dale
#                     Person (keep phone 447958391734); one named Dale left
#   fold-dale-seat    re-point 2f4a0a6b's inbound STARTED_BY to the owner,
#                     drop its OWNS, remove its empty profile, delete it
#   retire-joel-seat  delete c6a0e01a + its empty profile; drop the stray
#                     cross-account OWNS -> Person 324 (house Person 324 kept)
#   keith-timeline    create a Task RAISED_BY the owner Dale Person and
#                     AFFECTS Person Keith Bullot (759), from Event 1559
#   account-admins    account.json.admins -> [] (access is install-wide)
#   reembed-needed    report nodes needing a nomic-embed-text re-embed
#
# Write-gate invariant: the account never drops below one
# AdminUser{accountId} and always retains its role:'owner' node. Asserted
# before and after every destructive step.
#
# Usage
# -----
#   bash remediate-glsmith-identity.sh                 # dry-run (default)
#   bash remediate-glsmith-identity.sh --dry-run
#   bash remediate-glsmith-identity.sh --verify        # post-apply asserts
#   bash remediate-glsmith-identity.sh --apply --dump=/path/to/neo4j.dump
#   bash remediate-glsmith-identity.sh --apply --force-no-dump   # no dump (loud)
#
# --dry-run  Print every intended node/edge change; mutate nothing (default).
# --apply    Perform the writes. Refuses to run without a valid --dump path
#            (a fresh full neo4j-admin dump) unless --force-no-dump is given.
#            Writes a JSON snapshot of every touched node/rel before writing.
# --verify   Run the post-apply asserts; exit non-zero if any fail.
#
# Rollback
# --------
# Authoritative: restore the operator's --dump via `neo4j-admin database
# load`. Fast/targeted: the JSON snapshot at ~/glsmith-1569-snapshot-<ts>.{nodes,rels}
# records the touched nodes (elementId, labels, properties) and relationships
# (type, endpoints, properties) as they were before the writes.
#
# Reads:
#   ~/sitedesk-code/platform/config/.neo4j-password   Neo4j password
#   ~/sitedesk-code/data/accounts/2078cb54-.../account.json
# ============================================================

set -euo pipefail

# --- Fixed identity constants (account 2078cb54) --------------------
readonly ACCT='2078cb54-08e9-49e9-bf8e-e9f3ad76ca41'
readonly OWNER='3f81e7c2-8e38-42f0-bce2-7355cd7c1ecf'   # survivor, role:owner
readonly JOEL='c6a0e01a-ca12-4c6c-b8a9-0d5be937136b'    # operator seat, retire
readonly DALE='2f4a0a6b-f239-461b-ba90-0d380fc72f43'    # sub-account seat, fold
readonly HOUSEOP='436fdf2d-cfa7-4836-9ffa-77d91cef15f9' # house operator; stray profile userId
readonly DALE_PHONE='447958391734'

readonly PWFILE="$HOME/sitedesk-code/platform/config/.neo4j-password"
readonly ACCOUNT_JSON="$HOME/sitedesk-code/data/accounts/$ACCT/account.json"
readonly NEO4J_USER="${NEO4J_USER:-neo4j}"
readonly URI="${NEO4J_URI:-bolt://localhost:7689}"

MODE="dry-run"
DUMP_PATH=""
FORCE_NO_DUMP=0

while [ $# -gt 0 ]; do
  case "$1" in
    --dry-run) MODE="dry-run"; shift ;;
    --apply)   MODE="apply"; shift ;;
    --verify)  MODE="verify"; shift ;;
    --dump=*)  DUMP_PATH="${1#*=}"; shift ;;
    --dump)    DUMP_PATH="${2:-}"; shift 2 ;;
    --force-no-dump) FORCE_NO_DUMP=1; shift ;;
    -h|--help) sed -n '2,/^# =\{20,\}/p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
    *) echo "Unknown arg: $1" >&2; exit 2 ;;
  esac
done

log() { echo "[id-remediation] $*" >&2; }

# --- Connection -----------------------------------------------------
[ -f "$PWFILE" ] || { echo "Error: $PWFILE missing (run on the SiteDesk Code box)" >&2; exit 1; }
command -v cypher-shell >/dev/null 2>&1 || { echo "Error: cypher-shell not in PATH" >&2; exit 1; }
PW="$(cat "$PWFILE")"

# run_cypher: reads a cypher statement on stdin, prints --format plain output.
run_cypher() { cypher-shell -u "$NEO4J_USER" -p "$PW" -a "$URI" --format plain; }

# scalar: first data value (row 2), quotes stripped. Always exits 0 (empty result
# yields empty string) so a `set -e` assignment never aborts on no-rows.
scalar() { { tail -n +2 | tr -d '"' | grep -v '^[[:space:]]*$' | head -1; } || true; }
# int: last bare-integer token, defaulting 0.
as_int() { awk -F',' '{for(i=1;i<=NF;i++){gsub(/[^0-9-]/,"",$i); if($i ~ /^-?[0-9]+$/) v=$i}} END{print v+0}'; }

# --- Write-gate --------------------------------------------------------
# Echoes "admins=<n> owners=<n> business=<0|1>"; returns 0 iff n>=1 && owners>=1.
write_gate() {
  local out n owners biz
  out="$(run_cypher <<CYPHER
MATCH (a:AdminUser {accountId:'$ACCT'})
WITH count(a) AS n, sum(CASE WHEN a.role='owner' THEN 1 ELSE 0 END) AS owners
OPTIONAL MATCH (b:LocalBusiness {accountId:'$ACCT'})
RETURN n, owners, CASE WHEN count(b) > 0 THEN 1 ELSE 0 END AS business;
CYPHER
)"
  n="$(echo "$out"   | tail -n +2 | awk -F',' '{print $1+0}')"
  owners="$(echo "$out" | tail -n +2 | awk -F',' '{print $2+0}')"
  biz="$(echo "$out" | tail -n +2 | awk -F',' '{print $3+0}')"
  echo "admins=$n owners=$owners business=$biz"
  [ "$n" -ge 1 ] && [ "$owners" -ge 1 ]
}

assert_write_gate() {
  local g; g="$(write_gate)" || { log "ABORT write-gate breached: $g"; exit 3; }
  case "$g" in *business=1*) : ;; *) log "ABORT write-gate: no LocalBusiness ($g)"; exit 3 ;; esac
  log "write-gate ok $g"
}

# --- Preconditions -----------------------------------------------------
preconditions() {
  run_cypher <<< "RETURN 1;" >/dev/null 2>&1 || { echo "Error: cannot connect to $URI" >&2; exit 1; }
  local ownerName
  ownerName="$(run_cypher <<CYPHER | scalar
MATCH (a:AdminUser {userId:'$OWNER', accountId:'$ACCT', role:'owner'}) RETURN a.name;
CYPHER
)"
  [ -n "$ownerName" ] || { log "ABORT precondition: survivor owner $OWNER (role:owner) not found on $ACCT"; exit 3; }
  assert_write_gate
  log "preconditions ok survivorOwner=$OWNER name=\"$ownerName\""
}

# ======================================================================
# Steps. Each runs a read-only DETECT (always, prints WOULD-change) and,
# when MODE=apply, the mutation. Idempotent: DETECT reports 0 -> no-op.
# ======================================================================

step_rename_owner() {
  local pending
  pending="$(run_cypher <<CYPHER | as_int
MATCH (a:AdminUser {userId:'$OWNER', accountId:'$ACCT'}) WHERE a.name <> 'Dale Smith' RETURN count(a);
CYPHER
)"
  if [ "$pending" -eq 0 ]; then log "step=rename-owner no-op (already 'Dale Smith')"; return; fi
  if [ "$MODE" = "dry-run" ]; then log "step=rename-owner WOULD set AdminUser $OWNER name -> 'Dale Smith'"; return; fi
  run_cypher <<CYPHER >/dev/null
MATCH (a:AdminUser {userId:'$OWNER', accountId:'$ACCT'}) SET a.name='Dale Smith', a.updatedAt=timestamp();
CYPHER
  log "step=rename-owner name=\"Dale Smith\""
}

step_rehome_prefs() {
  local explicit behavioural
  explicit="$(run_cypher <<CYPHER | as_int
MATCH (:UserProfile {userId:'$HOUSEOP', accountId:'$ACCT'})-[:HAS_PREFERENCE]->(p:Preference) WHERE p.source='explicit' RETURN count(p);
CYPHER
)"
  behavioural="$(run_cypher <<CYPHER | as_int
MATCH (:UserProfile {userId:'$HOUSEOP', accountId:'$ACCT'})-[:HAS_PREFERENCE]->(p:Preference) WHERE p.source='behavioural' RETURN count(p);
CYPHER
)"
  if [ "$explicit" -eq 0 ] && [ "$behavioural" -eq 0 ]; then
    local strayGone
    strayGone="$(run_cypher <<CYPHER | as_int
MATCH (u:UserProfile {userId:'$HOUSEOP', accountId:'$ACCT'}) RETURN count(u);
CYPHER
)"
    [ "$strayGone" -eq 0 ] && { log "step=rehome-prefs no-op (stray profile already removed)"; return; }
  fi
  if [ "$MODE" = "dry-run" ]; then
    local ownerNow
    ownerNow="$(run_cypher <<CYPHER | as_int
MATCH (:AdminUser {userId:'$OWNER', accountId:'$ACCT'})-[:HAS_PROFILE]->(op:UserProfile)-[:HAS_PREFERENCE]->(p) RETURN count(p);
CYPHER
)"
    log "step=rehome-prefs WOULD move explicit=$explicit -> owner profile (now has $ownerNow); delete behavioural=$behavioural; remove stray profile"
    return
  fi
  # Move explicit prefs to the owner profile, re-stamp userId.
  local moved
  moved="$(run_cypher <<CYPHER | as_int
MATCH (stray:UserProfile {userId:'$HOUSEOP', accountId:'$ACCT'})-[hp:HAS_PREFERENCE]->(p:Preference) WHERE p.source='explicit'
MATCH (:AdminUser {userId:'$OWNER', accountId:'$ACCT'})-[:HAS_PROFILE]->(op:UserProfile)
MERGE (op)-[:HAS_PREFERENCE]->(p)
DELETE hp
SET p.userId='$OWNER', p.updatedAt=timestamp()
RETURN count(p);
CYPHER
)"
  # Delete behavioural prefs.
  local dropped
  dropped="$(run_cypher <<CYPHER | as_int
MATCH (:UserProfile {userId:'$HOUSEOP', accountId:'$ACCT'})-[:HAS_PREFERENCE]->(p:Preference) WHERE p.source='behavioural'
DETACH DELETE p RETURN count(p);
CYPHER
)"
  # Remove the now-empty stray profile (clears cross-account HAS_PROFILE).
  local strayRemoved
  strayRemoved="$(run_cypher <<CYPHER | as_int
MATCH (stray:UserProfile {userId:'$HOUSEOP', accountId:'$ACCT'}) WHERE NOT (stray)-[:HAS_PREFERENCE]->()
DETACH DELETE stray RETURN count(*);
CYPHER
)"
  local ownerPrefs
  ownerPrefs="$(run_cypher <<CYPHER | as_int
MATCH (:AdminUser {userId:'$OWNER', accountId:'$ACCT'})-[:HAS_PROFILE]->(op:UserProfile)-[:HAS_PREFERENCE]->(p) RETURN count(p);
CYPHER
)"
  log "step=rehome-prefs moved=$moved droppedBehavioural=$dropped strayRemoved=$strayRemoved ownerProfilePrefs=$ownerPrefs"
}

step_merge_person() {
  # Survivor = the Dale Person carrying the phone + WhatsApp SENT history (496).
  # Loser    = the bare owner-linked Dale stub (338), no phone, no outbound.
  # We re-point the loser's inbound OWNS and RAISED_BY onto the survivor, then
  # delete the stub. This preserves the survivor's hundreds of SENT->Message
  # edges intact (never delete the message-history node).
  local dalePersons stubCount
  dalePersons="$(run_cypher <<CYPHER | as_int
MATCH (p:Person {accountId:'$ACCT'}) WHERE p.givenName='Dale' AND p.familyName='Smith' RETURN count(p);
CYPHER
)"
  if [ "$dalePersons" -le 1 ]; then log "step=merge-person no-op (one Dale Person already)"; return; fi
  # A loser is a BARE Dale stub only: not the phone-holder, no phone of its own,
  # and no SENT history. A real Dale contact with its own phone or messages can
  # never be the loser, so a name collision is left untouched. The grounded
  # premise is exactly one such stub (338); abort on any other count.
  stubCount="$(run_cypher <<CYPHER | as_int
MATCH (surv:Person {accountId:'$ACCT'}) WHERE surv.givenName='Dale' AND surv.familyName='Smith' AND surv.telephone='$DALE_PHONE'
MATCH (loser:Person {accountId:'$ACCT'}) WHERE loser.givenName='Dale' AND loser.familyName='Smith' AND elementId(loser) <> elementId(surv) AND loser.telephone IS NULL AND NOT (loser)-[:SENT]->(:Message)
RETURN count(loser);
CYPHER
)"
  if [ "$stubCount" -ne 1 ]; then
    log "step=merge-person ABORT expected exactly one bare Dale stub to merge, found $stubCount (dalePersons=$dalePersons); state drift, inspect before applying"
    exit 3
  fi
  if [ "$MODE" = "dry-run" ]; then
    log "step=merge-person WOULD keep the Dale Person with phone $DALE_PHONE + message history (survivor), re-point the bare stub's OWNS + RAISED_BY onto it, then delete the stub"
    return
  fi
  local movedOwns movedRaised
  movedOwns="$(run_cypher <<CYPHER | as_int
MATCH (surv:Person {accountId:'$ACCT'}) WHERE surv.givenName='Dale' AND surv.familyName='Smith' AND surv.telephone='$DALE_PHONE'
MATCH (loser:Person {accountId:'$ACCT'}) WHERE loser.givenName='Dale' AND loser.familyName='Smith' AND elementId(loser) <> elementId(surv) AND loser.telephone IS NULL AND NOT (loser)-[:SENT]->(:Message)
MATCH (a)-[o:OWNS]->(loser)
MERGE (a)-[:OWNS]->(surv)
DELETE o
RETURN count(*);
CYPHER
)"
  movedRaised="$(run_cypher <<CYPHER | as_int
MATCH (surv:Person {accountId:'$ACCT'}) WHERE surv.givenName='Dale' AND surv.familyName='Smith' AND surv.telephone='$DALE_PHONE'
MATCH (loser:Person {accountId:'$ACCT'}) WHERE loser.givenName='Dale' AND loser.familyName='Smith' AND elementId(loser) <> elementId(surv) AND loser.telephone IS NULL AND NOT (loser)-[:SENT]->(:Message)
MATCH (t)-[r:RAISED_BY]->(loser)
MERGE (t)-[:RAISED_BY]->(surv)
DELETE r
RETURN count(*);
CYPHER
)"
  # Delete the now-detached bare stub (same narrow predicate: never the survivor,
  # never a Dale with its own phone or message history).
  local deleted
  deleted="$(run_cypher <<CYPHER | as_int
MATCH (surv:Person {accountId:'$ACCT'}) WHERE surv.givenName='Dale' AND surv.familyName='Smith' AND surv.telephone='$DALE_PHONE'
MATCH (loser:Person {accountId:'$ACCT'}) WHERE loser.givenName='Dale' AND loser.familyName='Smith' AND elementId(loser) <> elementId(surv) AND loser.telephone IS NULL AND NOT (loser)-[:SENT]->(:Message)
DETACH DELETE loser
RETURN count(*);
CYPHER
)"
  local sent ownerOwns
  sent="$(run_cypher <<CYPHER | as_int
MATCH (:AdminUser {userId:'$OWNER', accountId:'$ACCT'})-[:OWNS]->(p:Person)-[:SENT]->(:Message) RETURN count(*);
CYPHER
)"
  ownerOwns="$(run_cypher <<CYPHER | as_int
MATCH (:AdminUser {userId:'$OWNER', accountId:'$ACCT'})-[:OWNS]->(p:Person) RETURN count(p);
CYPHER
)"
  log "step=merge-person survivor=phone+history stubsDeleted=$deleted ownsMoved=$movedOwns raisedByMoved=$movedRaised ownerOwnsPersons=$ownerOwns survivorSentEdges=$sent"
}

step_fold_dale_seat() {
  local present
  present="$(run_cypher <<CYPHER | as_int
MATCH (a:AdminUser {userId:'$DALE', accountId:'$ACCT'}) RETURN count(a);
CYPHER
)"
  if [ "$present" -eq 0 ]; then log "step=fold-dale-seat no-op (seat already folded)"; return; fi
  local startedBy
  startedBy="$(run_cypher <<CYPHER | as_int
MATCH (x)-[:STARTED_BY]->(a:AdminUser {userId:'$DALE', accountId:'$ACCT'}) RETURN count(x);
CYPHER
)"
  if [ "$MODE" = "dry-run" ]; then log "step=fold-dale-seat WOULD re-point startedBy=$startedBy to owner, remove empty profile, delete seat $DALE"; return; fi
  # Re-point inbound STARTED_BY sessions to the owner.
  local moved
  moved="$(run_cypher <<CYPHER | as_int
MATCH (x)-[r:STARTED_BY]->(dale:AdminUser {userId:'$DALE', accountId:'$ACCT'})
MATCH (owner:AdminUser {userId:'$OWNER', accountId:'$ACCT'})
MERGE (x)-[:STARTED_BY]->(owner)
DELETE r
RETURN count(x);
CYPHER
)"
  # Remove the empty profile (guarded: only if it holds no preferences), then the
  # seat (DETACH DELETE drops its OWNS edges).
  run_cypher <<CYPHER >/dev/null
MATCH (dale:AdminUser {userId:'$DALE', accountId:'$ACCT'})-[:HAS_PROFILE]->(dp:UserProfile)
WHERE NOT (dp)-[:HAS_PREFERENCE]->()
DETACH DELETE dp;
CYPHER
  run_cypher <<CYPHER >/dev/null
MATCH (dale:AdminUser {userId:'$DALE', accountId:'$ACCT'}) DETACH DELETE dale;
CYPHER
  assert_write_gate
  local surviving; surviving="$(write_gate | sed 's/.*admins=\([0-9]*\).*/\1/')"
  log "step=fold-dale-seat startedByMoved=$moved seatRemoved=1 survivingAdminUsers=$surviving"
}

step_retire_joel_seat() {
  local present
  present="$(run_cypher <<CYPHER | as_int
MATCH (a:AdminUser {userId:'$JOEL', accountId:'$ACCT'}) RETURN count(a);
CYPHER
)"
  if [ "$present" -eq 0 ]; then log "step=retire-joel-seat no-op (seat already retired)"; return; fi
  if [ "$MODE" = "dry-run" ]; then log "step=retire-joel-seat WOULD delete seat $JOEL + empty profile, drop stray cross-account OWNS -> house Person 324 (324 kept)"; return; fi
  run_cypher <<CYPHER >/dev/null
MATCH (joel:AdminUser {userId:'$JOEL', accountId:'$ACCT'})-[:HAS_PROFILE]->(jp:UserProfile)
WHERE NOT (jp)-[:HAS_PREFERENCE]->()
DETACH DELETE jp;
CYPHER
  run_cypher <<CYPHER >/dev/null
MATCH (joel:AdminUser {userId:'$JOEL', accountId:'$ACCT'}) DETACH DELETE joel;
CYPHER
  assert_write_gate
  local surviving; surviving="$(write_gate | sed 's/.*admins=\([0-9]*\).*/\1/')"
  log "step=retire-joel-seat seatRemoved=1 housePersonRetained=324 survivingAdminUsers=$surviving"
}

step_keith_timeline() {
  local exists
  exists="$(run_cypher <<CYPHER | as_int
MATCH (t:Task {remediationKey:'1569-keith-bathroom-tap', accountId:'$ACCT'}) RETURN count(t);
CYPHER
)"
  if [ "$exists" -gt 0 ]; then log "step=keith-timeline no-op (task already present)"; return; fi
  if [ "$MODE" = "dry-run" ]; then log "step=keith-timeline WOULD create Task RAISED_BY owner Dale Person, AFFECTS Person Keith Bullot (759)"; return; fi
  local taskId
  taskId="$(run_cypher <<CYPHER | scalar
MATCH (owner:AdminUser {userId:'$OWNER', accountId:'$ACCT'})-[:OWNS]->(dale:Person)
MATCH (keith:Person {accountId:'$ACCT'}) WHERE keith.givenName='Keith' AND keith.familyName='Bullot'
MERGE (t:Task {remediationKey:'1569-keith-bathroom-tap', accountId:'$ACCT'})
ON CREATE SET t.taskId=randomUUID(), t.name='Text Keith Bullot about the bathroom tap',
  t.description='Contact Keith Bullot regarding the bathroom tap.', t.status='pending', t.priority='normal',
  t.createdAt=timestamp(), t.updatedAt=timestamp(),
  t.createdByTool='remediate-glsmith-identity', t.createdBySession='task-1569', t.createdByAgent='id-remediation'
MERGE (t)-[:AFFECTS]->(keith)
MERGE (t)-[:RAISED_BY]->(dale)
RETURN t.taskId;
CYPHER
)"
  log "step=keith-timeline taskCreated=1 taskId=$taskId raisedBy=owner-dale-person affects=keith-759"
}

step_account_admins() {
  [ -f "$ACCOUNT_JSON" ] || { log "step=account-admins SKIP (account.json not found at $ACCOUNT_JSON)"; return; }
  local already
  already="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print("yes" if d.get("admins")==[] else "no")' "$ACCOUNT_JSON")"
  if [ "$already" = "yes" ]; then log "step=account-admins no-op (already [])"; return; fi
  if [ "$MODE" = "dry-run" ]; then
    local cur; cur="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print([a.get("userId","")[:8] for a in d.get("admins",[])])' "$ACCOUNT_JSON")"
    log "step=account-admins WOULD set admins [] (currently $cur)"; return
  fi
  local ts; ts="$(date +%Y%m%d-%H%M%S)"
  cp "$ACCOUNT_JSON" "$ACCOUNT_JSON.bak-$ts"
  python3 -c 'import json,sys
p=sys.argv[1]; d=json.load(open(p)); d["admins"]=[]
json.dump(d, open(p,"w"), indent=2)' "$ACCOUNT_JSON"
  log "step=account-admins admins=[] backup=$ACCOUNT_JSON.bak-$ts"
}

step_reembed_needed() {
  local keithTask
  keithTask="$(run_cypher <<CYPHER | scalar
MATCH (t:Task {remediationKey:'1569-keith-bathroom-tap', accountId:'$ACCT'}) RETURN t.taskId;
CYPHER
)"
  log "step=reembed-needed nodes=\"AdminUser:$OWNER(name-changed),Task:${keithTask:-<pending>}(new)\" note=raw-cypher-writes-carry-no-embedding"
}

# --- Rollback guard + snapshot (apply only) -------------------------
rollback_guard() {
  if [ -n "$DUMP_PATH" ]; then
    [ -s "$DUMP_PATH" ] || { echo "Error: --dump path '$DUMP_PATH' is missing or empty" >&2; exit 1; }
    log "rollback dump provided path=$DUMP_PATH bytes=$(wc -c < "$DUMP_PATH")"
  elif [ "$FORCE_NO_DUMP" -eq 1 ]; then
    log "WARNING proceeding WITHOUT a neo4j dump (--force-no-dump); JSON snapshot is the only rollback reference"
  else
    echo "Error: --apply needs --dump=/path/to/neo4j.dump (fresh full dump) or --force-no-dump" >&2
    echo "  Take a dump on the box, e.g.:" >&2
    echo "    systemctl stop neo4j-sitedesk-code.service && \\" >&2
    echo "    neo4j-admin database dump neo4j --to-path=\$HOME/glsmith-1569-dump && \\" >&2
    echo "    systemctl start neo4j-sitedesk-code.service" >&2
    exit 1
  fi
}

write_snapshot() {
  local ts snap
  ts="$(date +%Y%m%d-%H%M%S)"; snap="$HOME/glsmith-1569-snapshot-$ts.json"
  # Nodes: the whole sub-account plus the house-side nodes this script touches
  # (house Person 324 via Joel's phone, house Dale Person 194 via Dale's phone,
  # house operator 436fdf2d whose cross-account HAS_PROFILE is cleared).
  run_cypher <<CYPHER > "$snap.nodes"
MATCH (n) WHERE n.accountId='$ACCT'
   OR (n:Person AND n.telephone IN ['$DALE_PHONE','447504472444'])
   OR (n:AdminUser AND n.userId IN ['$OWNER','$JOEL','$DALE','$HOUSEOP'])
RETURN elementId(n) AS eid, labels(n) AS labels, properties(n) AS props;
CYPHER
  # Rels: every relationship anchored on a touched node, with its properties, so
  # a re-pointed or dropped edge can be recreated exactly.
  run_cypher <<CYPHER > "$snap.rels"
MATCH (n)-[r]-(m) WHERE n.accountId='$ACCT'
   OR (n:AdminUser AND n.userId IN ['$OWNER','$JOEL','$DALE','$HOUSEOP'])
   OR (n:Person AND n.telephone IN ['$DALE_PHONE','447504472444'])
RETURN DISTINCT type(r) AS type, elementId(startNode(r)) AS start, elementId(endNode(r)) AS end, properties(r) AS props;
CYPHER
  log "snapshot nodes+rels written prefix=$snap"
}

# --- Verify ----------------------------------------------------------
verify() {
  local fail=0
  check() { # desc, actual, expected
    if [ "$2" = "$3" ]; then log "verify OK  $1 ($2)"; else log "verify FAIL $1 (got '$2' want '$3')"; fail=1; fi
  }
  local ownerCount ownerName ownerOwns ownerPrefs strayCount dalePersons joelCount daleCount gate
  ownerCount="$(run_cypher <<< "MATCH (a:AdminUser {accountId:'$ACCT', role:'owner'}) RETURN count(a);" | as_int)"
  ownerName="$(run_cypher <<< "MATCH (a:AdminUser {accountId:'$ACCT', role:'owner'}) RETURN a.name;" | scalar)"
  ownerOwns="$(run_cypher <<< "MATCH (a:AdminUser {accountId:'$ACCT', role:'owner'})-[:OWNS]->(p:Person) RETURN count(p);" | as_int)"
  ownerPrefs="$(run_cypher <<< "MATCH (:AdminUser {userId:'$OWNER', accountId:'$ACCT'})-[:HAS_PROFILE]->(op)-[:HAS_PREFERENCE]->(p) RETURN count(p);" | as_int)"
  strayCount="$(run_cypher <<< "MATCH (u:UserProfile {userId:'$HOUSEOP', accountId:'$ACCT'}) RETURN count(u);" | as_int)"
  dalePersons="$(run_cypher <<< "MATCH (p:Person {accountId:'$ACCT'}) WHERE p.givenName='Dale' AND p.familyName='Smith' RETURN count(p);" | as_int)"
  joelCount="$(run_cypher <<< "MATCH (a:AdminUser {userId:'$JOEL', accountId:'$ACCT'}) RETURN count(a);" | as_int)"
  daleCount="$(run_cypher <<< "MATCH (a:AdminUser {userId:'$DALE', accountId:'$ACCT'}) RETURN count(a);" | as_int)"
  gate="$(write_gate || true)"
  check "one role:owner AdminUser" "$ownerCount" "1"
  check "owner named Dale Smith" "$ownerName" "Dale Smith"
  check "owner owns exactly one Person" "$ownerOwns" "1"
  check "owner profile pref count" "$ownerPrefs" "28"
  check "stray profile removed" "$strayCount" "0"
  check "one Dale Person on account" "$dalePersons" "1"
  check "Joel seat absent" "$joelCount" "0"
  check "Dale seat absent" "$daleCount" "0"
  case "$gate" in *"owners=1 business=1"*) log "verify OK  write-gate ($gate)";; *) log "verify FAIL write-gate ($gate)"; fail=1;; esac
  [ "$fail" -eq 0 ] && { log "verify PASS"; return 0; } || { log "verify FAILED"; return 1; }
}

# --- Main ------------------------------------------------------------
log "start mode=$MODE account=$ACCT uri=$URI"

if [ "$MODE" = "verify" ]; then verify; exit $?; fi

preconditions

if [ "$MODE" = "apply" ]; then
  rollback_guard
  write_snapshot
fi

step_rename_owner
step_rehome_prefs
step_merge_person
step_fold_dale_seat
step_retire_joel_seat
step_keith_timeline
step_account_admins
step_reembed_needed

if [ "$MODE" = "apply" ]; then
  log "apply complete — running verify"
  verify || { log "POST-APPLY VERIFY FAILED — inspect before trusting"; exit 4; }
else
  log "dry-run complete — no writes performed. Diff the WOULD lines against the target end-state, then re-run with --apply --dump=<path>."
fi
