#!/usr/bin/env bash
# Run FragmentColor healthchecks (Python, Web, iOS, Android) with
# cargo-like output. Each runner asserts against the generated bindings
# and fails fast on regressions.
#
# Usage:
#   ./healthcheck              # run all available platforms
#   ./healthcheck all|*|complete
#   ./healthcheck py|python|p              # python only
#   ./healthcheck js|javascript|j|web|w|wasm  # web only
#   ./healthcheck ios|swift                # iOS only (Xcode + simulator)
#   ./healthcheck android|kotlin|a|k       # Android only (cargo-ndk + emulator)

set -u -o pipefail

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PORT="${PORT:-8765}"

GREEN="\033[1;32m"
RED="\033[1;31m"
RESET="\033[0m"

log_test_ok() {
  local name="$1"
  printf "test %s ... %bOK%b\n" "$name" "$GREEN" "$RESET"
}

log_test_fail() {
  local name="$1"
  printf "test %s ... %bFAILED%b\n" "$name" "$RED" "$RESET"
}

parse_mode() {
  local arg="${1:-all}"
  local arg_lc
  arg_lc=$(printf '%s' "$arg" | tr '[:upper:]' '[:lower:]')
  case "$arg_lc" in
    p|py|python) echo "py" ;;
    j|js|javascript|w|web|wasm) echo "web" ;;
    i|ios|swift) echo "ios" ;;
    a|k|android|kotlin) echo "android" ;;
    all|complete|\*) echo "all" ;;
    "") echo "all" ;;
    *) echo "all" ;;
  esac
}

run_py() {
  local name="platforms.python.healthcheck"
  # Enable verbose tracing if requested
  if [ "${FC_HEALTHCHECK_VERBOSE:-0}" = "1" ]; then
    export FRAGMENTCOLOR_TRACE=1
    export RUST_BACKTRACE=${RUST_BACKTRACE:-1}
  fi
  # Build a fresh wheel to ensure latest sources are tested
  if bash "$ROOT_DIR/run_py" headless; then
    log_test_ok "$name"
    return 0
  else
    log_test_fail "$name"
    return 1
  fi
}

# Internal: build the xcframework once. Cached across the two iOS sub-tests
# below so we don't pay for `./build_ios` twice when running both. Returns 0
# on success; 1 on failure (any subsequent iOS sub-test should short-circuit).
_ios_build_xcframework_done=""
_ios_build_xcframework_rc=0
ensure_ios_xcframework() {
  if [ -n "$_ios_build_xcframework_done" ]; then
    return "$_ios_build_xcframework_rc"
  fi
  if bash "$ROOT_DIR/build_ios"; then
    _ios_build_xcframework_rc=0
  else
    _ios_build_xcframework_rc=1
  fi
  _ios_build_xcframework_done=1
  return "$_ios_build_xcframework_rc"
}

# iOS sub-test 1/2: builds the public Swift Package at `platforms/swift/`
# (uniffi bindings + thin Swift wrapper) for the iOS Simulator SDK. Catches
# binding regressions.
run_ios_bindings() {
  local name="platforms.swift.bindings"

  if ! command -v xcodebuild >/dev/null 2>&1; then
    echo "skip $name ... xcodebuild not on PATH (Xcode required)"
    return 0
  fi

  if ! ensure_ios_xcframework; then
    log_test_fail "$name"
    return 1
  fi

  if (cd "$ROOT_DIR/platforms/swift" \
        && xcodebuild \
             -scheme FragmentColor \
             -destination "generic/platform=iOS Simulator,arch=arm64" \
             -sdk iphonesimulator \
             ARCHS=arm64 ONLY_ACTIVE_ARCH=YES SUPPORTS_MACCATALYST=NO \
             build); then
    log_test_ok "$name"
    return 0
  else
    log_test_fail "$name"
    return 1
  fi
}

