#!/usr/bin/env bash
#
# Removes Tailscale from macOS completely.
#
# Usage: sudo ./uninstallTailscale.sh

MACSYS_TEAM_ID="W5364U7YZB"
MACSYS_SYSEXT_ID="io.tailscale.ipn.macsys.network-extension"
MACSYS_PKG_ID="com.tailscale.ipn.macsys"
TAILSCALE_BIN="/Applications/Tailscale.app/Contents/MacOS/Tailscale"

if [[ $(uname) != "Darwin" ]]; then
    echo "Error: this script is for macOS only." >&2
    exit 1
fi

IS_ROOT=false
if [[ $EUID -eq 0 ]]; then
    IS_ROOT=true
else
    echo "Warning: running without sudo. Items that require root will be skipped." >&2
    echo "For a complete uninstall, re-run with: sudo $0" >&2
    echo >&2
    read -rp "Proceed without sudo? [y/N] " _sudo_response
    echo
    [[ "$_sudo_response" =~ ^[Yy] ]] || { echo "Aborted."; exit 0; }
fi

# Resolve the real user's home directory.
if $IS_ROOT; then
    REAL_USER="${SUDO_USER:-$USER}"
else
    REAL_USER="$USER"
fi
REAL_HOME=$(eval echo "~${REAL_USER}")

# Run a command as the real (non-root) user.
as_user() {
    if $IS_ROOT; then
        sudo -u "$REAL_USER" "$@"
    else
        "$@"
    fi
}

# Result tracking — collected silently during execution; printed in the summary.
DONE=()
WARNED=()
done_msg() { DONE+=("$1"); }
warn_msg() { WARNED+=("$1"); }

