#!/usr/bin/env bash
# One-screen "what's actually running, and is it working" card.
#
# Run this first when you arrive to a dev session. Distinguishes
# the three states that look identical from `ls`-and-pray inspection:
#
#   - Nothing running       → start-sim.sh + tail-logs.sh
#   - Apps running, streams down → stop-logs.sh + tail-logs.sh
#   - Everything healthy    → start debugging
#
# Companion to `tail-logs.sh` (which writes the session under
# `temp/dev-logs/<timestamp>`). Designed for the "first 60 seconds of
# a session" check — emits ✓ for healthy lines, ✗ for broken ones
# with a suggested fix, and ⌀ for legitimately quiet streams (auth /
# push on a stable account) so they don't read as failures.
#
# Usage:
#   scripts/dev/diagnose.sh           # full per-check breakdown
#   scripts/dev/diagnose.sh --human   # compact "where am I" card
#
# Exit code = number of ✗ lines (zero is healthy).

# NB: NOT `-e` — we want every check to run even if an earlier one
# fails. `pipefail` stays on so anything we DO pipe surfaces failures.
set -uo pipefail

HUMAN=0
for arg in "$@"; do
  case "$arg" in
    --human) HUMAN=1 ;;
    -h|--help)
      sed -n '2,21p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
      exit 0
      ;;
  esac
done

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
# shellcheck source=/dev/null
source "$SCRIPT_DIR/sim.env"

cd "$REPO_ROOT"

ok=0
fail=0
soft=0

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

# --- Output gating ---
# In --human mode we suppress the per-line breakdown and emit a
# compact card at the end instead. The counters still tick so the
# exit code matches the verbose mode.
emit()       { [[ "$HUMAN" -eq 0 ]] && printf "%s\n" "$*"; }
emit_nl()    { [[ "$HUMAN" -eq 0 ]] && printf "\n"; }
mark_ok()    { emit "$(printf "${G}✓${X} %s" "$*")";  ok=$((ok+1)); }
mark_fail()  { emit "$(printf "${R}✗${X} %s" "$*")";  fail=$((fail+1)); }
mark_soft()  { emit "$(printf "${Y}⌀${X} %s" "$*")";  soft=$((soft+1)); }
note()       { emit "$(printf "${D}    %s${X}" "$*")"; }

# Per-stream status accumulator for the --human card. Parallel arrays
# keep this portable to bash 3.2 (no associative arrays on macOS bash).
HUMAN_LOG_NAMES=()
HUMAN_LOG_STATES=()   # ok|soft|fail
record_log() {
  HUMAN_LOG_NAMES+=("$1")
  HUMAN_LOG_STATES+=("$2")
}

# Portable bytes → "1.2K" / "3.4M" / "5.6G". Saves a dependency on
# `numfmt` which isn't part of base macOS.
human_bytes() {
  awk -v b="$1" 'BEGIN {
    sfx="BKMG"
    for (i=1; b>=1024 && i<4; i++) b/=1024
    if (i==1) printf "%dB", b
    else      printf "%.1f%s", b, substr(sfx,i,1)
  }'
}

# ---- Session ----------------------------------------------------------------
if [[ ! -e temp/dev-logs/latest ]]; then
  if [[ "$HUMAN" -eq 1 ]]; then
    printf "No session in temp/dev-logs/latest — run scripts/dev/tail-logs.sh\n"
  else
    mark_fail "No session in temp/dev-logs/latest"
    note "Run: scripts/dev/start-sim.sh && scripts/dev/tail-logs.sh"
    echo
    printf "Summary: %d ok  %d quiet  %d fail\n" "$ok" "$soft" "$fail"
  fi
  exit 1
fi

