#!/usr/bin/env bash
# SPDX-FileCopyrightText: 2026 Marcus Baw and Baw Medical Ltd
# SPDX-License-Identifier: AGPL-3.0-or-later

# Serve the docs locally with hot-reload. Binds the first free port in
# 8000-8030, so it never clashes with another project's docs server already
# running. Pass your own `-a`/`--dev-addr` to override the auto-pick.
#
# Requires `zensical` - install with `pip install -r requirements.txt`
# in a venv, then re-run this script with that venv activated.
#
# Common gotcha on Linux: Zensical's watcher consumes inotify instances. The
# kernel default (128 per user) can run out on a dev machine with editors and
# file-syncers already open. If you see `Too many open files` / inotify panics,
# raise the cap:
#
#     sudo sysctl fs.inotify.max_user_instances=512
#
# Persist it with:
#
#     echo 'fs.inotify.max_user_instances=512' \
#       | sudo tee /etc/sysctl.d/99-inotify.conf
#     sudo sysctl --system

set -euo pipefail
cd "$(git rev-parse --show-toplevel)"

# Preflight: Zensical's watcher fails at inotify_init1 if the user's cap
# (fs.inotify.max_user_instances) is saturated by other running processes.
limit=$(sysctl -n fs.inotify.max_user_instances 2>/dev/null || echo 0)
held=$(find /proc -maxdepth 3 -user "$(id -u)" -name fd 2>/dev/null \
  | xargs -I{} ls -l {} 2>/dev/null \
  | grep -c 'anon_inode:inotify' || true)

if [[ "$limit" -gt 0 && "$held" -ge "$limit" ]]; then
  cat >&2 <<EOF
ERROR: no free inotify instances for Zensical's file watcher
  cap:  fs.inotify.max_user_instances = $limit
  held: $held (by existing processes in your user session)

Fix (this session):
  sudo sysctl fs.inotify.max_user_instances=$(( limit * 2 < 512 ? 512 : limit * 2 ))

Persist across reboots:
  echo 'fs.inotify.max_user_instances=512' | sudo tee /etc/sysctl.d/99-inotify.conf
  sudo sysctl --system

Then re-run $0.
EOF
  exit 1
fi

# Non-fatal: getting close to the cap - warn but proceed.
if [[ "$limit" -gt 0 && "$held" -ge $(( limit - 4 )) ]]; then
  echo >&2 "warning: only $(( limit - held )) free inotify instances (cap $limit, held $held)"
  echo >&2 "         if Zensical panics with \"Too many open files\", raise the cap:"
  echo >&2 "           sudo sysctl fs.inotify.max_user_instances=$(( limit * 2 ))"
  echo >&2
fi

# Pick the first free port in 8000-8030 so concurrent docs servers do not fight
# over 8000 - unless the caller passed their own address.
PORT_MIN=8000
PORT_MAX=8030

addr_given=0
open_given=0
for arg in "$@"; do
  case "$arg" in
    -a | --dev-addr | --dev-addr=*) addr_given=1 ;;
    -o | --open) open_given=1 ;;
  esac
done

# True when something is already LISTENING on $1, on ANY address and either IP
# family. Zensical binds `localhost`, which often resolves to IPv6 `::1`, so an
# IPv4-only connect probe misses it - use `ss` (Linux), then `lsof`
# (macOS/BSD), then a best-effort connect probe.
port_in_use() {
  if command -v ss >/dev/null 2>&1; then
    if listeners=$(ss -ltnH 2>/dev/null); then
      awk -v port="$1" '
        {
          addr = $4
          sub(/.*:/, "", addr)
          gsub(/[^0-9].*/, "", addr)
          if (addr == port) found = 1
        }
        END { exit !found }
      ' <<<"$listeners"
      return
    fi
  fi

  if command -v lsof >/dev/null 2>&1; then
    lsof -nP -iTCP:"$1" -sTCP:LISTEN >/dev/null 2>&1
  else
    (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null
  fi
}

if [[ "$addr_given" -eq 0 ]]; then
  chosen=""
  for port in $(seq "$PORT_MIN" "$PORT_MAX"); do
    if ! port_in_use "$port"; then
      chosen="$port"
      break
    fi
  done
  if [[ -n "$chosen" ]]; then
    echo >&2 "Serving docs on http://localhost:$chosen"
    set -- --dev-addr "localhost:$chosen" "$@"
  else
    echo >&2 "warning: no free port in ${PORT_MIN}-${PORT_MAX}; letting zensical use its default"
  fi
fi

if [[ "$open_given" -eq 0 ]]; then
  set -- --open "$@"
fi

exec zensical serve "$@"
