#!/usr/bin/env bash
# -E (errtrace): the ERR trap must propagate INTO functions and command
# substitutions. Without it the `trap restore_snapshot ERR` set below is dead
# code for any failure inside sync_framework_keep() (its whole body runs in a
# function) — a mid-sync failure would abort with a half-written target and NO
# rollback (#791 B1). Keep -E first so every later function inherits the trap.
set -Eeuo pipefail

# ─── Mosaic Framework Installer ──────────────────────────────────────────────
#
# Installs/upgrades the framework DATA to ~/.config/mosaic/.
# No executables are placed on PATH — the mosaic npm CLI is the only binary.
#
# Called by tools/install.sh (the unified installer). Can also be run directly.
#
# Environment:
#   MOSAIC_HOME              — target directory (default: ~/.config/mosaic)
#   MOSAIC_INSTALL_MODE      — prompt|keep|overwrite (default: prompt)
#   MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING — 1 to bypass MCP check
#   MOSAIC_SKIP_SKILLS_SYNC  — 1 to skip skill sync
#
# Flags (CLI args, NOT environment variables — see #869 Point-1 C2):
#   --allow-inactive-enforcement  Explicit, per-invocation opt-out that lets the
#                                 lease-enforcement hooks (mutator-gate.py,
#                                 receipt-observer-client.py) be wired into
#                                 ~/.claude/settings.json even when this host
#                                 cannot confirm it can ACTIVATE them. Loud on
#                                 use (see mosaic-link-runtime-assets). Default
#                                 (flag absent) is fail-loud: the enforcement
#                                 hooks are NOT wired and the framework's
#                                 runtime-asset-link step reports a failure.
# ──────────────────────────────────────────────────────────────────────────────

SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TARGET_DIR="${MOSAIC_HOME:-$HOME/.config/mosaic}"
INSTALL_MODE="${MOSAIC_INSTALL_MODE:-prompt}"

# Deliberately parsed from "$@" (a real, explicit, per-invocation argument) —
# never an environment variable — so this opt-out can never sit silently
# inherited in a shell profile. See #869 Point-1 C2.
ALLOW_INACTIVE_ENFORCEMENT=0
# Component-scoped install (#892 W7): `install.sh --component <name>` runs an
# additive, self-contained component installer and EXITS — it never enters the
# full-framework sync below and never modifies framework-manifest ownership
# behavior (#869: the diff is ADDITIVE). Parsed as a two-token flag here.
COMPONENT=""
_prev_arg=""
for _arg in "$@"; do
  case "$_arg" in
    --allow-inactive-enforcement) ALLOW_INACTIVE_ENFORCEMENT=1 ;;
    --component=*) COMPONENT="${_arg#--component=}" ;;
  esac
  [[ "$_prev_arg" == "--component" ]] && COMPONENT="$_arg"
  _prev_arg="$_arg"
done

# Shared framework path-ownership manifest reader (#791). Parity with
# packages/mosaic/src/framework/manifest.ts — both consume framework-manifest.txt.
# Sourcing does not run its CLI dispatch (guarded by BASH_SOURCE==$0).
# shellcheck source=tools/_lib/manifest.sh
source "$SOURCE_DIR/tools/_lib/manifest.sh"

# Which paths a keep-mode upgrade may touch is no longer a hand-maintained
# denylist. It is derived from the shared framework-manifest.txt (#791): the
# updater only ever creates/overwrites framework-owned paths and only prunes a
# retired framework file inside a shipped framework subtree. Everything else —
# every operator file, and every path the manifest never anticipated — is
# operator-owned by default (fail-safe) and is never written or deleted. See
# sync_framework_keep() below and packages/mosaic/src/framework/manifest.ts.

# Framework-owned contract files: re-copied from defaults/ on every upgrade (the
# user must not edit them; a divergent copy is backed up once before overwrite).
# USER_SEEDED files are written once on first install, then owned by the user.
# Both lists are APPEND-FRIENDLY — add a new shipped framework file here and to the
# matching list in packages/mosaic/src/config/file-adapter.ts.
FRAMEWORK_OWNED=("CONSTITUTION.md" "AGENTS.md" "STANDARDS.md")
USER_SEEDED=("TOOLS.md")

# Current framework schema version — bump this when the layout changes.
# The migration system uses this to run upgrade steps.
FRAMEWORK_VERSION=3

# ─── colours ──────────────────────────────────────────────────────────────────
if [[ -t 1 ]]; then
  GREEN='\033[0;32m' YELLOW='\033[0;33m' RED='\033[0;31m'
  CYAN='\033[0;36m' BOLD='\033[1m' RESET='\033[0m'
else
  GREEN='' YELLOW='' RED='' CYAN='' BOLD='' RESET=''
fi

ok()   { echo -e "  ${GREEN}✓${RESET} $1"; }
warn() { echo -e "  ${YELLOW}⚠${RESET} $1" >&2; }
fail() { echo -e "  ${RED}✗${RESET} $1" >&2; }
step() { echo -e "\n${BOLD}$1${RESET}"; }

