#!/usr/bin/env bash
#clear
#export PS4='\e[90m+${LINENO} in ${#BASH_SOURCE[@]}>${FUNCNAME[0]}:${BASH_SOURCE[@]##*/} \e[0m'
#set -x

#echo "starting: $0 <LOG_LEVEL=$1>"

promote.dev.to.testing() # <?reset|yes|skip> # promote dev to testing, gated by core tests
{
  local arg1="$1"
  source state

  private.promote.config.load
  PROMOTE_TARGET=testing

  if [ "$arg1" = "reset" ]; then
    # reset = clean slate: drop any persisted per-invocation force/skip too.
    PROMOTE_FORCE=""
    PROMOTE_SKIP=""
    if state.machine.exists PROMOTE 2>/dev/null; then
      state.machine.delete PROMOTE 2>/dev/null
    fi
    private.promote.state.machine.init
    private.promote.config.save
  elif [ "$arg1" = "yes" ]; then
    PROMOTE_FORCE=yes
    private.promote.config.save
  elif [ "$arg1" = "skip" ]; then
    PROMOTE_FORCE=yes
    PROMOTE_SKIP=yes
    private.promote.config.save
  else
    # No arg = a plain resume. PROMOTE_FORCE / PROMOTE_SKIP are PER-INVOCATION:
    # only `yes` / `skip` enable them, and only for that one run. Clear any
    # value left over in PROMOTE.promote.env from a previous forced run —
    # otherwise a single `oo stage dev yes` would silently suppress the
    # confirmation prompt on every later `oo stage dev`.
    PROMOTE_FORCE=""
    PROMOTE_SKIP=""
    private.promote.config.save
  fi

  if ! state.machine.exists PROMOTE 2>/dev/null; then
    private.promote.state.machine.init
  fi

  state of PROMOTE
  source $CONFIG_PATH/current.state.machine.env

  # Reinit unless resuming an in-progress testing-path run (states 11-18)
  if ! { [ $state -ge 11 ] && [ $state -le 18 ]; } || [ $state -ge 99 ]; then
    state.machine.delete PROMOTE 2>/dev/null
    private.promote.state.machine.init
    private.promote.config.save
    state of PROMOTE
    source $CONFIG_PATH/current.state.machine.env
  fi

  # Handle skip: advance past current stuck state without running its check
  if [ "$PROMOTE_SKIP" = "yes" ]; then
    local skipFrom=$state
    local nextState=$((state + 1))
    local nextStateName="PROMOTE_STATES[${nextState}]"
    local nextStateLabel="${!nextStateName}"
    important.log "Skipping check for state [$nextState] = $nextStateLabel"
    state.set - $nextState
    source $CONFIG_PATH/current.state.machine.env
    PROMOTE_SKIP=""
    private.promote.config.save
    success.log "Skipped from [$skipFrom] past [$nextState], now at [$state]"
  fi

  # Advancement loop
  local i=0
  while [ $state -lt 99 ] && [ $i -lt 30 ]; do
    local prevState=$state
    state next
    # $RESULT is unreliable immediately after `state next`: state.next runs
    # state.list afterward, which clobbers $RESULT with its own
    # state.machine.exists output ("state machine PROMOTE exists"). state.next
    # now stashes the check's real message in $STATE_CHECK_RESULT before that
    # clobber — prefer it, fall back to $RESULT for older state scripts.
    local _checkResult="${STATE_CHECK_RESULT:-$RESULT}"
    source $CONFIG_PATH/current.state.machine.env
    if [ $state -le $prevState ]; then
      error.log "State machine stuck at [$state]"
      # User-visible failure block — per [status-command-output-idiom],
      # the answer to `oo stage dev` must reach stdout regardless of
      # LOG_LEVEL or LOG_DEVICE routing. error.log alone disappears at
      # LL=0 or when LOG_DEVICE is pointed at a non-tty (file, /dev/null,
      # captured context). Use bare `echo` so the user always sees WHY
      # the promotion stopped.
      local _stuckLabel="PROMOTE_STATES[${state}]"
      echo ""
      echo "Cannot promote to ${PROMOTE_TARGET}: ${_checkResult:-state machine stuck at [$state]}"
      echo "  Stuck at state: [$state] = ${!_stuckLabel}"
      echo "  Fix the blocking condition, then re-run \`oo stage dev\`."
      break
    fi
    ((i++))
  done

  if [ $state -ge 99 ]; then
    success.log "Promotion to testing complete"
  fi
}

