#!/usr/bin/env bash
# gig-up — ONE command that says GO or NO-GO for the whole chain.
#
# Why this exists (2026-07-31, J-8 to OPAL)
# -----------------------------------------
# A fortnight of debugging produced a shelf of good checks — check-boot,
# check-mix, silent-eval, fix-mute-roles, pvlint, orphan-orbits — and every one
# of them is currently a command PLN would have to REMEMBER. On Sat 8 Aug at
# ~19:00 he is in a field, alone, with a soundcheck window and no assistant.
# A check you have to remember under pressure is a check you do not run.
#
# So this is not new logic. It is a running order and an exit code. Every check
# below already exists and already works; gig-up only decides which are
# GO-BLOCKING and prints the fix inline so the answer and the remedy arrive
# together.
#
# The one that earned its HARD FAIL
# ---------------------------------
# `check-mix.py` finding an orbit at -inf has now been caught three sessions in
# a row, with a DIFFERENT set of tracks each time:
#
#     2026-07-28   Tidal 05, 06, 08, 10 = -inf
#     2026-07-30   Tidal 06, 10, 12 = -inf
#     2026-07-31   Tidal 10 = -inf        (after the Ctrl+S that fixed 06 and 12)
#
# It recurs because the D-row faders are MIDI-learned to LCXL CC 77-84, so the
# saved session and the physical desk drift independently in BOTH directions.
# Three recurrences is not bad luck, it is a property of the setup — hence a
# hard gate rather than a warning. NB the remedy splits: a fader that is still
# -inf AFTER a save is genuinely down (raise it), not merely unpersisted (Ctrl+S).
#
# What it deliberately does NOT do
# --------------------------------
#   * make sound          — the audio gate is `--audio` (check-tracks.sh, ~45s
#                           per track). Default is cold, so this is safe to run
#                           mid-set, at 3am, or in a quiet backstage room.
#   * send any MIDI       — the surface check reads ALSA only. It never touches
#                           CC 77-84 (Ardour track gains; CC77 down is total
#                           silence) and never sweeps CC 93 (gPanic).
#   * touch a .tidal      — read-only, always. PLN rehearses on these files and
#                           Pulsar saves the BUFFER, not the disk.
#   * judge the corpus    — scope is the SETLIST. 14 corpus tracks do not compile
#                           (#112) and must never make the gig gate red.
#
# Usage
# -----
#     tools/gig-up.sh            # the cold gate — safe anywhere, ~1 min
#     tools/gig-up.sh --audio    # + check-tracks.sh (makes sound, ~10 min)
#     tools/gig-up.sh --live     # + assert a RIG exists, not just a set
#     tools/gig-up.sh --converge # + START what is missing, then assert (implies --live)
#     tools/gig-up.sh --quiet    # only the verdict and any failures
#
# Exit 0 = GO. Non-zero = do not start; the reasons are printed first.
set -u
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" || exit 2

AUDIO=0
QUIET=0
LIVE=0
CONVERGE=0
for a in "$@"; do
  case "$a" in
    --audio) AUDIO=1 ;;
    --live) LIVE=1 ;;
    --converge) CONVERGE=1; LIVE=1 ;;
    --quiet|-q) QUIET=1 ;;
    -h|--help) sed -n '2,58p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'; exit 0 ;;
    *) echo "gig-up: unknown option $a (try --help)" >&2; exit 2 ;;
  esac
done

if [ -t 1 ]; then R=$'\033[31m'; G=$'\033[32m'; Y=$'\033[33m'; D=$'\033[2m'; Z=$'\033[0m'
else R=; G=; Y=; D=; Z=; fi

LOG="$(mktemp -t gig-up-XXXXXX.log)"
FAILURES=()   # one "name|fix command" per blocking failure, printed FIRST at the end
WARNINGS=()

# run <name> <fix-hint> <cmd...>
#   Blocking. Non-zero exit => the gig does not start.
run() {
  local name="$1" fix="$2"; shift 2
  printf '  %-22s' "$name"
  { echo "########## $name : $*"; "$@"; } >>"$LOG" 2>&1
  local rc=$?
  if [ $rc -eq 0 ]; then
    echo "${G}ok${Z}"
  else
    echo "${R}FAIL${Z}  ${D}(rc=$rc)${Z}"
    FAILURES+=("$name|$fix")
  fi
  return $rc
}

