#!/usr/bin/env bash
# scripts/sync_to_nova.sh — manual-only "2nd release" of ~/acdl into ~/nova.
#
# ~/nova is a SEPARATE GitLab repo (jonathanchery/nova) with its own history,
# consumer/platform-team audience, and conventional commit standards. It is
# NOT a mirror of ~/acdl (the CIAgent audit trail lives only in ~/acdl).
#
# This script:
#   1. Refuses to run unless --release (or RELEASE_CONFIRMED=1) is set — it can
#      NEVER be triggered by CI or accidentally. This is a human-only gate.
#   2. rsyncs the CONSUMER SUBSET of ~/acdl into ~/nova (internal-only paths —
#      .ciagent, terraform, demo, internal scripts, runtime metrics — are
#      excluded; the destination's .git history is protected untouched).
#   3. Commits changes DOMAIN BY DOMAIN in a fixed order, using one
#      conventional-commit message per changed domain passed via repeated -m
#      flags. NO kitchen-sink "sync from source mirror" commit. Messages are
#      consumed positionally over the changed domains (in the order printed by
#      --list-domains / --dry-run).
#   4. Pushes the current branch to its upstream (unless --no-push).
#
# Usage:
#   bash scripts/sync_to_nova.sh --release -m "feat(core): add X" -m "docs(contracts): refresh Y"
#   bash scripts/sync_to_nova.sh --dry-run --release -m "chore(core): sync"
#   bash scripts/sync_to_nova.sh --list-domains
#   bash scripts/sync_to_nova.sh --no-push --release -m "fix(schemas): tighten validation"
#
#   SRC=~/acdl DST=~/nova bash scripts/sync_to_nova.sh --release -m "..."
#
# Domain order (first match wins; a domain with no staged changes is skipped
# and does NOT consume a -m message — messages map positionally over the
# CHANGED domains only):
#   1  config      README.md, pyproject.toml, requirements-test.txt, .gitignore
#   2  core        core/**
#   3  adapters    adapters/**
#   4  modules     modules/**
#   5  contracts   contracts/**
#   6  schemas     schemas/**
#   7  pipelines   pipelines/**
#   8  mcp         mcp/**
#   9  skills      skills/**
#   10 scripts     scripts/**   (consumer runbooks only; internal scripts excluded)
#   11 tests       tests/**
#   12 docs        docs/**
#   13 metrics    metrics/README.md, metrics/TRUST_SNAPSHOT.md, metrics/powerbi/**
#   14 workflows   .github/**, workflows-src/**
set -euo pipefail

SRC="${SRC:-$HOME/acdl}"
DST="${DST:-$HOME/nova}"
VERBOSE=0
NO_PUSH=0
DRY_RUN=0
RELEASE=0
LIST_DOMAINS=0
NO_VERIFY_FORMAT=0
COMMIT_MSGS=()

usage() { sed -n '2,40p' "$0"; }

while [ $# -gt 0 ]; do
  case "$1" in
    --release)            RELEASE=1; shift ;;
    RELEASE_CONFIRMED=1)  RELEASE=1; shift ;;
    -v|--verbose)         VERBOSE=1; shift ;;
    --no-push)            NO_PUSH=1; shift ;;
    --dry-run)            DRY_RUN=1; shift ;;
    --list-domains)       LIST_DOMAINS=1; shift ;;
    --no-verify-format)   NO_VERIFY_FORMAT=1; shift ;;
    -m|--message)         shift; [ $# -gt 0 ] || { echo "FAIL: -m requires a value" >&2; exit 1; }; COMMIT_MSGS+=("$1"); shift ;;
    -m=*)                 COMMIT_MSGS+=("${1#-m=}"); shift ;;
    --message=*)          COMMIT_MSGS+=("${1#--message=}"); shift ;;
    -h|--help)            usage; exit 0 ;;
    *) echo "FAIL: unknown argument: $1" >&2; exit 1 ;;
  esac
done

