#!/bin/bash
# End-to-end (E2E) testing framework for mcpc
#
# Features:
# - Shared home directory by default (tests file locking and concurrency)
# - Optional isolated home directory (--isolated flag)
# - Parallel execution support with unique session names
# - Two commands: mcpc (direct) and xmcpc (with invariant checks)
# - Automatic keychain cleanup
# - Structured logging
#
# Usage in test files:
#   source "$(dirname "$0")/../../lib/framework.sh"
#   test_init "my-test-name"              # Uses shared home directory
#   test_init "my-test-name" --isolated   # Uses isolated home directory
#
#   test_case "description"
#   run_mcpc @session tools-list
#   assert_success
#   assert_contains "$STDOUT" "expected text"
#   test_pass
#
#   test_done
#
# Environment variables:
#   E2E_ISOLATED_ALL=1  - Force all tests to use isolated home (for troubleshooting)

set -euo pipefail

# ============================================================================
# Configuration
# ============================================================================

# Get script locations
_FRAMEWORK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
export PROJECT_ROOT="$(cd "$_FRAMEWORK_DIR/../../.." && pwd)"

# Generate unique run ID (shared across all tests in this run)
export E2E_RUN_ID="${E2E_RUN_ID:-$(date +%Y%m%d-%H%M%S)-$$}"

# Base directory for all test runs
export E2E_RUNS_DIR="${E2E_RUNS_DIR:-$PROJECT_ROOT/test/runs}"

# Force all tests to use isolated home directories (for troubleshooting)
E2E_ISOLATED_ALL="${E2E_ISOLATED_ALL:-0}"

# Shared home directory for this test run (set by run.sh)
# Falls back to run directory if not set
E2E_SHARED_HOME="${E2E_SHARED_HOME:-$E2E_RUNS_DIR/$E2E_RUN_ID/_shared_home}"

# Colors
_RED='\033[0;31m'
_GREEN='\033[0;32m'
_YELLOW='\033[0;33m'
_BLUE='\033[0;34m'
_DIM='\033[0;2m'
_NC='\033[0m'

# ============================================================================
# Cross-platform helpers
# ============================================================================

# Detect platform once
_UNAME_S="$(uname -s)"

# Check if running on Windows (Git Bash / MSYS2 / MINGW)
is_windows() {
  [[ "$_UNAME_S" == MINGW* || "$_UNAME_S" == MSYS* ]]
}

# Cross-platform temp directory
_TMPDIR="${TMPDIR:-${TEMP:-/tmp}}"

# Convert an MSYS/Git Bash path to a native Windows path (mixed mode: C:/...)
# On non-Windows, returns the path unchanged.
# Usage: native_path=$(to_native_path "/d/a/repo/config.json")
to_native_path() {
  if is_windows && command -v cygpath &>/dev/null; then
    cygpath -m "$1"
  else
    echo "$1"
  fi
}

# Kill a process and all its children
# Usage: _kill_tree <pid>
_kill_tree() {
  local pid="$1"
  if is_windows; then
    # On Windows/Git Bash, $! returns MSYS PIDs; bash's built-in kill
    # understands those, but Windows taskkill does not.  Use kill -9
    # to force-terminate, then try taskkill as a best-effort fallback
    # for any Windows-native child processes.
    kill -9 "$pid" 2>/dev/null || true
    taskkill //F //T //PID "$pid" >/dev/null 2>&1 || true
  else
    pkill -P "$pid" 2>/dev/null || true
    kill "$pid" 2>/dev/null || true
  fi
}

# Wait for a background process to exit after it has been killed.
# On Windows, wait can block forever if the process ignores signals, so
# we skip the wait entirely and rely on kill -9 having done its job.
# Usage: _wait_killed <pid>
_wait_killed() {
  local pid="$1"
  if is_windows; then
    # Give the process a brief moment to die, but don't block
    sleep 0.3
  else
    wait "$pid" 2>/dev/null || true
  fi
}

# Find a Python interpreter
_find_python() {
  if command -v python3 &>/dev/null; then
    echo "python3"
  elif command -v python &>/dev/null; then
    echo "python"
  else
    echo "python3"  # will fail with clear error
  fi
}