# ─── snapshot / restore (crash safety for upgrades) ──────────────────────────
SNAPSHOT_DIR=""
make_snapshot() {
  is_existing_install || return 0
  # mktemp -d creates the dir 0700 — the snapshot (which mirrors operator config,
  # possibly including secrets) is never world-readable.
  SNAPSHOT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/mosaic-snapshot-XXXXXX")"
  # The snapshot MUST be complete: restore rebuilds the target from it, so a
  # partial capture (unreadable file, disk-full, I/O error) would silently
  # discard whatever it missed. If cp -a cannot copy the whole tree, abort NOW —
  # before the restore trap is armed and before anything is mutated. Fail closed
  # rather than proceed with a snapshot we cannot trust (#791 blocker-2).
  if ! cp -a "$TARGET_DIR/." "$SNAPSHOT_DIR/"; then
    fail "Could not capture a complete pre-upgrade snapshot of $TARGET_DIR — aborting before any changes were made (fail-closed)."
    rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""
    exit 1
  fi
}
restore_snapshot() {
  # Disarm the trap first: restore runs under `set -e`, and a non-zero step
  # inside it must not re-enter this handler (errtrace makes ERR fire in
  # functions now). One restore attempt, then let the script exit non-zero.
  trap - ERR INT TERM
  [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] || return 0
  fail "Install interrupted/failed — restoring previous state from snapshot"
  # Reset the target before rebuilding from the snapshot — but CHECK it. Under
  # `set -e` (trap already disarmed) a bare `rm -rf; mkdir -p` that fails would
  # exit the whole script immediately, after `rm` may have deleted part of the
  # target, WITHOUT ever printing the recovery pointer below — the operator would
  # be left with a half-removed target and no idea the snapshot survives in /tmp.
  # Test the reset explicitly (like the cp -a below), and on failure keep the
  # snapshot and tell the operator where it is (#791 blocker-D2).
  if ! rm -rf "$TARGET_DIR" || ! mkdir -p "$TARGET_DIR"; then
    fail "Snapshot restore could not reset $TARGET_DIR. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually."
    return 1
  fi
  # Surface an incomplete restore instead of swallowing it: the snapshot is the
  # last good copy, so if cp cannot fully rebuild the target we must NOT delete
  # the snapshot — point the operator at it for manual recovery (#791 blocker-2).
  if ! cp -a "$SNAPSHOT_DIR/." "$TARGET_DIR/"; then
    fail "Snapshot restore did not complete cleanly. Your previous configuration is preserved at: $SNAPSHOT_DIR — copy it back into $TARGET_DIR manually."
    return 1
  fi
}
cleanup_snapshot() { [[ -n "$SNAPSHOT_DIR" && -d "$SNAPSHOT_DIR" ]] && rm -rf "$SNAPSHOT_DIR"; SNAPSHOT_DIR=""; }

# ─── durable operator-config snapshot (#791 PR2) ─────────────────────────────
# A SECOND, independent safety layer, distinct from SNAPSHOT_DIR above:
#   • SNAPSHOT_DIR is ephemeral (/tmp, deleted on success) and mirrors the WHOLE
#     target for CRASH rollback if the sync aborts mid-write.
#   • DURABLE_SNAPSHOT_DIR is RETAINED, holds only the operator-owned surface, and
#     lives OUTSIDE the framework tree and any repo. It exists for the failure the
#     crash-rollback cannot see: a sync that finishes "successfully" yet a
#     manifest/logic bug let it modify an operator file. verify_operator_surface()
#     (post-sync) heals from it; `mosaic restore` recovers from it days later.
# Path convention is mirrored in packages/mosaic/src/commands/restore.ts — keep
# the two in sync (there is no shared code across the bash/TS boundary).
DURABLE_SNAPSHOT_DIR=""
backup_root() { printf '%s/mosaic/backups' "${XDG_STATE_HOME:-$HOME/.local/state}"; }

# Relative paths that a migration INTENTIONALLY removes from the target (e.g. the
# legacy bin/ tree). Such a path is operator-classified by the manifest (unknown⇒
# operator), so the durable snapshot captures it — but its post-migration absence
# is correct, NOT a manifest bug. run_migrations() records each removal here so
# verify_operator_surface() does not "heal" it back and silently undo the
# migration (which would then be skipped forever once the version is stamped).
MIGRATION_REMOVED_PATHS=()