promote.testing.to.prod() # <?reset|yes|skip> # promote testing to prod, gated by platform tests
{
  local arg1="$1"
  source state

  private.promote.config.load
  PROMOTE_TARGET=prod

  if [ "$arg1" = "reset" ]; then
    # reset = clean slate: drop any persisted per-invocation force/skip too.
    PROMOTE_FORCE=""
    PROMOTE_SKIP=""
    if state.machine.exists PROMOTE 2>/dev/null; then
      state.machine.delete PROMOTE 2>/dev/null
    fi
    private.promote.state.machine.init
    private.promote.config.save
  elif [ "$arg1" = "yes" ]; then
    PROMOTE_FORCE=yes
    private.promote.config.save
  elif [ "$arg1" = "skip" ]; then
    PROMOTE_FORCE=yes
    PROMOTE_SKIP=yes
    private.promote.config.save
  else
    # No arg = a plain resume. PROMOTE_FORCE / PROMOTE_SKIP are PER-INVOCATION:
    # only `yes` / `skip` enable them, and only for that one run. Clear any
    # value left over in PROMOTE.promote.env from a previous forced run —
    # otherwise a single `oo stage testing yes` would silently suppress the
    # confirmation prompt on every later `oo stage testing`.
    PROMOTE_FORCE=""
    PROMOTE_SKIP=""
    private.promote.config.save
  fi

  if ! state.machine.exists PROMOTE 2>/dev/null; then
    private.promote.state.machine.init
  fi

  state of PROMOTE
  source $CONFIG_PATH/current.state.machine.env

  # Reinit unless resuming an in-progress prod-path run. Upper bound is
  # PROMOTE_PROD_LAST_STATE (set by private.promote.state.machine.init based
  # on must-pass platform count + 1 for the '99' terminator slot). If the
  # config file is missing or stale, default to a generous bound so we don't
  # incorrectly re-init a valid in-flight machine.
  local _prodMax="${PROMOTE_PROD_LAST_STATE:-99}"
  if ! { [ $state -ge 20 ] && [ $state -le $((_prodMax + 1)) ]; } || [ $state -ge 99 ]; then
    state.machine.delete PROMOTE 2>/dev/null
    private.promote.state.machine.init
    private.promote.config.save
    state of PROMOTE
    source $CONFIG_PATH/current.state.machine.env
  fi

  # Ensure per-platform check stubs are present in this shell. Init creates
  # them at machine-creation time; on resume init is skipped, so re-source.
  private.promote.platform.stubs.source

  # Handle skip: advance past current stuck state without running its check
  if [ "$PROMOTE_SKIP" = "yes" ]; then
    local skipFrom=$state
    local nextState=$((state + 1))
    local nextStateName="PROMOTE_STATES[${nextState}]"
    local nextStateLabel="${!nextStateName}"
    important.log "Skipping check for state [$nextState] = $nextStateLabel"
    state.set - $nextState
    source $CONFIG_PATH/current.state.machine.env
    PROMOTE_SKIP=""
    private.promote.config.save
    success.log "Skipped from [$skipFrom] past [$nextState], now at [$state]"
  fi

  # Advancement loop — same shape as promote.dev.to.testing's loop; see the
  # stuck-state failure-block comment there for the rationale.
  local i=0
  while [ $state -lt 99 ] && [ $i -lt 30 ]; do
    local prevState=$state
    state next
    # See promote.dev.to.testing's loop: prefer $STATE_CHECK_RESULT (the check's
    # real message, stashed by state.next before state.list clobbers $RESULT).
    local _checkResult="${STATE_CHECK_RESULT:-$RESULT}"
    source $CONFIG_PATH/current.state.machine.env
    if [ $state -le $prevState ]; then
      error.log "State machine stuck at [$state]"
      # User-visible failure block — per [status-command-output-idiom],
      # bare echo (not log primitive) so the answer reaches stdout
      # regardless of LOG_LEVEL / LOG_DEVICE.
      local _stuckLabel="PROMOTE_STATES[${state}]"
      echo ""
      echo "Cannot promote to ${PROMOTE_TARGET}: ${_checkResult:-state machine stuck at [$state]}"
      echo "  Stuck at state: [$state] = ${!_stuckLabel}"
      echo "  Fix the blocking condition, then re-run \`oo stage testing\`."
      break
    fi
    ((i++))
  done

  if [ $state -ge 99 ]; then
    success.log "Promotion to prod complete"
  fi
}

promote.status() # # show PROMOTE pipeline state and branch diffs
{
  # Force a non-interactive pager so `git log/tag` can't stall in stripped-env
  # shells like `env -i su <user>` (what `user login` uses). Under those
  # shells stdout is still a TTY but TERM/LESS aren't configured, and git's
  # auto-pager launches `less` which blocks indefinitely.
  local _git_pager_orig="${GIT_PAGER:-__unset__}"
  export GIT_PAGER=cat
  source state

  if state.machine.exists PROMOTE 2>/dev/null; then
    state of PROMOTE
    source $CONFIG_PATH/current.state.machine.env
    if [ $state -ge 99 ]; then
      success.log "Last promotion completed — ready for next run"
    else
      private.promote.config.load
      # Pipeline state belongs in the user's answer — promote.status is a
      # user-invoked query, plain echo so LOG_LEVEL=1 still shows the labels.
      # This is what the bare integer leaking out of state.of's pre-fix echo
      # used to (accidentally) hint at — now it's an explicit "State: [N] =
      # <name>" line instead of an unexplained number.
      echo "Promoting to: $PROMOTE_TARGET"
      echo "Current state: [$state] = ${PROMOTE_STATES[$state]:-unknown}"
      local nextIdx=$((state + 1))
      local nextLabel="${PROMOTE_STATES[$nextIdx]:-finished}"
      echo "Next check: [$nextIdx] = $nextLabel"
    fi
  else
    # Event-driven notice (no PROMOTE machine yet). important.log keeps the
    # "no active promotion" message visible at LOG_LEVEL >= 2 — but the
    # *branch status* block below still echoes plainly so the user gets
    # their answer regardless of log level.
    important.log "No active promotion — run 'promote testing' or 'promote prod' to start"
  fi

  # Status-command output is the user's expected return value, not a log
  # event — must survive every LOG_LEVEL. Plain echo (stdout) is the OOSH
  # idiom for user-invoked .status / .report methods. The Promoting-to /
  # Current-state / Next-check lines above remain via important.log
  # because those are pipeline-internal diagnostics, surfaced only when
  # the user opts into LOG_LEVEL >= 2.
  echo
  echo "============="
  echo "Branch Status"
  echo "============="

  # Use full refs/heads/<name> so git doesn't get confused when a sibling
  # worktree or a stray symlink with the same name lives inside $OOSH_DIR
  # (in which case bare 'dev' is ambiguous: "revision vs filename").
  local devHead testingHead prodHead
  devHead=$(git -C "$OOSH_DIR" log -1 --format='%h  %ci' refs/heads/dev 2>/dev/null || echo "not found")
  testingHead=$(git -C "$OOSH_DIR" log -1 --format='%h  %ci' refs/heads/testing 2>/dev/null || echo "not found")
  prodHead=$(git -C "$OOSH_DIR" log -1 --format='%h  %ci' refs/heads/prod 2>/dev/null || echo "not found")

  echo "dev    $devHead"
  promote.branch.alignment dev testing
  echo "testing  $testingHead  ($RESULT)"
  promote.branch.alignment testing prod
  echo "prod   $prodHead  ($RESULT)"

  # Restore caller's GIT_PAGER
  if [ "$_git_pager_orig" = "__unset__" ]; then unset GIT_PAGER; else export GIT_PAGER="$_git_pager_orig"; fi
}

