#!/usr/bin/env bash
#
# smoke-boot-services.sh — boot every compiled service against a synthetic
# install fixture, run the cypher schema against a live Neo4j, and assert
# every startup-self-test the production boot would log fires here.
#
# Failure modes caught (Task 438):
#   - specialist-tool-drift: a specialist template (or per-account override)
#     names a tool the plugin does not export.
#   - system-prompt-sentinels: the composer drops a `<name>` the
#     IDENTITY.md prose references.
#   - identity-drift: IDENTITY.md mentions a `<name>` the composer does not
#     emit.
#   - cypher-schema: schema.cypher contains syntax the live Neo4j rejects
#     (e.g. Neo4j 4 `DROP FULLTEXT INDEX` against a Neo4j 5 parser).
#   - module-load: the original Task 099 motivation — `require()` in ESM,
#     missing externals, stray top-level throws.
#
# Fixture shape: the temp install dir mirrors what the installer writes on
# first boot. Real templates are copied (not stubs) so the drift gates run
# against the same content production would see:
#   $INSTALL_DIR/platform/config/brand.json
#   $INSTALL_DIR/platform/plugins/<name>/PLUGIN.md          (real manifests)
#   $INSTALL_DIR/platform/templates/agents/admin/*.md       (real templates)
#   $INSTALL_DIR/platform/templates/specialists/agents/*.md (real templates)
#   $INSTALL_DIR/data/accounts/<uuid>/account.json
#   $INSTALL_DIR/data/accounts/<uuid>/agents/admin/{IDENTITY,SOUL,AGENTS}.md
#   $INSTALL_DIR/data/accounts/<uuid>/specialists/agents/*.md (per-account)
#   $INSTALL_DIR/.claude/                                    (CLAUDE_CONFIG_DIR)
#
# Cypher gate: apply schema.cypher to the dev's local Neo4j via cypher-shell.
# NEO4J_URI / NEO4J_USER / NEO4J_PASSWORD must be set (same shape as
# seed-neo4j.sh — the maintainer's existing local install). The gate is a
# real apply, not parse-only: schema commands use `IF NOT EXISTS` / `IF
# EXISTS` and the dev's Neo4j is the test surface — pollution is the point.
# If cypher-shell is absent or NEO4J_URI is unset, the gate exits non-zero
# so the publish blocker maps to the same code path a real install hits.
#
# Service list: extend SERVICES below as additional services adopt the
# smoke gate. Each entry needs a fixture function that creates the temp
# install dir layout the service's loadConfig expects.

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"

# Services to smoke. Add new entries as `<name>:<dist-entry-relative-to-REPO_ROOT>`.
SERVICES=(
  "claude-session-manager:platform/services/claude-session-manager/dist/index.js"
)

# MCP plugins to smoke. Task 265 — CC's plugin-system spawns each MCP server
# with empty env for the tools/list enumeration pass. Any module-init throw
# (e.g. `requirePortEnv` at the top level) empties plugin registry #1 and
# breaks /agents resolution. Each entry must boot cleanly with `env: {}` and
# `stdin: /dev/null` — exit code 0, no `missing-port-env reason=missing-env`
# on stderr. Add new entries as `<name>:<dist-entry-relative-to-REPO_ROOT>`.
MCP_PLUGINS=(
  "whatsapp:platform/plugins/whatsapp/mcp/dist/index.js"
)

TMP_ROOT="$(mktemp -d -t maxy-smoke-boot.XXXXXX)"
cleanup() {
  rm -rf "$TMP_ROOT"
}
trap cleanup EXIT

