#!/usr/bin/env bash
# ============================================================
# seed-neo4j.sh — Neo4j schema + root-node seeding
# Applies the schema (constraints, indexes, vector indexes) and
# MERGEs the per-account root :LocalBusiness and owner :AdminUser
# nodes. Account directory creation, Claude Code settings, hooks
# wiring, and specialist templates live in `setup-account.sh`.
# This script does no filesystem setup beyond reading what
# setup-account.sh wrote.
# ============================================================

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
NEO4J_DIR="$PROJECT_DIR/neo4j"
# Accounts live at {installDir}/data/accounts/ — outside the platform/ wipe
# zone. resolve_and_sweep_account_dir reads ACCOUNTS_DIR from env.
INSTALL_DIR="$(dirname "$PROJECT_DIR")"
ACCOUNTS_DIR="$INSTALL_DIR/data/accounts"

# NEO4J_URI is hard-required. The previous default
# `bolt://localhost:7687` would silently route the seed to the wrong Neo4j on
# any brand-dedicated install — masking every other defect downstream.
if [ -z "${NEO4J_URI:-}" ]; then
  echo "Error: NEO4J_URI required (no default)" >&2
  exit 1
fi
NEO4J_USER="${NEO4J_USER:-neo4j}"

# Read password from the file created during setup
NEO4J_PASSWORD_FILE="$PROJECT_DIR/config/.neo4j-password"
if [ -n "${NEO4J_PASSWORD:-}" ]; then
  : # Explicit env var takes precedence
elif [ -f "$NEO4J_PASSWORD_FILE" ]; then
  NEO4J_PASSWORD=$(cat "$NEO4J_PASSWORD_FILE")
else
  echo "Error: Neo4j password not found."
  echo "  Expected at: $NEO4J_PASSWORD_FILE"
  echo "  Or set NEO4J_PASSWORD environment variable."
  exit 1
fi

CYPHER_SHELL="cypher-shell"

# ------------------------------------------------------------------
# 1. Resolve the account dir (must already exist; setup-account.sh
#    is the creator). The resolver is idempotent — calling it here
#    re-finds the same ACCOUNT_DIR / ACCOUNT_ID setup-account.sh
#    minted.
# ------------------------------------------------------------------
# shellcheck source=lib/resolve-account-dir.sh
. "$SCRIPT_DIR/lib/resolve-account-dir.sh"
# shellcheck source=lib/read-brand-json.sh
. "$SCRIPT_DIR/lib/read-brand-json.sh"
_RESOLVER_CONFIG_DIR_NAME=".maxy"
read_brand_json_key "$PROJECT_DIR/config/brand.json" "configDir"
[ -n "$BRAND_JSON_VALUE" ] && _RESOLVER_CONFIG_DIR_NAME="$BRAND_JSON_VALUE"
USERS_FILE="$HOME/$_RESOLVER_CONFIG_DIR_NAME/users.json" resolve_and_sweep_account_dir

# ------------------------------------------------------------------
# 2. Apply Neo4j schema (constraints + indexes only)
# ------------------------------------------------------------------
if ! command -v "$CYPHER_SHELL" &> /dev/null; then
    echo "Error: cypher-shell not found. Install Neo4j or add cypher-shell to PATH."
    exit 1
fi

echo "==> Connecting to Neo4j at $NEO4J_URI as $NEO4J_USER"

# Vector index dimensions — configurable at install time via --embed-model.
# The installer sets EMBED_DIMENSIONS in the environment; default 768 (nomic-embed-text).
EMBED_DIMENSIONS="${EMBED_DIMENSIONS:-768}"
echo "==> Applying schema (constraints, indexes, vector indexes — ${EMBED_DIMENSIONS} dims)..."

SCHEMA_CYPHER=$(sed "s/\`vector\.dimensions\`: 768/\`vector.dimensions\`: ${EMBED_DIMENSIONS}/g" "$NEO4J_DIR/schema.cypher")

