#!/usr/bin/env bash
# ============================================================
# dedupe-userprofile-ghosts.sh
#
# Purpose
# -------
# Remove ghost AdminUser + UserProfile rows whose userId does not appear
# in users.json (carryover from the userId-minting bug fixed),
# and dedupe (accountId, userId) UserProfile collisions (would-be schema
# violations once the new constraint is applied).
#
# Per loser:
#   1. Reparent every outbound edge to the winner — except edges whose
#      target is itself a loser, edges that would create a duplicate of
#      an edge the winner already has, and self-loops.
#   2. Reparent every inbound edge to the winner under the same rules.
#   3. DETACH DELETE the loser node.
#
# All work is operator-invoked over SSH. The script is idempotent:
# a second run logs `[dedupe-userprofile] nothing-to-dedupe brand=<n>`
# and exits 0 with no writes.
#
# Usage
# -----
#   bash dedupe-userprofile-ghosts.sh [--brand=NAME] [--dry-run]
#
#   --brand=NAME   Brand name (e.g. "maxy", "realagent"). Auto-detected if
#                  exactly one ~/.<brand>/.neo4j-password exists.
#   --dry-run      Print plan without writes.
#
# Reads:
#   ~/.<brand>/.neo4j-password   Neo4j password
#   ~/.<brand>/.env              NEO4J_URI (override with env var)
#   ~/.<brand>/users.json        live userIds
#
# Edge-reparent caveat
# --------------------
# Reparented edges keep their original properties. Properties on the
# *other* node (e.g. a Preference's own `userId`) are not rewritten —
# if a Preference's userId no longer matches its parent UserProfile's
# userId after dedupe, it surfaces in subsequent `[graph-health]` ticks
# and is the responsibility of a follow-up task. This script's contract
# is "no UserProfile/AdminUser ghost nodes remain", not "every property
# referencing a ghost userId is rewritten".
# ============================================================

set -euo pipefail

BRAND=""
DRY_RUN=0

while [ $# -gt 0 ]; do
  case "$1" in
    --brand=*) BRAND="${1#*=}"; shift ;;
    --brand)   BRAND="${2:-}"; shift 2 ;;
    --dry-run) DRY_RUN=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

# --- Brand detection ------------------------------------------------
if [ -z "$BRAND" ]; then
  candidates=()
  for d in "$HOME"/.maxy "$HOME"/.realagent; do
    if [ -f "$d/.neo4j-password" ]; then
      candidates+=("$(basename "$d" | sed 's/^\.//')")
    fi
  done
  if [ "${#candidates[@]}" -eq 1 ]; then
    BRAND="${candidates[0]}"
  elif [ "${#candidates[@]}" -gt 1 ]; then
    echo "Error: multiple brand dirs (${candidates[*]}); pass --brand=NAME" >&2
    exit 1
  else
    echo "Error: no brand dir with .neo4j-password under ~/.maxy or ~/.realagent" >&2
    exit 1
  fi
fi

BRAND_DIR="$HOME/.$BRAND"
[ -d "$BRAND_DIR" ] || { echo "Error: $BRAND_DIR not found" >&2; exit 1; }

PASSWORD_FILE="$BRAND_DIR/.neo4j-password"
ENV_FILE="$BRAND_DIR/.env"
USERS_FILE="$BRAND_DIR/users.json"

[ -f "$PASSWORD_FILE" ] || { echo "Error: $PASSWORD_FILE missing" >&2; exit 1; }
[ -f "$USERS_FILE" ]    || { echo "Error: $USERS_FILE missing" >&2; exit 1; }

NEO4J_PASSWORD="$(cat "$PASSWORD_FILE")"
NEO4J_USER="${NEO4J_USER:-neo4j}"
NEO4J_URI="${NEO4J_URI:-}"
if [ -z "$NEO4J_URI" ] && [ -f "$ENV_FILE" ]; then
  # shellcheck disable=SC2002
  NEO4J_URI="$(awk -F'=' '/^NEO4J_URI=/ {sub(/^NEO4J_URI=/,""); print; exit}' "$ENV_FILE")"
