#!/usr/bin/env bash
# kandev-instances — list every running kandev backend instance on this host.
#
# For each backend process (binary named `kandev` with a listening TCP socket)
# it prints, tab-separated:
#   PID  BACKEND_PORT  WEB_PORT  AGENTCTL_PORT  HOME_DIR  REPO_PATH
#
# Sources:
#   - backend PID + listening port: `ss -tlnp` (falls back to `lsof`)
#   - web port: the separate `next-server` listener pointing at this backend
#     (matched via its KANDEV_API_BASE_URL / NEXT_PUBLIC_KANDEV_API_PORT, port
#     read from `ss`); falls back to the backend's KANDEV_WEB_INTERNAL_URL
#     (else `?`). The backend's own port is never the web port.
#   - agentctl port: KANDEV_AGENT_STANDALONE_PORT / AGENTCTL_PORT in environ,
#     else the `-port=` flag of a child `agentctl` process (else `?`)
#   - HOME_DIR: KANDEV_HOME_DIR in environ, else HOME, else `?`
#   - repo path: derived from /proc/<pid>/exe (…/apps/backend/bin/kandev →
#     repo root) or /proc/<pid>/cwd (else `?`)
#
# This is the tool a debugger uses to tell "my isolated instance" apart from
# "the user's live instance" before touching anything. Unresolved fields print
# as `?` rather than failing the whole listing.
#
# Usage:
#   scripts/kandev-instances            # human-readable table
#   scripts/kandev-instances --raw      # tab-separated, no header (scriptable)
#
set -euo pipefail

RAW=0
if [[ "${1:-}" == "--raw" ]]; then
  RAW=1
fi

# Read a single env var value from a process's environ. Prints empty on miss.
read_environ_var() {
  local pid="$1" var="$2"
  [[ -r "/proc/$pid/environ" ]] || return 0
  tr '\0' '\n' <"/proc/$pid/environ" 2>/dev/null |
    awk -F= -v k="$var" '$1==k {sub("^"k"=",""); print; exit}'
}

# Resolve the repo path from a kandev binary's exe path or cwd.
# …/apps/backend/bin/kandev → repo root (strip the trailing 4 segments).
resolve_repo() {
  local pid="$1" exe cwd
  exe="$(readlink "/proc/$pid/exe" 2>/dev/null || true)"
  if [[ "$exe" == */apps/backend/bin/kandev ]]; then
    printf '%s\n' "${exe%/apps/backend/bin/kandev}"
    return 0
  fi
  cwd="$(readlink "/proc/$pid/cwd" 2>/dev/null || true)"
  if [[ "$cwd" == */apps/backend/bin ]]; then
    printf '%s\n' "${cwd%/apps/backend/bin}"
    return 0
  fi
  if [[ -n "$cwd" ]]; then
    printf '%s\n' "$cwd"
    return 0
  fi
  printf '?\n'
}

# Resolve agentctl port: prefer the env var, fall back to a child agentctl's
# -port= flag.
resolve_agentctl_port() {
  local pid="$1" port
  port="$(read_environ_var "$pid" KANDEV_AGENT_STANDALONE_PORT)"
  [[ -z "$port" ]] && port="$(read_environ_var "$pid" AGENTCTL_PORT)"
  if [[ -z "$port" ]]; then
    # Inspect child processes for an `agentctl -port=NNNN`.
    local child cmd
    for child in $(pgrep -P "$pid" 2>/dev/null || true); do
      cmd="$(tr '\0' ' ' <"/proc/$child/cmdline" 2>/dev/null || true)"
      case "$cmd" in
        *agentctl*-port=*)
          port="$(printf '%s' "$cmd" | grep -oE 'port=[0-9]+' | head -1 | cut -d= -f2)"
          break
          ;;
      esac
    done
  fi
  printf '%s\n' "${port:-?}"
}

# True if something is currently listening on the given TCP port.
port_has_listener() {
  local port="$1"
  if command -v ss >/dev/null 2>&1; then
    ss -tlnH "sport = :$port" 2>/dev/null | grep -q .
  elif command -v lsof >/dev/null 2>&1; then
    lsof -nP -iTCP:"$port" -sTCP:LISTEN -t >/dev/null 2>&1
  else
    return 1
  fi
}