# Verify the substitution produced the expected dimension value
if [ "$EMBED_DIMENSIONS" != "768" ] && ! echo "$SCHEMA_CYPHER" | grep -q "\`vector.dimensions\`: ${EMBED_DIMENSIONS}"; then
  echo "WARNING: Expected dimension ${EMBED_DIMENSIONS} not found in schema output — indexes may use default 768"
fi

echo "$SCHEMA_CYPHER" | "$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI"

# Task 1304 — confirm the Person composite migration actually landed.
# CREATE CONSTRAINT ... IF NOT EXISTS is a no-op against a same-named
# constraint that predates the composite form, so a clean exit above does
# not by itself prove the migration applied — SHOW CONSTRAINTS is the only
# ground truth.
PERSON_CONSTRAINT_CHECK=$("$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" \
  --non-interactive --format plain \
  "SHOW CONSTRAINTS YIELD name, properties WHERE name IN ['person_email_unique', 'person_telephone_unique'] RETURN name, size(properties) AS propCount;")
if echo "$PERSON_CONSTRAINT_CHECK" | grep -q '"person_email_unique", 2' \
   && echo "$PERSON_CONSTRAINT_CHECK" | grep -q '"person_telephone_unique", 2'; then
  echo "[schema-migrate] op=person-constraint-composite before=global after=composite status=ok"
else
  echo "[schema-migrate] op=person-constraint-composite before=global after=composite status=failed" >&2
  echo "$PERSON_CONSTRAINT_CHECK" >&2
  exit 1
fi

# ------------------------------------------------------------------
# 3. Bootstrap root :LocalBusiness for the account
# ------------------------------------------------------------------
# Every node in the graph hierarchy (Project, Task, Person, Organisation,
# KnowledgeDocument) anchors to a :LocalBusiness via accountId. Without a
# root LocalBusiness, the recorder's "if no parent, do not write" rule
# fires correctly and the first write attempt is dropped. Stamp one
# LocalBusiness per account at seed time so the hierarchy has a root from
# install onward. Idempotent via MERGE on the (accountId) unique key.
#
# businessType is the bare slug (e.g. "estate-agent"). resolve-active-vertical.ts
# prepends "schema-" when reading back, so the stored value must NOT carry
# the prefix. brand.json#vertical holds the file basename ("schema-estate-agent")
# and is stripped here.
#
# ON MATCH coalesces businessType: an operator override on the LocalBusiness
# wins over the brand default; only NULL-businessType nodes get the brand
# default filled in on re-seed. createdAt is set ON CREATE only, so re-runs
# preserve the original timestamp.

BRAND_JSON="$PROJECT_DIR/config/brand.json"
read_brand_json_key "$BRAND_JSON" "vertical"
LOCALBUSINESS_BUSINESS_TYPE=$(derive_business_type "$BRAND_JSON_VALUE")
LOCALBUSINESS_BT_CYPHER=$(business_type_cypher "$LOCALBUSINESS_BUSINESS_TYPE")
LOCALBUSINESS_BT_LOG="${LOCALBUSINESS_BUSINESS_TYPE:-none}"

# The house LocalBusiness carries the brand product name (e.g. "SiteDesk") so
# the sub-account picker and header render a name, not the raw accountId UUID.
# Coalesced ON MATCH below so a name set later by the business-profile recorder
# is never clobbered, and a re-seed backfills a name-less existing node.
read_brand_json_key "$BRAND_JSON" "productName"
LOCALBUSINESS_PRODUCT_NAME="$BRAND_JSON_VALUE"
LOCALBUSINESS_NAME_CYPHER=$(cypher_string_literal "$LOCALBUSINESS_PRODUCT_NAME")

# Pre-count distinguishes created vs already-exists for the log line.
# Simpler than an in-cypher transient flag; the extra round-trip is
# sub-millisecond on a local Neo4j.
if ! LOCALBUSINESS_EXISTING_RAW=$("$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" \
    --format plain \
    "MATCH (lb:LocalBusiness {accountId: '$ACCOUNT_ID'}) RETURN count(lb)" 2>&1); then
  LOCALBUSINESS_REASON=$(echo "$LOCALBUSINESS_EXISTING_RAW" | tr '\n' '|' | head -c 500)
  echo "  [install-invariant] localbusiness-bootstrap accountId=${ACCOUNT_ID:0:8} result=failed reason=$LOCALBUSINESS_REASON" >&2
  exit 1
