#!/bin/bash
set -u

NETWORK_STATE_FILE="/var/signalytic/etc/_network"

# Per-interface last-known gateway cache. This script removes the default route
# from interfaces it excludes, which would otherwise leave us unable to recover
# their gateway on a later run (DHCP only re-adds it on lease renewal). We cache
# the gateway whenever a live default route is observed and fall back to it when
# the route is absent. tmpfs, so it clears on reboot (DHCP repopulates).
GW_CACHE_DIR="/run/signalytic/uplink-gw"

# Probe budget: worst case per iface = number of targets × PING_TIMEOUT
# With defaults: 2 × 3s = 6s per iface worst case.
# Keep the timer cadence at 60s or higher unless these values are revisited.
PING_TARGETS=(8.8.4.4 1.0.0.1)  # note: 8.8.8.8 and 1.1.1.1 used for DNS
PING_COUNT=3
PING_TIMEOUT=3

PROBE_ROUTE_IFACE=""
PROBE_ROUTE_GW=""
PROBE_ROUTE_DST=""
LAST_RESULT_REASON=""
LAST_PROBE_LOSS=""
LAST_PROBE_RTT=""

log() {
  echo "$*" >&2
}

cleanup_probe_route() {
  if [ -n "${PROBE_ROUTE_DST}" ] && [ -n "${PROBE_ROUTE_IFACE}" ] && [ -n "${PROBE_ROUTE_GW}" ]; then
    ip route del "${PROBE_ROUTE_DST}/32" via "${PROBE_ROUTE_GW}" dev "${PROBE_ROUTE_IFACE}" >/dev/null 2>&1 || true
  fi
  PROBE_ROUTE_DST=""
  PROBE_ROUTE_IFACE=""
  PROBE_ROUTE_GW=""
}

trap cleanup_probe_route EXIT INT TERM

contains_item() {
  local needle="$1"
  shift
  local item
  for item in "$@"; do
    [ "${item}" = "${needle}" ] && return 0
  done
  return 1
}

iface_exists() {
  ip link show dev "$1" >/dev/null 2>&1
}

iface_ready_ipv4() {
  local iface="$1"
  iface_exists "${iface}" || return 1
  ip -4 addr show dev "${iface}" 2>/dev/null | grep -q 'inet ' || return 1
}