# True (0) if $1 (a path relative to TARGET_DIR) equals or lives under a path a
# migration deliberately removed this run.
is_migration_removed() {
  local rel="$1" removed
  for removed in ${MIGRATION_REMOVED_PATHS[@]+"${MIGRATION_REMOVED_PATHS[@]}"}; do
    [[ -n "$removed" ]] || continue
    [[ "$rel" == "$removed" || "$rel" == "$removed"/* ]] && return 0
  done
  return 1
}

# True (0) if any parent directory of $1 (relative to TARGET_DIR) is a symlink.
# Restoring THROUGH a symlinked ancestor would let cp write snapshot contents —
# possibly secrets — outside the target (CWE-59), so the verify net refuses it.
has_symlinked_parent() {
  local rel="$1" dir p seg
  dir="$(dirname "$rel")"
  [[ "$dir" == "." ]] && return 1
  p="$TARGET_DIR"
  local IFS='/'
  for seg in $dir; do
    [[ -n "$seg" ]] || continue
    p="$p/$seg"
    [[ -L "$p" ]] && return 0
  done
  return 1
}

# Emit (NUL-delimited, into file $1) the operator-owned relative paths that exist
# under TARGET_DIR, classified via the shared manifest (deny-wins; unknown⇒
# operator). Returns non-zero if the filesystem walk itself failed — we must
# NEVER snapshot from a truncated scan (a `< <(find …)` process substitution
# would hide that error; capture-then-check does not — cf. #791 blocker-D1).
enumerate_operator_files() {
  local out="$1" scan abs rel
  scan="$(mktemp)"
  if ! find "$TARGET_DIR" -type f -print0 > "$scan"; then
    rm -f "$scan"
    return 1  # OP-SCAN-GUARD
  fi
  : > "$out"
  while IFS= read -r -d '' abs; do
    rel="${abs#"$TARGET_DIR"/}"
    # Not operator config: version marker and any VCS metadata.
    case "$rel" in .framework-version|.git|.git/*) continue ;; esac
    manifest_is_framework "$rel" || printf '%s\0' "$rel" >> "$out"
  done < "$scan"
  rm -f "$scan"
}

# Retain only the newest MOSAIC_BACKUP_RETENTION (default 5) snapshots. The
# pre-update-<UTC-ts> names sort lexicographically = chronologically, so a
# reverse sort is newest-first. Pruning failures are non-fatal (they only leave
# extra old backups); the enclosing find's status is still honored, not swallowed.
prune_durable_snapshots() {
  local root keep list d i=0
  root="$(backup_root)"
  keep="${MOSAIC_BACKUP_RETENTION:-5}"
  [[ "$keep" =~ ^[0-9]+$ ]] && (( keep >= 1 )) || keep=5
  list="$(mktemp)"
  if ! find "$root" -maxdepth 1 -type d -name 'pre-update-*' > "$list"; then
    rm -f "$list"; return 0
  fi
  # Newest-first ordering needs `sort` (`-o` writes back in place — no `mv`
  # dependency); if it is somehow unavailable, leave the backups untouched rather
  # than risk pruning in an undefined order.
  if ! LC_ALL=C sort -r -o "$list" "$list" 2>/dev/null; then
    rm -f "$list"; return 0
  fi
  while IFS= read -r d; do
    [[ -n "$d" ]] || continue
    i=$((i + 1))
    (( i > keep )) && rm -rf "$d"
  done < "$list"
  rm -f "$list"
}

# Take the durable pre-update snapshot BEFORE any mutation. Fail-OPEN: the durable
# snapshot is a recovery bonus on top of the manifest (which already keeps the
# sync out of operator paths) and the crash-rollback — so an un-writable backup
# location warns and continues rather than blocking the upgrade. Everything it
# creates is private (umask 077 + explicit 0700 dirs / 0600 files): the snapshot
# mirrors operator config, which may hold secrets, and must never be world-readable.
make_durable_snapshot() {
  is_existing_install || return 0
  local root ts dir list rel src dst count=0 old_umask
  root="$(backup_root)"
  # Fail-open if we cannot even stamp a timestamp: the durable snapshot is a
  # recovery bonus and must never be the thing that aborts an upgrade.
  ts="$(date -u +%Y%m%dT%H%M%SZ 2>/dev/null || true)"
  if [[ -z "$ts" ]]; then
    warn "Durable snapshot skipped: no UTC timestamp available (upgrade continues)."
    return 0
  fi
  # umask 077 makes every dir/file the snapshot creates private from birth (it
  # mirrors operator config, which may hold secrets). It is PROCESS-global, so we
  # save and restore it around exactly this block — otherwise every later sync
  # copy and new framework dir would inherit 0600/0700 instead of 0644/0755.
  old_umask="$(umask)"
  umask 077
  if ! mkdir -p "$root"; then
    umask "$old_umask"
    warn "Durable snapshot skipped: cannot create backup dir $root (upgrade continues; operator files remain manifest-protected)."
    return 0
  fi
  chmod 700 "$root" 2>/dev/null || true
  dir="$root/pre-update-$ts"
  if [[ -e "$dir" ]]; then           # same-second re-run: disambiguate
    local n=1; while [[ -e "$dir-$n" ]]; do n=$((n + 1)); done; dir="$dir-$n"
  fi
  if ! mkdir -p "$dir"; then
    umask "$old_umask"
    warn "Durable snapshot skipped: cannot create $dir (upgrade continues)."
    return 0
  fi
  chmod 700 "$dir"
  list="$(mktemp)"
  if ! enumerate_operator_files "$list"; then
    umask "$old_umask"
    warn "Durable snapshot skipped: could not enumerate operator files (upgrade continues)."
    rm -f "$list"; rmdir "$dir" 2>/dev/null || true
    return 0
  fi
  while IFS= read -r -d '' rel; do
    src="$TARGET_DIR/$rel"; dst="$dir/$rel"
    [[ -f "$src" ]] || continue
    mkdir -p "$(dirname "$dst")"
    if ! cp "$src" "$dst"; then
      warn "Durable snapshot: could not copy operator file '$rel' (skipped)."
      continue
    fi
    chmod 600 "$dst" 2>/dev/null || true
    count=$((count + 1))
  done < "$list"
  rm -f "$list"
  # Tighten every dir the copy created (mkdir -p honors umask, but be explicit).
  find "$dir" -type d -exec chmod 700 {} + 2>/dev/null || true
  umask "$old_umask"   # UMASK-RESTORE-NORMAL — restore before the upgrade proper resumes (see above)
  DURABLE_SNAPSHOT_DIR="$dir"
  ok "Durable pre-update snapshot: $count operator file(s) saved to $dir (recover with: mosaic restore --list)"
  prune_durable_snapshots
}

# Post-sync safety net: a keep-mode upgrade must NEVER modify an operator file.
# Compare every file in the durable snapshot to its current target counterpart;
# any that changed (or vanished) was touched by a framework bug — restore it from
# the snapshot and warn loudly. This does NOT abort: the framework itself synced
# correctly; we only heal the operator collateral. Runs after the restore trap is
# disarmed so its corrective copies can't spuriously trip a full rollback, and
# every step is guarded so `set -e` cannot exit silently mid-heal (cf. blocker-D2).
verify_operator_surface() {
  [[ -n "$DURABLE_SNAPSHOT_DIR" && -d "$DURABLE_SNAPSHOT_DIR" ]] || return 0
  local scan snap rel cur healed=0
  scan="$(mktemp)"
  if ! find "$DURABLE_SNAPSHOT_DIR" -type f -print0 > "$scan"; then
    rm -f "$scan"
    warn "Post-upgrade verify skipped: could not enumerate the pre-update snapshot at $DURABLE_SNAPSHOT_DIR."
    return 0
  fi
  while IFS= read -r -d '' snap; do
    rel="${snap#"$DURABLE_SNAPSHOT_DIR"/}"
    cur="$TARGET_DIR/$rel"
    # A migration may legitimately delete an operator-classified path (e.g. legacy
    # bin/). Its absence is intended — do not heal it back, or the migration is
    # silently undone and never re-runs once the version is stamped (#791 PR2).
    is_migration_removed "$rel" && continue   # MIGRATION-SKIP-GUARD
    if [[ ! -e "$cur" ]] || ! cmp -s "$snap" "$cur"; then
      # Never restore THROUGH a symlink: an operator path swapped for a link would
      # otherwise let cp write snapshot contents (possibly secrets) outside the
      # target (CWE-59). Refuse a symlinked parent; drop a symlinked leaf and write
      # a real file in its place.
      if has_symlinked_parent "$rel"; then
        warn "Operator path '$rel' has a symlinked parent under $TARGET_DIR; refusing to restore through it (possible tampering) — recover it manually from $DURABLE_SNAPSHOT_DIR."
        continue
      fi
      [[ -L "$cur" ]] && rm -f "$cur"   # SYMLINK-LEAF-GUARD
      # Guard mkdir too: under set -e (trap already disarmed) a bare failure would
      # exit the whole installer before the recovery pointer below is emitted.
      if ! mkdir -p "$(dirname "$cur")"; then
        warn "Operator file '$rel' was modified by the upgrade but could NOT be auto-restored (parent dir unavailable) — recover it manually from $DURABLE_SNAPSHOT_DIR."
        continue
      fi
      if cp "$snap" "$cur"; then
        chmod 600 "$cur" 2>/dev/null || true
        warn "Operator file was modified by the upgrade and has been restored from the pre-update snapshot: $rel"
        healed=$((healed + 1))
      else
        warn "Operator file '$rel' was modified by the upgrade but could NOT be auto-restored — recover it manually from $DURABLE_SNAPSHOT_DIR."
      fi
    fi
  done < "$scan"
  rm -f "$scan"
  if (( healed > 0 )); then
    warn "$healed operator file(s) were unexpectedly changed by this upgrade and were restored from the pre-update snapshot. A keep-mode upgrade must never modify operator files — this indicates a framework manifest bug; please report it (#791)."
  fi
}

# Reconcile contract files after sync: framework-owned overwrite (backup-once),
# user-seeded seed-if-absent.
reconcile_framework_files() {
  local defaults="$TARGET_DIR/defaults" f
  [[ -d "$defaults" ]] || return 0
  for f in "${FRAMEWORK_OWNED[@]}"; do
    [[ -f "$defaults/$f" ]] || continue
    # Already current — skip to avoid mtime churn.
    if [[ -f "$TARGET_DIR/$f" ]] && cmp -s "$TARGET_DIR/$f" "$defaults/$f"; then
      continue
    fi
    if [[ -f "$TARGET_DIR/$f" && ! -f "$TARGET_DIR/${f}.pre-constitution.bak" ]]; then
      cp "$TARGET_DIR/$f" "$TARGET_DIR/${f}.pre-constitution.bak"
      warn "$f is now framework-owned and was updated; your previous copy is saved as ${f}.pre-constitution.bak — re-apply intended changes as a .local overlay or policy/ file (see CONSTITUTION.md / constitution/LAYER-MODEL.md)."
    fi
    cp "$defaults/$f" "$TARGET_DIR/$f"
  done
  for f in "${USER_SEEDED[@]}"; do
    [[ -f "$defaults/$f" ]] || continue
    if [[ ! -f "$TARGET_DIR/$f" ]]; then
      cp "$defaults/$f" "$TARGET_DIR/$f"
      ok "Seeded $f from defaults"
    fi
  done
}

# ─── helpers ──────────────────────────────────────────────────────────────────

is_existing_install() {
  [[ -d "$TARGET_DIR" ]] || return 1
  [[ -f "$TARGET_DIR/AGENTS.md" || -f "$TARGET_DIR/SOUL.md" ]]
}

installed_framework_version() {
  local vf="$TARGET_DIR/.framework-version"
  if [[ -f "$vf" ]]; then
    cat "$vf" 2>/dev/null || echo "0"
  else
    # No version file = legacy install (version 0 or 1)
    if [[ -d "$TARGET_DIR/bin" ]]; then
      echo "1"   # Has bin/ → pre-migration legacy
    else
      echo "0"   # Fresh or unknown
    fi
  fi
}

write_framework_version() {
  echo "$FRAMEWORK_VERSION" > "$TARGET_DIR/.framework-version"
}

select_install_mode() {
  case "$INSTALL_MODE" in
    keep|overwrite|prompt) ;;
    *)
      fail "Invalid MOSAIC_INSTALL_MODE='$INSTALL_MODE'. Use: prompt, keep, overwrite."
      exit 1
      ;;
  esac

  if ! is_existing_install; then
    INSTALL_MODE="overwrite"
    return
  fi

  case "$INSTALL_MODE" in
    keep|overwrite) ;;
    prompt)
      if [[ -t 0 ]]; then
        echo ""
        echo "Existing Mosaic install detected at: $TARGET_DIR"
        echo "  1) keep      Update framework, preserve local files (SOUL.md, USER.md, etc.)"
        echo "  2) overwrite Replace everything"
        echo "  3) cancel    Abort"
        printf "Selection [1/2/3] (default: 1): "
        read -r selection
        case "${selection:-1}" in
          1|k|K|keep) INSTALL_MODE="keep" ;;
          2|o|O|overwrite) INSTALL_MODE="overwrite" ;;
          *) fail "Install cancelled."; exit 1 ;;
        esac
      else
        INSTALL_MODE="keep"
      fi
      ;;
  esac
}

sync_framework() {
  local source_real target_real
  source_real="$(cd "$SOURCE_DIR" && pwd -P)"
  target_real="$(mkdir -p "$TARGET_DIR" && cd "$TARGET_DIR" && pwd -P)"

  if [[ "$source_real" == "$target_real" ]]; then
    warn "Source and target are the same directory; skipping file sync."
    return
  fi

  if [[ "$INSTALL_MODE" == "keep" ]]; then
    # The `mosaic update` path. Manifest-driven, never-deleting-outside-framework:
    # operator config is structurally protected (#791). No rsync --delete here.
    # The manifest is already loaded+validated in main() BEFORE the snapshot/trap
    # (a fail-closed manifest must abort without ever restoring over operator
    # files — see the pre-flight in main, #791 blocker-1).
    sync_framework_keep
    return
  fi

  # overwrite mode — a full replace, chosen only for a fresh install or when the
  # operator explicitly asks to replace everything. No operator state to protect.
  sync_framework_overwrite
}

# Enumerate a NUL-delimited file list via `find` into the temp file $1, failing
# CLOSED if find errors. We capture to a checked file instead of consuming
# `< <(find …)` directly because a process substitution discards the producer's
# exit status: an EACCES/I/O failure partway through a scan would truncate the
# list yet leave the reading `while` loop exiting 0, so a partial upgrade would
# commit and report success and the ERR/restore trap would never fire. Running
# find to completion first, then checking its status, turns that silent
# truncation into a fail-closed abort that the restore trap can act on (#791
# blocker-D1). $1 after the shift is the scan root — named in the error.
_scan_or_die() {
  local out="$1"; shift
  if ! find "$@" -print0 > "$out"; then
    fail "Could not enumerate framework files under '$1' — aborting before committing an incomplete sync (fail-closed)."
    return 1  # D1-GUARD
  fi
}

# Keep-mode sync: create/refresh framework-owned files and prune only retired
# framework files inside shipped framework subtrees. Operator-owned and unknown
# paths (fail-safe default) are never written and never deleted — the #791 HARD
# GATE. Single code path (no rsync) so it is byte-for-byte parity-testable.
sync_framework_keep() {
  local src="$SOURCE_DIR" dst="$TARGET_DIR" abs rel root list

  # 1) Overlay copy — every framework-owned source file, refreshed only when its
  #    bytes changed (no mtime churn on unchanged files, never on operator files).
  #    The source scan is captured fail-closed (#791 blocker-D1): a find failure
  #    aborts the sync (→ ERR trap → restore) rather than silently truncating it.
  list="$(mktemp)"
  _scan_or_die "$list" "$src" -type f || { rm -f "$list"; return 1; }
  while IFS= read -r -d '' abs; do
    rel="${abs#"$src"/}"
    case "$rel" in
      .git|.git/*|.framework-version|*.pre-constitution.bak) continue ;;
    esac
    manifest_is_framework "$rel" || continue
    if [[ -f "$dst/$rel" ]] && cmp -s "$abs" "$dst/$rel"; then continue; fi
    [[ "$rel" == */* ]] && mkdir -p "$dst/${rel%/*}"
    cp "$abs" "$dst/$rel"
  done < "$list"
  rm -f "$list"

  # 2) Scoped prune — within each shipped framework subtree root, remove
  #    framework-owned target files the current source no longer ships. Operator
  #    carve-outs (e.g. tools/_lib/credentials.json) resolve to operator and are
  #    skipped; unknown paths resolve to operator too — both are unreachable here.
  #    Each subtree scan is captured fail-closed for the same reason as the copy.
  while IFS= read -r root; do
    [[ -n "$root" && -d "$dst/$root" ]] || continue
    list="$(mktemp)"
    _scan_or_die "$list" "$dst/$root" -type f || { rm -f "$list"; return 1; }
    while IFS= read -r -d '' abs; do
      rel="${abs#"$dst"/}"
      case "$rel" in *.pre-constitution.bak) continue ;; esac
      [[ -f "$src/$rel" ]] && continue                          # still shipped
      manifest_is_framework "$rel" || continue
      rm -f "$abs"
    done < "$list"
    rm -f "$list"
    # Drop framework dirs left empty by the prune (never touches a dir that still
    # holds an operator file — those are never emptied). A genuine find failure
    # (unreadable dir) is surfaced as a warning rather than silently swallowed;
    # the "directory not empty" races we tolerate are ignored via -delete's own
    # rc, not by hiding stderr — so a real error is still visible to the operator.
    if ! find "$dst/$root" -type d -empty -delete 2>/dev/null; then
      warn "prune: could not fully sweep empty framework dirs under $root (left as-is)"
    fi
  done < <(manifest_subtree_roots)
}