# Build the synthetic install dir for claude-session-manager. Layout mirrors
# what the installer writes on first boot, with REAL template content (not
# stubs) so the drift gates fire against the same files production sees.
#
# Why real-templates: a 0-specialist or empty-IDENTITY fixture lets every
# drift gate fall through trivially (Task 438). The 0.1.155 install regressed
# because the librarian per-account override referenced an MCP tool the
# memory plugin no longer exports — a defect the smoke fixture missed because
# the fixture's per-account specialists dir was empty.
fixture_claude_session_manager() {
  local install_dir="$1"
  local platform_root="$install_dir/platform"
  local templates_src="$REPO_ROOT/platform/templates"
  local account_id="00000000-0000-4000-8000-000000000000"
  local account_dir="$install_dir/data/accounts/$account_id"

  mkdir -p "$platform_root/config"
  mkdir -p "$account_dir/agents/admin"
  mkdir -p "$account_dir/agents/public"
  mkdir -p "$account_dir/specialists/agents"
  mkdir -p "$account_dir/.claude"
  mkdir -p "$install_dir/.claude"

  cat > "$platform_root/config/brand.json" <<'BRAND'
{
  "hostname": "smoke",
  "productName": "Smoke",
  "domain": null
}
BRAND

  # account.json field set mirrors the live template's required fields
  # (resolveAccount throws on missing adminModel/publicModel). Values just
  # need to satisfy the field check — boot never hits Anthropic.
  cat > "$account_dir/account.json" <<JSON
{
  "accountId": "$account_id",
  "tier": "smoke-test",
  "adminModel": "claude-fable-5[1m]",
  "publicModel": "claude-haiku-4-5"
}
JSON

  # Real admin templates — the drift gate reads the per-account IDENTITY.md;
  # SOUL/AGENTS are not boot-gated but presence is convention.
  cp "$templates_src/agents/admin/IDENTITY.md" "$account_dir/agents/admin/IDENTITY.md"
  cp "$templates_src/agents/admin/SOUL.md" "$account_dir/agents/admin/SOUL.md"
  [ -f "$templates_src/agents/admin/AGENTS.md" ] && cp "$templates_src/agents/admin/AGENTS.md" "$account_dir/agents/admin/AGENTS.md"
  [ -f "$templates_src/agents/public/IDENTITY.md" ] && cp "$templates_src/agents/public/IDENTITY.md" "$account_dir/agents/public/IDENTITY.md"

  # Real per-account specialist overrides — the drift gate walks
  # $accountDir/specialists/agents/ alongside the template path. 0.1.155
  # regressed because this dir was empty in the smoke fixture; a per-account
  # override referencing a withdrawn tool slipped through to a real install.
  for specialist in "$templates_src/specialists/agents/"*.md; do
    [ -f "$specialist" ] && cp "$specialist" "$account_dir/specialists/agents/$(basename "$specialist")"
  done

  # Real templates copied into $PLATFORM_ROOT/templates so the drift gate's
  # template-path walk sees the same content as production.
  mkdir -p "$platform_root/templates/agents/admin"
  mkdir -p "$platform_root/templates/specialists/agents"
  cp "$templates_src/agents/admin/"*.md "$platform_root/templates/agents/admin/" 2>/dev/null || true
  cp "$templates_src/specialists/agents/"*.md "$platform_root/templates/specialists/agents/"

  # Real plugin manifests — loadToolSurface walks platform/plugins/*/PLUGIN.md
  # to build the tool surface. An empty plugins dir gives an empty `all` set,
  # so every mcp__*__* tool would resolve as unknown-plugin-namespace; combined
  # with an empty specialists dir the drift check passed trivially either way.
  # Copy only PLUGIN.md — the drift check reads frontmatter, not binaries.
  mkdir -p "$platform_root/plugins"
  while IFS= read -r manifest; do
    local plugin_name
    plugin_name="$(basename "$(dirname "$manifest")")"
    mkdir -p "$platform_root/plugins/$plugin_name"
    cp "$manifest" "$platform_root/plugins/$plugin_name/PLUGIN.md"
  done < <(find "$REPO_ROOT/platform/plugins" -maxdepth 2 -name "PLUGIN.md")
}

# Pick an unlikely-to-clash port per service run. mktemp's tail gives entropy.
random_port() {
  printf '%d\n' $(( 40000 + RANDOM % 20000 ))
}

