#!/usr/bin/env bash
#
# pi-geo - view and edit /etc/geolocation, the GeoClue static location source,
# either interactively or from the command line.
#
# Return codes follow the WildlifeSystems convention, documented at
# https://docs.wildlife.systems/return-codes.html
# 0 - Success
# 1 - Error
# 3 - The geolocation file is missing or malformed
# 10 - invalid argument
#
# Env overrides (for testing): GEOLOC_FILE, GEOCLUE_CONF, GEOLOC_NO_ROOT=1

set -euo pipefail

PROG="${0##*/}"
GEOLOC_FILE="${GEOLOC_FILE:-/etc/geolocation}"
GEOCLUE_CONF="${GEOCLUE_CONF:-/etc/geoclue/geoclue.conf}"
SHOW_ONLY=0
JSON_ONLY=0

# Set by --lat/--lon/--alt/--acc; any one of them means "do not prompt".
LAT_ARG=""
LON_ARG=""
ALT_ARG=""
ACC_ARG=""
BATCH=0

# Built from $PROG so the help stays correct whatever the script is installed as.
usage() {
  cat <<EOF
Usage:
  sudo $PROG                    # interactive edit
  sudo $PROG --lat X --lon Y    # set values without prompting
  sudo $PROG --show             # print current values and exit
  sudo $PROG --json             # print current values as GeoJSON and exit
  sudo $PROG --file P           # operate on P instead of /etc/geolocation

Value options, given as "--opt V" or "--opt=V". Any of them switches off
prompting; values not given keep whatever is already in the file:
  --lat, --latitude   degrees, -90 to 90 (+ north / - south)
  --lon, --longitude  degrees, -180 to 180 (+ east / - west)
  --alt, --altitude   metres (default 0)
  --acc, --accuracy   accuracy radius in metres, 0 or more (default 1000)

Ranges match ws_read_geolocation() in libwildlifesystems, so a file this
accepts is one the WildlifeSystems sensor drivers can also read.

The file is four newline-separated floats: latitude, longitude, altitude (m),
accuracy radius (m). See geoclue(5), section [static-source].
EOF
}

die() { local code=$1; shift; printf 'Error: %s\n' "$*" >&2; exit "$code"; }

is_number() { [[ $1 =~ ^[+-]?([0-9]+(\.[0-9]*)?|\.[0-9]+)$ ]]; }

# Float comparison without bc.
in_range() { awk -v v="$1" -v lo="$2" -v hi="$3" 'BEGIN { exit !(v >= lo && v <= hi) }'; }
at_least() { awk -v v="$1" -v lo="$2" 'BEGIN { exit !(v >= lo) }'; }

# field_problem FIELD VALUE - prints why VALUE is unusable, or nothing if it is
# fine. Returns 1 when there is a problem.
#
# These are the ranges ws_read_geolocation() in libwildlifesystems enforces, so
# a file pi-geo accepts is one the sensor drivers can also use. Altitude is
# unbounded and accuracy only has to be non-negative: the library is the
# authority, because it is what silently discards a location the drivers cannot
# use, leaving "{{node}}" unresolved with no obvious cause.
field_problem() {
  local field=$1 v=$2
  if ! is_number "$v"; then printf 'not a number'; return 1; fi
  case $field in
    lat) in_range "$v" -90 90   || { printf 'out of range (-90 to 90)';    return 1; } ;;
    lon) in_range "$v" -180 180 || { printf 'out of range (-180 to 180)'; return 1; } ;;
    alt) : ;;
    acc) at_least "$v" 0        || { printf 'must not be negative';        return 1; } ;;
  esac
  return 0
}

# check_value OPTION FIELD VALUE - exits rather than returning, so a bad
# argument is rejected before sudo, prompting, or any writing happens.
check_value() {
  local opt=$1 field=$2 v=$3 why
  why=$(field_problem "$field" "$v") || die 10 "$opt: $why: $v"
}