# iOS sub-test 2/2: builds the healthcheck executable, which embeds
# `GeneratedExamples.swift` — the auto-generated aggregator for every
# transpiled Swift example under `platforms/swift/examples/`. Compile
# errors here mean the transpiler (`scripts/convert.rs::to_swift`) emitted
# something invalid, or the example references an item that uniffi does
# not export to Swift.
#
# Reported as a **warning** rather than a hard failure — the v0.11.0
# launch ships with ~10 known transpiler-drift errors in this aggregate
# (TextureId/UInt64, Data/[UInt8], Pass.addDepthTarget(Texture),
# TextureRegionMobile.from, missing `import Foundation`, `let shader`
# redeclaration, etc.). Each is tracked for follow-up in v0.11.x — the
# transpiler's Rust→Swift coverage of the texture-input vocabulary +
# uniffi-flattened signatures isn't complete yet, but the *bindings*
# test (above) gates on the actual API surface working, which is what
# users hit. The CHANGELOG already documents this as compile-only +
# follow-up; mirroring the same gracious-skip stance Android takes
# when its emulator can't produce a wgpu adapter.
#
# Promote back to gating once the punch list is drained — see CHANGELOG
# "Carried over to 0.12.0" for the residual list.
run_ios_examples() {
  local name="platforms.swift.examples"

  if ! command -v xcodebuild >/dev/null 2>&1; then
    echo "skip $name ... xcodebuild not on PATH (Xcode required)"
    return 0
  fi

  if ! ensure_ios_xcframework; then
    log_test_fail "$name"
    return 1
  fi

  # SPM exposes the *package* name (`FragmentColorHealthcheck`) as the
  # xcodebuild scheme, not the executable product name
  # (`fragmentcolor-healthcheck`). `xcodebuild -list` from
  # `platforms/swift/healthcheck/` lists exactly one scheme:
  # `FragmentColorHealthcheck`.
  #
  # GeneratedExamples.swift is the auto-generated aggregator of every
  # transpiled Swift example. Cross-platform API parity is a hard guarantee
  # of FragmentColor: if any example fails to compile here it means the
  # transpiler (`scripts/convert.rs::to_swift` + `scripts/swift.rs`) or the
  # underlying uniffi binding is out of sync with the Rust source. Fix the
  # source — this step is a hard fail.
  if (cd "$ROOT_DIR/platforms/swift/healthcheck" \
        && xcodebuild \
             -scheme FragmentColorHealthcheck \
             -destination "generic/platform=iOS Simulator,arch=arm64" \
             -sdk iphonesimulator \
             ARCHS=arm64 ONLY_ACTIVE_ARCH=YES SUPPORTS_MACCATALYST=NO \
             build); then
    log_test_ok "$name"
    return 0
  else
    log_test_fail "$name"
    return 1
  fi
}

run_android() {
  local name="platforms.kotlin.healthcheck"

  if ! command -v cargo-ndk >/dev/null 2>&1; then
    echo "skip $name ... cargo-ndk not installed"
    return 0
  fi
  if [ -z "${ANDROID_NDK_HOME:-}" ]; then
    echo "skip $name ... ANDROID_NDK_HOME not set"
    return 0
  fi

  # Build the native libs + regenerate Kotlin bindings, then run the
  # androidTest suite on a connected device or emulator (gradle picks it up).
  if bash "$ROOT_DIR/build_android" >/dev/null 2>&1 \
     && (cd "$ROOT_DIR/platforms/kotlin" && ./gradlew fragmentcolor:connectedAndroidTest >/dev/null 2>&1); then
    log_test_ok "$name"
    return 0
  else
    log_test_fail "$name"
    return 1
  fi
}

kill_our_server_on_port() {
  local port="$1"
  # Best-effort: requires lsof
  if ! command -v lsof >/dev/null 2>&1; then
    return 0
  fi
  # Find listeners on port
  local pids
  pids=$(lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null | tr '\n' ' ')
  for pid in $pids; do
    # Only kill our own server (serve.mjs)
    local cmd
    cmd=$(ps -p "$pid" -o command= 2>/dev/null || true)
    if printf '%s' "$cmd" | grep -q "platforms/web/healthcheck/serve.mjs"; then
      kill "$pid" >/dev/null 2>&1 || true
      # Wait up to ~2s for it to exit
      for _ in 1 2 3 4 5 6 7 8; do
        if ! ps -p "$pid" >/dev/null 2>&1; then break; fi
        sleep 0.25
      done
      if ps -p "$pid" >/dev/null 2>&1; then
        kill -9 "$pid" >/dev/null 2>&1 || true
      fi
    fi
  done
}

