#!/usr/bin/env bash
set -Eeu -o pipefail

# Restrict permissions on all files/dirs we create (mktemp temp files, cache dir).
# Temp files contain the full home-directory layout, so keep them private (0600/0700).
umask 077

# Temp files for tracking excluded directory sizes and pre-existing exclusions.
ASIMOV_SIZE_LOG="$(mktemp)"
ASIMOV_EXCLUDED_CACHE="$(mktemp)"
ASIMOV_PATH_CACHE_TMP=""
trap 'rm -f "$ASIMOV_SIZE_LOG" "$ASIMOV_EXCLUDED_CACHE" "$ASIMOV_PATH_CACHE_TMP"' EXIT

# Look through the local filesystem and exclude development dependencies
# from Apple Time Machine backups.
#
# Since these files can be restored easily via their respective installation
# tools, there's no reason to waste time/bandwidth on backing them up.
#
# To retrieve a full list of excluded files, you may run:
#
#   sudo mdfind "com_apple_backup_excludeItem = 'com.apple.backupd'"
#
# For a full explanation, please see https://apple.stackexchange.com/a/25833/206772
#
# @author  Steve Grunwell
# @license MIT

# Keep ASIMOV_VERSION in sync with CHANGELOG and package managers (e.g. Homebrew).
readonly ASIMOV_VERSION='0.8.0'

print_usage() {
    printf 'Usage: asimov [--dry-run] [--verbose] [--quiet] [--stats] [--no-read-cache] [--no-write-cache] [directory]\n'
    printf '\n'
    printf 'Exclude development dependency directories from Time Machine backups.\n'
    printf '\n'
    printf 'Options:\n'
    printf '  --dry-run          Print what would be excluded without changing Time Machine\n'
    printf '  --verbose          Show all directories including already-excluded ones\n'
    printf '  --quiet            Suppress all output except errors\n'
    printf '  --stats            Show directory sizes and total space in the summary\n'
    printf '  --no-read-cache    Ignore cached state; re-discover and re-verify everything (rebuilds the cache)\n'
    printf '  --no-write-cache   Run normally but do not persist any cache updates\n'
    printf '  --full-scan        Alias for --no-read-cache\n'
    printf '  --no-cache         Alias for --no-read-cache --no-write-cache (fully stateless run)\n'
    printf '  --help             Show this help and exit\n'
    printf '  --version          Show version and exit\n'
    printf '\n'
    printf 'Arguments:\n'
    printf '  directory          Directory to scan (default: home directory)\n'
}

# Parse options (before we need ASIMOV_ROOT).
# Supports: --help, --version, --dry-run, --verbose, --quiet. Unknown options cause exit 1.
ASIMOV_DRY_RUN=
ASIMOV_VERBOSE=
ASIMOV_QUIET=
ASIMOV_STATS=
ASIMOV_NO_READ_CACHE=
ASIMOV_NO_WRITE_CACHE=
ASIMOV_SCAN_DIR=
for arg in "$@"; do
    case "$arg" in
        --dry-run)    ASIMOV_DRY_RUN=1 ;;
        --verbose)    ASIMOV_VERBOSE=1 ;;
        --quiet)      ASIMOV_QUIET=1 ;;
        --stats)      ASIMOV_STATS=1 ;;
        # Cache controls along two axes. --full-scan and --no-cache are kept as
        # back-compat aliases: --full-scan = --no-read-cache; --no-cache = both.
        --no-read-cache)  ASIMOV_NO_READ_CACHE=1 ;;
        --no-write-cache) ASIMOV_NO_WRITE_CACHE=1 ;;
        --full-scan)  ASIMOV_NO_READ_CACHE=1 ;;
        --no-cache)   ASIMOV_NO_READ_CACHE=1; ASIMOV_NO_WRITE_CACHE=1 ;;
        --help)       print_usage; exit 0 ;;
        --version)    printf '%s\n' "$ASIMOV_VERSION"; exit 0 ;;
        -*)
            echo "asimov: unknown option '$arg'" >&2
            print_usage >&2
            exit 1
            ;;
        *)
            ASIMOV_SCAN_DIR="$arg"
            ;;
    esac
done
readonly ASIMOV_DRY_RUN
readonly ASIMOV_VERBOSE
readonly ASIMOV_QUIET
readonly ASIMOV_STATS
readonly ASIMOV_NO_READ_CACHE
readonly ASIMOV_NO_WRITE_CACHE

if [[ -n "$ASIMOV_QUIET" && -n "$ASIMOV_VERBOSE" ]]; then
    echo "asimov: --quiet and --verbose are mutually exclusive" >&2
    exit 1
fi

# Named constants for size formatting.
readonly ASIMOV_KB_PER_MB=1024
readonly ASIMOV_KB_PER_GB=1048576

# Disable colors when stdout is not a terminal (e.g. launchd, pipes, redirects).
if [[ -t 1 ]]; then
    readonly ASIMOV_COLOR_INFO=$'\033[0;36m'
    readonly ASIMOV_COLOR_SUCCESS=$'\033[0;32m'
    readonly ASIMOV_COLOR_DIM=$'\033[0;90m'
    readonly ASIMOV_COLOR_RESET=$'\033[0m'
else
    readonly ASIMOV_COLOR_INFO=''
    readonly ASIMOV_COLOR_SUCCESS=''
    readonly ASIMOV_COLOR_DIM=''
    readonly ASIMOV_COLOR_RESET=''
fi

# Print a dim timestamped debug line when --verbose is set.
# Uses bash's built-in $SECONDS variable (auto-increments from 0 at script start).
# Output goes to stderr so it stays visible even when stdout is redirected
# (e.g. inside discover_new_paths_via_mdfind whose stdout is captured to a file).
verbose_timing() {
    [[ -n "$ASIMOV_VERBOSE" ]] || return 0
    printf '%s  [%ds] %s%s\n' "$ASIMOV_COLOR_DIM" "$SECONDS" "$1" "$ASIMOV_COLOR_RESET" >&2
}

# Resolve the root directory to scan (console user's home when running as root).
resolve_asimov_root() {
    if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
        local console_user
        console_user="$(stat -f '%Su' /dev/console 2>/dev/null || echo '')"
        if [[ -n "$console_user" && "$console_user" != "root" ]]; then
            local root_dir
            root_dir="$(dscl . -read "/Users/${console_user}" NFSHomeDirectory 2>/dev/null | awk '{print $2}')"
            if [[ -n "$root_dir" ]]; then
                echo "$root_dir"
                return
            fi
        fi
        echo ~
    else
        echo ~
    fi
}