promote.branch.alignment() # <from> <to> # symmetric branch comparison; sets RESULT to one of: up to date with <from>, <from> merged in, N commits behind <from>, diverged: N behind <from>
{
  local from="$1"
  local to="$2"
  if [ -z "$from" ] || [ -z "$to" ]; then
    create.result 1 "promote.branch.alignment requires <from> <to>"
    error.log "$RESULT"
    return $(result)
  fi

  # Verdict is from <to>'s perspective relative to <from>:
  #   behindCount = commits on <from> not on <to>  (commits <to> is behind <from> by)
  #   aheadCount  = commits on <to> not on <from>  (commits <to> is ahead of <from> by)
  # Delegate raw git to the documented this.git.* namespace helper so
  # promote keeps zero direct `git rev-list --count` call sites.
  local behindCount aheadCount
  this.git.commits.count "$OOSH_DIR" "$to"   "$from"; behindCount="$RESULT"
  this.git.commits.count "$OOSH_DIR" "$from" "$to";   aheadCount="$RESULT"

  if [ "$behindCount" = "0" ] && [ "$aheadCount" = "0" ]; then
    create.result 0 "up to date with $from"
  elif [ "$behindCount" = "0" ]; then
    # <to> contains every commit of <from> plus its own. Post-promote
    # steady state: <from> has been successfully merged into <to>, plus
    # bookkeeping commits (the merge commit + OOSH_SELF_BRANCH rewrite).
    # The bookkeeping count is not actionable — the timestamp on the <to>
    # line shows when the merge happened.
    create.result 0 "$from merged in"
  elif [ "$aheadCount" = "0" ]; then
    create.result 0 "$behindCount commits behind $from"
  else
    # Diverged: <from> advanced past where <to>'s last merge picked it up,
    # AND <to> has its own bookkeeping commits from prior promotes. The
    # ahead-count is just history (bookkeeping); only the behind-count is
    # actionable — that's how far <to> needs to catch up via `oo stage
    # <from>`. See docs/promote.md for the full state vocabulary.
    create.result 0 "diverged: $behindCount behind $from"
  fi
  return $(result)
}

promote.report() # # show promotion history from git tags
{
  # Force non-interactive pager (see promote.status for rationale).
  local _git_pager_orig="${GIT_PAGER:-__unset__}"
  export GIT_PAGER=cat

  important.log "Testing promotions (testing-* tags):"
  git -C "$OOSH_DIR" tag -l 'testing-*' --sort=-creatordate --format='  %(creatordate:short)  %(refname:short)' 2>/dev/null || echo "  (none)"

  echo
  important.log "Prod releases (v* tags):"
  git -C "$OOSH_DIR" tag -l 'v*' --sort=-creatordate --format='  %(creatordate:short)  %(refname:short)' 2>/dev/null || echo "  (none)"

  if [ "$_git_pager_orig" = "__unset__" ]; then unset GIT_PAGER; else export GIT_PAGER="$_git_pager_orig"; fi
}

### new.method

# ============================================================================
# Private config helpers
# ============================================================================

private.promote.config.load() {
  local configFile="$CONFIG_PATH/stateMachines/PROMOTE.promote.env"
  if [ -f "$configFile" ]; then
    source "$configFile"
  else
    PROMOTE_TARGET=""
    PROMOTE_FORCE=""
    PROMOTE_SKIP=""
    PROMOTE_PROD_LAST_STATE=""
  fi
}

private.promote.config.save() {
  local configFile="$CONFIG_PATH/stateMachines/PROMOTE.promote.env"
  mkdir -p "$CONFIG_PATH/stateMachines" 2>/dev/null
  {
    echo "PROMOTE_TARGET=$PROMOTE_TARGET"
    echo "PROMOTE_FORCE=$PROMOTE_FORCE"
    echo "PROMOTE_SKIP=$PROMOTE_SKIP"
    echo "PROMOTE_PROD_LAST_STATE=$PROMOTE_PROD_LAST_STATE"
  } > "$configFile"
}

# ============================================================================
# State machine init
# ============================================================================