# --- domains (ordered; first match wins) ------------------------------------
#
# Each entry: "<name>|<pathspec1 pathspec2 ...>". Pathspecs are relative to
# DST and use git pathspec semantics. The order here IS the commit order and
# the order messages are consumed in.
DOMAINS=(
  "config|README.md pyproject.toml requirements-test.txt .gitignore"
  "core|core"
  "adapters|adapters"
  "modules|modules"
  "contracts|contracts"
  "schemas|schemas"
  "pipelines|pipelines"
  "mcp|mcp"
  "skills|skills"
  "scripts|scripts"
  "tests|tests"
  "docs|docs"
  "metrics|metrics/README.md metrics/TRUST_SNAPSHOT.md metrics/powerbi"
  "workflows|.github workflows-src"
)

# Internal-only scripts that must NEVER be synced to ~/nova. These are
# CIAgent/ops/release plumbing that only makes sense in ~/acdl. Anything in
# scripts/ NOT in this list is a consumer-facing runbook and IS synced.
EXCLUDE_SCRIPTS=(
  sync_to_gl.sh
  sync_to_nova.sh
  ship_phase.sh
  update_atelier_vendor.sh
  post_stage_comment.sh
  rotate_spike_key.sh
  run_l2_lifecycle_destroy.sh
  run_lifecycle_destroy.sh
  run_lifecycle_test.sh
  migrate_dynamodb_data.py
  migrate_ssm_paths.py
  untag_acdl_keys.py
  seed_uptime_monitors.py
  push_consumer_image.py
  sync_workflows.py
  attach_release_asset.py
  check_north_star_diff.sh
  render_slides.sh
)

CONV_RE='^(feat|fix|docs|chore|refactor|perf|test|build|ci|style|revert)(\([^)]+\))?: .+'

# --- helpers ---------------------------------------------------------------

fail() { echo "FAIL: $*" >&2; exit 1; }

# Print the domain list (name | paths) for --list-domains.
print_domains() {
  printf '%-12s %s\n' "DOMAIN" "PATHS"
  for entry in "${DOMAINS[@]}"; do
    name="${entry%%|*}"; paths="${entry#*|}"
    printf '%-12s %s\n' "$name" "$paths"
  done
}

# --- --list-domains (no side effects, no gate) ------------------------------

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

# --- manual-only gate ------------------------------------------------------

if [ "$RELEASE" = "0" ]; then
  echo "sync_to_nova: this is a MANUAL-ONLY 2nd release into ~/nova (separate repo," >&2
  echo "separate history, consumer-facing). It is never triggered by CI." >&2
  echo "" >&2
  echo "To confirm intent, re-run with --release (or set RELEASE_CONFIRMED=1):" >&2
  echo "  bash scripts/sync_to_nova.sh --release -m \"<conventional commit>\" [-m ...]" >&2
  echo "" >&2
  echo "Use --list-domains to see the domain order, or --dry-run --release to preview." >&2
  exit 2
fi

# --- sanity checks ---------------------------------------------------------

[ -d "$SRC" ] || fail "source not found: $SRC"
[ -d "$DST" ] || fail "destination not found: $DST (create it first)"
[ -d "$DST/.git" ] || fail "destination has no .git: $DST/.git (restore it first)"