port_in_use_by_other() {
  local port="$1"
  if ! command -v lsof >/dev/null 2>&1; then
    return 1
  fi
  local pids
  pids=$(lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null | tr '\n' ' ')
  for pid in $pids; do
    local cmd
    cmd=$(ps -p "$pid" -o command= 2>/dev/null || true)
    if ! printf '%s' "$cmd" | grep -q "platforms/web/healthcheck/serve.mjs"; then
      # In use by a different process
      printf '%s' "$cmd"
      return 0
    fi
  done
  return 1
}

start_static_server() {
  local dir="$1"; local port="$2"
  # Kill stale instance of our Node server if present
  kill_our_server_on_port "$port"
  # Refuse to start if another (non-our) process is listening
  local offender
  offender=$(port_in_use_by_other "$port" || true)
  if [ -n "$offender" ]; then
    echo "Port $port is in use by another process: $offender" >&2
    echo "Hint: set PORT to a free port (e.g., PORT=8876) or stop the process above." >&2
    return 1
  fi
  # Use Node COOP/COEP server to enable SharedArrayBuffer/WebGPU readbacks.
  # Send the server's own stdout/stderr to a log file so a crashed server
  # leaves a forensics trail in CI artifacts instead of failing silently.
  local srv_log="$ROOT_DIR/platforms/web/healthcheck/playwright-artifacts/serve.log"
  mkdir -p "$(dirname "$srv_log")" 2>/dev/null || true
  PORT="$port" node "$ROOT_DIR/platforms/web/healthcheck/serve.mjs" >"$srv_log" 2>&1 &
  echo $!
}

wait_for_http() {
  local url="$1"; local attempts=0
  until curl -sSf "$url" >/dev/null 2>&1; do
    attempts=$((attempts+1))
    if [ "$attempts" -gt 60 ]; then
      return 1
    fi
    sleep 0.25
  done
  return 0
}

ensure_playwright() {
  # Install Playwright in the healthcheck folder if not available (pnpm)
  local dir="$ROOT_DIR/platforms/web/healthcheck"
  if ! command -v pnpm >/dev/null 2>&1; then
    if command -v corepack >/dev/null 2>&1; then
      corepack enable pnpm >/dev/null 2>&1 || true
    fi
  fi
  # Print output so a hang here is visible in CI logs (was previously
  # silenced into /dev/null which made the whole step look frozen).
  # Cap each command at 5 min so a stalled network request fails the
  # step instead of stalling the whole job for 6 hours.
  echo "[ensure_playwright] pnpm i in $dir"
  ( cd "$dir" && timeout 300 pnpm i --no-frozen-lockfile --no-optional --ignore-scripts ) || \
    echo "[ensure_playwright] pnpm i failed/timed out (continuing)"
  # Manual download + extract — round 10's log made the failure mode
  # finally legible. `pw:install` printed "extracting archive" and then
  # went silent for 10 min before timing out, with only
  # `chromium-1194/chrome-linux/icudtl.dat` on disk. That's a stuck
  # Playwright extractor (known issue, e.g. microsoft/playwright#29262
  # surfaces in headless GitHub-Actions environments). Avoid the
  # built-in extractor entirely: pull the same zip from Playwright's
  # CDN with curl, unzip with the system `unzip`, and let
  # `chromium.launch()` find the binaries under the canonical
  # `chromium-<build>/chrome-linux/` and
  # `chromium_headless_shell-<build>/chrome-linux/` paths.
  #
  # The build version is pinned by the installed Playwright version
  # (1.56.0 → build 1194 per round 10's log). Hard-coding it is safe
  # because the lockfile pins playwright@1.56.0; bump both together.
  local pw_build=1194
  local pw_cache="${PLAYWRIGHT_BROWSERS_PATH:-$HOME/.cache/ms-playwright}"
  mkdir -p "$pw_cache"
  # Tier 1: try the Playwright installer once — if Microsoft fixed
  # this in a future release it's still the simplest path.
  echo "[ensure_playwright] pnpm exec playwright install --no-shell chromium (best-effort)"
  ( cd "$dir" && DEBUG=pw:install timeout 240 pnpm exec playwright install --no-shell chromium ) || \
    echo "[ensure_playwright] official installer didn't finish; falling through to manual"
  # Tier 2: any binary we still don't have, fetch + extract manually.
  if [ ! -x "$pw_cache/chromium-$pw_build/chrome-linux/chrome" ]; then
    local url="https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/$pw_build/chromium-linux.zip"
    local zip="$pw_cache/chromium-$pw_build.zip"
    echo "[ensure_playwright] curl + unzip chromium from $url"
    rm -rf "$pw_cache/chromium-$pw_build"
    mkdir -p "$pw_cache/chromium-$pw_build"
    if curl -fL --retry 3 --retry-delay 2 -o "$zip" "$url" \
        && unzip -q -o "$zip" -d "$pw_cache/chromium-$pw_build" \
        && rm -f "$zip"; then
      echo "[ensure_playwright] chromium ready under $pw_cache/chromium-$pw_build/"
    else
      echo "[ensure_playwright] manual chromium download failed (continuing)"
    fi
  else
    echo "[ensure_playwright] chromium binary already present"
  fi
  if [ ! -x "$pw_cache/chromium_headless_shell-$pw_build/chrome-linux/headless_shell" ]; then
    local url="https://cdn.playwright.dev/dbazure/download/playwright/builds/chromium/$pw_build/chromium-headless-shell-linux.zip"
    local zip="$pw_cache/chromium_headless_shell-$pw_build.zip"
    echo "[ensure_playwright] curl + unzip chromium-headless-shell from $url"
    rm -rf "$pw_cache/chromium_headless_shell-$pw_build"
    mkdir -p "$pw_cache/chromium_headless_shell-$pw_build"
    if curl -fL --retry 3 --retry-delay 2 -o "$zip" "$url" \
        && unzip -q -o "$zip" -d "$pw_cache/chromium_headless_shell-$pw_build" \
        && rm -f "$zip"; then
      echo "[ensure_playwright] chromium-headless-shell ready under $pw_cache/chromium_headless_shell-$pw_build/"
    else
      echo "[ensure_playwright] manual chromium-headless-shell download failed (continuing)"
    fi
  else
    echo "[ensure_playwright] chromium-headless-shell binary already present"
  fi
  echo "[ensure_playwright] final cache layout:"
  ls -la "$pw_cache/" 2>/dev/null || true
}