run_one() {
  local name="$1"
  local dist_entry="$2"
  local dist_path="$REPO_ROOT/$dist_entry"

  if [ ! -f "$dist_path" ]; then
    echo "[smoke] service=$name reason=dist-missing path=$dist_path" >&2
    return 1
  fi

  local install_dir="$TMP_ROOT/$name"
  mkdir -p "$install_dir"
  case "$name" in
    claude-session-manager) fixture_claude_session_manager "$install_dir" ;;
    *) echo "[smoke] service=$name reason=no-fixture-function" >&2; return 1 ;;
  esac

  local log_file="$TMP_ROOT/$name.log"
  local port; port="$(random_port)"
  local start_ms; start_ms="$(python3 -c 'import time;print(int(time.time()*1000))')"

  # Spawn the service. PLATFORM_ROOT is the synthetic platform dir; loadConfig
  # walks $(dirname PLATFORM_ROOT)/data/accounts for the account dir.
  # CLAUDE_CONFIG_DIR + NEO4J_URI + NEO4J_PASSWORD are stamped to satisfy the
  # manager's env-presence checks at boot. NEO4J values are dummies — the
  # manager only asserts non-empty here, it does not connect. The cypher-
  # schema gate runs separately against the dev's real Neo4j.
  PLATFORM_ROOT="$install_dir/platform" \
  CLAUDE_SESSION_MANAGER_PORT="$port" \
  CLAUDE_SESSION_MANAGER_PERSIST_DIR="$install_dir/persist" \
  CLAUDE_CONFIG_DIR="$install_dir/.claude" \
  NEO4J_URI="bolt://127.0.0.1:65535" \
  NEO4J_PASSWORD="smoke" \
  node "$dist_path" >"$log_file" 2>&1 &
  local pid=$!

  # Poll up to 10s for the final readiness signal (identity-drift line — last
  # startup-self-test logged) or any fast-fail line. The boot port= line fires
  # before self-tests; using it as the kill trigger lost the drift gates'
  # output (Task 438). identity-drift=ok|fail is the last log line on the
  # self-test path; boot-failed reason= is the fast-fail surface.
  local boot_line=""
  local fatal_line=""
  local readiness_line=""
  local waited_ms=0
  while [ "$waited_ms" -lt 10000 ]; do
    if [ -s "$log_file" ]; then
      readiness_line="$(grep -m1 -E 'startup-self-test identity-drift=' "$log_file" || true)"
      boot_line="$(grep -m1 -E '^([0-9TZ:.-]+ )?\[claude-session-manager\] boot port=' "$log_file" || true)"
      fatal_line="$(grep -m1 -E '^([0-9TZ:.-]+ )?\[.*\] fatal |boot-failed reason=' "$log_file" || true)"
      if [ -n "$readiness_line" ] || [ -n "$fatal_line" ]; then break; fi
    fi
    if ! kill -0 "$pid" 2>/dev/null; then break; fi
    sleep 0.1
    waited_ms=$((waited_ms + 100))
  done

  # Terminate the service. SIGTERM hits the registered shutdown handler.
  if kill -0 "$pid" 2>/dev/null; then
    kill -TERM "$pid" 2>/dev/null || true
    # Wait briefly for clean exit before falling through to SIGKILL.
    local term_waited=0
    while kill -0 "$pid" 2>/dev/null && [ "$term_waited" -lt 2000 ]; do
      sleep 0.1
      term_waited=$((term_waited + 100))
    done
    if kill -0 "$pid" 2>/dev/null; then
      kill -KILL "$pid" 2>/dev/null || true
    fi
  fi
  wait "$pid" 2>/dev/null || true
  local exit_code=$?

  local end_ms; end_ms="$(python3 -c 'import time;print(int(time.time()*1000))')"
  local elapsed_ms=$((end_ms - start_ms))

  # Re-scan after the process has exited; the boot line may have arrived
  # in the same tick as termination on a fast host.
  fatal_line="$(grep -m1 -E '^([0-9TZ:.-]+ )?\[.*\] fatal ' "$log_file" || true)"
  if [ -z "$boot_line" ]; then
    boot_line="$(grep -m1 -E '^([0-9TZ:.-]+ )?\[claude-session-manager\] boot port=' "$log_file" || true)"
  fi

  if [ -n "$fatal_line" ]; then
    echo "[smoke] service=$name reason=fatal-detected matched=$fatal_line" >&2
    echo "--- log ($log_file) ---" >&2
    cat "$log_file" >&2
    return 1
  fi
  # boot-failed lines mean the manager rejected a startup-self-test before
  # printing the boot line. Surface the gate-specific reason verbatim — same
  # signal a real device would print to journald.
  local boot_failed_line
  boot_failed_line="$(grep -m1 -E 'boot-failed reason=' "$log_file" || true)"
  if [ -n "$boot_failed_line" ]; then
    echo "[smoke] service=$name reason=boot-failed matched=$boot_failed_line" >&2
    echo "--- log ($log_file) ---" >&2
    cat "$log_file" >&2
    return 1
  fi
  if [ -z "$boot_line" ]; then
    echo "[smoke] service=$name reason=no-boot-line matched=" >&2
    echo "--- log ($log_file) ---" >&2
    cat "$log_file" >&2
    return 1
  fi
  # SIGTERM yields exit code 0 (clean shutdown handler called process.exit(0))
  # or 143 (128 + 15) if the handler did not run; SIGKILL yields 137.
  if [ "$exit_code" -ne 0 ] && [ "$exit_code" -ne 143 ]; then
    echo "[smoke] service=$name reason=exit-code matched=$exit_code boot-line=$boot_line" >&2
    echo "--- log ($log_file) ---" >&2
    cat "$log_file" >&2
    return 1
  fi

  # Assert every startup-self-test the manager would log on a real boot fired
  # here. The fixture-from-real-templates change above is what makes these
  # gates non-trivial — without the per-account specialists/IDENTITY copies,
  # the drift gates would log =ok against an empty surface.
  local required_tests=(
    "startup-self-test specialist-tool-drift=ok"
    "startup-self-test system-prompt-sentinels=ok"
    "startup-self-test identity-drift=ok"
  )
  for test_line in "${required_tests[@]}"; do
    if ! grep -qF "$test_line" "$log_file"; then
      echo "[smoke] service=$name reason=startup-self-test-missing expected=\"$test_line\"" >&2
      echo "--- log ($log_file) ---" >&2
      cat "$log_file" >&2
      return 1
    fi
  done

  echo "[smoke-boot] service=$name specialist-tool-drift=ok system-prompt-sentinels=ok identity-drift=ok boot=ok elapsed-ms=$elapsed_ms"
  return 0
}