# Refuse if DST is not inside $HOME or is the same as SRC.
case "$DST" in
  "$HOME"/*) : ;;
  *) fail "destination must live under \$HOME (got $DST)" ;;
esac
[ "$SRC" != "$DST" ] || fail "source and destination are identical"

# Refuse to run inside a merge/rebase/conflict state in DST.
git_dir_state() {
  local f
  for f in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD BISECT_LOG; do
    [ -e "$DST/.git/$f" ] && return 1
  done
  [ -d "$DST/.git/rebase-merge" ] || [ -d "$DST/.git/rebase-apply" ] && return 1
  return 0
}
git_dir_state || fail "destination .git is mid-operation (merge/rebase/etc); resolve it then re-run"

echo "=== sync_to_nova (manual 2nd release) ==="
echo "source:      $SRC"
echo "destination: $DST"
[ "$VERBOSE"     = "1" ] && echo "mode:        verbose"
[ "$NO_PUSH"     = "1" ] && echo "mode:        sync + commit (no push)"
[ "$DRY_RUN"     = "1" ] && echo "mode:        dry-run (no changes made)"
echo ""

# --- rsync (consumer subset) -----------------------------------------------
#
# Strategy: explicit excludes for everything internal-only, then a protected
# .git filter, then .gitignore dir-merge semantics so consumer-visible ignored
# files (pyc, .env, etc.) are also dropped. --delete prunes extras in the
# synced tree so removals in ~/acdl propagate to ~/nova. --delete-excluded is
# NOT used so the protected .git survives.

# Hidden dirs/files in SRC that are NOT consumer-facing. .github is kept.
EXCLUDES=(
  --exclude=/.ciagent
  --exclude=/.gitea
  --exclude=/.env
  --exclude=/.env.secrets
  --exclude=/.coverage
  --exclude=/.pytest_cache
  --exclude=/.git
  --exclude=/terraform
  --exclude=/demo
)

# Runtime metrics artifacts (keep README.md, powerbi/, TRUST_SNAPSHOT.md).
EXCLUDES+=(
  --exclude=/metrics/nova_metrics.db
  --exclude=/metrics/decision_ledger.db
  --exclude=/metrics/events.jsonl
  --exclude=/metrics/test-results.xml
  --exclude=/metrics/test-report.json
  --exclude=/metrics/coverage.json
  --exclude=/metrics/runs
  --exclude=/metrics/lifecycle
)

# Internal-only scripts (by basename).
for s in "${EXCLUDE_SCRIPTS[@]}"; do
  EXCLUDES+=("--exclude=/scripts/$s")
done

# Universal noise.
EXCLUDES+=(
  --exclude=**/__pycache__
  --exclude=**/*.pyc
  --exclude=**/*.pyo
  --exclude=**/.DS_Store
)

# .git protection (P = protect from --delete) + per-directory .gitignore merge.
# Each --filter is a single token: "<rule> <pattern>".
FILTERS=(
  "--filter=P .git"
  "--filter=:- .gitignore"
)

RSYNC_ARGS=(-a --delete)
[ "$VERBOSE" = "1" ] && RSYNC_ARGS+=(-v)

echo "rsync excludes: ${EXCLUDES[*]}"
echo "rsync filters:  ${FILTERS[*]}"
echo ""

if [ "$DRY_RUN" = "1" ]; then
  echo "[dry-run] rsync would run:"
  printf '  %q ' rsync "${RSYNC_ARGS[@]}" "${FILTERS[@]}" "${EXCLUDES[@]}" "$SRC/" "$DST/"; echo
else
  rsync "${RSYNC_ARGS[@]}" "${FILTERS[@]}" "${EXCLUDES[@]}" "$SRC/" "$DST/"
  echo "rsync: OK"
fi
echo ""

# --- domain-based commits ---------------------------------------------------

cd "$DST"

branch="$(git symbolic-ref --quiet --short HEAD 2>/dev/null || true)"
[ -n "$branch" ] || fail "HEAD is detached; checkout a branch first (got $(git rev-parse --short HEAD))"

# Stage everything (including deletions) so we can diff per-domain.
# In dry-run we do NOT run rsync (nothing is written), so per-domain change
# detection against the destination is meaningless — dry-run is a preview
# only (rsync command + message validation). Domain mapping is computed in
# real mode below.
if [ "$DRY_RUN" = "0" ]; then
  git add -A
fi

# Determine which domains have changes (positional, in DOMAIN order).
changed_names=()
changed_pathspecs=()
if [ "$DRY_RUN" = "1" ]; then
  # Preview: print the fixed domain order so the user can line up -m messages.
  echo "domain order (messages map positionally over CHANGED domains only):"
  for entry in "${DOMAINS[@]}"; do
    name="${entry%%|*}"; paths="${entry#*|}"
    printf '  %-12s %s\n' "$name" "$paths"
  done
  echo ""
  echo "provided -m messages (${#COMMIT_MSGS[@]}):"
  for i in "${!COMMIT_MSGS[@]}"; do
    printf '  %2d  %s\n' "$((i+1))" "${COMMIT_MSGS[$i]}"
  done
  echo ""
  echo "(dry-run: rsync not run — actual changed-domain detection happens in real mode;"
  echo " re-run without --dry-run, or with --no-push to commit without pushing.)"
