#!/usr/bin/env bash
#
# Spin up N local n8n eval lanes and run eval:instance-ai against them.
#
# Usage:
#   ./scripts/run-eval-lanes.sh --instance-count 5
#   ./scripts/run-eval-lanes.sh 5 --tier pr --verbose
#   ./scripts/run-eval-lanes.sh --instance-count 3 --build -- --filter contact-form
#   ./scripts/run-eval-lanes.sh --inherit-env --instance-count 1 --build -- --filter contact-form
#
# Requires:
#   - docker
#   - dotenvx for env-file mode (pnpm exec dotenvx works)
#   - n8nio/n8n:local image (build with: INCLUDE_TEST_CONTROLLER=true pnpm build:docker)
#   - Either an env file with N8N_INSTANCE_AI_MODEL_API_KEY (+ optional N8N_EVAL_*),
#     or --inherit-env with ANTHROPIC_API_KEY exported. Env-file entries are passed
#     into each lane via docker --env-file; inherited mode passes an allowlist only.
#
set -euo pipefail

# ---------------------------------------------------------------------------
# Defaults
# ---------------------------------------------------------------------------
INSTANCE_COUNT=""
START_PORT=6678
IMAGE="n8nio/n8n:local"
BUILD_IMAGE=false
SKIP_EVAL=false
KEEP_CONTAINERS=false
EVAL_CONCURRENCY="" # auto = instance-count * 4
EVAL_ITERATIONS=1
EVAL_TIER=""
ENV_FILE=".env.local"
ENV_FILE_EXPLICIT=false
INHERIT_ENV=false
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
EVAL_ARGS=()
CONTAINER_NAMES=()
DOCKER_RUN_ARGS=()
SANDBOX_PROJECT_NAME="n8n-eval-sandbox-$$"
SANDBOX_NETWORK_NAME="n8n-eval-sandbox-$$"
SANDBOX_ENV_FILE=""
SANDBOX_ENV_BACKUP=""
SANDBOX_TEMP_DIR=""

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
usage() {
	cat <<'EOF'
Usage: run-eval-lanes.sh [--instance-count N] [options] [-- eval-args...]

Options:
  --instance-count N  Number of n8n instances / ports to open (required)
  --start-port N      First host port to try (default: 6678; skips Node fetch-blocked ports)
  --image NAME        Docker image (default: n8nio/n8n:local)
  --build             Build docker image before starting lanes
  --env-file PATH     Env file relative to repo root (default: .env.local)
  --inherit-env       Use ANTHROPIC_API_KEY from the shell and a local Docker sandbox
  --concurrency N     Eval scenario concurrency (default: instance-count * 4)
  --iterations N      Eval iterations per case (default: 3)
  --tier NAME         Eval tier filter (default: none; use "pr" for PR suite)
  --skip-eval         Only start + seed lanes; do not run evals
  --keep-containers   Do not remove containers on exit
  -h, --help          Show this help

Everything after `--` is passed straight to eval:instance-ai
(e.g. --filter foo, --keep-workflows, --exclude bar).

Examples:
  ./scripts/run-eval-lanes.sh --instance-count 5
  ./scripts/run-eval-lanes.sh 5 --build --tier pr --verbose
  ./scripts/run-eval-lanes.sh --instance-count 3 -- --filter contact-form --keep-workflows
  ./scripts/run-eval-lanes.sh --inherit-env --instance-count 1 --build -- --filter contact-form
EOF
}

log() {
	printf '[eval-lanes] %s\n' "$*"
}

die() {
	printf '[eval-lanes] ERROR: %s\n' "$*" >&2
	exit 1
}

require_cmd() {
	command -v "$1" >/dev/null 2>&1 || die "Missing required command: $1"
}

port_in_use() {
	lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1
}