ASIMOV_ROOT="$(resolve_asimov_root)"
readonly ASIMOV_ROOT
readonly ASIMOV_EXCLUDED_STATE="${ASIMOV_ROOT}/.cache/asimov/excluded"
readonly ASIMOV_FAILED_STATE="${ASIMOV_ROOT}/.cache/asimov/failed"
readonly ASIMOV_MDFIND_SEEN="${ASIMOV_ROOT}/.cache/asimov/mdfind_seen"

# Default scan dir to ASIMOV_ROOT; override if user supplied a directory.
if [[ -n "$ASIMOV_SCAN_DIR" ]]; then
    if [[ ! -d "$ASIMOV_SCAN_DIR" ]]; then
        echo "asimov: not a directory: ${ASIMOV_SCAN_DIR}" >&2
        exit 1
    fi
else
    ASIMOV_SCAN_DIR="$ASIMOV_ROOT"
fi
readonly ASIMOV_SCAN_DIR

# When ignoring cached reads but still writing (e.g. --full-scan / --no-read-cache),
# clear the append-only state so the rebuilt cache reflects only this run's results.
# When not writing (--no-cache / --no-write-cache), leave every cache file untouched.
# (The paths cache is truncated separately by init_path_cache before the full scan.)
if [[ -n "$ASIMOV_NO_READ_CACHE" && -z "$ASIMOV_NO_WRITE_CACHE" ]]; then
    rm -f "$ASIMOV_EXCLUDED_STATE" "$ASIMOV_FAILED_STATE" "$ASIMOV_MDFIND_SEEN"
fi

# Build a cache of paths already excluded from Time Machine.
# Merges Spotlight index (what macOS reports) with our own persistent state
# (written after each successful tmutil call). The persistent state handles:
#   - Interrupted runs (Ctrl+C before all tmutil calls complete)
#   - Spotlight indexing delays (tmutil sets xattr but Spotlight hasn't indexed it)
verbose_timing "Building excluded-path cache via Spotlight…"
mdfind -onlyin "${ASIMOV_SCAN_DIR}" "com_apple_backup_excludeItem = 'com.apple.backupd'" 2>/dev/null \
    | sort -u > "$ASIMOV_EXCLUDED_CACHE" || true
spotlight_count="$(wc -l < "$ASIMOV_EXCLUDED_CACHE" | tr -d ' ')"
# Merge persistent excluded + failed state into the Spotlight cache.
# Failed paths (e.g. Go modules with @ chars) are included so the bulk filter
# skips them instantly instead of wasting time retrying tmutil.
# Skipped under --no-read-cache (--full-scan / --no-cache): every path is then
# re-verified against the tmutil isexcluded ground truth instead.
if [[ -z "$ASIMOV_NO_READ_CACHE" ]] && { [[ -f "$ASIMOV_EXCLUDED_STATE" ]] || [[ -f "$ASIMOV_FAILED_STATE" ]]; }; then
    {
        cat "$ASIMOV_EXCLUDED_CACHE"
        if [[ -f "$ASIMOV_EXCLUDED_STATE" ]]; then cat "$ASIMOV_EXCLUDED_STATE"; fi
        if [[ -f "$ASIMOV_FAILED_STATE" ]]; then cat "$ASIMOV_FAILED_STATE"; fi
    } | sort -u > "${ASIMOV_EXCLUDED_CACHE}.merged"
    mv -f "${ASIMOV_EXCLUDED_CACHE}.merged" "$ASIMOV_EXCLUDED_CACHE"
fi
verbose_timing "Excluded-path cache ready ($(wc -l < "$ASIMOV_EXCLUDED_CACHE" | tr -d ' ') entries, ${spotlight_count} from Spotlight)"

# Load optional config from ~/.config/asimov/config.
# Populates ASIMOV_CONFIG_* variables. Missing file is silently ignored.
load_config() {
    local config_file="${ASIMOV_ROOT}/.config/asimov/config"

    ASIMOV_CONFIG_FIXED_DIRS_ENABLED=false
    ASIMOV_CONFIG_EXTRA_FIXED_DIRS=()
    ASIMOV_CONFIG_EXTRA_SENTINELS=()
    ASIMOV_CONFIG_DISABLED_SENTINELS=()

    [[ -f "$config_file" ]] || return 0

    local section="" line key value
    while IFS= read -r line || [[ -n "$line" ]]; do
        line="${line%%#*}"
        line="${line#"${line%%[![:space:]]*}"}"
        line="${line%"${line##*[![:space:]]}"}"
        [[ -z "$line" ]] && continue

        if [[ "$line" =~ ^\[([a-z_]+)\]$ ]]; then
            section="${BASH_REMATCH[1]}"
            continue
        fi

        if [[ "$line" =~ ^([a-z_]+)[[:space:]]*=[[:space:]]*(.*)$ ]]; then
            key="${BASH_REMATCH[1]}"
            value="${BASH_REMATCH[2]}"
            value="${value%"${value##*[![:space:]]}"}"

            case "${section}:${key}" in
                fixed_dirs:enabled)
                    ASIMOV_CONFIG_FIXED_DIRS_ENABLED="$value"
                    ;;
                fixed_dirs:extra)
                    value="${value/#\~/$HOME}"
                    ASIMOV_CONFIG_EXTRA_FIXED_DIRS+=("$value")
                    ;;
                sentinels:extra)
                    ASIMOV_CONFIG_EXTRA_SENTINELS+=("$value")
                    ;;
                sentinels:disabled)
                    ASIMOV_CONFIG_DISABLED_SENTINELS+=("$value")
                    ;;
            esac
        fi
    done < "$config_file"
}

load_config

# --- Path cache ---
readonly ASIMOV_PATH_CACHE="${ASIMOV_ROOT}/.cache/asimov/paths"

# Create the cache directory, chown to console user when running as root.
ensure_cache_dir() {
    local cache_dir
    cache_dir="$(dirname "$ASIMOV_PATH_CACHE")"
    mkdir -p "$cache_dir"
    if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
        local console_user
        console_user="$(stat -f '%Su' /dev/console 2>/dev/null || echo '')"
        if [[ -n "$console_user" && "$console_user" != "root" ]]; then
            chown -R "$console_user" "$cache_dir" 2>/dev/null || true
        fi
    fi
}