# Apply schema.cypher to the dev's local Neo4j via cypher-shell. Catches
# Neo4j 4/5 syntax drift (e.g. 0.1.151's `DROP FULLTEXT INDEX`) the same way
# a real `seed-neo4j.sh` apply would. The dev's database is the test surface;
# schema commands use IF NOT EXISTS / IF EXISTS so re-runs are idempotent.
#
# NEO4J_URI + password source + cypher-shell on PATH = the schema is applied
# against a live Neo4j and any syntax regression fails the publish. Otherwise
# the check is skipped with an explicit log line; the maintainer opts back in
# by exporting the vars pointing at a disposable Neo4j (docker, dedicated
# brew instance, etc.). SMOKE_CYPHER_SCHEMA=required forces the strict
# behaviour so CI can insist even when the local Neo4j has been repurposed.
#
# Rationale for the skip: once the laptop's brew Neo4j is used by a local
# maxy-code install, its auth password is the operator's account credential.
# Resetting it every publish would corrupt the install. The check is best
# run against a disposable instance; when one isn't available, skipping
# beats bricking the maintainer's own device.
run_cypher_schema() {
  local schema_path="$REPO_ROOT/platform/neo4j/schema.cypher"
  if [ ! -f "$schema_path" ]; then
    echo "[smoke-boot] cypher-schema=fail err=schema-missing path=$schema_path" >&2
    return 1
  fi

  local required="${SMOKE_CYPHER_SCHEMA:-optional}"

  skip_msg() { # $1=reason
    if [ "$required" = "required" ]; then
      echo "[smoke-boot] cypher-schema=fail err=$1 (SMOKE_CYPHER_SCHEMA=required)" >&2
      return 1
    fi
    echo "[smoke-boot] cypher-schema=skip reason=$1 (export SMOKE_CYPHER_SCHEMA=required to fail hard; provision a disposable Neo4j to run the check)"
    return 0
  }

  if ! command -v cypher-shell >/dev/null 2>&1; then
    skip_msg cypher-shell-not-on-path; return $?
  fi

  if [ -z "${NEO4J_URI:-}" ]; then
    skip_msg neo4j-uri-unset; return $?
  fi

  local neo4j_user="${NEO4J_USER:-neo4j}"
  local neo4j_password="${NEO4J_PASSWORD:-}"
  if [ -z "$neo4j_password" ]; then
    # Fall back to the same password-file convention seed-neo4j.sh uses.
    local pw_file="$REPO_ROOT/platform/config/.neo4j-password"
    if [ -f "$pw_file" ]; then
      neo4j_password="$(cat "$pw_file")"
    fi
  fi
  if [ -z "$neo4j_password" ]; then
    skip_msg neo4j-password-unset; return $?
  fi

  # Live probe: if the credentials don't authenticate, the check would fail
  # for reasons unrelated to schema syntax (rotated password, etc.). Skip
  # rather than fail so the maintainer's own install's Neo4j — whose password
  # they own — can't block publish.
  if ! cypher-shell -u "$neo4j_user" -p "$neo4j_password" -a "$NEO4J_URI" \
      --non-interactive "RETURN 1" >/dev/null 2>&1; then
    skip_msg neo4j-auth-failed; return $?
  fi

  local log_file="$TMP_ROOT/cypher-schema.log"
  local statement_count
  # Count CREATE/DROP/CALL statement-starts. Approximate but stable signal
  # for the log line; the real authority is cypher-shell's exit code.
  statement_count="$(grep -cE '^(CREATE|DROP|CALL)\b' "$schema_path" || true)"

  if ! cypher-shell -u "$neo4j_user" -p "$neo4j_password" -a "$NEO4J_URI" \
      --non-interactive --fail-fast --format plain < "$schema_path" \
      > "$log_file" 2>&1; then
    local first_err
    first_err="$(grep -m1 -E '^(Invalid|Error|Neo\.)' "$log_file" || head -1 "$log_file" || true)"
    echo "[smoke-boot] cypher-schema=fail err=${first_err:-unknown}" >&2
    echo "--- log ($log_file) ---" >&2
    cat "$log_file" >&2
    return 1
  fi

  echo "[smoke-boot] cypher-schema=ok statements=$statement_count uri=$NEO4J_URI"
  return 0
}