# Node's fetch() (undici) refuses a fixed set of "bad" ports. The eval CLI
# uses fetch for every n8n REST call, so lane ports must not land on them.
# See: https://fetch.spec.whatwg.org/#port-blocking
is_node_fetch_blocked_port() {
	case "$1" in
		1 | 7 | 9 | 11 | 13 | 15 | 17 | 19 | 20 | 21 | 22 | 23 | 25 | 37 | 42 | 43 | 53 | 69 | 77 | 79 | 87 | 95 | 101 | 102 | 103 | 104 | 109 | 110 | 111 | 113 | 115 | 117 | 119 | 123 | 135 | 137 | 139 | 143 | 161 | 179 | 389 | 427 | 465 | 512 | 513 | 514 | 515 | 526 | 530 | 531 | 532 | 540 | 548 | 554 | 556 | 563 | 587 | 601 | 636 | 989 | 990 | 993 | 995 | 1719 | 1720 | 1723 | 2049 | 3659 | 4045 | 4190 | 5060 | 5061 | 6000 | 6566 | 6665 | 6666 | 6667 | 6668 | 6669 | 6679 | 6697)
			return 0
			;;
		*)
			return 1
			;;
	esac
}

# Force-remove any docker container holding the given host port (e.g. a lane
# left over from a previous --keep-containers run). Returns non-zero when the
# port is held by something that isn't a docker container.
remove_container_on_port() {
	local port="$1"
	local expected_name="n8n-eval-${port}"
	local ids
	ids="$(docker ps -q --filter "publish=${port}")"
	if [[ -z "$ids" ]]; then
		return 1
	fi
	local id
	for id in $ids; do
		local name
		name="$(docker inspect --format '{{.Name}}' "$id")"
		name="${name#/}"
		if [[ "$name" != "$expected_name" ]]; then
			local names
			names="$(docker ps --filter "publish=${port}" --format '{{.Names}}' | tr '\n' ' ')"
			die "port ${port} is held by non-eval container(s): ${names}"
		fi
	done
	log "removing existing eval container on port ${port}: ${expected_name}"
	# shellcheck disable=SC2086 # ids is a newline-separated list of container IDs
	docker rm -f $ids >/dev/null 2>&1 || true
	# Wait for the kernel to release the port after the container dies.
	for _ in $(seq 1 20); do
		port_in_use "$port" || return 0
		sleep 0.5
	done
	return 1
}