private.promote.state.machine.init() {
  local machine=PROMOTE
  console.log "initialising state machine: ${machine}"

  source state

  state.machine.create ${machine} promote

  # Testing path states [11]-[18]
  state.add initialization.checked       silent
  state.add target.checked               silent
  state.add uncommitted.checked          silent
  state.add test.suite.passed            silent
  state.add confirmation.received        silent
  state.add merged.to.testing            silent
  state.add testing.tagged               silent
  state.add testing.pushed               silent

  # After 8 state.add calls: [11]-[18] filled, [19]=next.custom.state
  # Re-source to get the full array into current scope
  # (state.add modifies a local copy due to declare -a scoping)
  source $CONFIG_PATH/stateMachines/PROMOTE.states.env

  # Manual printf -v for [19]-[N]:
  #   state.add 99 can't work here because [99]="finished" is pre-set
  #   by the template — state.add would try to add at [99] which is
  #   already occupied. We also need non-sequential IDs ([19] terminates
  #   the testing path, [20]+ start the prod path) that state.add's
  #   auto-increment can't produce.
  #
  # Prod path is data-driven: one state per must-pass platform in
  # defaults/platforms.env (plus user overrides). When platforms are
  # added/removed via config, the next `promote ... reset` rebuilds the
  # state list with no code edit.
  printf -v "PROMOTE_STATES[19]" '%s' '99'

  # Source os so private.os.platform.* are available, then load the platform matrix.
  # No 2>/dev/null suppression — if os is missing or platform.load errors, surface
  # it (first-principles.md transparency) rather than letting state-machine
  # init succeed silently with an empty platform set.
  source "$OOSH_DIR/os"
  private.os.platform.load

  local _id=20
  printf -v "PROMOTE_STATES[$_id]" '%s' 'prod.path.started';        _id=$((_id+1))
  printf -v "PROMOTE_STATES[$_id]" '%s' 'uncommitted.checked.prod'; _id=$((_id+1))
  printf -v "PROMOTE_STATES[$_id]" '%s' 'test.suite.passed.prod';   _id=$((_id+1))

  local _p
  for _p in $(private.os.platform.names); do
    private.os.platform.parse "$_p"
    [ "$PLATFORM_TIER" = "must-pass" ] || continue
    printf -v "PROMOTE_STATES[$_id]" '%s' "platform.test.$_p"
    _id=$((_id+1))
  done
  # Source per-platform check stubs into this shell so the advancement
  # loop has them on the first init run. (On resume, the state machine
  # already exists and init is skipped — callers must re-source the stubs
  # themselves via private.promote.platform.stubs.source.)
  private.promote.platform.stubs.source

  printf -v "PROMOTE_STATES[$_id]" '%s' 'confirmation.received.prod'; _id=$((_id+1))
  printf -v "PROMOTE_STATES[$_id]" '%s' 'merged.to.prod';             _id=$((_id+1))
  printf -v "PROMOTE_STATES[$_id]" '%s' 'prod.tagged';                _id=$((_id+1))
  printf -v "PROMOTE_STATES[$_id]" '%s' 'prod.pushed'
  # Last advance-eligible state in the prod path is prod.pushed.
  # Capture its ID before writing the '99' terminator slot.
  PROMOTE_PROD_LAST_STATE=$_id
  _id=$((_id+1))
  printf -v "PROMOTE_STATES[$_id]" '%s' '99'

  private.state.machine.update

  # Advance through template states: [2]setup -> [3]all.states.added -> [4]started
  state.machine.start promote
}

# ============================================================================
# private.check.* functions for state transitions
# ============================================================================

private.check.initialization.checked() # <script> <stageTo> <stateFound> # verify PROMOTE_TARGET is set
{
  local script=$1; shift
  local stageTo=$1; shift
  local stateFound=$1; shift

  private.promote.config.load
  if [ -n "$PROMOTE_TARGET" ]; then
    create.result 0 "PROMOTE_TARGET=$PROMOTE_TARGET"
  else
    create.result 1 "PROMOTE_TARGET not set — call promote dev.to.testing or promote testing.to.prod"
  fi
  return $(result)
}

private.check.target.checked() # <script> <stageTo> <stateFound> # branch to testing or prod path
{
  local script=$1; shift
  local stageTo=$1; shift
  local stateFound=$1; shift

  private.promote.config.load
  if [ "$PROMOTE_TARGET" = "testing" ]; then
    console.log "Target: testing — following testing promotion path"
    create.result 0 13
  elif [ "$PROMOTE_TARGET" = "prod" ]; then
    console.log "Target: prod — following prod promotion path"
    create.result 0 20
  else
    create.result 1 "Unknown PROMOTE_TARGET: $PROMOTE_TARGET"
  fi
  return $(result)
}

private.check.uncommitted.checked() # <script> <stageTo> <stateFound> # verify clean working tree
{
  local script=$1; shift
  local stageTo=$1; shift
  local stateFound=$1; shift

  local dirty
  dirty=$(git -C "$OOSH_DIR" status --porcelain 2>/dev/null)
  if [ -z "$dirty" ]; then
    create.result 0 "Working tree is clean"
  else
    create.result 1 "Uncommitted changes detected — commit or stash first"
  fi
  return $(result)
}

private.check.test.suite.passed() # <script> <stageTo> <stateFound> # run test.suite core 1
{
  local script=$1; shift
  local stageTo=$1; shift
  local stateFound=$1; shift

  console.log "Running test.suite core 1..."

  # Save PROMOTE state machine (test.promote deletes and recreates it)
  local smDir="$CONFIG_PATH/stateMachines"
  cp "$smDir/PROMOTE.states.env" "$smDir/PROMOTE.states.env.bak" 2>/dev/null
  cp "$smDir/PROMOTE.promote.env" "$smDir/PROMOTE.promote.env.bak" 2>/dev/null

  local rc=0
  test.suite core 1 || rc=$?

  # Restore PROMOTE state machine
  cp "$smDir/PROMOTE.states.env.bak" "$smDir/PROMOTE.states.env" 2>/dev/null
  cp "$smDir/PROMOTE.promote.env.bak" "$smDir/PROMOTE.promote.env" 2>/dev/null
  rm -f "$smDir/PROMOTE.states.env.bak" "$smDir/PROMOTE.promote.env.bak" 2>/dev/null
  state of PROMOTE

  if [ $rc -eq 0 ]; then
    create.result 0 "All core tests passed"
  else
    create.result 1 "Core test suite failed — fix failures and re-run promote"
  fi
  return $(result)
}

# Prod-path thin wrappers — mirror state 13 (uncommitted.checked) and state 14
# (test.suite.passed) of the dev→testing path. Keep the actual gate logic
# centralised in the testing-path checks; the .prod aliases just give the
# prod-path state machine the function name it expects.
private.check.uncommitted.checked.prod() { private.check.uncommitted.checked "$@"; }
private.check.test.suite.passed.prod()   { private.check.test.suite.passed "$@"; }