# Overwrite-mode sync: full replace. Only reached for a fresh install or an
# explicit operator "replace everything" choice, so nothing is preserved.
sync_framework_overwrite() {
  if command -v rsync >/dev/null 2>&1; then
    rsync -a --delete \
      --exclude ".git" --exclude ".framework-version" --exclude "*.pre-constitution.bak" \
      "$SOURCE_DIR/" "$TARGET_DIR/"
    return
  fi
  find "$TARGET_DIR" -mindepth 1 -maxdepth 1 \
    ! -name ".git" ! -name ".framework-version" ! -name "*.pre-constitution.bak" \
    -exec rm -rf {} +
  cp -R "$SOURCE_DIR"/. "$TARGET_DIR"/
  rm -rf "$TARGET_DIR/.git"
}

# ═══════════════════════════════════════════════════════════════════════════════
# Migrations — run sequentially from the installed version to FRAMEWORK_VERSION
# ═══════════════════════════════════════════════════════════════════════════════

run_migrations() {
  local from_version
  from_version="$(installed_framework_version)"

  if [[ "$from_version" -ge "$FRAMEWORK_VERSION" ]]; then
    return  # Already current
  fi

  step "Running migrations (v${from_version} → v${FRAMEWORK_VERSION})"

  # ── Migration: v0/v1 → v2 ─────────────────────────────────────────────────
  # Remove bin/ directory — all executables now live in the npm CLI.
  # Scripts that were in bin/ are now in tools/_scripts/.
  if [[ "$from_version" -lt 2 ]]; then
    # bin/ and the rails symlink are operator-classified by the manifest (unknown⇒
    # operator) and thus captured in the durable snapshot; record them as
    # intentional removals so the post-sync verify net does not restore them.
    MIGRATION_REMOVED_PATHS+=("bin" "rails")
    if [[ -d "$TARGET_DIR/bin" ]]; then
      ok "Removing legacy bin/ directory (executables now in npm CLI)"
      rm -rf "$TARGET_DIR/bin"
    fi

    # Remove old mosaic PATH entry from shell profiles
    for profile in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.profile"; do
      if [[ -f "$profile" ]] && grep -qF "$TARGET_DIR/bin" "$profile"; then
        # Remove the PATH line and the comment above it
        sed -i.mosaic-migration-bak \
          -e "\|# Mosaic agent framework|d" \
          -e "\|$TARGET_DIR/bin|d" \
          "$profile"
        ok "Cleaned up old PATH entry from $(basename "$profile")"
        rm -f "${profile}.mosaic-migration-bak"
      fi
    done

    # Remove stale rails/ symlink
    if [[ -L "$TARGET_DIR/rails" ]]; then
      rm -f "$TARGET_DIR/rails"
    fi
  fi

  # ── Migration: v2 → v3 (Constitution split) ───────────────────────────────
  # CONSTITUTION.md / AGENTS.md / STANDARDS.md become framework-owned (overwritten
  # on upgrade). reconcile_framework_files() has already run before this point: it
  # backed up any user-edited copy to <file>.pre-constitution.bak and installed the
  # new framework version. Nothing further to do here — the advisory was emitted at
  # reconcile time. (STANDARDS.local.md composition lands with the overlay composer.)
  if [[ "$from_version" -lt 3 ]]; then
    ok "Migrated to the Constitution layout (framework-owned CONSTITUTION/AGENTS/STANDARDS)"
  fi
}