fi
[ -n "$NEO4J_URI" ] || { echo "Error: NEO4J_URI not in env or $ENV_FILE" >&2; exit 1; }

if ! command -v cypher-shell >/dev/null 2>&1; then
  echo "Error: cypher-shell not found in PATH" >&2
  exit 1
fi

# --- Live userIds (from users.json) ---------------------------------
# Build a Cypher list literal: ['uid1', 'uid2', ...]
LIVE_USER_IDS_CYPHER="$(python3 -c '
import json, sys
with open(sys.argv[1]) as f:
    ids = [u["userId"] for u in json.load(f) if u.get("userId")]
print("[" + ",".join("'\''" + i.replace("'\''","''") + "'\''" for i in ids) + "]")
' "$USERS_FILE")"

if [ "$LIVE_USER_IDS_CYPHER" = "[]" ]; then
  echo "Error: $USERS_FILE has no userId entries" >&2
  exit 1
fi

# --- Cypher-shell helpers -------------------------------------------
cs() {
  cypher-shell -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" "$@"
}

now_ms() { python3 -c 'import time; print(int(time.time()*1000))'; }

START_MS=$(now_ms)
REPARENTED_TOTAL=0
DELETED_TOTAL=0

# --- Per-loser reparent + delete ------------------------------------
# Args: $1=label (UserProfile|AdminUser) $2=loserEid $3=winnerEid $4=allLosersJson
# (allLosersJson is a Cypher list literal of all loser eids — used to skip
# edges between two ghosts that will both be deleted.)
dedupe_one() {
  local label="$1" loser_eid="$2" winner_eid="$3" all_losers_cypher="$4"

  echo "[dedupe-userprofile] delete elementId=$loser_eid label=$label" >&2

  if [ "$DRY_RUN" = "1" ]; then
    DELETED_TOTAL=$((DELETED_TOTAL + 1))
    return 0
  fi

  # Single transaction: reparent outbound, reparent inbound, delete loser.
  # Skips:
  #   - edges to/from other losers (they die together)
  #   - edges that would duplicate an edge the winner already has
  #   - self-loops on winner
  local out
  out="$(cs --format plain <<CYPHER
CYPHER 5
MATCH (loser) WHERE elementId(loser) = '$loser_eid'
MATCH (winner) WHERE elementId(winner) = '$winner_eid'
CALL {
  WITH loser, winner
  MATCH (loser)-[r]->(o)
  WHERE elementId(o) <> elementId(winner)
    AND NOT elementId(o) IN $all_losers_cypher
    AND NOT EXISTS { MATCH (winner)-[r2]->(o) WHERE type(r2) = type(r) }
  WITH winner, o, type(r) AS t, properties(r) AS p, r
  CREATE (winner)-[nr:\$(t)]->(o)
  SET nr = p
  RETURN count(*) AS outMoved
}
CALL {
  WITH loser, winner
  MATCH (i)-[r]->(loser)
  WHERE elementId(i) <> elementId(winner)
    AND NOT elementId(i) IN $all_losers_cypher
    AND NOT EXISTS { MATCH (i)-[r2]->(winner) WHERE type(r2) = type(r) }
  WITH winner, i, type(r) AS t, properties(r) AS p, r
  CREATE (i)-[nr:\$(t)]->(winner)
  SET nr = p
  RETURN count(*) AS inMoved
}
WITH loser, outMoved, inMoved
DETACH DELETE loser
RETURN outMoved + inMoved AS reparented;
CYPHER
)"

  # Parse result (last numeric token)
  local moved
  moved="$(printf '%s\n' "$out" | awk '/^[0-9]+$/ {n=$0} END {print (n+0)}')"
  REPARENTED_TOTAL=$((REPARENTED_TOTAL + moved))
  DELETED_TOTAL=$((DELETED_TOTAL + 1))
  if [ "$moved" -gt 0 ]; then
    echo "[dedupe-userprofile] reparent edges=$moved from=$loser_eid to=$winner_eid" >&2
  fi
}

# --- Plan both phases up front --------------------------------------
# So that `start` / `nothing-to-dedupe` / `DRY-RUN` lines fire BEFORE
# any bucket/delete events. Pre-count ghosts per phase = total rows in
# the plan minus distinct accountIds (one keeper per account; solo-tier).