# Compute a short MD5 hash (first 8 chars)
# Usage: _md5_short "string"
_md5_short() {
  echo "$1" | md5sum 2>/dev/null | cut -c1-8 || echo "$1" | md5 -q -s 2>/dev/null | cut -c1-8
}

# Test state
_TEST_NAME=""
_TEST_RUN_DIR=""
_TEST_CASES_RUN=0
_TEST_CASES_PASSED=0
_TEST_CASES_FAILED=0
_CURRENT_CASE=""
_TEST_ISOLATED=0  # Whether this test uses isolated home directory
_TEST_ISOLATED_HOME=""  # Path to isolated home dir (for cleanup)
declare -a _SESSIONS_CREATED=()  # Explicit array declaration

# ============================================================================
# Test Initialization
# ============================================================================

# Initialize test environment
# Usage: test_init "test-name" [--isolated]
# Options:
#   --isolated  Use isolated home directory (for tests that manipulate files directly)
test_init() {
  _TEST_NAME="$1"
  shift

  # Parse options
  _TEST_ISOLATED=0
  while [[ $# -gt 0 ]]; do
    case "$1" in
      --isolated)
        _TEST_ISOLATED=1
        shift
        ;;
      *)
        echo "Unknown option to test_init: $1" >&2
        exit 1
        ;;
    esac
  done

  # Force isolated mode if E2E_ISOLATED_ALL is set
  if [[ "$E2E_ISOLATED_ALL" == "1" || "$E2E_ISOLATED_ALL" == "true" ]]; then
    _TEST_ISOLATED=1
  fi

  # Create unique run directory for this test (for logs and artifacts)
  _TEST_RUN_DIR="$E2E_RUNS_DIR/$E2E_RUN_ID/$_TEST_NAME"
  mkdir -p "$_TEST_RUN_DIR"

  # Generate short unique ID for this test (used in session names)
  # This keeps Unix socket paths under the 104 char limit
  _TEST_SHORT_ID="$(_md5_short "$E2E_RUN_ID-$_TEST_NAME")"

  # Set up mcpc home directory
  # Use /tmp to keep socket paths short (Unix sockets limited to ~104 bytes)
  if [[ $_TEST_ISOLATED -eq 1 ]]; then
    # Isolated: each test gets its own home directory
    export MCPC_HOME_DIR="$_TMPDIR/mcpc-e2e-$_TEST_SHORT_ID"
    _TEST_ISOLATED_HOME="$MCPC_HOME_DIR"  # Track for cleanup
  else
    # Shared: all tests in this run share the same home directory
    export MCPC_HOME_DIR="$E2E_SHARED_HOME"
  fi
  mkdir -p "$MCPC_HOME_DIR"

  # Create temp directory for test artifacts
  export TEST_TMP="$_TEST_RUN_DIR/tmp"
  mkdir -p "$TEST_TMP"

  # Native-path version of TEST_TMP for passing through CLI arguments
  # (MSYS2 won't auto-convert paths embedded in composite args like path:=/d/a/...)
  export NATIVE_TEST_TMP
  NATIVE_TEST_TMP="$(to_native_path "$TEST_TMP")"

  # Set up cleanup trap
  trap '_test_cleanup' EXIT

  # Log test start
  local home_mode="shared"
  [[ $_TEST_ISOLATED -eq 1 ]] && home_mode="isolated"
  echo "# Test: $_TEST_NAME"
  echo "# Run dir: $_TEST_RUN_DIR"
  echo "# Home dir: $MCPC_HOME_DIR ($home_mode)"
  echo ""
}