configured_uplink_ifaces_from_network() {
  local cfg="$1"
  local idx iface
  local -a ordered=()

  [ -f "${cfg}" ] || return 0

  while IFS=$'\t' read -r idx iface; do
    [ -n "${iface}" ] || continue
    if ! contains_item "${iface}" "${ordered[@]-}"; then
      ordered+=("${iface}")
    fi
  done < <(
    awk -F'"' '
      /^wan\.interfaces\.\[[0-9]+="/ {
        key = $1
        sub(/^wan\.interfaces\.\[/, "", key)
        sub(/=.*/, "", key)
        print key "\t" $2
      }
    ' "${cfg}" | sort -n -k1,1
  )

  if [ "${#ordered[@]}" -gt 0 ]; then
    printf '%s\n' "${ordered[@]}"
  fi
}

ordered_uplink_ifaces() {
  local iface
  local -a ordered=()

  # extract wan interfaces from network state (pre-ordered)
  if [ -f "${NETWORK_STATE_FILE}" ]; then
    while IFS= read -r iface; do
      [ -n "${iface}" ] || continue
      ordered+=("${iface}")
    done < <(configured_uplink_ifaces_from_network "${NETWORK_STATE_FILE}")
  fi

  # make sure wwan0 is always present (lowest priority if not explicitly set)
  if ! contains_item "wwan0" "${ordered[@]-}"; then
    ordered+=("wwan0")
  fi

  printf '%s\n' "${ordered[@]}"
}

default_gateway() {
  local iface="$1"
  local gw cache="${GW_CACHE_DIR}/${iface}"

  # read the live gateway from the interface's default route
  gw=$(ip route show default dev "${iface}" 2>/dev/null | \
    awk '/^default/ {for (i = 1; i <= NF; i++) if ($i == "via") {print $(i + 1); exit}}')

  if [ -n "${gw}" ]; then
    # cache it so the gateway survives this script removing the default route
    # from an interface it later excludes (see reset loop in main())
    if mkdir -p "${GW_CACHE_DIR}" 2>/dev/null; then
      printf '%s\n' "${gw}" > "${cache}.tmp" 2>/dev/null \
        && mv -f "${cache}.tmp" "${cache}" 2>/dev/null || true
    fi
    printf '%s\n' "${gw}"
    return 0
  fi

  # no live default route (e.g. interface was excluded on a previous run and
  # DHCP has not renewed yet) — fall back to the last known gateway. The probe
  # validates it, so a stale value simply keeps the interface excluded.
  [ -f "${cache}" ] && cat "${cache}"
}

probe_target_via_gw() {
  local iface="$1"
  local gw="$2"
  local target="$3"
  local output rc

  # setup a temporary route - cleaned up automatically on exit
  cleanup_probe_route
  PROBE_ROUTE_IFACE="${iface}"
  PROBE_ROUTE_GW="${gw}"
  PROBE_ROUTE_DST="${target}"

  ip route replace "${target}/32" via "${gw}" dev "${iface}" >/dev/null 2>&1 || {
    cleanup_probe_route
    return 1
  }

  # ping the target server
  output=$(ping -c "${PING_COUNT}" -w "${PING_TIMEOUT}" -I "${iface}" "${target}" 2>/dev/null)
  rc=$?

  cleanup_probe_route

  # extract packet loss and latency metrics, but for now these are just logged
  # TODO: consider additional guardrails
  LAST_PROBE_LOSS=$(echo "${output}" | grep -oP '\d+(?=% packet loss)')
  LAST_PROBE_RTT=$(echo "${output}" | grep -oP 'rtt min/avg/max/mdev = [0-9.]+/\K[0-9.]+')

  return "${rc}"
}

probe_iface() {
  local iface="$1"
  local gw="$2"
  local target

  LAST_PROBE_LOSS=""
  LAST_PROBE_RTT=""

  # check valid IPv4 address
  if ! iface_ready_ipv4 "${iface}"; then
    LAST_RESULT_REASON="missing-ipv4"
    return 1
  fi

  # check valid gateway address
  if [ -z "${gw}" ]; then
    LAST_RESULT_REASON="missing-gateway"
    return 1
  fi

  # confirm connectivity to external target
  for target in "${PING_TARGETS[@]}"; do
    if probe_target_via_gw "${iface}" "${gw}" "${target}"; then
      LAST_RESULT_REASON="probe-succeeded"
      return 0
    fi
  done

  LAST_RESULT_REASON="upstream-unreachable"
  return 1
}

log_result() {
  local iface="$1"
  local result="$2"
  local reason="$3"
  local decision="$4"
  local extra="${5:-}"

  if [ -n "${extra}" ]; then
    log "iface=${iface} result=${result} reason=${reason} decision=${decision} ${extra}"
  else
    log "iface=${iface} result=${result} reason=${reason} decision=${decision}"
  fi
}

up_to_date() {
  local i metric=100 iface gw actual

  # check that all healthy interfaces have expected default routes
  for i in "${!healthy_ifaces[@]}"; do
    iface="${healthy_ifaces[$i]}"
    gw="${healthy_gws[$i]}"
    actual=$(ip route show default dev "${iface}" 2>/dev/null)
    [ "$(printf '%s\n' "${actual}" | grep -c .)" -eq 1 ] || return 1
    printf '%s\n' "${actual}" | grep -q "via ${gw}.*metric ${metric}" || return 1
    metric=$((metric + 100))
  done

  # check for any unwanted default routes
  for iface in "${managed_ifaces[@]}"; do
    contains_item "${iface}" "${healthy_ifaces[@]-}" && continue
    ip route show default dev "${iface}" 2>/dev/null | grep -q . && return 1
  done

  return 0
}

main() {
  local iface gw metric rc extra
  local -a managed_ifaces=()
  local -a healthy_ifaces=()
  local -a healthy_gws=()

  if [ "$(id -u)" -ne 0 ]; then
    echo "Root permission required" >&2
    exit 1
  fi

  # get ordered list of managed interfaces from the network state file
  while IFS= read -r iface; do
    [ -n "${iface}" ] || continue

    if iface_exists "${iface}"; then
      managed_ifaces+=("${iface}")
    else
      log_result "${iface}" "missing" "interface-not-present" "skip"
    fi
  done < <(ordered_uplink_ifaces)

  if [ "${#managed_ifaces[@]}" -eq 0 ]; then
    log "result=info reason=no-candidate-uplinks decision=exit"
    exit 0
  fi

  # check health status of each managed interface
  for iface in "${managed_ifaces[@]}"; do
    gw=$(default_gateway "${iface}")

    probe_iface "${iface}" "${gw}"
    rc=$?

    case "${rc}" in
      0)
        healthy_ifaces+=("${iface}")
        healthy_gws+=("${gw}")
        log_result "${iface}" "healthy" "${LAST_RESULT_REASON}" "include" \
          "gw=${gw} loss=${LAST_PROBE_LOSS}% rtt=${LAST_PROBE_RTT}ms"
        ;;
      *)
        extra="gw=${gw:-none}"
        [ -n "${LAST_PROBE_LOSS}" ] && extra+=" loss=${LAST_PROBE_LOSS}%"
        [ -n "${LAST_PROBE_RTT}" ] && extra+=" rtt=${LAST_PROBE_RTT}ms"
        log_result "${iface}" "unhealthy" "${LAST_RESULT_REASON}" "exclude" "${extra}"
        ;;
    esac
  done

  if [ "${#healthy_ifaces[@]}" -eq 0 ]; then
    log "result=info reason=no-healthy-uplinks decision=leave-existing-default-routes"
    exit 0
  fi

  # check if routes need to be updated
  if up_to_date; then
    log "result=info reason=up-to-date decision=skip"
    exit 0
  fi

  # reset default routes
  for iface in "${managed_ifaces[@]}"; do
    while ip route del default dev "${iface}" 2>/dev/null; do :; done
  done

  metric=100
  for i in "${!healthy_ifaces[@]}"; do
    if ip route replace default via "${healthy_gws[$i]}" dev "${healthy_ifaces[$i]}" metric "${metric}"; then
      log_result "${healthy_ifaces[$i]}" "applied" "healthy-set" "set-metric" "metric=${metric} gw=${healthy_gws[$i]}"
    else
      log_result "${healthy_ifaces[$i]}" "error" "route-replace-failed" "skip" "metric=${metric} gw=${healthy_gws[$i]}"
    fi
    metric=$((metric + 100))
  done

  log "result=info reason=post-reconciliation decision=show-default-routes"
  ip route show default
}

main "$@"