#!/bin/zsh

# Detect a captive portal on macOS and surface its sign-in page.
#
# This script deliberately does not click or submit the portal's acceptance
# form. Portal forms vary, and automatically bypassing an operator's session
# policy may violate its terms.

set -u
set -o pipefail

readonly SCRIPT_NAME="${0:t}"

CHECK_INTERVAL="${CHECK_INTERVAL:-20}"
FAIL_THRESHOLD="${FAIL_THRESHOLD:-2}"
RECOVERY_THRESHOLD="${RECOVERY_THRESHOLD:-2}"
PORTAL_COOLDOWN="${PORTAL_COOLDOWN:-300}"
CURL_TIMEOUT="${CURL_TIMEOUT:-7}"
LOG_FILE="${LOG_FILE:-$HOME/Library/Logs/captive-portal-watch.log}"

OPEN_PORTAL=1
RUN_ONCE=0
VERBOSE=0

usage() {
  cat <<EOF
Usage: $SCRIPT_NAME [options]

Detect when macOS is still connected to Wi-Fi but internet access has been
intercepted by a captive portal. On detection, open a plain-HTTP page that
should redirect to the portal.

Options:
  --once              Check once, print the result, and exit
  --no-open           Detect and notify without opening a browser
  --verbose           Log individual connectivity probes
  --interval SECONDS  Seconds between checks (default: $CHECK_INTERVAL)
  --help              Show this help

Environment variables:
  CHECK_INTERVAL      Seconds between checks
  FAIL_THRESHOLD      Consecutive failed rounds before alerting
  RECOVERY_THRESHOLD  Consecutive good rounds before declaring recovery
  PORTAL_COOLDOWN     Seconds before reopening a persistent portal
  CURL_TIMEOUT        Per-request timeout in seconds
  LOG_FILE            Log destination

Stop the watcher with Control-C.
EOF
}

is_positive_integer() {
  [[ "$1" == <-> && "$1" -gt 0 ]]
}

while (( $# > 0 )); do
  case "$1" in
    --once)
      RUN_ONCE=1
      ;;
    --no-open)
      OPEN_PORTAL=0
      ;;
    --verbose)
      VERBOSE=1
      ;;
    --interval)
      shift
      if (( $# == 0 )) || ! is_positive_integer "$1"; then
        print -u2 "Error: --interval requires a positive integer."
        exit 2
      fi
      CHECK_INTERVAL="$1"
      ;;
    --help|-h)
      usage
      exit 0
      ;;
    *)
      print -u2 "Error: unknown option: $1"
      usage >&2
      exit 2
      ;;
  esac
  shift
done

for setting_name in CHECK_INTERVAL FAIL_THRESHOLD RECOVERY_THRESHOLD PORTAL_COOLDOWN CURL_TIMEOUT; do
  setting_value="${(P)setting_name}"
  if ! is_positive_integer "$setting_value"; then
    print -u2 "Error: $setting_name must be a positive integer."
    exit 2
  fi
done

log_dir="${LOG_FILE:h}"
if ! mkdir -p "$log_dir" 2>/dev/null; then
  LOG_FILE="/tmp/captive-portal-watch-${UID}.log"
  print -u2 "Warning: using fallback log file $LOG_FILE"
fi

log() {
  local level="$1"
  shift
  local line
  line="$(date '+%Y-%m-%d %H:%M:%S') [$level] $*"
  # Logs go to stderr so command substitutions can safely return state values
  # on stdout without verbose diagnostics corrupting them.
  print -u2 -r -- "$line"
  print -r -- "$line" >> "$LOG_FILE" 2>/dev/null || true
}

notify() {
  local title="$1"
  local message="$2"

  /usr/bin/osascript \
    -e 'on run argv' \
    -e 'display notification (item 2 of argv) with title (item 1 of argv)' \
    -e 'end run' \
    "$title" "$message" >/dev/null 2>&1 || true
}

find_wifi_device() {
  /usr/sbin/networksetup -listallhardwareports 2>/dev/null |
    /usr/bin/awk '
      /^Hardware Port: (Wi-Fi|AirPort)$/ {
        getline
        if ($1 == "Device:") {
          print $2
          exit
        }
      }
    '
}

wifi_link_state() {
  local device="$1"

  if [[ -z "$device" ]]; then
    print "unknown"
    return
  fi

  if ! /sbin/ifconfig "$device" 2>/dev/null | /usr/bin/grep -q "status: active"; then
    print "disconnected"
    return
  fi

  if ! /usr/sbin/ipconfig getifaddr "$device" >/dev/null 2>&1; then
    print "disconnected"
    return
  fi

  print "connected"
}

# Each probe is URL|expected status|optional exact response body.
# Requiring two successful, independent probes reduces false alarms when one
# connectivity-check service is temporarily blocked or unavailable.
readonly -a PROBES=(
  "http://connectivitycheck.gstatic.com/generate_204|204|"
  "http://www.msftconnecttest.com/connecttest.txt|200|Microsoft Connect Test"
  "http://captive.apple.com/hotspot-detect.html|200|<HTML><HEAD><TITLE>Success</TITLE></HEAD><BODY>Success</BODY></HTML>"
)