SESSION_NAME="$(readlink temp/dev-logs/latest)"
SESSION_DIR="temp/dev-logs/$SESSION_NAME"
# Parse the timestamp encoded in the directory name (set by tail-logs.sh)
# rather than relying on mtime — the dir's mtime drifts as new files
# land inside it, which would make the session look perpetually "0m old".
session_start_ts=$(date -j -f "%Y%m%d-%H%M%S" "$SESSION_NAME" +%s 2>/dev/null \
  || stat -f "%m" "$SESSION_DIR" 2>/dev/null || echo 0)
session_age_s=$(( $(date +%s) - session_start_ts ))
session_age_min=$(( session_age_s / 60 ))
[[ "$HUMAN" -eq 0 ]] && printf "Session:        %s (%dm old)\n\n" "$SESSION_DIR" "$session_age_min"

# ---- Sims booted ------------------------------------------------------------
booted=$(xcrun simctl list devices booted 2>/dev/null || true)
if echo "$booted" | grep -q "$IPHONE_UDID"; then
  mark_ok "iPhone sim booted   ($IPHONE_UDID)"
else
  mark_fail "iPhone sim NOT booted ($IPHONE_UDID)"
  note "Fix: scripts/dev/start-sim.sh --ios-only"
fi

if echo "$booted" | grep -q "$WATCH_UDID"; then
  mark_ok "Watch sim booted    ($WATCH_UDID)"
else
  mark_fail "Watch sim NOT booted  ($WATCH_UDID)"
  note "Fix: scripts/dev/start-sim.sh --watch-only"
fi

# ---- Metro :8081 ------------------------------------------------------------
metro_pid=$(lsof -ti :8081 2>/dev/null | head -1 || true)
if [[ -z "$metro_pid" ]]; then
  mark_fail ":8081 not bound — Metro is down"
  note "Fix: scripts/dev/tail-logs.sh"
else
  metro_etime=$(ps -p "$metro_pid" -o etime= 2>/dev/null | tr -d ' ' || echo "?")
  if curl -fsS -m 1 http://localhost:8081/status 2>/dev/null | grep -q 'packager-status:running'; then
    mark_ok ":8081 PID $metro_pid up $metro_etime, /status responding"
  else
    mark_fail ":8081 PID $metro_pid up $metro_etime, /status NOT responding"
    note "Stale Metro — kill $metro_pid && scripts/dev/tail-logs.sh"
  fi
fi

# ---- tail-logs PIDs ---------------------------------------------------------
PIDS_FILE="$SESSION_DIR/.pids"
if [[ ! -f "$PIDS_FILE" ]]; then
  mark_fail "No .pids file in session — tail-logs.sh never ran or was interrupted"
else
  total=0
  alive=0
  while read -r pid; do
    [[ -z "$pid" ]] && continue
    total=$((total+1))
    if kill -0 "$pid" 2>/dev/null; then alive=$((alive+1)); fi
  done < "$PIDS_FILE"
  if [[ "$alive" -eq 0 ]]; then
    mark_fail "tail-logs PIDs: 0/$total alive — streams gone"
    note "Fix: scripts/dev/stop-logs.sh && scripts/dev/tail-logs.sh"
  elif [[ "$alive" -lt "$total" ]]; then
    mark_soft "tail-logs PIDs: $alive/$total alive (some may have been respawned in-session)"
  else
    mark_ok "tail-logs PIDs: $alive/$total alive"
  fi
fi