# ═══════════════════════════════════════════════════════════════════════════════
# Component-scoped install (#892 W7) — additive early dispatch.
# ═══════════════════════════════════════════════════════════════════════════════
# `install.sh --component <name>` delegates to the component's own idempotent,
# fail-closed installer and EXITS. This path is ADDITIVE (#869): it does NOT run
# the full-framework sync, does NOT alter framework-manifest ownership behavior,
# and touches NOTHING the #869 install-ordering-guard covers (no runtime-asset
# linking, no lease-enforcement hook wiring). Each component installer is
# INTERSECTED-AND-VALIDATED against the single SSOT framework-manifest.txt, so a
# component manifest can never authorize a write outside framework ownership.
run_component_install() {
  local name="$1"
  case "$name" in
    wake)
      local wi="$SOURCE_DIR/tools/wake/wake-install.sh"
      if [[ ! -x "$wi" && ! -f "$wi" ]]; then
        fail "Component 'wake' installer not found at $wi"
        exit 1
      fi
      step "Installing Mosaic component: wake"
      WAKE_INSTALL_SOURCE="$SOURCE_DIR" WAKE_INSTALL_TARGET="$TARGET_DIR" \
        bash "$wi" install
      ;;
    "")
      fail "--component requires a name (e.g. --component wake)."
      exit 1
      ;;
    *)
      fail "Unknown component '$name'. Supported: wake."
      exit 1
      ;;
  esac
}