# Read cached paths, filtering to those that still exist as directories
# and fall under ASIMOV_SCAN_DIR. Outputs valid paths to stdout.
read_path_cache() {
    [[ -f "$ASIMOV_PATH_CACHE" ]] || return 0
    local line
    while IFS= read -r line; do
        # Skip comments and blank lines
        [[ "$line" =~ ^#  ]] && continue
        [[ -z "$line" ]] && continue
        # Must still exist as a directory
        [[ -d "$line" ]] || continue
        # Must be under ASIMOV_SCAN_DIR
        [[ "$line" == "${ASIMOV_SCAN_DIR}"/* || "$line" == "${ASIMOV_SCAN_DIR}" ]] || continue
        printf '%s\n' "$line"
    done < "$ASIMOV_PATH_CACHE"
}

# Initialize the path cache file with a header, truncating any old contents.
# No-op if --dry-run or writes are disabled (--no-write-cache / --no-cache).
init_path_cache() {
    [[ -n "$ASIMOV_DRY_RUN" || -n "$ASIMOV_NO_WRITE_CACHE" ]] && return 0
    ensure_cache_dir
    printf '# asimov path cache — updated %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" > "$ASIMOV_PATH_CACHE"
}

# Append a single path to the cache file. No-op if --dry-run or writes are disabled (--no-write-cache / --no-cache).
append_path_to_cache() {
    [[ -n "$ASIMOV_DRY_RUN" || -n "$ASIMOV_NO_WRITE_CACHE" ]] && return 0
    printf '%s\n' "$1" >> "$ASIMOV_PATH_CACHE"
}

# Remove paths that are descendants of other paths in the list.
# Expects sorted input. Outputs only outermost (non-nested) paths.
# Example: /a/node_modules and /a/node_modules/foo/node_modules → keeps only /a/node_modules
# (Time Machine exclusions are recursive, so the descendant is already covered.)
dedup_nested_paths() {
    local last_kept="" path
    while IFS= read -r path; do
        [[ -z "$path" ]] && continue
        if [[ -n "$last_kept" && "$path" == "${last_kept}/"* ]]; then
            continue
        fi
        printf '%s\n' "$path"
        last_kept="$path"
    done
}

# Sort, deduplicate, and prune stale entries from the cache file.
# Writes atomically via temp file + mv. No-op if --dry-run or writes are disabled (--no-write-cache / --no-cache).
finalize_path_cache() {
    [[ -n "$ASIMOV_DRY_RUN" || -n "$ASIMOV_NO_WRITE_CACHE" ]] && return 0
    [[ -f "$ASIMOV_PATH_CACHE" ]] || return 0

    ASIMOV_PATH_CACHE_TMP="${ASIMOV_PATH_CACHE}.tmp.$$"
    {
        printf '# asimov path cache — updated %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
        # Keep only lines that are existing directories, sorted and deduplicated
        while IFS= read -r line; do
            [[ "$line" =~ ^# ]] && continue
            [[ -z "$line" ]] && continue
            if [[ -d "$line" ]]; then
                printf '%s\n' "$line"
            fi
        done < "$ASIMOV_PATH_CACHE" | sort -u | dedup_nested_paths
    } > "$ASIMOV_PATH_CACHE_TMP"
    mv -f "$ASIMOV_PATH_CACHE_TMP" "$ASIMOV_PATH_CACHE"
    ASIMOV_PATH_CACHE_TMP=""

    # Dedup the mdfind_seen file (grows with appends each run)
    if [[ -f "$ASIMOV_MDFIND_SEEN" ]]; then
        sort -u "$ASIMOV_MDFIND_SEEN" > "${ASIMOV_MDFIND_SEEN}.tmp"
        mv -f "${ASIMOV_MDFIND_SEEN}.tmp" "$ASIMOV_MDFIND_SEEN"
    fi

    # Dedup the failed-state file (grows with appends each run)
    if [[ -f "$ASIMOV_FAILED_STATE" ]]; then
        sort -u "$ASIMOV_FAILED_STATE" > "${ASIMOV_FAILED_STATE}.tmp"
        mv -f "${ASIMOV_FAILED_STATE}.tmp" "$ASIMOV_FAILED_STATE"
    fi

    if [[ "${EUID:-$(id -u)}" -eq 0 ]]; then
        local console_user
        console_user="$(stat -f '%Su' /dev/console 2>/dev/null || echo '')"
        if [[ -n "$console_user" && "$console_user" != "root" ]]; then
            chown "$console_user" "$ASIMOV_PATH_CACHE" 2>/dev/null || true
        fi
    fi
}

# Discover new dependency paths via Spotlight (mdfind) that aren't in the
# current cache. For each candidate, validate the sentinel exists in the
# parent directory. Outputs newly discovered paths to stdout.
discover_new_paths_via_mdfind() {
    local cached_paths_file="$1"

    # Pre-build active sentinel pairs (filter disabled once, not per candidate)
    local -a dir_names=() active_sentinels=()
    local pair parts dir_name seen=""
    for pair in "${ASIMOV_VENDOR_DIR_SENTINELS[@]}"; do
        local disabled=false
        if [[ ${#ASIMOV_CONFIG_DISABLED_SENTINELS[@]} -gt 0 ]]; then
            local dpair
            for dpair in "${ASIMOV_CONFIG_DISABLED_SENTINELS[@]}"; do
                [[ "$pair" == "$dpair" ]] && { disabled=true; break; }
            done
        fi
        [[ "$disabled" == true ]] && continue
        active_sentinels+=("$pair")
        read -ra parts <<< "$pair"
        dir_name="${parts[0]}"
        if [[ " $seen " != *" $dir_name "* ]]; then
            dir_names+=("$dir_name")
            seen="$seen $dir_name"
        fi
    done
    for pair in ${ASIMOV_CONFIG_EXTRA_SENTINELS[@]+"${ASIMOV_CONFIG_EXTRA_SENTINELS[@]}"}; do
        active_sentinels+=("$pair")
        read -ra parts <<< "$pair"
        dir_name="${parts[0]}"
        if [[ " $seen " != *" $dir_name "* ]]; then
            dir_names+=("$dir_name")
            seen="$seen $dir_name"
        fi
    done

    [[ ${#dir_names[@]} -eq 0 ]] && return 0

    # Build mdfind query: kMDItemContentType == "public.folder" && (name == "x" || ...)
    local name_clauses=""
    for dir_name in "${dir_names[@]}"; do
        [[ -n "$name_clauses" ]] && name_clauses="${name_clauses} || "
        name_clauses="${name_clauses}kMDItemFSName == \"${dir_name}\""
    done
    local query="kMDItemContentType == \"public.folder\" && (${name_clauses})"

    # Run mdfind, write results to temp file (avoids slow printf on 15K-line strings).
    verbose_timing "Running Spotlight query for ${#dir_names[@]} directory names…"
    local candidates_file uncached_file
    candidates_file="$(mktemp)"
    uncached_file="$(mktemp)"
    mdfind -onlyin "$ASIMOV_SCAN_DIR" "$query" 2>/dev/null > "$candidates_file" || true
    if [[ ! -s "$candidates_file" ]]; then
        rm -f "$candidates_file" "$uncached_file"
        return 0
    fi

    local total_mdfind
    total_mdfind="$(wc -l < "$candidates_file" | tr -d ' ')"
    verbose_timing "Spotlight returned ${total_mdfind} candidates"

    # Descendant filter first: skip candidates nested under cached paths. TM exclusions
    # are recursive so descendants are already covered. Run this BEFORE the exact-match
    # filter because it's fast (grep -Fvf ~4s on 15K lines) and eliminates ~90% of
    # candidates, making the subsequent exact filter much cheaper.
    if [[ -n "$cached_paths_file" && -s "$cached_paths_file" ]]; then
        local prefix_file
        prefix_file="$(mktemp)"
        sed 's|$|/|' "$cached_paths_file" > "$prefix_file"
        grep -Fvf "$prefix_file" "$candidates_file" > "$uncached_file" || true
        rm -f "$prefix_file"
    else
        cp "$candidates_file" "$uncached_file"
    fi
    rm -f "$candidates_file"

    if [[ ! -s "$uncached_file" ]]; then
        verbose_timing "All ${total_mdfind} candidates were nested under cached paths"
        rm -f "$uncached_file"
        return 0
    fi
    local uncached_count
    uncached_count="$(wc -l < "$uncached_file" | tr -d ' ')"
    verbose_timing "After descendant filter: ${uncached_count} remain (skipped $((total_mdfind - uncached_count)) nested)"

    # Exact-match filter: remove candidates already in the path cache or previously
    # checked by mdfind (no-sentinel directories that would just be rechecked for nothing).
    # Uses sort+comm for set difference — BSD grep -Fxvf is O(n×m) and takes 35s on 15K
    # lines; sort+comm is O(n log n) and handles the same in <1s.
    local combined_filter
    combined_filter="$(mktemp)"
    {
        if [[ -n "$cached_paths_file" && -s "$cached_paths_file" ]]; then cat "$cached_paths_file"; fi
        if [[ -f "$ASIMOV_MDFIND_SEEN" ]]; then cat "$ASIMOV_MDFIND_SEEN"; fi
    } > "$combined_filter"
    if [[ -s "$combined_filter" ]]; then
        local sorted_candidates sorted_filter filtered_file
        sorted_candidates="$(mktemp)"
        sorted_filter="$(mktemp)"
        filtered_file="$(mktemp)"
        sort "$uncached_file" > "$sorted_candidates"
        sort -u "$combined_filter" > "$sorted_filter"
        comm -23 "$sorted_candidates" "$sorted_filter" > "$filtered_file"
        rm -f "$sorted_candidates" "$sorted_filter"
        mv -f "$filtered_file" "$uncached_file"
    fi
    rm -f "$combined_filter"

    if [[ ! -s "$uncached_file" ]]; then
        verbose_timing "All candidates already checked"
        rm -f "$uncached_file"
        return 0
    fi
    local filtered_count
    filtered_count="$(wc -l < "$uncached_file" | tr -d ' ')"
    verbose_timing "After cache filter: ${filtered_count} new candidates to check (skipped $((uncached_count - filtered_count)) seen)"

    if [[ ! -s "$uncached_file" ]]; then
        verbose_timing "All candidates were nested under cached paths"
        rm -f "$uncached_file"
        return 0
    fi

    local candidate mdfind_checked=0 mdfind_matched=0
    while IFS= read -r candidate; do
        [[ -d "$candidate" ]] || continue

        # Skip paths under ASIMOV_SKIP_PATHS
        local skip=false
        local skip_dir
        for skip_dir in "${ASIMOV_SKIP_PATHS[@]}"; do
            if [[ "$candidate" == "${skip_dir}"/* || "$candidate" == "${skip_dir}" ]]; then
                skip=true
                break
            fi
        done
        [[ "$skip" == true ]] && continue

        # Bash builtins instead of dirname/basename subprocesses
        local parent_dir="${candidate%/*}"
        local candidate_basename="${candidate##*/}"
        local sentinel_found=false

        # Check pre-built active sentinel pairs
        for pair in "${active_sentinels[@]}"; do
            read -ra parts <<< "$pair"
            [[ "${parts[0]}" == "$candidate_basename" ]] || continue
            local sentinel_name="${parts[1]}"
            if [[ "$sentinel_name" == *'*'* ]]; then
                # shellcheck disable=SC2086
                if (cd "$parent_dir" && ls -d $sentinel_name) >/dev/null 2>&1; then
                    sentinel_found=true
                    break
                fi
            else
                if [[ -e "${parent_dir}/${sentinel_name}" ]]; then
                    sentinel_found=true
                    break
                fi
            fi
        done

        mdfind_checked=$((mdfind_checked + 1))
        if [[ "$sentinel_found" == true ]]; then
            mdfind_matched=$((mdfind_matched + 1))
            printf '%s\n' "$candidate"
        fi
        if [[ $((mdfind_checked % 100)) -eq 0 ]]; then
            verbose_timing "  …checked ${mdfind_checked} candidates (${mdfind_matched} matched)"
        fi
    done < "$uncached_file"

    # Persist all checked candidates so they're skipped on the next cached run.
    # Prevents re-checking 1000+ no-sentinel candidates every time.
    # No-op for dry-run/no-cache (no persistent state should be written).
    if [[ -z "$ASIMOV_DRY_RUN" && -z "$ASIMOV_NO_WRITE_CACHE" ]]; then
        ensure_cache_dir
        cat "$uncached_file" >> "$ASIMOV_MDFIND_SEEN"
    fi
    rm -f "$uncached_file"
    verbose_timing "Spotlight: checked ${mdfind_checked} candidates, ${mdfind_matched} new matches"
}

# Paths to unconditionally skip over. This prevents Asimov from modifying the
# Time Machine exclusions for these paths (and descendants). It has an important
# side-effect of speeding up the search.
readonly ASIMOV_SKIP_PATHS=(
    "${ASIMOV_ROOT}/.Trash"
    "${ASIMOV_ROOT}/Library"
)

# A list of "directory"/"sentinel" pairs.
#
# Directories will only be excluded if the dependency ("sentinel") file exists.
#
# For example, 'node_modules package.json' means "exclude node_modules/ from the
# Time Machine backups if there is a package.json file next to it."
readonly ASIMOV_VENDOR_DIR_SENTINELS=(
    '.build Package.swift'             # Swift
    '.gradle build.gradle'             # Gradle
    '.gradle build.gradle.kts'         # Gradle Kotlin Script
    'build build.gradle'               # Gradle build files
    'build build.gradle.kts'           # Gradle Kotlin Script build files
    '.dart_tool pubspec.yaml'          # Flutter (Dart)
    '.packages pubspec.yaml'           # Pub (Dart)
    '.stack-work stack.yaml'           # Stack (Haskell)
    '.tox tox.ini'                     # Tox (Python)
    '.nox noxfile.py'                  # Nox (Python)
    '.vagrant Vagrantfile'             # Vagrant
    '.venv requirements.txt'           # virtualenv (Python)
    '.venv pyproject.toml'             # virtualenv (Python)
    'Carthage Cartfile'                # Carthage
    'Pods Podfile'                     # CocoaPods
    'DerivedData *.xcodeproj'          # Xcode DerivedData
    'bower_components bower.json'      # Bower (JavaScript)
    'build pubspec.yaml'               # Flutter (Dart)
    'build setup.py'                   # Python
    'dist setup.py'                    # PyPI Publishing (Python)
    'node_modules package.json'        # npm, Yarn (NodeJS)
    '.parcel-cache package.json'       # Parcel v2 cache (JavaScript)
    '.next package.json'               # Next.js (JavaScript)
    '.nuxt package.json'               # Nuxt (JavaScript)
    '.angular angular.json'            # Angular (JavaScript)
    '.svelte-kit svelte.config.js'     # SvelteKit (JavaScript)
    '.turbo turbo.json'                # Turborepo (JavaScript)
    '.yarn .yarnrc.yml'                # Yarn Berry cache (JavaScript)
    'cache workspace.yml'              # moonrepo task cache (.moon/cache)
    'target Cargo.toml'                # Cargo (Rust)
    'target pom.xml'                   # Maven
    'target build.sbt'                 # Sbt (Scala)
    'target plugins.sbt'               # Sbt plugins (Scala)
    'target project.clj'               # Leiningen (Clojure)
    'target deps.edn'                  # Clojure CLI
    '.cpcache deps.edn'                # Clojure CLI classpath cache
    '.shadow-cljs shadow-cljs.edn'     # Shadow-CLJS (ClojureScript)
    'bin *.csproj'                     # .NET C# build output
    'obj *.csproj'                     # .NET C# intermediate output
    'bin *.fsproj'                     # .NET F# build output
    'obj *.fsproj'                     # .NET F# intermediate output
    'vendor composer.json'             # Composer (PHP)
    'vendor Gemfile'                   # Bundler (Ruby)
    'vendor go.mod'                    # Go Modules (Golang)
    'venv requirements.txt'            # virtualenv (Python)
    'venv pyproject.toml'              # virtualenv (Python)
    '__pypackages__ pyproject.toml'    # PEP 582/PDM (Python)
    'deps mix.exs'                     # Mix dependencies (Elixir)
    '.build mix.exs'                   # Mix build files (Elixir)
    '_build mix.exs'                   # Mix build output (Elixir)
    '.terraform.d .terraformrc'        # Terraform plugin cache
    '.terragrunt-cache terragrunt.hcl' # Terragrunt
    '.terraform .terraform.lock.hcl'   # Terraform providers/modules
    '.direnv .envrc'                   # direnv
    'cdk.out cdk.json'                 # AWS CDK
    '_build dune-project'              # Dune (OCaml)
    '.zig-cache build.zig'             # Zig build cache
    'zig-out build.zig'                # Zig build output
    'elm-stuff elm.json'               # Elm packages
    '.godot project.godot'             # Godot 4 editor cache
    'renv renv.lock'                   # renv (R)
)

# A list of fixed directories to exclude from Time Machine backups.
#
# Unlike sentinel-based pairs above, these directories are always excluded
# when they exist — they represent global tool caches and artifacts that
# can be safely restored.
readonly ASIMOV_FIXED_DIRS=(
    "${ASIMOV_ROOT}/.cache"                           # XDG cache directory
    "${ASIMOV_ROOT}/.gradle/caches"                   # Gradle download cache
    "${ASIMOV_ROOT}/.gradle/wrapper"                  # Gradle wrapper distributions
    "${ASIMOV_ROOT}/.m2/repository"                   # Maven local repository
    "${ASIMOV_ROOT}/.npm/_cacache"                    # npm content-addressable cache
    "${ASIMOV_ROOT}/.nuget/packages"                  # NuGet global packages
    "${ASIMOV_ROOT}/.kube/cache"                      # Kubernetes API cache
    "${ASIMOV_ROOT}/.kube/http-cache"                 # Kubernetes HTTP cache
)

# Record an excluded path: log for counting, compute size only with --stats.
record_excluded_path() {
    local path="$1"
    local message="$2"
    if [[ -n "$ASIMOV_STATS" ]]; then
        local rawsize size_human
        rawsize=$(du -sk "${path}" 2>/dev/null | cut -f1 || echo "0")
        echo "$rawsize" >> "$ASIMOV_SIZE_LOG"
        if [[ -z "$ASIMOV_QUIET" ]]; then
            size_human="$(format_size_kb "$rawsize")"
            printf '%s\n' "- ${message} (${size_human})."
        fi
    else
        echo "0" >> "$ASIMOV_SIZE_LOG"
        if [[ -z "$ASIMOV_QUIET" ]]; then
            printf '%s\n' "- ${message}."
        fi
    fi
}

# Process newline-separated paths from stdin: check the excluded-path cache,
# call tmutil addexclusion (unless dry-run), and log sizes.
#
# Performance notes (learned the hard way):
# - tmutil addexclusion takes ~11s per call (IPC with TM daemon). This dominates runtime.
# - Batching (tmutil addexclusion path1 path2 ...) provides NO speed benefit — tmutil
#   processes paths sequentially at ~11s each. Worse: one bad path (e.g. Go modules with
#   @ characters) fails the entire batch, wasting all accumulated time before fallback.
# - Spotlight (mdfind) can be stale: recently excluded paths may not appear in the index.
#   Without a guard, this causes re-excluding already-done paths (~11s each, wasted).
# - Three-layer defense against wasted tmutil calls:
#   1. Bulk grep against Spotlight + persistent state (instant, catches ~90%)
#   2. Filter descendants of fixed dirs (instant, catches ~5%)
#   3. tmutil isexcluded per-path guard (fast, catches remaining stale entries)
exclude_paths_from_stdin() {
    local path
    local -a all_paths=()

    # Read all paths from stdin, filtering non-directories
    while IFS=$'\n' read -r path; do
        [[ -d "$path" ]] || continue
        all_paths+=("$path")
    done
    [[ ${#all_paths[@]} -eq 0 ]] && return 0

    # Layer 1: Bulk filter against excluded-path cache (1 grep vs N subprocess spawns).
    # NEVER use per-path grep in a loop — spawning a subprocess per candidate is O(N)
    # process creation overhead that dominated runtime with ~15k mdfind candidates.
    verbose_timing "Filtering ${#all_paths[@]} paths against excluded-path cache…"
    local -a to_exclude=()
    local already_excluded=0
    if [[ -f "$ASIMOV_EXCLUDED_CACHE" ]]; then
        local new_paths_str
        new_paths_str="$(printf '%s\n' "${all_paths[@]}" | grep -Fxvf "$ASIMOV_EXCLUDED_CACHE" || true)"
        if [[ -n "$new_paths_str" ]]; then
            while IFS= read -r path; do
                to_exclude+=("$path")
            done <<< "$new_paths_str"
        fi
        already_excluded=$(( ${#all_paths[@]} - ${#to_exclude[@]} ))
        if [[ -n "$ASIMOV_VERBOSE" && $already_excluded -gt 0 ]]; then
            echo "- ${already_excluded} paths already excluded, skipping."
        fi
    else
        to_exclude=("${all_paths[@]}")
    fi
    verbose_timing "Filtered: ${#to_exclude[@]} new, ${already_excluded} already excluded"

    # Layer 2: Filter descendants of ASIMOV_FIXED_DIRS — they'll be excluded
    # unconditionally later, so calling tmutil on them individually is wasted (~11s each).
    if [[ "$ASIMOV_CONFIG_FIXED_DIRS_ENABLED" == "true" && ${#to_exclude[@]} -gt 0 ]]; then
        local -a filtered_exclude=()
        for path in "${to_exclude[@]}"; do
            local under_fixed=false
            local fixed_dir
            for fixed_dir in "${ASIMOV_FIXED_DIRS[@]}"; do
                if [[ "$path" == "${fixed_dir}/"* ]]; then
                    under_fixed=true
                    break
                fi
            done
            if [[ "$under_fixed" == false ]]; then
                filtered_exclude+=("$path")
            fi
        done
        if [[ ${#filtered_exclude[@]} -lt ${#to_exclude[@]} ]]; then
            verbose_timing "Skipped $((${#to_exclude[@]} - ${#filtered_exclude[@]})) paths inside fixed dirs"
            to_exclude=("${filtered_exclude[@]+"${filtered_exclude[@]}"}")
        fi
    fi

    [[ ${#to_exclude[@]} -eq 0 ]] && {
        verbose_timing "Nothing to exclude (${already_excluded} already excluded)"
        return 0
    }
    verbose_timing "${#to_exclude[@]} to exclude, ${already_excluded} already excluded"

    # Layer 3: tmutil isexcluded guard before the expensive addexclusion (~11s each).
    # This is the ground truth — catches paths the Spotlight cache missed (stale index,
    # interrupted previous runs, or exclusions added outside Asimov — e.g. a manually
    # excluded parent like ~/.nvm). v0.5.0 removed this check; re-adding it cut runtime
    # from 73 min to 79s on a real system with ~460 dependency paths.
    #
    # --dry-run takes this exact same path so the preview matches a real run: it still
    # runs the read-only `tmutil isexcluded` guard, but prints "Would exclude" instead
    # of calling addexclusion and never persists state.
    verbose_timing "Excluding ${#to_exclude[@]} paths via tmutil…"
    [[ -z "$ASIMOV_DRY_RUN" && -z "$ASIMOV_NO_WRITE_CACHE" ]] && ensure_cache_dir
    local tmutil_i=0 skipped_already=0
    for path in "${to_exclude[@]}"; do
        tmutil_i=$((tmutil_i + 1))
        # Ground-truth check: is this path actually already excluded?
        if tmutil isexcluded "$path" 2>/dev/null | grep -Fq '[Excluded]'; then
            skipped_already=$((skipped_already + 1))
            # Persist so we skip it via the fast cache next time (not in dry-run / no-write)
            [[ -z "$ASIMOV_DRY_RUN" && -z "$ASIMOV_NO_WRITE_CACHE" ]] && printf '%s\n' "$path" >> "$ASIMOV_EXCLUDED_STATE"
            if [[ -n "$ASIMOV_VERBOSE" ]]; then
                echo "- ${path} is already excluded, skipping."
            fi
            continue
        fi
        if [[ -n "$ASIMOV_DRY_RUN" ]]; then
            record_excluded_path "$path" "Would exclude: ${path}"
            continue
        fi
        local path_start=$SECONDS
        if ! tmutil addexclusion "${path}" 2>/dev/null; then
            echo "! ${path}: failed to exclude (tmutil error), skipping." >&2
            # Persist failure so we skip this path on subsequent runs.
            # Common cause: Go module paths with @ characters. Use --no-read-cache to retry.
            [[ -z "$ASIMOV_NO_WRITE_CACHE" ]] && printf '%s\n' "$path" >> "$ASIMOV_FAILED_STATE"
            continue
        fi
        # Persist immediately — survives Ctrl+C so the next run doesn't redo this path
        [[ -z "$ASIMOV_NO_WRITE_CACHE" ]] && printf '%s\n' "$path" >> "$ASIMOV_EXCLUDED_STATE"
        record_excluded_path "$path" "${path} has been excluded from Time Machine backups"
        verbose_timing "  [${tmutil_i}/${#to_exclude[@]}] excluded in $((SECONDS - path_start))s: ${path}"
    done
    if [[ $skipped_already -gt 0 ]]; then
        verbose_timing "Skipped ${skipped_already} already-excluded paths (Spotlight cache was stale)"
    fi
}

# Build find parameters to skip ASIMOV_SKIP_PATHS (hardcoded directories like .Trash, Library).
# Already-excluded paths are NOT pruned here — they are filtered cheaply via grep in
# exclude_paths_from_stdin instead, avoiding the O(dirs × prune_count) performance trap.
# Result is stored in global find_parameters_skip.
build_find_skip_params() {
    find_parameters_skip=()
    local skip_dir
    for skip_dir in "${ASIMOV_SKIP_PATHS[@]}"; do
        find_parameters_skip+=( -not \( -path "${skip_dir}" -prune \) )
    done
}

# Build find parameters for all directory/sentinel pairs.
# Skips pairs disabled via config and appends extra pairs from config.
# Result is stored in global find_parameters_vendor.
build_find_vendor_params() {
    find_parameters_vendor=()
    local pair parts dir_name sentinel_name sentinel_check

    # Process built-in sentinels, skipping any disabled by config
    for pair in "${ASIMOV_VENDOR_DIR_SENTINELS[@]}"; do
        local disabled=false
        if [[ ${#ASIMOV_CONFIG_DISABLED_SENTINELS[@]} -gt 0 ]]; then
            local dpair
            for dpair in "${ASIMOV_CONFIG_DISABLED_SENTINELS[@]}"; do
                if [[ "$pair" == "$dpair" ]]; then
                    disabled=true
                    break
                fi
            done
        fi
        [[ "$disabled" == true ]] && continue

        read -ra parts <<< "${pair}"
        dir_name="${parts[0]}"
        sentinel_name="${parts[1]}"

        if [[ "$sentinel_name" == *'*'* ]]; then
            # Pass the glob pattern as a positional arg ($1), never interpolated into
            # the sh -c script body, so a malicious sentinel can't inject shell commands.
            # $1 is intentionally unquoted so the inner sh glob-expands the pattern; its
            # value is never re-parsed as shell code, so injection is not possible.
            # shellcheck disable=SC2016,SC2086
            sentinel_check=( -execdir sh -c 'ls -d -- $1 >/dev/null 2>&1' _ "${sentinel_name}" \; )
        else
            sentinel_check=( -execdir test -e "${sentinel_name}" \; )
        fi

        find_parameters_vendor+=( -or \( \
            -type d \
            -name "${dir_name}" \
            "${sentinel_check[@]}" \
            -prune \
            -print \
        \) )
    done

    # Append extra sentinels from config
    for pair in ${ASIMOV_CONFIG_EXTRA_SENTINELS[@]+"${ASIMOV_CONFIG_EXTRA_SENTINELS[@]}"}; do
        read -ra parts <<< "${pair}"
        dir_name="${parts[0]}"
        sentinel_name="${parts[1]}"

        if [[ "$sentinel_name" == *'*'* ]]; then
            # sentinel_name here comes from the user's config; pass it as a positional
            # arg ($1) instead of interpolating it into the sh -c script body to
            # prevent shell command injection from a crafted config value. $1 is
            # intentionally unquoted so the inner sh glob-expands it; its value is never
            # re-parsed as shell code, so a payload like "*.x'; cmd; '" can't execute.
            # shellcheck disable=SC2016,SC2086
            sentinel_check=( -execdir sh -c 'ls -d -- $1 >/dev/null 2>&1' _ "${sentinel_name}" \; )
        else
            sentinel_check=( -execdir test -e "${sentinel_name}" \; )
        fi

        find_parameters_vendor+=( -or \( \
            -type d \
            -name "${dir_name}" \
            "${sentinel_check[@]}" \
            -prune \
            -print \
        \) )
    done
}

# Format a size in KB as human-readable string (e.g. 1.2G, 500M, 42K).
format_size_kb() {
    local kb="$1"
    if [[ "$kb" -ge ASIMOV_KB_PER_GB ]]; then
        local whole=$((kb / ASIMOV_KB_PER_GB))
        local frac=$(( (kb % ASIMOV_KB_PER_GB) * 10 / ASIMOV_KB_PER_GB ))
        printf '%s.%sG' "$whole" "$frac"
    elif [[ "$kb" -ge ASIMOV_KB_PER_MB ]]; then
        local whole=$((kb / ASIMOV_KB_PER_MB))
        local frac=$(( (kb % ASIMOV_KB_PER_MB) * 10 / ASIMOV_KB_PER_MB ))
        printf '%s.%sM' "$whole" "$frac"
    else
        printf '%sK' "$kb"
    fi
}

# Print summary of excluded (or would-exclude) count, and total size when --stats is set.
print_exclusion_summary() {
    local excluded_count msg
    excluded_count=$(wc -l < "$ASIMOV_SIZE_LOG" | tr -d ' ')

    if [[ "$excluded_count" -eq 0 ]]; then
        if [[ -n "$ASIMOV_DRY_RUN" ]]; then
            msg="No directories would be excluded."
        else
            msg="✓ Done! No new directories to exclude."
        fi
    else
        local verb
        if [[ -n "$ASIMOV_DRY_RUN" ]]; then
            verb="Would exclude"
        else
            verb="✓ Done! Excluded"
        fi
        if [[ -n "$ASIMOV_STATS" ]]; then
            local total_kb total_human
            total_kb=$(awk '{s+=$1} END {print s}' "$ASIMOV_SIZE_LOG")
            total_human="$(format_size_kb "$total_kb")"
            msg="${verb} ${excluded_count} directories, totalling ${total_human}."
        else
            msg="${verb} ${excluded_count} directories."
        fi
    fi

    printf '\n%s%s%s\n' "$ASIMOV_COLOR_SUCCESS" "$msg" "$ASIMOV_COLOR_RESET"
}

# --- Main: build find params, run find (or use cache), process paths, then fixed dirs, then summary ---

if [[ ! -d "$ASIMOV_ROOT" ]]; then
    echo "asimov: root directory does not exist or is not a directory: $ASIMOV_ROOT" >&2
    exit 1
fi

# Determine whether to use the cache or do a full scan.
# Cache is read when the cache file exists and reads aren't disabled
# (--no-read-cache / --full-scan / --no-cache force a full scan).
use_cache=false
if [[ -z "$ASIMOV_NO_READ_CACHE" && -f "$ASIMOV_PATH_CACHE" ]]; then
    use_cache=true
fi

if [[ "$use_cache" == true ]]; then
    # --- Cached run ---
    [[ -z "$ASIMOV_QUIET" ]] && printf '\n%s⏳ Using cached paths…%s\n' \
        "$ASIMOV_COLOR_INFO" "$ASIMOV_COLOR_RESET"

    # Read cache, filter stale/out-of-scope paths, collect valid ones
    cached_valid="$(mktemp)"
    read_path_cache > "$cached_valid"
    verbose_timing "Cache read: $(wc -l < "$cached_valid" | tr -d ' ') valid paths"

    # Incremental discovery via Spotlight
    [[ -z "$ASIMOV_QUIET" ]] && printf '%s🔍 Checking for new projects via Spotlight…%s\n' \
        "$ASIMOV_COLOR_INFO" "$ASIMOV_COLOR_RESET"
    new_paths="$(mktemp)"
    discover_new_paths_via_mdfind "$cached_valid" > "$new_paths"
    verbose_timing "Spotlight discovery: $(wc -l < "$new_paths" | tr -d ' ') new paths"

    # Exclude all paths (cached + new), deduped to remove nested redundancies.
    # Time Machine exclusions are recursive, so excluding a parent covers descendants.
    [[ -z "$ASIMOV_QUIET" ]] && printf '%s📦 Processing matches…%s\n' \
        "$ASIMOV_COLOR_INFO" "$ASIMOV_COLOR_RESET"
    deduped="$(mktemp)"
    { cat "$cached_valid"; cat "$new_paths"; } | sort -u | dedup_nested_paths > "$deduped"
    total_before=$(( $(wc -l < "$cached_valid" | tr -d ' ') + $(wc -l < "$new_paths" | tr -d ' ') ))
    total_after=$(wc -l < "$deduped" | tr -d ' ')
    verbose_timing "Dedup: ${total_before} paths → ${total_after} (removed $((total_before - total_after)) nested)"
    cat "$deduped" | exclude_paths_from_stdin
    rm -f "$deduped"

    # Update cache: append new discoveries to existing cache, then sort/dedup/prune
    while IFS= read -r path; do
        append_path_to_cache "$path"
    done < "$new_paths"
    finalize_path_cache
    verbose_timing "Cache finalized"

    rm -f "$cached_valid" "$new_paths"
else
    # --- Full scan ---
    [[ -z "$ASIMOV_QUIET" ]] && printf '\n%s⏳ Scanning for dependency directories…%s\n' \
        "$ASIMOV_COLOR_INFO" "$ASIMOV_COLOR_RESET"

    build_find_skip_params
    build_find_vendor_params
    verbose_timing "Find parameters built"

    [[ -z "$ASIMOV_QUIET" ]] && printf '%s📦 Processing matches…%s\n' \
        "$ASIMOV_COLOR_INFO" "$ASIMOV_COLOR_RESET"

    # Stream find output: tee writes to cache incrementally, pipe feeds exclusion.
    # On interrupt, tee has already appended every path find emitted, so the
    # next run can use the partial cache.
    verbose_timing "Starting find traversal of ${ASIMOV_SCAN_DIR}…"
    if [[ -z "$ASIMOV_DRY_RUN" && -z "$ASIMOV_NO_WRITE_CACHE" ]]; then
        init_path_cache
        { find "${ASIMOV_SCAN_DIR}" \( "${find_parameters_skip[@]}" \) \( -false "${find_parameters_vendor[@]}" \) \
            || true; } | tee -a "$ASIMOV_PATH_CACHE" | exclude_paths_from_stdin
        verbose_timing "Find + exclude complete"
        finalize_path_cache
        verbose_timing "Cache finalized"
    else
        { find "${ASIMOV_SCAN_DIR}" \( "${find_parameters_skip[@]}" \) \( -false "${find_parameters_vendor[@]}" \) \
            || true; } | exclude_paths_from_stdin
        verbose_timing "Find + exclude complete"
    fi
fi

# Exclude built-in fixed dirs only when enabled via config.
if [[ "$ASIMOV_CONFIG_FIXED_DIRS_ENABLED" == "true" ]]; then
    [[ -z "$ASIMOV_QUIET" ]] && printf '\n%s💾 Excluding known cache directories…%s\n' \
        "$ASIMOV_COLOR_INFO" "$ASIMOV_COLOR_RESET"
    for fixed_dir in "${ASIMOV_FIXED_DIRS[@]}"; do
        if [[ -d "$fixed_dir" ]]; then
            echo "$fixed_dir"
        fi
    done | exclude_paths_from_stdin
fi

# Always process extra fixed dirs from config (user explicitly wants them).
if [[ ${#ASIMOV_CONFIG_EXTRA_FIXED_DIRS[@]} -gt 0 ]]; then
    [[ -z "$ASIMOV_QUIET" ]] && printf '\n%s⚙️  Excluding user-configured directories…%s\n' \
        "$ASIMOV_COLOR_INFO" "$ASIMOV_COLOR_RESET"
    for fixed_dir in "${ASIMOV_CONFIG_EXTRA_FIXED_DIRS[@]}"; do
        if [[ -d "$fixed_dir" ]]; then
            echo "$fixed_dir"
        fi
    done | exclude_paths_from_stdin
fi

verbose_timing "All exclusions processed"
[[ -z "$ASIMOV_QUIET" ]] && print_exclusion_summary
exit 0
