#!/usr/bin/env bash
set -euo pipefail

usage() {
  cat <<'EOF'
Usage: Scripts/burrow-doctor [--last 10m]

Print a single stdout diagnostics report for the CURRENT macOS networking state.

Run this while Burrow is the active VPN but routing/websites are broken, then
redirect stdout to a file before switching to a working VPN:

  Scripts/burrow-doctor --last 10m > burrow-broken.txt

The report focuses on live VPN/proxy/DNS/route state, Burrow NetworkExtension
state, socket ownership, the selected Burrow node, and recent Burrow logs.
It intentionally does not redact local output, because broken proxy state often
depends on exact subscription and node configuration.
EOF
}

last="10m"
while [[ $# -gt 0 ]]; do
  case "$1" in
    --last)
      [[ $# -ge 2 ]] || { echo "--last requires a value" >&2; exit 2; }
      last="$2"
      shift 2
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    *)
      echo "unknown argument: $1" >&2
      usage >&2
      exit 2
      ;;
  esac
done

repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
group_id="${BURROW_APP_GROUP_ID:-2KPBQHRJ2D.com.tianruichen.burrow}"
group_dir="${BURROW_APP_GROUP_DIR:-$HOME/Library/Group Containers/$group_id}"
db_path="${BURROW_DB_PATH:-$group_dir/burrow.db}"
runtime_log="${BURROW_RUNTIME_LOG:-$group_dir/burrow-runtime.log}"
bundle_id="${BURROW_BUNDLE_ID:-com.tianruichen.burrow}"
extension_process="${BURROW_EXTENSION_PROCESS:-BurrowNetworkExtension}"

section() {
  printf '\n## %s\n\n' "$1"
}

cmd() {
  printf '$'
  printf ' %q' "$@"
  printf '\n'
}

run() {
  local title="$1"
  shift
  section "$title"
  cmd "$@"
  printf '\n'
  "$@" 2>&1 || printf '\n[burrow-doctor] command exited with status %s\n' "$?"
}

run_zsh() {
  local title="$1"
  local script="$2"
  section "$title"
  printf '$ %s\n\n' "$script"
  /bin/zsh -lc "$script" 2>&1 || printf '\n[burrow-doctor] command exited with status %s\n' "$?"
}

run_python() {
  if command -v uv >/dev/null 2>&1; then
    uv run python "$@"
  elif command -v python3 >/dev/null 2>&1; then
    python3 "$@"
  else
    return 127
  fi
}

capture() {
  "$@" 2>&1 || true
}

nc_list="$(capture scutil --nc list)"
proxy_state="$(capture scutil --proxy)"
dns_state="$(capture scutil --dns)"
inet_routes="$(capture netstat -rn -f inet)"
proxy_listeners="$(capture /bin/zsh -lc "lsof -nP -iTCP:6152 -iTCP:6153 -iTCP:7890 -iTCP:7891 -iTCP:7897 -iTCP:1080 -iTCP:1087 -sTCP:LISTEN")"

cat <<EOF
# Burrow Doctor

Generated: $(date)
Repo: $repo_root
Log window: $last
App group: $group_id
DB path: $db_path
Runtime log: $runtime_log

Purpose: snapshot CURRENT networking state while Burrow is active but not
routing requests, so this report can be inspected later after switching to a
working VPN.
EOF

section "Quick Findings"
if printf '%s\n' "$nc_list" | grep -Eiq '\(Connected\).+Burrow'; then
  echo "- Burrow is currently connected according to scutil."
else
  echo "- Burrow is NOT currently connected according to scutil. If this was not captured during the broken active-Burrow window, rerun it while Burrow is active."
fi

other_connected="$(printf '%s\n' "$nc_list" | grep -Ei '\(Connected\)' | grep -Eiv 'Burrow|MT65xx' || true)"
if [[ -n "$other_connected" ]]; then
  echo "- Another VPN-like service is connected and may supersede or interfere with Burrow:"
  printf '%s\n' "$other_connected" | sed 's/^/  /'
fi

if printf '%s\n' "$proxy_state" | grep -Eq 'HTTPProxy : 127\.0\.0\.1|SOCKSProxy : 127\.0\.0\.1|HTTPSProxy : 127\.0\.0\.1'; then
  echo "- System proxy settings point to localhost. See Local Proxy Listeners for the owning process."
fi

if printf '%s\n%s\n' "$dns_state" "$inet_routes" | grep -Eq '198\.18\.|198\.19\.'; then
  echo "- Current DNS/routes include 198.18.0.0/15 fake-IP style addresses."
fi

if printf '%s\n' "$inet_routes" | grep -Eq '^default[[:space:]].*utun'; then
  echo "- IPv4 default route currently points at a utun interface."
fi

if [[ -n "$proxy_listeners" ]]; then
  echo "- Common localhost proxy ports are listening:"
  printf '%s\n' "$proxy_listeners" | sed 's/^/  /'
fi

if [[ -f "$runtime_log" ]] && tail -400 "$runtime_log" | grep -q 'panicked'; then
  echo "- Burrow runtime log contains recent task panics. See Burrow Runtime Log File."
fi

if [[ -f "$runtime_log" ]] && tail -400 "$runtime_log" | grep -q 'CryptoProvider'; then
  echo "- Burrow runtime log mentions rustls CryptoProvider initialization failures."
fi

if printf '%s\n' "$dns_state" | grep -A8 '^resolver #1' | grep -Eq 'if_index[[:space:]]*:.*utun' \
  && [[ -f "$runtime_log" ]] \
  && tail -400 "$runtime_log" | grep -q 'proxy udp session failed'; then
  echo "- Primary DNS is on the tunnel while Burrow proxy UDP sessions are failing; name resolution may be blocked before HTTP/TCP requests can start."
fi

if ! printf '%s\n' "$dns_state" | awk '
  /^DNS configuration$/ { global = 1; next }
  /^DNS configuration \(for scoped queries\)/ { global = 0; next }
  global && /nameserver\[[0-9]+\]/ { found = 1 }
  END { exit found ? 0 : 1 }
'; then
  echo "- No global DNS nameserver is configured. If only scoped physical DNS remains, ordinary hostname lookups may fail under the full-tunnel route."
fi

section "Selected Burrow Network"
if [[ -f "$db_path" ]]; then
  run_python - "$db_path" <<'PY'
from __future__ import annotations

import json
import sqlite3
import sys

db_path = sys.argv[1]


conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
rows = list(conn.execute("select id,type,idx,payload,interface_id from network order by idx"))
if not rows:
    print("No rows in network table.")
    raise SystemExit

summaries = []
for row in rows:
    payload_blob = row["payload"]
    payload_text = payload_blob.decode("utf-8", errors="replace") if isinstance(payload_blob, bytes) else payload_blob
    try:
        payload = json.loads(payload_text) if payload_text else {}
    except Exception as exc:
        summaries.append({
            "id": row["id"],
            "type": row["type"],
            "idx": row["idx"],
            "payload_parse_error": str(exc),
        })
        continue

    summary = {
        "id": row["id"],
        "type": row["type"],
        "idx": row["idx"],
        "interface_id": row["interface_id"],
        "name": payload.get("name"),
        "selected_ordinal": payload.get("selected_ordinal"),
        "selected_name": payload.get("selected_name"),
        "source": payload.get("source"),
        "node_count": len(payload.get("nodes") or []),
    }
    selected_node = None
    for node in payload.get("nodes") or []:
        if node.get("name") == payload.get("selected_name") or node.get("ordinal") == payload.get("selected_ordinal"):
            selected_node = node
            break
    summary["selected_node"] = selected_node
    summaries.append(summary)

print(json.dumps(summaries, ensure_ascii=False, indent=2))
PY
else
  echo "DB not found: $db_path"
fi

run "VPN Configuration List" scutil --nc list
run "System Proxy State" scutil --proxy
run_zsh "Local Proxy Listeners" "lsof -nP -iTCP:6152 -iTCP:6153 -iTCP:7890 -iTCP:7891 -iTCP:7897 -iTCP:1080 -iTCP:1087 -sTCP:LISTEN || true"
run "DNS State" scutil --dns
run "IPv4 Routes" netstat -rn -f inet
run "IPv6 Routes" netstat -rn -f inet6
run "Interfaces" ifconfig
run_zsh "Burrow And VPN Processes" "ps aux | egrep -i 'Burrow|BurrowNetworkExtension|nesessionmanager|neagent|Surge|Tailscale|Clash|mihomo|sing-box' | egrep -v 'egrep|burrow-doctor'"
run_zsh "Burrow Socket Owners" "lsof -U | egrep 'burrow.*sock|Burrow|BurrowNetworkExtension|NetworkExtension' || true"
run "App Group Directory" ls -la "$group_dir"

section "Selected Proxy Server Connectivity"
if [[ -f "$db_path" ]]; then
  run_python - "$db_path" <<'PY'
from __future__ import annotations

import json
import socket
import sqlite3
import subprocess
import sys
import time

db_path = sys.argv[1]


def choose_node(payload: dict) -> dict | None:
    nodes = payload.get("nodes") or []
    selected_name = payload.get("selected_name")
    selected_ordinal = payload.get("selected_ordinal")
    for node in nodes:
        if selected_name is not None and node.get("name") == selected_name:
            return node
        if selected_ordinal is not None and node.get("ordinal") == selected_ordinal:
            return node
    return nodes[0] if nodes else None


conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
    "select payload from network where type = 'ProxySubscription' order by idx limit 1"
).fetchone()
if row is None:
    print("No ProxySubscription network in database.")
    raise SystemExit

payload_blob = row["payload"]
payload_text = payload_blob.decode("utf-8", errors="replace") if isinstance(payload_blob, bytes) else payload_blob
payload = json.loads(payload_text)
node = choose_node(payload)
if not node:
    print("ProxySubscription has no nodes.")
    raise SystemExit

server = node.get("server")
port = int(node.get("port"))
print(f"selected_proxy_server={server}:{port}")

addresses = []
try:
    for family, socktype, proto, canonname, sockaddr in socket.getaddrinfo(server, port, type=socket.SOCK_STREAM):
        host, resolved_port = sockaddr[:2]
        key = (host, resolved_port)
        if key not in addresses:
            addresses.append(key)
except Exception as exc:
    print(f"resolve_error={type(exc).__name__}: {exc}")
    raise SystemExit

print("resolved_addresses=" + ",".join(f"{host}:{resolved_port}" for host, resolved_port in addresses))

for host, _ in addresses:
    print(f"\n$ route -n get {host}")
    try:
        result = subprocess.run(
            ["route", "-n", "get", host],
            text=True,
            stdout=subprocess.PIPE,
            stderr=subprocess.STDOUT,
            timeout=5,
        )
        print(result.stdout.rstrip())
        print(f"[exit={result.returncode}]")
    except Exception as exc:
        print(f"route_probe_error={type(exc).__name__}: {exc}")

for host, resolved_port in addresses:
    started = time.monotonic()
    try:
        with socket.create_connection((host, resolved_port), timeout=5):
            elapsed_ms = (time.monotonic() - started) * 1000
            print(f"tcp_connect_ok address={host}:{resolved_port} elapsed_ms={elapsed_ms:.1f}")
    except Exception as exc:
        elapsed_ms = (time.monotonic() - started) * 1000
        print(f"tcp_connect_error address={host}:{resolved_port} elapsed_ms={elapsed_ms:.1f} error={type(exc).__name__}: {exc}")
PY
else
  echo "DB not found: $db_path"
fi

run_zsh "Route Probes" "for host in 1.1.1.1 8.8.8.8 google.com; do echo; echo \"\$ route -n get \$host\"; route -n get \"\$host\" 2>&1 || true; done"

run_zsh "DNS Probes" "if command -v dig >/dev/null 2>&1; then dig +time=3 +tries=1 google.com A; echo; dig +time=3 +tries=1 @1.1.1.1 google.com A; else dscacheutil -q host -a name google.com; fi"

run_zsh "Scoped Physical DNS Probes" "if command -v dig >/dev/null 2>&1; then servers=\$(scutil --dns | awk 'BEGIN { scoped=0; current=\"\" } /^DNS configuration \\(for scoped queries\\)/ { scoped=1; next } scoped && /^resolver #/ { current=\"\"; next } scoped && /nameserver\\[[0-9]+\\]/ { current=\$3 } scoped && /if_index/ && \$0 !~ /utun/ && current != \"\" { print current; current=\"\" }'); if [[ -z \"\$servers\" ]]; then echo 'No scoped non-utun DNS servers found.'; else for server in \$servers; do echo; echo \"\$ dig +time=3 +tries=1 @\$server google.com A\"; dig +time=3 +tries=1 @\"\$server\" google.com A; done; fi; else echo 'dig not found'; fi"

run_zsh "TCP Probes" "for target in '1.1.1.1 53' '1.1.1.1 443' 'google.com 443'; do set -- \$target; echo; echo \"\$ nc -vz -w 5 \$1 \$2\"; nc -vz -w 5 \"\$1\" \"\$2\" 2>&1 || true; done"

run_zsh "HTTP Probes" "for url in 'http://www.gstatic.com/generate_204' 'https://www.google.com/generate_204' 'https://www.cloudflare.com/cdn-cgi/trace'; do echo; echo \"\$ curl -4 -I --connect-timeout 5 --max-time 10 \$url\"; curl -4 -I --connect-timeout 5 --max-time 10 \"\$url\" 2>&1 || true; done"

run_zsh "Burrow Controlled Proxy Self-Test" "if [[ -x '$repo_root/Scripts/burrow-proxy-selftest' ]]; then '$repo_root/Scripts/burrow-proxy-selftest'; else echo 'Scripts/burrow-proxy-selftest not found or not executable'; fi"

run_zsh "Burrow Direct Provider Proxy Ladder" "if [[ -x '$repo_root/Scripts/burrow-proxy-ladder' ]]; then '$repo_root/Scripts/burrow-proxy-ladder' --db '$db_path' --resolve-server doh-all --target google.com:80 --http-host google.com --skip-udp --timeout 8; else echo 'Scripts/burrow-proxy-ladder not found or not executable'; fi"

run_zsh "Burrow Daemon Packet Proxy Probe" "if [[ -x '$repo_root/target/debug/burrow' ]]; then BURROW_SOCKET_PATH=\"\${BURROW_SOCKET_PATH:-burrow.sock}\" '$repo_root/target/debug/burrow' proxy-tcp-probe --dns-name google.com --remote 1.1.1.1:80 --timeout-ms 12000; else echo 'target/debug/burrow not found; run cargo build -p burrow first'; fi"

run_zsh "Burrow Daemon HTTPS Packet Proxy Probe" "if [[ -x '$repo_root/target/debug/burrow' ]]; then BURROW_SOCKET_PATH=\"\${BURROW_SOCKET_PATH:-burrow.sock}\" '$repo_root/target/debug/burrow' proxy-https-probe --dns-name news.ycombinator.com --remote 1.1.1.1:443 --timeout-ms 20000; else echo 'target/debug/burrow not found; run cargo build -p burrow first'; fi"

section "Proxy Subscription Endpoint Status"
if [[ -f "$db_path" ]]; then
  run_python - "$db_path" <<'PY'
from __future__ import annotations

import json
import sqlite3
import sys
import urllib.error
import urllib.request

db_path = sys.argv[1]

conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
row = conn.execute(
    "select payload from network where type = 'ProxySubscription' order by idx limit 1"
).fetchone()
if row is None:
    print("No ProxySubscription network in database.")
    raise SystemExit

payload_blob = row["payload"]
payload_text = payload_blob.decode("utf-8", errors="replace") if isinstance(payload_blob, bytes) else payload_blob
payload = json.loads(payload_text)
url = (payload.get("source") or {}).get("url")
if not url:
    print("ProxySubscription has no source URL.")
    raise SystemExit

print("source_url_redacted=yes")
request = urllib.request.Request(url, headers={"User-Agent": "mihomo/1.18.3"})
try:
    with urllib.request.urlopen(request, timeout=20) as response:
        body = response.read(1024 * 1024)
        print(f"status={response.status}")
        for key, value in response.headers.items():
            lower = key.lower()
            if (
                lower in {"subscription-userinfo", "profile-update-interval", "profile-title", "content-type"}
                or "expire" in lower
                or "subscription" in lower
            ):
                print(f"header_{key}={value}")
        print(f"body_bytes={len(body)}")
except urllib.error.HTTPError as exc:
    print(f"status={exc.code}")
    print(f"reason={exc.reason}")
    for key, value in exc.headers.items():
        lower = key.lower()
        if (
            lower in {"subscription-userinfo", "profile-update-interval", "profile-title", "content-type"}
            or "expire" in lower
            or "subscription" in lower
        ):
            print(f"header_{key}={value}")
    body = exc.read(1000).decode("utf-8", errors="replace").strip()
    if body:
        print(f"body_prefix={body[:200]}")
except Exception as exc:
    print(f"fetch_error={type(exc).__name__}: {exc}")
PY
else
  echo "DB not found: $db_path"
fi

section "Burrow Runtime Log File"
if [[ -f "$runtime_log" ]]; then
  cmd tail -400 "$runtime_log"
  printf '\n'
  tail -400 "$runtime_log" 2>&1 || printf '\n[burrow-doctor] command exited with status %s\n' "$?"
else
  echo "Runtime log not found: $runtime_log"
fi

section "Recent Burrow NetworkExtension Logs"
cmd /usr/bin/log show --last "$last" --style compact --info --debug --predicate "process == \"nesessionmanager\" AND eventMessage CONTAINS[c] \"Burrow\""
printf '\n'
{ /usr/bin/log show --last "$last" --style compact --info --debug --predicate "process == \"nesessionmanager\" AND eventMessage CONTAINS[c] \"Burrow\"" 2>&1 \
  || printf '\n[burrow-doctor] log command exited with status %s\n' "$?"; }

section "Recent Burrow Extension Logs"
cmd /usr/bin/log show --last "$last" --style compact --info --debug --predicate "process == \"$extension_process\" OR eventMessage CONTAINS[c] \"$bundle_id.network\""
printf '\n'
{ /usr/bin/log show --last "$last" --style compact --info --debug --predicate "process == \"$extension_process\" OR eventMessage CONTAINS[c] \"$bundle_id.network\"" 2>&1 \
  || printf '\n[burrow-doctor] log command exited with status %s\n' "$?"; }

section "Recent Burrow Errors"
cmd /usr/bin/log show --last "$last" --style compact --info --debug --predicate "((process CONTAINS[c] \"Burrow\") OR (eventMessage CONTAINS[c] \"$bundle_id\") OR (eventMessage CONTAINS[c] \"Trojan\") OR (eventMessage CONTAINS[c] \"proxy\")) AND (messageType == error OR messageType == fault OR eventMessage CONTAINS[c] \"failed\" OR eventMessage CONTAINS[c] \"error\" OR eventMessage CONTAINS[c] \"panic\")"
printf '\n'
{ /usr/bin/log show --last "$last" --style compact --info --debug --predicate "((process CONTAINS[c] \"Burrow\") OR (eventMessage CONTAINS[c] \"$bundle_id\") OR (eventMessage CONTAINS[c] \"Trojan\") OR (eventMessage CONTAINS[c] \"proxy\")) AND (messageType == error OR messageType == fault OR eventMessage CONTAINS[c] \"failed\" OR eventMessage CONTAINS[c] \"error\" OR eventMessage CONTAINS[c] \"panic\")" 2>&1 \
  || printf '\n[burrow-doctor] log command exited with status %s\n' "$?"; }
