#!/usr/bin/env bash
# lib/common.sh — claude-multiacc shared helpers.
# Sourced by bin/claude-accounts, bin/codex-accounts and install.sh.
# Bash 3.2 compatible (macOS stock).
#
# PROVIDER SUPPORT: the same pool machinery serves two independent providers —
# claude (Claude Code, the default) and codex (OpenAI Codex CLI). A caller opts
# into codex by exporting MULTIACC_PROVIDER=codex BEFORE sourcing this file; every
# path/endpoint global below then points at the codex pool. The two pools are
# completely separate on disk (~/.claude-accounts vs ~/.codex-accounts), so
# nothing an operation does on one provider can ever touch the other's accounts.
# shellcheck disable=SC2034

MULTIACC_PROVIDER="${MULTIACC_PROVIDER:-claude}"
PYBIN="${CLAUDE_MULTIACC_PYTHON:-python3}"
DEFAULT_SERVER="root@138.197.36.107"
DEFAULT_SERVER_REPO="/root/claude-multiacc"

LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd -P)"

# INSTANCE-SCOPED POOLS: <PROVIDER>_ACCOUNTS_ROOT relocates the whole pool, so
# several app-robot instances on one machine/user keep completely isolated account
# sets. The older <PROVIDER>_ACCOUNTS_DIR spelling still works (it is what the test
# suite and existing runner envs set) — _ROOT wins when both are present.
if [ "$MULTIACC_PROVIDER" = "codex" ]; then
  ACC_ROOT="${CODEX_ACCOUNTS_ROOT:-${CODEX_ACCOUNTS_DIR:-$HOME/.codex-accounts}}"
  DEFAULT_ACC_ROOT="$HOME/.codex-accounts"
  DEFAULT_SERVER_ROOT="/root/.codex-accounts"
  USAGE_URL="${CODEX_MULTIACC_USAGE_URL:-https://chatgpt.com/backend-api/codex/usage}"
  AUDIT_PY="$LIB_DIR/codex_audit.py"
  PROVIDER_CLI="codex-accounts"
  SYNC_TARGET_ENV="${CODEX_MULTIACC_SYNC_TARGET:-${MULTIACC_SYNC_TARGET:-}}"
  SYNC_ROOT_ENV="${CODEX_MULTIACC_SYNC_ROOT:-${MULTIACC_SYNC_ROOT:-}}"
  SYNC_REPO_ENV="${CODEX_MULTIACC_SYNC_REPO:-${MULTIACC_SYNC_REPO:-}}"
else
  ACC_ROOT="${CLAUDE_ACCOUNTS_ROOT:-${CLAUDE_ACCOUNTS_DIR:-$HOME/.claude-accounts}}"
  DEFAULT_ACC_ROOT="$HOME/.claude-accounts"
  DEFAULT_SERVER_ROOT="/root/.claude-accounts"
  # `?cedar_ember=1&skip_spend=1` is Claude Code's own /limit-reset read: the same
  # usage payload plus the limit-reset status block (lib/claude_reset.py), in ONE call.
  USAGE_URL="${CLAUDE_MULTIACC_USAGE_URL:-https://api.anthropic.com/api/oauth/usage?cedar_ember=1&skip_spend=1}"
  AUDIT_PY="$LIB_DIR/audit.py"
  PROVIDER_CLI="claude-accounts"
  SYNC_TARGET_ENV="${CLAUDE_MULTIACC_SYNC_TARGET:-${MULTIACC_SYNC_TARGET:-}}"
  SYNC_ROOT_ENV="${CLAUDE_MULTIACC_SYNC_ROOT:-${MULTIACC_SYNC_ROOT:-}}"
  SYNC_REPO_ENV="${CLAUDE_MULTIACC_SYNC_REPO:-${MULTIACC_SYNC_REPO:-}}"
fi
MANIFEST="$ACC_ROOT/accounts.json"
REPORT_PY="$LIB_DIR/report.py"

ts_utc() { date -u +%Y-%m-%dT%H:%M:%SZ; }
epoch_now() { date +%s; }

log_to() { # log_to <file-in-acc-root> <msg...>
  local f="$1"; shift
  [ -d "$ACC_ROOT" ] || return 0
  printf '%s %s\n' "$(ts_utc)" "$*" >> "$ACC_ROOT/$f" 2>/dev/null || true
}

die() { printf '%s: error: %s\n' "$PROVIDER_CLI" "$*" >&2; exit 1; }
warn() { printf '%s: warning: %s\n' "$PROVIDER_CLI" "$*" >&2; }

machine_kind() { case "$(uname -s)" in Darwin) echo mac ;; *) echo linux ;; esac; }

if [ "$(uname -s)" = "Darwin" ]; then
  file_mtime() { stat -f %m "$1" 2>/dev/null || echo 0; }
else
  file_mtime() { stat -c %Y "$1" 2>/dev/null || echo 0; }
fi