while [[ $# -gt 0 ]]; do
  # Accept --opt=value as well as --opt value: split the argument first, then
  # let each case decide whether it still needs to consume the next one.
  opt=${1%%=*}
  if [[ $1 == *=* ]]; then val=${1#*=}; nshift=1; else val=""; nshift=2; fi
  case "$opt" in
    --file|--lat|--latitude|--lon|--longitude|--alt|--altitude|--acc|--accuracy)
      if (( nshift == 2 )); then
        [[ $# -ge 2 ]] || die 10 "$opt needs a value"
        val=$2
      fi ;;
  esac
  case "$opt" in
    --show) SHOW_ONLY=1; shift ;;
    --json) JSON_ONLY=1; shift ;;
    --file) GEOLOC_FILE="$val"; shift "$nshift" ;;
    --lat|--latitude)   LAT_ARG="$val"; BATCH=1; shift "$nshift" ;;
    --lon|--longitude)  LON_ARG="$val"; BATCH=1; shift "$nshift" ;;
    --alt|--altitude)   ALT_ARG="$val"; BATCH=1; shift "$nshift" ;;
    --acc|--accuracy)   ACC_ARG="$val"; BATCH=1; shift "$nshift" ;;
    -h|--help) usage; exit 0 ;;
    *) printf 'Unknown option: %s (try --help)\n' "$1" >&2; usage >&2; exit 10 ;;
  esac
done

(( SHOW_ONLY && BATCH )) && die 10 "--show cannot be combined with --lat/--lon/--alt/--acc"
(( JSON_ONLY && BATCH )) && die 10 "--json cannot be combined with --lat/--lon/--alt/--acc"
(( SHOW_ONLY && JSON_ONLY )) && die 10 "--show and --json are alternatives"

[[ -n $LAT_ARG ]] && check_value --lat lat "$LAT_ARG"
[[ -n $LON_ARG ]] && check_value --lon lon "$LON_ARG"
[[ -n $ALT_ARG ]] && check_value --alt alt "$ALT_ARG"
[[ -n $ACC_ARG ]] && check_value --acc acc "$ACC_ARG"

if [[ ${GEOLOC_NO_ROOT:-0} != 1 && $EUID -ne 0 ]]; then
  command -v sudo >/dev/null 2>&1 || die 1 "must be run as root to write $GEOLOC_FILE"
  # NB: ${SHOW_ONLY:+--show} would expand for the string "0" too - it tests for
  # emptiness, not truth. Build the argument list explicitly instead.
  reargs=(--file "$GEOLOC_FILE")
  if (( SHOW_ONLY )); then reargs+=(--show); fi
  if (( JSON_ONLY )); then reargs+=(--json); fi
  [[ -n $LAT_ARG ]] && reargs+=(--lat "$LAT_ARG")
  [[ -n $LON_ARG ]] && reargs+=(--lon "$LON_ARG")
  [[ -n $ALT_ARG ]] && reargs+=(--alt "$ALT_ARG")
  [[ -n $ACC_ARG ]] && reargs+=(--acc "$ACC_ARG")
  exec sudo -E "$0" "${reargs[@]}"
fi