# Print a labelled list of items if the array is non-empty.
print_section() {
    local title="$1"; shift
    [[ $# -eq 0 ]] && return
    echo "  $title:"
    for item in "$@"; do
        echo "    - $item"
    done
}

# Discovery

echo "Tailscale macOS Uninstaller"
echo "==========================="
echo "Scanning for Tailscale components for user: ${REAL_USER}..."
echo

# Temp files — all cleaned up on exit.
app_candidates_file=$(mktemp)
user_candidates_file=$(mktemp)
trap 'rm -f "$app_candidates_file" "$user_candidates_file"' EXIT

# Running app variants (App Store and macsys)
RUNNING_APPS=()
for app_id in io.tailscale.ipn.macos io.tailscale.ipn.macsys; do
    result=$(as_user osascript \
        -e "application id \"$app_id\" is running" 2>/dev/null || true)
    [[ "$result" == "true" ]] && RUNNING_APPS+=("$app_id")
done

# Background processes matched by exact name
RUNNING_PROCS_X=()
for proc in tailscaled tailscale; do
    pgrep -x "$proc" >/dev/null 2>&1 && RUNNING_PROCS_X+=("$proc")
done

# Network extensions matched by full command line
RUNNING_PROCS_F=()
for ext_id in io.tailscale.ipn.macos.network-extension \
              io.tailscale.ipn.macsys.network-extension; do
    pgrep -f "$ext_id" >/dev/null 2>&1 && RUNNING_PROCS_F+=("$ext_id")
done

# Launchd daemon (macsys tssentineld)
LAUNCHD_TO_UNLOAD=()
launchctl list "io.tailscale.ipn.macsys.tssentineld" >/dev/null 2>&1 \
    && LAUNCHD_TO_UNLOAD+=("io.tailscale.ipn.macsys.tssentineld")

# System extension (macsys only)
SYSEXT_TO_REMOVE=()
systemextensionsctl list 2>/dev/null | grep -q "$MACSYS_SYSEXT_ID" \
    && SYSEXT_TO_REMOVE+=("$MACSYS_SYSEXT_ID")

# App bundles: Spotlight + filesystem search, deduplicated
as_user mdfind \
    "kMDItemCFBundleIdentifier == 'io.tailscale.ipn.*'" \
    2>/dev/null >> "$app_candidates_file" || true
for search_root in \
    /Applications \
    "${REAL_HOME}/Applications" \
    "${REAL_HOME}/Desktop" \
    "${REAL_HOME}/Downloads" \
    "${REAL_HOME}/Library/Developer/Xcode/DerivedData" \
    /tmp
do
    find "$search_root" -maxdepth 4 -name "Tailscale.app" -type d \
        2>/dev/null >> "$app_candidates_file" || true
done
APPS_TO_REMOVE=()
while IFS= read -r path; do
    APPS_TO_REMOVE+=("$path")
done < <(sort -u "$app_candidates_file")

# CLI wrapper and man pages
CLI_TO_REMOVE=()
[[ -e "/usr/local/bin/tailscale" ]] && CLI_TO_REMOVE+=("/usr/local/bin/tailscale")
while IFS= read -r f; do
    CLI_TO_REMOVE+=("$f")
done < <(find /usr/local/share/man -name "tailscale*" 2>/dev/null | sort)

# Homebrew packages
BREW=""
BREW_TO_REMOVE=()
BREW=$(as_user bash -lc 'command -v brew' 2>/dev/null || true)
if [[ -n "$BREW" ]]; then
    as_user "$BREW" list --cask tailscale &>/dev/null \
        && BREW_TO_REMOVE+=("cask:tailscale")
    as_user "$BREW" list --formula tailscale &>/dev/null \
        && BREW_TO_REMOVE+=("formula:tailscale")
fi

# System-level data directory (macsys)
SYS_DATA_TO_REMOVE=()
[[ -e "/Library/Tailscale" ]] && SYS_DATA_TO_REMOVE+=("/Library/Tailscale")

# Pkg receipt
PKG_TO_FORGET=()
pkgutil --pkgs 2>/dev/null | grep -qx "$MACSYS_PKG_ID" \
    && PKG_TO_FORGET+=("$MACSYS_PKG_ID")

# User library files: explicit known paths + wildcard sweeps, deduplicated via
# a temp file and sort -u.
for path in \
    "${REAL_HOME}/Library/Application Scripts/io.tailscale.ipn.macos" \
    "${REAL_HOME}/Library/Application Scripts/io.tailscale.ipn.macos.login-item-helper" \
    "${REAL_HOME}/Library/Application Scripts/io.tailscale.ipn.macos.share-extension" \
    "${REAL_HOME}/Library/Caches/io.tailscale.ipn.macos" \
    "${REAL_HOME}/Library/Containers/io.tailscale.ipn.macos" \
    "${REAL_HOME}/Library/Containers/io.tailscale.ipn.macos.login-item-helper" \
    "${REAL_HOME}/Library/Containers/io.tailscale.ipn.macos.network-extension" \
    "${REAL_HOME}/Library/Containers/io.tailscale.ipn.macos.share-extension" \
    "${REAL_HOME}/Library/HTTPStorages/io.tailscale.ipn.macos" \
    "${REAL_HOME}/Library/Preferences/io.tailscale.ipn.macos.plist" \
    "${REAL_HOME}/Library/Application Scripts/io.tailscale.ipn.macsys" \
    "${REAL_HOME}/Library/Application Scripts/io.tailscale.ipn.macsys.login-item-helper" \
    "${REAL_HOME}/Library/Application Scripts/io.tailscale.ipn.macsys.share-extension" \
    "${REAL_HOME}/Library/Caches/io.tailscale.ipn.macsys" \
    "${REAL_HOME}/Library/Containers/io.tailscale.ipn.macsys" \
    "${REAL_HOME}/Library/Containers/io.tailscale.ipn.macsys.login-item-helper" \
    "${REAL_HOME}/Library/Containers/io.tailscale.ipn.macsys.share-extension" \
    "${REAL_HOME}/Library/HTTPStorages/io.tailscale.ipn.macsys" \
    "${REAL_HOME}/Library/Preferences/io.tailscale.ipn.macsys.plist" \
    "${REAL_HOME}/Library/Containers/Tailscale" \
    "${REAL_HOME}/Library/Tailscale"
do
    [[ -e "$path" || -L "$path" ]] && echo "$path" >> "$user_candidates_file"
done
for glob in \
    "${REAL_HOME}/Library/Application Scripts/"*io.tailscale.ipn* \
    "${REAL_HOME}/Library/Application Scripts/"**io.tailscale.ipn* \
    "${REAL_HOME}/Library/Caches/"*io.tailscale.ipn* \
    "${REAL_HOME}/Library/Caches/"*io.tailscale.ipn* \
    "${REAL_HOME}/Library/Containers/"*io.tailscale.ipn* \
    "${REAL_HOME}/Library/Containers/"*io.tailscale.ipn* \
    "${REAL_HOME}/Library/Group Containers/"*io.tailscale.ipn*\
    "${REAL_HOME}/Library/Group Containers/"*io.tailscale.ipn* \
    "${REAL_HOME}/Library/HTTPStorages/"*io.tailscale.ipn* \
    "${REAL_HOME}/Library/HTTPStorages/"*io.tailscale.ipn* \
    "${REAL_HOME}/Library/Preferences/"*io.tailscale.ipn* \
    "${REAL_HOME}/Library/Preferences/"*io.tailscale.ipn** \
    "${REAL_HOME}/Library/Saved Application State/"*io.tailscale.ipn* \
    "${REAL_HOME}/Library/Saved Application State/"*io.tailscale.ipn* \
    "${REAL_HOME}/Library/WebKit/"*io.tailscale.ipn* \
    "${REAL_HOME}/Library/WebKit/"*io.tailscale.ipn*
do
    [[ -e "$glob" || -L "$glob" ]] && echo "$glob" >> "$user_candidates_file"
done
USER_FILES_TO_REMOVE=()
while IFS= read -r path; do
    USER_FILES_TO_REMOVE+=("$path")
done < <(sort -u "$user_candidates_file")

# Keychain entries.
# Tailscale stores items keyed on kSecAttrAccount, so discovery and deletion
# both use -a (account), not -s (service).
KEYCHAIN_ENTRIES_LOGIN=()   # login keychain, queried/deleted as real user
KEYCHAIN_ENTRIES_SYSTEM=()  # system keychain, queried/deleted as root

for item in \
    tailscale-machinekey \
    tailscale-logdata \
    tailscale-preferences \
    tailscale-profiles \
    tailscale-current-profile \
    tailscale-taildrop-received \
    "com.tailscale.localAPICredentials"
do
    as_user security find-generic-password -a "$item" \
        "${REAL_HOME}/Library/Keychains/login.keychain-db" \
        >/dev/null 2>&1 && KEYCHAIN_ENTRIES_LOGIN+=("$item")
    security find-generic-password -a "$item" \
        /Library/Keychains/System.keychain \
        >/dev/null 2>&1 && KEYCHAIN_ENTRIES_SYSTEM+=("$item")
done

# Wildcard sweep for tailscale-id-* (profile/node IDs vary per install)
while IFS= read -r svc; do
    KEYCHAIN_ENTRIES_LOGIN+=("$svc")
done < <(as_user security dump-keychain \
        "${REAL_HOME}/Library/Keychains/login.keychain-db" 2>/dev/null \
    | grep '"acct"<blob>=' \
    | sed 's/.*"acct"<blob>="\([^"]*\)".*/\1/' \
    | grep '^tailscale-id-' \
    | sort -u)

while IFS= read -r svc; do
    KEYCHAIN_ENTRIES_SYSTEM+=("$svc")
done < <(security dump-keychain \
        /Library/Keychains/System.keychain 2>/dev/null \
    | grep '"acct"<blob>=' \
    | sed 's/.*"acct"<blob>="\([^"]*\)".*/\1/' \
    | grep '^tailscale-id-' \
    | sort -u)

# Show plan

echo "The following changes will be made:"
echo

# Early exit if there is nothing to do.
_nothing_found=true
for _n in \
    "${#RUNNING_APPS[@]}" "${#RUNNING_PROCS_X[@]}" "${#RUNNING_PROCS_F[@]}" \
    "${#LAUNCHD_TO_UNLOAD[@]}" "${#SYSEXT_TO_REMOVE[@]}" "${#APPS_TO_REMOVE[@]}" \
    "${#CLI_TO_REMOVE[@]}" "${#BREW_TO_REMOVE[@]}" "${#SYS_DATA_TO_REMOVE[@]}" \
    "${#PKG_TO_FORGET[@]}" "${#USER_FILES_TO_REMOVE[@]}" \
    "${#KEYCHAIN_ENTRIES_LOGIN[@]}" "${#KEYCHAIN_ENTRIES_SYSTEM[@]}"
do
    [[ $_n -gt 0 ]] && _nothing_found=false && break
done
[[ -x "$TAILSCALE_BIN" ]] && _nothing_found=false

if $_nothing_found; then
    echo "  Nothing to remove — Tailscale does not appear to be installed."
    exit 0
fi

# Build and display the processes section
_proc_items=()
[[ -x "$TAILSCALE_BIN" ]] && _proc_items+=("tailscale down (disconnect)")
for a in "${RUNNING_APPS[@]}";     do _proc_items+=("quit app: $a"); done
for p in "${RUNNING_PROCS_X[@]}";  do _proc_items+=("kill: $p"); done
for f in "${RUNNING_PROCS_F[@]}";  do _proc_items+=("kill: $f"); done
[[ ${#_proc_items[@]} -gt 0 ]] && \
    print_section "Processes" "${_proc_items[@]}"

[[ ${#LAUNCHD_TO_UNLOAD[@]} -gt 0 ]] && \
    print_section "Launchd services to unload"   "${LAUNCHD_TO_UNLOAD[@]}"
[[ ${#SYSEXT_TO_REMOVE[@]} -gt 0 ]] && \
    print_section "System extensions to remove"  "${SYSEXT_TO_REMOVE[@]}"
[[ ${#APPS_TO_REMOVE[@]} -gt 0 ]] && \
    print_section "App bundles to remove"        "${APPS_TO_REMOVE[@]}"
[[ ${#CLI_TO_REMOVE[@]} -gt 0 ]] && \
    print_section "CLI tools / man pages"        "${CLI_TO_REMOVE[@]}"
[[ ${#BREW_TO_REMOVE[@]} -gt 0 ]] && \
    print_section "Homebrew packages"            "${BREW_TO_REMOVE[@]}"
[[ ${#SYS_DATA_TO_REMOVE[@]} -gt 0 ]] && \
    print_section "System data to remove"        "${SYS_DATA_TO_REMOVE[@]}"
[[ ${#PKG_TO_FORGET[@]} -gt 0 ]] && \
    print_section "Pkg receipts to forget"       "${PKG_TO_FORGET[@]}"
[[ ${#USER_FILES_TO_REMOVE[@]} -gt 0 ]] && \
    print_section "User library files to remove" "${USER_FILES_TO_REMOVE[@]}"
[[ ${#KEYCHAIN_ENTRIES_LOGIN[@]} -gt 0 ]] && \
    print_section "Login keychain entries"       "${KEYCHAIN_ENTRIES_LOGIN[@]}"
[[ ${#KEYCHAIN_ENTRIES_SYSTEM[@]} -gt 0 ]] && \
    print_section "System keychain entries"      "${KEYCHAIN_ENTRIES_SYSTEM[@]}"

echo

if ! $IS_ROOT; then
    echo "Note: running without sudo — items that require root (launchd, system"
    echo "extension, /usr/local, /Library/Tailscale, system keychain) will be"
    echo "skipped or may fail."
    echo
fi

# Confirm

read -rp "Proceed with removal? [y/N] " _response
echo
[[ "$_response" =~ ^[Yy] ]] || { echo "Aborted."; exit 0; }

# Execute

# Disconnect first, while the daemon is still alive to receive the command.
if [[ -x "$TAILSCALE_BIN" ]]; then
    "$TAILSCALE_BIN" down 2>/dev/null \
        && done_msg "tailscale down" \
        || warn_msg "tailscale down failed (may not have been connected)"
fi

# Now quit the UI apps and kill background processes.
for app_id in "${RUNNING_APPS[@]}"; do
    as_user osascript \
        -e "if app id \"$app_id\" is running then quit app id \"$app_id\"" \
        2>/dev/null \
        && done_msg "Quit $app_id" \
        || warn_msg "Could not quit $app_id"
done

for proc in "${RUNNING_PROCS_X[@]}"; do
    pkill -x "$proc" 2>/dev/null && done_msg "Sent SIGTERM to $proc" || true
done
for proc in "${RUNNING_PROCS_F[@]}"; do
    pkill -f "$proc" 2>/dev/null && done_msg "Sent SIGTERM to $proc" || true
done
sleep 2
for proc in "${RUNNING_PROCS_X[@]}"; do
    pgrep -x "$proc" >/dev/null 2>&1 || continue
    pkill -9 -x "$proc" 2>/dev/null \
        && done_msg "Force-killed $proc" \
        || warn_msg "Could not force-kill $proc"
done
for proc in "${RUNNING_PROCS_F[@]}"; do
    pgrep -f "$proc" >/dev/null 2>&1 || continue
    pkill -9 -f "$proc" 2>/dev/null \
        && done_msg "Force-killed $proc" \
        || warn_msg "Could not force-kill $proc"
done
sleep 1

# Launchd
for svc in "${LAUNCHD_TO_UNLOAD[@]}"; do
    if ! $IS_ROOT; then
        warn_msg "Skipped (requires sudo): unload $svc from launchd"
    elif launchctl bootout "system/$svc" 2>/dev/null; then
        done_msg "Unloaded $svc from launchd"
    else
        warn_msg "Could not unload $svc from launchd"
    fi
done

# System extension
for ext in "${SYSEXT_TO_REMOVE[@]}"; do
    if ! $IS_ROOT; then
        warn_msg "Skipped (requires sudo): remove system extension $ext"
    elif systemextensionsctl uninstall "$MACSYS_TEAM_ID" "$ext" 2>/dev/null; then
        done_msg "Removed system extension $ext"
    else
        warn_msg "systemextensionsctl uninstall failed for $ext — may need approval in System Settings > Privacy & Security"
    fi
done

# App bundles
for path in "${APPS_TO_REMOVE[@]}"; do
    rm -rf "$path" 2>/dev/null \
        && done_msg "Removed app bundle: $path" \
        || warn_msg "Could not remove app bundle: $path"
done

# CLI tools and man pages
for f in "${CLI_TO_REMOVE[@]}"; do
    rm -f "$f" 2>/dev/null \
        && done_msg "Removed $f" \
        || warn_msg "Could not remove $f"
done

# Homebrew packages
for pkg in "${BREW_TO_REMOVE[@]}"; do
    type="${pkg%%:*}"
    name="${pkg##*:}"
    as_user "$BREW" uninstall "--${type}" --force "$name" 2>/dev/null \
        && done_msg "Uninstalled Homebrew $type: $name" \
        || warn_msg "brew uninstall --$type $name failed"
done

# System data
for path in "${SYS_DATA_TO_REMOVE[@]}"; do
    rm -rf "$path" 2>/dev/null \
        && done_msg "Removed system data: $path" \
        || warn_msg "Could not remove system data: $path"
done

# Pkg receipts
for pkg in "${PKG_TO_FORGET[@]}"; do
    pkgutil --forget "$pkg" 2>/dev/null \
        && done_msg "Forgot pkg receipt: $pkg" \
        || warn_msg "Could not forget pkg receipt: $pkg"
done

# User library files
for path in "${USER_FILES_TO_REMOVE[@]}"; do
    rm -rf "$path" 2>/dev/null \
        && done_msg "Removed user file: $path" \
        || warn_msg "Could not remove user file: $path"
done

# Keychain entries
for entry in "${KEYCHAIN_ENTRIES_LOGIN[@]}"; do
    count=0
    while as_user security delete-generic-password \
            -a "$entry" \
            "${REAL_HOME}/Library/Keychains/login.keychain-db" >/dev/null 2>&1; do
        (( count++ )) || true
    done
    [[ $count -gt 0 ]] \
        && done_msg "Removed login keychain entry: $entry" \
        || warn_msg "Could not remove login keychain entry: $entry"
done

for entry in "${KEYCHAIN_ENTRIES_SYSTEM[@]}"; do
    count=0
    while security delete-generic-password \
            -a "$entry" \
            /Library/Keychains/System.keychain >/dev/null 2>&1; do
        (( count++ )) || true
    done
    [[ $count -gt 0 ]] \
        && done_msg "Removed system keychain entry: $entry" \
        || warn_msg "Could not remove system keychain entry: $entry"
done

echo "========================================"
echo "Summary"
echo "========================================"

if [[ ${#DONE[@]} -gt 0 ]]; then
    echo
    echo "Completed (${#DONE[@]}):"
    for msg in "${DONE[@]}"; do
        echo "  ✓ $msg"
    done
fi

if [[ ${#WARNED[@]} -gt 0 ]]; then
    echo
    echo "Warnings — needs attention (${#WARNED[@]}):"
    for msg in "${WARNED[@]}"; do
        echo "  ✗ $msg"
    done
fi

echo
echo "NOTE: If a 'Tailscale Tunnel' VPN entry is still visible in"
echo "System Settings > VPN, remove it manually:"
echo "  click the entry > the info (i) button > Remove Configuration"
echo
echo "A reboot is recommended to fully clear any residual network state."