canon_path() { # resolve symlinks (loop-guarded) and normalize; always prints something
  local p="$1" t i=0 d b
  case "$p" in /*) ;; *) p="$PWD/$p" ;; esac
  while [ -L "$p" ] && [ "$i" -lt 40 ]; do
    t="$(readlink "$p")" || break
    case "$t" in /*) p="$t" ;; *) p="$(dirname "$p")/$t" ;; esac
    i=$((i+1))
  done
  d="$(cd "$(dirname "$p")" 2>/dev/null && pwd -P)" || { printf '%s\n' "$p"; return 0; }
  b="$(basename "$p")"
  if [ "$d" = "/" ]; then printf '/%s\n' "$b"; else printf '%s/%s\n' "$d" "$b"; fi
}

# Matches both shims: bin/claude ("claude-multiacc-shim") and bin/codex
# ("codex-multiacc-shim") — find_real_bin must skip either, whatever it scans for.
is_shim_file() { head -c 300 "$1" 2>/dev/null | grep -q multiacc-shim; }

# Prints an INVOCABLE path to the real <name> binary (kept as the symlink path,
# not the canonical target, so process cmdlines keep matching monitors like
# `pgrep -f '.local/bin/claude'`). $1 = binary name, $2 = caller path to exclude.
find_real_bin() {
  local name="$1" self_c cand c d
  self_c="$(canon_path "${2:-${BASH_SOURCE[0]}}")"
  local oldifs="$IFS"
  IFS=':'; set -f
  # shellcheck disable=SC2086
  set -- $PATH
  IFS="$oldifs"; set +f
  for d in "$@"; do
    [ -n "$d" ] || continue
    cand="$d/$name"
    [ -f "$cand" ] && [ -x "$cand" ] || continue
    c="$(canon_path "$cand")"
    [ "$c" = "$self_c" ] && continue
    case "$c" in "$ACC_ROOT"/*) continue ;; esac
    is_shim_file "$c" && continue
    printf '%s\n' "$cand"; return 0
  done
  for cand in "$HOME/.local/bin/$name" "/usr/local/bin/$name" "/opt/homebrew/bin/$name" "/usr/bin/$name"; do
    [ -f "$cand" ] && [ -x "$cand" ] || continue
    c="$(canon_path "$cand")"
    [ "$c" = "$self_c" ] && continue
    is_shim_file "$c" && continue
    printf '%s\n' "$cand"; return 0
  done
  return 1
}

find_real_claude() { find_real_bin claude "${1:-}"; }
find_real_codex() { find_real_bin codex "${1:-}"; }

# The version string of an installed copy (its package.json), '' when unreadable.
# Shared because self-update must VERIFY the tree it wrote, in both CLIs.
pkg_version_at() {
  [ -f "$1/package.json" ] || return 0
  sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' "$1/package.json" | head -1
}

# The npm that owns THIS install, resolved without trusting the ambient PATH.
# A launchd agent (and a bare cron) runs with PATH=/usr/bin:/bin:/usr/sbin:/sbin —
# no homebrew, no nvm — so `command -v npm` fails there and the daily self-update
# logged "npm not found; skipping" every night while announcing nothing was wrong.
# Every Mac in the fleet sat frozen on a three-day-old build that way.
# Order matters: the npm inside the prefix that holds the RUNNING copy is the one
# whose global tree we must write (my-mini has two node installs, homebrew's and
# nvm's, and updating the wrong one is the older bug this pairs with).
find_npm() { # find_npm [prefix]; prints an npm path, or nothing
  local prefix="${1:-}" cand
  for cand in ${prefix:+"$prefix/bin/npm"} \
              /opt/homebrew/bin/npm /usr/local/bin/npm /usr/bin/npm; do
    [ -x "$cand" ] && { printf '%s\n' "$cand"; return 0; }
  done
  cand="$(command -v npm 2>/dev/null)" && [ -n "$cand" ] && { printf '%s\n' "$cand"; return 0; }
  return 1
}

manifest_get() { # manifest_get <dot.path> [default]
  local val
  if [ -f "$MANIFEST" ]; then
    val="$("$PYBIN" - "$MANIFEST" "$1" <<'PYEOF' 2>/dev/null
import json, sys
cur = json.load(open(sys.argv[1]))
for part in sys.argv[2].split('.'):
    if isinstance(cur, dict) and part in cur:
        cur = cur[part]
    else:
        sys.exit(1)
print(cur if not isinstance(cur, (dict, list)) else json.dumps(cur))
PYEOF
)" && { printf '%s\n' "$val"; return 0; }
  fi
  [ $# -ge 2 ] && { printf '%s\n' "$2"; return 0; }
  return 1
}

# Account ids are used to build filesystem paths and remote shell words, so they are
# validated at EVERY boundary — a hand-edited or corrupted manifest must never be able
# to smuggle `../` (path traversal) or shell metacharacters out of the pool directory.
valid_acct_id() { case "$1" in acct-[0-9][0-9]) return 0 ;; *) return 1 ;; esac; }

account_ids() { # prints VALID manifest account ids, one per line
  [ -f "$MANIFEST" ] || return 0
  "$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null
import json, re, sys
for a in json.load(open(sys.argv[1])).get('accounts', []):
    if isinstance(a, dict) and re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))):
        print(a['id'])
PYEOF
}

# Remote paths and ssh targets come from the manifest and are interpolated into a
# remote shell command, so they must be strictly shaped — no quotes, spaces, $, ;, etc.
# Reject-by-character-class (a trailing `*` glob would match metacharacters and defeat
# the check), then require the leading shape.
valid_remote_path() {
  case "$1" in
    *[!A-Za-z0-9._/-]*) return 1 ;;   # any char outside the safe set => reject
    /?*) return 0 ;;                  # must be an absolute path
    *) return 1 ;;
  esac
}
valid_ssh_target() {
  case "$1" in
    *[!A-Za-z0-9._@-]*) return 1 ;;
    ?*) return 0 ;;
    *) return 1 ;;
  esac
}

acct_dir() { printf '%s/%s\n' "$ACC_ROOT" "$1"; }

# ---- sync target resolution ----------------------------------------------------
# Where this pool pushes, in precedence order:
#   1. <PROVIDER>_MULTIACC_SYNC_TARGET / MULTIACC_SYNC_TARGET (env — the runner daemon
#      sets these per instance, without rewriting a manifest it does not own)
#   2. the manifest's server/server_root/server_repo (what install.sh --server wrote)
#   3. DEFAULT_SERVER — the historical default, unchanged for existing installs.
# The value 'none' (also 'local'/'off'/'disabled'/empty) selects LOCAL-ONLY mode: the
# pool has no ssh target because something else (the panel/runner daemon) distributes
# it. That is a supported mode, not a misconfiguration.
sync_target_is_local() {
  case "$(printf '%s' "${1:-}" | tr '[:upper:]' '[:lower:]')" in
    ''|none|local|off|disabled) return 0 ;;
    *) return 1 ;;
  esac
}
sync_target() {
  if [ -n "${SYNC_TARGET_ENV:-}" ]; then printf '%s\n' "$SYNC_TARGET_ENV"
  else manifest_get server "$DEFAULT_SERVER"; fi
}
sync_target_root() {
  if [ -n "${SYNC_ROOT_ENV:-}" ]; then printf '%s\n' "$SYNC_ROOT_ENV"
  else manifest_get server_root "$DEFAULT_SERVER_ROOT"; fi
}
sync_target_repo() {
  if [ -n "${SYNC_REPO_ENV:-}" ]; then printf '%s\n' "$SYNC_REPO_ENV"
  else manifest_get server_repo "$DEFAULT_SERVER_REPO"; fi
}

# The machine-readable pool document shared by `list|status|limits --json`
# (lib/report.py). Stdout is JSON and nothing else — every human line a --json run
# would otherwise print goes to stderr or a log.
emit_report_json() { # emit_report_json <list|status|limits>
  local target mode
  target="$(sync_target)"
  if sync_target_is_local "$target"; then mode=local; target=""; else mode=server; fi
  MULTIACC_REPORT_SYNC_TARGET="$target" \
  MULTIACC_REPORT_SYNC_ROOT="$(sync_target_root)" \
  MULTIACC_REPORT_SYNC_REPO="$(sync_target_repo)" \
  MULTIACC_REPORT_SYNC_MODE="$mode" \
  "$PYBIN" "$REPORT_PY" "$ACC_ROOT" "$MULTIACC_PROVIDER" "$(machine_kind)" "${1:-list}" \
    || die "could not build the JSON report from $ACC_ROOT"
}

# True when every manifest account has a well-formed id and an email. A corrupt
# manifest must never be pushed (it would blank the target pools) and must never be
# reported as a clean local sync either.
manifest_well_formed() {
  "$PYBIN" - "$MANIFEST" <<'PYEOF' 2>/dev/null
import json, re, sys
doc = json.load(open(sys.argv[1]))
accounts = doc.get('accounts')
if not isinstance(accounts, list):
    sys.exit(1)
for a in accounts:
    if not isinstance(a, dict) or not re.fullmatch(r'acct-\d{2}', str(a.get('id', ''))) \
       or not str(a.get('email', '')).strip():
        sys.exit(1)
PYEOF
}

# The housekeeping a pool does on ITSELF: seed any missing account dirs and put the
# credential files back to 0600. It is what a sync target runs after a push, and all
# a local-only pool has to do (nothing is pushed anywhere there).
local_pool_fixup() {
  local id d
  for id in $(account_ids); do
    d="$ACC_ROOT/$id"
    seed_account_dir "$d"
    if [ "$MULTIACC_PROVIDER" = "codex" ]; then
      [ -f "$d/auth.json" ] && chmod 600 "$d/auth.json" 2>/dev/null
    else
      [ -f "$d/server.token" ] && chmod 600 "$d/server.token" 2>/dev/null
      [ -f "$d/.credentials.json" ] && chmod 600 "$d/.credentials.json" 2>/dev/null
    fi
  done
  return 0
}

CREDENTIAL_PY="$LIB_DIR/credential.py"

# EXIT-trap body for the limits refresh lock — a function for the same reason as
# import_cred_cleanup: the lock path contains the pool root.
LIMITS_LOCK=""
limits_lock_release() {
  [ -n "${LIMITS_LOCK:-}" ] && rm -rf "$LIMITS_LOCK" 2>/dev/null
  LIMITS_LOCK=""
  return 0
}

# EXIT-trap body for cmd_import_credential. A function, not an interpolated string:
# the staged blob lives under the pool root, and a root containing a quote would
# otherwise inject shell code into the trap program.
IMPORT_STAGE=""
import_cred_cleanup() {
  [ -n "${IMPORT_STAGE:-}" ] && rm -f "$IMPORT_STAGE" 2>/dev/null
  IMPORT_STAGE=""
  mutate_unlock 2>/dev/null || true
  return 0
}

cmd_export_credential() {
  # Emit a self-contained transfer blob for ONE account. Read-only on this pool:
  # nothing is written, refreshed or re-minted, so exporting a live production
  # account cannot disturb it. lib/credential.py owns the format AND the
  # portability rule (it exits 3 on a machine-local credential); this wrapper only
  # decides where the blob lands and propagates that exit code verbatim, because a
  # daemon branches on it.
  require_manifest
  local id="" out="" identity=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --out) out="${2:?--out requires a path}"; shift 2 ;;
      --identity-only) identity=1; shift ;;
      --*) die "unknown option: $1" ;;
      *) if [ -z "$id" ]; then id="$1"; shift; else die "unexpected argument: $1"; fi ;;
    esac
  done
  [ -n "$id" ] || die "usage: $PROVIDER_CLI export-credential <acct-NN> [--out PATH] [--identity-only]"
  valid_acct_id "$id" || die "not a valid account id: $id"
  set -- export "$ACC_ROOT" "$MULTIACC_PROVIDER" "$id"
  [ "$identity" = "1" ] && set -- "$@" --identity-only
  # --out is handled INSIDE credential.py: it creates a unique 0600 file next to the
  # target and renames it into place, so the blob is never briefly world-readable and
  # a planted symlink at the target is replaced rather than written through — neither
  # of which a shell redirect can promise.
  [ -n "$out" ] && set -- "$@" --out "$out"
  local rc=0
  "$PYBIN" "$CREDENTIAL_PY" "$@" || rc=$?
  [ "$rc" = "0" ] || return "$rc"
  log_to ops.log "export-credential $id -> ${out:-stdout} identity_only=$identity"
  if [ -n "$out" ]; then
    if [ "$identity" = "1" ]; then
      echo "Wrote $out (identity only — no credential material)."
    else
      echo "Wrote $out (carries a live credential: keep it secret, delete it once imported)."
    fi
  fi
  return 0
}

cmd_import_credential() {
  # Install a transfer blob into this pool, non-interactively. Idempotent by EMAIL:
  # re-importing an account that is already here refreshes its credential in place
  # instead of creating a second entry, so a daemon can push the same pool to a
  # machine repeatedly. The secret never passes through a shell variable or argv —
  # it is staged 0600 and installed by lib/credential.py.
  require_manifest
  local id="" in_file="-" home="" no_sync=0 force=0
  while [ $# -gt 0 ]; do
    case "$1" in
      --in|--file) in_file="${2:?--in requires a path}"; shift 2 ;;
      --id) id="${2:?--id requires acct-NN}"; shift 2 ;;
      --home) home="${2:?--home requires mac|server}"; shift 2 ;;
      --no-sync) no_sync=1; shift ;;
      --force) force=1; shift ;;
      --*) die "unknown option: $1" ;;
      *) if [ -z "$id" ]; then id="$1"; shift; else die "unexpected argument: $1"; fi ;;
    esac
  done
  if [ -n "$id" ]; then valid_acct_id "$id" || die "not a valid account id: $id"; fi
  if [ "$in_file" = "-" ] && [ -t 0 ]; then
    die "import-credential reads the blob on stdin — pipe it in, or pass --in PATH"
  fi
  mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
  # mktemp (0600, O_EXCL, unpredictable name), not "$$": the staged file holds live
  # credential material, and a predictable path could be pre-planted as a symlink and
  # written through. The cleanup trap is armed BEFORE anything is written, so an
  # interrupt during staging cannot leave the blob behind either.
  local stage rc=0
  stage="$(mktemp "$ACC_ROOT/tmp/import-cred.XXXXXX")" \
    || die "could not create a staging file under $ACC_ROOT/tmp"
  IMPORT_STAGE="$stage"
  trap import_cred_cleanup EXIT
  if [ "$in_file" = "-" ]; then
    cat > "$stage" || rc=$?
  else
    [ -f "$in_file" ] || { import_cred_cleanup; trap - EXIT; die "credential blob not found: $in_file"; }
    cat "$in_file" > "$stage" || rc=$?
  fi
  [ "$rc" = "0" ] || { import_cred_cleanup; trap - EXIT; die "could not read the credential blob"; }
  chmod 600 "$stage" 2>/dev/null || true

  local meta cclass bid bemail bhome badded btype us
  # 0x1F, not a tab: bash collapses runs of IFS *whitespace*, so a blob with an empty
  # `home` would shift added_at into it and corrupt the manifest entry. credential.py
  # refuses any control character in the metadata, so the separator is unambiguous.
  us="$(printf '\037')"
  meta="$("$PYBIN" "$CREDENTIAL_PY" inspect "$MULTIACC_PROVIDER" "$stage")" || rc=$?
  if [ "$rc" != "0" ]; then
    import_cred_cleanup
    trap - EXIT
    return "$rc"
  fi
  IFS="$us" read -r cclass bid bemail bhome badded btype <<EOF
$meta
EOF
  [ -n "$home" ] || home="$bhome"
  [ -n "$home" ] || home="$(machine_kind)"

  mutate_lock || { import_cred_cleanup; trap - EXIT; die "could not acquire the account lock (another op is stuck?) — try again"; }
  fail_locked() { import_cred_cleanup; trap - EXIT; die "$@"; }
  local owner target_id existing
  owner="$(email_owner "$bemail")"
  target_id="$id"
  if [ -z "$target_id" ]; then
    if [ -n "$owner" ]; then
      target_id="$owner"          # this email already lives here: refresh it in place
    elif [ -n "$bid" ] && [ ! -e "$ACC_ROOT/$bid" ] && ! account_ids | grep -qx "$bid"; then
      target_id="$bid"            # the source's id is free here: keep ids aligned
    else
      target_id="$(next_id)"
    fi
  fi
  if [ -n "$owner" ] && [ "$owner" != "$target_id" ] && [ "$force" != "1" ]; then
    fail_locked "$bemail is already registered as $owner — import into it ('$PROVIDER_CLI import-credential $owner') or pass --force"
  fi
  existing="$(manifest_email_of "$target_id")"
  if [ -n "$existing" ] && [ "$existing" != "$bemail" ] && [ "$force" != "1" ]; then
    fail_locked "$target_id is $existing on this machine, not $bemail — pick another id or pass --force"
  fi
  # An adopted account dir is a symlink (usually to ~/.claude). Writing a credential
  # through it would land outside the pool — the very boundary account ids are
  # validated to protect — so this is refused outright, --force included: --force
  # settles identity conflicts, it does not authorize writing outside the pool.
  if [ -L "$ACC_ROOT/$target_id" ]; then
    fail_locked "$target_id is adopted (a symlink to $(readlink "$ACC_ROOT/$target_id")) — a credential must never be written through it; remove the account first ('$PROVIDER_CLI remove $target_id'), or import under a different id"
  fi
  local d="$ACC_ROOT/$target_id"
  seed_account_dir "$d"
  "$PYBIN" "$CREDENTIAL_PY" install "$MULTIACC_PROVIDER" "$stage" "$d" >/dev/null \
    || fail_locked "could not install the credential into $d — nothing registered"
  manifest_add_account "$target_id" "$bemail" "$home" "$badded" \
    || fail_locked "manifest update failed — $target_id was seeded but NOT registered (re-run import-credential)"
  # Fresh material landed: whatever parked this account before no longer applies.
  [ "$cclass" = "portable" ] && clear_auth_markers "$d"
  import_cred_cleanup
  trap - EXIT
  log_to ops.log "import-credential $target_id $bemail class=$cclass type=$btype home=$home"
  if [ "$cclass" = "portable" ]; then
    echo "Imported $target_id ($bemail, home=$home) with a portable $btype credential."
  else
    echo "Registered $target_id ($bemail, home=$home) — identity only, no credential."
    echo "It needs one interactive sign-in on a machine that will run it: $PROVIDER_CLI login $target_id"
  fi
  [ "$no_sync" = "1" ] || auto_sync
}

has_local_auth() { # $1 = acct dir
  if [ "$MULTIACC_PROVIDER" = "codex" ]; then
    [ -s "$1/auth.json" ]
  else
    [ -f "$1/.credentials.json" ] || [ -s "$1/server.token" ] || keychain_has_login "$1"
  fi
}

# ---- macOS Keychain-held logins (claude only) ------------------------------------
# Claude Code on macOS moves a config dir's OAuth login into the login Keychain the
# first time a GUI-session process can write there, deleting .credentials.json as it
# goes (lib/keychain.py has the whole story). These wrap that module so bash callers
# never parse `security` output themselves. Each one is a no-op (absent) off macOS.
keychain_state() { # $1 = acct dir -> prints present|locked|absent|corrupt|unavailable
  local state
  [ "$MULTIACC_PROVIDER" = "codex" ] && { echo absent; return 0; }
  state="$("$PYBIN" "$LIB_DIR/keychain.py" probe "$1" 2>/dev/null \
    | LC_ALL=C sed -n 's/.*"state": *"\([a-z]*\)".*/\1/p' | head -1)"
  printf '%s\n' "${state:-unavailable}"
}
keychain_has_login() { # $1 = acct dir: an item EXISTS here (readable or locked)
  case "$(keychain_state "$1")" in present|locked|corrupt) return 0 ;; *) return 1 ;; esac
}
keychain_forget() { # $1 = acct dir: drop the item (a removed account must not leave a grant behind)
  [ "$MULTIACC_PROVIDER" = "codex" ] && return 0
  "$PYBIN" "$LIB_DIR/keychain.py" delete "$1" >/dev/null 2>&1 || true
}