fi
LOCALBUSINESS_EXISTING=$(echo "$LOCALBUSINESS_EXISTING_RAW" | tail -1 | tr -d ' \r')

# Pre-read the current name so the log line reports set|preserved. A name-less
# or absent node is backfilled to the product name; an existing non-null name
# is preserved by the coalesce below. --format plain wraps a string value in
# double quotes, so strip them alongside whitespace.
if ! LOCALBUSINESS_PRENAME_RAW=$("$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" \
    --format plain \
    "MATCH (lb:LocalBusiness {accountId: '$ACCOUNT_ID'}) RETURN coalesce(lb.name, '')" 2>&1); then
  LOCALBUSINESS_REASON=$(echo "$LOCALBUSINESS_PRENAME_RAW" | tr '\n' '|' | head -c 500)
  echo "  [install-invariant] localbusiness-bootstrap accountId=${ACCOUNT_ID:0:8} result=failed reason=$LOCALBUSINESS_REASON" >&2
  exit 1
fi
LOCALBUSINESS_PRENAME=$(echo "$LOCALBUSINESS_PRENAME_RAW" | tail -1 | tr -d ' \r"')

# $ACCOUNT_ID is a UUID minted by the resolver; $LOCALBUSINESS_BT_CYPHER is
# either a single-quoted slug from a closed enum (brands/*/brand.json) or the
# bare keyword null; $LOCALBUSINESS_NAME_CYPHER is a Cypher string literal from
# brand.json#productName, single-quote-escaped by cypher_string_literal, or the
# bare keyword null. No operator input is interpolated.
if ! LOCALBUSINESS_OUT=$("$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" 2>&1 << CYPHER_EOF
MERGE (lb:LocalBusiness {accountId: '$ACCOUNT_ID'})
  ON CREATE SET lb.createdBySource = 'installer',
                lb.createdAt = datetime(),
                lb.businessType = $LOCALBUSINESS_BT_CYPHER,
                lb.name = $LOCALBUSINESS_NAME_CYPHER
  ON MATCH SET lb.businessType = coalesce(lb.businessType, $LOCALBUSINESS_BT_CYPHER),
               lb.name = coalesce(lb.name, $LOCALBUSINESS_NAME_CYPHER)
RETURN lb.accountId;
CYPHER_EOF
); then
  LOCALBUSINESS_REASON=$(echo "$LOCALBUSINESS_OUT" | tr '\n' '|' | head -c 500)
  echo "  [install-invariant] localbusiness-bootstrap accountId=${ACCOUNT_ID:0:8} businessType=$LOCALBUSINESS_BT_LOG result=failed reason=$LOCALBUSINESS_REASON" >&2
  exit 1
fi

if [ "$LOCALBUSINESS_EXISTING" = "0" ]; then
  LOCALBUSINESS_RESULT="created"
else
  LOCALBUSINESS_RESULT="already-exists"
fi

# name verdict: absent brand productName sets nothing; a fresh node is created
# with the name; a pre-existing non-null name is preserved by the coalesce;
# a pre-existing name-less node is backfilled. Gate on the existence pre-count
# first: for a node that did not exist, the pre-name MATCH returned zero rows
# (so $LOCALBUSINESS_PRENAME holds the header line, not a value) — that garbage
# is never read here. Greppable so a future name-less seed surfaces as a signal.
if [ -z "$LOCALBUSINESS_PRODUCT_NAME" ]; then
  LOCALBUSINESS_NAME_LOG="absent-no-productName"
elif [ "$LOCALBUSINESS_EXISTING" = "0" ]; then
  LOCALBUSINESS_NAME_LOG="set"
elif [ -n "$LOCALBUSINESS_PRENAME" ]; then
  LOCALBUSINESS_NAME_LOG="preserved"
else
  LOCALBUSINESS_NAME_LOG="set"
fi
echo "  [install-invariant] localbusiness-bootstrap accountId=${ACCOUNT_ID:0:8} businessType=$LOCALBUSINESS_BT_LOG result=$LOCALBUSINESS_RESULT name=$LOCALBUSINESS_NAME_LOG"