# ---- Log files --------------------------------------------------------------
# Three categories:
#
#   CANARY — should always be writing on a live session. Stale = ✗ fail.
#     metro.log:          RN bundle activity; the JS source-of-truth.
#     net.log:            [net] devlog; the generate_204 + markers poll
#                         fires every ~60s on a foregrounded app.
#     ios-app-system.log: process == Halflight stream; framework
#                         chatter (keychain reads, network, lifecycle).
#
#   ONESHOT — captured at app launch, then stops. Existence + size
#   matter; freshness does not.
#     ios-launch.log:     simctl launch --console output, write-once.
#
#   QUIET — legitimately silent on a stable session. Empty / stale is ⌀.
#     wc.log:             [wc] devlog; only emits on handoff pushes,
#                         which dedupe once delivered.
#     auth.log:           [auth] devlog; sign-in / refresh events only.
#     push.log:           [push] devlog; notification taps only.
#     ios-app.log:        ht.halflig.app subsystem; intentional emits
#                         only (currently rare).
#     js-errors.log:      javascript subset; only on red boxes.
#     watch-app.log:      watchkitapp subsystem; emits while watch
#                         is foregrounded — sleeps with the watch.
#     watch-app-system.log: process == HalflightWatch + complication;
#                         sleeps with the watch.
#     watch-img.log:      [img] carve-out of watch-app.log; emits only
#                         on watch feed scroll, so empty on a quiet
#                         session is the healthy state.
#     net-tap-page.log:   tapestry/page carve-out of net.log; emits
#                         only on tapestry backfill / scroll.
#     net-tap-bucket.log: tapestry/bucket carve-out; ~3 s polling on
#                         the tapestry surface, empty otherwise.
#     net-tap-since.log:  tapestry/since carve-out; one request per
#                         mount + gap-fill, empty otherwise.
#     429.log:            rate-limited responses across every endpoint;
#                         non-zero is the signal — empty is healthy.
#     metro-hmr.log:      fs.watch over src/ + /events WS tap; one line
#                         per file save + bundle build / error events.
#                         Empty until the operator edits something.
EXPECTED_LOGS=(metro.log net.log ios-app-system.log ios-launch.log wc.log auth.log push.log ios-app.log js-errors.log watch-app.log watch-app-system.log watch-img.log net-tap-page.log net-tap-bucket.log net-tap-since.log 429.log metro-hmr.log)
CANARY_LOGS=(metro.log net.log ios-app-system.log)
ONESHOT_LOGS=(ios-launch.log)

# Canary streams that are stale beyond this window are failed. The
# session-age gate keeps fresh sessions from reading as broken before
# the first poll lands (~30s on a cold start).
STALE_S=120
now_ts=$(date +%s)
# Track newest activity across all real (non-oneshot) streams for the
# --human "Last activity" line.
newest_age=999999
newest_log=""
emit_nl
for log in "${EXPECTED_LOGS[@]}"; do
  file="$SESSION_DIR/$log"
  if [[ ! -f "$file" ]]; then
    mark_fail "$log MISSING"
    record_log "$log" fail
    continue
  fi
  size=$(stat -f "%z" "$file" 2>/dev/null || echo 0)
  mtime=$(stat -f "%m" "$file" 2>/dev/null || echo 0)
  age=$(( now_ts - mtime ))
  hsize=$(human_bytes "$size")

  is_canary=0; is_oneshot=0
  for c in "${CANARY_LOGS[@]}";  do [[ "$c" == "$log" ]] && is_canary=1; done
  for o in "${ONESHOT_LOGS[@]}"; do [[ "$o" == "$log" ]] && is_oneshot=1; done

  # Newest-activity tracker: only consider streaming logs (not oneshot)
  # so ios-launch.log's stale mtime doesn't dominate.
  if [[ "$is_oneshot" -eq 0 && "$size" -gt 0 && "$age" -lt "$newest_age" ]]; then
    newest_age="$age"
    newest_log="$log"
  fi

  if [[ "$size" -eq 0 ]]; then
    if [[ "$is_canary" -eq 1 && "$session_age_s" -gt 30 ]]; then
      mark_fail "$(printf '%-23s empty after %dm — canary down' "$log" "$session_age_min")"
      record_log "$log" fail
      note "Stream may not be writing. Check .pids and the tail-logs demuxer pattern."
    else
      mark_soft "$(printf '%-23s empty (quiet stream)' "$log")"
      record_log "$log" soft
    fi
    continue
  fi

  if [[ "$is_oneshot" -eq 1 ]]; then
    # Freshness is meaningless — `simctl launch --console` captures
    # startup then exits. Existence + non-zero size is the contract.
    mark_ok "$(printf '%-23s %s (oneshot at launch)' "$log" "$hsize")"
    record_log "$log" ok
    continue
  fi

  if [[ "$is_canary" -eq 1 && "$age" -gt "$STALE_S" && "$session_age_s" -gt "$STALE_S" ]]; then
    mark_fail "$(printf '%-23s %s, last write %ds ago — canary STALE' "$log" "$hsize" "$age")"
    record_log "$log" fail
  elif [[ "$age" -gt "$STALE_S" && "$session_age_s" -gt "$STALE_S" ]]; then
    mark_soft "$(printf '%-23s %s, last write %ds ago (quiet)' "$log" "$hsize" "$age")"
    record_log "$log" soft
  else
    mark_ok "$(printf '%-23s %s, last write %ds ago' "$log" "$hsize" "$age")"
    record_log "$log" ok
  fi