allocate_lane_ports() {
	local count="$1"
	local start="$2"
	local port="$start"
	local max_port=65535

	PORTS=()
	while ((${#PORTS[@]} < count)); do
		if ((port > max_port)); then
			die "could not allocate ${count} fetch-safe ports from ${start} (stopped at ${max_port})"
		fi
		if is_node_fetch_blocked_port "$port"; then
			log "skipping port ${port} (blocked by Node fetch)"
		elif port_in_use "$port"; then
			if remove_container_on_port "$port"; then
				PORTS+=("$port")
			else
				die "port ${port} is in use by a non-docker process — stop it or pick --start-port"
			fi
		else
			PORTS+=("$port")
		fi
		port=$((port + 1))
	done
}

wait_for_lane() {
	local port="$1"
	local timeout="${2:-120}"
	for ((i = 1; i <= timeout; i++)); do
		if curl -sf "http://localhost:${port}/healthz/readiness" >/dev/null 2>&1; then
			log "lane ready on port ${port} (${i}s)"
			return 0
		fi
		sleep 1
	done
	die "lane on port ${port} did not become ready within ${timeout}s"
}

load_env_var() {
	local key="$1"
	local file="$2"
	[[ -f "$file" ]] || die "Env file not found: $file"
	local line
	line="$(grep -E "^${key}=" "$file" | tail -n1 || true)"
	[[ -n "$line" ]] || die "Missing ${key} in ${file}"
	printf '%s' "${line#*=}"
}

dotenvx_run() {
	if command -v dotenvx >/dev/null 2>&1; then
		dotenvx run -f "$ENV_FILE_PATH" -- "$@"
	else
		pnpm exec dotenvx run -f "$ENV_FILE_PATH" -- "$@"
	fi
}

prepare_sandbox_env_file() {
	SANDBOX_ENV_FILE="${REPO_ROOT}/packages/cli/bin/.env"
	if [[ -e "$SANDBOX_ENV_FILE" ]]; then
		SANDBOX_ENV_BACKUP="$(mktemp "${TMPDIR:-/tmp}/n8n-eval-sandbox-env.XXXXXX")"
		chmod 600 "$SANDBOX_ENV_BACKUP"
		cp -p "$SANDBOX_ENV_FILE" "$SANDBOX_ENV_BACKUP"
	fi
}

restore_sandbox_env_file() {
	[[ -n "$SANDBOX_ENV_FILE" ]] || return 0
	if [[ -n "$SANDBOX_ENV_BACKUP" && -f "$SANDBOX_ENV_BACKUP" ]]; then
		mv -f "$SANDBOX_ENV_BACKUP" "$SANDBOX_ENV_FILE"
	else
		rm -f "$SANDBOX_ENV_FILE"
	fi
}

cleanup_local_sandbox() {
	local ids
	ids="$(docker ps -aq --filter "label=com.docker.compose.project=${SANDBOX_PROJECT_NAME}" 2>/dev/null || true)"
	if [[ -n "$ids" ]]; then
		log "removing local sandbox containers..."
		# shellcheck disable=SC2086 # ids is a newline-separated list of container IDs
		docker rm -f $ids >/dev/null 2>&1 || true
	fi
	if [[ -n "$SANDBOX_TEMP_DIR" ]]; then
		rm -rf -- "$SANDBOX_TEMP_DIR"
	fi
	docker network rm "$SANDBOX_NETWORK_NAME" >/dev/null 2>&1 || true
}

cleanup() {
	local status=$?
	restore_sandbox_env_file
	if [[ "$KEEP_CONTAINERS" == true ]]; then
		log "keeping containers: ${CONTAINER_NAMES[*]:-none}"
		if [[ "$INHERIT_ENV" == true ]]; then
			log "keeping local sandbox project: ${SANDBOX_PROJECT_NAME}"
		fi
		exit "$status"
	fi

	if [[ ${#CONTAINER_NAMES[@]:-0} -gt 0 ]]; then
		log "removing containers..."
		docker rm -f "${CONTAINER_NAMES[@]}" >/dev/null 2>&1 || true
	fi
	if [[ "$INHERIT_ENV" == true ]]; then
		cleanup_local_sandbox
	fi
	exit "$status"
}

# ---------------------------------------------------------------------------
# Parse args
# ---------------------------------------------------------------------------
while [[ $# -gt 0 ]]; do
	case "$1" in
		--instance-count)
			INSTANCE_COUNT="${2:-}"
			shift 2
			;;
		--start-port)
			START_PORT="${2:-}"
			shift 2
			;;
		--image)
			IMAGE="${2:-}"
			shift 2
			;;
		--build)
			BUILD_IMAGE=true
			shift
			;;
		--env-file)
			ENV_FILE="${2:-}"
			ENV_FILE_EXPLICIT=true
			shift 2
			;;
		--inherit-env)
			INHERIT_ENV=true
			shift
			;;
		--concurrency)
			EVAL_CONCURRENCY="${2:-}"
			shift 2
			;;
		--iterations)
			EVAL_ITERATIONS="${2:-}"
			shift 2
			;;
		--tier)
			EVAL_TIER="${2:-}"
			shift 2
			;;
		--skip-eval)
			SKIP_EVAL=true
			shift
			;;
		--keep-containers)
			KEEP_CONTAINERS=true
			shift
			;;
		-h | --help)
			usage
			exit 0
			;;
		--)
			shift
			EVAL_ARGS+=("$@")
			break
			;;
		[0-9]*)
			INSTANCE_COUNT="$1"
			shift
			;;
		*)
			EVAL_ARGS+=("$1")
			shift
			;;
	esac
done

[[ -n "$INSTANCE_COUNT" ]] || {
	usage
	die "--instance-count is required"
}
[[ "$INSTANCE_COUNT" =~ ^[0-9]+$ ]] || die "--instance-count must be a positive integer"
((INSTANCE_COUNT >= 1)) || die "--instance-count must be >= 1"
[[ "$START_PORT" =~ ^[0-9]+$ ]] || die "--start-port must be a number"
((START_PORT >= 1 && START_PORT <= 65535)) || die "invalid --start-port"
if [[ "$INHERIT_ENV" == true && "$ENV_FILE_EXPLICIT" == true ]]; then
	die "--inherit-env and --env-file cannot be used together"