# ---- expired-login bookkeeping -------------------------------------------------
# `.expired` is the persistent "this account cannot authenticate" marker the shim
# honors (bin/claude: expired_marked). Two lines: marked-at epoch, then details.
# It is deliberately NOT time-boxed like `.limited`: dead auth only heals through a
# re-login, a credential refresh, or a usage fetch that proves the bearer works.
mark_expired() { # mark_expired <acct dir> <reason-slug> [detail]
  local d="$1" slug="$2" detail="${3:-}"
  [ -d "$d" ] || return 0
  {
    epoch_now
    printf 'reason=%s marked_at=%s detail=%s\n' "$slug" "$(ts_utc)" "$detail"
  } > "$d/.expired.$$" 2>/dev/null \
    && mv -f "$d/.expired.$$" "$d/.expired" 2>/dev/null \
    || rm -f "$d/.expired.$$" 2>/dev/null || true
}

# Called wherever fresh auth lands (login/add/mint, successful usage fetch): the
# account is provably alive again, so both the dead-auth marker and any refresh
# backoff must go, or it would stay parked until the next re-login.
clear_auth_markers() { # $1 = acct dir, $2 = "keep-token-park" after a LOGIN-only ceremony
  # A fresh login proves the login; it says nothing about a portable token a real
  # call rejected, so that park outlives it — only a new token (mint / login --token)
  # or a real call through the token lifts it.
  if [ "${2:-}" = "keep-token-park" ] \
      && grep -q 'reason=setup-token-invalid' "$1/.expired" 2>/dev/null; then
    rm -f "$1/.oauth-refresh.json" 2>/dev/null || true
    return 0
  fi
  rm -f "$1/.expired" "$1/.oauth-refresh.json" 2>/dev/null || true
}