if [[ -n "$COMPONENT" ]]; then
  mkdir -p "$TARGET_DIR"
  run_component_install "$COMPONENT"
  exit 0
fi

# ═══════════════════════════════════════════════════════════════════════════════
# Main
# ═══════════════════════════════════════════════════════════════════════════════

step "Installing Mosaic framework"

mkdir -p "$TARGET_DIR"
select_install_mode

if [[ "$INSTALL_MODE" == "keep" ]]; then
  ok "Install mode: keep local files (SOUL.md, USER.md, TOOLS.md, memory/)"
else
  ok "Install mode: overwrite"
fi

# Pre-flight (keep mode): load + validate the framework manifest BEFORE taking a
# snapshot or arming the restore trap. A fail-closed manifest (missing / empty /
# malformed) must abort here WITHOUT deleting or restoring over operator files —
# the snapshot/restore path exists only for a genuine mid-sync mutation failure,
# not for a validation failure that has touched nothing yet (#791 blocker-1).
if [[ "$INSTALL_MODE" == "keep" ]]; then
  manifest_load
  # Durable, operator-scoped backup taken BEFORE any mutation (#791 PR2). Kept
  # outside the framework tree; recovered later via `mosaic restore`. Fail-open.
  make_durable_snapshot
fi

# Snapshot before any destructive file operation; restore on interrupt/failure.
# The trap MUST exit after restoring: a bash INT/TERM handler that merely returns
# does NOT terminate the script — execution would resume past the interrupt,
# clear the snapshot, and report success, leaving a partial post-interrupt update
# (#791 blocker-A). `restore_snapshot; exit 1` guarantees a non-zero exit for
# both the errtrace (ERR) and signal (INT/TERM) paths.
make_snapshot
trap 'restore_snapshot; exit 1' ERR INT TERM