done

# ---- App Group plist (watch) ------------------------------------------------
emit_nl
APP_GROUP_PLIST=$(/usr/bin/find "$HOME/Library/Developer/CoreSimulator/Devices/$WATCH_UDID/data/Containers/Shared/AppGroup" -name "group.ht.halflig.app.plist" -print -quit 2>/dev/null || true)
if [[ -z "$APP_GROUP_PLIST" || ! -f "$APP_GROUP_PLIST" ]]; then
  mark_soft "Watch App Group plist not yet created (run watch app to populate)"
else
  keys=$(plutil -p "$APP_GROUP_PLIST" 2>/dev/null | grep -oE '"tapestry\.[a-z0-9.]+"' | sort -u | tr '\n' ' ')
  has_snapshot=0; has_history=0
  if echo "$keys" | grep -q "complication.snapshot.v2"; then has_snapshot=1; fi
  if echo "$keys" | grep -q "history.v1"; then has_history=1; fi
  if [[ "$has_snapshot" -eq 1 && "$has_history" -eq 1 ]]; then
    mark_ok "Watch App Group: snapshot.v2 + history.v1 present"
  elif [[ "$has_snapshot" -eq 1 ]]; then
    mark_soft "Watch App Group: snapshot.v2 present (no history yet)"
  elif [[ "$has_history" -eq 1 ]]; then
    mark_fail "Watch App Group: history present but NO snapshot.v2 — complication will render empty"
    note "Watch app may not be running, or fetch loop hasn't fired writeComplicationSnapshot yet."
  else
    mark_fail "Watch App Group: empty"
    note "Watch app may not have received handoff (check wc.log) or sign-in not completed."
  fi
fi

# ---- Watch image failures ---------------------------------------------------
# watch-img.log carries the `[img]` carve-out of watch-app.log; the
# DebugRecorder emits `← ERR …` for transport / TLS failures and
# `← <non-2xx-status> …` for server-side denials (403 / 404 / 5xx). Both
# encode the host immediately after the status / ERR token, so a single
# awk pass aggregates and ranks by host. A quiet session sees zero —
# this line stays as `Image fails: 0` until the feed scrolls and
# something actually fails.
img_log="$SESSION_DIR/watch-img.log"
img_fail_total=0
img_fail_summary=""
if [[ -s "$img_log" ]]; then
  # Each failure line shape: `… [<subsystem>:img] ← <head> <host>/<path> …`
  # head is either `ERR` (transport) or a 3-digit status >=300. The
  # `index()` lookup skips past the leftward arrow without leaning on
  # multi-byte regex matching (BSD awk's POSIX bracket handling around
  # the U+2190 LEFT ARROW is environment-dependent).
  read -r img_fail_total img_fail_summary < <(
    awk '
      /:img\]/ {
        idx = index($0, "← ")
        if (idx == 0) next
        rest = substr($0, idx + length("← "))
        n = split(rest, parts, " ")
        head = parts[1]
        host = parts[2]
        gsub(/\/.*/, "", host)
        is_fail = 0
        if (head == "ERR") is_fail = 1
        else if (head ~ /^[3-5][0-9][0-9]$/) is_fail = 1
        if (is_fail) {
          total++
          by_host[host]++
        }
      }
      END {
        printf "%d ", total
        n = 0
        for (h in by_host) {
          n++
          arr[n] = by_host[h] " " h
        }
        # Sort descending by count; bash 3.2 has no associative arrays,
        # so do it in awk. Bubble-sort is fine — never more than ~20
        # unique hosts in practice.
        for (i = 1; i <= n; i++) {
          for (j = i+1; j <= n; j++) {
            split(arr[i], a, " "); split(arr[j], b, " ")
            if ((a[1]+0) < (b[1]+0)) { tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp }
          }
        }
        # Top 3 hosts only — the long tail is noise on the card.
        cap = (n < 3 ? n : 3)
        sep = ""
        for (i = 1; i <= cap; i++) {
          split(arr[i], a, " ")
          printf "%s%s: %s", sep, a[2], a[1]
          sep = ", "
        }
      }
    ' "$img_log"
  )