# TSV rows: id, email, home, state
# (ok|expired|token-invalid|blocked|missing|remote|locked|unverified), label, reason, fix.
# Fails LOUD (nonzero, message on stderr): callers decide policy from these rows, and a
# silently empty audit reads exactly like a perfectly healthy pool.
account_audit() {
  local mode="${1:-}" out rc err="$ACC_ROOT/tmp/audit.$$.err"
  mkdir -p "$ACC_ROOT/tmp" 2>/dev/null || true
  # stderr goes to a file, never into the rows — a stray warning must not become a
  # bogus account line.
  out="$("$PYBIN" "$AUDIT_PY" "$ACC_ROOT" "$(machine_kind)" "$mode" 2>"$err")"
  rc=$?
  if [ "$rc" -ne 0 ]; then
    printf '%s: cannot audit accounts (%s)\n' "$PROVIDER_CLI" "$(tail -1 "$err" 2>/dev/null)" >&2
    rm -f "$err" 2>/dev/null
    return 1
  fi
  rm -f "$err" 2>/dev/null
  [ -n "$out" ] && printf '%s\n' "$out"
  return 0
}

# Ids the shim will NOT select — everything a human has to look at.
accounts_unusable() {
  account_audit \
    | awk -F'\t' \
      'NF >= 4 && $1 != "" && $4 ~ /^(expired|token-invalid|blocked|missing)$/ { print $1 }'
}