else
  for entry in "${DOMAINS[@]}"; do
    name="${entry%%|*}"; paths="${entry#*|}"
    # shellcheck disable=SC2086
    if ! git diff --cached --quiet -- $paths 2>/dev/null; then
      changed_names+=("$name"); changed_pathspecs+=("$paths")
    fi
  done

  n_changed=${#changed_names[@]}
  echo "changed domains (in commit order):"
  if [ "$n_changed" = "0" ]; then
    echo "  (none)"
  else
    for i in "${!changed_names[@]}"; do
      printf '  %2d  %-12s %s\n' "$((i+1))" "${changed_names[$i]}" "${changed_pathspecs[$i]}"
    done
  fi
  echo ""
fi

# Validate -m count matches changed-domain count (real mode only).
n_msgs=${#COMMIT_MSGS[@]}
if [ "$DRY_RUN" = "1" ]; then
  # In dry-run we can't know how many domains will change, so we only
  # validate conventional-commit format. Count check happens in real mode.
  if [ "$NO_VERIFY_FORMAT" = "0" ]; then
    for i in "${!COMMIT_MSGS[@]}"; do
      msg="${COMMIT_MSGS[$i]}"
      if ! [[ "$msg" =~ $CONV_RE ]]; then
        echo "FAIL: message $((i+1)) is not a conventional commit:" >&2
        echo "      \"$msg\"" >&2
        echo "      expected: <type>[optional(scope)]: <subject>" >&2
        echo "      types: feat|fix|docs|chore|refactor|perf|test|build|ci|style|revert" >&2
        echo "      (use --no-verify-format to skip this check)" >&2
        exit 1
      fi
    done
  fi
  echo "git: (dry-run) no commits made"
else
  if [ "$n_changed" = "0" ]; then
    echo "git: no changes to commit on branch '$branch'"
  else
    if [ "$n_msgs" -ne "$n_changed" ]; then
      echo "FAIL: $n_changed domain(s) changed but $n_msgs -m message(s) provided." >&2
      echo "      Messages map POSITIONALLY to the changed domains above (in order)." >&2
      echo "      Re-run with exactly $n_changed -m flag(s), or --list-domains to" >&2
      echo "      see the order, or --dry-run to preview." >&2
      exit 1
    fi

    # Validate conventional-commit format (unless --no-verify-format).
    if [ "$NO_VERIFY_FORMAT" = "0" ]; then
      for i in "${!COMMIT_MSGS[@]}"; do
        msg="${COMMIT_MSGS[$i]}"
        if ! [[ "$msg" =~ $CONV_RE ]]; then
          echo "FAIL: message $((i+1)) is not a conventional commit:" >&2
          echo "      \"$msg\"" >&2
          echo "      expected: <type>[optional(scope)]: <subject>" >&2
          echo "      types: feat|fix|docs|chore|refactor|perf|test|build|ci|style|revert" >&2
          echo "      (use --no-verify-format to skip this check)" >&2
          exit 1
        fi
      done
    fi

    # Commit per domain in order.
    for i in "${!changed_names[@]}"; do
      name="${changed_names[$i]}"
      paths="${changed_pathspecs[$i]}"
      msg="${COMMIT_MSGS[$i]}"
      echo "git: committing domain '$name' on branch '$branch'"
      [ "$VERBOSE" = "1" ] && { git diff --cached --stat -- $paths 2>/dev/null || true; }
      git reset HEAD -- . >/dev/null 2>&1 || true
      # shellcheck disable=SC2086
      git add -- $paths
      git commit -m "$msg" >/dev/null
    done
  fi
fi

# --- push ------------------------------------------------------------------

if [ "$NO_PUSH" = "1" ]; then
  echo "git: --no-push set, skipping push"
else
  upstream="$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || true)"
  if [ -z "$upstream" ]; then
    fail "no upstream configured for branch '$branch'; set one with: git -C $DST branch --set-upstream-to=origin/$branch $branch"
  fi
  if [ "$DRY_RUN" = "1" ]; then
    echo "  [dry-run] git push to $upstream"
  else
    echo "git: pushing '$branch' to $upstream"
    git push
    echo "git: push OK"
  fi
fi

echo ""
echo "=== sync_to_nova OK ==="
echo "copied (consumer subset) $SRC -> $DST"
[ "${n_changed:-0}" -gt 0 ] && echo "$n_changed domain commit(s) on branch '$branch'"
[ "$DRY_RUN" = "1" ] && echo "(dry-run: nothing actually written, committed, or pushed)"
[ "$NO_PUSH" = "1" ] && echo "(no-push: changes committed but not pushed)"
exit 0