#!/usr/bin/env bash
set -euo pipefail

# SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0

# secrets — encrypted env file sync between machines
# Uses age key-file encryption + a private git repo.

# F5: explicit HOME check. `set -u` would already error if HOME were unset,
# but the message would be cryptic ("HOME: unbound variable" pointing at
# the SECRETS_DIR default-init line). Check up front with a directed message
# so cron/sudo/CI users know what to fix.
: "${HOME:?HOME is not set; secrets needs a home directory to find or create the store}"

# Capture the user-provided SECRETS_DIR (if any) before defaulting.
# resolve_store() uses this to honor SECRETS_DIR as the legacy escape hatch
# while letting .secrets-store files take precedence per project.
_USER_SECRETS_DIR="${SECRETS_DIR:-}"
SECRETS_DIR="${SECRETS_DIR:-$HOME/.secrets}"
KEY_FILE="$SECRETS_DIR/key.txt"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

# Set by resolve_store(). Reports which rule chose SECRETS_DIR.
STORE_SOURCE="default"
# Path to the .secrets-store file that won resolution, if any.
_LAST_FOUND_AT=""
# Remote URL parsed from the active .secrets-store file (optional 2nd token).
# Used by check_initialized to fill in a runnable `git clone <URL> <PATH>`
# in the missing-store error so teammates don't have to ask.
_REMOTE_URL=""
# --store flag value, captured by the pre-pass.
STORE_OVERRIDE=""

# ─── Helpers ───────────────────────────────────────────────────────────

die() { echo "ERROR: $*" >&2; exit 1; }
info() { echo "==> $*"; }

check_cmd() {
  command -v "$1" >/dev/null 2>&1 && return
  # Platform-aware install hint — hardcoding brew is wrong guidance on a
  # Linux box or CI runner (EGB-677 DX review).
  local hint="install '$1' with your package manager"
  if command -v brew >/dev/null 2>&1; then
    hint="brew install $1"
  elif command -v apt-get >/dev/null 2>&1; then
    hint="sudo apt-get install $1"
  elif command -v dnf >/dev/null 2>&1; then
    hint="sudo dnf install $1"
  fi
  die "'$1' is not installed. Run: $hint"
}

check_initialized() {
  if [ -d "$SECRETS_DIR/.git" ]; then
    return
  fi
  if [ "$STORE_SOURCE" != "default" ]; then
    # Fill in the remote URL when .secrets-store provided one, so the
    # teammate can copy-paste the clone command without asking the original
    # setter for the URL. Falls back to a placeholder otherwise.
    local clone_url
    if [ -n "${_REMOTE_URL:-}" ]; then
      clone_url="$_REMOTE_URL"
    else
      clone_url="<their-store-remote>"
    fi
    die "Store not initialized: $SECRETS_DIR
  Resolved from: $STORE_SOURCE
  This path doesn't exist on this machine yet.

  If you're joining a teammate's existing store:
    git clone $clone_url $SECRETS_DIR
    # then copy their key.txt to $SECRETS_DIR/key.txt

  If you want a fresh new store at this path:
    secrets --store $SECRETS_DIR init"
  fi
  die "Not initialized. Run: secrets init"
}

check_key() {
  if [ -f "$KEY_FILE" ]; then
    return
  fi
  if [ "$STORE_SOURCE" != "default" ]; then
    die "Key file not found at $KEY_FILE
  The store at $SECRETS_DIR exists but has no key.txt.
  Resolved from: $STORE_SOURCE

  If joining a teammate's store: copy their key.txt to $KEY_FILE.
  If this is your own new store: re-run init for this store path."
  fi
  die "Key file not found at $KEY_FILE. Run: secrets init"
}

get_pubkey() {
  age-keygen -y "$KEY_FILE" 2>/dev/null || die "Failed to derive public key from $KEY_FILE"
}

derive_project_name() {
  local explicit="${1:-}"
  if [ -n "$explicit" ]; then
    echo "$explicit"
    return
  fi
  # Try git remote basename
  local remote
  remote=$(git config --get remote.origin.url 2>/dev/null || true)
  if [ -n "$remote" ]; then
    basename "$remote" .git
    return
  fi
  # Fall back to current directory name
  basename "$PWD"
}

# ─── Store resolution ──────────────────────────────────────────────────
#
# Resolution order (highest precedence first):
#   1. --store <dir>  flag (captured by main pre-pass into STORE_OVERRIDE)
#   2. .secrets-store file in cwd or any ancestor up to but NOT including $HOME
#   3. SECRETS_DIR    env var (legacy escape hatch)
#   4. $HOME/.secrets default
#
# .secrets-store file format: first non-empty non-comment line is the store
# spec, optionally followed by a remote URL on the same line. Spec is one of:
#   - absolute path (/Users/you/.secrets-work)
#   - ~/path        (expanded to $HOME/path)
#   - bare name     (e.g. "work" → $HOME/.secrets-work; "default" → $HOME/.secrets)
#
# The optional second token (whitespace-separated) is a git remote URL. It
# is only used as a hint when the store directory does not yet exist on
# the current machine — the missing-store error includes a runnable
# `git clone <url> <path>` for the teammate to copy. Examples:
#
#   work
#   work git@github.com:acme/work-secrets.git
#   ~/.secrets-work https://github.com/acme/work-secrets.git
#
# Comments (#) and CRLF line endings are tolerated. NO shell expansion is
# applied to the file content — `$VAR`, `$(...)`, and backticks are read as
# literal characters to prevent code injection from a committed file.

# Take a store spec (path or bare name) and return an absolute directory.
_expand_store_path() {
  local path="$1"
  case "$path" in
    /*)
      echo "$path"
      ;;
    '~')
      echo "$HOME"
      ;;
    '~/'*)
      echo "$HOME/${path#\~/}"
      ;;
    .*|*/*)
      # Relative path or path containing /. Treat as path relative to cwd.
      echo "$path"
      ;;
    default)
      # Sugar: --store default → the canonical default store
      echo "$HOME/.secrets"
      ;;
    *)
      # Bare name like "work" → $HOME/.secrets-work
      echo "$HOME/.secrets-$path"
      ;;
  esac
}