# Ids a re-login should be pointed at. Org-blocked accounts are included: a fresh
# sign-in re-issues the grant and in practice clears the block, so they are handled
# exactly like any other dead login rather than being left for someone to notice.
accounts_needing_login() {
  account_audit \
    | awk -F'\t' 'NF >= 4 && $1 != "" && ($4 == "expired" || $4 == "blocked" || $4 == "missing") { print $1 }'
}

# True when <acct dir>'s credential can authenticate right now (the audit module's
# rule — audit.py for claude, codex_audit.py for codex; both expose creds_state).
creds_alive() { # $1 = acct dir
  if [ "$MULTIACC_PROVIDER" = "codex" ]; then
    [ -s "$1/auth.json" ] || return 1
    "$PYBIN" - "$AUDIT_PY" "$1/auth.json" <<'PYEOF' 2>/dev/null
import importlib.util, sys, time
spec = importlib.util.spec_from_file_location('audit', sys.argv[1])
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
sys.exit(0 if mod.creds_state(sys.argv[2], time.time())[0] == 'ok' else 1)
PYEOF
    return $?
  fi
  # claude: the login is wherever the client put it — .credentials.json, or the macOS
  # Keychain when the sign-in ran in a session that could open it. A ceremony that
  # succeeded into the Keychain used to be reported as "login failed" and its
  # half-made account dir deleted, stranding a live grant under an orphaned service.
  "$PYBIN" - "$AUDIT_PY" "$1" <<'PYEOF' 2>/dev/null
import importlib.util, sys, time
spec = importlib.util.spec_from_file_location('audit', sys.argv[1])
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
sys.exit(0 if mod.oauth_login(sys.argv[2], time.time())['state'] == 'ok' else 1)
PYEOF
}