# soft <name> <hint> <cmd...>
#   Advisory. Reports, never blocks — for things that are known-open work or
#   depend on hardware that is legitimately absent at this moment.
soft() {
  local name="$1" hint="$2"; shift 2
  printf '  %-22s' "$name"
  { echo "########## $name : $*"; "$@"; } >>"$LOG" 2>&1
  if [ $? -eq 0 ]; then echo "${G}ok${Z}"
  else echo "${Y}warn${Z}"; WARNINGS+=("$name|$hint"); fi
}

# --- context: is the rig up? -------------------------------------------------
# Not a check, an interpretation key. Several results below mean different
# things cold vs live, and reading the report without knowing which is how you
# get a confidently wrong verdict.
rig_state() {
  local sc=no ard=no
  pgrep -x scsynth >/dev/null 2>&1 && sc=yes
  pgrep -x ardour >/dev/null 2>&1 || pgrep -x ardour8 >/dev/null 2>&1 \
    || pgrep -x ardour9 >/dev/null 2>&1 && ard=yes
  echo "$sc $ard"
}
read -r SC ARD <<<"$(rig_state)"

(( QUIET )) || {
  echo
  echo "gig-up — proving the chain.  ${D}scsynth=$SC ardour=$ARD  $( [ "$SC" = no ] && echo '(rig DOWN — cold checks only are meaningful)')${Z}"
  echo
}