# private.promote.platform.stubs.source
# Source one private.check.platform.test.<name>() stub per must-pass platform
# into the current shell. The stubs delegate to private.check.platform.test.dispatch.
# Idempotent: re-sourcing redefines the functions identically. Must be called
# before the state-machine advancement loop in both code paths:
#   1. private.promote.state.machine.init (first-run / reset) — invokes this.
#   2. promote.testing.to.prod (resume against existing PROMOTE machine) — invokes
#      this explicitly because init is skipped when the machine already exists.
# Without (2), resuming a per-platform run from a fresh shell hit
# `promote.check.platform.test.<name> not found` and the state machine stuck
# at the first per-platform state.
private.promote.platform.stubs.source()
{
  # Precondition: $OOSH_DIR must be set + readable. This function is also
  # invoked at top-level whenever `promote` is sourced — including from the
  # interactive `oo` REPL, which sources scripts for completion *before*
  # $OOSH_DIR is bootstrapped. When that happens the helper has nothing to
  # do (no state machine in flight either) — return cleanly instead of
  # crashing the sourcing shell with `source "/os": No such file`.
  [ -n "$OOSH_DIR" ] && [ -d "$OOSH_DIR" ] || return 0

  source "$OOSH_DIR/os"
  private.os.platform.load
  local _p
  for _p in $(private.os.platform.names); do
    private.os.platform.parse "$_p"
    [ "$PLATFORM_TIER" = "must-pass" ] || continue
    # Documented OOSH delegate-generation helper — defines
    # private.check.platform.test.<name>() => private.check.platform.test.dispatch <name> "$@"
    this.function.partial \
      "private.check.platform.test.$_p" \
      private.check.platform.test.dispatch \
      "$_p"
  done
}

# private.check.platform.test.dispatch <platformName> <script> <stageTo> <stateFound>
# Shared body that every per-platform check function (generated dynamically by
# private.promote.state.machine.init) delegates to. Returns 0 if `os platform.test
# <platform>` passes, 1 otherwise — the state machine then stops at that
# platform's state and re-running advances only when the same platform passes.
# Uses important.log / error.log so progress is visible at LOG_LEVEL=1 (works
# around the state framework swallowing console.log of check functions; see
# project_promote_silent_output_bug.md).
private.check.platform.test.dispatch()
{
  local platform=$1; shift
  local script=$1; shift
  local stageTo=$1; shift
  local stateFound=$1; shift

  important.log "Running os platform.test $platform ..."
  if os platform.test "$platform"; then
    create.result 0 "Platform test passed: $platform"
    success.log "Platform $platform: PASS"
  else
    create.result 1 "Platform $platform FAILED — fix the platform, then re-run 'oo stage testing'"
    error.log "Platform $platform: FAIL"
  fi
  return $(result)
}

private.check.confirmation.received() # <script> <stageTo> <stateFound> # confirm merge dev to testing
{
  local script=$1; shift
  local stageTo=$1; shift
  local stateFound=$1; shift

  private.promote.config.load
  if [ "$PROMOTE_FORCE" = "yes" ]; then
    console.log "PROMOTE_FORCE=yes — skipping confirmation"
    create.result 0 "auto-confirmed"
    return $(result)
  fi

  important.log "Commits to be merged (dev → testing):"
  git -C "$OOSH_DIR" log --oneline testing..dev 2>/dev/null

  echo
  local answer
  read -p "Proceed with merge to testing? [y/N] " answer
  if [ "$answer" = "y" ] || [ "$answer" = "Y" ]; then
    create.result 0 "confirmed"
  else
    create.result 1 "Merge cancelled by user"
  fi
  return $(result)
}

private.promote.rewrite.self.branch() # <ooshDir> # rewrite OOSH_SELF_BRANCH defaults in init/oosh and Install oosh.command to the currently-checked-out branch and commit if changed
{
  local ooshDir="$1"
  local branch
  branch=$(git -C "$ooshDir" branch --show-current 2>/dev/null)
  [ -z "$branch" ] && return 0

  local sedFlag="-i"
  [ "$(uname -s)" = "Darwin" ] && sedFlag="-i ''"

  # Files that carry an OOSH_SELF_BRANCH="${OOSH_SELF_BRANCH:-<branch>}"
  # default line that must match the file's branch of origin.
  local targets=("init/oosh" "Install oosh.command")
  local t
  for t in "${targets[@]}"; do
    [ -f "$ooshDir/$t" ] || continue
    eval sed $sedFlag "'s|^OOSH_SELF_BRANCH=\"\${OOSH_SELF_BRANCH:-[A-Za-z0-9_-]*}\"|OOSH_SELF_BRANCH=\"\${OOSH_SELF_BRANCH:-$branch}\"|' '$ooshDir/$t'"
  done

  if ! git -C "$ooshDir" diff --quiet -- "${targets[@]}" 2>/dev/null; then
    git -C "$ooshDir" add "${targets[@]}"
    # Pass committer identity inline so the commit succeeds even on
    # containers / CI runners without a global `git config user.email`
    # (e.g. test.promote's temp-dir rig). Uses a stable sentinel identity
    # so the git log shows this commit was machine-generated by promote.
    git -C "$ooshDir" \
        -c user.email="oosh-promote@local" \
        -c user.name="oosh promote" \
        commit -m "chore(promote): set OOSH_SELF_BRANCH default to $branch" -q
  fi
}

private.promote.find.worktree() # <ooshDir> <branch> # echo the worktree path that currently has <branch> checked out (empty if none)
{
  local ooshDir=$1
  local branch=$2
  git -C "$ooshDir" worktree list --porcelain 2>/dev/null | awk -v ref="refs/heads/$branch" '
    /^worktree / { wt=$2; next }
    $1 == "branch" && $2 == ref { print wt; exit }
  '
}

# private.promote.push.source.branch <ooshDir> <sourceBranch>
# Push the source branch to origin BEFORE merging it into the destination.
# Without this, a successful merge+push of the destination branch can leave
# origin/<source> behind origin/<destination> — i.e. origin/testing
# references commits that aren't reachable from origin/dev. A fresh clone
# would see testing "ahead" of dev with unreachable-via-dev commits.
# Pushing source first also fails fast (before any destructive merge work)
# if origin/<source> has diverged — the user must pull/resolve manually
# rather than discover the conflict after a half-finished promote.
# Returns 0 on success or when no 'origin' remote is configured (test rigs
# without a remote should not gate on push). Returns 1 on push failure.
private.promote.push.source.branch()
{
  local ooshDir="$1"
  local sourceBranch="$2"

  git -C "$ooshDir" remote get-url origin >/dev/null 2>&1 || return 0

  git -C "$ooshDir" push origin "$sourceBranch" 2>/dev/null
}

