#!/usr/bin/env bash
# Usage: source ./scripts/resolve-ports.sh
#
# Resolves a free host port for every service the stack publishes and
# exports the matching *_PORT env var, so `docker compose up` binds ports
# that are actually free instead of failing on "port is already allocated".
#
# Docker compose can't do this itself - a published port must be chosen
# before the container starts. This walks each port's compose default
# (or any value already set in the environment / .env) upward until it
# finds one that's free AND not already claimed by another service in
# this same stack, then exports it. Remaps are printed so the boot
# report stays accurate.
#
# Meant to be *sourced* (not executed) so the exports reach the caller's
# `docker compose up`. Safe to source repeatedly.

# Load .env first so user overrides are the starting point, not clobbered.
if [[ -f .env ]]; then
  set -o allexport
  # shellcheck disable=SC1091
  source ./.env
  set +o allexport
fi

# Host ports already published by THIS compose project's own containers
# (__OWN_PORTS - a re-run must keep these rather than treat them as "busy"
# and migrate every service to a new port) and by ALL running Docker
# containers on the box, any project (__ALL_DOCKER_PORTS - checked because
# Docker tracks these internally and they may not yet appear in ss/lsof if
# the docker-proxy is still starting, avoiding the race where a port looks
# free to ss but Docker rejects it). Own container IDs are always a subset
# of all-running IDs, so both are derived from a single `docker inspect`
# call over their union rather than inspecting twice. Uses inspect (not
# `docker ps --format '{{.Ports}}'`) so IPv6 bindings and port-range
# mappings are handled correctly without fragile string parsing.
__OWN_PORTS=" "
__ALL_DOCKER_PORTS=" "
if command -v docker >/dev/null 2>&1; then
  # `docker compose ps -q` returns full 64-char IDs; `docker ps -q` returns
  # short 12-char ones. Normalize both to short IDs so the membership check
  # below (against docker inspect's .ID, truncated the same way) actually
  # matches instead of silently finding nothing.
  __own_ids="$(docker compose ps -q 2>/dev/null | cut -c1-12 | tr '\n' ' ')"
  __all_ids="$(docker ps -q 2>/dev/null | tr '\n' ' ')"
  __union_ids="$(printf '%s\n%s\n' "$__own_ids" "$__all_ids" | tr ' ' '\n' | awk 'NF' | sort -u | tr '\n' ' ')"
  if [[ -n "${__union_ids// /}" ]]; then
    while read -r __id __ports; do
      [[ -z "$__id" ]] && continue
      __ALL_DOCKER_PORTS+="$__ports "
      [[ " $__own_ids " == *" ${__id:0:12} "* ]] && __OWN_PORTS+="$__ports "
    done < <(docker inspect \
      --format '{{.ID}} {{range $p, $conf := .NetworkSettings.Ports}}{{range $conf}}{{.HostPort}} {{end}}{{end}}' \
      $__union_ids 2>/dev/null)
  fi
fi

# Returns 0 if the port is free for us to bind, 1 if something else holds it.
# A port our own stack already publishes counts as free (we'll keep it).
__port_is_free() {
  local port="$1"
  [[ "$__OWN_PORTS" == *" $port "* ]] && return 0
  # Reject ports Docker has already allocated to other containers.
  [[ "$__ALL_DOCKER_PORTS" == *" $port "* ]] && return 1
  if command -v ss >/dev/null 2>&1; then
    ss -tln "( sport = :$port )" 2>/dev/null | grep -q "LISTEN" && return 1
  elif command -v lsof >/dev/null 2>&1; then
    lsof -i ":$port" -sTCP:LISTEN >/dev/null 2>&1 && return 1
  elif command -v nc >/dev/null 2>&1; then
    nc -z localhost "$port" 2>/dev/null && return 1
  fi
  return 0
}

# Ports this stack has already claimed during this resolve pass, so two
# services bumping off the same busy port don't collide with each other.
__CLAIMED_PORTS=" "

# resolve_port VARNAME DEFAULT
# Exports VARNAME to the first free port >= its current value (or DEFAULT),
# skipping ports already claimed by this stack. Prints a line if it remaps.
resolve_port() {
  local var="$1" default="$2"
  local start="${!var:-$default}"
  local port="$start"
  while [[ "$__CLAIMED_PORTS" == *" $port "* ]] || ! __port_is_free "$port"; do
    port=$((port + 1))
    if (( port > 65535 )); then
      echo "ERROR: no free host port found for $var (started at $start)." >&2
      exit 1
    fi
  done
  __CLAIMED_PORTS+="$port "
  export "$var=$port"
  if [[ "$port" != "$start" ]]; then
    printf "  %-22s %s → %s (%s was in use)\n" "$var" "$start" "$port" "$start"
  fi
}

# Every host-published port in docker-compose.yml, with its compose default.
echo "Resolving free host ports…"
resolve_port APP_PORT              80
resolve_port VITE_DEV_SERVER_PORT  5173
resolve_port DB_EXTERNAL_PORT      3306
resolve_port MAILHOG_WEB_PORT      8025
resolve_port MAILHOG_SMTP_PORT     1025