fi

if [[ -z "$EVAL_CONCURRENCY" ]]; then
	EVAL_CONCURRENCY=$((INSTANCE_COUNT * 4))
fi

ENV_FILE_PATH="${REPO_ROOT}/${ENV_FILE}"
EVAL_PKG_DIR="${REPO_ROOT}/packages/@n8n/instance-ai"
RESET_PAYLOAD='{"owner":{"email":"nathan@n8n.io","password":"PlaywrightTest123","firstName":"Eval","lastName":"Owner"},"admin":{"email":"admin@n8n.io","password":"PlaywrightTest123","firstName":"Admin","lastName":"User"},"members":[],"chat":{"email":"chat@n8n.io","password":"PlaywrightTest123","firstName":"Chat","lastName":"User"}}'

# ---------------------------------------------------------------------------
# Preflight
# ---------------------------------------------------------------------------
require_cmd docker
require_cmd curl
require_cmd lsof
require_cmd pnpm

cd "$REPO_ROOT"

# Port allocation may docker-rm stale lane containers, so it must run after
# the docker preflight check.
PORTS=()
BASE_URLS=()

allocate_lane_ports "$INSTANCE_COUNT" "$START_PORT"

for port in "${PORTS[@]}"; do
	BASE_URLS+=("http://localhost:${port}")
done

BASE_URL_CSV="$(IFS=,; printf '%s' "${BASE_URLS[*]}")"

if [[ "$INHERIT_ENV" == true ]]; then
	[[ -n "${ANTHROPIC_API_KEY:-}" ]] || die "ANTHROPIC_API_KEY is empty"

	export N8N_INSTANCE_AI_MODEL_API_KEY="$ANTHROPIC_API_KEY"
	export N8N_AI_ENABLED="${N8N_AI_ENABLED:-true}"
	export N8N_ENABLED_MODULES="${N8N_ENABLED_MODULES:-instance-ai}"
	export N8N_INSTANCE_AI_SANDBOX_ENABLED=true
	export N8N_INSTANCE_AI_SANDBOX_PROVIDER=n8n-sandbox
	export N8N_SANDBOX_SERVICE_URL=http://sandbox-api:8080
	export N8N_SANDBOX_SERVICE_API_KEY=n8n-sandbox-ci-key

	for name in \
		ANTHROPIC_API_KEY \
		N8N_INSTANCE_AI_MODEL_API_KEY \
		N8N_AI_ENABLED \
		N8N_ENABLED_MODULES \
		N8N_INSTANCE_AI_SANDBOX_ENABLED \
		N8N_INSTANCE_AI_SANDBOX_PROVIDER \
		N8N_SANDBOX_SERVICE_URL \
		N8N_SANDBOX_SERVICE_API_KEY; do
		DOCKER_RUN_ARGS+=(-e "$name")
	done
	DOCKER_RUN_ARGS+=(--network "$SANDBOX_NETWORK_NAME")
else
	[[ -f "$ENV_FILE_PATH" ]] || die "Env file not found: $ENV_FILE_PATH"
	API_KEY="$(load_env_var N8N_INSTANCE_AI_MODEL_API_KEY "$ENV_FILE_PATH")"
	[[ -n "$API_KEY" ]] || die "N8N_INSTANCE_AI_MODEL_API_KEY is empty"
	DOCKER_RUN_ARGS+=(--env-file "$ENV_FILE_PATH")
fi

if [[ "$BUILD_IMAGE" == true ]]; then
	log "building docker image ${IMAGE}..."
	INCLUDE_TEST_CONTROLLER=true pnpm build:docker
elif ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
	die "docker image '${IMAGE}' not found — rerun with --build"
fi

trap cleanup EXIT