# private.promote.try.resolve.self.branch.conflict <ooshDir> <sourceBranch>
# Called from private.check.merged.to.* after `git merge <source>` fails.
# Every promote commits an OOSH_SELF_BRANCH rewrite onto the destination
# branch (see private.promote.rewrite.self.branch), which becomes a permanent
# 1-line divergence between source and destination init/oosh + Install
# oosh.command. The next time the source-branch refactor touches lines near
# that divergence, git can no longer 3-way-merge it and produces a conflict.
# This helper auto-resolves that specific class of conflict: if EVERY
# conflicted path is within the known-set {init/oosh, Install oosh.command},
# take the source-branch version (theirs) for each, stage, and commit the
# merge. The caller then runs private.promote.rewrite.self.branch which
# immediately rewrites OOSH_SELF_BRANCH back to the destination branch.
# Returns 0 on successful auto-resolve, 1 if conflicts touch any other file
# (caller must abort the merge as before).
private.promote.try.resolve.self.branch.conflict()
{
  local ooshDir="$1"
  local sourceBranch="$2"
  local destBranch
  destBranch=$(git -C "$ooshDir" branch --show-current 2>/dev/null)

  local conflicted
  conflicted=$(git -C "$ooshDir" diff --name-only --diff-filter=U 2>/dev/null)
  [ -z "$conflicted" ] && return 1

  local f
  while IFS= read -r f; do
    [ -z "$f" ] && continue
    case "$f" in
      "init/oosh"|"Install oosh.command") ;;
      *) return 1 ;;
    esac
  done <<EOF
$conflicted
EOF

  while IFS= read -r f; do
    [ -z "$f" ] && continue
    git -C "$ooshDir" checkout --theirs -- "$f" 2>/dev/null || return 1
    git -C "$ooshDir" add -- "$f" 2>/dev/null || return 1
  done <<EOF
$conflicted
EOF

  git -C "$ooshDir" \
      -c user.email="oosh-promote@local" \
      -c user.name="oosh promote" \
      -c commit.gpgsign=false \
      commit -q -m "Merge branch '$sourceBranch' into $destBranch (auto-resolved OOSH_SELF_BRANCH drift)" 2>/dev/null || return 1

  return 0
}

private.check.merged.to.testing() # <script> <stageTo> <stateFound> # merge dev into testing
{
  local script=$1; shift
  local stageTo=$1; shift
  local stateFound=$1; shift

  local originalBranch
  originalBranch=$(this.git.branch.short "$OOSH_DIR")

  # Stash any local changes (e.g. .claude/settings.local.json)
  local stashed=false
  if ! git -C "$OOSH_DIR" diff --quiet 2>/dev/null; then
    git -C "$OOSH_DIR" stash push -q -m "promote: pre-merge stash"
    stashed=true
  fi

  # Push source (dev) first so origin/dev contains every commit that the
  # subsequent origin/testing push will reference. Fail fast — before any
  # destructive merge work — if the push is rejected.
  if ! private.promote.push.source.branch "$OOSH_DIR" "dev"; then
    [ "$stashed" = true ] && git -C "$OOSH_DIR" stash pop -q 2>/dev/null
    create.result 1 "Failed to push dev to origin — pull/resolve manually before promoting"
    return $(result)
  fi

  if git -C "$OOSH_DIR" checkout testing 2>/dev/null; then
    # -c user.email/-c user.name: the merge commit needs identity, same as
    # the explicit commits below — without inline creds, `git merge` aborts
    # before producing conflict markers on environments lacking global git
    # user config (alma containers, fresh CI runners, etc).
    if git -C "$OOSH_DIR" \
         -c user.email="oosh-promote@local" \
         -c user.name="oosh promote" \
         merge dev --no-edit 2>/dev/null; then
      # Rewrite init/oosh's OOSH_SELF_BRANCH default so /<branch>/init/oosh
      # curl installs auto-select the matching worktree (see init/oosh:228).
      private.promote.rewrite.self.branch "$OOSH_DIR"
      # Stay on testing for tagging/pushing; stash popped in testing.pushed after returning to dev
      create.result 0 "Merged dev into testing"
    elif private.promote.try.resolve.self.branch.conflict "$OOSH_DIR" "dev"; then
      important.log "Auto-resolved OOSH_SELF_BRANCH drift in known files; merge completed"
      private.promote.rewrite.self.branch "$OOSH_DIR"
      create.result 0 "Merged dev into testing (auto-resolved OOSH_SELF_BRANCH drift)"
    else
      error.log "Merge conflict — aborting"
      git -C "$OOSH_DIR" merge --abort 2>/dev/null
      git -C "$OOSH_DIR" checkout "$originalBranch" 2>/dev/null
      [ "$stashed" = true ] && git -C "$OOSH_DIR" stash pop -q 2>/dev/null
      create.result 1 "Merge failed — resolve conflicts manually"
      return $(result)
    fi
  else
    [ "$stashed" = true ] && git -C "$OOSH_DIR" stash pop -q 2>/dev/null
    create.result 1 "Could not checkout testing branch"
    return $(result)
  fi

  return $(result)
}

private.check.testing.tagged() # <script> <stageTo> <stateFound> # tag testing with date
{
  local script=$1; shift
  local stageTo=$1; shift
  local stateFound=$1; shift

  local tagBase="testing-$(date +%Y-%m-%d)"
  local tag="$tagBase"
  local counter=1
  while git -C "$OOSH_DIR" tag -l "$tag" | grep -q "$tag"; do
    tag="${tagBase}.${counter}"
    ((counter++))
  done

  if git -C "$OOSH_DIR" tag "$tag" 2>/dev/null; then
    create.result 0 "Tagged: $tag"
    success.log "Tagged: $tag"
  else
    create.result 1 "Failed to create tag: $tag"
  fi
  return $(result)
}