sync_framework

# Ensure persistent directories exist
mkdir -p "$TARGET_DIR/memory"
mkdir -p "$TARGET_DIR/credentials"

# Reconcile contract files from defaults/ into the framework root: framework-owned
# files (CONSTITUTION/AGENTS/STANDARDS) are overwritten every upgrade (a divergent
# copy is backed up once); user-seeded files (TOOLS) are written on first install only.
#
# This list must match the framework-contract whitelist in
# packages/mosaic/src/config/file-adapter.ts (FileConfigAdapter.syncFramework).
# SOUL.md and USER.md are intentionally NOT seeded here — they are generated
# by `mosaic init` from templates with user-supplied values.
reconcile_framework_files

# Ensure tool scripts are executable
find "$TARGET_DIR/tools" -name "*.sh" -exec chmod +x {} + 2>/dev/null || true
find "$TARGET_DIR/tools/_scripts" -type f -exec chmod +x {} + 2>/dev/null || true
# git-credential-mosaic (per-agent Gitea identity helper) ships without a .sh
# suffix — git resolves credential helpers by exact name/path, not extension —
# so the *.sh glob above does not cover it; chmod it explicitly.
[[ -f "$TARGET_DIR/tools/git/git-credential-mosaic" ]] && chmod +x "$TARGET_DIR/tools/git/git-credential-mosaic" 2>/dev/null || true