plan_admin="$(cs --format plain <<CYPHER
MATCH (au:AdminUser)-[:ADMIN_OF]->(b:LocalBusiness)
WITH b.accountId AS accountId, au
WITH accountId, collect({eid: elementId(au), userId: coalesce(au.userId, '')}) AS rows, count(au) AS n
WHERE n > 1
UNWIND rows AS row
RETURN accountId + '|' + row.eid + '|' + row.userId + '|' + toString(row.userId IN $LIVE_USER_IDS_CYPHER) AS line
ORDER BY accountId, line;
CYPHER
)"

plan_up="$(cs --format plain <<CYPHER
MATCH (up:UserProfile)
WITH up.accountId AS accountId, up
WITH accountId, collect({eid: elementId(up), userId: coalesce(up.userId, ''), updatedAt: coalesce(up.updatedAt, '')}) AS rows, count(up) AS n
WHERE n > 1
UNWIND rows AS row
RETURN accountId + '|' + row.eid + '|' + row.userId + '|' + toString(row.userId IN $LIVE_USER_IDS_CYPHER) + '|' + row.updatedAt AS line
ORDER BY accountId, line;
CYPHER
)"

admin_lines="$(printf '%s\n' "$plan_admin" | tail -n +2 | sed -e 's/^"//' -e 's/"$//' | grep -v '^$' || true)"
up_lines="$(printf '%s\n'    "$plan_up"    | tail -n +2 | sed -e 's/^"//' -e 's/"$//' | grep -v '^$' || true)"

count_ghosts() {
  # ghosts = total_rows - distinct_accountIds (first |-field). 0 on empty.
  local lines="$1"
  [ -z "$lines" ] && { echo 0; return; }
  local total distinct
  total=$(printf '%s\n' "$lines" | wc -l | awk '{print $1}')
  distinct=$(printf '%s\n' "$lines" | awk -F'|' '{print $1}' | sort -u | wc -l | awk '{print $1}')
  echo $((total - distinct))
}
admin_pre_ghosts=$(count_ghosts "$admin_lines")
up_pre_ghosts=$(count_ghosts "$up_lines")
total_pre_ghosts=$((admin_pre_ghosts + up_pre_ghosts))

if [ "$total_pre_ghosts" -eq 0 ]; then
  echo "[dedupe-userprofile] nothing-to-dedupe brand=$BRAND" >&2
  exit 0
fi

if [ "$DRY_RUN" = "1" ]; then
  echo "[dedupe-userprofile] DRY-RUN brand=$BRAND admin-ghosts=$admin_pre_ghosts userprofile-ghosts=$up_pre_ghosts (no writes)" >&2
fi

# Pre-count distinct accountIds with ghosts (across both phases) so the
# `start` line carries the same total as the success criterion query.
distinct_admin_accts=$([ -n "$admin_lines" ] && printf '%s\n' "$admin_lines" | awk -F'|' '{print $1}' | sort -u | wc -l | awk '{print $1}' || echo 0)
distinct_up_accts=$([ -n "$up_lines" ]    && printf '%s\n' "$up_lines"    | awk -F'|' '{print $1}' | sort -u | wc -l | awk '{print $1}' || echo 0)
distinct_total_accts=$((distinct_admin_accts + distinct_up_accts))

if [ "$DRY_RUN" != "1" ]; then
  echo "[dedupe-userprofile] start brand=$BRAND accounts-with-ghosts=$distinct_total_accts total-ghosts=$total_pre_ghosts" >&2
fi

# --- Phase A: AdminUser cleanup -------------------------------------
admin_accounts_with_ghosts=0
admin_total_ghosts=0