# ------------------------------------------------------------------
# 4. Create AdminUser node + ADMIN_OF relationship
# ------------------------------------------------------------------
# Resolve owner userId from users.json (set-pin POST creates it).
# Same brand-aware resolution as setup-account.sh.
CONFIG_DIR="$PROJECT_DIR/config"
_CONFIG_DIR_NAME=".maxy"
read_brand_json_key "$CONFIG_DIR/brand.json" "configDir"
[ -n "$BRAND_JSON_VALUE" ] && _CONFIG_DIR_NAME="$BRAND_JSON_VALUE"
USERS_FILE="$HOME/$_CONFIG_DIR_NAME/users.json"

USER_ID=""
if [ -f "$USERS_FILE" ]; then
  USER_ID=$(python3 -c "import json; print(json.load(open('$USERS_FILE'))[0]['userId'])" 2>/dev/null || true)
fi

if [ -n "$USER_ID" ]; then
  # Escape single quotes for Cypher string interpolation (e.g. O'Brien → O''Brien).
  USER_NAME=$(python3 -c "import json; n=json.load(open('$USERS_FILE'))[0]['name']; print(n.replace(\"'\",\"''\"))" 2>/dev/null || true)
  # Trim surrounding whitespace so a padded value is neither persisted verbatim
  # nor able to slip a padded " Owner " past the placeholder check below — the
  # same trim the shared isPlaceholderOwnerName predicate applies.
  USER_NAME=$(printf '%s' "$USER_NAME" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
  # Task 1570 — the AdminUser carries no name; the owner name lives only on the
  # OWNS-Person that set-pin's writeAdminUserAndPerson already wrote (1567 made a
  # real name mandatory there). This block only stamps accountId/role and the
  # ADMIN_OF edge on that existing AdminUser. A blank or "Owner" USER_NAME means
  # the install cannot supply a real owner identity, so SKIP the stamp loudly
  # rather than anchor an owner seat to a placeholder. Skip, not exit: this block
  # only runs when users.json exists, so the real node is untouched, the per-deploy
  # reconciles below still run (including the au.name -> Person backfill), and the
  # standing [identity-audit] surfaces any orphan/nameless owner. Aborting the
  # whole deploy here would brick re-deploys of exactly the installs to remediate.
  if [ -z "$USER_NAME" ] || [ "$USER_NAME" = "Owner" ]; then
    echo "  [seed] WARN: owner name is blank or the placeholder \"Owner\" (users.json[0].name for userId=${USER_ID:0:8}) — skipping owner AdminUser seed. Set a real name (change-PIN) to reconcile; the standing [identity-audit] flags it meanwhile." >&2
  else
    CREATED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)

    echo "==> Creating AdminUser node for userId=$USER_ID"
    # Stamp accountId + role='owner' at seed so the graph-write gate's
    # account-scoped MATCH (au:AdminUser {accountId: $accountId}) succeeds on the
    # first user-domain write. The ON MATCH branch COALESCEs to preserve any
    # existing non-null value.
    "$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" << CYPHER_EOF
MERGE (au:AdminUser {userId: '$USER_ID'})
ON CREATE SET au.accountId = '$ACCOUNT_ID',
              au.role = 'owner',
              au.createdAt = '$CREATED_AT'
ON MATCH SET au.accountId = COALESCE(au.accountId, '$ACCOUNT_ID'),
             au.role = COALESCE(au.role, 'owner')
WITH au
MATCH (b:LocalBusiness {accountId: '$ACCOUNT_ID'})
MERGE (au)-[r:ADMIN_OF]->(b)
ON CREATE SET r.role = 'owner', r.grantedAt = '$CREATED_AT'
RETURN au.userId, b.accountId;
CYPHER_EOF
    echo "  [seed] AdminUser stamped userId=${USER_ID:0:8} accountId=${ACCOUNT_ID:0:8} role=owner"
  fi
else
  echo "  No users.json found — skipping AdminUser seed (set-pin POST creates it)"
fi