ok "Framework synced to $TARGET_DIR"

# Run migrations before post-install (migrations may remove old bin/ etc.)
run_migrations

# File-system phase complete and consistent — clear the restore trap.
trap - ERR INT TERM
# Post-sync safety net: heal any operator file a manifest bug let the sync touch,
# using the durable pre-update snapshot (#791 PR2). Runs with the trap disarmed so
# a corrective copy can't spuriously trigger a full rollback.
verify_operator_surface   # VERIFY-NET (#791 PR2)
cleanup_snapshot

# Testability / minimal-install hook: stop after the file-system phase, before any
# environment-touching post-install steps (runtime linking, MCP setup, skills, doctor).
if [[ "${MOSAIC_SYNC_ONLY:-0}" == "1" ]]; then
  write_framework_version
  ok "Sync-only mode: file phase complete"
  exit 0
fi

step "Post-install tasks"

SCRIPTS="$TARGET_DIR/tools/_scripts"

if [[ -x "$SCRIPTS/mosaic-link-runtime-assets" ]]; then
  link_args=()
  [[ "$ALLOW_INACTIVE_ENFORCEMENT" == "1" ]] && link_args+=(--allow-inactive-enforcement)
  # stdout is suppressed as before, but stderr is left connected: the
  # install-ordering guard's FAIL LOUD message (#869 Point-1 C2) must reach
  # the operator, not be swallowed silently.
  if "$SCRIPTS/mosaic-link-runtime-assets" "${link_args[@]}" >/dev/null; then
    ok "Runtime assets linked"
  else
    warn "Runtime asset linking failed (non-fatal) — see message above for details."
  fi
fi

if [[ -x "$SCRIPTS/mosaic-ensure-sequential-thinking" ]]; then
  if "$SCRIPTS/mosaic-ensure-sequential-thinking" >/dev/null 2>&1; then
    ok "sequential-thinking MCP configured"
  else
    if [[ "${MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING:-0}" == "1" ]]; then
      warn "sequential-thinking MCP setup bypassed (MOSAIC_ALLOW_MISSING_SEQUENTIAL_THINKING=1)"
    else
      fail "sequential-thinking MCP setup failed (hard requirement)."
      exit 1
    fi
  fi
fi

if [[ -x "$SCRIPTS/mosaic-ensure-excalidraw" ]]; then
  "$SCRIPTS/mosaic-ensure-excalidraw" >/dev/null 2>&1 && ok "excalidraw MCP configured" || warn "excalidraw MCP setup failed (non-fatal)"
fi

if [[ "${MOSAIC_SKIP_SKILLS_SYNC:-0}" != "1" ]] && [[ -x "$SCRIPTS/mosaic-sync-skills" ]]; then
  "$SCRIPTS/mosaic-sync-skills" >/dev/null 2>&1 && ok "Skills synced" || warn "Skills sync failed (non-fatal)"
fi

if [[ -x "$SCRIPTS/mosaic-migrate-local-skills" ]]; then
  "$SCRIPTS/mosaic-migrate-local-skills" --apply >/dev/null 2>&1 && ok "Local skills migrated" || warn "Local skill migration failed (non-fatal)"
fi

if [[ -x "$SCRIPTS/mosaic-doctor" ]]; then
  "$SCRIPTS/mosaic-doctor" >/dev/null 2>&1 && ok "Health audit passed" || warn "Health audit reported issues — run 'mosaic doctor' for details"
fi

# Write version stamp AFTER everything succeeds
write_framework_version

# ── Summary ──────────────────────────────────────────────────
echo ""
echo -e "${GREEN}${BOLD}  Mosaic framework installed.${RESET}"
echo ""

if [[ ! -f "$TARGET_DIR/SOUL.md" ]]; then
  echo -e "  Run ${CYAN}mosaic init${RESET} to set up your agent identity."
  echo ""
fi