private.check.testing.pushed() # <script> <stageTo> <stateFound> # push testing and tags, return to dev
{
  local script=$1; shift
  local stageTo=$1; shift
  local stateFound=$1; shift

  if git -C "$OOSH_DIR" push origin testing --tags 2>/dev/null; then
    git -C "$OOSH_DIR" checkout dev 2>/dev/null
    # Pop any stash from merged.to.testing (now safe — back on dev)
    if git -C "$OOSH_DIR" stash list 2>/dev/null | head -1 | grep -q "promote: pre-merge stash"; then
      git -C "$OOSH_DIR" stash pop -q 2>/dev/null
    fi
    create.result 0 "Pushed testing with tags, back on dev"
    success.log "Testing promotion complete"
  else
    create.result 1 "Failed to push testing — check remote access"
  fi
  return $(result)
}


private.check.prod.path.started() # <script> <stageTo> <stateFound> # pass-through to platform tests
{
  local script=$1; shift
  local stageTo=$1; shift
  local stateFound=$1; shift

  console.log "Entering prod promotion path..."
  create.result 0 "prod path started"
  return $(result)
}

private.check.confirmation.received.prod() # <script> <stageTo> <stateFound> # confirm merge testing to prod
{
  local script=$1; shift
  local stageTo=$1; shift
  local stateFound=$1; shift

  private.promote.config.load
  if [ "$PROMOTE_FORCE" = "yes" ]; then
    console.log "PROMOTE_FORCE=yes — skipping confirmation"
    create.result 0 "auto-confirmed"
    return $(result)
  fi

  important.log "Commits to be merged (testing → prod):"
  git -C "$OOSH_DIR" log --oneline prod..testing 2>/dev/null

  echo
  local answer
  read -p "Proceed with merge to prod? [y/N] " answer
  if [ "$answer" = "y" ] || [ "$answer" = "Y" ]; then
    create.result 0 "confirmed"
  else
    create.result 1 "Merge cancelled by user"
  fi
  return $(result)
}

private.check.merged.to.prod() # <script> <stageTo> <stateFound> # merge testing into prod
{
  local script=$1; shift
  local stageTo=$1; shift
  local stateFound=$1; shift

  local originalBranch
  originalBranch=$(this.git.branch.short "$OOSH_DIR")

  # Stash any local changes (e.g. .claude/settings.local.json)
  local stashed=false
  if ! git -C "$OOSH_DIR" diff --quiet 2>/dev/null; then
    git -C "$OOSH_DIR" stash push -q -m "promote: pre-merge stash"
    stashed=true
  fi

  # Push source (testing) first so origin/testing contains every commit
  # that the subsequent origin/prod push will reference. Same rationale as
  # in private.check.merged.to.testing.
  if ! private.promote.push.source.branch "$OOSH_DIR" "testing"; then
    [ "$stashed" = true ] && git -C "$OOSH_DIR" stash pop -q 2>/dev/null
    create.result 1 "Failed to push testing to origin — pull/resolve manually before promoting"
    return $(result)
  fi

  # Worktree-aware: in a multi-worktree layout (dev, testing, prod as
  # separate worktrees of the same repo), `git checkout prod` from the
  # current worktree fails with "already used by worktree at <path>".
  # Detect that case and merge inside prod's worktree directly — avoids
  # touching the current worktree's checkout, and the merge commit lands
  # on prod via the prod worktree's HEAD. Single-worktree setups fall
  # through to the historical checkout-and-merge path.
  local prodWorktree mergeDir prodStashed=false
  prodWorktree=$(private.promote.find.worktree "$OOSH_DIR" "prod")
  if [ -n "$prodWorktree" ] && [ "$prodWorktree" != "$OOSH_DIR" ]; then
    # Stash prod-worktree's dirty changes too so merge isn't blocked.
    if ! git -C "$prodWorktree" diff --quiet 2>/dev/null; then
      git -C "$prodWorktree" stash push -q -m "promote: pre-merge stash"
      prodStashed=true
    fi
    mergeDir="$prodWorktree"
  elif git -C "$OOSH_DIR" checkout prod 2>/dev/null; then
    mergeDir="$OOSH_DIR"
  else
    [ "$stashed" = true ] && git -C "$OOSH_DIR" stash pop -q 2>/dev/null
    create.result 1 "Could not checkout prod branch (not in another worktree either)"
    return $(result)
  fi

  # See merge-creds note on the dev→testing site above — same rationale.
  if git -C "$mergeDir" \
       -c user.email="oosh-promote@local" \
       -c user.name="oosh promote" \
       merge testing --no-edit 2>/dev/null; then
    # Rewrite init/oosh's OOSH_SELF_BRANCH default — operates on mergeDir
    # (the prod worktree if separate, else $OOSH_DIR currently on prod).
    private.promote.rewrite.self.branch "$mergeDir"
    [ "$prodStashed" = true ] && git -C "$prodWorktree" stash pop -q 2>/dev/null
    create.result 0 "Merged testing into prod"
  elif private.promote.try.resolve.self.branch.conflict "$mergeDir" "testing"; then
    important.log "Auto-resolved OOSH_SELF_BRANCH drift in known files; merge completed"
    private.promote.rewrite.self.branch "$mergeDir"
    [ "$prodStashed" = true ] && git -C "$prodWorktree" stash pop -q 2>/dev/null
    create.result 0 "Merged testing into prod (auto-resolved OOSH_SELF_BRANCH drift)"
  else
    error.log "Merge conflict — aborting"
    git -C "$mergeDir" merge --abort 2>/dev/null
    if [ "$mergeDir" = "$OOSH_DIR" ]; then
      git -C "$OOSH_DIR" checkout "$originalBranch" 2>/dev/null
    fi
    [ "$prodStashed" = true ] && git -C "$prodWorktree" stash pop -q 2>/dev/null
    [ "$stashed" = true ] && git -C "$OOSH_DIR" stash pop -q 2>/dev/null
    create.result 1 "Merge failed — resolve conflicts manually"
    return $(result)
  fi

  return $(result)
}