if [ -n "$admin_lines" ]; then
  # Group by accountId
  current_account=""
  winner_eid=""
  losers_for_account=()

  process_account() {
    local acct="$1"
    [ -z "$acct" ] && return 0
    if [ -z "$winner_eid" ]; then
      echo "[dedupe-userprofile] WARN no live AdminUser for account=${acct:0:8} — skipping" >&2
      return 0
    fi
    if [ "${#losers_for_account[@]}" -eq 0 ]; then
      return 0
    fi
    admin_accounts_with_ghosts=$((admin_accounts_with_ghosts + 1))
    admin_total_ghosts=$((admin_total_ghosts + ${#losers_for_account[@]}))

    # Build Cypher list literal of all loser eids (for the inter-loser skip)
    local all_losers="["
    local first=1
    for e in "${losers_for_account[@]}"; do
      [ $first -eq 0 ] && all_losers="$all_losers,"
      all_losers="$all_losers'$e'"
      first=0
    done
    all_losers="$all_losers]"

    echo "[dedupe-userprofile] bucket label=AdminUser account=${acct:0:8} winner=$winner_eid losers=${#losers_for_account[@]}" >&2
    for loser in "${losers_for_account[@]}"; do
      dedupe_one "AdminUser" "$loser" "$winner_eid" "$all_losers"
    done
  }

  # iterate
  while IFS='|' read -r acct eid uid is_live; do
    [ -z "$acct" ] && continue
    if [ "$acct" != "$current_account" ]; then
      process_account "$current_account"
      current_account="$acct"
      winner_eid=""
      losers_for_account=()
    fi
    if [ "$is_live" = "true" ] && [ -z "$winner_eid" ]; then
      winner_eid="$eid"
    else
      losers_for_account+=("$eid")
    fi
  done <<< "$admin_lines"
  process_account "$current_account"
fi

# --- Phase B: UserProfile cleanup -----------------------------------
up_accounts_with_ghosts=0
up_total_ghosts=0

if [ -n "$up_lines" ]; then
  current_account=""
  winner_eid=""
  winner_updated=""
  losers_for_account=()

  process_up_account() {
    local acct="$1"
    [ -z "$acct" ] && return 0
    if [ -z "$winner_eid" ]; then
      echo "[dedupe-userprofile] WARN no live UserProfile for account=${acct:0:8} — skipping" >&2
      return 0
    fi
    if [ "${#losers_for_account[@]}" -eq 0 ]; then
      return 0
    fi
    up_accounts_with_ghosts=$((up_accounts_with_ghosts + 1))
    up_total_ghosts=$((up_total_ghosts + ${#losers_for_account[@]}))

    local all_losers="["
    local first=1
    for e in "${losers_for_account[@]}"; do
      [ $first -eq 0 ] && all_losers="$all_losers,"
      all_losers="$all_losers'$e'"
      first=0
    done
    all_losers="$all_losers]"

    echo "[dedupe-userprofile] bucket label=UserProfile account=${acct:0:8} winner=$winner_eid losers=${#losers_for_account[@]}" >&2
    for loser in "${losers_for_account[@]}"; do
      dedupe_one "UserProfile" "$loser" "$winner_eid" "$all_losers"
    done
  }

  while IFS='|' read -r acct eid uid is_live updated; do
    [ -z "$acct" ] && continue
    if [ "$acct" != "$current_account" ]; then
      process_up_account "$current_account"
      current_account="$acct"
      winner_eid=""
      winner_updated=""
      losers_for_account=()
    fi
    # Winner = first live row with most-recent updatedAt; ties broken by first-seen.
    if [ "$is_live" = "true" ]; then
      if [ -z "$winner_eid" ] || [[ "$updated" > "$winner_updated" ]]; then
        # demote previous winner (if any) to loser
        if [ -n "$winner_eid" ]; then
          losers_for_account+=("$winner_eid")
        fi
        winner_eid="$eid"
        winner_updated="$updated"
      else
        losers_for_account+=("$eid")
      fi
    else
      losers_for_account+=("$eid")
    fi
  done <<< "$up_lines"
  process_up_account "$current_account"
fi

# --- Summary --------------------------------------------------------
END_MS=$(now_ms)
DURATION_MS=$((END_MS - START_MS))

if [ "$DRY_RUN" = "1" ]; then
  exit 0
fi

echo "[dedupe-userprofile] done brand=$BRAND reparented=$REPARENTED_TOTAL deleted=$DELETED_TOTAL duration-ms=$DURATION_MS" >&2