# Resolve the web port for a backend.
#
# The web UI is served by a SEPARATE `next-server` process, not the backend, so
# the backend's own listening port is never the web port. We resolve in two
# steps, preferring the authoritative source:
#
#   1. The next-server process that points back at THIS backend. Each web
#      process is launched with KANDEV_API_BASE_URL=http://host:<backend_port>
#      (and/or NEXT_PUBLIC_KANDEV_API_PORT=<backend_port>) and its own
#      PORT=<web_port>. We find the next-server whose backend-pointing env
#      matches this backend's port, then read its ACTUAL listening port from ss
#      (authoritative — the real socket, not an env hint).
#   2. Fallback: the backend's KANDEV_WEB_INTERNAL_URL=http://host:<web_port>,
#      which the backend uses to reach the web server. Parse the trailing
#      :<port> (the host:port pair, never the scheme's colon).
#
# If neither resolves, print `?` rather than guessing a wrong port (e.g. the
# backend port from PORT / NEXT_PUBLIC_KANDEV_API_PORT, which are the backend's).
resolve_web_port() {
  local backend_pid="$1" backend_port="${2:-}" port
  port="$(resolve_web_port_via_next_server "$backend_port")"
  if [[ -z "$port" ]]; then
    local url cand=""
    url="$(read_environ_var "$backend_pid" KANDEV_WEB_INTERNAL_URL)"
    # Match the LAST :<port> so an IPv6 host or path can't fool us; the trailing
    # host:port is what we want, not the scheme separator (http:).
    if [[ "$url" =~ :([0-9]+)([/?#].*)?$ ]]; then
      cand="${BASH_REMATCH[1]}"
    fi
    # KANDEV_WEB_INTERNAL_URL is the web URL the backend was CONFIGURED with — it
    # may name a web port that never started (e.g. a backend-only dev-isolated
    # run). Only trust it if something is actually listening there; otherwise the
    # web UI isn't reachable, so report `?` rather than a dead port.
    if [[ -n "$cand" ]] && port_has_listener "$cand"; then
      port="$cand"
    fi
  fi
  printf '%s\n' "${port:-?}"
}

# Find the next-server process bound to this backend and return its listening
# port (the web port). Empty output means "not found".
resolve_web_port_via_next_server() {
  local backend_port="$1"
  [[ -n "$backend_port" ]] || return 0
  command -v ss >/dev/null 2>&1 || return 0

  # Enumerate listening next-server sockets as "pid port" pairs.
  local pid web_port api_port api_url
  while read -r _ _ _ local_addr _ procinfo; do
    case "$procinfo" in
      *'next-server'*) ;;
      *) continue ;;
    esac
    # sed (not grep) so a no-match yields empty + exit 0 instead of tripping
    # `set -o pipefail` when a socket line carries no pid= field.
    pid="$(printf '%s\n' "$procinfo" | sed -nE 's/.*pid=([0-9]+).*/\1/p' | head -1)"
    web_port="${local_addr##*:}"
    [[ -n "$pid" && -n "$web_port" ]] || continue
    # Does this web process point back at OUR backend port?
    api_port="$(read_environ_var "$pid" NEXT_PUBLIC_KANDEV_API_PORT)"
    if [[ -z "$api_port" ]]; then
      api_url="$(read_environ_var "$pid" KANDEV_API_BASE_URL)"
      [[ "$api_url" =~ :([0-9]+)([/?#].*)?$ ]] && api_port="${BASH_REMATCH[1]}"
    fi
    if [[ "$api_port" == "$backend_port" ]]; then
      printf '%s\n' "$web_port"
      return 0
    fi
  done < <(ss -tlnpH 2>/dev/null)
  return 0
}

# Emit "PID PORT" pairs for every listening kandev backend socket.
# Primary: ss. Fallback: lsof. De-duplicated by PID (a backend may also have
# the agentctl child listening, but agentctl's comm is "agentctl", not
# "kandev", so it's excluded by the comm filter).
list_backend_sockets() {
  if command -v ss >/dev/null 2>&1; then
    # ss columns: State Recv-Q Send-Q Local:Port Peer:Port users:((...))
    # The process info is the last field; the local address is the 4th.
    ss -tlnpH 2>/dev/null | while read -r _ _ _ local_addr _ procinfo; do
      case "$procinfo" in
        *'"kandev"'*)
          local pid port
          # sed (not grep) so a no-match yields empty + exit 0 instead of tripping
          # `set -o pipefail` when a socket line carries no pid= field.
          pid="$(printf '%s\n' "$procinfo" | sed -nE 's/.*pid=([0-9]+).*/\1/p' | head -1)"
          port="${local_addr##*:}"
          [[ -n "$pid" && -n "$port" ]] && printf '%s %s\n' "$pid" "$port"
          ;;
      esac
    done
  elif command -v lsof >/dev/null 2>&1; then
    lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | awk '$1=="kandev" {
      pid=$2; split($9,a,":"); port=a[length(a)]; print pid, port
    }'
  fi
}

# Build a unique "PID PORT" list, keeping the lowest port per PID (the backend
# HTTP server binds the configured KANDEV_SERVER_PORT; that is the canonical one
# we report).
declare -A SEEN_PORT
mapfile -t PAIRS < <(list_backend_sockets | sort -u)
for pair in "${PAIRS[@]:-}"; do
  [[ -z "$pair" ]] && continue
  pid="${pair%% *}"
  port="${pair##* }"
  cur="${SEEN_PORT[$pid]:-}"
  if [[ -z "$cur" || "$port" -lt "$cur" ]]; then
    SEEN_PORT[$pid]="$port"
  fi
done

if [[ "$RAW" -eq 0 ]]; then
  printf '%-7s  %-12s  %-8s  %-13s  %-28s  %s\n' \
    "PID" "BACKEND_PORT" "WEB_PORT" "AGENTCTL_PORT" "HOME_DIR" "REPO_PATH"
fi

# `set -u` treats an empty associative array as unbound when expanded, so
# collect the keys with -u temporarily relaxed.
set +u
pids=("${!SEEN_PORT[@]}")
set -u

if [[ ${#pids[@]} -eq 0 ]]; then
  [[ "$RAW" -eq 0 ]] && echo "(no running kandev instances found)" >&2
  exit 0
fi

for pid in "${pids[@]}"; do
  backend_port="${SEEN_PORT[$pid]}"
  web_port="$(resolve_web_port "$pid" "$backend_port")"
  agentctl_port="$(resolve_agentctl_port "$pid")"
  home_dir="$(read_environ_var "$pid" KANDEV_HOME_DIR)"
  [[ -z "$home_dir" ]] && home_dir="$(read_environ_var "$pid" HOME)"
  [[ -z "$home_dir" ]] && home_dir="?"
  repo="$(resolve_repo "$pid")"

  if [[ "$RAW" -eq 1 ]]; then
    printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
      "$pid" "$backend_port" "$web_port" "$agentctl_port" "$home_dir" "$repo"
  else
    printf '%-7s  %-12s  %-8s  %-13s  %-28s  %s\n' \
      "$pid" "$backend_port" "$web_port" "$agentctl_port" "$home_dir" "$repo"
  fi
done | sort -k2 -n