# ------------------------------------------------------------------
# 5. Standing reconcile — Property-hub invariant (estate-agent vertical)
# ------------------------------------------------------------------
# :Property is the obligatory parent of every :Listing. The schema-apply step
# above runs the one-shot backfill that relinks any pre-inversion listing-only
# data; this per-deploy count proves it reached zero and catches a write path
# that later regresses the inversion. orphanListings=0 is healthy. A fresh
# non-estate install has zero :Listing nodes (orphanListings=0 total=0). This
# is a visibility signal only — never install-blocking, so failures here do not
# exit non-zero.
ESTATE_ORPHANS=$("$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" \
    --format plain \
    "MATCH (l:Listing {accountId: '$ACCOUNT_ID'}) WHERE NOT ( (:Property)-[:HAS_LISTING]->(l) ) RETURN count(l)" 2>/dev/null | tail -1 | tr -d ' \r')
ESTATE_TOTAL=$("$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" \
    --format plain \
    "MATCH (l:Listing {accountId: '$ACCOUNT_ID'}) RETURN count(l)" 2>/dev/null | tail -1 | tr -d ' \r')
echo "  [estate-reconcile] accountId=${ACCOUNT_ID:0:8} orphanListings=${ESTATE_ORPHANS:-0} total=${ESTATE_TOTAL:-0}"