probe_endpoint() {
  local spec="$1"
  local url expected_status expected_body actual_status body_file

  url="${spec%%|*}"
  spec="${spec#*|}"
  expected_status="${spec%%|*}"
  expected_body="${spec#*|}"
  body_file="$(mktemp -t captive-portal-watch.XXXXXX)" || return 1

  actual_status=$(
    /usr/bin/curl \
      --silent \
      --output "$body_file" \
      --write-out '%{http_code}' \
      --connect-timeout 3 \
      --max-time "$CURL_TIMEOUT" \
      --max-redirs 0 \
      --user-agent "CaptivePortalWatch/1.0 macOS" \
      "$url" 2>/dev/null
  )
  local curl_exit=$?

  if (( VERBOSE )); then
    log "DEBUG" "Probe ${url}: curl_exit=${curl_exit}, HTTP=${actual_status:-000}"
  fi

  if (( curl_exit != 0 )) || [[ "$actual_status" != "$expected_status" ]]; then
    rm -f "$body_file"
    return 1
  fi

  if [[ -n "$expected_body" ]]; then
    local actual_body
    actual_body="$(<"$body_file")"
    rm -f "$body_file"
    [[ "$actual_body" == "$expected_body" ]]
    return
  fi

  rm -f "$body_file"
  return 0
}

connectivity_state() {
  local successes=0
  local probe

  for probe in "${PROBES[@]}"; do
    if probe_endpoint "$probe"; then
      (( successes += 1 ))
    fi
  done

  if (( successes >= 2 )); then
    print "online"
  elif (( successes == 1 )); then
    print "uncertain"
  else
    print "intercepted"
  fi
}

open_portal() {
  # A plain HTTP URL is intentional: captive portals commonly intercept it and
  # redirect the browser to their local sign-in page.
  /usr/bin/open "http://captive.apple.com/hotspot-detect.html" >/dev/null 2>&1 ||
    /usr/bin/open "http://neverssl.com/" >/dev/null 2>&1 ||
    log "WARN" "Could not open a portal-discovery page."
}

wifi_device="$(find_wifi_device)"
if [[ -n "$wifi_device" ]]; then
  log "INFO" "Watching Wi-Fi device $wifi_device every ${CHECK_INTERVAL}s."
else
  log "WARN" "Could not identify a Wi-Fi device; connectivity checks will continue."
fi

failure_count=0
recovery_count=0
portal_active=0
last_portal_open=0
last_reported_state=""

cleanup() {
  log "INFO" "Watcher stopped."
  exit 0
}

trap cleanup INT TERM HUP

while true; do
  link_state="$(wifi_link_state "$wifi_device")"

  if [[ "$link_state" == "disconnected" ]]; then
    current_state="wifi-disconnected"
  else
    current_state="$(connectivity_state)"
  fi

  if (( RUN_ONCE )); then
    log "RESULT" "$current_state"
    case "$current_state" in
      online) exit 0 ;;
      uncertain) exit 3 ;;
      intercepted) exit 4 ;;
      wifi-disconnected) exit 5 ;;
      *) exit 6 ;;
    esac
  fi

  case "$current_state" in
    online)
      failure_count=0
      (( recovery_count += 1 ))

      if (( portal_active && recovery_count >= RECOVERY_THRESHOLD )); then
        portal_active=0
        log "INFO" "Internet access restored."
        notify "Wi-Fi restored" "Internet access is working again."
      elif [[ "$last_reported_state" != "online" && $portal_active -eq 0 ]]; then
        log "INFO" "Internet access is online."
      fi
      ;;

    uncertain)
      # A single successful probe is inconclusive. Do not increase either
      # counter, preventing a flaky endpoint from rapidly changing state.
      recovery_count=0
      if [[ "$last_reported_state" != "uncertain" ]]; then
        log "WARN" "Connectivity is uncertain; waiting for another check."
      fi
      ;;

    intercepted)
      recovery_count=0
      (( failure_count += 1 ))

      if (( failure_count >= FAIL_THRESHOLD )); then
        now="$(date +%s)"
        cooldown_elapsed=$(( now - last_portal_open ))

        if (( ! portal_active )); then
          portal_active=1
          log "WARN" "Captive portal or internet interception detected."
          notify "Captive portal detected" "The Wi-Fi sign-in page needs attention."
        fi

        if (( OPEN_PORTAL && cooldown_elapsed >= PORTAL_COOLDOWN )); then
          log "INFO" "Opening the captive-portal sign-in page."
          open_portal
          last_portal_open="$now"
        fi
      fi
      ;;

    wifi-disconnected)
      failure_count=0
      recovery_count=0
      portal_active=0
      if [[ "$last_reported_state" != "wifi-disconnected" ]]; then
        log "WARN" "Wi-Fi is disconnected; waiting for it to reconnect."
        notify "Wi-Fi disconnected" "Reconnect to Wi-Fi to resume portal monitoring."
      fi
      ;;
  esac

  last_reported_state="$current_state"
  sleep "$CHECK_INTERVAL"
done
