#!/usr/bin/env bash
# ============================================================
# backfill-task-ids.sh
#
# Purpose
# -------
# Repair :Task nodes that carry no taskId property. Such nodes are written
# by LLM-authored ingest paths and are unactionable: every
# task-* HTTP route and every work-* MCP tool matches on
# MATCH (:Task {taskId, accountId}), so a node without one is invisible to
# all of them. On /tasks they rendered a permanently-stuck minutes editor.
#
# Neo4j Community Edition cannot enforce this with a constraint: property
# existence constraints (IS NOT NULL) are Enterprise-only, and the existing
# task_id_unique constraint ignores nodes that lack the property entirely.
# This script is therefore the repair path, and the [task-timer] op=list
# missingTaskId census is the detector.
#
# What it does (idempotent; re-running on a clean graph is a no-op)
#   detect   count :Task nodes with taskId IS NULL, per account
#   backfill SET t.taskId = randomUUID() on each
#   gate     re-count; a non-zero remainder is a non-zero exit
#
# Usage
#   bash backfill-task-ids.sh                      # dry-run (default)
#   bash backfill-task-ids.sh --dry-run
#   bash backfill-task-ids.sh --apply
#   bash backfill-task-ids.sh --apply --account=<accountId>
#   bash backfill-task-ids.sh --brand=maxy --apply
#
# --dry-run  Report the count and name the offenders; mutate nothing (default).
# --apply    Mint randomUUID() for every taskId-less :Task, then re-count.
# --account  Restrict to one accountId. Omit to cover every account.
# --brand    Brand whose graph to connect to. Auto-detected when only one
#            ~/.<brand>/.neo4j-password exists.
#
# Reads:
#   ~/.<brand>/.neo4j-password   Neo4j password
#   ~/.<brand>/.env              NEO4J_URI (override with the env var)
# ============================================================

set -euo pipefail

# Origin: task 1751. Kept below the header separator so `--help`, which prints
# lines 2..separator, never emits a task citation to an operator — the
# check-no-task-id-leaks gate skips '#'-leading lines, so a citation in the
# printed header would evade it and surface in operator-visible output.

BRAND=""
ACCOUNT=""
MODE="dry-run"

# `${2:?...}` rather than `${2:-}` + `shift 2`: with a bare trailing flag and
# `set -e`, `shift 2` fails and aborts the script with no message at all.
while [ $# -gt 0 ]; do
  case "$1" in
    --brand=*)   BRAND="${1#*=}"; shift ;;
    --brand)     BRAND="${2:?--brand requires a value}"; shift 2 ;;
    --account=*) ACCOUNT="${1#*=}"; shift ;;
    --account)   ACCOUNT="${2:?--account requires a value}"; shift 2 ;;
    --dry-run)   MODE="dry-run"; shift ;;
    --apply)     MODE="apply"; 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 "[task-backfill] $*" >&2; }

# --- Argument validation --------------------------------------------
# Runs before any environment or connection work so a malformed argument is
# rejected on any machine, not only one with a live graph.
#
# $ACCOUNT is interpolated into Cypher below, so its shape is gated rather than
# trusted. cypher-shell executes semicolon-separated statements, so an
# unvalidated value containing a quote could close the string literal and run
# arbitrary Cypher; a stray quote could also silently change which rows the
# WHERE clause matches while the exit gate still reported success. Account ids
# are UUIDs, so anything outside this class is a mistake and is rejected loudly
# instead of being escaped and guessed at.
if [ -n "$ACCOUNT" ]; then
  case "$ACCOUNT" in
    *[!A-Za-z0-9._-]*)
      echo "Error: --account must contain only letters, digits, dot, underscore or dash (got: $ACCOUNT)" >&2
      exit 2
      ;;
  esac
fi

# --- Brand detection ------------------------------------------------
if [ -z "$BRAND" ]; then
  candidates=()
  for d in "$HOME"/.*; do
    [ -d "$d" ] || continue
    [ -f "$d/.neo4j-password" ] || continue
    candidates+=("$(basename "$d" | sed 's/^\.//')")
  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 $HOME" >&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"
[ -f "$PASSWORD_FILE" ] || { echo "Error: $PASSWORD_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
  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; }

command -v cypher-shell >/dev/null 2>&1 || { echo "Error: cypher-shell not in PATH" >&2; exit 1; }

cs() { cypher-shell -u "$NEO4J_USER" -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" --format plain; }

# Reads a cypher-shell count result and prints it as an integer.
#
# Exits NON-ZERO when it cannot find one. That matters more than it looks: the
# exit gate below treats 0 as "nothing left to repair", i.e. success. An earlier
# version defaulted to 0 on any unrecognised input, so a warning banner, a
# version notice, or an empty result would have printed "OK: repaired N node(s)"
# while the nodes were still broken. A parse failure must never be spelled the
# same way as a clean result.
#
# Scans every line for a bare integer rather than assuming the count sits on
# line 2, so a leading notification line does not shift it out of view. Under
# `set -euo pipefail` a non-zero exit here aborts the script at the assignment.
as_int() {
  awk '
    { line = $0; gsub(/[" \t\r]/, "", line); if (line ~ /^-?[0-9]+$/) { v = line; found = 1 } }
    END { if (!found) { print "as_int: no integer in cypher-shell output" > "/dev/stderr"; exit 1 } print v + 0 }
  '
}

# Account filter fragment, empty when covering every account. $ACCOUNT was
# shape-gated at argument-validation time above.
if [ -n "$ACCOUNT" ]; then
  ACCT_WHERE="AND t.accountId = '$ACCOUNT'"
else
  ACCT_WHERE=""
fi

# --- Detect ---------------------------------------------------------
BEFORE="$(cs <<CYPHER | as_int
MATCH (t:Task) WHERE t.taskId IS NULL $ACCT_WHERE RETURN count(t);
CYPHER
)"

log "step=detect brand=$BRAND account=${ACCOUNT:-ALL} missingTaskId=$BEFORE"

if [ "$BEFORE" -eq 0 ]; then
  log "step=detect nothing to repair; exiting clean"
  exit 0
fi

# Name the offenders so the operator sees what is about to change.
cs <<CYPHER >&2
MATCH (t:Task) WHERE t.taskId IS NULL $ACCT_WHERE
RETURN t.accountId AS accountId, t.name AS name, t.status AS status, elementId(t) AS elementId
ORDER BY t.accountId LIMIT 50;
CYPHER

if [ "$MODE" = "dry-run" ]; then
  log "step=backfill WOULD set taskId=randomUUID() on $BEFORE node(s); re-run with --apply"
  exit 0
fi

# --- Apply ----------------------------------------------------------
WROTE="$(cs <<CYPHER | as_int
MATCH (t:Task) WHERE t.taskId IS NULL $ACCT_WHERE
SET t.taskId = randomUUID()
RETURN count(t);
CYPHER
)"

log "step=backfill wrote=$WROTE"

# --- Gate -----------------------------------------------------------
AFTER="$(cs <<CYPHER | as_int
MATCH (t:Task) WHERE t.taskId IS NULL $ACCT_WHERE RETURN count(t);
CYPHER
)"

log "step=gate missingTaskId=$AFTER"

if [ "$AFTER" -ne 0 ]; then
  log "FAIL: $AFTER :Task node(s) still have no taskId after backfill"
  exit 1
fi

log "OK: repaired $WROTE node(s); missingTaskId=0"