# Read a .secrets-store file. On success, print "<store-spec>\t<remote-url>"
# (URL empty if not provided or rejected as unsafe). The first non-empty
# non-comment line is the active line; the first whitespace splits it into
# the spec and an optional URL.
#
# Security: the URL is later substituted into a copy-paste-ready `git clone`
# command in the missing-store error. An attacker who slips a malicious line
# into a committed .secrets-store could weaponize that copy-paste — e.g.
# `work evil.git;rm -rf ~` would render as `git clone evil.git;rm -rf ~ ...`
# and a teammate following the directed error would execute the payload.
# We reject URLs containing shell metacharacters, control characters, ANSI
# escapes, and embedded whitespace. Rejected URLs are dropped silently from
# the parser's perspective (a warning is printed to stderr); the resolver
# falls back to the `<their-store-remote>` placeholder so the directed
# error stays useful without rendering the attacker-controlled string.
_parse_secrets_store_file() {
  local file="$1"
  local line
  while IFS= read -r line || [ -n "$line" ]; do
    # Strip CRLF
    line="${line%$'\r'}"
    # Trim leading whitespace
    line="${line#"${line%%[![:space:]]*}"}"
    # Trim trailing whitespace
    line="${line%"${line##*[![:space:]]}"}"
    [ -z "$line" ] && continue
    case "$line" in '#'*) continue ;; esac
    # Split into spec (first whitespace-separated token) and url (rest of
    # the line, verbatim). `read -r` does NOT glob-expand and preserves the
    # tail in $rest as a single string — important so `work *` doesn't
    # silently expand to `work file1 file2 ...`.
    local spec="" rest=""
    read -r spec rest <<< "$line"
    local url="$rest"
    if [ -n "$url" ]; then
      # Reject URLs containing characters that could weaponize a
      # copy-paste shell command, terminal escapes, or be ambiguous.
      # Match order: control chars (incl. ESC \x1b), shell meta, whitespace.
      case "$url" in
        *[[:cntrl:]]*|*[\;\&\|\<\>\$\`\(\)\{\}\*\?\!\"\'\\]*|*' '*|*"	"*)
          echo "WARNING: $file: dropping unsafe characters in remote URL hint" >&2
          url=""
          ;;
      esac
    fi
    printf '%s\t%s\n' "$spec" "$url"
    return 0
  done < "$file"
  return 1
}

# Walk up from cwd looking for .secrets-store. Bounded by $HOME — never
# walks INTO or PAST $HOME. If cwd is outside $HOME entirely (e.g. /tmp),
# the walk does not run. Symlinks are resolved with `cd -P`.
# On success: prints "<expanded-store-dir>\t<source-file-path>\t<remote-url>"
# (URL empty if .secrets-store didn't include one) and returns 0. The caller
# (resolve_store) splits the tab-separated 3-tuple. We can't set a
# parent-shell variable from here because we're typically called inside
# `$(...)` command substitution, which runs in a subshell.
_find_secrets_store_file() {
  local dir
  dir=$(pwd -P 2>/dev/null) || dir="$PWD"
  local home_resolved
  home_resolved=$(cd -P "$HOME" 2>/dev/null && pwd -P) || home_resolved="$HOME"

  while [ -n "$dir" ] && [ "$dir" != "/" ] && [ "$dir" != "$home_resolved" ]; do
    # Bound: only walk while we're strictly below $HOME.
    case "$dir" in
      "$home_resolved"/*) ;;
      *) return 1 ;;
    esac

    # F2: never follow a symlinked .secrets-store. A committed symlink
    # could point at any user-readable file (~/.aws/credentials, /etc/passwd)
    # and trick the resolver into reading attacker-chosen content.
    if [ -L "$dir/.secrets-store" ]; then
      :
    elif [ -f "$dir/.secrets-store" ]; then
      local parsed
      if parsed=$(_parse_secrets_store_file "$dir/.secrets-store"); then
        # parsed is "<spec>\t<url>" (URL may be empty)
        local spec="${parsed%%$'\t'*}"
        local url="${parsed#*$'\t'}"
        local expanded
        expanded=$(_expand_store_path "$spec")
        printf '%s\t%s\t%s\n' "$expanded" "$dir/.secrets-store" "$url"
        return 0
      fi
    fi
    dir=$(dirname "$dir")
  done
  return 1
}

# Resolve the active store directory and update SECRETS_DIR + KEY_FILE.
# Sets STORE_SOURCE to one of:
#   "--store flag" | ".secrets-store file (<path>)" | "SECRETS_DIR env var" | "default"
# Also sets _REMOTE_URL to the optional remote URL parsed from .secrets-store
# (empty when not present). check_initialized uses _REMOTE_URL to fill in a
# copy-paste-ready `git clone` command for teammates whose store doesn't
# exist yet.
resolve_store() {
  local resolved=""
  local source=""
  _REMOTE_URL=""

  if [ -n "${STORE_OVERRIDE:-}" ]; then
    resolved=$(_expand_store_path "$STORE_OVERRIDE")
    source="--store flag"
    _LAST_FOUND_AT=""
  elif _find_result=$(_find_secrets_store_file); then
    # _find_secrets_store_file returns "<dir>\t<source-file-path>\t<url>"
    # IFS=$'\t' prefix is scoped to this single `read` builtin — no manual
    # save/restore needed. URL field is empty when .secrets-store didn't
    # include a URL or when it was rejected as unsafe.
    IFS=$'\t' read -r resolved _LAST_FOUND_AT _REMOTE_URL <<< "$_find_result"
    source=".secrets-store file ($_LAST_FOUND_AT)"
  elif [ -n "${_USER_SECRETS_DIR:-}" ]; then
    resolved="$_USER_SECRETS_DIR"
    source="SECRETS_DIR env var"
    _LAST_FOUND_AT=""
  else
    resolved="$HOME/.secrets"
    source="default"
    _LAST_FOUND_AT=""
  fi

  SECRETS_DIR="$resolved"
  KEY_FILE="$SECRETS_DIR/key.txt"
  STORE_SOURCE="$source"
}

# Echo "==> Store: ..." when SECRETS_DIR is not the canonical default.
# Called from cmd_push/cmd_pull (and their workspace variants) after resolution.
echo_store_if_non_default() {
  if [ "$SECRETS_DIR" != "$HOME/.secrets" ]; then
    info "Store: $SECRETS_DIR (from $STORE_SOURCE)"
  fi
}

# ─── End store resolution ──────────────────────────────────────────────

# Collect secret files from a directory:
#   .env, .env.*, .dev.vars (excluding .envrc, .environment-*)
# Sets the COLLECTED_FILES array. Returns 1 if no files found.
collect_env_files() {
  local dir="$1"
  COLLECTED_FILES=()
  for f in "$dir"/.env "$dir"/.env.* "$dir"/.dev.vars; do
    [ -f "$f" ] || continue
    local basename_f
    basename_f=$(basename "$f")
    case "$basename_f" in
      .envrc|.environment*) continue ;;
    esac
    COLLECTED_FILES+=("$f")
  done
  [ ${#COLLECTED_FILES[@]} -gt 0 ]
}

# ─── External files (.secrets-files) — EGB-531 ─────────────────────────
#
# A committed, project-local `.secrets-files` manifest declares files that
# live OUTSIDE the project root (e.g. global Gradle properties) whose
# *designated keys* should sync. One entry per non-empty non-comment line:
#
#     gradle-properties  ~/.gradle/gradle.properties  beaconClerkPkTest beaconClerkPkLive
#     <type>             <path>                        <key> [key...]
#
# push extracts only the named keys → encrypts a subset blob under
# <project>/external/. pull decrypts it and MERGES those keys into <path>,
# preserving every unrelated line. Parsing mirrors `.secrets-store`: no
# shell expansion, no symlink following, conservative sanitization. The
# merge is pure bash (no sed/regex) with exact-string key matching, so it
# is safe against values containing `& \ /` and against substring keys
# (beaconClerkPk vs beaconClerkPkTest).
#
# A second type, `file` (EGB-652), syncs a WHOLE file outside the project
# root (e.g. an Android upload keystore) — binary-safe via age, no keys:
#
#     file               ~/keystores/beacon-upload.keystore
#
# push encrypts the file verbatim; pull restores it (mode 600, existing
# target backed up to <name>.secrets-bak first). Same path rules as
# gradle-properties (inside $HOME, no `..`, no symlinks) minus the
# basename restriction.

SECRETS_FILES_NAME=".secrets-files"
SUPPORTED_EXTERNAL_TYPES="gradle-properties file"

# True if the string ends in an odd number of backslashes (a Java/Gradle
# properties line-continuation). Used to skip continuation lines when
# matching keys so an unrelated continued value is never clobbered.
_trailing_bs_odd() {
  local s="$1" n=0
  while [ "${s%\\}" != "$s" ]; do s="${s%\\}"; n=$((n + 1)); done
  [ $((n % 2)) -eq 1 ]
}

# Parse .secrets-files. Emits "<type>\t<path>\t<keys>" per valid entry on
# stdout; warnings (skipped/unsafe lines) to stderr. Conservative: rejects
# shell/control chars and `..` in the path, restricts keys to a safe
# charset. No shell expansion is applied to file content.
_parse_secrets_files_manifest() {
  local file="$1"
  local line lineno=0 mtype mpath mkeys
  while IFS= read -r line || [ -n "$line" ]; do
    lineno=$((lineno + 1))
    line="${line%$'\r'}"
    line="${line#"${line%%[![:space:]]*}"}"
    line="${line%"${line##*[![:space:]]}"}"
    [ -z "$line" ] && continue
    case "$line" in '#'*) continue ;; esac
    # `read -r` does not glob-expand and keeps the key list intact in $mkeys.
    read -r mtype mpath mkeys <<< "$line"
    case "$mtype" in
      gradle-properties)
        if [ -z "$mpath" ] || [ -z "$mkeys" ]; then
          echo "WARNING: $file line $lineno: expected 'gradle-properties <path> <key> [key...]'. Skipping." >&2
          continue
        fi
        ;;
      file)
        if [ -z "$mpath" ]; then
          echo "WARNING: $file line $lineno: expected 'file <path>'. Skipping." >&2
          continue
        fi
        if [ -n "$mkeys" ]; then
          echo "WARNING: $file line $lineno: 'file' entries take no keys (got '$mkeys'). Skipping." >&2
          continue
        fi
        ;;
      *)
        echo "WARNING: $file line $lineno: unknown type '$mtype' (supported: $SUPPORTED_EXTERNAL_TYPES). Skipping." >&2
        continue
        ;;
    esac
    # Path: allow alnum and / . _ ~ - only; reject everything else (blocks
    # \$ \` ; & | ( ) * ? whitespace etc.) and reject `..` traversal.
    case "$mpath" in
      *[!A-Za-z0-9/._~-]* | *..*)
        echo "WARNING: $file line $lineno: unsafe characters in path. Paths may contain letters, digits, / . _ - ~ only. Skipping." >&2
        continue
        ;;
    esac
    # Keys: space-separated; each may contain alnum and . _ - (covers the
    # systemProp. prefix). Reject anything else (=, *, control chars, ...).
    case "$mkeys" in
      *[!A-Za-z0-9._\ -]*)
        echo "WARNING: $file line $lineno: unsafe characters in key list. Skipping." >&2
        continue
        ;;
    esac
    printf '%s\t%s\t%s\n' "$mtype" "$mpath" "$mkeys"
  done < "$file"
}

# Validate a writable external target path. Requires the path to resolve
# inside $HOME, blocks `..`, and refuses symlinks (target file or its
# parent dir) — the path comes from a committed file, so it is
# attacker-controllable. For type `gradle-properties` the basename must be
# 'gradle.properties' (type `file` allows any basename — the whole file is
# replaced, never merged). Returns 0 if safe.
_validate_external_target_path() {
  local p="$1" mtype="${2:-gradle-properties}"
  local base; base=$(basename "$p")
  if [ "$mtype" = "gradle-properties" ]; then
    # EGB-677: generalized from exact 'gradle.properties' to any
    # '*.properties' basename — still blocks merging key=value lines
    # into ~/.bashrc / ~/.gitconfig style targets.
    case "$base" in
      *.properties) ;;
      *)
        echo "ERROR: properties target basename must end in '.properties' (got '$base'). Refusing." >&2
        return 1
        ;;
    esac
  fi
  case "$p" in *..*) echo "ERROR: $SECRETS_FILES_NAME: target path may not contain '..'. Refusing." >&2; return 1 ;; esac
  local home_real; home_real=$(cd -P "$HOME" 2>/dev/null && pwd -P) || home_real="$HOME"
  local dir; dir=$(dirname "$p")
  # Resolve the deepest existing ancestor and require it inside $HOME.
  local probe="$dir"
  while [ ! -e "$probe" ] && [ "$probe" != "/" ] && [ -n "$probe" ]; do
    probe=$(dirname "$probe")
  done
  local probe_real
  probe_real=$(cd -P "$probe" 2>/dev/null && pwd -P) || {
    echo "ERROR: $SECRETS_FILES_NAME: cannot resolve target ancestor '$probe'. Refusing." >&2
    return 1
  }
  case "$probe_real/" in
    "$home_real"/*) ;;
    *) echo "ERROR: $SECRETS_FILES_NAME: target '$p' must resolve inside \$HOME. Refusing." >&2; return 1 ;;
  esac
  if [ -L "$p" ]; then
    echo "ERROR: $SECRETS_FILES_NAME: target '$p' is a symlink. Refusing to follow." >&2
    return 1
  fi
  if [ -e "$dir" ] && [ -L "$dir" ]; then
    echo "ERROR: $SECRETS_FILES_NAME: target dir '$dir' is a symlink. Refusing." >&2
    return 1
  fi
  return 0
}

# Print the value of an exact key from a properties file (last definition
# wins, matching Gradle). Returns 1 if the key is absent. Pure bash; the
# key is compared by string equality (never as a regex), and the value is
# read verbatim. Skips comments and continuation lines.
_props_get() {
  local file="$1" want="$2"
  [ -f "$file" ] || return 1
  local line t key after val="" found=0 cont=0
  while IFS= read -r line || [ -n "$line" ]; do
    line="${line%$'\r'}"
    if [ "$cont" -eq 1 ]; then
      _trailing_bs_odd "$line" && cont=1 || cont=0
      continue
    fi
    _trailing_bs_odd "$line" && cont=1 || cont=0
    t="${line#"${line%%[![:space:]]*}"}"
    case "$t" in ''|'#'*|'!'*) continue ;; esac
    key="${t%%[=:[:space:]]*}"
    [ "$key" = "$want" ] || continue
    after="${t#"$key"}"
    after="${after#"${after%%[![:space:]]*}"}"
    case "$after" in [=:]*) after="${after#?}" ;; esac
    after="${after#"${after%%[![:space:]]*}"}"
    val="$after"
    found=1
  done < "$file"
  [ "$found" -eq 1 ] || return 1
  printf '%s' "$val"
}

# Turn a manifest path token into a machine-independent blob slug.
# A bare char-replace would collide (e.g. a/b and a_b both → a_b), so append a
# checksum of the original path to keep distinct targets' blobs distinct.
_secrets_files_slug() {
  local p="$1"
  local clean="${p//[!A-Za-z0-9._-]/_}"
  local sum
  sum=$(printf '%s' "$p" | cksum | cut -d' ' -f1)
  printf '%s-%s' "$clean" "$sum"
}

# ─── Store format (EGB-703) ───────────────────────────────────────────
#
# The store self-describes its format via a committed one-line file
# `$SECRETS_DIR/.secrets-format` containing `2`. Absence (or any non-`2`
# content) means format v1 — the legacy default for every store that
# predates EGB-703. `init` stamps a fresh store v2 (born-v2); `migrate
# --finalize` stamps a migrated store v2. Requires resolve_store to have
# run (SECRETS_DIR set).
STORE_FORMAT_FILE_NAME=".secrets-format"
_store_format() {
  local f="$SECRETS_DIR/$STORE_FORMAT_FILE_NAME" v
  if [ -f "$f" ]; then
    # Strict exact match (modulo line endings): only a marker of exactly "2"
    # reads as v2. Anything else (empty, "20", "v2", garbage) ⇒ v1 — the safe
    # default, since misreading v2-as-v1 only triggers a harmless re-migrate
    # while v1-as-v2 would mislocate blobs.
    v=$(head -1 "$f" 2>/dev/null | tr -d '\r\n')
    [ "$v" = "2" ] && { echo 2; return; }
  fi
  echo 1
}

# The on-disk blob suffix for an external entry, format-aware. v2 unifies
# the legacy `gradle-properties` suffix to `properties` (matching the JSON
# manifest `type`); `file` is unchanged in both formats. The slug + this
# suffix + `.age` is the external blob name. This is the single source of
# truth for the suffix — push, pull, verify all route through it so a v1
# and a v2 store can never disagree on where a blob lives.
_external_blob_suffix() {
  local mtype="$1"
  if [ "$mtype" = "gradle-properties" ] && [ "$(_store_format)" = "2" ]; then
    echo "properties"
  else
    echo "$mtype"
  fi
}

# Merge managed key=value lines (from $2) into target file $1, preserving
# all unrelated lines/comments/order. Updates a managed key in place (first
# occurrence), collapses duplicates, appends new keys. Atomic + mode-safe.
merge_gradle_keys() {
  local target="$1" kvfile="$2"
  local -a mkey=() mval=() seen=()
  local line key val i
  while IFS= read -r line || [ -n "$line" ]; do
    [ -n "$line" ] || continue
    key="${line%%=*}"
    val="${line#*=}"
    mkey+=("$key"); mval+=("$val"); seen+=(0)
  done < "$kvfile"
  local n=${#mkey[@]}
  [ "$n" -gt 0 ] || return 0

  local dir; dir=$(dirname "$target")
  mkdir -p "$dir"
  local tmp; tmp=$(mktemp "$dir/.gradle-merge.XXXXXX") || return 1

  local cont=0 t tkey matched
  if [ -f "$target" ]; then
    while IFS= read -r line || [ -n "$line" ]; do
      local stripped="${line%$'\r'}"
      if [ "$cont" -eq 1 ]; then
        printf '%s\n' "$line" >> "$tmp"
        _trailing_bs_odd "$stripped" && cont=1 || cont=0
        continue
      fi
      _trailing_bs_odd "$stripped" && cont=1 || cont=0
      t="${stripped#"${stripped%%[![:space:]]*}"}"
      case "$t" in ''|'#'*|'!'*) printf '%s\n' "$line" >> "$tmp"; continue ;; esac
      tkey="${t%%[=:[:space:]]*}"
      matched=-1
      for ((i = 0; i < n; i++)); do
        if [ "$tkey" = "${mkey[$i]}" ]; then matched=$i; break; fi
      done
      if [ "$matched" -ge 0 ]; then
        if [ "${seen[$matched]}" -eq 0 ]; then
          printf '%s=%s\n' "${mkey[$matched]}" "${mval[$matched]}" >> "$tmp"
          seen[$matched]=1
        fi
      else
        printf '%s\n' "$line" >> "$tmp"
      fi
    done < "$target"
  fi
  for ((i = 0; i < n; i++)); do
    if [ "${seen[$i]}" -eq 0 ]; then
      printf '%s=%s\n' "${mkey[$i]}" "${mval[$i]}" >> "$tmp"
    fi
  done

  # Back up the existing target before overwriting (seatbelt for the merge
  # engine touching a hand-maintained file), preserve mode, atomic rename.
  if [ -f "$target" ]; then
    cp "$target" "$target.secrets-bak" 2>/dev/null || true
    local mode
    mode=$(stat -f '%Lp' "$target" 2>/dev/null || stat -c '%a' "$target" 2>/dev/null || echo 600)
    chmod "$mode" "$tmp" 2>/dev/null || true
  else
    chmod 600 "$tmp" 2>/dev/null || true
  fi
  # TOCTOU recheck before the write into $HOME.
  if [ -L "$target" ]; then
    rm -f "$tmp"
    echo "ERROR: $target became a symlink; aborting merge." >&2
    return 1
  fi
  mv "$tmp" "$target"
}

# Encrypt the managed keys declared in <root>/.secrets-files into
# <project>/external/. Returns 0 if at least one entry was pushed, 1 if
# there is no usable manifest. Dies on unsafe targets or all-missing keys.
push_external_files() {
  local root="$1" project="$2" pubkey="$3"
  # Entries come from .secrets.json (EGB-677) plus any legacy
  # .secrets-files entries the manifest doesn't cover yet.
  local entries
  entries=$(_external_entries_for_push "$root")
  [ -n "$entries" ] || return 1

  local pushed=0 mtype mpath mkeys
  while IFS=$'\t' read -r mtype mpath mkeys; do
    [ -n "$mtype" ] || continue
    local expanded; expanded=$(_expand_store_path "$mpath")
    if ! _validate_external_target_path "$expanded" "$mtype"; then
      die "Refusing unsafe external target in $SECRETS_FILES_NAME: $mpath"
    fi
    if [ ! -f "$expanded" ]; then
      echo "WARNING: source '$mpath' not found on this machine; skipping." >&2
      continue
    fi
    if [ "$mtype" = "file" ]; then
      # EGB-652: whole-file sync — encrypt the file verbatim (binary-safe).
      mkdir -p "$SECRETS_DIR/$project/external"
      local fslug; fslug=$(_secrets_files_slug "$mpath")
      age -r "$pubkey" -o "$SECRETS_DIR/$project/external/$fslug.$(_external_blob_suffix file).age" "$expanded"
      info "Encrypted file $mpath"
      pushed=$((pushed + 1))
      continue
    fi
    local tmp; tmp=$(mktemp)
    local found=0 k v
    for k in $mkeys; do
      if v=$(_props_get "$expanded" "$k"); then
        if _trailing_bs_odd "$v"; then
          # A trailing odd backslash means a multi-line (continuation) value.
          # We only support single-line values; syncing this would write a
          # dangling backslash that turns the next target line into a
          # continuation and corrupts the file. Skip it loudly.
          echo "WARNING: key '$k' in $mpath has a multi-line (continuation) value — not supported, skipping." >&2
        else
          printf '%s=%s\n' "$k" "$v" >> "$tmp"
          found=$((found + 1))
        fi
      else
        echo "WARNING: key '$k' not found in $mpath — not synced. Set it locally first, or remove it from $SECRETS_FILES_NAME." >&2
      fi
    done
    if [ "$found" -eq 0 ]; then
      rm -f "$tmp"
      die "No managed keys found in $mpath (looked for: $mkeys)."
    fi
    mkdir -p "$SECRETS_DIR/$project/external"
    local slug; slug=$(_secrets_files_slug "$mpath")
    age -r "$pubkey" -o "$SECRETS_DIR/$project/external/$slug.$(_external_blob_suffix "$mtype").age" "$tmp"
    rm -f "$tmp"
    info "Extracted $found key(s) from $mpath"
    pushed=$((pushed + 1))
  done <<< "$entries"

  [ "$pushed" -gt 0 ]
}

# Decrypt the external blobs named by <root>/.secrets-files and merge their
# keys into the declared targets. Skips (with a warning) unsafe targets or
# missing blobs rather than aborting the whole pull.
pull_external_files() {
  local root="$1" project="$2"
  # .secrets.json wins entirely when present (EGB-677); legacy
  # .secrets-files only drives manifest-less projects.
  local entries
  entries=$(_external_entries_for_pull "$root")
  [ -n "$entries" ] || return 0

  local mtype mpath mkeys
  while IFS=$'\t' read -r mtype mpath mkeys; do
    [ -n "$mtype" ] || continue
    local expanded; expanded=$(_expand_store_path "$mpath")
    if ! _validate_external_target_path "$expanded" "$mtype"; then
      echo "WARNING: skipping unsafe external target: $mpath" >&2
      continue
    fi
    local slug; slug=$(_secrets_files_slug "$mpath")
    local blob="$SECRETS_DIR/$project/external/$slug.$(_external_blob_suffix "$mtype").age"
    if [ ! -f "$blob" ]; then
      echo "WARNING: $SECRETS_FILES_NAME names '$mpath' but no encrypted data exists in the store yet. Run 'secrets push' on a machine that has these keys. Skipping." >&2
      continue
    fi
    if [ "$mtype" = "file" ]; then
      # EGB-652: whole-file restore — decrypt next to the target (same
      # filesystem -> atomic mv), back up any existing target, mode 600,
      # TOCTOU symlink recheck before the write into $HOME.
      local fdir; fdir=$(dirname "$expanded")
      mkdir -p "$fdir"
      local ftmp; ftmp=$(mktemp "$fdir/.secrets-file.XXXXXX") || { echo "WARNING: mktemp failed for $mpath; skipping." >&2; continue; }
      if ! age -d -i "$KEY_FILE" -o "$ftmp" "$blob"; then
        rm -f "$ftmp"
        die "Decryption failed for external target $mpath."
      fi
      chmod 600 "$ftmp" 2>/dev/null || true
      if [ -L "$expanded" ]; then
        rm -f "$ftmp"
        echo "WARNING: $expanded became a symlink; skipping restore." >&2
        continue
      fi
      if [ -f "$expanded" ] && ! cmp -s "$expanded" "$ftmp"; then
        cp "$expanded" "$expanded.secrets-bak" 2>/dev/null || true
      fi
      mv "$ftmp" "$expanded"
      info "Restored file $expanded"
      continue
    fi
    local tmp; tmp=$(mktemp)
    if ! age -d -i "$KEY_FILE" -o "$tmp" "$blob"; then
      rm -f "$tmp"
      die "Decryption failed for external target $mpath."
    fi
    local k k_esc
    for k in $mkeys; do
      # Escape regex-special '.' so e.g. systemProp.foo can't match systemPropXfoo.
      k_esc=$(printf '%s' "$k" | sed 's/\./\\./g')
      grep -q "^$k_esc=" "$tmp" 2>/dev/null || echo "WARNING: '$k' listed in $SECRETS_FILES_NAME but absent from synced data for $mpath." >&2
    done
    if [ ! -d "$(dirname "$expanded")" ]; then
      echo "WARNING: creating $(dirname "$expanded")" >&2
    fi
    local count; count=$(grep -c '=' "$tmp" 2>/dev/null || true); count=${count:-0}
    if merge_gradle_keys "$expanded" "$tmp"; then
      local klist; klist=$(printf '%s' "$mkeys" | tr ' ' ',' | sed 's/,/, /g')
      info "Merged $count key(s) into $expanded ($klist)"
    else
      echo "WARNING: failed to merge keys into $expanded — target left unchanged." >&2
    fi
    rm -f "$tmp"
  done <<< "$entries"
}

# ─── Manifest (.secrets.json) — EGB-677 store format v2, stage 1 ──────
#
# A committed, project-root JSON manifest declaring everything the project
# syncs. Stage 1 is ADDITIVE: the manifest is read alongside the existing
# dotenv globs and `.secrets-files`; the v1 store layout is unchanged.
#
#     {
#       "version": 2,
#       "options": { "autoAdd": true },
#       "dotenv": [".env", "packages/web/.env.development"],
#       "external": [
#         { "type": "properties", "path": "~/.gradle/gradle.properties",
#           "keys": ["beaconClerkPk"] },
#         { "type": "file", "path": "~/keystores/beacon-upload.keystore" }
#       ]
#     }
#
# Security posture mirrors `.secrets-store`/`.secrets-files`: symlinked
# manifests are refused, dotenv paths are confined to the project root
# (no `..`, no absolute paths, conservative charset — `@` allowed for
# npm-scoped workspace dirs), and every jq-extracted string is
# re-validated before any filesystem use. jq is required only when a
# manifest exists or is being written — manifest-less projects keep
# working with zero new dependencies.

SECRETS_JSON_NAME=".secrets.json"
MANIFEST_VERSION=2

# Validate a project-relative dotenv path from the manifest (or `secrets
# add`). The path is attacker-influenced in team repos (committed file),
# so this is a security rail, not just hygiene. Prints an error and
# returns 1 when unsafe.
_validate_dotenv_rel_path() {
  local p="$1"
  if [ -z "$p" ]; then
    echo "ERROR: $SECRETS_JSON_NAME: empty dotenv path." >&2
    return 1
  fi
  case "$p" in
    /*)
      echo "ERROR: $SECRETS_JSON_NAME: '$p' is absolute — dotenv paths must be project-relative." >&2
      return 1
      ;;
    *..*)
      echo "ERROR: $SECRETS_JSON_NAME: '$p' contains '..' — dotenv paths must be project-relative (no traversal)." >&2
      return 1
      ;;
    *[!A-Za-z0-9@/._-]*)
      echo "ERROR: $SECRETS_JSON_NAME: unsafe characters in '$p'. Paths may contain letters, digits, @ / . _ - only." >&2
      return 1
      ;;
  esac
  return 0
}

# Validate the manifest file itself (existence assumed checked by caller):
# refuse symlinks, malformed JSON, and unsupported schema versions — each
# with a directed error. $1 = manifest path.
_check_manifest_file() {
  local manifest="$1"
  if [ -L "$manifest" ]; then
    die "$manifest is a symlink. Refusing to read it.
  A committed symlink could point the manifest at attacker-chosen content.
  Replace it with a regular file."
  fi
  check_cmd jq
  local jq_err
  if ! jq_err=$(jq -e . "$manifest" 2>&1 >/dev/null); then
    die "$manifest: invalid JSON.
  $jq_err
  Fix the syntax (or delete the file and re-run 'secrets add' / 'secrets push')."
  fi
  local ver
  ver=$(jq -r '.version // "missing"' "$manifest")
  if [ "$ver" != "$MANIFEST_VERSION" ]; then
    die "$manifest: manifest version $ver is not supported.
  This client supports version $MANIFEST_VERSION.
  If the manifest was written by a newer secrets, upgrade this machine:
    git -C $SCRIPT_DIR pull"
  fi
}

# Canonically (re)write the manifest: sorted keys, sorted+deduped dotenv,
# atomic tmp+mv in the project dir. stdin = the new JSON document.
_write_manifest_canonical() {
  local manifest="$1"
  local dir; dir=$(dirname "$manifest")
  local tmp; tmp=$(mktemp "$dir/.secrets-json.XXXXXX") || return 1
  if ! jq --sort-keys '.dotenv |= ((. // []) | unique | sort)' > "$tmp"; then
    rm -f "$tmp"
    return 1
  fi
  mv "$tmp" "$manifest"
}

# `secrets add <path>` — the explicit manifest writer. Creates
# .secrets.json on first use; validates and dedupes thereafter.
cmd_add() {
  local p="${1:-}"
  [ -n "$p" ] || die "Usage: secrets add <project-relative-path>"
  # Normalize a leading ./
  p="${p#./}"
  _validate_dotenv_rel_path "$p" || exit 1
  if [ ! -f "$PWD/$p" ]; then
    die "'$p' not found in $PWD.
  Create the file first, then re-run: secrets add $p"
  fi
  check_cmd jq
  local manifest="$PWD/$SECRETS_JSON_NAME"
  if [ -e "$manifest" ]; then
    _check_manifest_file "$manifest"
    jq --arg p "$p" '.dotenv = ((.dotenv // []) + [$p])' "$manifest" \
      | _write_manifest_canonical "$manifest" || die "Failed to update $manifest"
  else
    jq -n --arg p "$p" '{version: '"$MANIFEST_VERSION"', dotenv: [$p]}' \
      | _write_manifest_canonical "$manifest" || die "Failed to write $manifest"
  fi
  info "Added '$p' to $SECRETS_JSON_NAME"
  info "Commit the manifest so other machines pick it up. To undo: edit $SECRETS_JSON_NAME and remove the entry."
}

# Emit "<type>\t<path>\t<keys>" tuples from a .secrets.json external[]
# array — the same wire format _parse_secrets_files_manifest produces, so
# push_external_files / pull_external_files consume either source
# unchanged. JSON type 'properties' maps to the legacy tuple token
# 'gradle-properties' so blob suffixes (and existing store blobs) stay
# stable in stage 1. Applies the same conservative charset checks as the
# legacy parser — jq guarantees well-formed JSON, not safe VALUES.
_json_external_entries() {
  local manifest="$1"
  check_cmd jq
  local etype epath ekeys
  while IFS=$'\t' read -r etype epath ekeys; do
    [ -n "$etype" ] || continue
    case "$etype" in
      properties|gradle-properties)
        etype="gradle-properties"
        if [ -z "$ekeys" ]; then
          echo "WARNING: $SECRETS_JSON_NAME: properties entry '$epath' has no keys. Skipping." >&2
          continue
        fi
        ;;
      file)
        if [ -n "$ekeys" ]; then
          echo "WARNING: $SECRETS_JSON_NAME: 'file' entries take no keys ('$epath' lists '$ekeys'). Skipping." >&2
          continue
        fi
        ;;
      *)
        echo "WARNING: $SECRETS_JSON_NAME: unknown external type '$etype' (supported: properties file). Skipping." >&2
        continue
        ;;
    esac
    case "$epath" in
      ''|*[!A-Za-z0-9/._~-]*|*..*)
        echo "WARNING: $SECRETS_JSON_NAME: unsafe characters in external path '$epath'. Skipping." >&2
        continue
        ;;
    esac
    case "$ekeys" in
      *[!A-Za-z0-9._\ -]*)
        echo "WARNING: $SECRETS_JSON_NAME: unsafe characters in key list for '$epath'. Skipping." >&2
        continue
        ;;
    esac
    printf '%s\t%s\t%s\n' "$etype" "$epath" "$ekeys"
  done < <(jq -r '.external // [] | .[] | [.type, .path, ((.keys // []) | join(" "))] | @tsv' "$manifest")
}

# External tuples for PUSH: .secrets.json entries first, then legacy
# .secrets-files entries whose (type, path) the manifest doesn't cover —
# the absorb set, which cmd_push folds into the manifest after a
# successful push so the two sources converge.
_external_entries_for_push() {
  local root="$1"
  local json="$root/$SECRETS_JSON_NAME" legacy="$root/$SECRETS_FILES_NAME"
  local seen="" t p k
  if [ -f "$json" ] && [ ! -L "$json" ]; then
    while IFS=$'\t' read -r t p k; do
      [ -n "$t" ] || continue
      printf '%s\t%s\t%s\n' "$t" "$p" "$k"
      seen="$seen$t|$p"$'\n'
    done < <(_json_external_entries "$json")
  fi
  if [ -e "$legacy" ]; then
    if [ -L "$legacy" ]; then
      echo "WARNING: $legacy is a symlink; ignoring." >&2
    elif [ -f "$legacy" ]; then
      while IFS=$'\t' read -r t p k; do
        [ -n "$t" ] || continue
        case "$seen" in *"$t|$p"$'\n'*) continue ;; esac
        printf '%s\t%s\t%s\n' "$t" "$p" "$k"
      done < <(_parse_secrets_files_manifest "$legacy")
    fi
  fi
}

# External tuples for PULL: the manifest wins entirely when present;
# legacy .secrets-files is only consulted in manifest-less projects.
_external_entries_for_pull() {
  local root="$1"
  local json="$root/$SECRETS_JSON_NAME" legacy="$root/$SECRETS_FILES_NAME"
  if [ -f "$json" ] && [ ! -L "$json" ]; then
    if [ -f "$legacy" ] && [ ! -L "$legacy" ]; then
      echo "WARNING: $legacy is superseded by $SECRETS_JSON_NAME and was ignored on pull. Run 'secrets push' to absorb it, then delete it." >&2
    fi
    _json_external_entries "$json"
    return 0
  fi
  [ -e "$legacy" ] || return 0
  if [ -L "$legacy" ]; then
    echo "WARNING: $legacy is a symlink; ignoring." >&2
    return 0
  fi
  [ -f "$legacy" ] && _parse_secrets_files_manifest "$legacy"
  return 0
}

# JSON array of legacy .secrets-files entries NOT yet in the manifest —
# what cmd_push absorbs. gradle-properties becomes 'properties' on the
# JSON side. Parser warnings suppressed (push_external_files re-parses
# and warns once).
_legacy_absorb_json() {
  local root="$1"
  local json="$root/$SECRETS_JSON_NAME" legacy="$root/$SECRETS_FILES_NAME"
  local out="[]"
  if [ ! -f "$legacy" ] || [ -L "$legacy" ]; then
    printf '%s' "$out"
    return 0
  fi
  local seen=""
  if [ -f "$json" ] && [ ! -L "$json" ]; then
    seen=$(jq -r '.external // [] | .[] | ((if .type == "properties" then "gradle-properties" else .type end) + "|" + .path)' "$json")
  fi
  local t p k s found jtype
  while IFS=$'\t' read -r t p k; do
    [ -n "$t" ] || continue
    found=0
    while IFS= read -r s; do [ "$s" = "$t|$p" ] && { found=1; break; }; done <<< "$seen"
    [ "$found" -eq 1 ] && continue
    jtype="$t"; [ "$t" = "gradle-properties" ] && jtype="properties"
    out=$(printf '%s' "$out" | jq --arg type "$jtype" --arg path "$p" --arg keys "$k" \
      '. + [if $type == "file" then {type: $type, path: $path}
            else {type: $type, path: $path, keys: ($keys | split(" ") | map(select(length > 0)))} end]')
  done < <(_parse_secrets_files_manifest "$legacy" 2>/dev/null)
  printf '%s' "$out"
}

# Quietly emit "ws-dir/basename" for every env file in a package.json
# workspace under <root>. Emits nothing (and never dies) when <root> is
# not a workspace monorepo or jq is unavailable — plain `push` calls this
# speculatively so a new workspace's env files keep getting discovered
# after the one-time --workspaces generator run (EGB-677 E13).
_maybe_workspace_env_files() {
  local root="$1"
  [ -f "$root/package.json" ] || return 0
  command -v jq >/dev/null 2>&1 || return 0
  jq -e '.workspaces' "$root/package.json" >/dev/null 2>&1 || return 0
  local ws f
  while IFS= read -r ws; do
    [ -n "$ws" ] || continue
    if collect_env_files "$root/$ws"; then
      for f in "${COLLECTED_FILES[@]}"; do
        printf '%s/%s\n' "$ws" "$(basename "$f")"
      done
    fi
  done < <(get_workspaces "$root")
}

# ─── End manifest ──────────────────────────────────────────────────────

# Read package.json workspaces and expand globs to actual directories.
# Prints one workspace path per line (relative to the monorepo root).
get_workspaces() {
  local root="$1"
  local pkg="$root/package.json"
  [ -f "$pkg" ] || die "No package.json found in $root"
  check_cmd jq

  local patterns
  patterns=$(jq -r '.workspaces // .workspaces.packages // empty | .[]' "$pkg" 2>/dev/null)
  [ -n "$patterns" ] || die "No workspaces field in $pkg"

  # Expand each glob pattern relative to root
  local old_dir="$PWD"
  cd "$root"
  for pattern in $patterns; do
    # Use bash glob expansion
    for dir in $pattern; do
      [ -d "$dir" ] && echo "$dir"
    done
  done
  cd "$old_dir"
}

install_hook() {
  local hook_src="$SCRIPT_DIR/hooks/pre-commit"
  local hook_dst="$SECRETS_DIR/.git/hooks/pre-commit"
  if [ -f "$hook_src" ]; then
    cp "$hook_src" "$hook_dst"
    chmod +x "$hook_dst"
  else
    # Inline hook if template not found (e.g. secrets installed standalone)
    cat > "$hook_dst" << 'HOOKEOF'
#!/usr/bin/env bash
BLOCKED=$(git diff --cached --name-only | grep -E '(\.env|\.dev\.vars|gradle\.properties)' | grep -v '\.age$' || true)
if [ -n "$BLOCKED" ]; then
  echo "ERROR: Plaintext secret files staged for commit:"
  echo "$BLOCKED"
  echo "Only .age (encrypted) files should be committed."
  exit 1
fi
HOOKEOF
    chmod +x "$hook_dst"
  fi
}

# Write the store-level .gitignore. Critical: the `key.txt` line is what
# keeps the private key out of `git add -A` during push/rekey.
write_store_gitignore() {
  cat > "$SECRETS_DIR/.gitignore" << 'EOF'
# Never commit the private key
key.txt

# Block plaintext secret files
**/.env
**/.env.*
**/.dev.vars

# Allow encrypted files
!**/.env.age
!**/.env.*.age
!**/.dev.vars.age
EOF
}

# Restore store-level protections if missing. A cloned store has no
# pre-commit hook (hooks aren't cloned), and a half-initialized store may
# lack .gitignore — without it, `git add -A` would commit key.txt.
ensure_store_protections() {
  # Content-aware: a present-but-corrupted .gitignore missing the key.txt
  # line is just as dangerous as a missing one.
  if [ ! -f "$SECRETS_DIR/.gitignore" ] || ! grep -qx 'key.txt' "$SECRETS_DIR/.gitignore"; then
    write_store_gitignore
    info "Restored store .gitignore"
  fi
  # .gitignore can't untrack an already-tracked key (legacy damage, or a
  # past window where .gitignore was missing). Remove it from the index so
  # the next commit drops it from the tip.
  if git -C "$SECRETS_DIR" ls-files --error-unmatch key.txt >/dev/null 2>&1; then
    git -C "$SECRETS_DIR" rm --cached --quiet key.txt
    echo "WARNING: key.txt was tracked in the store repo — untracked it now." >&2
    echo "It may still exist in git history; consider 'secrets rekey' and scrubbing history." >&2
  fi
  if [ ! -x "$SECRETS_DIR/.git/hooks/pre-commit" ]; then
    mkdir -p "$SECRETS_DIR/.git/hooks"
    install_hook
    info "Reinstalled pre-commit hook"
  fi
}

# ─── Subcommands ───────────────────────────────────────────────────────

cmd_init() {
  check_cmd age
  check_cmd git
  resolve_store

  if [ -d "$SECRETS_DIR/.git" ]; then
    die "Already initialized at $SECRETS_DIR. Key file preserved."
  fi

  # Second-machine trap: a copied key.txt without a repo means the user
  # should clone their existing secrets repo, not init a fresh one.
  # Catch it BEFORE git init so we don't leave a half-initialized store.
  if [ -f "$KEY_FILE" ]; then
    # Render a runnable clone command when .secrets-store carried a remote
    # URL (already sanitized by resolve_store), mirroring check_initialized.
    local clone_src="<your-secrets-remote>"
    [ -n "${_REMOTE_URL:-}" ] && clone_src="$_REMOTE_URL"
    die "Found an existing key at $KEY_FILE but no repo at $SECRETS_DIR.
If this is a second machine, don't run 'secrets init' — clone your existing secrets repo instead:

    git clone $clone_src $SECRETS_DIR

Your key file has been left untouched."
  fi

  info "Initializing secrets repo at $SECRETS_DIR"
  mkdir -p "$SECRETS_DIR"
  git init "$SECRETS_DIR" >/dev/null

  # Generate age key pair
  info "Generating age key pair"
  age-keygen -o "$KEY_FILE" 2>&1

  # Write .gitignore
  write_store_gitignore

  # Stamp the store format (EGB-703): a fresh store is born v2 — it has no
  # v1 blobs, so it is already in v2 shape. The marker is a committed,
  # non-secret metadata file (NOT gitignored); the first push stages it.
  printf '2\n' > "$SECRETS_DIR/$STORE_FORMAT_FILE_NAME"

  # Install pre-commit hook
  mkdir -p "$SECRETS_DIR/.git/hooks"
  install_hook

  local pubkey
  pubkey=$(get_pubkey)

  info "Done! Your public key is:"
  echo "  $pubkey"
  echo ""
  echo "Next steps:"
  echo "  1. Add a remote:  cd $SECRETS_DIR && git remote add origin <url>"
  echo "  2. Copy $KEY_FILE to your other machine (AirDrop, scp, USB)"
  echo "  3. Run 'secrets push <project>' from a project directory"
}

# Encrypt env files from a source dir into a project path in the secrets repo.
# Does NOT commit or push — caller handles that.
push_dir_to_project() {
  local source_dir="$1"
  local project="$2"
  local pubkey="$3"

  if ! collect_env_files "$source_dir"; then
    return 1
  fi

  info "$project: ${#COLLECTED_FILES[@]} file(s)"
  for f in "${COLLECTED_FILES[@]}"; do
    echo "  $(basename "$f")"
  done

  mkdir -p "$SECRETS_DIR/$project"
  for f in "${COLLECTED_FILES[@]}"; do
    local name
    name=$(basename "$f")
    age -r "$pubkey" -o "$SECRETS_DIR/$project/${name}.age" "$f"
  done
  return 0
}

# Git commit + push for the secrets repo. Shared by push and push --workspaces.
commit_and_push_secrets() {
  local message="$1"

  if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
    if ! git -C "$SECRETS_DIR" pull --ff-only 2>/dev/null; then
      die "Fast-forward pull failed. Run 'secrets pull' first, then retry push."
    fi
  fi

  # Must run AFTER the pull and immediately before `git add -A`: the pull
  # can remove or alter .gitignore (remote history that lacks it), and a
  # store missing the key.txt line would stage and push the private key.
  ensure_store_protections

  git -C "$SECRETS_DIR" add -A
  if git -C "$SECRETS_DIR" diff --cached --quiet 2>/dev/null; then
    info "No changes to push (secrets unchanged)"
    return
  fi
  git -C "$SECRETS_DIR" commit -m "$message" >/dev/null
  if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
    git -C "$SECRETS_DIR" push >/dev/null 2>&1
    info "Pushed to remote"
  else
    info "Committed locally (no remote configured)"
  fi
}

# Manifest-aware push (EGB-677 stage 1). Discovery (root globs + a quiet
# package.json workspace re-scan when a manifest exists) feeds the
# manifest as a GENERATOR; the sync itself runs FROM the manifest. The
# v1 store layout is unchanged: root files land at <project>/<name>.age,
# nested entries at <project>/<relpath>.age (same shape -w always used).
cmd_push() {
  local frozen=false dry_run=false explicit_project=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --frozen) frozen=true; shift ;;
      --dry-run) dry_run=true; shift ;;
      -*) die "Unknown push flag: $1. Usage: secrets push [--frozen] [--dry-run] [project]" ;;
      *) explicit_project="$1"; shift ;;
    esac
  done

  check_cmd age
  check_cmd git
  resolve_store
  check_initialized
  check_key

  local project
  project=$(derive_project_name "$explicit_project")
  info "Pushing secrets for project: $project"
  echo_store_if_non_default

  local pubkey
  pubkey=$(get_pubkey)

  # ── Manifest read (validated; absence = bootstrap) ──
  # jq is required only when a manifest exists (authoritative, can't be
  # ignored) or is being written. Without jq on a manifest-less project,
  # manifest features are skipped with a notice — clone-and-run for v1
  # users survives.
  local have_jq=true
  command -v jq >/dev/null 2>&1 || have_jq=false
  local manifest="$PWD/$SECRETS_JSON_NAME"
  local have_manifest=false auto_add=true declared=""
  if [ -e "$manifest" ]; then
    _check_manifest_file "$manifest"
    have_manifest=true
    declared=$(jq -r '.dotenv // [] | .[]' "$manifest")
    local d
    while IFS= read -r d; do
      [ -n "$d" ] || continue
      _validate_dotenv_rel_path "$d" \
        || die "Refusing unsafe dotenv path in $SECRETS_JSON_NAME (paths must be project-relative): $d"
    done <<< "$declared"
    # NB: jq's // treats false as empty, so `.options.autoAdd // true`
    # would silently flip an explicit false back to true. Compare directly.
    auto_add=$(jq -r '.options.autoAdd | if . == false then "false" else "true" end' "$manifest")
  fi
  [ "$frozen" = true ] && auto_add=false

  # ── Discovery: root globs + workspace re-scan (manifest projects) ──
  local discovered="" f
  if collect_env_files "$PWD"; then
    for f in "${COLLECTED_FILES[@]}"; do
      discovered="$discovered$(basename "$f")"$'\n'
    done
  fi
  if [ "$have_manifest" = true ]; then
    discovered="$discovered$(_maybe_workspace_env_files "$PWD")"$'\n'
  fi

  # to_add = discovered − declared (deduped; pure bash 3.2, no assoc arrays)
  local to_add="" e known
  while IFS= read -r e; do
    [ -n "$e" ] || continue
    known=0
    while IFS= read -r d; do [ "$d" = "$e" ] && { known=1; break; }; done <<< "$declared"
    [ "$known" -eq 1 ] && continue
    while IFS= read -r d; do [ "$d" = "$e" ] && { known=1; break; }; done <<< "$to_add"
    [ "$known" -eq 1 ] && continue
    to_add="$to_add$e"$'\n'
  done <<< "$discovered"

  if [ "$dry_run" = true ]; then
    info "Dry run — nothing encrypted, nothing written."
    if [ -n "$to_add" ]; then
      echo "Would add to $SECRETS_JSON_NAME:"
      while IFS= read -r e; do [ -n "$e" ] && echo "  $e"; done <<< "$to_add"
    else
      echo "Nothing new to add to $SECRETS_JSON_NAME."
    fi
    if [ -n "$declared" ]; then
      echo "Would sync (declared):"
      while IFS= read -r e; do [ -n "$e" ] && echo "  $e"; done <<< "$declared"
    fi
    return 0
  fi

  # ── Build the sync list ──
  local sync_list="$declared"
  if [ "$auto_add" = true ] || [ "$have_manifest" = false ]; then
    sync_list="$declared"$'\n'"$to_add"
  else
    while IFS= read -r e; do
      [ -n "$e" ] || continue
      echo "WARNING: '$e' is not declared in $SECRETS_JSON_NAME and autoAdd is off — not synced. Run: secrets add $e" >&2
    done <<< "$to_add"
  fi

  # ── Encrypt FROM the (effective) manifest ──
  local count=0 rel
  while IFS= read -r rel; do
    [ -n "$rel" ] || continue
    if [ ! -f "$PWD/$rel" ]; then
      echo "WARNING: '$rel' is declared in $SECRETS_JSON_NAME but not found in $PWD — skipping." >&2
      continue
    fi
    case "$rel" in
      */*) mkdir -p "$SECRETS_DIR/$project/$(dirname "$rel")" ;;
      *)   mkdir -p "$SECRETS_DIR/$project" ;;
    esac
    age -r "$pubkey" -o "$SECRETS_DIR/$project/${rel}.age" "$PWD/$rel"
    echo "  $rel"
    count=$((count + 1))
  done <<< "$sync_list"
  [ "$count" -gt 0 ] && info "$project: $count file(s)"

  local did=0
  [ "$count" -gt 0 ] && did=1
  if push_external_files "$PWD" "$project" "$pubkey"; then did=1; fi
  if [ "$did" -eq 0 ]; then
    die "No secret files (.env, .env.*, .dev.vars) or $SECRETS_FILES_NAME entries found in $PWD"
  fi

  # ── Manifest write AFTER successful encryption (bootstrap ordering) ──
  # Two independent reasons to write: dotenv auto-adds, and absorbing a
  # legacy .secrets-files (gradle-properties → properties) so the two
  # external sources converge on the manifest.
  if [ "$have_jq" = false ]; then
    echo "NOTE: jq not found — skipping $SECRETS_JSON_NAME manifest features (auto-add, absorb). Install jq to enable them." >&2
  fi
  local absorbed_json="[]" n_absorbed=0
  if [ "$frozen" = false ] && [ "$have_jq" = true ]; then
    absorbed_json=$(_legacy_absorb_json "$PWD")
    n_absorbed=$(printf '%s' "$absorbed_json" | jq 'length')
  fi
  local write_adds=false
  if [ -n "$to_add" ] && { [ "$auto_add" = true ] || [ "$have_manifest" = false ]; }; then
    write_adds=true
  fi
  if [ "$did" -eq 1 ] && [ "$frozen" = false ] && [ "$have_jq" = true ] \
     && { [ "$write_adds" = true ] || [ "$n_absorbed" -gt 0 ]; }; then
    local add_json="[]"
    [ "$write_adds" = true ] && add_json=$(printf '%s' "$to_add" | jq -R -s 'split("\n") | map(select(length > 0))')
    if [ "$have_manifest" = true ]; then
      jq --argjson add "$add_json" --argjson ext "$absorbed_json" \
        '.dotenv = ((.dotenv // []) + $add) | .external = ((.external // []) + $ext)' "$manifest" \
        | _write_manifest_canonical "$manifest" || die "Failed to update $manifest"
    else
      jq -n --argjson add "$add_json" --argjson ext "$absorbed_json" \
        '{version: '"$MANIFEST_VERSION"', dotenv: $add} | if ($ext | length) > 0 then .external = $ext else . end' \
        | _write_manifest_canonical "$manifest" || die "Failed to write $manifest"
    fi
    if [ "$write_adds" = true ]; then
      while IFS= read -r e; do
        [ -n "$e" ] && info "Added '$e' to $SECRETS_JSON_NAME"
      done <<< "$to_add"
    fi
    if [ "$n_absorbed" -gt 0 ]; then
      info "Absorbed $n_absorbed entr(y/ies) from $SECRETS_FILES_NAME into $SECRETS_JSON_NAME (gradle-properties → properties). $SECRETS_FILES_NAME can be deleted."
    fi
    info "Commit the manifest so other machines pick it up. To undo an entry: edit $SECRETS_JSON_NAME (or use 'secrets push --frozen' to skip auto-add)."
  fi

  commit_and_push_secrets "update $project"
}

cmd_push_workspaces() {
  check_cmd age
  check_cmd git
  check_cmd jq
  resolve_store
  check_initialized
  check_key

  local root="$PWD"
  local monorepo_name
  monorepo_name=$(derive_project_name "")
  info "Pushing workspaces for monorepo: $monorepo_name"
  echo_store_if_non_default

  local pubkey
  pubkey=$(get_pubkey)
  local total=0

  # Push root env files (if any)
  if push_dir_to_project "$root" "$monorepo_name" "$pubkey"; then
    total=$((total + ${#COLLECTED_FILES[@]}))
  fi

  # Push each workspace
  local workspaces
  workspaces=$(get_workspaces "$root")
  while IFS= read -r ws; do
    [ -n "$ws" ] || continue
    local ws_dir="$root/$ws"
    local ws_project="$monorepo_name/$ws"
    if push_dir_to_project "$ws_dir" "$ws_project" "$pubkey"; then
      total=$((total + ${#COLLECTED_FILES[@]}))
    fi
  done <<< "$workspaces"

  # External files (.secrets-files) are monorepo-root-scoped, like
  # .secrets-store — handle once, not per-workspace.
  if push_external_files "$root" "$monorepo_name" "$pubkey"; then
    total=$((total + 1))
  fi

  if [ "$total" -eq 0 ]; then
    die "No secret files found in any workspace"
  fi

  commit_and_push_secrets "update $monorepo_name workspaces"
}

cmd_pull() {
  check_cmd age
  check_cmd git
  resolve_store
  check_initialized
  check_key

  local project
  project=$(derive_project_name "${1:-}")
  local target_dir="$PWD"
  info "Pulling secrets for project: $project"
  echo_store_if_non_default

  # Pull latest
  if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
    git -C "$SECRETS_DIR" pull >/dev/null 2>&1
  fi

  # ── Manifest-driven pull (EGB-677 stage 1) ──
  # With a .secrets.json present, the manifest decides what restores and
  # where (nested entries get their directories created). The dotenv rail
  # runs again at restore time — warn+skip on pull, never die, so one bad
  # entry can't block the rest of the restore.
  local manifest="$PWD/$SECRETS_JSON_NAME"
  if [ -e "$manifest" ]; then
    _check_manifest_file "$manifest"
    local declared n_external
    declared=$(jq -r '.dotenv // [] | .[]' "$manifest")
    n_external=$(jq -r '.external // [] | length' "$manifest")

    if [ -z "$declared" ] && [ "$n_external" -eq 0 ]; then
      echo "WARNING: $SECRETS_JSON_NAME declares nothing to pull (empty manifest). Run 'secrets push' on a machine that has the files." >&2
      ensure_store_protections
      return 0
    fi
    if [ -n "$declared" ] && [ ! -d "$SECRETS_DIR/$project" ]; then
      die "Project '$project' not found. Run: secrets list"
    fi

    local count=0 rel
    while IFS= read -r rel; do
      [ -n "$rel" ] || continue
      if ! _validate_dotenv_rel_path "$rel" 2>/dev/null; then
        echo "WARNING: skipping unsafe dotenv path from $SECRETS_JSON_NAME: $rel" >&2
        continue
      fi
      local blob="$SECRETS_DIR/$project/${rel}.age"
      if [ ! -f "$blob" ]; then
        echo "WARNING: '$rel' is declared in $SECRETS_JSON_NAME but has no encrypted data in the store yet. Run 'secrets push' on a machine that has it. Skipping." >&2
        continue
      fi
      case "$rel" in */*) mkdir -p "$target_dir/$(dirname "$rel")" ;; esac
      age -d -i "$KEY_FILE" -o "$target_dir/$rel" "$blob"
      if [ ! -s "$target_dir/$rel" ]; then
        echo "WARNING: Decrypted file '$rel' is empty (possibly truncated .age blob)"
      fi
      count=$((count + 1))
    done <<< "$declared"

    info "Decrypted $count file(s) into $target_dir"
    pull_external_files "$PWD" "$project"
    ensure_store_protections
    return 0
  fi

  # ── Legacy glob pull (manifest-less projects; unchanged) ──
  # Check project exists
  if [ ! -d "$SECRETS_DIR/$project" ]; then
    die "Project '$project' not found. Run: secrets list"
  fi

  # Decrypt each .age file into target dir (including dotfiles)
  local count=0
  for f in "$SECRETS_DIR/$project"/*.age "$SECRETS_DIR/$project"/.*.age; do
    [ -f "$f" ] || continue
    local name
    name=$(basename "$f" .age)
    local outfile="$target_dir/$name"
    age -d -i "$KEY_FILE" -o "$outfile" "$f"
    # Integrity check: verify non-empty
    if [ ! -s "$outfile" ]; then
      echo "WARNING: Decrypted file '$name' is empty (possibly truncated .age blob)"
    fi
    count=$((count + 1))
  done

  info "Decrypted $count file(s) into $target_dir"

  # Merge any external files (.secrets-files) declared in this project.
  pull_external_files "$PWD" "$project"

  # Reinstall hook / store .gitignore if missing
  ensure_store_protections
}

# Pull and decrypt .age files from a project path into a target directory.
# Does NOT do git pull — caller handles that.
pull_project_to_dir() {
  local project="$1"
  local target_dir="$2"
  local project_dir="$SECRETS_DIR/$project"

  [ -d "$project_dir" ] || return 1

  local count=0
  for f in "$project_dir"/*.age "$project_dir"/.*.age; do
    [ -f "$f" ] || continue
    local name
    name=$(basename "$f" .age)
    local outfile="$target_dir/$name"
    age -d -i "$KEY_FILE" -o "$outfile" "$f"
    if [ ! -s "$outfile" ]; then
      echo "WARNING: Decrypted file '$name' is empty (possibly truncated .age blob)"
    fi
    count=$((count + 1))
  done
  echo "$count"
}

cmd_pull_workspaces() {
  check_cmd age
  check_cmd git
  check_cmd jq
  resolve_store
  check_initialized
  check_key

  local root="$PWD"
  local monorepo_name
  monorepo_name=$(derive_project_name "")
  info "Pulling workspaces for monorepo: $monorepo_name"
  echo_store_if_non_default

  # Pull latest from remote
  if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
    git -C "$SECRETS_DIR" pull >/dev/null 2>&1
  fi

  local total=0

  # Pull root secrets (if any)
  if [ -d "$SECRETS_DIR/$monorepo_name" ]; then
    local count
    count=$(pull_project_to_dir "$monorepo_name" "$root")
    total=$((total + count))
    info "$monorepo_name (root): $count file(s)"
  fi

  # Pull each workspace
  local workspaces
  workspaces=$(get_workspaces "$root")
  while IFS= read -r ws; do
    [ -n "$ws" ] || continue
    local ws_dir="$root/$ws"
    local ws_project="$monorepo_name/$ws"
    if [ -d "$SECRETS_DIR/$ws_project" ]; then
      local count
      count=$(pull_project_to_dir "$ws_project" "$ws_dir")
      total=$((total + count))
      info "$ws_project: $count file(s)"
    fi
  done <<< "$workspaces"

  # External files (.secrets-files) are monorepo-root-scoped — once.
  if [ -f "$root/$SECRETS_FILES_NAME" ] && [ ! -L "$root/$SECRETS_FILES_NAME" ]; then
    pull_external_files "$root" "$monorepo_name"
    total=$((total + 1))
  fi

  if [ "$total" -eq 0 ]; then
    die "No secrets found for any workspace in $monorepo_name"
  fi

  info "Decrypted $total file(s) total"

  # Reinstall hook / store .gitignore if missing
  ensure_store_protections
}

cmd_list() {
  resolve_store
  check_initialized

  local found=0
  for dir in "$SECRETS_DIR"/*/; do
    [ -d "$dir" ] || continue
    local project
    project=$(basename "$dir")
    # Skip hidden dirs
    [[ "$project" == .* ]] && continue
    echo "$project:"
    # Recurse the whole project tree so nested manifest blobs
    # (<project>/<relpath>.age) are visible, not just top-level entries.
    # External blobs (external/<slug>.age) are labelled distinctly.
    while IFS= read -r f; do
      [ -f "$f" ] || continue
      local rel
      rel=${f#"$dir"}
      rel=${rel%.age}
      case "$rel" in
        external/*) echo "  [external] ${rel#external/}" ;;
        *)          echo "  $rel" ;;
      esac
      found=1
    done < <(find "$dir" -type f -name '*.age' | sort)
  done

  if [ "$found" -eq 0 ]; then
    echo "No projects found. Run 'secrets push <project>' to add one."
  fi

  # DX-7 hint: when a non-default store is active, point users at `secrets which`.
  if [ "$SECRETS_DIR" != "$HOME/.secrets" ]; then
    echo ""
    info "Showing projects in $SECRETS_DIR. Run 'secrets which' for details."
  fi
}

cmd_rm() {
  check_cmd git
  resolve_store
  check_initialized

  local project="${1:-}"
  [ -n "$project" ] || die "Usage: secrets rm <project>"

  if [ ! -d "$SECRETS_DIR/$project" ]; then
    die "Project '$project' not found. Run: secrets list"
  fi

  info "Removing project: $project"
  git -C "$SECRETS_DIR" rm -r "$project/" >/dev/null
  git -C "$SECRETS_DIR" commit -m "remove $project" >/dev/null
  if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
    git -C "$SECRETS_DIR" push >/dev/null 2>&1
    info "Removed $project from remote"
  else
    info "Removed $project locally (no remote configured)"
  fi
}

cmd_rekey() {
  check_cmd age
  check_cmd git
  resolve_store
  check_initialized
  check_key

  # Create temp dir with cleanup trap
  local tmpdir
  tmpdir=$(mktemp -d)
  # `${tmpdir:-}` guards against `set -u` if the trap somehow fires after the
  # function returns (the local would be out of scope). Normal completion
  # cleans up explicitly and clears the trap at the end of this function.
  trap 'rm -rf "${tmpdir:-}"' EXIT INT TERM

  info "Decrypting all files with current key..."

  # Decrypt all .age files into temp dir
  local file_count=0
  for dir in "$SECRETS_DIR"/*/; do
    [ -d "$dir" ] || continue
    local project
    project=$(basename "$dir")
    [[ "$project" == .* ]] && continue
    mkdir -p "$tmpdir/$project"
    # Walk the WHOLE project tree, not just its top level. Manifest dotenv
    # entries can nest (<project>/<relpath>.age) and external blobs live in
    # <project>/external/<slug>.age. A non-recursive glob would skip both,
    # leaving them encrypted under the old key = permanently undecryptable
    # after rotation (silent data loss). `find` is bash-3.2 safe and recurses.
    while IFS= read -r f; do
      [ -f "$f" ] || continue
      local rel dest
      rel=${f#"$dir"}        # path relative to the project dir (keeps .age)
      rel=${rel%.age}        # strip the .age suffix → original relpath
      dest="$tmpdir/$project/$rel"
      mkdir -p "$(dirname "$dest")"
      if ! age -d -i "$KEY_FILE" -o "$dest" "$f"; then
        die "Decryption failed for $project/$rel. Rekey aborted. Old key preserved."
      fi
      file_count=$((file_count + 1))
    done < <(find "$dir" -type f -name '*.age')
  done

  if [ "$file_count" -eq 0 ]; then
    die "No encrypted files found. Nothing to rekey."
  fi

  info "Decrypted $file_count file(s). Generating new key pair..."

  # Generate the new key. age-keygen refuses to overwrite an existing
  # file, so generate into the temp dir and move it into place only on
  # success — the old key stays intact if generation fails.
  local newkey="$tmpdir/key.txt.new"
  age-keygen -o "$newkey" 2>&1
  mv "$newkey" "$KEY_FILE"
  local pubkey
  pubkey=$(get_pubkey)

  info "Re-encrypting all files with new key..."

  # Re-encrypt all files. `find -type f` recurses into nested dotenv dirs and
  # external/ and natively includes dotfiles (decrypted dotenv files like
  # "$tmpdir/p/.env"), which a bare "*" glob would silently skip — leaving
  # their blobs on the old key (undecryptable after rotation). The walk mirrors
  # the recursive decrypt above so every blob round-trips back to its relpath.
  for dir in "$tmpdir"/*/; do
    [ -d "$dir" ] || continue
    local project
    project=$(basename "$dir")
    mkdir -p "$SECRETS_DIR/$project"
    while IFS= read -r f; do
      [ -f "$f" ] || continue
      local rel
      rel=${f#"$dir"}        # path relative to the project temp dir
      mkdir -p "$(dirname "$SECRETS_DIR/$project/$rel")"
      age -r "$pubkey" -o "$SECRETS_DIR/$project/${rel}.age" "$f"
    done < <(find "$dir" -type f)
  done

  # Commit and push (heal .gitignore first so add -A can't stage key.txt)
  ensure_store_protections
  git -C "$SECRETS_DIR" add -A
  git -C "$SECRETS_DIR" commit -m "rekey all secrets" >/dev/null
  if git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1; then
    git -C "$SECRETS_DIR" push >/dev/null 2>&1
    info "Pushed rekeyed secrets to remote"
  else
    info "Committed rekeyed secrets locally (no remote configured)"
  fi

  info "Rekey complete!"
  echo ""
  echo "IMPORTANT: Copy new key to your other machine:"
  echo "  scp $KEY_FILE <other-machine>:$KEY_FILE"
  echo ""
  echo "WARNING: Old ciphertext remains in git history."
  echo "For full rotation, create a fresh repo."

  # Clean up the plaintext temp dir while $tmpdir is still in scope, then
  # drop the trap so it can't fire (and error under set -u) post-return.
  rm -rf "$tmpdir"
  trap - EXIT INT TERM
}

cmd_clear() {
  local dir="$PWD"
  if ! collect_env_files "$dir"; then
    info "No secret files to clear in $dir"
    return
  fi

  local count=0
  for f in "${COLLECTED_FILES[@]}"; do
    rm "$f"
    count=$((count + 1))
  done
  info "Cleared $count secret file(s) from $dir"
}

cmd_clear_workspaces() {
  check_cmd jq

  local root="$PWD"
  local total=0

  # Clear root
  if collect_env_files "$root"; then
    for f in "${COLLECTED_FILES[@]}"; do
      rm "$f"
      total=$((total + 1))
    done
  fi

  # Clear each workspace
  local workspaces
  workspaces=$(get_workspaces "$root")
  while IFS= read -r ws; do
    [ -n "$ws" ] || continue
    local ws_dir="$root/$ws"
    if collect_env_files "$ws_dir"; then
      for f in "${COLLECTED_FILES[@]}"; do
        rm "$f"
        total=$((total + 1))
      done
    fi
  done <<< "$workspaces"

  info "Cleared $total secret file(s) from workspace"
}

# EXIT-trap helpers for cmd_run. Defined as functions (not inline string
# traps) so $_RUN_PWD is dereferenced safely regardless of special chars
# in the path. F1: trap "cd '$X'" breaks when X contains a single quote.
_run_cleanup() {
  cd -- "${_RUN_PWD:-.}" 2>/dev/null && cmd_clear
}
_run_cleanup_workspaces() {
  cd -- "${_RUN_PWD:-.}" 2>/dev/null && cmd_clear_workspaces
}

cmd_run() {
  local workspace_mode=false
  local project=""

  # Parse flags before the command
  while [ $# -gt 0 ]; do
    case "$1" in
      -w|--workspaces) workspace_mode=true; shift ;;
      --) shift; break ;;
      -*) die "Unknown flag: $1. Usage: secrets run [-w] [--] <command...>" ;;
      *) break ;;
    esac
  done

  [ $# -gt 0 ] || die "Usage: secrets run [-w] [--] <command...>"

  # F8: pin the project directory now so the EXIT trap clears the right
  # plaintext files even if the user's command does `cd` into another dir.
  # Use a global + named function (NOT string-interpolated trap) so paths
  # with special characters — apostrophes, dollar signs, spaces — work.
  # Single-quoting `$_RUN_PWD` into a string trap would break on any path
  # with a single quote (e.g. /Users/bri/it's-app), and the trap would
  # silently fail to clean up plaintext secrets. EGB-281 F1.
  _RUN_PWD="$PWD"

  # Pull secrets (cmd_pull/cmd_pull_workspaces call resolve_store internally)
  if [ "$workspace_mode" = true ]; then
    cmd_pull_workspaces
  else
    cmd_pull "$project"
  fi

  if [ "$workspace_mode" = true ]; then
    trap _run_cleanup_workspaces EXIT
  else
    trap _run_cleanup EXIT
  fi

  # Execute the command, capturing exit code (don't let set -e kill us)
  local rc=0
  "$@" || rc=$?
  exit "$rc"
}

cmd_which() {
  resolve_store
  echo "store:  $SECRETS_DIR"
  echo "source: $STORE_SOURCE"
  # EGB-700: surface the store format so users can tell v1 from v2 during the
  # migration window. A v1 store is a legacy store with no format marker.
  echo "format: v$(_store_format)"

  # v2 manifest (.secrets.json): validate and summarize. Validation here
  # is deliberately fatal (symlink / malformed / unsupported version) so
  # `secrets which` doubles as the manifest linter.
  local json_manifest="$PWD/$SECRETS_JSON_NAME"
  if [ -e "$json_manifest" ]; then
    _check_manifest_file "$json_manifest"
    echo "manifest ($SECRETS_JSON_NAME at $json_manifest):"
    local entry
    while IFS= read -r entry; do
      [ -n "$entry" ] || continue
      if _validate_dotenv_rel_path "$entry"; then
        echo "  dotenv    $entry"
      else
        echo "  dotenv    $entry  [UNSAFE — will be refused]"
      fi
    done < <(jq -r '.dotenv // [] | .[]' "$json_manifest")
    local etype epath ekeys
    while IFS=$'\t' read -r etype epath ekeys; do
      [ -n "$etype" ] || continue
      echo "  $etype  $epath  $ekeys"
    done < <(jq -r '.external // [] | .[] | [.type, .path, ((.keys // []) | join(" "))] | @tsv' "$json_manifest")
  fi

  # Read back any external-file manifest in cwd (validates the format and
  # gives the user a way to confirm it parsed, since there's no add-file
  # command). Skips symlinked manifests.
  local manifest="$PWD/$SECRETS_FILES_NAME"
  if [ -f "$manifest" ] && [ ! -L "$manifest" ]; then
    local mtype mpath mkeys printed=0
    while IFS=$'\t' read -r mtype mpath mkeys; do
      [ -n "$mtype" ] || continue
      if [ "$printed" -eq 0 ]; then
        echo "external files ($SECRETS_FILES_NAME at $manifest):"
        printed=1
      fi
      echo "  $mtype  $mpath  $mkeys"
    done < <(_parse_secrets_files_manifest "$manifest")
  fi
}

# Decrypt-test one blob with the current key. Plaintext is streamed to
# /dev/null and never written to disk (read-only contract). Returns 0 if the
# blob decrypts, non-zero otherwise.
_verify_blob_decrypts() {
  age -d -i "$KEY_FILE" "$1" >/dev/null 2>&1
}

# `secrets verify --all` — store-wide decrypt sweep. Decrypt-tests every blob
# in every project. No manifest consistency check: the store carries only
# ciphertext (manifests live in each project's repo), so orphan/missing
# detection is impossible store-wide. This is the migration integrity gate.
_verify_all() {
  local checked=0 failed=0 dir project f rel
  for dir in "$SECRETS_DIR"/*/; do
    [ -d "$dir" ] || continue
    project=$(basename "$dir")
    [[ "$project" == .* ]] && continue
    # Recurse the whole project tree (top-level / nested / external) — never a
    # non-recursive glob, or nested blobs would be silently skipped (the exact
    # class of bug that orphaned nested blobs on rekey before v0.4.0.0).
    while IFS= read -r f; do
      [ -f "$f" ] || continue
      checked=$((checked + 1))
      if ! _verify_blob_decrypts "$f"; then
        rel="${f#"$SECRETS_DIR"/}"
        echo "FAIL: $rel does not decrypt with the current key." >&2
        failed=$((failed + 1))
      fi
    done < <(find "$dir" -type f -name '*.age')
  done
  if [ "$checked" -eq 0 ]; then
    info "verify --all: store is empty — nothing to check."
    return 0
  fi
  if [ "$failed" -gt 0 ]; then
    echo "verify --all: $failed of $checked blob(s) failed to decrypt." >&2
    return 1
  fi
  info "verify --all: OK — all $checked blob(s) decrypt with the current key (integrity only; run 'secrets verify' in a project for manifest consistency)."
}

# `secrets verify [project]` — current-project consistency + decrypt check.
# Cross-checks the committed .secrets.json against the store both ways
# (declared-but-missing blobs, orphaned blobs) and decrypt-tests every blob.
_verify_project() {
  local explicit_project="$1"
  local manifest="$PWD/$SECRETS_JSON_NAME"
  if [ ! -e "$manifest" ]; then
    die "No $SECRETS_JSON_NAME in $PWD.
  'secrets verify' checks a project's manifest against the store. Either cd
  into a project that has a $SECRETS_JSON_NAME, or run 'secrets verify --all'
  for a store-wide decrypt sweep."
  fi
  _check_manifest_file "$manifest"   # fatal on symlink / malformed / version

  local project
  project=$(derive_project_name "$explicit_project")
  local pdir="$SECRETS_DIR/$project"

  local findings=0 checked=0
  # `expected` accumulates the store-relative blob paths the manifest implies,
  # newline-framed (leading + trailing \n per entry) so the orphan walk can
  # test membership. bash 3.2 has no associative arrays — this string-set +
  # case match mirrors the `seen` pattern in _external_entries_for_push.
  local expected=$'\n'

  # ── dotenv entries: rail + missing-blob + decrypt ──
  local rel blob
  while IFS= read -r rel; do
    [ -n "$rel" ] || continue
    if ! _validate_dotenv_rel_path "$rel" 2>/dev/null; then
      echo "FINDING: unsafe dotenv path in $SECRETS_JSON_NAME: '$rel' (will be refused)." >&2
      findings=$((findings + 1))
      # Still account for it so a matching stray blob isn't ALSO flagged as an
      # orphan (one bad entry → one finding, not two).
      expected="$expected$rel.age"$'\n'
      continue
    fi
    expected="$expected$rel.age"$'\n'
    blob="$pdir/$rel.age"
    if [ ! -f "$blob" ]; then
      echo "FINDING: '$rel' is declared in $SECRETS_JSON_NAME but has no blob in the store ($project/$rel.age missing). Run 'secrets push'." >&2
      findings=$((findings + 1))
      continue
    fi
    checked=$((checked + 1))
    if ! _verify_blob_decrypts "$blob"; then
      echo "FINDING: blob for '$rel' ($project/$rel.age) does not decrypt with the current key." >&2
      findings=$((findings + 1))
    fi
  done < <(jq -r '.dotenv // [] | .[]' "$manifest")

  # ── external entries: missing-blob + decrypt ──
  # verify only needs type + path to locate the blob; keys are irrelevant here.
  local etype epath slug erel eblob
  while IFS=$'\t' read -r etype epath _; do
    [ -n "$etype" ] || continue
    slug=$(_secrets_files_slug "$epath")
    erel="external/$slug.$(_external_blob_suffix "$etype").age"
    expected="$expected$erel"$'\n'
    eblob="$pdir/$erel"
    if [ ! -f "$eblob" ]; then
      echo "FINDING: external '$epath' ($etype) is declared but has no blob in the store ($project/$erel missing). Run 'secrets push'." >&2
      findings=$((findings + 1))
      continue
    fi
    checked=$((checked + 1))
    if ! _verify_blob_decrypts "$eblob"; then
      echo "FINDING: external blob for '$epath' ($project/$erel) does not decrypt with the current key." >&2
      findings=$((findings + 1))
    fi
  done < <(_json_external_entries "$manifest")

  # ── orphan detection: any stored blob the manifest doesn't account for ──
  if [ -d "$pdir" ]; then
    local f frel
    while IFS= read -r f; do
      [ -f "$f" ] || continue
      frel="${f#"$pdir"/}"
      case "$expected" in
        *$'\n'"$frel"$'\n'*) : ;;   # accounted for by a manifest entry
        *)
          echo "FINDING: orphan blob $project/$frel has no entry in $SECRETS_JSON_NAME." >&2
          findings=$((findings + 1))
          ;;
      esac
    done < <(find "$pdir" -type f -name '*.age')
  fi

  if [ "$findings" -gt 0 ]; then
    echo "verify: $findings finding(s) for project '$project'." >&2
    return 1
  fi
  info "verify: OK — $checked blob(s) verified for '$project' (manifest and store agree; all decrypt)."
}