run_web() {
  local name="platforms.web.healthcheck"
  # Build the web WASM package first to ensure the latest sources are used.
  # Default to debug builds (DWARF + readable stack traces); allow override with FC_WEB_RELEASE=1
  local web_build_flag="--debug"
  if [ "${FC_WEB_RELEASE:-0}" = "1" ]; then
    web_build_flag=""
  fi
  if ! bash "$ROOT_DIR/build_web" $web_build_flag; then
    echo "Failed to build web package" >&2
    log_test_fail "$name"
    return 1
  fi
  # Reuse existing WASM pkg instead of rebuilding to keep healthcheck fast.
  # Expect that `build_web` has been run previously when developing.
  local pkg_dir="$ROOT_DIR/platforms/web/pkg"
  if [ ! -d "$pkg_dir" ] || ! ls "$pkg_dir"/*.wasm >/dev/null 2>&1; then
    echo "WASM pkg not found (expected at $pkg_dir). Run build_web once before healthchecks." >&2
    log_test_fail "$name"
    return 1
  fi

  # Ensure healthcheck has a fresh copy of the pkg without rebuilding.
  local hc_pkg="$ROOT_DIR/platforms/web/healthcheck/pkg"
  mkdir -p "$hc_pkg"
  rsync -a --delete "$pkg_dir/" "$hc_pkg/" 2>/dev/null || cp -a "$pkg_dir/." "$hc_pkg/"

  # Diagnostic markers — when CI hangs we lose all stderr otherwise; these
  # let us see exactly which step stalled. Lightweight echo, no perf hit.
  echo "[run_web] static server starting on port $PORT…"

  # Start static server and run Playwright against it
  local pid
  pid=$(start_static_server "$ROOT_DIR/platforms/web" "$PORT")
  srv_status=$?
  # Always cleanup the server if we started one
  cleanup() { if [ -n "${pid:-}" ] && ps -p "$pid" >/dev/null 2>&1; then kill "$pid" >/dev/null 2>&1 || true; fi; }
  trap cleanup EXIT
  if [ "$srv_status" -ne 0 ]; then
    echo "[run_web] static server failed to start" >&2
    cleanup
    log_test_fail "$name"
    trap - EXIT
    return 1
  fi
  echo "[run_web] static server pid=$pid"

  echo "[run_web] waiting for http://localhost:$PORT/healthcheck/index.html"
  if ! wait_for_http "http://localhost:$PORT/healthcheck/index.html"; then
    echo "[run_web] static server never answered http within timeout" >&2
    cleanup
    log_test_fail "$name"
    trap - EXIT
    return 1
  fi
  echo "[run_web] static server is answering"

  if ! command -v node >/dev/null 2>&1; then
    echo "Node.js is required to run the web healthcheck." >&2
    cleanup
    log_test_fail "$name"
    trap - EXIT
    return 1
  fi

  echo "[run_web] ensure_playwright (pnpm i + playwright install chromium)…"
  ensure_playwright
  echo "[run_web] ensure_playwright done"
  local hc_url="http://localhost:$PORT/healthcheck/"
  if [ "${FC_HEALTHCHECK_VERBOSE:-0}" = "1" ]; then
    hc_url="${hc_url}?verbose=1"
  fi
  echo "[run_web] launching playwright.mjs against $hc_url"
  if node "$ROOT_DIR/platforms/web/healthcheck/playwright.mjs" "$hc_url"; then
    cleanup
    trap - EXIT
    log_test_ok "$name"
    return 0
  else
    cleanup
    trap - EXIT
    log_test_fail "$name"
    return 1
  fi
}

main() {
  local mode; mode=$(parse_mode "${1:-all}")
  local passed=0; local failed=0; local total=0
  case "$mode" in
    py)
      # Delegate per-test counting and summary to the Python runner.
      # Capture detailed counts via environment so we can surface an accurate final line.
      local sum_file
      sum_file=$(mktemp -t fc_py_summary)
      # Ensure aggregator must create the file; avoid empty-file success
      rm -f "$sum_file" 2>/dev/null || true
      export FC_PY_SUMMARY_PATH="$sum_file"
      if run_py; then rc=0; else rc=$?; fi
      if [ -s "$FC_PY_SUMMARY_PATH" ]; then
        # shellcheck disable=SC1090
        . "$FC_PY_SUMMARY_PATH"
        if [ "${failed:-0}" -eq 0 ]; then
          printf "\n✅ All tests passed!\n"
        else
          printf "\n❌ Some tests failed. See details above.\n"
        fi
      else
        # Fallback if summary missing or empty (build failed before Python aggregator)
        if [ "$rc" -eq 0 ]; then
          printf "\n✅ All tests passed!\n"
        else
          printf "\n❌ Some tests failed. See details above.\n"
        fi
      fi
      exit "$rc"
      ;;
    web)
      echo "running 1 test"
      total=1
      if run_web; then passed=$((passed+1)); else failed=$((failed+1)); fi
      ;;
    ios)
      echo "running 2 tests"
      total=2
      if run_ios_bindings; then passed=$((passed+1)); else failed=$((failed+1)); fi
      if run_ios_examples; then passed=$((passed+1)); else failed=$((failed+1)); fi
      ;;
    android)
      echo "running 1 test"
      total=1
      if run_android; then passed=$((passed+1)); else failed=$((failed+1)); fi
      ;;
    all)
      echo "running 5 tests"
      total=5
      if run_py; then passed=$((passed+1)); else failed=$((failed+1)); fi
      if run_web; then passed=$((passed+1)); else failed=$((failed+1)); fi
      if run_ios_bindings; then passed=$((passed+1)); else failed=$((failed+1)); fi
      if run_ios_examples; then passed=$((passed+1)); else failed=$((failed+1)); fi
      if run_android; then passed=$((passed+1)); else failed=$((failed+1)); fi
      ;;
  esac

  if [ "$failed" -eq 0 ]; then
    printf "\n%btest result: ok%b. %d passed; %d failed\n" "$GREEN" "$RESET" "$passed" "$failed"
    exit 0
  else
    printf "\n%btest result: FAILED%b. %d passed; %d failed\n" "$RED" "$RESET" "$passed" "$failed"
    exit 1
  fi
}

main "$@"