# Internal cleanup function
_test_cleanup() {
  local exit_code=$?

  # Close any sessions we created
  if [[ ${#_SESSIONS_CREATED[@]} -gt 0 ]]; then
    for session in "${_SESSIONS_CREATED[@]}"; do
      [[ -n "$session" ]] && $MCPC "$session" close 2>/dev/null || true
    done
  fi

  # Stop proxy server if started
  if [[ -n "${_PROXY_SERVER_PID:-}" ]]; then
    _kill_tree "$_PROXY_SERVER_PID"
    _wait_killed "$_PROXY_SERVER_PID"
    _PROXY_SERVER_PID=""
  fi

  # Clean up keychain entries for our sessions
  _cleanup_keychain

  # Clean up isolated home directory (it's in /tmp)
  if [[ -n "${_TEST_ISOLATED_HOME:-}" && -d "$_TEST_ISOLATED_HOME" ]]; then
    rm -rf "$_TEST_ISOLATED_HOME"
  fi

  return $exit_code
}

# Clean up keychain entries created by tests
_cleanup_keychain() {
  # The keychain entries are keyed by server URL and profile name
  # Our test sessions use unique names, so entries should be unique
  # For now, we rely on sessions being closed properly
  # TODO: Add explicit keychain cleanup using `security delete-generic-password` on macOS
  :
}

# ============================================================================
# mcpc Command Wrappers
# ============================================================================

# Path to mcpc
# Runtime can be overridden via E2E_RUNTIME env var (e.g. E2E_RUNTIME=bun)
MCPC="${E2E_RUNTIME:-node} $PROJECT_ROOT/dist/cli/index.js"

# Run mcpc and capture output
# Sets: STDOUT, STDERR, EXIT_CODE
run_mcpc() {
  local stdout_file="$TEST_TMP/stdout.$$.$RANDOM"
  local stderr_file="$TEST_TMP/stderr.$$.$RANDOM"

  set +e
  $MCPC "$@" >"$stdout_file" 2>"$stderr_file"
  EXIT_CODE=$?
  set -e

  STDOUT=$(cat "$stdout_file")
  STDERR=$(cat "$stderr_file")

  # Log to test log file
  {
    echo "=== run_mcpc $* ==="
    echo "Exit code: $EXIT_CODE"
    echo "--- stdout ---"
    cat "$stdout_file"
    echo "--- stderr ---"
    cat "$stderr_file"
    echo "=== end ==="
    echo ""
  } >> "$_TEST_RUN_DIR/commands.log"

  rm -f "$stdout_file" "$stderr_file"
}

# Run mcpc with extended invariant checks (xmcpc)
# Checks:
# 1. --verbose only adds to stderr, not stdout (for both bare and --json)
# 2. --json on success: valid JSON to stdout, nothing to stderr
# 3. --json on error: valid JSON to stderr, nothing to stdout
#
# Runs the caller's exact command and returns those results.
# Additionally runs all 4 combinations of --json/--verbose to verify invariants.
run_xmcpc() {
  # Run caller's exact command first
  run_mcpc "$@"
  local caller_stdout="$STDOUT"
  local caller_stderr="$STDERR"
  local caller_exit="$EXIT_CODE"

  # Strip --json and --verbose from args to get bare args
  local bare_args=()
  for arg in "$@"; do
    case "$arg" in
      --json|-j|--verbose|-v) ;;
      *) bare_args+=("$arg") ;;
    esac
  done

  # Run all 4 variants for invariant checking
  # Use ${bare_args[@]+"${bare_args[@]}"} to handle empty array with nounset
  run_mcpc ${bare_args[@]+"${bare_args[@]}"}
  local bare_stdout="$STDOUT"

  run_mcpc --verbose ${bare_args[@]+"${bare_args[@]}"}
  local verbose_stdout="$STDOUT"

  run_mcpc --json ${bare_args[@]+"${bare_args[@]}"}
  local json_stdout="$STDOUT"
  local json_stderr="$STDERR"
  local json_exit="$EXIT_CODE"

  run_mcpc --json --verbose ${bare_args[@]+"${bare_args[@]}"}
  local json_verbose_stdout="$STDOUT"
  local json_verbose_stderr="$STDERR"

  # Check --verbose invariant: stdout should be identical with or without --verbose
  if [[ "$verbose_stdout" != "$bare_stdout" ]]; then
    echo "INVARIANT VIOLATION: --verbose changed stdout" >&2
    echo "--- bare stdout ---" >&2
    echo "$bare_stdout" >&2
    echo "--- verbose stdout ---" >&2
    echo "$verbose_stdout" >&2
    EXIT_CODE=99
    return 1
  fi

  # Normalize time-varying fields (lastSeenAt changes between calls due to keepalive pings)
  local json_normalized json_verbose_normalized
  json_normalized=$(echo "$json_stdout" | sed 's/"lastSeenAt": *"[^"]*"/"lastSeenAt":"__NORMALIZED__"/g')
  json_verbose_normalized=$(echo "$json_verbose_stdout" | sed 's/"lastSeenAt": *"[^"]*"/"lastSeenAt":"__NORMALIZED__"/g')

  if [[ "$json_verbose_normalized" != "$json_normalized" ]]; then
    echo "INVARIANT VIOLATION: --verbose changed stdout (with --json)" >&2
    echo "--- json stdout ---" >&2
    echo "$json_stdout" >&2
    echo "--- json verbose stdout ---" >&2
    echo "$json_verbose_stdout" >&2
    EXIT_CODE=99
    return 1
  fi

  # Check --json invariants
  if [[ $json_exit -eq 0 ]]; then
    # On success: valid JSON to stdout, stderr should be empty
    if ! echo "$json_stdout" | jq . >/dev/null 2>&1; then
      echo "INVARIANT VIOLATION: --json success did not return valid JSON to stdout" >&2
      echo "--- stdout ---" >&2
      echo "$json_stdout" >&2
      EXIT_CODE=99
      return 1
    fi
    if [[ -n "$json_stderr" ]]; then
      echo "INVARIANT VIOLATION: --json success should not output to stderr" >&2
      echo "--- stderr ---" >&2
      echo "$json_stderr" >&2
      EXIT_CODE=99
      return 1
    fi
  else
    # On error: valid JSON to stderr, stdout should be empty
    if ! echo "$json_stderr" | jq . >/dev/null 2>&1; then
      echo "INVARIANT VIOLATION: --json error did not return valid JSON to stderr" >&2
      echo "--- stderr ---" >&2
      echo "$json_stderr" >&2
      EXIT_CODE=99
      return 1
    fi
    if [[ -n "$json_stdout" ]]; then
      echo "INVARIANT VIOLATION: --json error should not output to stdout" >&2
      echo "--- stdout ---" >&2
      echo "$json_stdout" >&2
      EXIT_CODE=99
      return 1
    fi
  fi

  # Return caller's exact results
  STDOUT="$caller_stdout"
  STDERR="$caller_stderr"
  EXIT_CODE="$caller_exit"
}

# ============================================================================
# Session Helpers
# ============================================================================

# Generate unique session name for this test
# Usage: session_name "suffix"
# Returns: @e-<short-id>-<suffix>
# Note: Uses short ID to keep socket paths under Unix 104 char limit
session_name() {
  local suffix="${1:-s}"
  echo "@e-${_TEST_SHORT_ID}-${suffix}"
}

# Create a session and track it for cleanup
# Usage: create_session <target> [session-suffix]
# Automatically adds X-Test header when connecting to TEST_SERVER_URL
create_session() {
  local target="$1"
  local suffix="${2:-default}"
  local session=$(session_name "$suffix")

  if [[ -n "${TEST_SERVER_URL:-}" && "$target" == "$TEST_SERVER_URL" ]]; then
    run_mcpc connect "$target" "$session" --header "X-Test: true"
  else
    run_mcpc connect "$target" "$session"
  fi
  if [[ $EXIT_CODE -eq 0 ]]; then
    _SESSIONS_CREATED+=("$session")
  fi

  echo "$session"
}

# ============================================================================
# Test Case Management
# ============================================================================

# Start a test case
test_case() {
  _CURRENT_CASE="$1"
  ((_TEST_CASES_RUN++)) || true
}

# Mark current test case as passed
test_pass() {
  echo -e "${_GREEN}ok${_NC} $_TEST_CASES_RUN - $_CURRENT_CASE"
  ((_TEST_CASES_PASSED++)) || true
}

# Mark current test case as failed
test_fail() {
  local detail="${1:-}"
  echo -e "${_RED}not ok${_NC} $_TEST_CASES_RUN - $_CURRENT_CASE"
  if [[ -n "$detail" ]]; then
    echo "# $detail"
  fi
  ((_TEST_CASES_FAILED++)) || true
}

# Skip a test case
test_skip() {
  local reason="${1:-}"
  echo -e "${_YELLOW}ok${_NC} $_TEST_CASES_RUN - $_CURRENT_CASE # SKIP${reason:+ $reason}"
  ((_TEST_CASES_PASSED++)) || true
}

# Print test summary and exit with appropriate code
test_done() {
  echo ""
  echo "# Tests: $_TEST_CASES_RUN, Passed: $_TEST_CASES_PASSED, Failed: $_TEST_CASES_FAILED"

  if [[ $_TEST_CASES_FAILED -gt 0 ]]; then
    exit 1
  fi
  exit 0
}

# ============================================================================
# Assertions
# ============================================================================

assert_success() {
  local msg="${1:-command should succeed}"
  if [[ $EXIT_CODE -ne 0 ]]; then
    test_fail "$msg (exit code: $EXIT_CODE)"
    echo "# stdout: $STDOUT"
    echo "# stderr: $STDERR"
    exit 1
  fi
}

assert_failure() {
  local msg="${1:-command should fail}"
  if [[ $EXIT_CODE -eq 0 ]]; then
    test_fail "$msg (expected non-zero exit code)"
    exit 1
  fi
}

assert_exit_code() {
  local expected="$1"
  local msg="${2:-exit code should be $expected}"
  if [[ $EXIT_CODE -ne $expected ]]; then
    test_fail "$msg (got: $EXIT_CODE)"
    exit 1
  fi
}

assert_contains() {
  local haystack="$1"
  local needle="$2"
  local msg="${3:-should contain '$needle'}"
  if [[ "$haystack" != *"$needle"* ]]; then
    test_fail "$msg"
    echo "# Got: ${haystack:0:200}..."
    exit 1
  fi
}

assert_not_contains() {
  local haystack="$1"
  local needle="$2"
  local msg="${3:-should not contain '$needle'}"
  if [[ "$haystack" == *"$needle"* ]]; then
    test_fail "$msg"
    exit 1
  fi
}

assert_eq() {
  local actual="$1"
  local expected="$2"
  local msg="${3:-values should be equal}"
  if [[ "$actual" != "$expected" ]]; then
    test_fail "$msg"
    echo "# Expected: $expected"
    echo "# Got: $actual"
    exit 1
  fi
}

assert_not_empty() {
  local value="$1"
  local msg="${2:-value should not be empty}"
  if [[ -z "$value" ]]; then
    test_fail "$msg"
    exit 1
  fi
}

assert_empty() {
  local value="$1"
  local msg="${2:-value should be empty}"
  if [[ -n "$value" ]]; then
    test_fail "$msg"
    echo "# Got: $value"
    exit 1
  fi
}

assert_json_valid() {
  local json="$1"
  local msg="${2:-should be valid JSON}"
  if ! echo "$json" | jq . >/dev/null 2>&1; then
    test_fail "$msg"
    echo "# Got: ${json:0:200}..."
    exit 1
  fi
}

assert_json() {
  local json="$1"
  local expr="$2"
  local msg="${3:-JSON should match '$expr'}"
  if ! echo "$json" | jq -e "$expr" >/dev/null 2>&1; then
    test_fail "$msg"
    exit 1
  fi
}

assert_json_eq() {
  local json="$1"
  local field="$2"
  local expected="$3"
  local msg="${4:-$field should equal '$expected'}"

  local actual
  actual=$(echo "$json" | jq -r "$field" 2>/dev/null) || {
    test_fail "Failed to extract $field from JSON"
    exit 1
  }

  if [[ "$actual" != "$expected" ]]; then
    test_fail "$msg"
    echo "# Expected: $expected"
    echo "# Got: $actual"
    exit 1
  fi
}

assert_file_exists() {
  local path="$1"
  local msg="${2:-file should exist: $path}"
  if [[ ! -f "$path" ]]; then
    test_fail "$msg"
    exit 1
  fi
}

assert_file_not_exists() {
  local path="$1"
  local msg="${2:-file should not exist: $path}"
  if [[ -f "$path" ]]; then
    test_fail "$msg"
    exit 1
  fi
}

assert_stdout_empty() {
  assert_empty "$STDOUT" "stdout should be empty"
}

assert_stderr_empty() {
  assert_empty "$STDERR" "stderr should be empty"
}

# ============================================================================
# Test Server Helpers
# ============================================================================

# Default test server port
TEST_SERVER_PORT="${TEST_SERVER_PORT:-0}"  # 0 = random port
_TEST_SERVER_PID=""
_PROXY_SERVER_PID=""
_HTTPS_WRAPPER_PID=""

# Start test MCP server
# Usage: start_test_server [env_vars...]
# Example: start_test_server PAGINATION_SIZE=2 LATENCY_MS=100
start_test_server() {
  # Find a free port if not specified
  if [[ "$TEST_SERVER_PORT" == "0" ]]; then
    TEST_SERVER_PORT=$($(_find_python) -c 'import socket; s=socket.socket(); s.bind(("",0)); print(s.getsockname()[1]); s.close()')
  fi

  # Build environment
  local env_str="PORT=$TEST_SERVER_PORT"
  for var in "$@"; do
    env_str+=" $var"
  done

  # Start server
  cd "$PROJECT_ROOT"
  env $env_str npx tsx test/e2e/server/index.ts >"$_TEST_RUN_DIR/server.log" 2>&1 &
  _TEST_SERVER_PID=$!

  # Wait for server to be ready
  local max_wait=50  # 10 seconds
  local waited=0
  while ! curl -s "http://localhost:$TEST_SERVER_PORT/health" >/dev/null 2>&1; do
    sleep 0.2
    ((waited++)) || true
    if [[ $waited -ge $max_wait ]]; then
      echo "Error: Test server failed to start" >&2
      cat "$_TEST_RUN_DIR/server.log" >&2
      kill $_TEST_SERVER_PID 2>/dev/null || true
      exit 1
    fi
  done

  export TEST_SERVER_URL="http://localhost:$TEST_SERVER_PORT"
  echo "# Test server started at $TEST_SERVER_URL (PID: $_TEST_SERVER_PID)"

  # Create a dummy auth profile for the test server
  # mcpc requires auth profiles for HTTP servers, so we create a minimal one
  _create_test_auth_profile "localhost:$TEST_SERVER_PORT"
}

# Create a dummy auth profile for a host (internal helper)
# This is needed because mcpc requires auth profiles for all HTTP servers
# Uses atomic mkdir for cross-platform locking
# Usage: _create_test_auth_profile <host> [scheme]
#   scheme defaults to "http" if not specified
_create_test_auth_profile() {
  local host="$1"
  local scheme="${2:-http}"
  local profiles_file="$MCPC_HOME_DIR/profiles.json"
  local lock_file="$MCPC_HOME_DIR/profiles.lock"

  # Ensure mcpc home dir exists
  mkdir -p "$MCPC_HOME_DIR"

  # Acquire lock using atomic mkdir (works on macOS, Linux, and Windows)
  local max_wait=50
  local waited=0
  while ! mkdir "$lock_file" 2>/dev/null; do
    sleep 0.1
    ((waited++)) || true
    if [[ $waited -ge $max_wait ]]; then
      # Stale lock - remove and retry
      rm -rf "$lock_file"
      waited=0
    fi
  done

  # Ensure lock is released on function exit
  trap "rm -rf '$lock_file'" RETURN

  # Create or update profiles.json with a dummy profile for this host
  if [[ -f "$profiles_file" && -s "$profiles_file" ]]; then
    # File exists and is non-empty - add profile for this host using jq
    local updated
    if updated=$(jq --arg host "$host" --arg scheme "$scheme" '.profiles[$host] = {"default": {"name": "default", "serverUrl": ($scheme + "://" + $host), "authType": "oauth", "oauthIssuer": "https://test.example.com", "createdAt": "2025-01-01T00:00:00Z"}}' "$profiles_file" 2>/dev/null); then
      # Write to temp file then atomically rename
      local temp_file="$profiles_file.$$.tmp"
      echo "$updated" > "$temp_file"
      mv "$temp_file" "$profiles_file"
    else
      # JSON parse error - recreate file
      cat > "$profiles_file" << EOF
{
  "profiles": {
    "$host": {
      "default": {
        "name": "default",
        "serverUrl": "$scheme://$host",
        "authType": "oauth",
        "oauthIssuer": "https://test.example.com",
        "createdAt": "2025-01-01T00:00:00Z"
      }
    }
  }
}
EOF
    fi
  else
    # Create new profiles file
    cat > "$profiles_file" << EOF
{
  "profiles": {
    "$host": {
      "default": {
        "name": "default",
        "serverUrl": "$scheme://$host",
        "authType": "oauth",
        "oauthIssuer": "https://test.example.com",
        "createdAt": "2025-01-01T00:00:00Z"
      }
    }
  }
}
EOF
  fi
}

# Start a local HTTP proxy server (proxy-chain) for testing HTTPS_PROXY / HTTP_PROXY support.
# Sets PROXY_URL and PROXY_CONTROL_URL in the environment.
start_proxy_server() {
  local log="$_TEST_RUN_DIR/proxy-server.log"
  cd "$PROJECT_ROOT"
  npx tsx test/e2e/server/proxy-server.ts >"$log" 2>&1 &
  _PROXY_SERVER_PID=$!

  # Wait for PROXY_CONTROL_PORT= line in log (up to 10 seconds)
  # (PROXY_CONTROL_PORT is output after PROXY_PORT, so both are ready)
  local max_wait=50
  local waited=0
  while ! grep -q "^PROXY_CONTROL_PORT=" "$log" 2>/dev/null; do
    sleep 0.2
    ((waited++)) || true
    if [[ $waited -ge $max_wait ]]; then
      echo "Error: Proxy server failed to start" >&2
      cat "$log" >&2
      kill $_PROXY_SERVER_PID 2>/dev/null || true
      exit 1
    fi
  done

  local proxy_port control_port
  proxy_port=$(grep "^PROXY_PORT=" "$log" | cut -d= -f2 | tr -d '[:space:]')
  control_port=$(grep "^PROXY_CONTROL_PORT=" "$log" | cut -d= -f2 | tr -d '[:space:]')

  export PROXY_URL="http://127.0.0.1:${proxy_port}"
  export PROXY_CONTROL_URL="http://127.0.0.1:${control_port}"
  echo "# Proxy server started at $PROXY_URL (control: $PROXY_CONTROL_URL, PID: $_PROXY_SERVER_PID)"
}

# Get the number of requests that have been routed through the proxy server.
# Usage: count=$(proxy_request_count)
proxy_request_count() {
  curl -s "$PROXY_CONTROL_URL/request-count" | grep -o '"count":[0-9]*' | cut -d: -f2
}

# Reset the proxy request counter to zero.
proxy_reset_count() {
  curl -s -X POST "$PROXY_CONTROL_URL/reset" >/dev/null
}

# Start an HTTPS wrapper around the test server using a self-signed certificate.
# Requires start_test_server to have been called first.
# Sets TEST_HTTPS_SERVER_URL in the environment.
start_https_test_server() {
  local log="$_TEST_RUN_DIR/https-wrapper.log"
  cd "$PROJECT_ROOT"
  env TARGET_URL="$TEST_SERVER_URL" npx tsx test/e2e/server/https-wrapper.ts >"$log" 2>&1 &
  _HTTPS_WRAPPER_PID=$!

  # Wait for HTTPS_PORT= line in log (up to 10 seconds)
  local max_wait=50
  local waited=0
  while ! grep -q "^HTTPS_PORT=" "$log" 2>/dev/null; do
    sleep 0.2
    ((waited++)) || true
    if [[ $waited -ge $max_wait ]]; then
      echo "Error: HTTPS wrapper server failed to start" >&2
      cat "$log" >&2
      kill $_HTTPS_WRAPPER_PID 2>/dev/null || true
      exit 1
    fi
  done

  local https_port
  https_port=$(grep "^HTTPS_PORT=" "$log" | cut -d= -f2 | tr -d '[:space:]')

  export TEST_HTTPS_SERVER_URL="https://localhost:${https_port}"
  echo "# HTTPS wrapper started at $TEST_HTTPS_SERVER_URL (PID: $_HTTPS_WRAPPER_PID)"

  # Create auth profile for HTTPS host
  _create_test_auth_profile "localhost:${https_port}" "https"
}

# Stop test server
stop_test_server() {
  if [[ -n "$_HTTPS_WRAPPER_PID" ]]; then
    _kill_tree "$_HTTPS_WRAPPER_PID"
    _wait_killed "$_HTTPS_WRAPPER_PID"
    _HTTPS_WRAPPER_PID=""
  fi
  if [[ -n "$_TEST_SERVER_PID" ]]; then
    _kill_tree "$_TEST_SERVER_PID"
    _wait_killed "$_TEST_SERVER_PID"
    _TEST_SERVER_PID=""
  fi
}

# Server control: fail next N requests
server_fail_next() {
  local count="${1:-1}"
  curl -s -X POST "$TEST_SERVER_URL/control/fail-next?count=$count" >/dev/null
}

# Server control: expire session
server_expire_session() {
  curl -s -X POST "$TEST_SERVER_URL/control/expire-session" >/dev/null
}

# Server control: reset state
server_reset() {
  curl -s -X POST "$TEST_SERVER_URL/control/reset" >/dev/null
}

# Server control: send tools/list_changed notification
server_notify_tools_changed() {
  curl -s -X POST "$TEST_SERVER_URL/control/notify-tools-changed" >/dev/null
}

# Server control: send prompts/list_changed notification
server_notify_prompts_changed() {
  curl -s -X POST "$TEST_SERVER_URL/control/notify-prompts-changed" >/dev/null
}

# Server control: send resources/list_changed notification
server_notify_resources_changed() {
  curl -s -X POST "$TEST_SERVER_URL/control/notify-resources-changed" >/dev/null
}

# Add server cleanup to trap
_original_cleanup=$(trap -p EXIT | sed "s/trap -- '\(.*\)' EXIT/\1/")
trap 'stop_test_server; '"$_original_cleanup" EXIT

# ============================================================================
# Stdio Server Helpers
# ============================================================================

# Create a config file for stdio server
# Usage: create_stdio_config <name> <command> [args...]
# Returns: path to config file
create_stdio_config() {
  local name="$1"
  local command="$2"
  shift 2

  # Convert path-like args (starting with /, ~, .) to native paths on Windows
  local native_args=()
  for arg in "$@"; do
    case "$arg" in
      /*|~*|.*)
        native_args+=("$(to_native_path "$arg")")
        ;;
      *)
        native_args+=("$arg")
        ;;
    esac
  done

  local config_file="$TEST_TMP/config-$name.json"
  local args_json=$(printf '%s\n' "${native_args[@]}" | jq -R . | jq -s .)

  cat > "$config_file" <<EOF
{
  "mcpServers": {
    "$name": {
      "command": "$command",
      "args": $args_json
    }
  }
}
EOF

  to_native_path "$config_file"
}

# Create config for filesystem server
# Usage: create_fs_config [path]
create_fs_config() {
  local path="${1:-$TEST_TMP}"
  create_stdio_config "fs" "npx" "-y" "@modelcontextprotocol/server-filesystem" "$path"
}

# ============================================================================
# Utility Functions
# ============================================================================

# Wait for condition with timeout
# Usage: wait_for <command> [timeout_seconds]
wait_for() {
  local cmd="$1"
  local timeout="${2:-10}"
  local max_iterations=$(( timeout * 5 ))  # 0.2s intervals
  local waited=0

  while ! eval "$cmd" 2>/dev/null; do
    sleep 0.2
    ((waited++)) || true
    if [[ $waited -ge $max_iterations ]]; then
      return 1
    fi
  done
  return 0
}

# Get JSON field from STDOUT
# Usage: json_get ".field.path"
json_get() {
  echo "$STDOUT" | jq -r "$1"
}

# Check if running on macOS
is_macos() {
  [[ "$(uname)" == "Darwin" ]]
}

# Check if running on Linux
is_linux() {
  [[ "$(uname)" == "Linux" ]]
}