# `secrets verify [--all] [project]` — read-only integrity check. Exits
# non-zero on any finding so it can gate `migrate --finalize` and CI.
cmd_verify() {
  resolve_store
  check_initialized
  check_key

  local all=false explicit_project=""
  while [ $# -gt 0 ]; do
    case "$1" in
      --all) all=true; shift ;;
      -*)    die "Unknown verify flag: $1. Usage: secrets verify [--all] [project]" ;;
      *)     explicit_project="$1"; shift ;;
    esac
  done

  if [ "$all" = true ]; then
    _verify_all
  else
    _verify_project "$explicit_project"
  fi
}

# ─── Store-format-v2 migration (EGB-703) ──────────────────────────────
#
# v2 renames the legacy `properties` blob suffix (.gradle-properties.age →
# .properties.age) and marks the store self-describing via .secrets-format.
# Migration is copy-forward and non-destructive until --finalize:
#   secrets migrate --dry-run   # per project: report old→new, write nothing
#   secrets migrate             # per project: write v2 twins beside v1 blobs
#   secrets migrate --finalize  # store-wide: verify, drop v1, stamp v2
# Per-project (needs the project manifest to know which externals are
# `properties`); finalize is store-wide. Mirrors verify's project/--all split.

# Copy-forward (or dry-run preview) for the current project. Reads
# $PWD/.secrets.json; only `properties` external blobs rename in v2.
_migrate_project() {
  local dry_run="$1"
  if [ "$(_store_format)" = "2" ]; then
    info "Store is already format v2 — nothing to migrate."
    return 0
  fi
  local manifest="$PWD/$SECRETS_JSON_NAME"
  if [ ! -e "$manifest" ]; then
    die "No $SECRETS_JSON_NAME in $PWD.
  'secrets migrate' copy-forwards a project's v1 blobs to their v2 names and
  reads the project manifest to do so. cd into a project that has a manifest,
  then run 'secrets migrate'. (Store-wide 'secrets migrate --finalize' comes
  after every project is migrated.)"
  fi
  _check_manifest_file "$manifest"
  local project; project=$(derive_project_name "")
  local pdir="$SECRETS_DIR/$project"

  local moved=0 already=0 would=0 etype epath slug old new
  while IFS=$'\t' read -r etype epath _; do
    [ -n "$etype" ] || continue
    # Only `properties` blobs change name in v2; dotenv and `file` are already
    # in their v2 shape and never move.
    [ "$etype" = "gradle-properties" ] || continue
    slug=$(_secrets_files_slug "$epath")
    old="$pdir/external/$slug.gradle-properties.age"
    new="$pdir/external/$slug.properties.age"
    [ -f "$old" ] || continue          # nothing pushed yet (or already dropped)
    if [ -f "$new" ]; then             # idempotent: twin already exists
      already=$((already + 1))
      continue
    fi
    if [ "$dry_run" = true ]; then
      echo "would migrate: $project/external/$slug.gradle-properties.age -> $slug.properties.age"
      would=$((would + 1))
    else
      cp "$old" "$new"
      moved=$((moved + 1))
    fi
  done < <(_json_external_entries "$manifest")

  if [ "$dry_run" = true ]; then
    echo "migrate --dry-run: $would blob(s) would be copy-forwarded for '$project' (writes nothing); $already already present. v1 blobs are kept until 'secrets migrate --finalize'."
    return 0
  fi
  if [ "$moved" -eq 0 ] && [ "$already" -eq 0 ]; then
    info "Nothing to migrate for '$project' (no v1 properties blobs)."
    return 0
  fi
  ensure_store_protections
  git -C "$SECRETS_DIR" add -A
  git -C "$SECRETS_DIR" commit -m "migrate: copy-forward v2 twins for $project" >/dev/null 2>&1 || true
  info "Copy-forward for '$project': $moved new v2 twin(s), $already already present. v1 blobs kept (non-destructive). Run 'secrets migrate --finalize' once every project is migrated and every machine is upgraded."
}