# ---------------------------------------------------------------------------
# Start local sandbox
# ---------------------------------------------------------------------------
if [[ "$INHERIT_ENV" == true ]]; then
	prepare_sandbox_env_file
	SANDBOX_TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/${SANDBOX_PROJECT_NAME}.XXXXXX")"
	log "starting local Docker sandbox on network ${SANDBOX_NETWORK_NAME}"
	TMPDIR="$SANDBOX_TEMP_DIR" pnpm --filter n8n-containers services \
		--services sandbox \
		--network "$SANDBOX_NETWORK_NAME" \
		--name "$SANDBOX_PROJECT_NAME"
fi

# ---------------------------------------------------------------------------
# Start lanes
# ---------------------------------------------------------------------------
log "starting ${INSTANCE_COUNT} lane(s) on ports: ${PORTS[*]}"
if [[ "$INHERIT_ENV" == true ]]; then
	log "lane environment: inherited allowlist"
else
	log "lane env file: ${ENV_FILE_PATH}"
fi

for port in "${PORTS[@]}"; do
	name="n8n-eval-${port}"
	CONTAINER_NAMES+=("$name")

	# A stopped leftover container doesn't hold the port (so allocation passes)
	# but would still collide on the name.
	if docker container inspect "$name" >/dev/null 2>&1; then
		log "removing stale container ${name}"
		docker rm -f "$name" >/dev/null 2>&1 || true
	fi

	# Same bounds as CI (test-evals-instance-ai.yml): capped + restartable
	# lanes, pruned executions.
	docker run -d --name "$name" \
		"${DOCKER_RUN_ARGS[@]}" \
		--memory 2.5g --memory-swap 2.5g \
		--restart on-failure \
		--log-opt max-size=50m --log-opt max-file=2 \
		-e NODE_OPTIONS=--max-old-space-size=2048 \
		-e EXECUTIONS_DATA_PRUNE=true \
		-e EXECUTIONS_DATA_MAX_AGE=1 \
		-e E2E_TESTS=true \
		-e N8N_USER_FOLDER=/home/node/.n8n \
		-p "${port}:5678" \
		"$IMAGE" >/dev/null

	log "started container ${name}"
done

for port in "${PORTS[@]}"; do
	wait_for_lane "$port" 120
done

# ---------------------------------------------------------------------------
# Seed owner on every lane
# ---------------------------------------------------------------------------
log "seeding credentials/users on all lanes..."

for port in "${PORTS[@]}"; do
	curl -sf -X POST "http://localhost:${port}/rest/e2e/reset" \
		-H "Content-Type: application/json" \
		-d "$RESET_PAYLOAD" >/dev/null
	log "seeded port ${port}"
done

# ---------------------------------------------------------------------------
# Run evals
# ---------------------------------------------------------------------------
if [[ "$SKIP_EVAL" == true ]]; then
	log "skip-eval set — lanes are up and seeded"
	log "base URLs: ${BASE_URL_CSV}"
	exit 0
fi

[[ -d "$EVAL_PKG_DIR" ]] || die "eval package not found: ${EVAL_PKG_DIR}"

log "running eval:instance-ai"
log "  lanes:       ${INSTANCE_COUNT}"
log "  base-url:    ${BASE_URL_CSV}"
log "  concurrency: ${EVAL_CONCURRENCY}"
log "  iterations:  ${EVAL_ITERATIONS}"
log "  tier:        ${EVAL_TIER:-<none>}"

cd "$EVAL_PKG_DIR"

ARGS=(
	pnpm eval:instance-ai
	--base-url "$BASE_URL_CSV"
	--concurrency "$EVAL_CONCURRENCY"
	--iterations "$EVAL_ITERATIONS"
	--verbose
)

if [[ -n "$EVAL_TIER" ]]; then
	ARGS+=(--tier "$EVAL_TIER")
fi

if [[ ${#EVAL_ARGS[@]} -gt 0 ]]; then
	ARGS+=("${EVAL_ARGS[@]}")
fi

if [[ "$INHERIT_ENV" == true ]]; then
	"${ARGS[@]}"
else
	dotenvx_run "${ARGS[@]}"
fi