fi
if [[ "$img_fail_total" -eq 0 ]]; then
  mark_ok "Image fails: 0 (watch feed clean)"
else
  # Non-zero failures don't fail the card — they're informational, not
  # a session-health signal. Counted as soft so the exit code stays
  # tied to streams + sims + crashes.
  mark_soft "Image fails: $img_fail_total ($img_fail_summary)"
fi

# ---- HTTP 429s (informational) ----------------------------------------------
# 429.log is the tail-logs demuxer for any HTTP response with status 429.
# Non-zero is interesting (rate-limit budget exhausted somewhere) but not
# a session-health failure — surface as ⌀ when present, ✓ when clean.
ratelimit_log="$SESSION_DIR/429.log"
ratelimit_count=0
if [[ -s "$ratelimit_log" ]]; then
  ratelimit_count=$(wc -l < "$ratelimit_log" | tr -d ' ')
fi
if [[ "$ratelimit_count" -eq 0 ]]; then
  mark_ok "Rate limits: 0 (no 429s this session)"
else
  mark_soft "Rate limits: $ratelimit_count (429 responses — see net-tap-* or 429.log)"
fi

# ---- Recent crashes ---------------------------------------------------------
DR="$HOME/Library/Logs/DiagnosticReports"
human_crash_count=0
human_crash_latest=""
if [[ -d "$DR" ]]; then
  # Use the session timestamp as the cutoff, not the dir mtime — file
  # adds inside SESSION_DIR keep updating the mtime, which would mask
  # crashes that landed seconds after the session started.
  recent=$(find "$DR" \( -name "Halflight*.ips" -o -name "chronoseed*.ips" \) -newer "$SESSION_DIR/.pids" 2>/dev/null || true)
  if [[ -z "$recent" ]]; then
    mark_ok "No Halflight / chronoseed crashes since session start"
  else
    human_crash_count=$(echo "$recent" | wc -l | tr -d ' ')
    # Newest first: stat -f %m and sort. Strip path + timestamp suffix
    # for the human card so the latest crash reads as e.g.
    # "HalflightWatchComplication @ 10:36".
    latest_file=$(echo "$recent" | xargs stat -f '%m %N' 2>/dev/null | sort -rn | head -1 | awk '{print $2}')
    if [[ -n "$latest_file" ]]; then
      latest_base=$(basename "$latest_file")
      latest_proc="${latest_base%%-*}"
      latest_time=$(stat -f '%Sm' -t '%H:%M' "$latest_file" 2>/dev/null || echo "?")
      human_crash_latest="$latest_proc @ $latest_time"
    fi
    mark_fail "$human_crash_count crash report(s) since session start"
    [[ "$HUMAN" -eq 0 ]] && echo "$recent" | head -3 | sed 's|^|    |'
  fi
else
  mark_soft "No DiagnosticReports directory (system has never crashed anything)"
fi