# --- 0. converge, opt-in (--converge) ----------------------------------------
# THE BIG RED BUTTON, CLI half: bring the rig to ready, then let the checks below
# prove it. Everything after this point only OBSERVES; this is the only section that
# changes the world, which is why it is opt-in and why it is first.
#
# Scope is SERVICES, not apps. It starts systemd units it owns; it does NOT launch
# Pulsar or Ardour. Those are PLN's GUI apps with sessions and crash-recovery dialogs
# (#20), and a script that opens windows under someone is a script they stop trusting.
# They are REPORTED instead, with the command. Launching them belongs to the Bridge
# gear panel (#116).
#
# SAFE TO RUN AT ANY MOMENT, because every action is conditional on the thing being
# broken. A healthy rig converges to a no-op: nothing restarts, nothing makes a sound,
# no MIDI is sent, and CC 77-84 / CC 93 are never touched by anything here.
if (( CONVERGE )); then
  (( QUIET )) || { echo "  ${D}converge — starting what is missing${Z}"; }
  did=0

  # THE SETLIST BEFORE EVERYTHING, because everything downstream reads it — including
  # the preload plan immediately below. PLN's ruling 2026-08-01: backlog.md is the SSOT
  # for membership AND order, and "if i added new ones, id not need to tell you to
  # update, it should flow downstream". This is where that flow starts (#123).
  #
  # ORDER OF THESE TWO BLOCKS IS LOAD-BEARING. Regenerating preload first would warm
  # the OLD set: that is exactly the 2026-08-01 bug, where the backlog had 15 tracks,
  # every tool believed 13, and three banks (dr, gfunk_bass, h2ogmcy) were left to
  # lazy-load from disk mid-transition.
  if ! python3 tools/setlist.py --check >/dev/null 2>&1; then
    if python3 tools/setlist.py --emit >>"$LOG" 2>&1; then
      echo "  ${G}+${Z} setlist regenerated from backlog.md"; did=1
    else
      # A backlog we cannot parse must never fall back to the stale file in silence.
      echo "  ${R}!${Z} backlog.md's OPAL entry does not parse — see: tools/setlist.py"
    fi
  fi

  # Preload SECOND: fixing it after SuperDirt has booted would warm nothing until the
  # next restart, and the whole point is that the boot reads the set's banks off disk.
  if ! tools/check-preload.sh >/dev/null 2>&1; then
    tools/check-preload.sh --fix >>"$LOG" 2>&1 && { echo "  ${G}+${Z} preload plan regenerated"; did=1; }
  fi

  # THE FADERS, if and only if Ardour is closed. PLN, on being told to raise two of
  # them by hand for the third session running: "but ideally we should not depend in
  # that?" — correct, and the dependency was on him REMEMBERING.
  #
  # fader-baseline replays levels HE captured; it never invents one, it only ever
  # addresses routes named "Tidal NN", and it refuses to write under a live session
  # because the physical desk must always win. So this is a restore, not a mix
  # decision, which is what makes it safe to automate at all.
  if ! python3 tools/fader-baseline.py --check >/dev/null 2>&1; then
    if python3 tools/fader-baseline.py --restore >>"$LOG" 2>&1; then
      echo "  ${G}+${Z} Ardour faders restored from baseline"; did=1
    else
      # Almost always "Ardour is open" — a legitimate refusal, not an error.
      echo "  ${Y}!${Z} faders differ from baseline and could not be restored"
      echo "     ${D}close Ardour, then: tools/fader-baseline.py --restore${Z}"
    fi
  fi

  # The exact failure of 2026-08-01: unit ACTIVE, audio server DEAD. `start` is a no-op
  # on an active unit, so this case needs a RESTART — and distinguishing the two is the
  # entire lesson (a green unit is not sound).
  if ! pgrep -x scsynth >/dev/null 2>&1; then
    if systemctl --user is-active --quiet parvagues-sc.service; then
      echo "  ${Y}+${Z} unit is active but scsynth is DEAD — restarting SuperDirt"
      systemctl --user restart parvagues-sc.service >>"$LOG" 2>&1
    else
      echo "  ${G}+${Z} starting SuperDirt"
      systemctl --user start parvagues-sc.service >>"$LOG" 2>&1
    fi
    did=1
    # Wait for the server, do not guess: a cold boot recompiles the class library and
    # warms 51 banks. Judging too early is what made the first watchdog flap.
    for _ in $(seq 1 50); do sleep 2; pgrep -x scsynth >/dev/null 2>&1 && break; done
  fi

  for u in parvagues-sc-watchdog tidal-ardour-autoroute; do
    if ! systemctl --user is-active --quiet "$u.service"; then
      systemctl --user start "$u.service" >>"$LOG" 2>&1 && { echo "  ${G}+${Z} started $u"; did=1; }
    fi
  done

  if command -v powerprofilesctl >/dev/null 2>&1 && ! powerprofilesctl get 2>/dev/null | grep -q performance; then
    powerprofilesctl set performance >>"$LOG" 2>&1 && { echo "  ${G}+${Z} perf mode on"; did=1; }
  fi

  # Apps: report, never launch.
  pgrep -x pulsar >/dev/null 2>&1 || echo "  ${Y}!${Z} Pulsar is not running — open it and boot Tidal"
  pgrep -f 'ardour[0-9]*' >/dev/null 2>&1 || echo "  ${Y}!${Z} Ardour is not running — open the \"Tidal Live\" session"

  (( did )) || { (( QUIET )) || echo "  ${D}nothing to do — already converged${Z}"; }
  echo
  # Re-read: converge just changed the world the banner described.
  read -r SC ARD <<<"$(rig_state)"
fi

# --- 1. the helpers typecheck ------------------------------------------------
# First because one parse error in BootTidal.hs silences EVERY track at once —
# the worst failure this rig has, and invisible while a stale ghci still holds
# the old definitions.
run "boot helpers" \
    "read the ghc error: tools/check-boot.sh" \
    tools/check-boot.sh

# --- 1a. the gate's own tools are runnable -----------------------------------
# THE THIRD TIME. gig-up's very first run (1219c70) found check-boot.sh and
# check-tracks.sh at mode 644 — never runnable as `tools/check-tracks.sh`, only ever
# working because a human typed `zsh tools/...` out of habit. On 2026-08-01 the same
# bug turned up in THIS FILE: gig-up.sh was committed 100644 by that very commit and
# by 6ab09ea, so it had never been executable in git. It ran only because the local
# working tree happened to carry the bit; a `git checkout` materialised it at 644 and
# it died with "permission denied" — and because the caller filtered the output, the
# run LOOKED like a clean no-op. A false green from a script that never executed.
#
# So the gate now checks its own hands. Cheap, and it has earned its place three times.
run "tools executable" \
    "chmod +x the file(s) listed, then: git update-index --chmod=+x <file>" \
    bash -c '
      bad=0
      for f in tools/gig-up.sh tools/check-boot.sh tools/check-tracks.sh \
               tools/check-preload.sh tools/sc-watchdog.sh tools/setlist.py \
               tools/check-drift.sh tools/take-segments.py tools/fader-baseline.py; do
        [ -e "$f" ] || continue
        [ -x "$f" ] || { echo "not executable on disk: $f"; bad=1; }
        m=$(git ls-files -s "$f" 2>/dev/null | awk "{print \$1}")
        [ -n "$m" ] && [ "$m" != "100755" ] && { echo "git records $m (not 100755): $f"; bad=1; }
      done
      exit $bad'