manifest_init() { # manifest_init [server-target]
  mkdir -p "$ACC_ROOT/tmp"
  chmod 700 "$ACC_ROOT"
  [ -f "$MANIFEST" ] && return 0
  "$PYBIN" - "$MANIFEST" "${1:-$DEFAULT_SERVER}" "$DEFAULT_SERVER_ROOT" "$DEFAULT_SERVER_REPO" <<'PYEOF'
import json, sys
doc = {
    "version": 1,
    "server": sys.argv[2],
    "server_root": sys.argv[3],
    "server_repo": sys.argv[4],
    "threshold": 90,
    "accounts": [],
}
import os
with open(sys.argv[1] + '.tmp', 'w') as f:
    json.dump(doc, f, indent=2)
    f.write('\n')
os.replace(sys.argv[1] + '.tmp', sys.argv[1])
PYEOF
}

# ---- one index for the shared session tree -----------------------------------------
# codex refuses to start until its rollout index (state_<schema>.sqlite) has been
# backfilled from every rollout under $CODEX_HOME/sessions. The codex layout shares that
# tree across accounts (<acct>/sessions -> ~/.codex/sessions), so the index is shared
# the same way: <acct>/state_N.sqlite -> ~/.codex/state_N.sqlite. Otherwise every new
# account re-reads the whole tree (10 GB on a busy Mac) before its first launch, and a
# launch that meets another process's unfinished scan dies after 30 s with the CLI's
# "local database appears to be damaged" message. bin/codex applies the same at every
# launch (and pre-links the name the installed binary will create); seeding covers a
# CODEX_HOME handed straight to the real binary. The two CORE functions below are
# byte-identical with bin/codex — tests/run-tests.sh diffs them.
# CORE — byte-identical in bin/codex; tests diff them.
state_index_names() { # $1 acct dir, $2 home dir -> the index file names either side holds
  local f name seen=" "
  for f in "$2"/state_[0-9]*.sqlite "$1"/state_[0-9]*.sqlite; do
    [ -e "$f" ] || [ -L "$f" ] || continue          # an unmatched glob is the pattern itself
    name="${f##*/}"
    case "$seen" in *" $name "*) continue ;; esac
    seen="$seen$name "
    printf '%s\n' "$name"
  done
}