# ---- Summary ----------------------------------------------------------------
if [[ "$HUMAN" -eq 1 ]]; then
  # Compact "where am I" card. Streams collapse to short abbreviations
  # grouped on one line so the whole status fits in ~6 lines of terminal.
  printf "Session:        %s (%dm old)\n" "$SESSION_DIR" "$session_age_min"

  # Stream abbreviations. Keep the trailing space-padded width consistent
  # so the column doesn't jitter as states change.
  abbrev_for() {
    case "$1" in
      metro.log)              echo "metro" ;;
      net.log)                echo "net" ;;
      ios-app-system.log)     echo "ios-sys" ;;
      ios-launch.log)         echo "ios-launch" ;;
      ios-app.log)            echo "ios-app" ;;
      js-errors.log)          echo "js-err" ;;
      wc.log)                 echo "wc" ;;
      auth.log)               echo "auth" ;;
      push.log)               echo "push" ;;
      watch-app.log)          echo "watch-app" ;;
      watch-app-system.log)   echo "watch-sys" ;;
      watch-img.log)          echo "watch-img" ;;
      net-tap-page.log)       echo "tap-page" ;;
      net-tap-bucket.log)     echo "tap-buck" ;;
      net-tap-since.log)      echo "tap-since" ;;
      429.log)                echo "429" ;;
      metro-hmr.log)          echo "hmr" ;;
      *)                      echo "$1" ;;
    esac
  }
  glyph_for() {
    case "$1" in
      ok)   printf "${G}✓${X}" ;;
      soft) printf "${Y}⌀${X}" ;;
      fail) printf "${R}✗${X}" ;;
    esac
  }
  printf "Streams:        "
  i=0
  while [[ "$i" -lt "${#HUMAN_LOG_NAMES[@]}" ]]; do
    [[ "$i" -gt 0 && $(( i % 5 )) -eq 0 ]] && printf "\n                "
    printf "%s %-11s" "$(glyph_for "${HUMAN_LOG_STATES[$i]}")" "$(abbrev_for "${HUMAN_LOG_NAMES[$i]}")"
    i=$((i+1))
  done
  printf "\n"

  # PIDs alive (re-derive from the .pids file — simpler than threading
  # state through, and cheap on a 10-line file).
  if [[ -f "$PIDS_FILE" ]]; then
    total=0; alive=0
    while read -r pid; do
      [[ -z "$pid" ]] && continue
      total=$((total+1))
      kill -0 "$pid" 2>/dev/null && alive=$((alive+1))
    done < "$PIDS_FILE"
    printf "PIDs alive:     %d/%d\n" "$alive" "$total"
  else
    printf "PIDs alive:     ?/? (no .pids file)\n"
  fi

  if [[ -n "$newest_log" ]]; then
    printf "Last activity:  %ds ago (%s)\n" "$newest_age" "$newest_log"
  else
    printf "Last activity:  (no streamed activity)\n"
  fi

  # Image fails landing on the human card only when the counter is
  # non-zero — keeps the steady-state card 6 lines, expands to 7 when
  # there's something to investigate. Same pattern for rate-limit
  # 429s: invisible at zero, surfaces a line when there's something
  # to see.
  if [[ "$img_fail_total" -gt 0 ]]; then
    printf "Image fails:    %d (%s)\n" "$img_fail_total" "$img_fail_summary"
  fi
  if [[ "$ratelimit_count" -gt 0 ]]; then
    printf "Rate limits:    %d (429s in 429.log)\n" "$ratelimit_count"
  fi

  if [[ "$human_crash_count" -gt 0 ]]; then
    printf "Crashes today:  %d (latest: %s)\n" "$human_crash_count" "$human_crash_latest"
  else
    printf "Crashes today:  0\n"
  fi
else
  echo
  printf "Summary: ${G}%d ok${X}  ${Y}%d quiet${X}  ${R}%d fail${X}\n" "$ok" "$soft" "$fail"
fi
exit "$fail"