# --- 1b. the setlist still matches the backlog -------------------------------
# HARD, and placed before every check that reads the setlist, because if this is stale
# then "every setlist track compiles", "the preload plan covers the set" and "transition
# ghosts" are all answering about a set PLN is not playing. That is not a hypothetical:
# on 2026-08-01 the backlog said 15 tracks and all eight setlist consumers said 13, so
# three sample banks went unwarmed and PLN heard it as crackle mid-transition (#120).
#
# The remedy is `--emit`, never a hand-edit: armada/setlist_opal2026.txt is a GENERATED
# artifact now, and hand-editing it is how a generated file drifts from its source
# ([[feedback_parsers_over_copy]]).
run "setlist vs backlog" \
    "tools/setlist.py --emit   (edit backlog.md, never the .txt)" \
    python3 tools/setlist.py --check

# --- 1c. the surface grid still holds -----------------------------------------
# HARD. On 2026-08-02 PLN closed Pulsar after a run and its stale buffer silently
# reverted wap.tidal's d4 knobs from ^32 back to ^52 — undoing a column migration
# that had been committed AND pushed hours earlier ([[reference_pulsar_buffer_clobber]]).
#
# check-drift.sh exists precisely for this and was believed to be wired in here.
# It was not: grepping this file for it found nothing, and the script was mode
# 100644 so it exited 126 on invocation. A check that exists, is documented as
# guarding the gate, and is not actually called is worse than no check — it buys
# confidence it does not deliver ([[feedback_assembling_checks_revalidates]]).
#
# The clobber was invisible in `git status` (the file just reads as modified,
# mixed in with PLN's own real edits). What CAUGHT it was migrate-columns being
# idempotent: 0 moves one minute, 1 move the next. So we assert both — the
# committed grid is intact, AND the working tree needs no migration.
run "surface grid intact" \
    "tools/check-drift.sh   then  tools/migrate-columns.py --plan (expect 0 moves)" \
    bash -c 'bash tools/check-drift.sh >/dev/null 2>&1 || exit 1
             n=$(python3 tools/migrate-columns.py --plan 2>/dev/null \
                 | sed -n "s/^ *\([0-9]\+\) move(s).*/\1/p" | tail -1)
             [ "${n:-0}" = "0" ] || {
               echo "migrate-columns wants $n move(s) — a buffer clobber reverts CCs"
               python3 tools/migrate-columns.py --plan 2>/dev/null | grep "^     " | head -20
               exit 1; }'

# --- 2. the saved Ardour faders ----------------------------------------------
run "fader baseline" \
    "tools/fader-baseline.py --restore   (close Ardour first) — or --capture if these levels ARE the new intent" \
    python3 tools/fader-baseline.py --check

run "ardour faders" \
    "raise it on the desk, then Ctrl+S in Ardour, then re-run. Still -inf after a save = genuinely down." \
    python3 tools/check-mix.py --quiet

# --- 3. every setlist track compiles -----------------------------------------
# Cold, seeded, no rig required. Baseline 13/13 as of 2026-07-31.
run "setlist compiles" \
    "python3 tools/silent-eval.py --seeded --keep  # then read the ghc error for the named track" \
    python3 tools/silent-eval.py --seeded

# --- 4. the mute map has not drifted -----------------------------------------
# gM1 = the kick alone, gM2 = the other percs, gM3 = bass + melodics. The
# filters group differently on purpose (gF1 = the whole rhythm bloc), so this
# check exists precisely because the two maps LOOK like they should match.
run "mute map" \
    "python3 tools/fix-mute-roles.py --apply   # then re-read the diff before committing" \
    python3 tools/fix-mute-roles.py --check