run_mcp_plugin() {
  local name="$1"
  local dist_entry="$2"
  local dist_path="$REPO_ROOT/$dist_entry"

  if [ ! -f "$dist_path" ]; then
    echo "[smoke] mcp=$name reason=dist-missing path=$dist_path" >&2
    return 1
  fi

  local log_file="$TMP_ROOT/mcp-$name.log"
  local start_ms; start_ms="$(python3 -c 'import time;print(int(time.time()*1000))')"

  # Empty env + closed stdin = the exact spawn shape CC's plugin-system
  # uses for the metadata-only tools/list enumeration. Module-init must
  # complete and the stdio transport must clean-exit on stdin EOF.
  env -i PATH="$PATH" HOME="$HOME" node "$dist_path" < /dev/null > "$log_file" 2>&1
  local exit_code=$?

  local end_ms; end_ms="$(python3 -c 'import time;print(int(time.time()*1000))')"
  local elapsed_ms=$((end_ms - start_ms))

  local missing_env_line
  missing_env_line="$(grep -m1 -E 'missing-port-env reason=missing-env' "$log_file" || true)"
  if [ -n "$missing_env_line" ]; then
    echo "[smoke] mcp=$name reason=missing-port-env-throw matched=$missing_env_line" >&2
    echo "--- log ($log_file) ---" >&2
    cat "$log_file" >&2
    return 1
  fi
  if [ "$exit_code" -ne 0 ]; then
    echo "[smoke] mcp=$name reason=exit-code matched=$exit_code" >&2
    echo "--- log ($log_file) ---" >&2
    cat "$log_file" >&2
    return 1
  fi

  echo "[smoke] mcp=$name ok elapsed-ms=$elapsed_ms"
  return 0
}

rc=0
for entry in "${SERVICES[@]}"; do
  name="${entry%%:*}"
  dist_entry="${entry#*:}"
  if ! run_one "$name" "$dist_entry"; then
    rc=1
  fi
done

for entry in "${MCP_PLUGINS[@]}"; do
  name="${entry%%:*}"
  dist_entry="${entry#*:}"
  if ! run_mcp_plugin "$name" "$dist_entry"; then
    rc=1
  fi
done

if ! run_cypher_schema; then
  rc=1
fi

exit "$rc"