# CORE — byte-identical in bin/codex; tests diff them.
shared_index_rejected() { # $1 acct dir, $2 index name, $3 shared target
  # True when codex's own corruption recovery moved THIS account's link out of the way:
  # it renames the database it judged damaged (and its -wal/-shm) into
  # <CODEX_HOME>/db-backups/sqlite-<ts>-<n>/ and rebuilds, and under a link that renames
  # the LINK. A link sitting in there pointing at the shared file is codex's verdict on
  # that file, so this account keeps the index codex rebuilt for it and this name is
  # left alone — handing the link back would hand the damage back.
  local marker
  for marker in "$1"/db-backups/*/"$2"; do
    [ -L "$marker" ] || continue
    [ "$(readlink "$marker" 2>/dev/null)" = "$3" ] && return 0
  done
  return 1
}

# CORE — byte-identical in bin/codex; tests diff them.
share_state_index_links() { # $1 acct dir, $2 home dir, $3 extra index name ('' for none)
  local d="$1" home="$2" name names target link retired n
  [ -L "$d" ] && return 0                              # adopted: the dir IS the home
  [ -L "$d/sessions" ] || return 0                     # a private tree keeps its private index
  [ "$(readlink "$d/sessions" 2>/dev/null)" = "$home/sessions" ] || return 0
  [ -d "$home" ] || return 0
  names="$(state_index_names "$d" "$home")"
  case "${3:-}" in ''|*[!A-Za-z0-9_.]*) ;; *) names="$names $3" ;; esac
  for name in $names; do
    target="$home/$name"; link="$d/$name"
    [ -L "$link" ] && continue                         # already shared (or pointed elsewhere on purpose)
    shared_index_rejected "$d" "$name" "$target" && continue
    if [ ! -e "$link" ]; then
      ln -s "$target" "$link" 2>/dev/null || true      # a new account: the whole point
      continue
    fi
    # This account has an index of its own, and MOVING one that a process can still open
    # is unsafe. codex holds the state database through an sqlx pool that opens its
    # connections lazily and BY PATH (max_connections(5), create_if_missing(true) —
    # codex-rs/state/src/sqlite.rs open_read_write_pool), so a rename under a live holder
    # leaves connection 1 on the old inode while every connection the pool opens
    # afterwards follows the new link: one process, two databases. A -wal or a -shm beside
    # the file is that proof. A codex killed mid-write leaves them behind too, and the
    # next clean session on the account removes them, so this heals itself in time.
    { [ -e "$link-wal" ] || [ -e "$link-shm" ]; } && continue
    if [ -e "$target" ]; then
      retired="$link.private"; n=0                     # never overwrite an earlier copy
      while [ -e "$retired" ] && [ "$n" -lt 100 ]; do n=$((n+1)); retired="$link.private.$n"; done
      [ -e "$retired" ] && continue
      mv "$link" "$retired" 2>/dev/null || continue
    else
      # Atomic or nothing: link(2) refuses an existing target, so two shims racing to be
      # the first to promote cannot rename one's fresh symlink onto the file the other
      # just promoted — which is how a self-referential shared index (ELOOP, and every
      # account on the Mac unable to start) could appear. A cross-device link simply
      # fails and this account keeps its own index.
      ln "$link" "$target" 2>/dev/null || continue
      [ -L "$link" ] || rm -f "$link"
    fi
    ln -s "$target" "$link" 2>/dev/null || true
  done
  return 0
}

codex_share_state_index() { # $1 acct dir — seed time: whatever index names exist already
  share_state_index_links "$1" "${HOME:-/nonexistent}/.codex" ""
}

# Seed an account config dir so headless runs never prompt.
#   claude: stripped .claude.json, copied settings.json, shared CLAUDE.md + projects/
#           (shared history => --continue/--resume work regardless of picked account).
#   codex:  copied config.toml (carries project trust + settings, no identity),
#           shared sessions/ symlink (=> `codex resume` finds every session) with the
#           shared rollout index that tree needs, and a shared AGENTS.md symlink so
#           global instructions apply under any account.
# MCP servers for EVERY account (lib/mcp_registry.py). Both clients keep MCP servers in
# the CONFIG DIR (`<dir>/.claude.json`, `<CODEX_HOME>/config.toml`), so under the pool
# there is one MCP configuration per account and a stock `claude mcp add` lands in ONE
# random one. The pool's registry (mcp-servers.json, synced) plus the machine-local
# overlay (mcp-servers.local.json, never synced) are reconciled into every account here,
# at seed time, and by the shims right before exec. Fail-open: a seed never dies on it.
seed_mcp_registry() { # $1 = acct dir
  local d="$1" off
  if [ "$MULTIACC_PROVIDER" = "codex" ]; then off="${CODEX_MULTIACC_MCP:-1}"; else off="${CLAUDE_MULTIACC_MCP:-1}"; fi
  [ "$off" = "0" ] && return 0
  [ -f "$ACC_ROOT/mcp-servers.json" ] || [ -f "$ACC_ROOT/mcp-servers.local.json" ] || return 0
  [ -f "$LIB_DIR/mcp_registry.py" ] || return 0
  "$PYBIN" "$LIB_DIR/mcp_registry.py" --root "$ACC_ROOT" --provider "$MULTIACC_PROVIDER" apply \
    --account-dir "$d" --fail-open --quiet >/dev/null || true
  return 0
}

seed_account_dir() { # $1 = acct dir
  local d="$1"
  mkdir -p "$d"
  chmod 700 "$d"
  if [ "$MULTIACC_PROVIDER" = "codex" ]; then
    if [ ! -f "$d/config.toml" ] && [ -f "$HOME/.codex/config.toml" ]; then
      cp "$HOME/.codex/config.toml" "$d/config.toml" 2>/dev/null || true
    fi
    if [ -f "$ACC_ROOT/codex-settings-policy.json" ]; then
      "$PYBIN" "$LIB_DIR/codex_settings.py" seed "$ACC_ROOT" --account-dir "$d" \
        || die "could not apply the opted-in Codex settings policy"
    fi
    if [ ! -e "$d/sessions" ] && [ -d "$HOME/.codex/sessions" ]; then
      ln -s "$HOME/.codex/sessions" "$d/sessions" 2>/dev/null || true
    fi
    if [ ! -e "$d/AGENTS.md" ] && [ -f "$HOME/.codex/AGENTS.md" ]; then
      ln -s "$HOME/.codex/AGENTS.md" "$d/AGENTS.md" 2>/dev/null || true
    fi
    codex_share_state_index "$d"
    seed_mcp_registry "$d"
    return 0
  fi
  if [ ! -f "$d/.claude.json" ] && [ -f "$HOME/.claude.json" ]; then
    "$PYBIN" - "$HOME/.claude.json" "$d/.claude.json" <<'PYEOF' 2>/dev/null || true
import json, sys
try:
    doc = json.load(open(sys.argv[1]))
except Exception:
    sys.exit(0)
for k in ('oauthAccount', 'userID'):
    doc.pop(k, None)
import os
with open(sys.argv[2] + '.tmp', 'w') as f:
    json.dump(doc, f)
os.replace(sys.argv[2] + '.tmp', sys.argv[2])
PYEOF
  fi
  if [ ! -f "$d/settings.json" ] && [ -f "$HOME/.claude/settings.json" ]; then
    cp "$HOME/.claude/settings.json" "$d/settings.json" 2>/dev/null || true
  fi
  if [ ! -e "$d/CLAUDE.md" ] && [ -f "$HOME/.claude/CLAUDE.md" ]; then
    ln -s "$HOME/.claude/CLAUDE.md" "$d/CLAUDE.md" 2>/dev/null || true
  fi
  if [ ! -e "$d/projects" ] && [ -d "$HOME/.claude/projects" ]; then
    ln -s "$HOME/.claude/projects" "$d/projects" 2>/dev/null || true
  fi
  seed_mcp_registry "$d"
}

# Short-lived mutation lock: serialize only the brief critical sections (id reservation,
# manifest write), NOT long interactive work like a browser sign-in — so several
# `add`/`login` in different terminals run in parallel and only briefly wait on each other.
# Held for milliseconds; a lock older than 30s is assumed abandoned (holder died) and reclaimed.
MUTATE_LOCK_HELD=0
mutate_lock() {
  local lock="$ACC_ROOT/.locks/mutate" tries=0
  mkdir -p "$ACC_ROOT/.locks" 2>/dev/null
  while ! mkdir "$lock" 2>/dev/null; do
    if [ $(( $(epoch_now) - $(file_mtime "$lock") )) -gt 30 ]; then
      rm -rf "$lock" 2>/dev/null
      continue
    fi
    tries=$((tries + 1))
    [ "$tries" -gt 600 ] && return 1   # ~60s ceiling; contention should clear in ms
    sleep 0.1
  done
  MUTATE_LOCK_HELD=1
  return 0
}
# Only removes the lock dir when THIS process holds it — so a normal release, or a
# cleanup trap firing after release, can never delete a lock a parallel process just took.
mutate_unlock() {
  [ "${MUTATE_LOCK_HELD:-0}" = 1 ] || return 0
  rm -rf "$ACC_ROOT/.locks/mutate" 2>/dev/null
  MUTATE_LOCK_HELD=0
}

rotate_log() { # keep logs bounded: rotate_log <file-in-acc-root> (keeps last ~256KB)
  local f="$ACC_ROOT/$1"
  [ -f "$f" ] || return 0
  local sz
  if [ "$(uname -s)" = "Darwin" ]; then sz="$(stat -f %z "$f" 2>/dev/null || echo 0)"
  else sz="$(stat -c %s "$f" 2>/dev/null || echo 0)"; fi
  if [ "${sz:-0}" -gt 524288 ]; then
    tail -c 262144 "$f" > "$f.tmp" 2>/dev/null && mv "$f.tmp" "$f"
  fi
}