# ------------------------------------------------------------------
# 6. Standing reconcile — sales-progression chain state (Task 1372)
# ------------------------------------------------------------------
# The primary failure mode is silent: a sale-agreed deal whose milestone dates
# stop advancing emits no tool call, so no log line exists. This per-deploy
# count is the only standing signal for it. A fresh non-estate install has zero
# :Sale nodes (all counts 0, total 0). Visibility only — never install-blocking,
# so failures here do not exit non-zero.
#   stalledSales   — :Sale{status:'sale-agreed'} whose newest milestone date
#                    (across its links, floored by sstcDate) is > 7 days old.
#   orphanChains   — :Chain with no HAS_LINK (an empty chain).
#   linklessChains — :Sale with no IN_CHAIN (a structurally-orphaned sale).
#   total          — every :Sale for the account.
PROG_STALLED=$("$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" \
    --format plain \
    "MATCH (s:Sale {accountId: '$ACCOUNT_ID', status: 'sale-agreed'})
     OPTIONAL MATCH (s)-[:IN_CHAIN]->(:Chain)-[:HAS_LINK]->(cl:ChainLink)
     WITH s, collect(cl) AS links
     WITH s, [d IN ([s.sstcDate] + reduce(acc = [], l IN links | acc + [
         l.mortgageValuationDate, l.surveyDate, l.draftContractIssuedDate,
         l.searchesRequestedDate, l.searchesReceivedDate, l.mortgageOfferReceivedDate,
         l.sellerSignedDate, l.buyerSignedDate, l.exchangedDate, l.completedDate
       ])) WHERE d IS NOT NULL | date(d)] AS parsed
     WITH s, CASE WHEN size(parsed) = 0 THEN null
                  ELSE reduce(m = parsed[0], d IN parsed | CASE WHEN d > m THEN d ELSE m END) END AS newest
     WHERE newest IS NOT NULL AND newest < date() - duration({days: 7})
     RETURN count(s)" 2>/dev/null | tail -1 | tr -d ' \r')
PROG_ORPHAN_CHAINS=$("$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" \
    --format plain \
    "MATCH (c:Chain {accountId: '$ACCOUNT_ID'}) WHERE NOT ( (c)-[:HAS_LINK]->() ) RETURN count(c)" 2>/dev/null | tail -1 | tr -d ' \r')
PROG_LINKLESS=$("$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" \
    --format plain \
    "MATCH (s:Sale {accountId: '$ACCOUNT_ID'}) WHERE NOT ( (s)-[:IN_CHAIN]->() ) RETURN count(s)" 2>/dev/null | tail -1 | tr -d ' \r')
PROG_TOTAL=$("$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" \
    --format plain \
    "MATCH (s:Sale {accountId: '$ACCOUNT_ID'}) RETURN count(s)" 2>/dev/null | tail -1 | tr -d ' \r')
echo "  [progression-reconcile] accountId=${ACCOUNT_ID:0:8} stalledSales=${PROG_STALLED:-0} orphanChains=${PROG_ORPHAN_CHAINS:-0} linklessChains=${PROG_LINKLESS:-0} total=${PROG_TOTAL:-0}"

# ------------------------------------------------------------------
# 7. One-time reconcile — AdminUser.name -> owned Person, then drop au.name (Task 1570)
# ------------------------------------------------------------------
# AdminUser carries no name; the owned Person is the sole name source. This heals
# legacy nodes globally (every account in the shared graph, not just this one).
# It must NOT touch an AdminUser that already has a named owned Person (the common
# case: a house owner with a real "Joel Smalley" Person) — only 7c drops au.name
# there. So the migrate is split so it never MERGE-duplicates a name-keyed Person:
#   7a — an owned Person exists but is nameless: populate its name.
#   7b — no owned Person exists at all: create one carrying the name.
# Owner/admin-personal Persons are keyed on {accountId} + name (writeAdminUserAndPerson,
# admin-add) or {accountId, userId} (seedAccountGraphRoot); keying a MERGE on
# {accountId, userId} would miss the former and create a duplicate, so 7a SETs the
# existing node and only 7b CREATEs (for a genuine orphan). Placeholder names
# ("Owner"/"Admin"/blank) are excluded from 7a/7b and simply dropped by 7c.
# Idempotent: once au.name is gone all three match nothing, so re-deploys are
# no-ops. Split mirrors writeAdminUserAndPerson (first ASCII space: head ->
# givenName, tail -> familyName); operator names never carry embedded tab/NBSP.
# Visibility only — never install-blocking.
IDENT_NAMED=$("$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" \
    --format plain \
    "MATCH (au:AdminUser)-[:OWNS]->(p:Person)
     WHERE p.accountId = au.accountId
       AND (p.givenName IS NULL OR trim(p.givenName) = '')
       AND au.name IS NOT NULL
       AND trim(au.name) <> '' AND trim(au.name) <> 'Owner' AND trim(au.name) <> 'Admin'
     WITH p, trim(au.name) AS nm
     SET p.givenName = CASE WHEN nm CONTAINS ' ' THEN trim(split(nm, ' ')[0]) ELSE nm END,
         p.familyName = CASE WHEN nm CONTAINS ' ' THEN trim(substring(nm, size(split(nm, ' ')[0]) + 1)) ELSE null END
     RETURN count(p)" 2>/dev/null | tail -1 | tr -d ' \r')
IDENT_CREATED=$("$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" \
    --format plain \
    "MATCH (au:AdminUser)
     WHERE au.name IS NOT NULL
       AND trim(au.name) <> '' AND trim(au.name) <> 'Owner' AND trim(au.name) <> 'Admin'
       AND au.accountId IS NOT NULL
       AND NOT EXISTS { MATCH (au)-[:OWNS]->(p:Person) WHERE p.accountId = au.accountId }
     WITH au, trim(au.name) AS nm
     CREATE (au)-[:OWNS]->(np:Person {
       accountId: au.accountId, userId: au.userId, role: 'admin-personal', scope: 'admin', createdAt: datetime(),
       givenName: CASE WHEN nm CONTAINS ' ' THEN trim(split(nm, ' ')[0]) ELSE nm END,
       familyName: CASE WHEN nm CONTAINS ' ' THEN trim(substring(nm, size(split(nm, ' ')[0]) + 1)) ELSE null END
     })
     RETURN count(np)" 2>/dev/null | tail -1 | tr -d ' \r')
IDENT_DROPPED=$("$CYPHER_SHELL" -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" \
    --format plain \
    "MATCH (au:AdminUser) WHERE au.name IS NOT NULL REMOVE au.name RETURN count(au)" 2>/dev/null | tail -1 | tr -d ' \r')
echo "  [identity-reconcile] namedExisting=${IDENT_NAMED:-0} createdOwned=${IDENT_CREATED:-0} droppedAuName=${IDENT_DROPPED:-0}"

echo "  Done."