# --- 5. lint, errors only ----------------------------------------------------
# pvlint's `--setlist` is a MODIFIER ("these paths are ordered"), not a mode —
# it still needs the paths. Rather than teach a fourth file how to parse the
# setlist (check-tracks.sh, orphan-orbits.py and set-coherence.py each already
# know), borrow the one parser that is importable. One parser per concept.
run "pvlint (setlist)" \
    "cd tools && python3 -m pvlint --setlist \$(...)   # --fix applies the safe ones" \
    bash -c 'cd tools && python3 -m pvlint --quiet --setlist $(python3 -c "
import importlib.util, pathlib
spec = importlib.util.spec_from_file_location(\"sc\", \"set-coherence.py\")
m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m)
print(\" \".join(str(p) for p in m.setlist_tracks()))")'

# --- 5b. the preload plan covers the set -------------------------------------
# Cold, and HARD. On 2026-08-01 preload.scd was 4 days old and 3 tracks short, so
# take_5_drops' banks were read from DISK on first play and PLN heard it as crackle
# mid-transition. Every other check was green throughout: the plan is GENERATED and
# GITIGNORED, so nothing kept it fresh and nothing compared it to the set. Its natural
# debut was a cold boot at the venue.
run "preload covers set" \
    "tools/check-preload.sh --fix   # then restart parvagues-sc to warm it" \
    tools/check-preload.sh

# --- 6. orbit ghosts across transitions --------------------------------------
# Advisory ON PURPOSE. Ghosting orbits are real (#77) and known-open; they make
# a transition messy, not the gig impossible. A gate that goes red for open work
# is a gate that gets ignored.
soft "transition ghosts" \
     "python3 tools/orphan-orbits.py --matrix   # #77 is the fix, not tonight" \
     python3 tools/orphan-orbits.py

# --- 7. the surface is plugged in --------------------------------------------
# READ-ONLY: asks ALSA who is on the bus. Sends nothing. Advisory because the
# desk is legitimately unplugged when this runs at a kitchen table — but at the
# venue, a warn here means the set has no hands.
soft "LCXL present" \
     "replug the LCXL (USB OUT endpoint stalls — LEDs dark but input alive), then tools/lcxl-init.py" \
     bash -c 'aseqdump -l 2>/dev/null | grep -qi "Launch Control XL"'

# --- 7b. the live chain, opt-in (--live) -------------------------------------
# Everything above proves the SET is correct. None of it proves a RIG EXISTS.
# On 2026-08-01 scsynth had been dead for two days and this gate would still have
# printed GO: scsynth was read only to colour a banner and to skip --audio.
#
# Cold-by-default stays the right default — cold checks are safe to run mid-set, and
# a gate that demands hardware would be useless on a train. So this is a MODE.
#
# Severity is chosen per link, on purpose:
#   HARD  no audio server / no listener / no path to the DAW = there is no gig.
#   SOFT  Pulsar not open yet, desk unplugged, Ardour not launched, laptop on battery
#         — all normal ten minutes before doors, and a gate that reddens for them is a
#         gate PLN stops reading.
#
# Every check reads the thing that MAKES SOUND — a process, a socket, a real link —
# never `systemctl is-active`. That distinction is the whole reason this section
# exists: the unit reported active/running for two days while the rig was mute.
if (( LIVE )); then
  (( QUIET )) || { echo; echo "  ${D}live chain — is there a rig, not just a set?${Z}"; }

  run "scsynth alive" \
      "systemctl --user start parvagues-sc   # the watchdog should beat you to it" \
      pgrep -x scsynth

  run "sclang :57120" \
      "SuperDirt is not listening — systemctl --user restart parvagues-sc" \
      bash -c 'ss -lunp 2>/dev/null | grep -q ":57120"'

  # 12 orbits x 2 channels = 24 at an absolute minimum; a healthy rig shows ~52.
  # Zero here with scsynth alive means SuperCollider is running and going NOWHERE.
  run "SC -> Ardour" \
      "check the autoroute: systemctl --user restart tidal-ardour-autoroute" \
      bash -c 'n=$(pw-link -l 2>/dev/null | grep -ci supercollider); echo "supercollider links: $n"; [ "$n" -ge 24 ]'

  soft "Tidal :6010" \
       "open the set in Pulsar and boot Tidal (nothing is sending patterns yet)" \
       bash -c 'ss -lunp 2>/dev/null | grep -q ":6010"'

  soft "LCXL -> SC" \
       "replug the LCXL, then tools/lcxl-init.py — knobs will move nothing until this is up" \
       bash -c 'aconnect -l 2>/dev/null | grep -A3 "Launch Control XL" | grep -q "128:"'

  # Asks whether THIS instance warmed, by keying to the unit's own start time —
  # not "was there ever a PRELOAD line", which a previous boot would satisfy forever.
  #
  # NOTE the query. `journalctl --user -u parvagues-sc` is NOT reliable on this box:
  # it returned lines from a PREVIOUS instance (Jul 30) while the current one had
  # already logged "51/51 banks OK", so the first version of this check warned about a
  # rig that was perfectly warm. Filter the plain journal by SyslogIdentifier instead.
  soft "SuperDirt warmed" \
       "no PRELOAD line since this boot — systemctl --user restart parvagues-sc to warm the set's banks" \
       bash -c 't=$(systemctl --user show parvagues-sc.service -p ActiveEnterTimestamp --value); [ -n "$t" ] || exit 1; journalctl -t parvagues-sc --since "$t" --no-pager 2>/dev/null | grep -q "banks OK"'

  soft "perf mode" \
       "powerprofilesctl set performance   # and plug in AC: battery caps turbo" \
       bash -c 'powerprofilesctl get 2>/dev/null | grep -q performance'