# --- current values: strip comments and blanks, trim whitespace -------------
CUR=()
if [[ -r $GEOLOC_FILE ]]; then
  mapfile -t CUR < <(sed -e 's/#.*//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' \
                         -e '/^$/d' "$GEOLOC_FILE")
  (( ${#CUR[@]} == 0 || ${#CUR[@]} == 4 )) ||
    printf 'Warning: %s has %d value lines; GeoClue expects 4.\n' "$GEOLOC_FILE" "${#CUR[@]}" >&2
fi

# Only offer an existing value as a default if it is actually a number.
cur_or() {
  local i=$1 fallback=$2
  local v                    # separate statement: in `local a=$1 b=${X[$a]}`
  v=${CUR[$i]:-}             # bash localises both names before expanding the RHS
  if [[ -n $v ]] && is_number "$v"; then printf '%s' "$v"; else printf '%s' "$fallback"; fi
}

# The four values in file order, so both output modes agree on what is where.
FIELDS=(lat lon alt acc)
LABELS=('latitude ' 'longitude' 'altitude ' 'accuracy ')
UNITS=('' '' ' m' ' m')

if (( SHOW_ONLY )); then
  (( ${#CUR[@]} )) || die 3 "no location set ($GEOLOC_FILE is absent or empty)"
  # Show everything, flagging what is wrong rather than refusing: the operator
  # needs to know which value to fix. A value the drivers would reject is worth
  # naming here, because otherwise a plausible-looking --show sits alongside a
  # "{{node}}" that never resolves, with nothing connecting the two.
  bad=0
  for i in 0 1 2 3; do
    v=${CUR[$i]:-}
    if [[ -z $v ]]; then
      printf '%s  ?%s        not set\n' "${LABELS[$i]}" "${UNITS[$i]}"
      # Only latitude and longitude are required for a usable location.
      (( i < 2 )) && bad=1
      continue
    fi
    if why=$(field_problem "${FIELDS[$i]}" "$v"); then
      printf '%s  %s%s\n' "${LABELS[$i]}" "$v" "${UNITS[$i]}"
    else
      printf '%s  %s%s      %s\n' "${LABELS[$i]}" "$v" "${UNITS[$i]}" "$why"
      bad=1
    fi
  done
  (( bad )) && exit 3
  exit 0
fi

if (( JSON_ONLY )); then
  (( ${#CUR[@]} )) || die 3 "no location set ($GEOLOC_FILE is absent or empty)"
  # Unlike --show, this cannot annotate a bad value - there is no way to say
  # "out of range" in a coordinate - so it refuses to emit anything at all.
  for i in 0 1 2 3; do
    v=${CUR[$i]:-}
    [[ -z $v ]] && continue          # altitude and accuracy may be absent
    why=$(field_problem "${FIELDS[$i]}" "$v") ||
      die 3 "${FIELDS[$i]}: $why: $v (see $PROG --show)"
  done
  [[ -n ${CUR[0]:-} && -n ${CUR[1]:-} ]] ||
    die 3 "latitude and longitude are both required ($GEOLOC_FILE)"

  # Formatted to match ws_geolocation_geojson() exactly - same %.6f and %.2f -
  # so a node's reported position and its sensors' resolved "{{node}}" compare
  # byte for byte rather than approximately. GeoJSON order is [lon, lat, alt].
  coords=$(printf '[%.6f,%.6f' "${CUR[1]}" "${CUR[0]}")
  [[ -n ${CUR[2]:-} ]] && coords+=$(printf ',%.2f' "${CUR[2]}")
  coords+=']'
  props='{}'
  [[ -n ${CUR[3]:-} ]] && props=$(printf '{"accuracy":%.2f}' "${CUR[3]}")
  printf '{"type":"Feature","geometry":{"type":"Point","coordinates":%s},"properties":%s}\n' \
    "$coords" "$props"
  exit 0
fi

# --- write -------------------------------------------------------------------
# write_geoloc LAT LON ALT ACC
write_geoloc() {
  local lat=$1 lon=$2 alt=$3 acc=$4
  local backup tmp

  if [[ -e $GEOLOC_FILE ]]; then
    backup="$GEOLOC_FILE.bak.$(date +%Y%m%d%H%M%S)"
    cp -p -- "$GEOLOC_FILE" "$backup"
    echo "Backed up to $backup"
  fi

  # Temp file in the same directory so the final rename is atomic.
  tmp=$(mktemp "$GEOLOC_FILE.XXXXXX")
  trap 'rm -f -- "$tmp"' EXIT

  cat >"$tmp" <<EOF
# GeoClue static source - see geoclue(5), [static-source]
$lat	# latitude  (+N / -S)
$lon	# longitude (+E / -W)
$alt	# altitude, metres
$acc	# accuracy radius, metres
EOF

  chmod 600 "$tmp"
  getent passwd geoclue >/dev/null 2>&1 && chown geoclue "$tmp"
  mv -f -- "$tmp" "$GEOLOC_FILE"
  trap - EXIT
  echo "Wrote $GEOLOC_FILE"

  # The file is inert unless the static source is switched on.
  if [[ -r $GEOCLUE_CONF ]] && ! awk '
        /^[[:space:]]*\[/ { in_s = ($0 ~ /^[[:space:]]*\[static-source\]/) }
        in_s && /^[[:space:]]*enable[[:space:]]*=[[:space:]]*true/ { found = 1 }
        END { exit !found }' "$GEOCLUE_CONF"; then
    cat >&2 <<EOF

Note: GeoClue will ignore this file until $GEOCLUE_CONF contains:

    [static-source]
    enable=true

Then: systemctl restart geoclue
EOF
  fi
}

# --- non-interactive ---------------------------------------------------------
# Anything not given on the command line keeps the value already in the file,
# so "pi-geo --lon 5" moves the node east and leaves the rest alone.
if (( BATCH )); then
  LAT=${LAT_ARG:-$(cur_or 0 '')}
  LON=${LON_ARG:-$(cur_or 1 '')}
  ALT=${ALT_ARG:-$(cur_or 2 0)}
  ACC=${ACC_ARG:-$(cur_or 3 1000)}
  [[ -n $LAT ]] || die 10 "no latitude given and none usable in $GEOLOC_FILE (use --lat)"
  [[ -n $LON ]] || die 10 "no longitude given and none usable in $GEOLOC_FILE (use --lon)"

  printf '%s, %s   alt %s m   +/- %s m\n' "$LAT" "$LON" "$ALT" "$ACC"
  write_geoloc "$LAT" "$LON" "$ALT" "$ACC"
  exit 0
fi

# --- prompt ------------------------------------------------------------------
[[ -t 0 ]] || die 1 "not a terminal; use --lat/--lon to set values, or --show to read them"

# ask LABEL UNIT DEFAULT MIN MAX -> echoes accepted value (errors go to stderr)
# ask LABEL UNIT DEFAULT FIELD - echoes accepted value (errors go to stderr).
# Validates through field_problem, so the prompt accepts exactly what the file
# is allowed to contain and what the sensor drivers can read.
ask() {
  local label=$1 unit=$2 default=$3 field=$4 ans prompt why
  while :; do
    prompt="$label ($unit)"
    [[ -n $default ]] && prompt+=" [$default]"
    read -r -p "$prompt: " ans || die 1 "input closed"
    ans=${ans//[[:space:]]/}
    [[ -z $ans && -n $default ]] && ans=$default
    if ! why=$(field_problem "$field" "$ans"); then
      echo "  $why" >&2; continue
    fi
    printf '%s' "$ans"; return 0
  done
}

echo "Editing $GEOLOC_FILE"
(( ${#CUR[@]} >= 4 )) && echo "Press Enter to keep the value in brackets."
echo

LAT=$(ask 'Latitude ' 'deg, + north / - south' "$(cur_or 0 '')"   lat)
LON=$(ask 'Longitude' 'deg, + east / - west'   "$(cur_or 1 '')"   lon)
ALT=$(ask 'Altitude ' 'metres'                 "$(cur_or 2 0)"    alt)
# Accuracy is a confidence radius, so "unknown" just means "use a big number":
# ~100 m a house, ~5000 m a village, ~25000 m a city, ~100000 m a region.
ACC=$(ask 'Accuracy ' 'metres radius, larger if unsure' "$(cur_or 3 1000)" acc)

printf '\n%s, %s   alt %s m   +/- %s m\n' "$LAT" "$LON" "$ALT" "$ACC"
read -r -p "Write to $GEOLOC_FILE? [y/N] " confirm
[[ ${confirm,,} == y* ]] || { echo "Aborted; nothing changed."; exit 1; }

write_geoloc "$LAT" "$LON" "$ALT" "$ACC"