private.check.prod.tagged() # <script> <stageTo> <stateFound> # tag prod with semver
{
  local script=$1; shift
  local stageTo=$1; shift
  local stateFound=$1; shift

  # Find latest v* tag and auto-increment patch
  local latestTag
  latestTag=$(git -C "$OOSH_DIR" tag -l 'v*' --sort=-version:refname | head -1)

  local newTag
  if [ -n "$latestTag" ]; then
    # Parse semver and increment patch
    local major minor patch
    major=$(echo "$latestTag" | sed 's/^v//' | cut -d. -f1)
    minor=$(echo "$latestTag" | sed 's/^v//' | cut -d. -f2)
    patch=$(echo "$latestTag" | sed 's/^v//' | cut -d. -f3)
    patch=${patch:-0}
    ((patch++))
    newTag="v${major}.${minor}.${patch}"
  else
    newTag="v1.0.0"
  fi

  private.promote.config.load
  if [ "$PROMOTE_FORCE" != "yes" ]; then
    local answer
    read -p "Tag as $newTag? [Y/n/custom] " answer
    if [ "$answer" = "n" ] || [ "$answer" = "N" ]; then
      create.result 1 "Tagging cancelled by user"
      return $(result)
    elif [ -n "$answer" ] && [ "$answer" != "y" ] && [ "$answer" != "Y" ]; then
      newTag="$answer"
    fi
  fi

  # Tag the prod ref explicitly. The previous form (`git tag $newTag`)
  # tagged HEAD of $OOSH_DIR's current checkout, which assumed
  # merged.to.prod had just done `git checkout prod`. The worktree-aware
  # merged.to.prod stays in $OOSH_DIR (often dev), so HEAD would be wrong.
  # Tagging `prod` works regardless of which worktree we're in.
  if git -C "$OOSH_DIR" tag "$newTag" prod 2>/dev/null; then
    create.result 0 "Tagged: $newTag"
    success.log "Tagged: $newTag"
  else
    create.result 1 "Failed to create tag: $newTag"
  fi
  return $(result)
}

private.check.prod.pushed() # <script> <stageTo> <stateFound> # push prod and tags, return to dev
{
  local script=$1; shift
  local stageTo=$1; shift
  local stateFound=$1; shift

  if git -C "$OOSH_DIR" push origin prod --tags 2>/dev/null; then
    git -C "$OOSH_DIR" checkout dev 2>/dev/null
    # Pop any stash from merged.to.prod (now safe — back on dev)
    if git -C "$OOSH_DIR" stash list 2>/dev/null | head -1 | grep -q "promote: pre-merge stash"; then
      git -C "$OOSH_DIR" stash pop -q 2>/dev/null
    fi
    export OOSH_MODE="released"
    config save oosh OOSH 2>/dev/null
    create.result 0 "Pushed prod with tags, back on dev"
    success.log "Prod promotion complete"
  else
    create.result 1 "Failed to push prod — check remote access"
  fi
  return $(result)
}

private.check.finished() # <script> <stageTo> <stateFound> # terminal state
{
  local script=$1; shift
  local stageTo=$1; shift
  local stateFound=$1; shift

  create.result 0 "finished"
  return $(result)
}

private.check.next.custom.state() # <script> <stageTo> <stateFound> # placeholder pass-through
{
  local script=$1; shift
  local stageTo=$1; shift
  local stateFound=$1; shift

  create.result 0 "next.custom.state"
  return $(result)
}

# ============================================================================
# Completion functions
# ============================================================================

promote.dev.to.testing.completion.reset() { echo "reset"; echo "yes"; echo "skip"; }
promote.testing.to.prod.completion.reset() { echo "reset"; echo "yes"; echo "skip"; }
promote.branch.alignment.completion.from() { echo "dev"; echo "testing"; echo "prod"; }
promote.branch.alignment.completion.to()   { echo "dev"; echo "testing"; echo "prod"; }

# Backward compat aliases
promote.testing() # <?reset|yes|skip> # [deprecated: use promote dev.to.testing] promote dev to testing
{ promote.dev.to.testing "$@"; }
promote.prod() # <?reset|yes|skip> # [deprecated: use promote testing.to.prod] promote testing to prod
{ promote.testing.to.prod "$@"; }

promote.usage()
{
  local this=${0##*/}
  echo "You started"
  echo "$0

  promote - Release Pipeline (PROMOTE State Machine)

  Gated promotion: dev -> testing -> prod
  Each step requires passing tests before advancing.

  Usage:
  $this: command   Parameter and Description"
  this.help
  echo "

  Examples
    $this testing        promote dev -> testing (gated by tests)
    $this testing reset  restart promotion from scratch
    $this testing yes    skip confirmations
    $this testing skip   skip the current stuck state and continue
    $this prod           promote testing -> prod
    $this status       show pipeline state and branch diffs
    $this report       show promotion history from git tags
    ----------
  "
}

promote.start()
{
  #echo "sourcing init"
  source this

  this.start "$@"
}

# Source per-platform check stubs whenever this file is loaded. `state.check`
# does `source $(command -v promote)` inside its own subshell to look up the
# per-state private.check.<name> function — that subshell never enters
# promote.testing.to.prod, so it would otherwise miss the dynamically
# generated platform stubs and report `promote.check.platform.test.<name>
# not found...`, sticking the state machine at state [23]. The helper is
# idempotent. No 2>/dev/null — if os is unsourceable here, fail loudly
# (first-principles.md transparency).
private.promote.platform.stubs.source

promote.start "$@"