# Store-wide finalize: the only destructive step. Refuses unless verify --all
# is green and every v1 properties blob has a v2 twin. Cuts a recovery tag,
# stamps the marker, then drops v1 blobs.
_migrate_finalize() {
  local force="$1"
  check_key
  if [ "$(_store_format)" = "2" ]; then
    info "Store is already format v2 — nothing to finalize."
    return 0
  fi

  # (gate 1) every blob must decrypt with the current key.
  info "Verifying every blob decrypts before finalizing..."
  if ! _verify_all >/dev/null 2>&1; then
    die "Refusing to finalize: 'secrets verify --all' is not green — a blob does not decrypt. Run 'secrets verify --all' to see which, fix it, then re-run --finalize."
  fi

  # (gate 2) every v1 properties blob must have a v2 twin (project migrated).
  local untwinned="" f new v1count=0
  while IFS= read -r f; do
    [ -f "$f" ] || continue
    v1count=$((v1count + 1))
    new="${f%.gradle-properties.age}.properties.age"
    [ -f "$new" ] || untwinned="$untwinned  ${f#"$SECRETS_DIR"/}"$'\n'
  done < <(find "$SECRETS_DIR" -type f -name '*.gradle-properties.age')
  if [ -n "$untwinned" ]; then
    die "Refusing to finalize: these v1 properties blobs have no v2 twin (their project was not migrated):
$untwinned  cd into each project and run 'secrets migrate', then re-run 'secrets migrate --finalize'."
  fi
  if [ "$v1count" -eq 0 ]; then
    # No v1 blobs at all — just stamp the marker (dotenv/file-only store).
    printf '2\n' > "$SECRETS_DIR/$STORE_FORMAT_FILE_NAME"
    ensure_store_protections
    git -C "$SECRETS_DIR" add -A
    git -C "$SECRETS_DIR" commit -m "migrate: finalize store format v2" >/dev/null 2>&1 || true
    git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1 && git -C "$SECRETS_DIR" push >/dev/null 2>&1 || true
    info "Store finalized to format v2 (no v1 blobs to drop)."
    return 0
  fi

  # (gate 3) operator confirms every machine is upgraded.
  if [ "$force" != true ]; then
    echo "Finalize will drop $v1count v1 blob(s) and stamp the store format v2."
    echo "Any machine still running a pre-v2 'secrets' will stop seeing 'properties'"
    echo "externals from this store until it upgrades (git pull in the secrets repo)."
    printf "Confirm every machine is upgraded? Type 'yes': "
    local reply=""
    read -r reply < /dev/tty 2>/dev/null || read -r reply || true
    [ "$reply" = "yes" ] || die "Finalize aborted — no confirmation."
  fi

  # Recovery tag BEFORE any mutation: points at the pre-finalize commit (v1
  # blobs intact, no marker), so `git checkout <tag>` fully restores v1.
  local tag="pre-v2-migrate-$(git -C "$SECRETS_DIR" rev-parse --short HEAD 2>/dev/null || echo unknown)"
  git -C "$SECRETS_DIR" tag "$tag" >/dev/null 2>&1 || true

  # Stamp the marker FIRST, then drop v1 blobs. If finalize crashes between
  # the two, the store reads as v2 and the (verified) v2 twins serve every
  # upgraded client; any leftover v1 blobs are harmless orphans (referenced by
  # no v2 suffix lookup) that `secrets verify` will flag and the operator can
  # remove. A re-run of --finalize early-returns (store is already v2).
  printf '2\n' > "$SECRETS_DIR/$STORE_FORMAT_FILE_NAME"
  while IFS= read -r f; do
    [ -f "$f" ] || continue
    rm -f "$f"
  done < <(find "$SECRETS_DIR" -type f -name '*.gradle-properties.age')

  ensure_store_protections
  git -C "$SECRETS_DIR" add -A
  git -C "$SECRETS_DIR" commit -m "migrate: finalize store format v2 (drop $v1count v1 blob(s))" >/dev/null 2>&1 || true
  git -C "$SECRETS_DIR" remote get-url origin >/dev/null 2>&1 && git -C "$SECRETS_DIR" push >/dev/null 2>&1 || true
  info "Store finalized to format v2. Dropped $v1count v1 blob(s). Recovery tag in the store: $tag"
}