fi

# --- verdict -----------------------------------------------------------------
# Failures print FIRST and carry their fix, because the read order in a field is
# "what is broken / what do I type", not "here is a report".
echo
if [ ${#FAILURES[@]} -gt 0 ]; then
  echo "${R}NO-GO — ${#FAILURES[@]} blocking failure(s):${Z}"
  for f in "${FAILURES[@]}"; do
    echo "  ${R}✗${Z} ${f%%|*}"
    echo "      fix: ${f#*|}"
  done
  echo
fi
if [ ${#WARNINGS[@]} -gt 0 ] && ! (( QUIET )); then
  for w in "${WARNINGS[@]}"; do
    echo "  ${Y}!${Z} ${w%%|*} — ${w#*|}"
  done
  echo
fi

# --- 8. the audio gate, opt-in ----------------------------------------------
# Last, and only on request: it makes sound and takes ~45s per track. Skipped if
# the cold gate is already red — booting 13 tracks to confirm what a typecheck
# just told you is 10 wasted minutes at a venue.
if (( AUDIO )); then
  if [ ${#FAILURES[@]} -gt 0 ]; then
    echo "${Y}--audio skipped: fix the cold failures first (they will only fail louder).${Z}"
  elif [ "$SC" = no ]; then
    echo "${Y}--audio skipped: scsynth is not running. Boot the rig, then re-run.${Z}"
  else
    echo "audio gate — every declared orbit must make sound (~45s/track):"
    tools/check-tracks.sh || FAILURES+=("audio gate|read the check-tracks log printed above")
    echo
  fi
fi

echo "${D}detail: $LOG${Z}"
if [ ${#FAILURES[@]} -gt 0 ]; then
  echo "${R}gig-up: NO-GO.${Z}"
  exit 1
fi
echo "${G}gig-up: GO.${Z}"
# State the limit every time, in these words. check-mix reads the SAVED session
# file, so a green fader line means "Ardour will BOOT with this fader up" — it
# says nothing about where the physical desk is right now, and the two drift
# independently (CC 77-84 are MIDI-learned; the desk wins on touch). A clean
# report otherwise reads as the stronger claim, which is how the -inf bug
# survived three sessions.
(( QUIET )) || {
  echo "${D}  Proves the set BOOTS correct. NOT that the mixer IS correct —${Z}"
  echo "${D}  check-mix reads the saved session, not the live desk. Ctrl+S in${Z}"
  echo "${D}  Ardour before trusting a green fader line.${Z}"
  [ "$AUDIO" = 0 ] && echo "${D}  Nothing here made sound: add --audio for the empirical gate.${Z}"
}
exit 0