cmd_migrate() {
  resolve_store
  check_initialized
  local dry_run=false finalize=false force=false
  while [ $# -gt 0 ]; do
    case "$1" in
      --dry-run)     dry_run=true; shift ;;
      --finalize)    finalize=true; shift ;;
      --yes|--force) force=true; shift ;;
      -*)            die "Unknown migrate flag: $1. Usage: secrets migrate [--dry-run | --finalize] [--yes]" ;;
      *)             die "migrate takes no project argument. Run it from inside a project (copy-forward) or use --finalize (store-wide)." ;;
    esac
  done
  if [ "$finalize" = true ]; then
    _migrate_finalize "$force"
  else
    _migrate_project "$dry_run"
  fi
}

cmd_help() {
  cat << 'EOF'
secrets — encrypted secret file sync between machines

Usage:
  secrets init                  Initialize the secrets repo and generate an age key
  secrets push [project]        Encrypt secret files and push to the secrets repo
  secrets push --frozen         Sync only manifest-declared files (skip auto-add)
  secrets push --dry-run        Show what would be added/synced; change nothing
  secrets push -w|--workspaces  Push secrets from all workspaces in package.json
  secrets pull [project]        Pull and decrypt secret files into current directory
  secrets pull -w|--workspaces  Pull secrets into all workspaces from package.json
  secrets add <path>            Declare a project-relative file in .secrets.json
  secrets clear                 Remove plaintext secret files from current directory
  secrets clear -w|--workspaces Clear secrets from all workspaces in package.json
  secrets run [-w] <command>    Pull secrets, run command, clear secrets on exit
  secrets list                  List all projects and their secret files
  secrets rm <project>          Remove a project's secrets from the repo
  secrets rekey                 Re-encrypt all secrets with a new key
  secrets verify [project]      Check the manifest against the store + decrypt every blob
  secrets verify --all          Decrypt-test every blob in every project (integrity gate)
  secrets migrate [--dry-run]   Copy-forward this project's blobs to store format v2
  secrets migrate --finalize    Drop v1 blobs and mark the store v2 (after verify)
  secrets which                 Show the active store, manifest, and external entries
  secrets where                 Alias for `which`
  secrets status                Alias for `which`

Tracked files: .env, .env.*, .dev.vars

Manifest (.secrets.json):
  A committed project-root manifest declaring everything the project
  syncs (requires jq). `secrets push` discovers conventional files and
  auto-adds them with a notice; set {"options":{"autoAdd":false}} to
  require explicit `secrets add` instead. Dotenv paths are project-
  relative (nested workspace paths welcome); external entries use
  {"type":"properties"|"file","path":...,"keys":[...]}:

      {
        "version": 2,
        "options": { "autoAdd": true },
        "dotenv": [".env", "packages/web/.env.development"],
        "external": [
          { "type": "properties", "path": "~/.gradle/gradle.properties",
            "keys": ["beaconClerkPkTest"] },
          { "type": "file", "path": "~/keystores/upload.keystore" }
        ]
      }

  A legacy .secrets-files is absorbed into .secrets.json on the next
  push (gradle-properties entries become type "properties") and can be
  deleted afterwards. Without jq, manifest-less projects keep working;
  manifest features are skipped with a notice.

If [project] is omitted, it is derived from the current directory's
git remote (if available) or the directory name.

Stores:
  Most users have one ~/.secrets/ store. To use a separate store (e.g.
  for work secrets vs personal), use any of these resolution rules
  (highest precedence first):

    1. --store <dir>  flag             secrets --store ~/.secrets-work pull
    2. .secrets-store file in project  echo work > .secrets-store && git add ...
    3. SECRETS_DIR env var (legacy)    SECRETS_DIR=~/.secrets-work secrets pull
    4. ~/.secrets default

  Bare names ("work") expand to ~/.secrets-work. The name "default"
  resolves to ~/.secrets. Run `secrets which` to inspect the active store.

  Optional .secrets-store URL hint:
    A second whitespace-separated token on the line is treated as the
    store's git remote URL. It is used to fill in a runnable `git clone
    <url> <path>` in the missing-store error so teammates joining the
    project don't have to ask for the URL. Example:
        work git@github.com:acme/work-secrets.git

Workspaces:
  With -w/--workspaces, reads package.json "workspaces" field to find
  workspace directories. Each workspace's secret files are stored under
  <monorepo>/<workspace-path>/ in the secrets repo. Root secret files
  are stored under <monorepo>/ directly. Requires jq.

External files (.secrets-files):
  Sync files (or specific keys from files) OUTSIDE the project root.
  Create a committed .secrets-files in the project root, one entry per
  line:

      # <type>             <path>                        <keys...>
      gradle-properties    ~/.gradle/gradle.properties   beaconClerkPkTest beaconClerkPkLive
      file                 ~/keystores/beacon-upload.keystore

  gradle-properties: on push, the named keys are extracted from <path>
  and encrypted under <project>/external/ in the store. On pull, they
  are MERGED back into <path>, leaving all unrelated keys untouched.
  The target basename must be 'gradle.properties'.

  file: the whole file is encrypted verbatim (binary-safe — keystores,
  certificates). On pull it is restored with mode 600; an existing
  divergent target is backed up to <name>.secrets-bak first. No keys.

  All targets must resolve inside $HOME (no '..', no symlinks). Run
  'secrets which' from the project to confirm the manifest parsed.

  Note: pulled external targets are permanent plaintext on disk —
  'secrets clear' does NOT remove them.

Environment:
  SECRETS_DIR    Path to secrets repo (default: ~/.secrets). See also
                 the .secrets-store file and --store flag above.
EOF
}

# ─── Main ──────────────────────────────────────────────────────────────

# Pre-pass: extract --store flag from anywhere in the args (before `--` only).
# Lets `secrets --store work push`, `secrets push --store work`, and
# `secrets run --store work cmd ...` all work consistently. After `--`,
# args belong to the user's command and are passed through untouched.
ARGS=()
while [ $# -gt 0 ]; do
  case "$1" in
    --)
      # Stop pre-pass; pass `--` and everything after through untouched
      # so the user's command (e.g. `secrets run -- cmd --store foo`) is
      # not mangled.
      ARGS+=("$@")
      break
      ;;
    --store)
      [ $# -ge 2 ] || die "--store requires a directory or store name"
      # F3: reject values that look like another flag — almost always a typo
      # (`secrets --store push` → user dropped the value, would silently use
      # ~/.secrets-push and surface a confusing "not initialized" error).
      case "$2" in
        --|-*) die "--store value looks like a flag: $2 (did you forget the value?)" ;;
      esac
      STORE_OVERRIDE="$2"
      shift 2
      ;;
    --store=*)
      # F4: --store= with empty value used to silently fall through to the
      # next rule. Treat it as a typo too.
      [ -n "${1#--store=}" ] || die "--store= requires a value"
      STORE_OVERRIDE="${1#--store=}"
      shift
      ;;
    *)
      ARGS+=("$1")
      shift
      ;;
  esac
done
# Re-set positional params. Bulletproof against empty array under `set -u`.
if [ ${#ARGS[@]} -gt 0 ]; then
  set -- "${ARGS[@]}"
else
  set --
fi

case "${1:-help}" in
  init)   cmd_init ;;
  push)
    if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then
      cmd_push_workspaces
    else
      shift
      cmd_push "$@"
    fi
    ;;
  pull)
    if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then
      cmd_pull_workspaces
    else
      cmd_pull "${2:-}"
    fi
    ;;
  clear)
    if [ "${2:-}" = "-w" ] || [ "${2:-}" = "--workspaces" ]; then
      cmd_clear_workspaces
    else
      cmd_clear
    fi
    ;;
  run)
    shift
    cmd_run "$@"
    ;;
  add)    cmd_add "${2:-}" ;;
  list)   cmd_list ;;
  rm)     cmd_rm "${2:-}" ;;
  rekey)  cmd_rekey ;;
  verify) shift; cmd_verify "$@" ;;
  migrate) shift; cmd_migrate "$@" ;;
  which|where|status) cmd_which ;;
  help|--help|-h) cmd_help ;;
  *)      die "Unknown command: $1. Run 'secrets help' for usage." ;;
esac
