#!/bin/ash

check_required_file() {
    local file="$1"

    if [ ! -r "$file" ]; then
        echo "Error: required file '$file' is missing or not readable" >&2
        exit 1
    fi
}

NETSHIFT_LIB="/usr/lib/netshift"
check_required_file /lib/functions.sh
check_required_file /lib/config/uci.sh
check_required_file /lib/functions/network.sh
check_required_file "$NETSHIFT_LIB/constants.sh"
check_required_file "$NETSHIFT_LIB/nft.sh"
check_required_file "$NETSHIFT_LIB/helpers.sh"
check_required_file "$NETSHIFT_LIB/sing_box_config_manager.sh"
check_required_file "$NETSHIFT_LIB/sing_box_config_facade.sh"
check_required_file "$NETSHIFT_LIB/logging.sh"
check_required_file "$NETSHIFT_LIB/rulesets.sh"
check_required_file "$NETSHIFT_LIB/updater.sh"
. /lib/functions.sh
. /lib/config/uci.sh
. /lib/functions/network.sh
. "$NETSHIFT_LIB/constants.sh"
. "$NETSHIFT_LIB/nft.sh"
. "$NETSHIFT_LIB/helpers.sh"
. "$NETSHIFT_LIB/sing_box_config_manager.sh"
. "$NETSHIFT_LIB/sing_box_config_facade.sh"
. "$NETSHIFT_LIB/logging.sh"
. "$NETSHIFT_LIB/rulesets.sh"
. "$NETSHIFT_LIB/updater.sh"

config_load "$NETSHIFT_CONFIG"

check_requirements() {
    log "Check Requirements"

    local sing_box_version jq_version coreutils_base64_version
    sing_box_version="$(sing-box version | head -n1 | awk '{print $3}')"
    jq_version="$(jq --version | awk -F- '{print $2}')"
    coreutils_base64_version="$(base64 --version | head -n1 | awk '{print $4}')"

    if [ -z "$sing_box_version" ]; then
        log "Package 'sing-box' is not installed. Aborted." "error"
        exit 1
    else
        if ! is_min_package_version "$sing_box_version" "$SB_REQUIRED_VERSION"; then
            log "Package 'sing-box' version ($sing_box_version) is lower than the required minimum ($SB_REQUIRED_VERSION). Update sing-box: opkg update && opkg remove sing-box && opkg install sing-box. Aborted." "error"
            exit 1
        fi

        if ! service_exists "sing-box"; then
            log "Service 'sing-box' is missing. Please install the official package to ensure the service is available. Aborted." "error"
            exit 1
        fi
    fi

    if [ -z "$jq_version" ]; then
        log "Package 'jq' is not installed. Aborted." "error"
        exit 1
    elif ! is_min_package_version "$jq_version" "$JQ_REQUIRED_VERSION"; then
        log "Package 'jq' version ($jq_version) is lower than the required minimum ($JQ_REQUIRED_VERSION). Aborted." "error"
        exit 1
    fi

    if [ -z "$coreutils_base64_version" ]; then
        log "Package 'coreutils-base64' is not installed. Aborted." "error"
        exit 1
    elif ! is_min_package_version "$coreutils_base64_version" "$COREUTILS_BASE64_REQUIRED_VERSION"; then
        log "Package 'coreutils-base64' version ($coreutils_base64_version) is lower than the required minimum ($COREUTILS_BASE64_REQUIRED_VERSION). This may cause issues when decoding base64 streams with missing padding, as automatic padding support is not available in older versions." "warn"
    fi

    if grep -qE 'doh_backup_noresolv|doh_backup_server|doh_server' /etc/config/dhcp; then
        log "Detected https-dns-proxy in DHCP config. Edit /etc/config/dhcp" "error"
    fi

    if has_outbound_section; then
        log "Outbound section found" "debug"
    else
        log "Outbound section not found. Please check your configuration file (missing proxy_string, selector_proxy_links, urltest_proxy_links, selector_proxy_links_text, urltest_proxy_links_text, subscription_url, outbound_json, or interface according to connection_type/proxy_config_type). Aborted." "error"
        exit 1
    fi
}

section_has_configured_outbound() {
    local section="$1"
    local connection_type proxy_config_type

    config_get connection_type "$section" "connection_type"

    case "$connection_type" in
    proxy)
        config_get proxy_config_type "$section" "proxy_config_type" "url"

        case "$proxy_config_type" in
        url)
            local proxy_string
            config_get proxy_string "$section" "proxy_string"
            [ -n "$proxy_string" ] && return 0
            ;;
        selector)
            local selector_proxy_links
            config_get selector_proxy_links "$section" "selector_proxy_links"
            [ -n "$selector_proxy_links" ] && return 0
            ;;
        urltest)
            local urltest_proxy_links
            config_get urltest_proxy_links "$section" "urltest_proxy_links"
            [ -n "$urltest_proxy_links" ] && return 0
            ;;
        selector_text)
            local selector_proxy_links_text
            config_get selector_proxy_links_text "$section" "selector_proxy_links_text"
            [ -n "$selector_proxy_links_text" ] && return 0
            ;;
        urltest_text)
            local urltest_proxy_links_text
            config_get urltest_proxy_links_text "$section" "urltest_proxy_links_text"
            [ -n "$urltest_proxy_links_text" ] && return 0
            ;;
        outbound)
            local outbound_json
            config_get outbound_json "$section" "outbound_json"
            [ -n "$outbound_json" ] && return 0
            ;;
        subscription)
            # get_subscription_urls_for_section handles both the UCI `list` shape
            # (new UI configs) and a scalar `option` shape (legacy / CLI /
            # migrated configs), so the section has a configured outbound if it
            # returns at least one URL.
            [ -n "$(get_subscription_urls_for_section "$section")" ] && return 0
            ;;
        esac
        ;;
    vpn)
        local interface
        config_get interface "$section" "interface"
        [ -n "$interface" ] && return 0
        ;;
    esac

    return 1
}

_check_outbound_section() {
    local section="$1"

    if section_has_configured_outbound "$section"; then
        section_exists=0
    fi
}

has_outbound_section() {
    local section_exists=1

    config_foreach _check_outbound_section "section"

    return $section_exists
}

# Stable, filesystem-safe subkey for a subscription URL within a section. A
# section may now list MULTIPLE subscription_url entries; each feed keeps its
# own independent download/UA/rejected/json cache keyed by this hash so the
# feeds never collide and one bad feed cannot poison another. md5sum is
# busybox-safe and the URL bytes are opaque user text (never parsed here).
get_subscription_url_hash() {
    local url="$1"

    printf '%s' "$url" | md5sum 2>/dev/null | awk '{print $1}'
}

# Subscription cache-path builders. The optional second argument is the per-URL
# hash (see get_subscription_url_hash): when present the path is keyed
# "${section}.<urlhash>.<ext>"; when absent it falls back to the legacy bare
# "${section}.<ext>" path (still used by migrate_subscription_cache_from_tmp and
# the legacy-cleanup reaper). Multi-URL feeds always pass a hash.
get_subscription_json_path() {
    local section="$1"
    local urlhash="$2"

    echo "$SUBSCRIPTION_CACHE_FOLDER/${section}${urlhash:+.$urlhash}.json"
}

get_subscription_url_cache_path() {
    local section="$1"
    local urlhash="$2"

    echo "$SUBSCRIPTION_CACHE_FOLDER/${section}${urlhash:+.$urlhash}.url"
}

get_subscription_rejected_cache_path() {
    local section="$1"
    local urlhash="$2"

    echo "$SUBSCRIPTION_CACHE_FOLDER/${section}${urlhash:+.$urlhash}.rejected"
}

# Path of the cached "winning" User-Agent for a subscription source: the first
# candidate that previously produced valid outbounds. Tried first on the next
# refresh so we don't re-probe the whole whitelist every time.
get_subscription_user_agent_cache_path() {
    local section="$1"
    local urlhash="$2"

    echo "$SUBSCRIPTION_CACHE_FOLDER/${section}${urlhash:+.$urlhash}.user_agent"
}

# Collect a section's subscription_url entries (a UCI list; a legacy / CLI /
# migrated `option subscription_url` is a scalar that config_list_foreach does
# NOT iterate — get_subscription_urls_for_section handles that shape with a
# scalar fallback) into the newline-delimited global SUBSCRIPTION_URLS_COLLECTED.
# URLs are opaque user text and may contain shell-special chars, so they are
# accumulated newline-delimited (URLs cannot contain a newline) and consumers
# read them with `while IFS= read -r`, never via word-splitting.
_collect_subscription_url_handler() {
    local url="$1"

    [ -n "$url" ] || return 0
    if [ -z "$SUBSCRIPTION_URLS_COLLECTED" ]; then
        SUBSCRIPTION_URLS_COLLECTED="$url"
    else
        SUBSCRIPTION_URLS_COLLECTED="$SUBSCRIPTION_URLS_COLLECTED
$url"
    fi
}

get_subscription_urls_for_section() {
    local section="$1"
    local scalar_url

    SUBSCRIPTION_URLS_COLLECTED=""
    config_list_foreach "$section" "subscription_url" _collect_subscription_url_handler

    # Backward compat: legacy / CLI / podkop-migrated configs store
    # subscription_url as a scalar `option` (not a `list`). config_list_foreach
    # iterates ONLY list values, so it returns nothing for a scalar option. Fall
    # back to a scalar read and treat it as a 1-element list. (PROVEN on hardware:
    # config_list_foreach over an option => empty; config_get => the value.) This
    # is the load-bearing fix and must stand alone even when the option->list
    # migration is skipped (read-only fs / uci failure).
    if [ -z "$SUBSCRIPTION_URLS_COLLECTED" ]; then
        config_get scalar_url "$section" "subscription_url"
        [ -n "$scalar_url" ] && _collect_subscription_url_handler "$scalar_url"
    fi

    printf '%s' "$SUBSCRIPTION_URLS_COLLECTED"
}

# Remove any stale legacy bare-"${section}.<ext>" cache files left over from
# before per-URL hashing. We now ALWAYS hash (uniform code path), so a bare file
# is never read again; reaping it keeps the cache dir from accumulating orphans
# and prevents subscription_cache_is_usable ever picking up a stale bare body.
reap_legacy_subscription_cache_files() {
    local section="$1"

    rm -f \
        "$(get_subscription_json_path "$section")" \
        "$(get_subscription_url_cache_path "$section")" \
        "$(get_subscription_rejected_cache_path "$section")" \
        "$(get_subscription_user_agent_cache_path "$section")" \
        2>/dev/null
}

ensure_subscription_cache_dir() {
    local state_dir_created=0 cache_dir_created=0
    local mkdir_errfile mkdir_rc

    [ -d "$NETSHIFT_STATE_DIR" ] || state_dir_created=1
    [ -d "$SUBSCRIPTION_CACHE_FOLDER" ] || cache_dir_created=1
    mkdir_errfile="/tmp/netshift-subscription-cache-mkdir.$$"
    if mkdir -p "$SUBSCRIPTION_CACHE_FOLDER" 2>"$mkdir_errfile"; then
        rm -f "$mkdir_errfile"
    else
        mkdir_rc=$?
        if [ -d "$SUBSCRIPTION_CACHE_FOLDER" ]; then
            log "Subscription cache directory '$SUBSCRIPTION_CACHE_FOLDER' already exists; continuing after mkdir rc=$mkdir_rc" "warn"
            rm -f "$mkdir_errfile"
        else
            local mkdir_err
            mkdir_err="$(tr '\n' ' ' < "$mkdir_errfile" 2>/dev/null | sed 's/[[:space:]][[:space:]]*/ /g; s/^ //; s/ $//' | cut -c1-220)"
            rm -f "$mkdir_errfile"
            log "Failed to prepare subscription cache directory '$SUBSCRIPTION_CACHE_FOLDER': mkdir rc=$mkdir_rc, state_dir_created=$state_dir_created, cache_dir_created=$cache_dir_created, error=\"${mkdir_err:-no stderr}\"" "error"
            return 1
        fi
    fi

    if [ "$state_dir_created" -eq 1 ] || [ "$cache_dir_created" -eq 1 ]; then
        chmod 700 "$NETSHIFT_STATE_DIR" 2>/dev/null
        chmod 700 "$SUBSCRIPTION_CACHE_FOLDER" 2>/dev/null
    fi
}

migrate_subscription_cache_from_tmp() {
    local src_json src_url src_section src_url_value dst_url dst_json dst_url_cache urlhash

    [ -d "$TMP_SUBSCRIPTION_FOLDER" ] || return 0
    ensure_subscription_cache_dir || return 0

    # Legacy tmp caches are bare-keyed "<section>.json" (one feed per section).
    # We now always hash per URL, so migrate each usable tmp body to the hashed
    # destination of the URL it belongs to: prefer the URL recorded in the tmp
    # "<section>.url" sidecar, else fall back to the section's first configured
    # subscription_url. This keeps a legacy single-feed section working across an
    # upgrade without an immediate re-download.
    for src_json in "$TMP_SUBSCRIPTION_FOLDER"/*.json; do
        [ -e "$src_json" ] || continue
        src_section="$(basename "${src_json%.json}")"
        src_url="${src_json%.json}.url"

        subscription_cache_is_usable "$src_json" || continue

        src_url_value="$(cat "$src_url" 2>/dev/null)"
        if [ -z "$src_url_value" ]; then
            src_url_value="$(get_subscription_urls_for_section "$src_section" | head -n 1)"
        fi
        [ -n "$src_url_value" ] || continue

        urlhash="$(get_subscription_url_hash "$src_url_value")"
        [ -n "$urlhash" ] || continue
        dst_json="$(get_subscription_json_path "$src_section" "$urlhash")"
        dst_url_cache="$(get_subscription_url_cache_path "$src_section" "$urlhash")"

        if [ ! -e "$dst_json" ]; then
            cp "$src_json" "$dst_json" 2>/dev/null
            printf '%s' "$src_url_value" > "$dst_url_cache" 2>/dev/null
            chmod 600 "$dst_json" "$dst_url_cache" 2>/dev/null
            log "Migrated subscription cache for section '$src_section' to per-URL persistent storage" "info"
        fi
    done
}

mark_subscription_outbound_unavailable() {
    local section="$1"
    local keyword_filter_active="${2:-0}"
    local url urlhash subscription_json_path rejected_cache_path rejected_hash

    case " $SUBSCRIPTION_UNAVAILABLE_SECTIONS " in
    *" $section "*) ;;
    *) SUBSCRIPTION_UNAVAILABLE_SECTIONS="$SUBSCRIPTION_UNAVAILABLE_SECTIONS $section" ;;
    esac

    if [ "$keyword_filter_active" -eq 1 ]; then
        # The empty result came from the user's keyword filter, not from a bad
        # feed: the bodies themselves may be perfectly valid. Recording their
        # hashes here would poison the per-URL caches and wedge future downloads
        # (return 14 loop), and would survive the user loosening the filter. So
        # never write the rejected-hash for this case, and proactively clear any
        # stale per-URL rejected hash so a feed poisoned by an earlier
        # empty-filter run self-heals on this pass.
        get_subscription_urls_for_section "$section" | while IFS= read -r url; do
            [ -n "$url" ] || continue
            urlhash="$(get_subscription_url_hash "$url")"
            rm -f "$(get_subscription_rejected_cache_path "$section" "$urlhash")"
        done
        log "Subscription keyword filter for section '$section' removed all nodes; matching traffic for this section will be rejected until the filter is loosened or a matching node appears (the feeds themselves are not rejected)" "warn"
        subscription_startup_blocked=1
        return 0
    fi

    log "Subscription cache for section '$section' is unavailable; matching traffic for this section will be rejected until refresh succeeds" "warn"
    # A structurally valid subscription feed can still contain no sing-box usable
    # proxy outbounds (e.g. all statically-unsupported). Remember each usable
    # cached feed's hash PER-URL so the retry worker does not persist, restart
    # and reject exactly the same unusable feed in a flash-writing loop; keeping
    # this per-URL means one bad feed cannot poison another feed's retry state.
    get_subscription_urls_for_section "$section" | while IFS= read -r url; do
        [ -n "$url" ] || continue
        urlhash="$(get_subscription_url_hash "$url")"
        subscription_json_path="$(get_subscription_json_path "$section" "$urlhash")"
        rejected_cache_path="$(get_subscription_rejected_cache_path "$section" "$urlhash")"
        [ -s "$subscription_json_path" ] || continue
        rejected_hash="$(md5sum "$subscription_json_path" 2>/dev/null | awk '{print $1}')"
        if [ -n "$rejected_hash" ] && [ "$(cat "$rejected_cache_path" 2>/dev/null)" != "$rejected_hash" ]; then
            printf '%s' "$rejected_hash" > "${rejected_cache_path}.tmp.$$" && mv "${rejected_cache_path}.tmp.$$" "$rejected_cache_path"
            chmod 600 "$rejected_cache_path" 2>/dev/null
        fi
    done
    subscription_startup_blocked=1
}

subscription_outbound_is_unavailable() {
    local section="$1"

    case " $SUBSCRIPTION_UNAVAILABLE_SECTIONS " in
    *" $section "*) return 0 ;;
    esac
    return 1
}

# Mark a non-subscription proxy section (url/selector/urltest) as having no
# usable outbound, so sing_box_configure_route emits a reject route rule for it
# (like the subscription-unavailable path) instead of a route rule pointing at a
# never-created outbound tag. Used when every configured link for the section
# was an unsupported scheme and got skipped by the facade. This reuses the
# existing SUBSCRIPTION_UNAVAILABLE_SECTIONS mechanism (subscription_outbound_is_unavailable),
# but does NOT touch any per-URL subscription rejected-hash cache (that is
# subscription-only). The section degrades to "matching traffic rejected"
# without aborting generation of the rest of the config.
mark_section_outbound_unavailable() {
    local section="$1"

    case " $SUBSCRIPTION_UNAVAILABLE_SECTIONS " in
    *" $section "*) ;;
    *) SUBSCRIPTION_UNAVAILABLE_SECTIONS="$SUBSCRIPTION_UNAVAILABLE_SECTIONS $section" ;;
    esac
}

get_subscription_download_proxy_address() {
    local section="$1"
    local phase="$2"
    local download_lists_via_proxy download_lists_via_proxy_section

    config_get_bool download_lists_via_proxy "settings" "download_lists_via_proxy" 0
    [ "$download_lists_via_proxy" -eq 1 ] || return 0

    config_get download_lists_via_proxy_section "settings" "download_lists_via_proxy_section"
    if [ -z "$download_lists_via_proxy_section" ]; then
        log "download_lists_via_proxy is enabled but no proxy section is selected; using direct mode for $phase subscription download" "warn"
        return 0
    fi

    if [ "$phase" = "bootstrap" ]; then
        if [ "$download_lists_via_proxy_section" = "$section" ]; then
            log "Cannot bootstrap subscription for section '$section' through itself before cache exists; using direct mode" "warn"
        else
            log "download_lists_via_proxy is configured, but bootstrap subscription download for section '$section' must use direct mode until sing-box starts" "info"
        fi
        return 0
    fi

    if [ "$download_lists_via_proxy_section" = "$section" ]; then
        if section_has_usable_subscription_cache "$section"; then
            log "Updating subscription for section '$section' through its currently active cached proxy" "info"
            echo "$(get_service_proxy_address 2>/dev/null || echo '')"
            return 0
        fi

        log "Subscription section '$section' is selected as its own download proxy, but no usable cache exists yet; falling back to direct mode for bootstrap" "warn"
        return 0
    fi

    local selected_connection_type selected_proxy_config_type
    config_get selected_connection_type "$download_lists_via_proxy_section" "connection_type"
    config_get selected_proxy_config_type "$download_lists_via_proxy_section" "proxy_config_type"
    if [ "$selected_connection_type" = "proxy" ] && [ "$selected_proxy_config_type" = "subscription" ] && \
        ! section_has_usable_subscription_cache "$download_lists_via_proxy_section"; then
        log "Selected download proxy section '$download_lists_via_proxy_section' has no usable subscription cache; using direct mode for recovery of '$section'" "warn"
        return 0
    fi

    echo "$(get_service_proxy_address 2>/dev/null || echo '')"
}

subscription_cache_is_usable() {
    local subscription_json_path="$1"
    local rejected_cache_path current_hash rejected_hash has_proxy_outbound

    [ -s "$subscription_json_path" ] || return 1

    validate_subscription_file "$subscription_json_path" || return 1

    # The rejected-hash veto must only be able to reject a body that does NOT
    # actually contain usable proxy outbounds (the genuine flash-loop guard). A
    # structurally valid body with >=1 proxy outbound is a real subscription and
    # is always usable, regardless of any stale rejected-hash (e.g. one poisoned
    # by an over-strict keyword filter before task-011). Same predicate as the
    # batch (candidate = not selector/urltest/direct/dns/block); no Oniguruma.
    has_proxy_outbound="$(jq -e '
        [.outbounds[]? | select(
            .type != "selector" and
            .type != "urltest" and
            .type != "direct" and
            .type != "dns" and
            .type != "block"
        )] | length > 0
    ' "$subscription_json_path" >/dev/null 2>&1 && echo 1 || echo 0)"
    [ "$has_proxy_outbound" = "1" ] && return 0

    rejected_cache_path="${subscription_json_path%.json}.rejected"
    if [ -s "$rejected_cache_path" ]; then
        current_hash="$(md5sum "$subscription_json_path" 2>/dev/null | awk '{print $1}')"
        rejected_hash="$(cat "$rejected_cache_path" 2>/dev/null)"
        if [ -n "$current_hash" ] && [ "$current_hash" = "$rejected_hash" ]; then
            return 1
        fi
    fi

    return 0
}

# True (0) if ANY of the section's per-URL subscription caches is usable. Used
# wherever a single-feed "section has a working cache" decision is needed (e.g.
# download-proxy selection); the section is considered to have a usable cache as
# soon as one feed does. URLs are written to a temp file and read with a plain
# `while read` (NOT a pipe) so the `found` flag survives in this shell.
section_has_usable_subscription_cache() {
    local section="$1"
    local url urlhash found urls_tmp

    found=1
    urls_tmp="$(mktemp "${TMPDIR:-/tmp}/netshift-sub-urls.XXXXXX")" || return 1
    get_subscription_urls_for_section "$section" > "$urls_tmp"
    while IFS= read -r url || [ -n "$url" ]; do
        [ -n "$url" ] || continue
        urlhash="$(get_subscription_url_hash "$url")"
        if subscription_cache_is_usable "$(get_subscription_json_path "$section" "$urlhash")"; then
            found=0
            break
        fi
    done < "$urls_tmp"
    rm -f "$urls_tmp"

    return "$found"
}

wait_for_subscription_connectivity() {
    local section="$1"
    local subscription_url="$2"
    local service_proxy_address="$3"
    local attempts="${4:-12}"
    local wait="${5:-5}"
    local timeout="${6:-5}"
    local attempt

    for attempt in $(seq 1 "$attempts"); do
        if check_subscription_connectivity "$subscription_url" "$service_proxy_address" 1 0 "$timeout"; then
            log "Subscription connectivity check passed for section '$section'" "info"
            return 0
        fi

        log "Subscription source is unavailable for section '$section' [$attempt/$attempts]" "warn"
        [ "$attempt" -lt "$attempts" ] && sleep "$wait"
    done

    log "Subscription connectivity check failed for section '$section' after $attempts attempts" "error"
    return 1
}

download_subscription_into_cache() {
    local section="$1"
    local subscription_url="$2"
    local subscription_json_path="$3"
    local subscription_url_cache_path="$4"
    local service_proxy_address="$5"
    # Optional per-URL hash subkey: keys the UA and rejected caches so each feed
    # in a multi-URL section keeps independent state. Empty = legacy bare paths.
    local urlhash="$6"
    local tmpfile persist_tmpfile url_tmpfile rejected_cache_path tmp_hash rejected_hash validation_reason file_size fallback_tmp
    local configured_user_agent user_agent_cache_path cached_user_agent candidates_file
    local effective_user_agent download_ok winning_user_agent ua_tmpfile
    local subscription_insecure subscription_format_preference

    ensure_subscription_cache_dir || {
        log "Failed to prepare persistent subscription cache directory '$SUBSCRIPTION_CACHE_FOLDER' for section '$section'" "error"
        return 10
    }
    mkdir -p "$TMP_SUBSCRIPTION_DOWNLOAD_FOLDER" || {
        log "Failed to create temporary subscription download directory '$TMP_SUBSCRIPTION_DOWNLOAD_FOLDER' for section '$section'" "error"
        return 11
    }
    tmpfile="$(mktemp "$TMP_SUBSCRIPTION_DOWNLOAD_FOLDER/${section}.download.XXXXXX")" || {
        log "Failed to create temporary subscription download file in '$TMP_SUBSCRIPTION_DOWNLOAD_FOLDER' for section '$section'" "error"
        return 11
    }

    # User-Agent fallback. Panels often key the returned body format off the
    # client User-Agent, so when none is configured we probe a whitelist of
    # well-known clients and keep the first that yields valid outbounds. The
    # previously successful UA (cached) is tried first to avoid re-probing.
    user_agent_cache_path="$(get_subscription_user_agent_cache_path "$section" "$urlhash")"
    configured_user_agent="$(uci -q get "netshift.${section}.subscription_user_agent" 2>/dev/null)"
    cached_user_agent="$(cat "$user_agent_cache_path" 2>/dev/null)"
    # Per-section format preference reorders the UA probe (auto|xray|singbox).
    # Empty/unset -> auto (today's order); unknown values are treated as auto by
    # the builder. The probe loop still keeps the first body with valid outbounds.
    subscription_format_preference="$(uci -q get "netshift.${section}.subscription_format_preference" 2>/dev/null)"
    [ -n "$subscription_format_preference" ] || subscription_format_preference="auto"
    candidates_file="${tmpfile}.ua"
    if ! build_subscription_user_agent_candidates "$configured_user_agent" "$cached_user_agent" "$subscription_format_preference" > "$candidates_file"; then
        log "Failed to build subscription User-Agent candidate list for section '$section'" "error"
        rm -f "$tmpfile" "$candidates_file"
        return 11
    fi

    # Opt-in per-section insecure TLS fetch (default OFF = secure). When enabled
    # download_subscription adds wget --no-check-certificate so IP-host panels
    # with invalid/self-signed/missing-SAN certs work. Log once (redacted).
    subscription_insecure="$(uci -q get "netshift.${section}.subscription_insecure" 2>/dev/null)"
    [ -n "$subscription_insecure" ] || subscription_insecure=0
    if [ "$subscription_insecure" = "1" ]; then
        log "Subscription fetch for section '$section' uses --no-check-certificate (TLS verification disabled): url=$(redact_url_for_log "$subscription_url")" "warn"
    fi

    download_ok=0
    winning_user_agent=""
    fallback_tmp="${tmpfile}.fb"
    # Each candidate gets the full download (with its own retries) + validate +
    # fallback-normalize pipeline. First success wins.
    while IFS= read -r effective_user_agent || [ -n "$effective_user_agent" ]; do
        [ -n "$effective_user_agent" ] || continue

        log "Trying subscription User-Agent for section '$section': $effective_user_agent" "info"

        if ! download_subscription "$subscription_url" "$tmpfile" "$service_proxy_address" 3 2 10 "$effective_user_agent" "$subscription_insecure"; then
            log "Subscription download failed for section '$section' with User-Agent '$effective_user_agent'; trying next candidate" "warn"
            continue
        fi

        # Some panels return a gzip-compressed body unconditionally (we send no
        # Accept-Encoding and busybox wget does not transparently decompress).
        # Decompress in place before validate/normalize so every consumer sees
        # text. Best-effort: a plain-text body is left untouched.
        maybe_gunzip_subscription_file "$tmpfile"
        if subscription_body_is_binary "$tmpfile"; then
            log "Subscription body for section '$section' is binary/undecodable after gzip handling (not text, not gzip) with User-Agent '$effective_user_agent'; trying next candidate" "warn"
            continue
        fi

        file_size="$(wc -c < "$tmpfile" 2>/dev/null | tr -d ' ')"
        log "Downloaded subscription body for section '$section': bytes=${file_size:-unknown}, User-Agent='$effective_user_agent'" "debug"

        if validate_subscription_file "$tmpfile"; then
            download_ok=1
            winning_user_agent="$effective_user_agent"
            break
        fi

        # Fallback: provider returned base64 / plaintext key list or Xray JSON
        # instead of a sing-box config.
        if normalize_subscription_to_singbox "$tmpfile" "$fallback_tmp" "$section" && validate_subscription_file "$fallback_tmp"; then
            mv -f "$fallback_tmp" "$tmpfile"
            log "Subscription for section '$section' parsed via fallback with User-Agent '$effective_user_agent'" "info"
            download_ok=1
            winning_user_agent="$effective_user_agent"
            break
        fi
        rm -f "$fallback_tmp"

        validation_reason="$(describe_subscription_validation_failure "$tmpfile")"
        log "Downloaded subscription for section '$section' is invalid with User-Agent '$effective_user_agent': ${validation_reason:-unknown validation error}; trying next candidate" "warn"
    done < "$candidates_file"
    rm -f "$candidates_file"

    if [ "$download_ok" -ne 1 ]; then
        log "No subscription User-Agent candidate produced valid outbounds for section '$section'" "error"
        rm -f "$tmpfile" "$fallback_tmp"
        return 13
    fi

    # Persist the winning User-Agent so the next refresh tries it first.
    if [ -n "$winning_user_agent" ]; then
        ua_tmpfile="${user_agent_cache_path}.tmp.$$"
        if printf '%s' "$winning_user_agent" > "$ua_tmpfile" && mv "$ua_tmpfile" "$user_agent_cache_path"; then
            chmod 600 "$user_agent_cache_path" 2>/dev/null
        else
            rm -f "$ua_tmpfile"
        fi
    fi

    rejected_cache_path="$(get_subscription_rejected_cache_path "$section" "$urlhash")"
    tmp_hash="$(md5sum "$tmpfile" 2>/dev/null | awk '{print $1}')"
    rejected_hash="$(cat "$rejected_cache_path" 2>/dev/null)"
    if [ -n "$tmp_hash" ] && [ "$tmp_hash" = "$rejected_hash" ]; then
        log "Downloaded subscription for section '$section' is unchanged and was previously rejected because it contains no usable sing-box outbounds" "warn"
        rm -f "$tmpfile"
        return 14
    fi

    if [ -f "$subscription_json_path" ] && cmp -s "$tmpfile" "$subscription_json_path"; then
        rm -f "$tmpfile"
        if [ "$(cat "$subscription_url_cache_path" 2>/dev/null)" != "$subscription_url" ]; then
            url_tmpfile="${subscription_url_cache_path}.tmp.$$"
            if printf '%s' "$subscription_url" > "$url_tmpfile" && mv "$url_tmpfile" "$subscription_url_cache_path"; then
                chmod 600 "$subscription_url_cache_path" 2>/dev/null
            else
                log "Subscription content for section '$section' is unchanged, but URL metadata could not be persisted; the next refresh may download it again" "warn"
                rm -f "$url_tmpfile"
            fi
        fi
        log "Subscription for section '$section' is unchanged" "info"
        return 2
    fi

    persist_tmpfile="${subscription_json_path}.tmp.$$"
    cp "$tmpfile" "$persist_tmpfile" && mv "$persist_tmpfile" "$subscription_json_path" || {
        log "Failed to persist subscription cache for section '$section' to '$subscription_json_path'" "error"
        rm -f "$tmpfile" "$persist_tmpfile"
        return 15
    }
    rm -f "$tmpfile"
    rm -f "$rejected_cache_path"

    url_tmpfile="${subscription_url_cache_path}.tmp.$$"
    printf '%s' "$subscription_url" > "$url_tmpfile" && mv "$url_tmpfile" "$subscription_url_cache_path" || {
        # The valid JSON cache has already been atomically installed. Treat
        # metadata failure as non-fatal so recovery reloads and applies it;
        # only refresh deduplication is degraded until metadata can be written.
        log "Subscription cache for section '$section' was updated, but URL metadata could not be persisted; applying the cache and retrying metadata on a later refresh" "warn"
        rm -f "$url_tmpfile"
    }
    chmod 600 "$subscription_json_path" "$subscription_url_cache_path" 2>/dev/null
    return 0
}

prepare_subscription_cache_for_startup() {
    local section="$1"
    local connection_type proxy_config_type urls_tmp url urlhash
    local subscription_json_path subscription_url_cache_path cached_subscription_url
    local service_proxy_address had_usable_cache cache_needs_refresh
    local _download_lists_via_proxy section_any_usable url_count

    config_get connection_type "$section" "connection_type"
    [ "$connection_type" = "proxy" ] || return 0

    config_get proxy_config_type "$section" "proxy_config_type"
    [ "$proxy_config_type" = "subscription" ] || return 0

    ensure_subscription_cache_dir || true
    migrate_subscription_cache_from_tmp
    # Reap any stale legacy bare-"${section}.<ext>" cache so it can never be read
    # again (we always hash now).
    reap_legacy_subscription_cache_files "$section"

    urls_tmp="$(mktemp "${TMPDIR:-/tmp}/netshift-startup-urls.XXXXXX")" || {
        log "Failed to create temporary URL list for section '$section'" "error"
        return 0
    }
    get_subscription_urls_for_section "$section" > "$urls_tmp"
    url_count=0
    while IFS= read -r url || [ -n "$url" ]; do
        [ -n "$url" ] && url_count=$((url_count + 1))
    done < "$urls_tmp"

    if [ "$url_count" -eq 0 ]; then
        rm -f "$urls_tmp"
        log "Subscription URL is not set for section '$section'. Aborted." "fatal"
        exit 1
    fi

    config_get_bool _download_lists_via_proxy "settings" "download_lists_via_proxy" 0
    if [ "$_download_lists_via_proxy" -eq 1 ]; then
        log "download_lists_via_proxy is set, but sing-box is not running yet during startup. Bootstrapping subscription for section '$section' over a direct connection" "info"
    fi

    # Best-effort: each URL is downloaded independently; a single dead feed must
    # not abort the others. The section is "available" if at least one feed has
    # a usable cache after this pass.
    section_any_usable=0
    while IFS= read -r url || [ -n "$url" ]; do
        [ -n "$url" ] || continue
        urlhash="$(get_subscription_url_hash "$url")"
        subscription_json_path="$(get_subscription_json_path "$section" "$urlhash")"
        subscription_url_cache_path="$(get_subscription_url_cache_path "$section" "$urlhash")"

        had_usable_cache=0
        if subscription_cache_is_usable "$subscription_json_path"; then
            had_usable_cache=1
        else
            rm -f "$subscription_json_path"
        fi

        cached_subscription_url=""
        [ -f "$subscription_url_cache_path" ] && cached_subscription_url="$(cat "$subscription_url_cache_path" 2>/dev/null)"

        cache_needs_refresh=0
        if [ "$had_usable_cache" -eq 0 ] || [ "$cached_subscription_url" != "$url" ]; then
            cache_needs_refresh=1
        fi

        if [ "$cache_needs_refresh" -eq 0 ]; then
            section_any_usable=1
            continue
        fi

        # Bootstrap directly: sing-box (and its service proxy) is not running yet.
        service_proxy_address=""
        if wait_for_subscription_connectivity "$section" "$url" "$service_proxy_address" 2 2 5 &&
            download_subscription_into_cache \
                "$section" "$url" "$subscription_json_path" "$subscription_url_cache_path" "$service_proxy_address" "$urlhash"; then
            section_any_usable=1
            continue
        fi

        if [ "$had_usable_cache" -eq 1 ]; then
            log "Keeping cached subscription feed for section '$section' (url=$(redact_url_for_log "$url")) until a fresh download succeeds" "warn"
            section_any_usable=1
        else
            log "Subscription feed for section '$section' (url=$(redact_url_for_log "$url")) is not reachable and has no usable cache yet" "warn"
        fi
    done < "$urls_tmp"
    rm -f "$urls_tmp"

    if [ "$section_any_usable" -eq 0 ]; then
        log "No usable subscription cache for any feed of section '$section'; startup will continue with a temporary blocked outbound until a subscription becomes reachable" "warn"
        subscription_startup_blocked=1
    fi

    return 0
}

prepare_subscription_caches_for_startup() {
    subscription_startup_blocked=0
    config_foreach prepare_subscription_cache_for_startup "section"

    [ "$subscription_startup_blocked" -eq 0 ]
}

stop_subscription_startup_retry_worker() {
    local pidfile="/var/run/netshift_subscription_retry.pid"

    if [ -f "$pidfile" ]; then
        pid="$(cat "$pidfile" 2> /dev/null)"
        if [ -n "$pid" ] && kill -0 "$pid" 2> /dev/null; then
            kill "$pid" 2> /dev/null
            log "Stopped deferred startup recovery worker"
        fi
        rm -f "$pidfile"
    fi
}

start_subscription_startup_retry_worker() {
    local pidfile="/var/run/netshift_subscription_retry.pid"

    if [ -f "$pidfile" ]; then
        pid="$(cat "$pidfile" 2> /dev/null)"
        if [ -n "$pid" ] && kill -0 "$pid" 2> /dev/null; then
            log "Deferred startup recovery worker is already running" "debug"
            return 0
        fi
        rm -f "$pidfile"
    fi

    (
        trap 'rm -f "'"$pidfile"'"' EXIT INT TERM

        # Let the primary sing-box instance come up first, so a configured
        # service proxy can be used for the post-bootstrap refresh.
        sleep 10

        while true; do
            config_load "$NETSHIFT_CONFIG"

            # Run in a child process: a successful subscription_update performs
            # its own restart, which stops this worker via the pidfile safely.
            # Keep diagnostics visible in syslog; subscription URLs are redacted
            # in the lower-level download helpers.
            if /usr/bin/netshift subscription_update; then
                log "Deferred subscription refresh succeeded; updated configuration is being applied" "info"
                rm -f "$pidfile"
                exit 0
            fi

            log "Deferred subscription refresh has not completed yet; retrying in background" "warn"
            sleep 30
        done
    ) &

    echo $! > "$pidfile"
    log "Started deferred startup recovery worker with PID $!" "warn"
}

start_main() {
    log "Starting netshift"

    # Normalize legacy scalar `option subscription_url` to the canonical `list`
    # shape BEFORE check_requirements / config generation read the URLs. The
    # read-fallback in get_subscription_urls_for_section already covers
    # correctness; this is hygiene that converges stored configs to a list.
    migrate_legacy_subscription_url_option

    check_requirements

    migration

    config_foreach process_validate_service "section"

    br_netfilter_disable

    # Sync time for DoH/DoT
    /usr/sbin/ntpd -q -p 194.190.168.1 -p 216.239.35.0 -p 216.239.35.4 -p 162.159.200.1 -p 162.159.200.123

    sleep 1

    mkdir -p "$TMP_SING_BOX_FOLDER"
    mkdir -p "$TMP_RULESET_FOLDER"
    mkdir -p "$TMP_SUBSCRIPTION_FOLDER"
    ensure_subscription_cache_dir
    migrate_subscription_cache_from_tmp

    prepare_subscription_caches_for_startup
    if [ "$subscription_startup_blocked" -ne 0 ]; then
        log "NetShift startup continues with temporary blocked subscription outbounds until sources become reachable" "warn"
    fi
    stop_subscription_startup_retry_worker

    # base
    route_table_rule_mark
    create_nft_rules
    sing_box_configure_service

    # sing-box
    sing_box_init_config
    config_foreach add_cron_job "section"
    config_foreach add_subscription_cron_job "section"
    /etc/init.d/sing-box start

    if [ "$subscription_startup_blocked" -ne 0 ]; then
        start_subscription_startup_retry_worker
    fi

    log "Nice"
    list_update &
    echo $! > /var/run/netshift_list_update.pid
}

stop_main() {
    log "Stopping the netshift"

    stop_subscription_startup_retry_worker

    if [ -f /var/run/netshift_list_update.pid ]; then
        pid=$(cat /var/run/netshift_list_update.pid)
        if kill -0 "$pid" 2> /dev/null; then
            kill "$pid" 2> /dev/null
            log "Stopped list_update"
        fi
        rm -f /var/run/netshift_list_update.pid
    fi

    remove_cron_job

    rm -f "$TMP_RULESET_FOLDER"/*

    log "Flush nft"
    if nft list table inet "$NFT_TABLE_NAME" > /dev/null 2>&1; then
        nft delete table inet "$NFT_TABLE_NAME"
    fi

    log "Flush ip rule"
    ip -4 rule del fwmark "$NFT_FAKEIP_MARK"/"$NFT_FAKEIP_MARK" table "$RT_TABLE_NAME" priority 105 2>/dev/null
    ip -6 rule del fwmark "$NFT_FAKEIP_MARK"/"$NFT_FAKEIP_MARK" table "$RT_TABLE_NAME" priority 105 2>/dev/null

    log "Flush ip route"
    ip -4 route flush table "$RT_TABLE_NAME" 2>/dev/null
    ip -6 route flush table "$RT_TABLE_NAME" 2>/dev/null

    log "Stop sing-box"
    /etc/init.d/sing-box stop
}

start() {
    local start_rc dont_touch_dhcp
    start_main
    start_rc=$?

    if [ "$start_rc" -eq 2 ]; then
        return 0
    fi

    if [ "$start_rc" -ne 0 ]; then
        return "$start_rc"
    fi

    config_get_bool dont_touch_dhcp "settings" "dont_touch_dhcp" 0
    if [ "$dont_touch_dhcp" -eq 0 ]; then
        dnsmasq_configure force
    fi

    uci_set "netshift" "settings" "shutdown_correctly" 0
    uci commit "netshift" && config_load "$NETSHIFT_CONFIG"

    start_sing_box_monitor
}

_kill_stale_sing_box_monitors() {
    # task-036: kill ALL detached health-monitor processes, not just the one
    # currently named in MONITOR_PIDFILE.
    #
    # Why this is needed: each monitor self-writes its OWN $$ to MONITOR_PIDFILE
    # at the top of monitor_sing_box (task-035), so the pidfile only ever
    # remembers the LATEST monitor. A monitor spawned by a PRIOR reload whose pid
    # was overwritten in the pidfile is invisible to stop() (which kills only the
    # pidfile pid) → since the monitor is detached via setsid (own session,
    # reparented to init) it survives stop()/reload forever → monitors leak
    # (2-3 live monitors instead of exactly 1) after repeated reloads.
    #
    # The hidden `__monitor` subcommand is a unique, stable marker that matches
    # ONLY the detached monitor (the re-exec'd argv is
    # `/bin/ash /usr/bin/netshift __monitor`) and never the main netshift process
    # or any other invocation. We MUST exclude our OWN pid ($$) and our parent
    # ($PPID): the recovery path inside monitor_sing_box calls stop_main/start_main
    # (NOT stop()/start_sing_box_monitor), so this helper is never reached from
    # recovery — but excluding self is the belt that guarantees a monitor can
    # never kill ITSELF even if a future caller wires this into a monitor-reachable
    # path.
    local selector="/usr/bin/netshift __monitor"
    local self="$$"
    local parent="${PPID:-0}"
    local mpids mpid killed=0

    if command -v pgrep > /dev/null 2>&1; then
        # busybox pgrep supports -f (full-command-line match); already used by
        # get_system_info (`pgrep -f "sing-box"`). Capture into a var and iterate
        # with a counted `for` (no pipe) so the killed counter survives.
        mpids="$(pgrep -f "$selector" 2> /dev/null)"
    else
        # Fallback for the rare box without pgrep: ps | grep, busybox-safe. Match
        # the unique `__monitor` marker, drop the grep line itself.
        mpids="$(ps w 2> /dev/null | grep "$selector" | grep -v grep | awk '{print $1}')"
    fi

    for mpid in $mpids; do
        [ -n "$mpid" ] || continue
        # Numeric guard (defensive: ps formats vary across busybox builds).
        case "$mpid" in
            *[!0-9]*) continue ;;
        esac
        # Never kill ourselves or our parent (the init/reload action).
        [ "$mpid" = "$self" ] && continue
        [ "$mpid" = "$parent" ] && continue
        if kill -0 "$mpid" 2> /dev/null; then
            kill "$mpid" 2> /dev/null
            killed=$((killed + 1))
        fi
    done

    if [ "$killed" -gt 0 ]; then
        log "Terminated $killed stale sing-box health monitor(s)" "info"
    fi
}

start_sing_box_monitor() {
    local pidfile="$MONITOR_PIDFILE"
    local pid waited

    # task-036: reliably terminate EVERY prior monitor before spawning a new one,
    # so exactly ONE monitor exists after any number of reloads/restarts. The old
    # "return 0 if the pidfile pid is alive" guard was insufficient: a reload runs
    # stop() (kills only the pidfile pid) then start() → here. Because each
    # monitor overwrites the pidfile with its own pid, monitors from prior reloads
    # were never recorded → never killed → leaked (orphaned, reparented to init by
    # setsid). Killing ALL `__monitor` procs (excluding self/parent) closes that
    # gap. We then ALWAYS spawn a fresh monitor bound to the just-(re)started
    # sing-box, instead of skipping — a stale monitor from a prior config is not a
    # valid substitute for one bound to the new run.
    _kill_stale_sing_box_monitors
    rm -f "$pidfile"

    # CRITICAL (task-035): the health monitor is a long-lived `while true` loop.
    # When start_main runs on the reload/restart path, procd holds its init
    # service lock on fd 1000 (/tmp/lock/procd_<name>.lock). A bare
    # `monitor_sing_box &` inherits ALL open fds, including fd 1000, so the
    # monitor would hold the procd lock FOREVER and the NEXT reload/restart
    # would block on `flock 1000` indefinitely (settings never re-applied).
    #
    # Detach the monitor from procd entirely:
    #   - `setsid` puts it in its own session (no controlling terminal, not in
    #     procd's process group), and re-execs /usr/bin/netshift fresh.
    #   - `exec 1000>&-` closes the inherited procd lock fd in the child before
    #     the re-exec, so even though exec does NOT close non-CLOEXEC fds, fd
    #     1000 is already gone (harmless no-op on the plain start/boot path
    #     where the lock fd is absent).
    #   - `</dev/null >/dev/null 2>&1` drops the inherited stdio (procd's
    #     stdout/stderr pipes) so the monitor never keeps those open either.
    # The re-exec runs the hidden `__monitor` subcommand, which calls the
    # existing monitor_sing_box function (detection/recovery logic unchanged)
    # and writes its OWN pid to the pidfile (so stop() can still kill it
    # regardless of how setsid forks/execs the chain).
    setsid /bin/sh -c 'exec 1000>&- 2>/dev/null; exec /usr/bin/netshift __monitor' < /dev/null > /dev/null 2>&1 &

    # The monitor child writes its real pid to the pidfile itself; wait briefly
    # for it to appear so callers/diagnostics see a populated pidfile.
    waited=0
    while [ ! -s "$pidfile" ] && [ "$waited" -lt 20 ]; do
        sleep 0.1 2> /dev/null || sleep 1
        waited=$((waited + 1))
    done

    if [ -s "$pidfile" ]; then
        pid="$(cat "$pidfile" 2> /dev/null)"
        log "Started sing-box health monitor with PID $pid" "info"
    else
        log "Started sing-box health monitor (pid pending)" "info"
    fi
}

stop() {
    if [ -f "$MONITOR_PIDFILE" ]; then
        local monitor_pid
        monitor_pid="$(cat "$MONITOR_PIDFILE" 2>/dev/null)"
        if [ -n "$monitor_pid" ] && kill -0 "$monitor_pid" 2>/dev/null; then
            kill "$monitor_pid" 2>/dev/null
            log "Stopped sing-box health monitor" "info"
        fi
        rm -f "$MONITOR_PIDFILE"
    fi

    # task-036: the pidfile only ever names the LATEST monitor, so a leaked
    # monitor from a prior reload (detached via setsid, reparented to init) is
    # invisible above. Reap ALL detached `__monitor` processes here so a clean
    # stop leaves zero monitors running. Excludes self/parent.
    _kill_stale_sing_box_monitors

    local dont_touch_dhcp
    config_get_bool dont_touch_dhcp "settings" "dont_touch_dhcp" 0
    if [ "$dont_touch_dhcp" -eq 0 ]; then
        dnsmasq_restore
    fi

    stop_main

    uci_set "netshift" "settings" "shutdown_correctly" 1
    uci commit "netshift" && config_load "$NETSHIFT_CONFIG"
}

monitor_sing_box() {
    local crash_count=0
    local backoff=0
    local dont_touch_dhcp

    # Record our OWN pid (task-035): the monitor runs detached via setsid, so
    # the launcher cannot reliably capture the final pid of the exec/setsid
    # chain. Writing $$ here is authoritative for stop()/diagnostics.
    echo $$ > "$MONITOR_PIDFILE"

    while true; do
        sleep "$MONITOR_CHECK_INTERVAL"

        if sing_box_process_exists; then
            crash_count=0
            backoff=0
            continue
        fi

        config_load "$NETSHIFT_CONFIG"
        local shutdown_correctly
        config_get shutdown_correctly "settings" "shutdown_correctly"
        if [ "$shutdown_correctly" -eq 1 ]; then
            log "sing-box shutdown detected, exiting monitor" "info"
            break
        fi

        crash_count=$((crash_count + 1))
        log "sing-box process died unexpectedly (crash #$crash_count), restoring DNS" "warn"

        dnsmasq_restore

        if [ "$crash_count" -ge "$MONITOR_MAX_CRASHES" ]; then
            log "sing-box crashed $crash_count times consecutively, giving up. DNS has been restored." "error"
            break
        fi

        backoff=$((MONITOR_BACKOFF_BASE * (1 << (crash_count - 1))))
        [ "$backoff" -gt "$MONITOR_BACKOFF_MAX" ] && backoff="$MONITOR_BACKOFF_MAX"
        log "Waiting ${backoff}s before attempting sing-box restart" "warn"
        sleep "$backoff"

        config_load "$NETSHIFT_CONFIG"
        config_get shutdown_correctly "settings" "shutdown_correctly"
        if [ "$shutdown_correctly" -eq 1 ]; then
            log "Clean shutdown requested, exiting monitor" "info"
            break
        fi

        log "Attempting sing-box recovery restart" "warn"
        stop_main
        if start_main; then
            config_get_bool dont_touch_dhcp "settings" "dont_touch_dhcp" 0
            if [ "$dont_touch_dhcp" -eq 0 ]; then
                dnsmasq_configure force
            fi
            log "NetShift recovered successfully after sing-box crash" "info"
        else
            log "Recovery restart failed, will retry" "error"
        fi
    done

    rm -f "$MONITOR_PIDFILE"
}

sing_box_process_exists() {
    pgrep "sing-box" > /dev/null 2>&1 || pidof sing-box > /dev/null 2>&1
}

reload() {
    log "NetShift reload"
    stop
    start
}

restart() {
    log "NetShift restart"
    stop
    start
}

# Migrations and validation funcs
migration() {
    :
}

# config_foreach callback for migrate_legacy_subscription_url_option. For a
# subscription section whose subscription_url is stored as a scalar `option`
# (legacy / CLI / podkop-migrated configs) rather than a UCI `list`, rewrite it
# in place as a `list` via uci. Detects the broken shape robustly: the LIST read
# (config_list_foreach) yields nothing AND a scalar config_get is non-empty —
# exactly the option-only shape, so an already-correct list is never touched.
# Sets the module-level SUBSCRIPTION_URL_OPTION_MIGRATED flag when it changes
# anything so the caller commits + reloads exactly once. Never exits: any uci
# failure is logged at warn and skipped (the read-fallback in
# get_subscription_urls_for_section covers correctness regardless).
_migrate_legacy_subscription_url_option_handler() {
    local section="$1"
    local connection_type proxy_config_type scalar_url

    config_get connection_type "$section" "connection_type"
    [ "$connection_type" = "proxy" ] || return 0

    config_get proxy_config_type "$section" "proxy_config_type" "url"
    [ "$proxy_config_type" = "subscription" ] || return 0

    # Only the broken shape: empty via the list path but present as a scalar.
    SUBSCRIPTION_URLS_COLLECTED=""
    config_list_foreach "$section" "subscription_url" _collect_subscription_url_handler
    [ -z "$SUBSCRIPTION_URLS_COLLECTED" ] || return 0

    config_get scalar_url "$section" "subscription_url"
    [ -n "$scalar_url" ] || return 0

    # Rewrite the scalar option as a list. Use the uci_add_list SHELL HELPER
    # (from /lib/functions.sh), NOT the `uci add_list "key=value"` CLI form: the
    # CLI form splits on the FIRST `=`, so a URL with a query string (very common,
    # e.g. "...?token=abc&x=1") makes the CLI add_list fail and lose the value.
    # The helper passes the value as a separate argument, preserving `=`/`&`
    # byte-for-byte. Delete the scalar first so the result is a CLEAN single-
    # element list (adding while the scalar option still exists would duplicate
    # it into a 2-element list); if the add then fails, RESTORE the scalar option
    # so a failed migration can never leave the section with NO url. Never exits.
    uci -q delete "netshift.${section}.subscription_url" 2>/dev/null
    if uci_add_list netshift "$section" subscription_url "$scalar_url" 2>/dev/null; then
        SUBSCRIPTION_URL_OPTION_MIGRATED=1
        log "Migrated legacy scalar subscription_url to list for section '$section'" "info"
    else
        uci_set netshift "$section" subscription_url "$scalar_url"
        log "Failed to migrate scalar subscription_url to list for section '$section'; restored the original option and continuing (read fallback covers correctness)" "warn"
    fi
}

# One-time, idempotent normalization of a legacy scalar `option subscription_url`
# into the canonical `list subscription_url`. Runs once at startup AFTER
# config_load and BEFORE config generation reads the URLs. Idempotent: a config
# already using `list` is left untouched (no commit, no churn). Never exits.
migrate_legacy_subscription_url_option() {
    SUBSCRIPTION_URL_OPTION_MIGRATED=0

    config_foreach _migrate_legacy_subscription_url_option_handler "section"

    if [ "$SUBSCRIPTION_URL_OPTION_MIGRATED" -eq 1 ]; then
        if uci commit "netshift" 2>/dev/null; then
            config_load "$NETSHIFT_CONFIG"
        else
            log "Failed to commit subscription_url option->list migration; continuing (read fallback covers correctness)" "warn"
        fi
    fi
}

validate_service() {
    local service="$1"

    for community_service in $COMMUNITY_SERVICES; do
        if [ "$service" = "$community_service" ]; then
            return 0
        fi
    done

    log "Invalid service in community lists: $service. Check config and LuCI cache. Aborted." "fatal"
    exit 1
}

process_validate_service() {
    local section="$1"
    local community_lists
    config_get community_lists "$section" "community_lists"
    if [ -n "$community_lists" ]; then
        config_list_foreach "$section" "community_lists" validate_service
    fi
}

br_netfilter_disable() {
    if lsmod | grep -q br_netfilter && [ "$(sysctl -n net.bridge.bridge-nf-call-iptables 2> /dev/null)" = "1" ]; then
        log "br_netfilter enabled detected. Disabling"
        sysctl -w net.bridge.bridge-nf-call-iptables=0
        sysctl -w net.bridge.bridge-nf-call-ip6tables=0
    fi
}

# Main funcs

route_table_rule_mark() {
    grep -q "105 $RT_TABLE_NAME" /etc/iproute2/rt_tables || echo "105 $RT_TABLE_NAME" >> /etc/iproute2/rt_tables

    log "Configure IPv4 route for tproxy" "debug"
    ip -4 route replace local 0.0.0.0/0 dev lo table "$RT_TABLE_NAME" 2>/dev/null || \
        ip -4 route add local 0.0.0.0/0 dev lo table "$RT_TABLE_NAME" 2>/dev/null

    if netshift_ipv6_enabled; then
        log "Configure IPv6 route for tproxy" "debug"
        ip -6 route replace local ::/0 dev lo table "$RT_TABLE_NAME" 2>/dev/null || \
            ip -6 route add local ::/0 dev lo table "$RT_TABLE_NAME" 2>/dev/null
    fi

    log "Configure IPv4 marking rule" "debug"
    ip -4 rule del fwmark "$NFT_FAKEIP_MARK"/"$NFT_FAKEIP_MARK" table "$RT_TABLE_NAME" priority 105 2>/dev/null
    ip -4 rule add fwmark "$NFT_FAKEIP_MARK"/"$NFT_FAKEIP_MARK" table "$RT_TABLE_NAME" priority 105 2>/dev/null

    if netshift_ipv6_enabled; then
        log "Configure IPv6 marking rule" "debug"
        ip -6 rule del fwmark "$NFT_FAKEIP_MARK"/"$NFT_FAKEIP_MARK" table "$RT_TABLE_NAME" priority 105 2>/dev/null
        ip -6 rule add fwmark "$NFT_FAKEIP_MARK"/"$NFT_FAKEIP_MARK" table "$RT_TABLE_NAME" priority 105 2>/dev/null
    fi
}

netshift_ipv6_enabled() {
    local enable_ipv6
    config_get_bool enable_ipv6 "settings" "enable_ipv6" 0

    [ "$enable_ipv6" -eq 1 ] || return 1
    ip -6 addr show dev lo 2>/dev/null | grep -q '::1'
}

nft_init_interfaces_set() {
    nft_create_ifname_set "$NFT_TABLE_NAME" "$NFT_INTERFACE_SET_NAME"

    local source_network_interfaces
    config_get source_network_interfaces "settings" "source_network_interfaces" "br-lan"

    for interface in $source_network_interfaces; do
        nft add element inet "$NFT_TABLE_NAME" "$NFT_INTERFACE_SET_NAME" "{ $interface }"
    done
}

# ── task-034: destination-selective marking population ──────────────────────
# The prerouting `mangle` chain marks ONLY proxied destinations into tproxy.
# Those destinations live in the union nft set NFT_COMMON_SET_NAME (IPv4) and
# its IPv6 mirror NFT_COMMON_SET_NAME_V6. These two helpers are the SINGLE
# centralized population point: every place that adds an ip_cidr to a sing-box
# route rule_set ALSO calls one of these so the nft set and the sing-box
# rule_set cannot drift (the historical 0.8.5 two-sources-of-truth fragility).
#
# Fail-open: if the set does not exist yet (table not built) or a line is
# malformed, that subnet simply isn't marked (goes direct) — never a blackhole.

# Add the IPv4/IPv6 CIDRs found in a file into the union destination set(s).
populate_netshift_subnets_from_file() {
    local filepath="$1"

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

    if nft list set inet "$NFT_TABLE_NAME" "$NFT_COMMON_SET_NAME" > /dev/null 2>&1; then
        nft_add_set_elements_from_file_chunked "$filepath" "$NFT_TABLE_NAME" "$NFT_COMMON_SET_NAME"
    fi

    if netshift_ipv6_enabled &&
        nft list set inet "$NFT_TABLE_NAME" "$NFT_COMMON_SET_NAME_V6" > /dev/null 2>&1; then
        nft_add_set_elements_from_file_chunked_v6 "$filepath" "$NFT_TABLE_NAME" "$NFT_COMMON_SET_NAME_V6"
    fi
}

# Add a comma/space-separated string of CIDRs into the union destination set(s).
# Items are written to a temp file and routed through the file-based path so the
# v4/v6 split logic is shared.
populate_netshift_subnets_from_string() {
    local items="$1"
    local tmpfile

    [ -n "$items" ] || return 0

    tmpfile="$(mktemp)" || return 0
    printf '%s\n' "$items" | tr ', ' '\n\n' > "$tmpfile"
    populate_netshift_subnets_from_file "$tmpfile"
    rm -f "$tmpfile"
}

# task-034: add source-based mark-all rules for every section's fully_routed_ips
# (LAN clients whose ALL traffic must enter sing-box, regardless of destination).
# Only used in the destination-selective default mode; under global_proxy ALL
# traffic is already marked. Fail-open: a missing/empty list adds no rule.
nft_mark_fully_routed_source_ips() {
    config_foreach _nft_mark_fully_routed_ips_for_section "section"
}

_nft_mark_fully_routed_ips_for_section() {
    local section="$1"
    local connection_type fully_routed_ips ip

    config_get connection_type "$section" "connection_type"
    case "$connection_type" in
    proxy | vpn) ;;
    *) return 0 ;;
    esac

    config_get fully_routed_ips "$section" "fully_routed_ips"
    [ -n "$fully_routed_ips" ] || return 0

    _nft_fully_routed_list_seen=""
    config_list_foreach "$section" "fully_routed_ips" _nft_mark_fully_routed_ip_handler
    # Fallback for option-form (space-separated) values.
    if [ -z "$_nft_fully_routed_list_seen" ]; then
        for ip in $fully_routed_ips; do
            _nft_mark_fully_routed_ip_handler "$ip"
        done
    fi
}

_nft_mark_fully_routed_ip_handler() {
    local ip="$1"

    _nft_fully_routed_list_seen=1
    [ -n "$ip" ] || return 0

    case "$ip" in
    *:*)
        if netshift_ipv6_enabled; then
            nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip6 saddr "$ip" meta mark set "$NFT_FAKEIP_MARK" counter
        fi
        ;;
    *)
        nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip saddr "$ip" meta mark set "$NFT_FAKEIP_MARK" counter
        ;;
    esac
}

create_nft_rules() {
    # Rebuild the table deterministically. `nft add chain`/`nft add rule` only
    # ever APPEND, so if a NetShiftTable was left behind by a previous start
    # that was not cleanly stopped (procd respawn, in-place package upgrade, or
    # a crash), its STALE rules survive and the new rules pile on top. A leftover
    # mark-EVERYTHING rule would then sit at the top of the prerouting chain and
    # mark all traffic before the destination-selective rules below ever run —
    # re-introducing the 100% CPU regression even though the selective code is
    # present. Flush first so create_nft_rules always yields exactly this chain.
    log "Flush stale nft table before rebuild"
    nft_delete_table "$NFT_TABLE_NAME"

    log "Create nft table"
    nft_create_table "$NFT_TABLE_NAME"

    log "Create localv4 set"
    nft_create_ipv4_set "$NFT_TABLE_NAME" "$NFT_LOCALV4_SET_NAME"
    nft add element inet "$NFT_TABLE_NAME" localv4 '{
        0.0.0.0/8,
        10.0.0.0/8,
        127.0.0.0/8,
        169.254.0.0/16,
        172.16.0.0/12,
        192.0.0.0/24,
        192.0.2.0/24,
        192.88.99.0/24,
        192.168.0.0/16,
        198.51.100.0/24,
        203.0.113.0/24,
        224.0.0.0/4,
        240.0.0.0-255.255.255.255
    }'

    if netshift_ipv6_enabled; then
        log "Create localv6 set"
        nft_create_ipv6_set "$NFT_TABLE_NAME" "$NFT_LOCALV6_SET_NAME"
        nft add element inet "$NFT_TABLE_NAME" localv6 '{
            ::1,
            fc00::/7,
            fe80::/10,
            ff00::/8
        }'
    fi

    log "Create proxied-subnets union set"
    nft_create_ipv4_set "$NFT_TABLE_NAME" "$NFT_COMMON_SET_NAME"
    if netshift_ipv6_enabled; then
        nft_create_ipv6_set "$NFT_TABLE_NAME" "$NFT_COMMON_SET_NAME_V6"
    fi

    log "Create interface set"
    nft_init_interfaces_set

    log "Create nft rules"
    nft add chain inet "$NFT_TABLE_NAME" mangle '{ type filter hook prerouting priority -150; policy accept; }'
    nft add chain inet "$NFT_TABLE_NAME" mangle_output '{ type route hook output priority -150; policy accept; }'
    nft add chain inet "$NFT_TABLE_NAME" proxy '{ type filter hook prerouting priority -100; policy accept; }'

    nft add rule inet "$NFT_TABLE_NAME" mangle ct status dnat return
    nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip daddr "@$NFT_LOCALV4_SET_NAME" return
    if netshift_ipv6_enabled; then
        nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip6 daddr "@$NFT_LOCALV6_SET_NAME" return
    fi

    # task-034: destination-selective marking.
    #
    # DEFAULT (no global_proxy section): mark ONLY traffic whose DESTINATION is
    #   - a proxied subnet (@NFT_COMMON_SET_NAME / its v6 mirror), or
    #   - the FakeIP range (proxied DOMAINS resolve to FakeIPs via the dns-in
    #     inbound, so marking the FakeIP range carries domain routing in), or
    #   - a DoH-block CIDR (when block_doh is on, so DoH probes are forced into
    #     sing-box where the route-level reject rule drops them).
    # Everything else (a torrent/4K stream to a random direct IP) is NEVER
    # marked -> never enters sing-box -> CPU stays at 0.8.5 levels.
    # `fully_routed_ips` are SOURCE clients whose ALL traffic must be proxied,
    # so they are marked by source regardless of destination.
    #
    # GLOBAL_PROXY OVERRIDE: when a global_proxy section is active, ALL LAN
    # tcp/udp is legitimately wanted inside sing-box (it routes every unmatched
    # flow through the proxy), so we keep the mark-EVERYTHING rules. The same
    # condition is read independently here (UCI only, via get_global_proxy_section)
    # because create_nft_rules runs separately from sing_box_configure_route.
    local nft_global_proxy_section
    nft_global_proxy_section="$(get_global_proxy_section)"
    if [ -n "$nft_global_proxy_section" ]; then
        log "Global proxy section '$nft_global_proxy_section' active: marking ALL LAN traffic into sing-box" "info"
        nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" meta l4proto tcp meta mark set "$NFT_FAKEIP_MARK" counter
        nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" meta l4proto udp meta mark set "$NFT_FAKEIP_MARK" counter
    else
        # Source-based mark-all for fully_routed_ips clients (regardless of dest).
        nft_mark_fully_routed_source_ips

        # Destination-selective: proxied subnets (union set).
        nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip daddr "@$NFT_COMMON_SET_NAME" meta mark set "$NFT_FAKEIP_MARK" counter
        # Destination-selective: FakeIP range (proxied domains).
        nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip daddr "$SB_FAKEIP_INET4_RANGE" meta mark set "$NFT_FAKEIP_MARK" counter
        if netshift_ipv6_enabled; then
            nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip6 daddr "@$NFT_COMMON_SET_NAME_V6" meta mark set "$NFT_FAKEIP_MARK" counter
            nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip6 daddr "$SB_FAKEIP_INET6_RANGE" meta mark set "$NFT_FAKEIP_MARK" counter
        fi

        # DoH-block CIDRs (when enabled): force well-known DoH resolver IPs into
        # sing-box so the route-level DoH reject rule can drop them.
        local nft_block_doh
        config_get_bool nft_block_doh "settings" "block_doh" 0
        if [ "$nft_block_doh" -eq 1 ]; then
            log "DoH blocking enabled: marking DoH resolver CIDRs into sing-box" "info"
            local doh_cidr
            for doh_cidr in $DOH_BLOCK_IPV4_CIDRS; do
                nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip daddr "$doh_cidr" meta mark set "$NFT_FAKEIP_MARK" counter
            done
            if netshift_ipv6_enabled; then
                for doh_cidr in $DOH_BLOCK_IPV6_CIDRS; do
                    nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip6 daddr "$doh_cidr" meta mark set "$NFT_FAKEIP_MARK" counter
                done
            fi
        fi
    fi

    nft add rule inet "$NFT_TABLE_NAME" proxy meta mark \& "$NFT_FAKEIP_MARK" == "$NFT_FAKEIP_MARK" meta l4proto tcp tproxy ip to "$SB_TPROXY_INBOUND_ADDRESS:$SB_TPROXY_INBOUND_PORT" counter
    nft add rule inet "$NFT_TABLE_NAME" proxy meta mark \& "$NFT_FAKEIP_MARK" == "$NFT_FAKEIP_MARK" meta l4proto udp tproxy ip to "$SB_TPROXY_INBOUND_ADDRESS:$SB_TPROXY_INBOUND_PORT" counter
    if netshift_ipv6_enabled; then
        nft add rule inet "$NFT_TABLE_NAME" proxy meta mark \& "$NFT_FAKEIP_MARK" == "$NFT_FAKEIP_MARK" meta l4proto tcp tproxy ip6 to "[$SB_TPROXY_INBOUND_ADDRESS_V6]:$SB_TPROXY_INBOUND_PORT_V6" counter
        nft add rule inet "$NFT_TABLE_NAME" proxy meta mark \& "$NFT_FAKEIP_MARK" == "$NFT_FAKEIP_MARK" meta l4proto udp tproxy ip6 to "[$SB_TPROXY_INBOUND_ADDRESS_V6]:$SB_TPROXY_INBOUND_PORT_V6" counter
    fi

    # Router-originated (locally generated) traffic is left DIRECT on purpose.
    # The model marks only LAN/forwarded traffic in `mangle` (prerouting) above
    # and delegates the proxy/direct split to sing-box route rules; the router's
    # own outgoing traffic is NOT proxied. These mangle_output rules only handle
    # the returns needed to keep that traffic direct: local/loopback daddr returns
    # and the outbound-mark return (sing-box-originated packets carrying
    # NFT_OUTBOUND_MARK must not be re-marked into the tproxy path -> avoids a
    # routing loop). Proxying router-originated traffic (the old mangle_output
    # @common/FakeIP daddr marking) was dropped deliberately to avoid such loops.
    nft add rule inet "$NFT_TABLE_NAME" mangle_output ip daddr "@$NFT_LOCALV4_SET_NAME" return
    if netshift_ipv6_enabled; then
        nft add rule inet "$NFT_TABLE_NAME" mangle_output ip6 daddr "@$NFT_LOCALV6_SET_NAME" return
    fi
    nft add rule inet "$NFT_TABLE_NAME" mangle_output meta mark "$NFT_OUTBOUND_MARK" counter return

    local exclude_ntp
    config_get_bool exclude_ntp "settings" "exclude_ntp" "0"
    if [ "$exclude_ntp" -eq 1 ]; then
        log "NTP traffic exclude for proxy"
        nft insert rule inet "$NFT_TABLE_NAME" mangle udp dport 123 return
    fi
}

backup_dnsmasq_config_option() {
    local key="$1"
    local backup_key="$2"
    local value
    value="$(uci_get "dhcp" "@dnsmasq[0]" "$key")"

    if [ -n "$value" ]; then
        uci_set "dhcp" "@dnsmasq[0]" "$backup_key" "$value"
    fi
}

uci_remove_quiet() {
    local config="$1"
    local section="$2"
    local option="$3"

    uci -q delete "$config.$section.$option" 2>/dev/null
}

dnsmasq_is_configured_for_netshift() {
    local configured

    # The authoritative "netshift owns this dnsmasq config" flag is the
    # netshift_configured sentinel that dnsmasq_configure sets unconditionally
    # AFTER applying our config, and dnsmasq_restore clears on teardown. We do
    # NOT infer ownership from the live server/noresolv/cachesize values: on a
    # stock dnsmasq with no original server/noresolv/cachesize, dnsmasq_configure
    # writes no backup markers, and after it runs the live values ARE netshift's
    # own (noresolv=1, cachesize=0) — so a value-based or marker-based check
    # could not distinguish "we configured it" from "an admin coincidentally set
    # the same values", and the redundant `dnsmasq_configure force` path (monitor
    # recovery / double-start) would re-run "backup" and capture netshift's own
    # values as the backup, corrupting the later restore. The sentinel is the
    # single source of truth.
    configured="$(uci_get "dhcp" "@dnsmasq[0]" "netshift_configured")"
    [ "$configured" = "1" ]
}

dnsmasq_configure() {
    local force shutdown_correctly
    force="$1"
    config_get shutdown_correctly "settings" "shutdown_correctly"
    if [ "$force" != "force" ] && [ "$shutdown_correctly" -eq 0 ]; then
        log "Previous shutdown of netshift was not correct, reconfiguration of dnsmasq is not required"
        return 0
    fi

    if dnsmasq_is_configured_for_netshift; then
        log "dnsmasq is already configured for sing-box"
        return 0
    fi

    log "Backup dnsmasq configuration"
    current_servers="$(uci_get "dhcp" "@dnsmasq[0]" "server")"
    if [ -n "$current_servers" ]; then
        for server in $(uci_get "dhcp" "@dnsmasq[0]" "server"); do
            if ! [ "$server" = "$SB_DNS_INBOUND_ADDRESS" ]; then
                uci_add_list "dhcp" "@dnsmasq[0]" "netshift_server" "$server"
            fi
        done
        uci_remove_quiet "dhcp" "@dnsmasq[0]" "server"
    fi

    backup_dnsmasq_config_option "noresolv" "netshift_noresolv"
    backup_dnsmasq_config_option "cachesize" "netshift_cachesize"

    log "Configure dnsmasq for sing-box"
    uci_add_list "dhcp" "@dnsmasq[0]" "server" "$SB_DNS_INBOUND_ADDRESS"
    uci_set "dhcp" "@dnsmasq[0]" "noresolv" 1
    uci_set "dhcp" "@dnsmasq[0]" "cachesize" 0
    # Authoritative ownership sentinel (see dnsmasq_is_configured_for_netshift):
    # marks that netshift, not the admin, applied this dnsmasq config. Set
    # unconditionally after our config is applied; cleared in dnsmasq_restore.
    uci_set "dhcp" "@dnsmasq[0]" "netshift_configured" 1
    uci_commit "dhcp"

    /etc/init.d/dnsmasq restart
}

dnsmasq_restore() {
    log "Restoring the dnsmasq configuration"
    local shutdown_correctly
    config_get shutdown_correctly "settings" "shutdown_correctly"
    if [ "$shutdown_correctly" -eq 1 ]; then
        log "Previous shutdown of netshift was correct, reconfiguration of dnsmasq is not required"
        return 0
    fi

    local cachesize noresolv backup_servers resolvfile
    log "Restoring cachesize" "debug"
    cachesize="$(uci_get "dhcp" "@dnsmasq[0]" "netshift_cachesize")"
    if [ -z "$cachesize" ]; then
        uci_remove_quiet "dhcp" "@dnsmasq[0]" "cachesize"
        uci_set "dhcp" "@dnsmasq[0]" "cachesize" 150
    else
        uci_set "dhcp" "@dnsmasq[0]" "cachesize" "$cachesize"
        uci_remove_quiet "dhcp" "@dnsmasq[0]" "netshift_cachesize"
    fi

    log "Restoring noresolv" "debug"
    noresolv="$(uci_get "dhcp" "@dnsmasq[0]" "netshift_noresolv")"
    if [ -z "$noresolv" ]; then
        uci_set "dhcp" "@dnsmasq[0]" "noresolv" 0
    else
        uci_set "dhcp" "@dnsmasq[0]" "noresolv" "$noresolv"
        uci_remove_quiet "dhcp" "@dnsmasq[0]" "netshift_noresolv"
    fi

    log "Restoring DNS servers" "debug"
    uci_remove_quiet "dhcp" "@dnsmasq[0]" "server"
    resolvfile="/tmp/resolv.conf.d/resolv.conf.auto"
    backup_servers="$(uci_get "dhcp" "@dnsmasq[0]" "netshift_server")"
    if [ -n "$backup_servers" ]; then
        for server in $backup_servers; do
            uci_add_list "dhcp" "@dnsmasq[0]" "server" "$server"
        done
        uci_remove_quiet "dhcp" "@dnsmasq[0]" "netshift_server"
    elif file_exists "$resolvfile"; then
        log "Backup DNS servers not found, using default resolvfile" "debug"
        uci_set "dhcp" "@dnsmasq[0]" "resolvfile" "$resolvfile"
        if [ -n "$noresolv" ] && [ "$noresolv" -eq 1 ]; then
            log "Disabling noresolv option to use system resolvfile" "debug"
            uci_set "dhcp" "@dnsmasq[0]" "noresolv" 0
        fi
    else
        log "Backup DNS servers and default resolvfile not found, possible resolving issues" "warn"
    fi

    # Clear the ownership sentinel so a fresh future dnsmasq_configure
    # re-establishes ownership cleanly (and dnsmasq_is_configured_for_netshift
    # no longer short-circuits once we have torn down).
    uci_remove_quiet "dhcp" "@dnsmasq[0]" "netshift_configured"

    uci_commit "dhcp"

    /etc/init.d/dnsmasq restart
}

add_cron_job() {
    ## Future: make a check so that it doesn't recreate many times
    local community_lists remote_domain_lists remote_subnet_lists update_interval
    config_get community_lists "$section" "community_lists"
    config_get remote_domain_lists "$section" "remote_domain_lists"
    config_get remote_subnet_lists "$section" "remote_subnet_lists"
    config_get update_interval "settings" "update_interval"

    case "$update_interval" in
    "1h")
        cron_job="13 * * * * /usr/bin/netshift list_update"
        ;;
    "3h")
        cron_job="13 */3 * * * /usr/bin/netshift list_update"
        ;;
    "12h")
        cron_job="13 */12 * * * /usr/bin/netshift list_update"
        ;;
    "1d")
        cron_job="13 9 * * * /usr/bin/netshift list_update"
        ;;
    "3d")
        cron_job="13 9 */3 * * /usr/bin/netshift list_update"
        ;;
    *)
        log "Invalid update_interval value: $update_interval"
        return
        ;;
    esac

    if [ -n "$community_lists" ] ||
        [ -n "$remote_domain_lists" ] ||
        [ -n "$remote_subnet_lists" ]; then
        remove_cron_job
        crontab -l | {
            cat
            echo "$cron_job"
        } | crontab -
        log "The cron job has been created: $cron_job"
    fi
}

remove_cron_job() {
    (crontab -l | grep -v "/usr/bin/netshift list_update" | grep -v "/usr/bin/netshift subscription_update") | crontab -
    log "The cron job removed"
}

add_subscription_cron_job() {
    local section="$1"
    local connection_type proxy_config_type subscription_update_interval cron_job

    config_get connection_type "$section" "connection_type"
    if [ "$connection_type" != "proxy" ]; then
        return
    fi

    config_get proxy_config_type "$section" "proxy_config_type"
    if [ "$proxy_config_type" != "subscription" ]; then
        return
    fi

    config_get subscription_update_interval "$section" "subscription_update_interval" "1h"

    case "$subscription_update_interval" in
    "30m")
        cron_job="*/30 * * * * /usr/bin/netshift subscription_update"
        ;;
    "1h")
        cron_job="17 * * * * /usr/bin/netshift subscription_update"
        ;;
    "3h")
        cron_job="17 */3 * * * /usr/bin/netshift subscription_update"
        ;;
    "6h")
        cron_job="17 */6 * * * /usr/bin/netshift subscription_update"
        ;;
    "12h")
        cron_job="17 */12 * * * /usr/bin/netshift subscription_update"
        ;;
    "1d")
        cron_job="17 9 * * * /usr/bin/netshift subscription_update"
        ;;
    *)
        log "Invalid subscription_update_interval value: $subscription_update_interval"
        return
        ;;
    esac

    # Avoid duplicate subscription cron
    (crontab -l | grep -v "/usr/bin/netshift subscription_update") | {
        cat
        echo "$cron_job"
    } | crontab -
    log "The subscription cron job has been created: $cron_job"
}
ensure_nft_ready_for_list_update() {
    if nft list table inet "$NFT_TABLE_NAME" > /dev/null 2>&1; then
        return 0
    fi

    log "NFT table '$NFT_TABLE_NAME' is missing before lists update, recreating nft rules" "warn"
    route_table_rule_mark
    create_nft_rules

    if ! nft list table inet "$NFT_TABLE_NAME" > /dev/null 2>&1; then
        log "Failed to recreate NFT table '$NFT_TABLE_NAME'" "error"
        return 1
    fi

    return 0
}


list_update() {
    echolog "🔄 Starting lists update..."

    local nslookup_timeout=3
    local nslookup_attempts=10
    local curl_timeout=5
    local curl_attempts=10
    local curl_max_timeout=10
    local delay=3
    local i

    # DNS Check
    for i in $(seq 1 $nslookup_attempts); do
        if nslookup -timeout=$nslookup_timeout openwrt.org > /dev/null 2>&1; then
            echolog "✅ DNS check passed"
            break
        fi
        echolog "DNS is unavailable [$i/$nslookup_attempts]"
        sleep $delay
    done

    if [ "$i" -eq $nslookup_attempts ]; then
        echolog "❌ DNS check failed after $nslookup_attempts attempts"
        return 1
    fi

    # Github Check
    for i in $(seq 1 $curl_attempts); do
        local service_proxy_address
        service_proxy_address="$(get_service_proxy_address)"

        if [ -n "$service_proxy_address" ]; then
            if curl -s -x "http://$service_proxy_address" -m $curl_timeout https://github.com > /dev/null; then
                echolog "✅ GitHub connection check passed (via proxy)"
                break
            fi
        else
            if curl -s -m $curl_timeout https://github.com > /dev/null; then
                echolog "✅ GitHub connection check passed"
                break
            fi
        fi

        echolog "GitHub is unavailable [$i/$curl_attempts] (max-timeout=$curl_timeout)"
        if [ "$curl_timeout" -lt $curl_max_timeout ]; then
            curl_timeout=$((curl_timeout + 1))
        fi
        sleep $delay
    done

    if [ "$i" -eq $curl_attempts ]; then
        echolog "❌ GitHub connection check failed after $curl_attempts attempts"
        return 1
    fi

    if ! ensure_nft_ready_for_list_update; then
        echolog "❌ NFT table is unavailable, cannot update lists"
        return 1
    fi

    echolog "📥 Downloading and processing lists..."

    local update_failed=0
    config_foreach import_community_subnet_lists "section" || update_failed=1
    config_foreach import_domains_from_remote_domain_lists "section" || update_failed=1
    config_foreach import_subnets_from_remote_subnet_lists "section" || update_failed=1

    if [ "$update_failed" -eq 0 ]; then
        echolog "✅ Lists update completed successfully"
    else
        echolog "❌ Lists update failed"
        return 1
    fi
}

subscription_update() {
    echolog "🔄 Starting subscription update..."

    local has_subscription=0
    local updated_sections=0
    local failed_sections=0

    _check_subscription_section() {
        local section="$1"
        local connection_type proxy_config_type

        config_get connection_type "$section" "connection_type"
        if [ "$connection_type" != "proxy" ]; then
            return
        fi

        config_get proxy_config_type "$section" "proxy_config_type"
        if [ "$proxy_config_type" = "subscription" ]; then
            has_subscription=1
        fi
    }
    config_foreach _check_subscription_section "section"

    if [ "$has_subscription" -eq 0 ]; then
        echolog "ℹ️ No subscription sections found, nothing to update"
        return 0
    fi

    _update_subscription_for_section() {
        local section="$1"
        local connection_type proxy_config_type url urlhash subscription_json_path
        local subscription_url_cache_path service_proxy_address update_result outbounds_count
        local urls_tmp url_count section_changed feed_usable_count feed_failed_count

        config_get connection_type "$section" "connection_type"
        if [ "$connection_type" != "proxy" ]; then
            return
        fi


        config_get proxy_config_type "$section" "proxy_config_type"
        if [ "$proxy_config_type" != "subscription" ]; then
            return
        fi

        mkdir -p "$TMP_SUBSCRIPTION_FOLDER"
        if ! ensure_subscription_cache_dir; then
            echolog "❌ Subscription cache directory is unavailable for section '$section'"
            failed_sections=$((failed_sections + 1))
            return
        fi
        reap_legacy_subscription_cache_files "$section"

        urls_tmp="$(mktemp "${TMPDIR:-/tmp}/netshift-refresh-urls.XXXXXX")" || {
            echolog "❌ Failed to enumerate subscription URLs for section '$section'"
            failed_sections=$((failed_sections + 1))
            return
        }
        get_subscription_urls_for_section "$section" > "$urls_tmp"
        url_count=0
        while IFS= read -r url || [ -n "$url" ]; do
            [ -n "$url" ] && url_count=$((url_count + 1))
        done < "$urls_tmp"

        if [ "$url_count" -eq 0 ]; then
            rm -f "$urls_tmp"
            echolog "❌ Subscription URL not set for section '$section'"
            failed_sections=$((failed_sections + 1))
            return
        fi

        echolog "📥 Updating subscription for section '$section' ($url_count feed(s))..."

        # Best-effort per-feed download. The section is "changed" (→ restart) if
        # ANY feed changed; the section "failed" only if ALL feeds failed AND none
        # has a usable cache. One dead feed never aborts the others.
        section_changed=0
        feed_usable_count=0
        feed_failed_count=0
        while IFS= read -r url || [ -n "$url" ]; do
            [ -n "$url" ] || continue
            urlhash="$(get_subscription_url_hash "$url")"
            subscription_json_path="$(get_subscription_json_path "$section" "$urlhash")"
            subscription_url_cache_path="$(get_subscription_url_cache_path "$section" "$urlhash")"

            service_proxy_address="$(get_subscription_download_proxy_address "$section" "runtime" || echo '')"
            if [ -n "$service_proxy_address" ]; then
                log "Updating subscription feed for section '$section' (url=$(redact_url_for_log "$url")) via service proxy $service_proxy_address" "info"
            else
                log "Updating subscription feed for section '$section' (url=$(redact_url_for_log "$url")) directly" "info"
            fi

            if ! wait_for_subscription_connectivity "$section" "$url" "$service_proxy_address" 6 5 5; then
                echolog "⚠️ Subscription feed not reachable for section '$section': url=$(redact_url_for_log "$url")"
                feed_failed_count=$((feed_failed_count + 1))
                subscription_cache_is_usable "$subscription_json_path" && feed_usable_count=$((feed_usable_count + 1))
                continue
            fi

            download_subscription_into_cache \
                "$section" "$url" "$subscription_json_path" "$subscription_url_cache_path" "$service_proxy_address" "$urlhash"
            update_result=$?

            case "$update_result" in
            0)
                section_changed=1
                feed_usable_count=$((feed_usable_count + 1))
                outbounds_count=$(jq -r '[.outbounds[] | select(
                    .type != "selector" and
                    .type != "urltest" and
                    .type != "direct" and
                    .type != "dns" and
                    .type != "block"
                )] | length' "$subscription_json_path" 2>/dev/null)
                echolog "✅ Subscription feed updated for section '$section': $outbounds_count outbounds (url=$(redact_url_for_log "$url"))"
                ;;
            2)
                feed_usable_count=$((feed_usable_count + 1))
                echolog "ℹ️ Subscription feed for section '$section' is unchanged (url=$(redact_url_for_log "$url"))"
                ;;
            *)
                feed_failed_count=$((feed_failed_count + 1))
                # A previously cached body for this feed still counts as usable.
                subscription_cache_is_usable "$subscription_json_path" && feed_usable_count=$((feed_usable_count + 1))
                echolog "⚠️ Subscription feed failed for section '$section' (rc=$update_result): url=$(redact_url_for_log "$url")"
                ;;
            esac
        done < "$urls_tmp"
        rm -f "$urls_tmp"

        if [ "$section_changed" -eq 1 ]; then
            updated_sections=$((updated_sections + 1))
            if [ "$feed_failed_count" -gt 0 ]; then
                echolog "✅ Subscription updated for section '$section' ($feed_failed_count feed(s) failed and kept their previous cache)"
            else
                echolog "✅ Subscription updated for section '$section'"
            fi
            return
        fi

        if [ "$feed_usable_count" -eq 0 ]; then
            echolog "❌ Failed to update any subscription feed for section '$section'"
            failed_sections=$((failed_sections + 1))
            return
        fi

        echolog "ℹ️ Subscription for section '$section' is unchanged"
    }
    config_foreach _update_subscription_for_section "section"

    if [ "$updated_sections" -eq 0 ]; then
        if [ "$failed_sections" -gt 0 ]; then
            echolog "❌ Subscription update finished with errors; keeping the last working cache"
            return 1
        fi

        echolog "ℹ️ Subscription update completed: no changes detected"
        return 0
    fi

    echolog "🔄 Restarting netshift to apply updated subscriptions..."
    restart
    restart_rc=$?
    if [ "$restart_rc" -ne 0 ]; then
        echolog "❌ Subscription was downloaded, but netshift restart failed"
        return "$restart_rc"
    fi

    if [ "$failed_sections" -gt 0 ]; then
        echolog "✅ Subscription update applied for changed sections; failed sections kept their previous cache"
    else
        echolog "✅ Subscription update completed"
    fi
}

# Worker for `component_action subscription clear_cache` (updater.sh router).
# Wipes ALL per-feed subscription cache files under SUBSCRIPTION_CACHE_FOLDER
# (the four "${section}.<md5(url)>.{json,url,rejected,user_agent}" sidecars per
# feed), then re-runs subscription_update verbatim so every feed is re-downloaded
# fresh, re-validated, and the service restarts on change. Deleting the .json
# defeats the unchanged guard and deleting the .rejected defeats the rejected-hash
# veto, so the redownload is a genuine full reset.
#
# CRITICAL: this runs inside the component_action async fork (component_action_async
# → "$0" component_action subscription clear_cache). It MUST echo a single JSON
# result and `return N` — NEVER `exit` (an exit would kill the fork before the
# finished-state file is written). Mirrors the updates_* workers' echo+return
# discipline.
#
# SAFETY: the globbed delete is guarded so a mistyped/empty SUBSCRIPTION_CACHE_FOLDER
# can never become `rm -f /*`. We require a non-empty constant AND an existing
# directory before `rm -f "$SUBSCRIPTION_CACHE_FOLDER"/*`, and only ever remove the
# directory CONTENTS — never `rm -rf` the directory itself.
subscription_clear_cache_and_redownload() {
    local has_subscription removed cache_file update_rc

    # Detect whether any subscription section is configured at all. If none,
    # there is nothing to clear or redownload — succeed gracefully.
    has_subscription=0
    _detect_subscription_section_for_clear() {
        local section="$1"
        local connection_type proxy_config_type

        config_get connection_type "$section" "connection_type"
        [ "$connection_type" = "proxy" ] || return 0

        config_get proxy_config_type "$section" "proxy_config_type"
        [ "$proxy_config_type" = "subscription" ] && has_subscription=1
    }
    config_foreach _detect_subscription_section_for_clear "section"

    # Guarded full-reset delete of the cache directory CONTENTS. The two guards
    # (non-empty constant AND existing directory) make a wild `rm -f /*`
    # impossible. No error when the directory is empty or missing.
    removed=0
    if [ -n "$SUBSCRIPTION_CACHE_FOLDER" ] && [ -d "$SUBSCRIPTION_CACHE_FOLDER" ]; then
        for cache_file in "$SUBSCRIPTION_CACHE_FOLDER"/*; do
            [ -e "$cache_file" ] || continue
            rm -f "$cache_file" 2>/dev/null && removed=$((removed + 1))
        done
    fi
    log "Cleared subscription cache: removed $removed file(s) from '$SUBSCRIPTION_CACHE_FOLDER'" "info"

    if [ "$has_subscription" -eq 0 ]; then
        echo "{\"success\":true,\"message\":\"No subscriptions configured; cleared $removed cache file(s), nothing to redownload\"}"
        return 0
    fi

    # Reuse subscription_update ENTIRELY for the redownload + re-validate + restart.
    subscription_update
    update_rc=$?

    if [ "$update_rc" -eq 0 ]; then
        echo "{\"success\":true,\"message\":\"Cleared $removed subscription cache file(s) and re-downloaded all feeds\"}"
        return 0
    fi

    echo "{\"success\":false,\"message\":\"Cleared $removed subscription cache file(s) but the redownload failed (rc=$update_rc); kept the last working cache\"}"
    return "$update_rc"
}

# sing-box funcs
sing_box_configure_service() {
    local sing_box_enabled sing_box_user sing_box_config_path sing_box_conffile
    sing_box_enabled="$(uci_get "sing-box" "main" "enabled")"
    sing_box_user="$(uci_get "sing-box" "main" "user")"

    if [ "$sing_box_enabled" -ne 1 ]; then
        uci_set "sing-box" "main" "enabled" 1
        uci_commit "sing-box"
        log "sing-box service has been enabled"
    fi

    if [ "$sing_box_user" != "root" ]; then
        uci_set "sing-box" "main" "user" "root"
        uci_commit "sing-box"
        log "sing-box service user has been changed to root"
    fi

    config_get sing_box_config_path "settings" "config_path"
    sing_box_conffile="$(uci_get "sing-box" "main" "conffile")"
    log "sing-box config path: $sing_box_config_path" "debug"
    log "sing-box service conffile: $sing_box_conffile" "debug"
    if [ "$sing_box_conffile" != "$sing_box_config_path" ]; then
        uci_set "sing-box" "main" "conffile" "$sing_box_config_path"
        uci_commit "sing-box"
        log "Configuration file path has been set to $sing_box_config_path"
    fi

    [ -f /etc/rc.d/S99sing-box ] && log "Disable sing-box" && /etc/init.d/sing-box disable
}

sing_box_init_config() {
    local config='{"log":{},"dns":{},"ntp":{},"certificate":{},"endpoints":[],"inbounds":[],"outbounds":[],"route":{},"services":[],"experimental":{}}'

    sing_box_configure_log
    sing_box_configure_inbounds
    sing_box_configure_outbounds
    sing_box_configure_dns
    sing_box_configure_route
    sing_box_configure_experimental
    sing_box_additional_inbounds
    sing_box_save_config
}

sing_box_configure_log() {
    log "Configure the log section of a sing-box JSON configuration"

    local log_level
    config_get log_level "settings" "log_level" "warn"
    config=$(sing_box_cm_configure_log "$config" false "$log_level" false)
}

sing_box_configure_inbounds() {
    log "Configure the inbounds section of a sing-box JSON configuration"

    config=$(
        sing_box_cm_add_tproxy_inbound \
            "$config" "$SB_TPROXY_INBOUND_TAG" "$SB_TPROXY_INBOUND_ADDRESS" "$SB_TPROXY_INBOUND_PORT" true true
    )
    config=$(
        sing_box_cm_add_direct_inbound "$config" "$SB_DNS_INBOUND_TAG" "$SB_DNS_INBOUND_ADDRESS" "$SB_DNS_INBOUND_PORT"
    )
    if netshift_ipv6_enabled; then
        config=$(
            sing_box_cm_add_tproxy_inbound \
                "$config" "${SB_TPROXY_INBOUND_TAG}-v6" "$SB_TPROXY_INBOUND_ADDRESS_V6" "$SB_TPROXY_INBOUND_PORT_V6" true true
        )
        # Local processes may query this inbound directly via [::1]:5354.
        # dnsmasq intentionally keeps using 127.0.0.42:53: router clients cannot
        # reach loopback ::1, and the IPv4 DNS inbound resolves AAAA records too.
        config=$(
            sing_box_cm_add_direct_inbound "$config" "${SB_DNS_INBOUND_TAG}-v6" "$SB_DNS_INBOUND_ADDRESS_V6" "$SB_DNS_INBOUND_PORT_V6"
        )
    fi
}

sing_box_configure_outbounds() {
    log "Configure the outbounds section of a sing-box JSON configuration"

    SUBSCRIPTION_UNAVAILABLE_SECTIONS=""
    config=$(sing_box_cm_add_direct_outbound "$config" "$SB_DIRECT_OUTBOUND_TAG")

    config_foreach configure_outbound_handler "section"
}

sing_box_get_unique_outbound_tag() {
    local config="$1"
    local base_tag="$2"
    local candidate="$base_tag"
    local tag_suffix=1

    while printf '%s' "$config" | jq -e --arg tag "$candidate" '.outbounds[]? | select(.tag == $tag)' > /dev/null 2>&1; do
        candidate="${base_tag}-${tag_suffix}"
        tag_suffix=$((tag_suffix + 1))
    done

    echo "$candidate"
}

# Mode-aware subscription group-key builder (task-044). Generalizes the former
# country-only grouper into off/country/prefix modes. Returns the shape
# {group_order: [...], groups: {key: [tags]}, ungrouped: [...]}.
#   - mode=country: byte-identical to the legacy flag extractor (regional-
#     indicator gate; non-flag tags -> ungrouped).
#   - mode=prefix: group key = first N codepoints of the tag (N = $3). A tag
#     shorter than N codepoints keys by its WHOLE tag (still groups with
#     identical short tags); an empty tag -> ungrouped. $3 is coerced via
#     tonumber, floored to 1; bad/0/empty -> default 2.
# No Oniguruma jq (explode/implode/slice/index/reduce only).
sing_box_build_subscription_groups() {
    local subscription_outbound_tags_json="$1"
    local mode="$2"
    local prefix_len="$3"

    printf '%s' "$subscription_outbound_tags_json" | jq -c \
        --arg mode "$mode" \
        --arg prefix_len "$prefix_len" \
        --argjson default_len "$SUBSCRIPTION_GROUP_DEFAULT_PREFIX_LEN" '
        def is_regional_indicator: . >= 127462 and . <= 127487;
        def extract_country_flag:
            (. | explode) as $codepoints
            | if ($codepoints | length) >= 2
                and ($codepoints[0] | is_regional_indicator)
                and ($codepoints[1] | is_regional_indicator)
              then ($codepoints[0:2] | implode)
              else ""
              end;
        # extract_prefix($n): group key = first $n codepoints, or "" (ungrouped)
        # for an empty tag. A short tag keys by its whole self.
        def extract_prefix($n):
            (. | explode) as $codepoints
            | if ($codepoints | length) == 0
              then ""
              else ($codepoints[0:$n] | implode)
              end;

        # Effective prefix length: coerce, floor to 1, fall back to default.
        (try ($prefix_len | tonumber) catch $default_len) as $raw_len
        | (if ($raw_len | type) != "number" or $raw_len < 1
            then $default_len
            else ($raw_len | floor)
            end) as $n

        | (if type == "array" then . else [] end) as $tags
        | reduce $tags[] as $tag (
            {group_order: [], groups: {}, ungrouped: []};
            (if $mode == "prefix" then ($tag | extract_prefix($n))
             else ($tag | extract_country_flag) end) as $key
            | if $key == "" then
                .ungrouped += [$tag]
              else
                .groups[$key] = ((.groups[$key] // []) + [$tag])
                | if (.group_order | index($key)) == null then
                    .group_order += [$key]
                  else
                    .
                  end
              end
        )
    ' 2>/dev/null
}

is_truthy_option() {
    local value="$1"

    case "$value" in
    1|true|TRUE|True|on|ON|yes|YES|enabled|ENABLED)
        return 0
        ;;
    *)
        return 1
        ;;
    esac
}

# Shared per-link member-outbound builder for the selector/urltest/selector_text/
# urltest_text proxy types. Iterates a whitespace/newline-separated blob of proxy
# links, creating one outbound per supported link via the facade and collecting
# their tags. Unsupported schemes are skipped with a warn (the facade echoes the
# config UNCHANGED and returns non-zero, so a single bad link never wipes the
# config nor leaves a selector referencing a non-existent outbound). A trailing
# CR (pasted CRLF blobs) is stripped per link and blank lines are skipped, so the
# text-list types tolerate pasted multi-line input. POSIX ash, no Oniguruma.
#
# Mutates the GLOBAL $config in place (same echo-and-reassign discipline as the
# subscription branch uses for its in-shell loop) and reports results via two
# GLOBALS the caller reads after the call:
#   _member_outbound_tags    comma-joined member outbound tags (empty if none)
#   _member_default_outbound first member tag (selector default; empty if none)
# Args: <section> <links_blob> <udp_over_tcp> <label>
_build_proxy_member_outbounds() {
    local section="$1"
    local links_blob="$2"
    local udp_over_tcp="$3"
    local label="$4"
    local link i outbound_tag _new_config cr

    _member_outbound_tags=""
    _member_default_outbound=""
    cr="$(printf '\r')"

    i=1
    for link in $links_blob; do
        # Tolerate pasted CRLF blobs: strip a trailing CR and skip blank lines.
        link="${link%"$cr"}"
        [ -n "$link" ] || continue

        # The facade returns non-zero (config echoed UNCHANGED) for an
        # unsupported scheme. Only add the member tag when the outbound was
        # actually created, so the selector/urltest never references a
        # non-existent outbound; a single bad link is skipped and the remaining
        # links still build. Reassign $config only on a non-empty result so a
        # skip never wipes the config.
        if _new_config="$(sing_box_cf_add_proxy_outbound "$config" "$section-$i" "$link" "$udp_over_tcp")" \
            && [ -n "$_new_config" ]; then
            config="$_new_config"
            outbound_tag="$(get_outbound_tag_by_section "$section-$i")"
            if [ -z "$_member_outbound_tags" ]; then
                _member_outbound_tags="$outbound_tag"
                _member_default_outbound="$outbound_tag"
            else
                _member_outbound_tags="$_member_outbound_tags,$outbound_tag"
            fi
        else
            log "$label section '$section' link #$i uses an unsupported scheme; skipping it" "warn"
        fi
        i=$((i + 1))
    done
}

configure_outbound_handler() {
    local section="$1"

    local connection_type
    config_get connection_type "$section" "connection_type"
    case "$connection_type" in
    proxy)
        log "Configuring outbound in proxy connection type for the $section section"
        local proxy_config_type
        config_get proxy_config_type "$section" "proxy_config_type"

        case "$proxy_config_type" in
        url)
            log "Detected proxy configuration type: url" "debug"
            local proxy_string udp_over_tcp
            config_get proxy_string "$section" "proxy_string"
            config_get udp_over_tcp "$section" "enable_udp_over_tcp"

            if [ -z "$proxy_string" ]; then
                log "Proxy string is not set. Aborted." "fatal"
                exit 1
            fi
            # The facade returns non-zero (config echoed UNCHANGED) when the link
            # uses an unsupported scheme. For a single-URL section that is the
            # only node, so there is no outbound to fall back to: surface a clear
            # error and mark the section unavailable (reject route rule) instead
            # of exiting — the rest of the config still generates and the service
            # still starts. Reassign $config only on a non-empty result so a
            # skip (or a defensively-empty echo) never wipes the config.
            local _new_config
            if _new_config="$(sing_box_cf_add_proxy_outbound "$config" "$section" "$proxy_string" "$udp_over_tcp")" \
                && [ -n "$_new_config" ]; then
                config="$_new_config"
            else
                echolog "Proxy link for section '$section' uses an unsupported scheme and was skipped; this section has no usable outbound and its traffic will be rejected until a supported link is configured" "error"
                mark_section_outbound_unavailable "$section"
            fi
            ;;
        outbound)
            log "Detected proxy configuration type: outbound" "debug"
            local json_outbound
            config_get json_outbound "$section" "outbound_json"
            config=$(sing_box_cf_add_json_outbound "$config" "$section" "$json_outbound")
            ;;
        selector)
            log "Detected proxy configuration type: selector" "debug"
            local selector_proxy_links udp_over_tcp selector_tag selector_outbounds
            config_get selector_proxy_links "$section" "selector_proxy_links"
            config_get udp_over_tcp "$section" "enable_udp_over_tcp"

            if [ -z "$selector_proxy_links" ]; then
                log "URLTest proxy links is not set. Aborted." "fatal"
                exit 1
            fi

            _build_proxy_member_outbounds "$section" "$selector_proxy_links" "$udp_over_tcp" "Selector"

            if [ -z "$_member_outbound_tags" ]; then
                echolog "Selector section '$section' has no usable links (all unsupported); its traffic will be rejected until a supported link is configured" "error"
                mark_section_outbound_unavailable "$section"
            else
                selector_tag="$(get_outbound_tag_by_section "$section")"
                selector_outbounds="$(comma_string_to_json_array "$_member_outbound_tags")"
                config="$(sing_box_cm_add_selector_outbound "$config" "$selector_tag" "$selector_outbounds" \
                    "$_member_default_outbound" "true")"
            fi
            ;;
        urltest)
            log "Detected proxy configuration type: urltest" "debug"
            local urltest_proxy_links udp_over_tcp urltest_tag selector_tag \
                urltest_outbounds selector_outbounds urltest_check_interval urltest_tolerance urltest_testing_url
            config_get urltest_proxy_links "$section" "urltest_proxy_links"
            config_get udp_over_tcp "$section" "enable_udp_over_tcp"
            config_get urltest_check_interval "$section" "urltest_check_interval" "3m"
            config_get urltest_tolerance "$section" "urltest_tolerance" 50
            config_get urltest_testing_url "$section" "urltest_testing_url" "https://www.gstatic.com/generate_204"

            if [ -z "$urltest_proxy_links" ]; then
                log "URLTest proxy links is not set. Aborted." "fatal"
                exit 1
            fi

            _build_proxy_member_outbounds "$section" "$urltest_proxy_links" "$udp_over_tcp" "URLTest"

            if [ -z "$_member_outbound_tags" ]; then
                echolog "URLTest section '$section' has no usable links (all unsupported); its traffic will be rejected until a supported link is configured" "error"
                mark_section_outbound_unavailable "$section"
            else
                urltest_tag="$(get_outbound_tag_by_section "$section-urltest")"
                selector_tag="$(get_outbound_tag_by_section "$section")"
                urltest_outbounds="$(comma_string_to_json_array "$_member_outbound_tags")"
                selector_outbounds="$(comma_string_to_json_array "$_member_outbound_tags,$urltest_tag")"
                config="$(sing_box_cm_add_urltest_outbound "$config" "$urltest_tag" "$urltest_outbounds" \
                    "$urltest_testing_url" "$urltest_check_interval" "$urltest_tolerance")"
                config="$(sing_box_cm_add_selector_outbound "$config" "$selector_tag" "$selector_outbounds" "$urltest_tag" "true")"
            fi
            ;;
        selector_text)
            log "Detected proxy configuration type: selector_text" "debug"
            local selector_text_links udp_over_tcp selector_tag selector_outbounds
            config_get selector_text_links "$section" "selector_proxy_links_text"
            config_get udp_over_tcp "$section" "enable_udp_over_tcp"

            if [ -z "$selector_text_links" ]; then
                log "URLTest proxy links is not set. Aborted." "fatal"
                exit 1
            fi

            _build_proxy_member_outbounds "$section" "$selector_text_links" "$udp_over_tcp" "Selector"

            if [ -z "$_member_outbound_tags" ]; then
                echolog "Selector section '$section' has no usable links (all unsupported); its traffic will be rejected until a supported link is configured" "error"
                mark_section_outbound_unavailable "$section"
            else
                selector_tag="$(get_outbound_tag_by_section "$section")"
                selector_outbounds="$(comma_string_to_json_array "$_member_outbound_tags")"
                config="$(sing_box_cm_add_selector_outbound "$config" "$selector_tag" "$selector_outbounds" \
                    "$_member_default_outbound" "true")"
            fi
            ;;
        urltest_text)
            log "Detected proxy configuration type: urltest_text" "debug"
            local urltest_text_links udp_over_tcp urltest_tag selector_tag \
                urltest_outbounds selector_outbounds urltest_check_interval urltest_tolerance urltest_testing_url
            config_get urltest_text_links "$section" "urltest_proxy_links_text"
            config_get udp_over_tcp "$section" "enable_udp_over_tcp"
            config_get urltest_check_interval "$section" "urltest_check_interval" "3m"
            config_get urltest_tolerance "$section" "urltest_tolerance" 50
            config_get urltest_testing_url "$section" "urltest_testing_url" "https://www.gstatic.com/generate_204"

            if [ -z "$urltest_text_links" ]; then
                log "URLTest proxy links is not set. Aborted." "fatal"
                exit 1
            fi

            _build_proxy_member_outbounds "$section" "$urltest_text_links" "$udp_over_tcp" "URLTest"

            if [ -z "$_member_outbound_tags" ]; then
                echolog "URLTest section '$section' has no usable links (all unsupported); its traffic will be rejected until a supported link is configured" "error"
                mark_section_outbound_unavailable "$section"
            else
                urltest_tag="$(get_outbound_tag_by_section "$section-urltest")"
                selector_tag="$(get_outbound_tag_by_section "$section")"
                urltest_outbounds="$(comma_string_to_json_array "$_member_outbound_tags")"
                selector_outbounds="$(comma_string_to_json_array "$_member_outbound_tags,$urltest_tag")"
                config="$(sing_box_cm_add_urltest_outbound "$config" "$urltest_tag" "$urltest_outbounds" \
                    "$urltest_testing_url" "$urltest_check_interval" "$urltest_tolerance")"
                config="$(sing_box_cm_add_selector_outbound "$config" "$selector_tag" "$selector_outbounds" "$urltest_tag" "true")"
            fi
            ;;
        subscription)
            log "Detected proxy configuration type: subscription" "debug"
            local subscription_urls_tmp subscription_url subscription_url_count urltest_tag selector_tag \
                urltest_outbounds selector_outbounds urltest_check_interval urltest_tolerance \
                urltest_testing_url group_mode group_mode_raw prefix_len prefix_len_raw legacy_group_raw \
                subscription_outbound_tags_json service_proxy_address subscription_ready \
                subscription_filter_include_keywords_json subscription_filter_exclude_keywords_json \
                subscription_keyword_filter_active urlhash subscription_json_path subscription_url_cache_path \
                cached_subscription_url should_download had_usable_cache merged_json_path merged_node_count \
                usable_feed_count feed_node_count merged_tmp

            config_get urltest_check_interval "$section" "urltest_check_interval" "3m"
            config_get urltest_tolerance "$section" "urltest_tolerance" 50
            config_get urltest_testing_url "$section" "urltest_testing_url" "https://www.gstatic.com/generate_204"
            # Grouping mode (task-044): off | country | prefix. The new
            # subscription_group_mode option OUTRANKS the legacy boolean. When
            # the new option is absent we fall back to the legacy boolean
            # subscription_group_by_countries (and its older alias
            # group_by_countries): truthy => country, else off.
            config_get group_mode_raw "$section" "subscription_group_mode" ""
            config_get prefix_len_raw "$section" "subscription_group_prefix_len" "$SUBSCRIPTION_GROUP_DEFAULT_PREFIX_LEN"

            if [ -z "$group_mode_raw" ]; then
                config_get legacy_group_raw "$section" "subscription_group_by_countries" ""
                if [ -z "$legacy_group_raw" ]; then
                    config_get legacy_group_raw "$section" "group_by_countries" ""
                fi
                if is_truthy_option "$legacy_group_raw"; then
                    group_mode="country"
                else
                    group_mode="off"
                fi
            else
                case "$group_mode_raw" in
                off | country | prefix)
                    group_mode="$group_mode_raw"
                    ;;
                *)
                    group_mode="off"
                    ;;
                esac
            fi

            # Sanitize the prefix length: positive integer only, else default.
            case "$prefix_len_raw" in
            '' | *[!0-9]*)
                prefix_len="$SUBSCRIPTION_GROUP_DEFAULT_PREFIX_LEN"
                ;;
            *)
                if [ "$prefix_len_raw" -ge 1 ] 2>/dev/null; then
                    prefix_len="$prefix_len_raw"
                else
                    prefix_len="$SUBSCRIPTION_GROUP_DEFAULT_PREFIX_LEN"
                fi
                ;;
            esac

            if [ "$group_mode" = "prefix" ]; then
                log "Subscription grouping for section '$section': mode=$group_mode, prefix_len=$prefix_len" "debug"
            else
                log "Subscription grouping for section '$section': mode=$group_mode" "debug"
            fi

            # Keyword whitelist/blacklist filtering of subscription nodes by
            # display name. Both are optional UCI lists of opaque match strings.
            subscription_filter_include_keywords_json="$(build_subscription_filter_keywords_json "$section" "subscription_filter_include_keywords")"
            subscription_filter_exclude_keywords_json="$(build_subscription_filter_keywords_json "$section" "subscription_filter_exclude_keywords")"
            subscription_keyword_filter_active=0
            if [ "$subscription_filter_include_keywords_json" != "[]" ] || [ "$subscription_filter_exclude_keywords_json" != "[]" ]; then
                subscription_keyword_filter_active=1
                log "Subscription keyword filter enabled for section '$section': include=$subscription_filter_include_keywords_json, exclude=$subscription_filter_exclude_keywords_json" "debug"
            fi

            mkdir -p "$TMP_SUBSCRIPTION_FOLDER"
            ensure_subscription_cache_dir || true
            migrate_subscription_cache_from_tmp
            # Always hash per URL; reap any stale legacy bare-"${section}.<ext>".
            reap_legacy_subscription_cache_files "$section"

            subscription_urls_tmp="$(mktemp "${TMPDIR:-/tmp}/netshift-cfg-urls.XXXXXX")" || {
                log "Failed to enumerate subscription URLs for section '$section'. Aborted." "fatal"
                exit 1
            }
            get_subscription_urls_for_section "$section" > "$subscription_urls_tmp"
            subscription_url_count=0
            while IFS= read -r subscription_url || [ -n "$subscription_url" ]; do
                [ -n "$subscription_url" ] && subscription_url_count=$((subscription_url_count + 1))
            done < "$subscription_urls_tmp"

            if [ "$subscription_url_count" -eq 0 ]; then
                rm -f "$subscription_urls_tmp"
                log "Subscription URL is not set. Aborted." "fatal"
                exit 1
            fi

            log "Section '$section' has $subscription_url_count subscription feed(s)" "debug"

            # ── Per-feed download (best-effort) ─────────────────────────────
            # Each URL is downloaded/validated/normalized into its own per-URL
            # cache. One dead/empty feed must NOT abort the others.
            while IFS= read -r subscription_url || [ -n "$subscription_url" ]; do
                [ -n "$subscription_url" ] || continue
                urlhash="$(get_subscription_url_hash "$subscription_url")"
                subscription_json_path="$(get_subscription_json_path "$section" "$urlhash")"
                subscription_url_cache_path="$(get_subscription_url_cache_path "$section" "$urlhash")"
                should_download=0
                had_usable_cache=0

                if subscription_cache_is_usable "$subscription_json_path"; then
                    had_usable_cache=1
                else
                    rm -f "$subscription_json_path"
                    should_download=1
                fi

                if [ -f "$subscription_url_cache_path" ]; then
                    cached_subscription_url="$(cat "$subscription_url_cache_path" 2>/dev/null)"
                else
                    cached_subscription_url=""
                fi

                if [ "$cached_subscription_url" != "$subscription_url" ]; then
                    if [ "$had_usable_cache" -eq 0 ]; then
                        should_download=1
                    else
                        log "Using cached subscription feed for section '$section' (url=$(redact_url_for_log "$subscription_url")) until a fresh download succeeds" "warn"
                    fi
                fi

                if [ "$should_download" -eq 1 ]; then
                    log "Downloading subscription feed for section '$section' (url=$(redact_url_for_log "$subscription_url"))"
                    # Config generation runs before sing-box is started. Never
                    # use its local download proxy here; runtime refresh will.
                    service_proxy_address="$(get_subscription_download_proxy_address "$section" "bootstrap" 2>/dev/null || echo '')"

                    if ! wait_for_subscription_connectivity "$section" "$subscription_url" "$service_proxy_address" 1 0 5 ||
                        ! download_subscription_into_cache \
                            "$section" "$subscription_url" "$subscription_json_path" "$subscription_url_cache_path" "$service_proxy_address" "$urlhash"; then
                        if [ "$had_usable_cache" -eq 1 ]; then
                            log "Failed to refresh subscription feed for section '$section' (url=$(redact_url_for_log "$subscription_url")), continuing with cached data" "warn"
                        else
                            log "Failed to download subscription feed for section '$section' (url=$(redact_url_for_log "$subscription_url"))" "warn"
                        fi
                    fi
                fi
            done < "$subscription_urls_tmp"

            # ── Merge all usable per-URL caches into one JSON ───────────────
            # Concatenate the proxy .outbounds[] of every usable feed into a
            # single { "outbounds": [...] } temp file, then run the existing
            # facade ONCE so its keyword filter + global tag-dedup (auto -2/-3
            # for same-named nodes across feeds) + per-batch sing-box check
            # bisection + the country-group/selector builder all operate over
            # the MERGED union, exactly as for a single feed.
            mkdir -p "$TMP_SUBSCRIPTION_MERGE_FOLDER"
            merged_json_path="$(mktemp "$TMP_SUBSCRIPTION_MERGE_FOLDER/${section}.merged.XXXXXX")" || {
                rm -f "$subscription_urls_tmp"
                log "Failed to create merged subscription file for section '$section'. Aborted." "fatal"
                exit 1
            }
            printf '%s' '{"outbounds":[]}' > "$merged_json_path"
            usable_feed_count=0
            while IFS= read -r subscription_url || [ -n "$subscription_url" ]; do
                [ -n "$subscription_url" ] || continue
                urlhash="$(get_subscription_url_hash "$subscription_url")"
                subscription_json_path="$(get_subscription_json_path "$section" "$urlhash")"
                subscription_cache_is_usable "$subscription_json_path" || {
                    log "Subscription feed for section '$section' has no usable cache; skipping (url=$(redact_url_for_log "$subscription_url"))" "warn"
                    continue
                }

                feed_node_count="$(jq -r '[.outbounds[]? | select(
                    .type != "selector" and .type != "urltest" and
                    .type != "direct" and .type != "dns" and .type != "block"
                )] | length' "$subscription_json_path" 2>/dev/null)"
                [ -n "$feed_node_count" ] || feed_node_count=0

                # Append this feed's proxy outbounds onto the merged set. No
                # Oniguruma: pure array concat with --slurpfile.
                merged_tmp="${merged_json_path}.tmp.$$"
                if jq -c --slurpfile feed "$subscription_json_path" '
                    .outbounds += [ $feed[0].outbounds[]? | select(
                        .type != "selector" and .type != "urltest" and
                        .type != "direct" and .type != "dns" and .type != "block"
                    ) ]
                ' "$merged_json_path" > "$merged_tmp" 2>/dev/null && [ -s "$merged_tmp" ]; then
                    mv "$merged_tmp" "$merged_json_path"
                    usable_feed_count=$((usable_feed_count + 1))
                    log "Subscription feed for section '$section' contributed $feed_node_count node(s) (url=$(redact_url_for_log "$subscription_url"))" "info"
                else
                    rm -f "$merged_tmp"
                    log "Failed to merge subscription feed for section '$section' (url=$(redact_url_for_log "$subscription_url"))" "warn"
                fi
            done < "$subscription_urls_tmp"
            rm -f "$subscription_urls_tmp"

            merged_node_count="$(jq -r '.outbounds | length' "$merged_json_path" 2>/dev/null)"
            [ -n "$merged_node_count" ] || merged_node_count=0
            log "Subscription for section '$section': merged $merged_node_count node(s) from $usable_feed_count usable feed(s)" "info"

            subscription_ready=0
            # A merged file with >=1 proxy outbound is a real subscription set.
            if [ "$usable_feed_count" -gt 0 ] && [ "$merged_node_count" -gt 0 ]; then
                chmod 600 "$merged_json_path" 2>/dev/null
                if sing_box_cf_add_subscription_outbounds "$config" "$section" "$merged_json_path" \
                    "$subscription_filter_include_keywords_json" "$subscription_filter_exclude_keywords_json" > /dev/null; then
                    if [ -n "$SUBSCRIPTION_OUTBOUND_TAGS" ]; then
                        config="$SING_BOX_CF_LAST_CONFIG"
                        subscription_ready=1
                    fi
                fi
            fi
            rm -f "$merged_json_path"

            if [ "$subscription_ready" -eq 0 ]; then
                # When the keyword filter empties the set, the precise cause is
                # already logged facade-side (only on the cache-usable branch
                # where add_subscription_outbounds actually ran the filter), so we
                # do not re-warn here to avoid misattribution / double-warning.
                log "Subscription cache for section '$section' is unavailable or empty; using a temporary blocked outbound" "warn"
                mark_subscription_outbound_unavailable "$section" "$subscription_keyword_filter_active"
            else
                selector_tag="$(get_outbound_tag_by_section "$section")"
                subscription_outbound_tags_json="$SUBSCRIPTION_OUTBOUND_TAGS_JSON"
                if [ -z "$subscription_outbound_tags_json" ] || [ "$subscription_outbound_tags_json" = "[]" ]; then
                    subscription_outbound_tags_json="$(comma_string_to_json_array "$SUBSCRIPTION_OUTBOUND_TAGS")"
                fi

                if [ "$group_mode" != "off" ]; then
                    local grouping_json group_key group_outbounds group_tag group_keys_tmp \
                        selector_outbounds_json selector_default ungrouped_outbounds_json grouped_count ungrouped_count \
                        group_tags_json group_tags_count fastest_tag

                    grouping_json="$(sing_box_build_subscription_groups "$subscription_outbound_tags_json" "$group_mode" "$prefix_len")"
                    if [ -z "$grouping_json" ]; then
                        log "Failed to build grouped subscription outbounds for section '$section'. Aborted." "fatal"
                        exit 1
                    fi

                    grouped_count="$(echo "$grouping_json" | jq -r '.group_order | length' 2>/dev/null)"
                    ungrouped_count="$(echo "$grouping_json" | jq -r '.ungrouped | length' 2>/dev/null)"
                    log "Subscription grouping prepared for section '$section' (mode=$group_mode): groups=$grouped_count, ungrouped=$ungrouped_count" "debug"

                    selector_outbounds_json="[]"

                    # Iterate group keys WITHOUT word-splitting: prefix-mode keys
                    # can legitimately contain spaces (e.g. a "letter+space"
                    # prefix), which a `for k in $(...)` loop would shatter. Use
                    # the same mktemp + `while read < file` pattern as the URL
                    # loop above so the body runs in the CURRENT shell and its
                    # mutations to $config / $selector_outbounds_json survive
                    # (a `... | while read` body would run in a subshell and lose
                    # them). One key per line; group keys never contain newlines.
                    group_keys_tmp="$(mktemp "${TMPDIR:-/tmp}/netshift-cfg-groupkeys.XXXXXX")" || {
                        log "Failed to enumerate subscription group keys for section '$section'. Aborted." "fatal"
                        exit 1
                    }
                    echo "$grouping_json" | jq -r '.group_order[]' 2>/dev/null > "$group_keys_tmp"
                    while IFS= read -r group_key || [ -n "$group_key" ]; do
                        group_outbounds="$(echo "$grouping_json" | jq -c --arg group_key "$group_key" '.groups[$group_key] // []' 2>/dev/null)"
                        if [ -z "$group_outbounds" ] || [ "$group_outbounds" = "[]" ]; then
                            continue
                        fi

                        group_tag="$(sing_box_get_unique_outbound_tag "$config" "$group_key Fastest")"
                        config="$(sing_box_cm_add_urltest_outbound "$config" "$group_tag" "$group_outbounds" \
                            "$urltest_testing_url" "$urltest_check_interval" "$urltest_tolerance")"

                        selector_outbounds_json=$(
                            printf '%s' "$selector_outbounds_json" | jq -ac --arg tag "$group_tag" '. + [$tag]' 2>/dev/null
                        )
                    done < "$group_keys_tmp"
                    rm -f "$group_keys_tmp"

                    if [ -z "$selector_outbounds_json" ]; then
                        selector_outbounds_json="[]"
                    fi

                    # At this point $selector_outbounds_json holds ONLY the
                    # per-group urltest tags ("<key> Fastest"), before ungrouped
                    # nodes are appended below. Capture that group-only list so
                    # we can build a top-level "Fastest" urltest OVER the groups
                    # (a urltest of urltests) and default the selector to it.
                    group_tags_json="$selector_outbounds_json"
                    group_tags_count="$(printf '%s' "$group_tags_json" | jq -r 'length' 2>/dev/null)"
                    [ -n "$group_tags_count" ] || group_tags_count=0

                    fastest_tag=""
                    # Only nest when there are >= 2 groups. With exactly 1 group
                    # the lone group urltest IS already the fastest (a urltest
                    # over a single member is redundant), so we skip the extra
                    # layer and fall back to the lone group as the default. With
                    # 0 groups (everything ungrouped) there is nothing to nest;
                    # never emit an empty-member urltest.
                    if [ "$group_tags_count" -ge 2 ]; then
                        fastest_tag="$(sing_box_get_unique_outbound_tag "$config" "$SB_SUBSCRIPTION_FASTEST_GROUP_TAG")"
                        # Reuse the section's urltest probe knobs (testing URL,
                        # check interval, tolerance) so the extra probe layer's
                        # cadence stays user-tunable; do NOT hardcode an
                        # aggressive interval. This adds one more probe layer
                        # (Fastest tests each group; each group tests its nodes).
                        config="$(sing_box_cm_add_urltest_outbound "$config" "$fastest_tag" "$group_tags_json" \
                            "$urltest_testing_url" "$urltest_check_interval" "$urltest_tolerance")"
                        selector_outbounds_json="$(jq -acn --arg t "$fastest_tag" --argjson rest "$selector_outbounds_json" '[$t] + $rest')"
                    fi

                    ungrouped_outbounds_json="$(echo "$grouping_json" | jq -c '.ungrouped // []' 2>/dev/null)"
                    if [ -n "$ungrouped_outbounds_json" ] && [ "$ungrouped_outbounds_json" != "[]" ]; then
                        selector_outbounds_json=$(
                            jq -acn --argjson selector "$selector_outbounds_json" --argjson ungrouped "$ungrouped_outbounds_json" \
                                '$selector + $ungrouped' 2>/dev/null
                        )
                    fi

                    if [ -z "$selector_outbounds_json" ] || [ "$selector_outbounds_json" = "[]" ]; then
                        log "No selector outbounds available after grouping subscription outbounds for section '$section'. Aborted." "fatal"
                        exit 1
                    fi

                    if [ -n "$fastest_tag" ]; then
                        # >= 2 groups: the cross-group "Fastest" urltest is the
                        # default (it was prepended above, so it is also .[0]).
                        selector_default="$fastest_tag"
                    else
                        # 0 or 1 groups: keep the existing "first element"
                        # default (the lone group urltest, or the first
                        # ungrouped node).
                        selector_default="$(echo "$selector_outbounds_json" | jq -r '.[0] // ""' 2>/dev/null)"
                    fi
                    if [ -z "$selector_default" ] || [ "$selector_default" = "null" ]; then
                        log "Unable to determine default selector outbound for section '$section'. Aborted." "fatal"
                        exit 1
                    fi

                    selector_outbounds="$selector_outbounds_json"
                    config="$(sing_box_cm_add_selector_outbound "$config" "$selector_tag" "$selector_outbounds" "$selector_default" "true")"
                else
                    # Create urltest + selector (default subscription behaviour)
                    urltest_tag="$(get_outbound_tag_by_section "$section-urltest")"
                    urltest_outbounds="$subscription_outbound_tags_json"
                    selector_outbounds=$(
                        jq -acn --argjson outbounds "$subscription_outbound_tags_json" --arg tag "$urltest_tag" \
                            '$outbounds + [$tag]' 2>/dev/null
                    )
                    if [ -z "$selector_outbounds" ]; then
                        log "Failed to build selector outbounds for subscription section '$section'. Aborted." "fatal"
                        exit 1
                    fi
                    config="$(sing_box_cm_add_urltest_outbound "$config" "$urltest_tag" "$urltest_outbounds" \
                        "$urltest_testing_url" "$urltest_check_interval" "$urltest_tolerance")"
                    config="$(sing_box_cm_add_selector_outbound "$config" "$selector_tag" "$selector_outbounds" "$urltest_tag" "true")"
                fi
            fi
            ;;
        *)
            log "Unknown proxy configuration type: '$proxy_config_type'. Aborted." "fatal"
            exit 1
            ;;
        esac
        ;;
    vpn)
        log "Configuring outbound in VPN connection type for the $section section"
        local interface_name domain_resolver_enabled domain_resolver_dns_type domain_resolver_dns_server \
            domain_resolver_dns_server_address outbound_tag domain_resolver_tag dns_domain_resolver

        config_get interface_name "$section" "interface"
        config_get domain_resolver_enabled "$section" "domain_resolver_enabled"
        config_get domain_resolver_dns_type "$section" "domain_resolver_dns_type"
        config_get domain_resolver_dns_server "$section" "domain_resolver_dns_server"

        if [ -z "$interface_name" ]; then
            log "VPN interface is not set. Aborted." "fatal"
            exit 1
        fi

        local outbound_tag
        outbound_tag="$(get_outbound_tag_by_section "$section")"

        if [ "$domain_resolver_enabled" -eq 1 ]; then
            domain_resolver_dns_server_address="$(url_get_host "$dns_server")"
            if ! is_ipv4 "$domain_resolver_dns_server_address"; then
                dns_domain_resolver=$SB_BOOTSTRAP_SERVER_TAG
            fi
            domain_resolver_tag="$(get_domain_resolver_tag "$section")"
            config=$(sing_box_cf_add_dns_server "$config" "$domain_resolver_dns_type" "$domain_resolver_tag" \
                "$domain_resolver_dns_server" "$dns_domain_resolver" "$outbound_tag")
        fi

        config=$(sing_box_cm_add_interface_outbound "$config" "$outbound_tag" "$interface_name" "$domain_resolver_tag")
        ;;
    block)
        log "Connection type 'block' detected for the $section section – no outbound will be created (handled via reject route rules)"
        ;;
    exclusion)
        log "Connection type 'exclusion' detected for the $section section – no outbound will be created (handled via route rules)"
        ;;
    *)
        log "Unknown connection type '$connection_type' for the $section section. Aborted." "fatal"
        exit 1
        ;;
    esac
}

sing_box_configure_dns() {
    log "Configure the DNS section of a sing-box JSON configuration"
    local dns_strategy
    if netshift_ipv6_enabled; then
        dns_strategy="prefer_ipv4"
    else
        dns_strategy="ipv4_only"
    fi
    config=$(sing_box_cm_configure_dns "$config" "$SB_DNS_SERVER_TAG" "$dns_strategy" true)

    log "Adding DNS Servers" "debug"
    local dns_type dns_server bootstrap_dns_server dns_domain_resolver dns_server_address
    config_get dns_type "settings" "dns_type" "doh"
    config_get dns_server "settings" "dns_server" "1.1.1.1"
    config_get bootstrap_dns_server "settings" "bootstrap_dns_server" "77.88.8.8"

    local block_doh
    config_get_bool block_doh "settings" "block_doh" 0
    if [ "$block_doh" -eq 1 ] && [ "$dns_type" = "doh" ]; then
        log "DoH blocking is enabled but upstream DNS type is 'doh'. Your own DNS queries will be blocked." "warn"
        log "Switch dns_type to 'udp' or 'dot' in NetShift settings to avoid self-blocking." "warn"
    fi

    dns_server_address="$(url_get_host "$dns_server")"
    if ! is_ipv4 "$dns_server_address"; then
        dns_domain_resolver=$SB_BOOTSTRAP_SERVER_TAG
    fi

    # Only the MAIN DNS server may carry a detour. Bootstrap stays direct (it
    # resolves the DoH/DoT hostname before the tunnel is up) and FakeIP stays
    # direct. An empty tag means no detour -> byte-identical to the off path.
    local dns_detour_tag
    dns_detour_tag="$(_get_dns_detour_tag)"

    config=$(sing_box_cm_add_udp_dns_server "$config" "$SB_BOOTSTRAP_SERVER_TAG" "$bootstrap_dns_server" 53)
    config=$(sing_box_cf_add_dns_server "$config" "$dns_type" "$SB_DNS_SERVER_TAG" "$dns_server" "$dns_domain_resolver" "$dns_detour_tag")
    if netshift_ipv6_enabled; then
        config=$(sing_box_cm_add_fakeip_dns_server "$config" "$SB_FAKEIP_DNS_SERVER_TAG" "$SB_FAKEIP_INET4_RANGE" "$SB_FAKEIP_INET6_RANGE")
    else
        config=$(sing_box_cm_add_fakeip_dns_server "$config" "$SB_FAKEIP_DNS_SERVER_TAG" "$SB_FAKEIP_INET4_RANGE" "")
    fi

    log "Adding DNS Rules"
    local rewrite_ttl service_domains
    config_get rewrite_ttl "settings" "dns_rewrite_ttl" "60"

    log "Adding DNS-level DoH prevention rules" "debug"
    config=$(sing_box_cm_add_dns_reject_rule "$config" "query_type" "HTTPS")
    config=$(sing_box_cm_add_dns_reject_rule "$config" "domain_suffix" '"use-application-dns.net"')
    config=$(sing_box_cm_add_dns_route_rule "$config" "$SB_FAKEIP_DNS_SERVER_TAG" "$SB_FAKEIP_DNS_RULE_TAG")
    config=$(sing_box_cm_patch_dns_route_rule "$config" "$SB_FAKEIP_DNS_RULE_TAG" "rewrite_ttl" "$rewrite_ttl")
    service_domains=$(comma_string_to_json_array "$FAKEIP_TEST_DOMAIN,$CHECK_PROXY_IP_DOMAIN")
    config=$(sing_box_cm_patch_dns_route_rule "$config" "$SB_FAKEIP_DNS_RULE_TAG" "domain" "$service_domains")
}

sing_box_configure_route() {
    log "Configure the route section of a sing-box JSON configuration"

    local route_final global_proxy_section
    route_final="$SB_DIRECT_OUTBOUND_TAG"
    global_proxy_section="$(get_global_proxy_section)"
    if [ -n "$global_proxy_section" ]; then
        if subscription_outbound_is_unavailable "$global_proxy_section"; then
            log "Global proxy section '$global_proxy_section' is unavailable; routing unmatched traffic directly until it recovers" "warn"
        else
            route_final="$(get_outbound_tag_by_section "$global_proxy_section")"
            log "Global proxy mode enabled: routing all unmatched traffic through section '$global_proxy_section' (outbound: $route_final)" "info"
        fi
    fi

    # Stamp every sing-box-originated egress connection with NFT_OUTBOUND_MARK
    # (route.default_mark). The nft mangle prerouting chain marks ALL LAN traffic
    # with NFT_FAKEIP_MARK and `ip rule ... fwmark NFT_FAKEIP_MARK lookup
    # netshift` redirects it to the tproxy path. Without an egress mark,
    # sing-box's own outbound sockets (especially direct-out, which carries ALL
    # unmatched traffic) inherit the tproxy SO_MARK (NFT_FAKEIP_MARK), so the
    # `ip rule` re-captures them into `local default dev lo` -> they loop back
    # into tproxy and never reach the internet (the 0.8.6 "direct-out i/o
    # timeout" regression). Marking egress with NFT_OUTBOUND_MARK makes the
    # `ip rule` (which matches only NFT_FAKEIP_MARK) skip these packets so they
    # egress via the main table, and the existing `mangle_output meta mark
    # NFT_OUTBOUND_MARK return` rule keeps them out of the proxy chain. This is
    # fail-open: unmatched/unproxied traffic always reaches the internet even
    # when a section's outbound is unreachable. sing-box default_mark is an
    # integer; convert the hex constant to decimal via ash arithmetic.
    local default_egress_mark
    default_egress_mark=$(( NFT_OUTBOUND_MARK ))

    local output_network_interface
    config_get output_network_interface "settings" "output_network_interface"
    if [ -z "$output_network_interface" ]; then
        config=$(sing_box_cm_configure_route "$config" "$route_final" true "$SB_DNS_SERVER_TAG" "" \
            "$default_egress_mark")
    else
        config=$(sing_box_cm_configure_route "$config" "$route_final" false "$SB_DNS_SERVER_TAG" \
            "$output_network_interface" "$default_egress_mark")
    fi

    local sniff_inbounds sniff_inbounds_csv
    sniff_inbounds_csv="$SB_TPROXY_INBOUND_TAG,$SB_DNS_INBOUND_TAG"
    if netshift_ipv6_enabled; then
        sniff_inbounds_csv="$sniff_inbounds_csv,${SB_TPROXY_INBOUND_TAG}-v6,${SB_DNS_INBOUND_TAG}-v6"
    fi
    sniff_inbounds=$(comma_string_to_json_array "$sniff_inbounds_csv")
    config=$(sing_box_cm_sniff_route_rule "$config" "inbound" "$sniff_inbounds")

    config=$(sing_box_cm_add_hijack_dns_route_rule "$config" "protocol" "dns")

    local disable_quic
    config_get_bool disable_quic "settings" "disable_quic" 0
    if [ "$disable_quic" -eq 1 ]; then
        config=$(sing_box_cf_add_single_key_reject_rule "$config" "$SB_TPROXY_INBOUND_TAG" "protocol" "quic")
    fi

    local block_doh
    config_get_bool block_doh "settings" "block_doh" 0
    if [ "$block_doh" -eq 1 ]; then
        log "DoH blocking enabled: adding route-level DoH IP block rules" "info"
        config=$(sing_box_cm_add_doh_block_route_rule "$config" "$SB_DOH_BLOCK_RULE_TAG" "$SB_TPROXY_INBOUND_TAG" \
            "$DOH_BLOCK_IPV4_CIDRS" "$DOH_BLOCK_IPV6_CIDRS")
    fi

    local first_outbound_section
    first_outbound_section="$(get_first_outbound_section)"
    if subscription_outbound_is_unavailable "$first_outbound_section"; then
        log "First configured outbound section '$first_outbound_section' is unavailable; proxy test traffic will be rejected until its subscription refresh succeeds" "warn"
        first_outbound_tag=""
    else
        first_outbound_tag="$(get_outbound_tag_by_section "$first_outbound_section")"
    fi
    if [ -n "$first_outbound_tag" ]; then
        config=$(sing_box_cf_proxy_domain "$config" "$SB_TPROXY_INBOUND_TAG" "$CHECK_PROXY_IP_DOMAIN" "$first_outbound_tag")
    else
        config=$(sing_box_cf_add_single_key_reject_rule "$config" "$SB_TPROXY_INBOUND_TAG" "domain" "$CHECK_PROXY_IP_DOMAIN")
    fi
    config=$(sing_box_cf_override_domain_port "$config" "$FAKEIP_TEST_DOMAIN" 8443)

    configure_common_reject_route_rule
    configure_common_direct_route_rule

    local routing_excluded_ips
    config_get routing_excluded_ips "settings" "routing_excluded_ips"
    if [ -n "$routing_excluded_ips" ]; then
        rule_tag="$(gen_id)"
        config=$(sing_box_cm_add_route_rule "$config" "$rule_tag" "$SB_TPROXY_INBOUND_TAG" "$SB_DIRECT_OUTBOUND_TAG")
        config_list_foreach "settings" "routing_excluded_ips" exclude_source_ip_from_routing_handler "$rule_tag"
    fi

    config_foreach include_source_ips_in_routing_handler "section"

    config_foreach configure_routing_for_section_lists "section"
}

include_source_ips_in_routing_handler() {
    local section="$1"

    local fully_routed_ips rule_tag
    config_get fully_routed_ips "$section" "fully_routed_ips"
    if [ -n "$fully_routed_ips" ]; then
        rule_tag="$(gen_id)"
        if subscription_outbound_is_unavailable "$section"; then
            config="$(sing_box_cm_add_reject_route_rule "$config" "$rule_tag" "$SB_TPROXY_INBOUND_TAG")"
        else
            config=$(
                sing_box_cm_add_route_rule \
                    "$config" "$rule_tag" "$SB_TPROXY_INBOUND_TAG" "$(get_outbound_tag_by_section "$section")"
            )
        fi
        config_list_foreach "$section" "fully_routed_ips" include_source_ip_in_routing_handler "$rule_tag"
    fi
}

configure_common_reject_route_rule() {
    local block_sections block_section_lists_enabled
    block_sections="$(get_sections_by_connection_type "block")"
    block_section_lists_enabled=0

    if [ -n "$block_sections" ]; then
        for block_section in $block_sections; do
            if section_has_enabled_lists "$block_section"; then
                block_section_lists_enabled=1
                break
            fi
        done
        if [ "$block_section_lists_enabled" -eq 1 ]; then
            config=$(sing_box_cm_add_reject_route_rule "$config" "$SB_REJECT_RULE_TAG" "$SB_TPROXY_INBOUND_TAG")
        else
            log "Block sections does not have any enabled list, reject rule is not required" "warn"
        fi
    fi
}

configure_common_direct_route_rule() {
    local exclusion_sections exclusion_section_list_enabled global_proxy_direct_needed
    exclusion_sections="$(get_sections_by_connection_type "exclusion")"
    exclusion_section_list_enabled=0

    if [ -n "$exclusion_sections" ]; then
        for exclusion_section in $exclusion_sections; do
            if section_has_enabled_lists "$exclusion_section"; then
                exclusion_section_list_enabled=1
                break
            fi
        done
    fi

    global_proxy_direct_needed=0
    if [ "$exclusion_section_list_enabled" -eq 0 ]; then
        config_foreach _check_global_proxy_direct_needed "section"
    fi

    if [ "$exclusion_section_list_enabled" -eq 1 ] || [ "$global_proxy_direct_needed" -eq 1 ]; then
        config=$(sing_box_cm_add_route_rule "$config" "$SB_EXCLUSION_RULE_TAG" "$SB_TPROXY_INBOUND_TAG" \
            "$SB_DIRECT_OUTBOUND_TAG")
    else
        log "No direct exclusion rules required" "warn"
    fi
}

_check_global_proxy_direct_needed() {
    local section="$1"
    local global_proxy

    config_get_bool global_proxy "$section" "global_proxy" 0
    if [ "$global_proxy" -eq 1 ] && section_has_enabled_lists "$section"; then
        global_proxy_direct_needed=1
    fi
}

include_source_ip_in_routing_handler() {
    local source_ip="$1"
    local rule_tag="$2"

    config=$(sing_box_cm_patch_route_rule "$config" "$rule_tag" "source_ip_cidr" "$source_ip")
}

exclude_source_ip_from_routing_handler() {
    local source_ip="$1"
    local rule_tag="$2"

    config=$(sing_box_cm_patch_route_rule "$config" "$rule_tag" "source_ip_cidr" "$source_ip")
}

# config_list_foreach callback that appends a raw keyword (opaque user text:
# may contain spaces, emoji, Cyrillic, &?#%) onto the JSON array carried in the
# global SUBSCRIPTION_FILTER_KEYWORDS_JSON. Items are appended verbatim; the
# facade's jq filter later drops empty ("") items via select(length > 0). A
# whitespace-only item is NOT trimmed and stays a literal match string
# (intentional opaque match semantics per the spec). Uses jq --arg so every byte
# survives intact (no URL/query parsing).
append_subscription_filter_keyword_handler() {
    local keyword="$1"
    local next

    next=$(printf '%s' "$SUBSCRIPTION_FILTER_KEYWORDS_JSON" |
        jq -c --arg kw "$keyword" '. + [$kw]' 2>/dev/null)
    [ -n "$next" ] && SUBSCRIPTION_FILTER_KEYWORDS_JSON="$next"
}

# Read a per-section UCI list of keyword filter values into a JSON array string.
# Arguments:
#   section: string, the UCI section name
#   list_name: string, the UCI list option name
# Outputs:
#   Writes the JSON array (e.g. ["grpc","\ud83e\udd16"]) to stdout; "[]" if empty.
build_subscription_filter_keywords_json() {
    local section="$1"
    local list_name="$2"

    SUBSCRIPTION_FILTER_KEYWORDS_JSON="[]"
    config_list_foreach "$section" "$list_name" append_subscription_filter_keyword_handler
    printf '%s' "$SUBSCRIPTION_FILTER_KEYWORDS_JSON"
}

configure_routing_for_section_lists() {
    local section="$1"

    log "Configuring routing for '$section' section"
    if ! section_has_enabled_lists "$section"; then
        log "Section '$section' does not have any enabled list, skipping..." "warn"
        return 0
    fi

    local community_lists user_domain_list_type user_subnet_list_type local_domain_lists local_subnet_lists \
        remote_domain_lists remote_subnet_lists section_connection_type route_rule_tag resolve_real_ip_for_routing outbound_tag
    config_get community_lists "$section" "community_lists"
    config_get user_domain_list_type "$section" "user_domain_list_type" "disabled"
    config_get user_subnet_list_type "$section" "user_subnet_list_type" "disabled"
    config_get local_domain_lists "$section" "local_domain_lists"
    config_get local_subnet_lists "$section" "local_subnet_lists"
    config_get remote_domain_lists "$section" "remote_domain_lists"
    config_get remote_subnet_lists "$section" "remote_subnet_lists"
    config_get section_connection_type "$section" "connection_type"
    config_get_bool resolve_real_ip_for_routing "$section" "resolve_real_ip_for_routing" 0

    case "$section_connection_type" in
    proxy | vpn)
        local global_proxy
        config_get_bool global_proxy "$section" "global_proxy" 0
        if [ "$global_proxy" -eq 1 ]; then
            route_rule_tag="$SB_EXCLUSION_RULE_TAG"
        else
            route_rule_tag="$(gen_id)"
            if subscription_outbound_is_unavailable "$section"; then
                config="$(sing_box_cm_add_reject_route_rule "$config" "$route_rule_tag" "$SB_TPROXY_INBOUND_TAG")"
            else
                outbound_tag=$(get_outbound_tag_by_section "$section")
                config=$(sing_box_cm_add_route_rule "$config" "$route_rule_tag" "$SB_TPROXY_INBOUND_TAG" "$outbound_tag")
            fi
        fi
        ;;
    block)
        route_rule_tag="$SB_REJECT_RULE_TAG"
        ;;
    exclusion)
        route_rule_tag="$SB_EXCLUSION_RULE_TAG"
        ;;
    *)
        log "Unsupported '$section_connection_type' connection type. Skipping routing for '$section' section" "fatal"
        exit 1
        ;;
    esac

    if [ -n "$community_lists" ]; then
        log "Processing community list routing rules for '$section' section"
        config_list_foreach "$section" "community_lists" configure_community_list_handler "$section" "$route_rule_tag"
    fi

    if [ "$user_domain_list_type" != "disabled" ]; then
        log "Processing user domains routing rules for '$section' section"
        configure_user_domain_list "$section" "$route_rule_tag"
    fi

    if [ "$user_subnet_list_type" != "disabled" ]; then
        log "Processing user subnets routing rules for '$section' section"
        configure_user_subnet_list "$section" "$route_rule_tag"
    fi

    if [ -n "$local_domain_lists" ]; then
        log "Processing local domains routing rules for '$section' section"
        configure_local_domain_lists "$section" "$route_rule_tag"
    fi

    if [ -n "$local_subnet_lists" ]; then
        log "Processing local subnets routing rules for '$section' section"
        configure_local_subnet_lists "$section" "$route_rule_tag"
    fi

    if [ -n "$remote_domain_lists" ]; then
        log "Processing remote domains routing rules for '$section' section"
        config_list_foreach "$section" "remote_domain_lists" configure_remote_domain_or_subnet_list_handler \
            "domains" "$section" "$route_rule_tag"
    fi

    if [ -n "$remote_subnet_lists" ]; then
        log "Processing remote subnets routing rules for '$section' section"
        config_list_foreach "$section" "remote_subnet_lists" configure_remote_domain_or_subnet_list_handler \
            "subnets" "$section" "$route_rule_tag"
    fi

    if [ "$resolve_real_ip_for_routing" -eq 1 ]; then
        config=$(sing_box_cm_add_resolve_rule "$config" "$route_rule_tag" "$(gen_id)" "$SB_DNS_SERVER_TAG")
        log "Added resolve rule for '$section' section" "debug"
    fi
}

configure_community_list_handler() {
    local tag="$1"
    local section="$2"
    local route_rule_tag="$3"

    local ruleset_tag format url update_interval detour
    ruleset_tag="$(get_ruleset_tag "$section" "$tag" "community")"
    format="binary"
    url="$SRS_MAIN_URL/$tag.srs"
    detour="$(get_download_detour_tag)"
    config_get update_interval "settings" "update_interval" "1d"

    config=$(sing_box_cm_add_remote_ruleset "$config" "$ruleset_tag" "$format" "$url" "$detour" "$update_interval")
    config=$(sing_box_cm_patch_route_rule "$config" "$route_rule_tag" "rule_set" "$ruleset_tag")
    config=$(sing_box_cm_patch_dns_route_rule "$config" "$SB_FAKEIP_DNS_RULE_TAG" "rule_set" "$ruleset_tag")
}

prepare_source_ruleset() {
    local section="$1"
    local name="$2"
    local type="$3"
    local route_rule_tag="$4"

    log "Preparing a $name $type rule set for '$section' section" "debug"
    ruleset_tag=$(get_ruleset_tag "$section" "$name" "$type")
    ruleset_filepath="$TMP_RULESET_FOLDER/$ruleset_tag.json"
    create_source_rule_set "$ruleset_filepath"
    case $? in
    0)
        config=$(sing_box_cm_add_local_ruleset "$config" "$ruleset_tag" "source" "$ruleset_filepath")
        config=$(sing_box_cm_patch_route_rule "$config" "$route_rule_tag" "rule_set" "$ruleset_tag")
        case "$type" in
        domains)
            config=$(sing_box_cm_patch_dns_route_rule "$config" "$SB_FAKEIP_DNS_RULE_TAG" "rule_set" "$ruleset_tag")
            ;;
        subnets) ;;
        *)
            log "Unsupported remote rule set type: $type" "error"
            return 1
            ;;
        esac
        ;;
    3) log "Source rule set $ruleset_filepath already exists, skipping." "debug" ;;
    esac
}

configure_user_domain_list() {
    local section="$1"
    local route_rule_tag="$2"

    prepare_source_ruleset "$section" "user" "domains" "$route_rule_tag"

    local user_domain_list_type items json_array
    config_get user_domain_list_type "$section" "user_domain_list_type"
    case "$user_domain_list_type" in
    dynamic) config_get items "$section" "user_domains" ;;
    text) config_get items "$section" "user_domains_text" ;;
    esac

    items="$(parse_domain_or_subnet_string_to_commas_string "$items" "domains")"
    json_array="$(comma_string_to_json_array "$items")"
    patch_source_ruleset_rules "$ruleset_filepath" "domain_suffix" "$json_array"
}

configure_user_subnet_list() {
    local section="$1"
    local route_rule_tag="$2"

    prepare_source_ruleset "$section" "user" "subnets" "$route_rule_tag"

    local user_subnet_list_type items json_array
    config_get user_subnet_list_type "$section" "user_subnet_list_type"
    case "$user_subnet_list_type" in
    dynamic) config_get items "$section" "user_subnets" ;;
    text) config_get items "$section" "user_subnets_text" ;;
    esac

    items="$(parse_domain_or_subnet_string_to_commas_string "$items" "subnets")"
    json_array="$(comma_string_to_json_array "$items")"
    patch_source_ruleset_rules "$ruleset_filepath" "ip_cidr" "$json_array"
    # task-034: feed the same subnets into the nft union set so the prerouting
    # mangle chain marks them into sing-box (centralized alongside the rule_set).
    populate_netshift_subnets_from_string "$items"
}

configure_local_domain_lists() {
    local section="$1"
    local route_rule_tag="$2"

    prepare_source_ruleset "$section" "local" "domains" "$route_rule_tag"

    config_list_foreach "$section" "local_domain_lists" import_local_domain_list_handler "$ruleset_filepath"
}

import_local_domain_list_handler() {
    local local_domain_list_filepath="$1"
    local ruleset_filepath="$2"

    if ! file_exists "$local_domain_list_filepath"; then
        log "Local domain list file $local_domain_list_filepath not found" "error"
        return 1
    fi

    import_plain_domain_list_to_local_source_ruleset_chunked "$local_domain_list_filepath" "$ruleset_filepath"
}

configure_local_subnet_lists() {
    local section="$1"
    local route_rule_tag="$2"

    prepare_source_ruleset "$section" "local" "subnets" "$route_rule_tag"

    config_list_foreach "$section" "local_subnet_lists" import_local_subnets_list_handler "$ruleset_filepath"
}

import_local_subnets_list_handler() {
    local local_subnet_list_filepath="$1"
    local ruleset_filepath="$2"

    if ! file_exists "$local_subnet_list_filepath"; then
        log "Local subnet list file $local_subnet_list_filepath not found" "error"
        return 1
    fi

    import_plain_subnet_list_to_local_source_ruleset_chunked "$local_subnet_list_filepath" "$ruleset_filepath"
    # task-034: also feed the nft union set (centralized alongside the rule_set).
    populate_netshift_subnets_from_file "$local_subnet_list_filepath"
}

configure_remote_domain_or_subnet_list_handler() {
    local url="$1"
    local type="$2"
    local section="$3"
    local route_rule_tag="$4"

    local file_extension
    file_extension=$(url_get_file_extension "$url")
    log "Detected file extension: '$file_extension'" "debug"
    case "$file_extension" in
    json | srs)
        log "Creating a remote $type ruleset from the source URL" "info"
        local basename ruleset_tag format detour update_interval
        basename=$(url_get_basename "$url")
        ruleset_tag=$(get_ruleset_tag "$section" "$basename" "remote-$type")
        format="$(get_ruleset_format_by_file_extension "$file_extension")"
        detour="$(get_download_detour_tag)"
        config_get update_interval "settings" "update_interval" "1d"

        config=$(sing_box_cm_add_remote_ruleset "$config" "$ruleset_tag" "$format" "$url" "$detour" "$update_interval")
        config=$(sing_box_cm_patch_route_rule "$config" "$route_rule_tag" "rule_set" "$ruleset_tag")
        case "$type" in
        domains)
            config=$(sing_box_cm_patch_dns_route_rule "$config" "$SB_FAKEIP_DNS_RULE_TAG" "rule_set" "$ruleset_tag")
            ;;
        subnets) ;;
        *) log "Unsupported remote rule set type: $type" "error" ;;
        esac
        ;;
    *)
        prepare_source_ruleset "$section" "remote" "$type" "$route_rule_tag"
        ;;
    esac
}

sing_box_configure_experimental() {
    log "Configure the experimental section of a sing-box JSON configuration"

    log "Configuring cache database"
    local cache_file
    config_get cache_file "settings" "cache_path" "/tmp/sing-box/cache.db"
    config=$(sing_box_cm_configure_cache_file "$config" true "$cache_file" true)

    log "Configuring Clash API"
    local enable_yacd enable_yacd_wan_access clash_api_controller_address
    config_get_bool enable_yacd "settings" "enable_yacd" 0
    config_get_bool enable_yacd_wan_access "settings" "enable_yacd_wan_access" 0

    if [ "$enable_yacd" -eq 1 ] && [ "$enable_yacd_wan_access" -eq 1 ]; then
        clash_api_controller_address="0.0.0.0"
    else
        clash_api_controller_address="$(get_service_listen_address)"
        if [ -z "$clash_api_controller_address" ]; then
            log "Could not determine the listening IP address for the Clash API controller. It will run only on localhost." "warn"
            clash_api_controller_address="127.0.0.1"
        fi
    fi

    if [ "$enable_yacd" -eq 1 ]; then
        log "YACD is enabled, enabling Clash API with downloadable YACD" "debug"
        local yacd_secret_key external_controller_ui
        config_get yacd_secret_key "settings" "yacd_secret_key"
        external_controller_ui="ui"

        config=$(
            sing_box_cm_configure_clash_api \
                "$config" \
                "$clash_api_controller_address:$SB_CLASH_API_CONTROLLER_PORT" \
                "$external_controller_ui" \
                "$yacd_secret_key"
        )
    else
        log "YACD is disabled, enabling Clash API in online mode" "debug"
        config=$(
            sing_box_cm_configure_clash_api "$config" "$clash_api_controller_address:$SB_CLASH_API_CONTROLLER_PORT"
        )
    fi
}

sing_box_additional_inbounds() {
    log "Configure the additional inbounds of a sing-box JSON configuration"

    local download_lists_via_proxy
    config_get_bool download_lists_via_proxy "settings" "download_lists_via_proxy" 0
    if [ "$download_lists_via_proxy" -eq 1 ]; then
        local download_lists_via_proxy_section section_outbound_tag
        config_get download_lists_via_proxy_section "settings" "download_lists_via_proxy_section"
        if subscription_outbound_is_unavailable "$download_lists_via_proxy_section"; then
            log "Service download proxy section '$download_lists_via_proxy_section' is unavailable; rejecting proxy requests until its subscription refresh succeeds" "warn"
            config="$(sing_box_cm_add_mixed_inbound "$config" "$SB_SERVICE_MIXED_INBOUND_TAG" "$SB_SERVICE_MIXED_INBOUND_ADDRESS" "$SB_SERVICE_MIXED_INBOUND_PORT")"
            config="$(sing_box_cm_add_reject_route_rule "$config" "$(gen_id)" "$SB_SERVICE_MIXED_INBOUND_TAG")"
        else
            section_outbound_tag="$(get_outbound_tag_by_section "$download_lists_via_proxy_section")"
            config=$(
                sing_box_cf_add_mixed_inbound_and_route_rule \
                    "$config" \
                    "$SB_SERVICE_MIXED_INBOUND_TAG" \
                    "$SB_SERVICE_MIXED_INBOUND_ADDRESS" \
                    "$SB_SERVICE_MIXED_INBOUND_PORT" \
                    "$section_outbound_tag"
            )
        fi
    fi

    config_foreach configure_section_mixed_proxy "section"
}

configure_section_mixed_proxy() {
    local section="$1"

    local mixed_inbound_enabled mixed_proxy_port mixed_inbound_tag mixed_outbound_tag mixed_proxy_address
    config_get_bool mixed_inbound_enabled "$section" "mixed_proxy_enabled" 0
    mixed_proxy_address="$(get_service_listen_address)"
    if [ -z "$mixed_proxy_address" ]; then
        log "Could not determine the listening IP address for the Mixed Proxy. The proxy will not be created." "warn"
        return 1
    fi
    config_get mixed_proxy_port "$section" "mixed_proxy_port" "2080"

    case "$mixed_proxy_port" in
    '' | *[!0-9]*)
        log "Invalid mixed_proxy_port '$mixed_proxy_port' for section '$section'. Falling back to 2080." "warn"
        mixed_proxy_port="2080"
        ;;
    esac

    if [ "$mixed_proxy_port" -lt 1 ] || [ "$mixed_proxy_port" -gt 65535 ]; then
        log "mixed_proxy_port '$mixed_proxy_port' for section '$section' is out of range (1-65535). Falling back to 2080." "warn"
        mixed_proxy_port="2080"
    fi
    if [ "$mixed_inbound_enabled" -eq 1 ]; then
        mixed_inbound_tag="$(get_inbound_tag_by_section "$section-mixed")"
        if subscription_outbound_is_unavailable "$section"; then
            config="$(sing_box_cm_add_mixed_inbound "$config" "$mixed_inbound_tag" "$mixed_proxy_address" "$mixed_proxy_port")"
            config="$(sing_box_cm_add_reject_route_rule "$config" "$(gen_id)" "$mixed_inbound_tag")"
        else
            mixed_outbound_tag="$(get_outbound_tag_by_section "$section")"
            config=$(
                sing_box_cf_add_mixed_inbound_and_route_rule \
                    "$config" \
                    "$mixed_inbound_tag" \
                    "$mixed_proxy_address" \
                    "$mixed_proxy_port" \
                    "$mixed_outbound_tag"
            )
        fi
    fi
}

sing_box_save_config() {
    local sing_box_config_path temp_file_path current_config_hash temp_config_hash
    config_get sing_box_config_path "settings" "config_path"
    temp_file_path="$(mktemp)"

    log "Save sing-box temporary config to $temp_file_path" "debug"
    sing_box_cm_save_config_to_file "$config" "$temp_file_path"

    sing_box_config_check "$temp_file_path"

    current_config_hash=$(md5sum "$sing_box_config_path" 2> /dev/null | awk '{print $1}')
    temp_config_hash=$(md5sum "$temp_file_path" | awk '{print $1}')
    log "Current sing-box config hash: $current_config_hash" "debug"
    log "Temporary sing-box config hash: $temp_config_hash" "debug"
    if [ "$current_config_hash" != "$temp_config_hash" ]; then
        log "sing-box configuration has changed and will be updated"
        mv "$temp_file_path" "$sing_box_config_path"
    else
        log "sing-box configuration is unchanged"
        rm "$temp_file_path"
    fi
}

sing_box_config_check() {
    local config_path="$1"

    if ! sing-box -c "$config_path" check > /dev/null 2>&1; then
        log "Sing-box configuration $config_path is invalid. Aborted." "fatal"
        exit 1
    fi
}

import_community_subnet_lists() {
    local section="$1"
    local community_lists
    config_get community_lists "$section" "community_lists"
    if [ -n "$community_lists" ]; then
        log "Importing community subnet lists for '$section' section"
        config_list_foreach "$section" "community_lists" import_community_service_subnet_list_handler
    fi
}

import_community_service_subnet_list_handler() {
    local service="$1"

    # Routing for every community service is carried by a sing-box remote rule
    # set (see configure_community_list_handler -> $SRS_MAIN_URL/<service>.srs).
    # task-034: the SUBNET-based community services (twitter/meta/telegram/
    # cloudflare/hetzner/ovh/digitalocean/cloudfront/roblox/discord) route by IP,
    # not by domain, so their destination IPs would NOT be covered by the FakeIP
    # mark rule. We therefore (re)populate the nft union set NFT_COMMON_SET_NAME
    # so the prerouting mangle chain marks them into sing-box. Domain-based
    # services (youtube, russia_inside, ...) are carried by the FakeIP range
    # mark rule and need no nft set population here.
    #
    # discord is special: it ALSO needs its own dport-restricted mangle rule
    # (@netshift_discord_subnets udp dport {...}) that a sing-box route rule
    # cannot express, so its subnets populate BOTH the discord set (for the
    # dport rule) and the general union set (so TCP/other-UDP discord traffic
    # is marked too, without the dport restriction).
    local url is_discord
    is_discord=0
    case "$service" in
    "twitter") url=$SUBNETS_TWITTER ;;
    "meta") url=$SUBNETS_META ;;
    "telegram") url=$SUBNETS_TELERAM ;;
    "cloudflare") url=$SUBNETS_CLOUDFLARE ;;
    "hetzner") url=$SUBNETS_HETZNER ;;
    "ovh") url=$SUBNETS_OVH ;;
    "digitalocean") url=$SUBNETS_DIGITALOCEAN ;;
    "cloudfront") url=$SUBNETS_CLOUDFRONT ;;
    "roblox") url=$SUBNETS_ROBLOX ;;
    "discord")
        url=$SUBNETS_DISCORD
        is_discord=1
        if ! nft list set inet "$NFT_TABLE_NAME" "$NFT_DISCORD_SET_NAME" > /dev/null 2>&1; then
            nft_create_ipv4_set "$NFT_TABLE_NAME" "$NFT_DISCORD_SET_NAME"
        fi
        if ! nft list chain inet "$NFT_TABLE_NAME" mangle 2> /dev/null | \
            grep -Fq "@$NFT_DISCORD_SET_NAME udp dport { 19000-20000, 50000-65535 }"; then
            nft add rule inet "$NFT_TABLE_NAME" mangle iifname "@$NFT_INTERFACE_SET_NAME" ip daddr \
                "@$NFT_DISCORD_SET_NAME" udp dport '{ 19000-20000, 50000-65535 }' meta mark set "$NFT_FAKEIP_MARK" counter
        fi
        ;;
    *) return 0 ;;
    esac

    local tmpfile http_proxy_address
    tmpfile=$(mktemp)
    http_proxy_address="$(get_service_proxy_address)"

    download_to_file "$url" "$tmpfile" "$http_proxy_address"

    if [ $? -ne 0 ] || [ ! -s "$tmpfile" ]; then
        log "Download $service list failed" "error"
        return 1
    fi

    if [ "$is_discord" -eq 1 ]; then
        nft_add_set_elements_from_file_chunked "$tmpfile" "$NFT_TABLE_NAME" "$NFT_DISCORD_SET_NAME"
    fi
    # task-034: feed the union set (centralized with the sing-box .srs rule_set).
    populate_netshift_subnets_from_file "$tmpfile"

    rm -f "$tmpfile"
}

import_domains_from_remote_domain_lists() {
    local section="$1"
    local remote_domain_lists
    config_get remote_domain_lists "$section" "remote_domain_lists"
    if [ -n "$remote_domain_lists" ]; then
        log "Importing domains from remote domain lists for '$section' section"
        config_list_foreach "$section" "remote_domain_lists" import_domains_from_remote_domain_list_handler "$section"
    fi
}

import_domains_from_remote_domain_list_handler() {
    local url="$1"
    local section="$2"

    log "Importing domains from URL: $url"

    local file_extension
    file_extension=$(url_get_file_extension "$url")
    log "Detected file extension: '$file_extension'" "debug"
    case "$file_extension" in
    json | srs)
        log "No update needed - sing-box manages updates automatically."
        ;;
    *)
        log "Import domains from a remote plain-text list"
        import_domains_from_remote_plain_file "$url" "$section"
        ;;
    esac
}

import_domains_from_remote_plain_file() {
    local url="$1"
    local section="$2"

    local tmpfile http_proxy_address items json_array
    tmpfile=$(mktemp)
    http_proxy_address="$(get_service_proxy_address)"

    download_to_file "$url" "$tmpfile" "$http_proxy_address"

    if [ $? -ne 0 ] || [ ! -s "$tmpfile" ]; then
        log "Download $url list failed" "error"
        return 1
    fi

    convert_crlf_to_lf "$tmpfile"
    ruleset_tag=$(get_ruleset_tag "$section" "remote" "domains")
    ruleset_filepath="$TMP_RULESET_FOLDER/$ruleset_tag.json"
    import_plain_domain_list_to_local_source_ruleset_chunked "$tmpfile" "$ruleset_filepath"

    rm -f "$tmpfile"
}

import_subnets_from_remote_subnet_lists() {
    local section="$1"
    local remote_subnet_lists
    config_get remote_subnet_lists "$section" "remote_subnet_lists"
    if [ -n "$remote_subnet_lists" ]; then
        log "Importing subnets from remote subnet lists for '$section' section"
        config_list_foreach "$section" "remote_subnet_lists" import_subnets_from_remote_subnet_list_handler "$section"
    fi
}

import_subnets_from_remote_subnet_list_handler() {
    local url="$1"
    local section="$2"

    log "Importing subnets from URL: $url"

    local file_extension
    file_extension="$(url_get_file_extension "$url")"
    log "Detected file extension: '$file_extension'" "debug"
    case "$file_extension" in
    json)
        # JSON/SRS remote subnet lists are ROUTED via a sing-box remote rule set
        # (configure_remote_domain_or_subnet_list_handler -> sing_box_cm_add_remote_ruleset),
        # and sing-box manages their updates automatically. But task-034 needs
        # their destination IPs in the nft union set so the prerouting mangle
        # chain marks them into sing-box (an .srs/.json ruleset matches IPs that
        # are NOT in the FakeIP range). So we extract the ip_cidr entries and
        # feed the union set here.
        log "Import subnets from a remote JSON list (for nft selective marking)" "info"
        import_subnets_from_remote_json_file "$url"
        ;;
    srs)
        log "Import subnets from a remote SRS list (for nft selective marking)" "info"
        import_subnets_from_remote_srs_file "$url"
        ;;
    *)
        log "Import subnets from a remote plain-text list" "info"
        import_subnets_from_remote_plain_file "$url" "$section"
        ;;
    esac
}

# task-034: download a remote JSON sing-box ruleset, extract its ip_cidr entries
# and feed the nft union set. Routing is still done by the sing-box remote rule
# set; this only adds the nft-layer ingress gating. Fail-open on download error.
import_subnets_from_remote_json_file() {
    local url="$1"
    local json_tmpfile subnets_tmpfile http_proxy_address
    json_tmpfile="$(mktemp)"
    subnets_tmpfile="$(mktemp)"
    http_proxy_address="$(get_service_proxy_address)"

    download_to_file "$url" "$json_tmpfile" "$http_proxy_address"

    if [ $? -ne 0 ] || [ ! -s "$json_tmpfile" ]; then
        log "Download $url list failed" "error"
        rm -f "$json_tmpfile" "$subnets_tmpfile"
        return 1
    fi

    extract_ip_cidr_from_json_ruleset_to_file "$json_tmpfile" "$subnets_tmpfile"
    populate_netshift_subnets_from_file "$subnets_tmpfile"
    rm -f "$json_tmpfile" "$subnets_tmpfile"
}

# task-034: download a remote binary (.srs) ruleset, decompile to JSON, extract
# its ip_cidr entries and feed the nft union set. Fail-open on download/decompile.
import_subnets_from_remote_srs_file() {
    local url="$1"
    local binary_tmpfile json_tmpfile subnets_tmpfile http_proxy_address
    binary_tmpfile="$(mktemp)"
    json_tmpfile="$(mktemp)"
    subnets_tmpfile="$(mktemp)"
    http_proxy_address="$(get_service_proxy_address)"

    download_to_file "$url" "$binary_tmpfile" "$http_proxy_address"

    if [ $? -ne 0 ] || [ ! -s "$binary_tmpfile" ]; then
        log "Download $url list failed" "error"
        rm -f "$binary_tmpfile" "$json_tmpfile" "$subnets_tmpfile"
        return 1
    fi

    if ! decompile_binary_ruleset "$binary_tmpfile" "$json_tmpfile"; then
        log "Failed to decompile binary rule set file" "error"
        rm -f "$binary_tmpfile" "$json_tmpfile" "$subnets_tmpfile"
        return 1
    fi

    extract_ip_cidr_from_json_ruleset_to_file "$json_tmpfile" "$subnets_tmpfile"
    populate_netshift_subnets_from_file "$subnets_tmpfile"
    rm -f "$binary_tmpfile" "$json_tmpfile" "$subnets_tmpfile"
}

import_subnets_from_remote_plain_file() {
    local url="$1"
    local section="$2"

    local tmpfile http_proxy_address items json_array
    tmpfile=$(mktemp)
    http_proxy_address="$(get_service_proxy_address)"

    download_to_file "$url" "$tmpfile" "$http_proxy_address"

    if [ $? -ne 0 ] || [ ! -s "$tmpfile" ]; then
        log "Download $url list failed" "error"
        return 1
    fi

    convert_crlf_to_lf "$tmpfile"

    ruleset_tag=$(get_ruleset_tag "$section" "remote" "subnets")
    ruleset_filepath="$TMP_RULESET_FOLDER/$ruleset_tag.json"
    import_plain_subnet_list_to_local_source_ruleset_chunked "$tmpfile" "$ruleset_filepath"
    # task-034: also feed the nft union set (centralized with the rule_set).
    populate_netshift_subnets_from_file "$tmpfile"

    rm -f "$tmpfile"
}

## Support functions
get_service_proxy_address() {
    local download_lists_via_proxy
    config_get_bool download_lists_via_proxy "settings" "download_lists_via_proxy" 0
    if [ "$download_lists_via_proxy" -eq 1 ]; then
        echo "$SB_SERVICE_MIXED_INBOUND_ADDRESS:$SB_SERVICE_MIXED_INBOUND_PORT"
    else
        echo ""
    fi
}

get_download_detour_tag() {
    config_get_bool download_lists_via_proxy "settings" "download_lists_via_proxy" 0
    if [ "$download_lists_via_proxy" -eq 1 ]; then
        local download_lists_via_proxy_section section_outbound_tag
        config_get download_lists_via_proxy_section "settings" "download_lists_via_proxy_section"
        if subscription_outbound_is_unavailable "$download_lists_via_proxy_section"; then
            log "Download detour section '$download_lists_via_proxy_section' is unavailable; using direct detour until subscription refresh succeeds" "warn"
            echo ""
            return 0
        fi
        section_outbound_tag="$(get_outbound_tag_by_section "$download_lists_via_proxy_section")"
        echo "$section_outbound_tag"
    else
        echo ""
    fi
}

_determine_first_outbound_section() {
    local section="$1"

    if section_has_configured_outbound "$section"; then
        [ -z "$first_section" ] && first_section="$section"
    fi
}

# Resolve the outbound tag the MAIN sing-box DNS server should detour through.
# Echoes the tag, or an EMPTY string meaning "no detour / direct DNS" (the safe
# default). NEVER exits: every failure path logs the reason and falls back to
# direct DNS so the service always starts and resolves. Mirrors the toggle +
# optional section selector model of get_subscription_download_proxy_address.
_get_dns_detour_tag() {
    local dns_via_outbound dns_outbound_section candidate
    local candidate_connection_type

    # Step 1: feature off -> direct (silent).
    config_get_bool dns_via_outbound "settings" "dns_via_outbound" 0
    [ "$dns_via_outbound" -eq 1 ] || return 0

    config_get dns_outbound_section "settings" "dns_outbound_section"

    # Step 2: resolve the candidate section.
    if [ -n "$dns_outbound_section" ] && \
        config_get candidate_connection_type "$dns_outbound_section" "connection_type" && \
        [ -n "$candidate_connection_type" ] && \
        section_has_configured_outbound "$dns_outbound_section"; then
        candidate="$dns_outbound_section"
    else
        if [ -n "$dns_outbound_section" ]; then
            log "DNS-via-outbound section '$dns_outbound_section' is invalid or has no configured outbound; falling back to the first outbound section" "warn"
        fi
        candidate="$(get_first_outbound_section)"
    fi

    # Step 3: no candidate section at all -> direct.
    if [ -z "$candidate" ]; then
        log "DNS-via-outbound is enabled but no outbound section is configured; using direct DNS" "warn"
        return 0
    fi

    # Step 4: block/exclusion sections produce no real outbound -> direct.
    config_get candidate_connection_type "$candidate" "connection_type"
    case "$candidate_connection_type" in
    block | exclusion)
        log "DNS-via-outbound candidate section '$candidate' has connection_type '$candidate_connection_type' (no real outbound); using direct DNS" "warn"
        return 0
        ;;
    esac

    # Step 5: subscription outbound not built yet / failed refresh -> direct (self-heal).
    if subscription_outbound_is_unavailable "$candidate"; then
        log "DNS-via-outbound selected section '$candidate' has no usable outbound yet; using direct DNS until it recovers" "warn"
        return 0
    fi

    # Step 6: use the candidate section's outbound tag.
    get_outbound_tag_by_section "$candidate"
}

get_first_outbound_section() {
    local first_section=""

    config_foreach _determine_first_outbound_section "section"

    echo "$first_section"
}

_determine_global_proxy_section() {
    local section="$1"
    local global_proxy

    config_get_bool global_proxy "$section" "global_proxy" 0
    if [ "$global_proxy" -eq 1 ] && section_has_configured_outbound "$section"; then
        [ -z "$global_section" ] && global_section="$section"
    fi
}

get_global_proxy_section() {
    local global_section=""

    config_foreach _determine_global_proxy_section "section"

    echo "$global_section"
}

get_sections_by_connection_type() {
    local connection_type="$1"

    uci show netshift | grep "\.connection_type='$connection_type'" | cut -d'.' -f2
}

section_by_connection_type_exists() {
    local connection_type="$1"

    if uci show netshift | grep -q "\.connection_type='$connection_type'"; then
        return 0
    else
        return 1
    fi
}

section_has_enabled_lists() {
    local section="$1"
    local community_lists user_domain_list_type user_subnet_list_type local_domain_lists local_subnet_lists \
        remote_domain_lists remote_subnet_lists

    config_get community_lists "$section" "community_lists"
    config_get user_domain_list_type "$section" "user_domain_list_type" "disabled"
    config_get user_subnet_list_type "$section" "user_subnet_list_type" "disabled"
    config_get local_domain_lists "$section" "local_domain_lists"
    config_get local_subnet_lists "$section" "local_subnet_lists"
    config_get remote_domain_lists "$section" "remote_domain_lists"
    config_get remote_subnet_lists "$section" "remote_subnet_lists"

    if [ -n "$community_lists" ] ||
        [ "$user_domain_list_type" != "disabled" ] ||
        [ "$user_subnet_list_type" != "disabled" ] ||
        [ -n "$local_domain_lists" ] ||
        [ -n "$local_subnet_lists" ] ||
        [ -n "$remote_domain_lists" ] ||
        [ -n "$remote_subnet_lists" ]; then
        return 0
    else
        return 1
    fi
}

get_service_listen_address() {
    local service_listen_address

    config_get service_listen_address "settings" "service_listen_address"
    if [ -n "$service_listen_address" ]; then
        log "Attention! The service_listen_address option is being used, overriding the automatic detection of the listening IP address!" "warn"
        echo "$service_listen_address"
        return 0
    fi

    local interface="lan"
    network_get_ipaddr service_listen_address "$interface"

    if [ -z "$service_listen_address" ]; then
        log "Failed to determine the listening IP address. Please open an issue to report this problem: https://github.com/yandexru45/netshift/issues" "error"
        return 1
    fi

    echo "$service_listen_address"
}

# Diagnostics
sing_box_fetch_ifconfig_with_timeout() {
    local config_dir="$1"
    local config_path="$2"
    local outbound_tag="$3"
    local output_file fetch_pid watchdog_pid fetch_status

    output_file="$(mktemp /tmp/netshift-check-proxy-response.XXXXXX)" || return 1

    sing-box tools fetch -D "$config_dir" -c "$config_path" --outbound "$outbound_tag" ifconfig.me \
        > "$output_file" 2>/dev/null &
    fetch_pid=$!

    (
        sleep 20
        kill "$fetch_pid" 2>/dev/null
    ) &
    watchdog_pid=$!

    wait "$fetch_pid" 2>/dev/null
    fetch_status=$?

    kill "$watchdog_pid" 2>/dev/null
    wait "$watchdog_pid" 2>/dev/null

    cat "$output_file"
    rm -f "$output_file"
    return "$fetch_status"
}

get_clash_selected_outbound_tag() {
    local outbound_tag="$1"
    local controller_address encoded_tag selected_tag i

    controller_address="$(get_service_listen_address 2>/dev/null)"
    [ -z "$controller_address" ] && controller_address="127.0.0.1"

    i=0
    while [ "$i" -lt 5 ]; do
        encoded_tag="$(printf '%s' "$outbound_tag" | jq -sRr @uri 2>/dev/null)"
        selected_tag="$(curl -m 3 -s "http://$controller_address:$SB_CLASH_API_CONTROLLER_PORT/proxies/$encoded_tag" \
            | jq -r '.now // ""' 2>/dev/null)"

        if [ -z "$selected_tag" ] || [ "$selected_tag" = "$outbound_tag" ]; then
            echo "$outbound_tag"
            return 0
        fi

        outbound_tag="$selected_tag"
        i=$((i + 1))
    done

    echo "$outbound_tag"
}

check_proxy() {
    local sing_box_config_path check_config_path config_dir first_outbound_section outbound_tag response ip
    config_get sing_box_config_path "settings" "config_path"

    if ! command -v sing-box > /dev/null 2>&1; then
        nolog "sing-box is not installed"
        return 1
    fi

    if [ ! -f "$sing_box_config_path" ]; then
        nolog "Configuration file not found"
        return 1
    fi

    nolog "Checking sing-box configuration..."

    if ! sing-box -c "$sing_box_config_path" check > /dev/null; then
        nolog "Invalid configuration"
        return 1
    fi

    jq '
    walk(
        if type == "object" then
            with_entries(
                if .key == "uuid" then
                    .value = "MASKED"
                elif .key == "server" then
                    .value = "MASKED"
                elif .key == "server_name" then
                    .value = "MASKED"
                elif .key == "password" then
                    .value = "MASKED"
                elif .key == "public_key" then
                    .value = "MASKED"
                elif .key == "short_id" then
                    .value = "MASKED"
                elif .key == "fingerprint" then
                    .value = "MASKED"
                elif .key == "server_port" then
                    .value = "MASKED"
                else . end
            )
        else . end
    )' "$sing_box_config_path"

    nolog "Checking proxy connection..."

    first_outbound_section="$(get_first_outbound_section)"
    if [ -z "$first_outbound_section" ]; then
        nolog "No outbound section configured"
        return 1
    fi

    outbound_tag="$(get_outbound_tag_by_section "$first_outbound_section")"
    config_dir="${sing_box_config_path%/*}"
    [ "$config_dir" = "$sing_box_config_path" ] && config_dir="."

    check_config_path="$(mktemp /tmp/netshift-check-proxy.XXXXXX)" || {
        nolog "Failed to create temporary sing-box configuration"
        return 1
    }

    if ! jq '
        if .experimental.cache_file then .experimental.cache_file.enabled = false else . end
        | .dns.rules = []
        | .route.rules = []
        | .route.rule_set = []
    ' "$sing_box_config_path" > "$check_config_path"; then
        rm -f "$check_config_path"
        nolog "Failed to prepare temporary sing-box configuration"
        return 1
    fi

    if ! jq -e --arg tag "$outbound_tag" '.outbounds[]? | select(.tag == $tag)' \
        "$check_config_path" > /dev/null; then
        rm -f "$check_config_path"
        nolog "Outbound '$outbound_tag' not found in sing-box configuration"
        return 1
    fi

    if jq -e --arg tag "$outbound_tag" \
        '.outbounds[]? | select(.tag == $tag and (.type == "selector" or .type == "urltest"))' \
        "$check_config_path" > /dev/null; then
        outbound_tag="$(get_clash_selected_outbound_tag "$outbound_tag")"
    fi

    if [ -z "$outbound_tag" ]; then
        rm -f "$check_config_path"
        nolog "No testable proxy outbound found in sing-box configuration"
        return 1
    fi

    for attempt in $(seq 1 5); do
        response="$(sing_box_fetch_ifconfig_with_timeout "$config_dir" "$check_config_path" "$outbound_tag")"
        if echo "$response" | grep -q "^<html\|403 Forbidden"; then
            if [ "$attempt" -eq 5 ]; then
                nolog "Failed to get valid IP address after 5 attempts"
                nolog "Error response: $response"
                rm -f "$check_config_path"
                return 1
            fi
            continue
        fi
        if echo "$response" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$'; then
            ip="$(echo "$response" | sed -n 's/^[0-9]\+\.[0-9]\+\.[0-9]\+\.\([0-9]\+\)$/X.X.X.\1/p')"
            nolog "$ip - should match proxy IP"
            rm -f "$check_config_path"
            return 0
        elif echo "$response" | grep -Eq '^([0-9a-fA-F]*:)[0-9a-fA-F:]+$'; then
            ip="$(echo "$response" | sed 's/\([0-9a-fA-F]\+:[0-9a-fA-F]\+:[0-9a-fA-F]\+\):.*/\1:XXXX:XXXX:XXXX/')"
            nolog "$ip - should match proxy IP"
            rm -f "$check_config_path"
            return 0
        fi
        if [ "$attempt" -eq 5 ]; then
            nolog "Failed to get valid IP address after 5 attempts"
            if [ -z "$response" ]; then
                nolog "Error: Empty response"
            else
                nolog "Error response: $response"
            fi
            rm -f "$check_config_path"
            return 1
        fi
    done

    rm -f "$check_config_path"
    return 1
}

check_nft() {
    if ! command -v nft > /dev/null 2>&1; then
        nolog "nft is not installed"
        return 1
    fi

    nolog "Checking $NFT_TABLE_NAME rules..."

    # Check if table exists
    if ! nft list table inet "$NFT_TABLE_NAME" > /dev/null 2>&1; then
        nolog "❌ $NFT_TABLE_NAME not found"
        return 1
    fi

    local found_hetzner=0
    local found_ovh=0

    check_domain_list_contains() {
        local section="$1"

        config_get_bool domain_list_enabled "$section" "domain_list_enabled" "0"
        if [ "$domain_list_enabled" -eq 1 ]; then
            config_list_foreach "$section" "domain_list" check_domain_value
        fi
    }

    check_domain_value() {
        local domain_value="$1"

        if [ "$domain_value" = "hetzner" ]; then
            found_hetzner=1
        elif [ "$domain_value" = "ovh" ]; then
            found_ovh=1
        fi
    }

    config_foreach check_domain_list_contains

    if [ "$found_hetzner" -eq 1 ] || [ "$found_ovh" -eq 1 ]; then

        local sets="netshift_domains interfaces netshift_discord_subnets localv4"

        nolog "Sets statistics:"
        for set_name in $sets; do
            if nft list set inet "$NFT_TABLE_NAME" $set_name > /dev/null 2>&1; then
                # Count elements using grep to count commas and add 1 (last element has no comma)
                local count=$(nft list set inet "$NFT_TABLE_NAME" $set_name 2> /dev/null | grep -o ',\|{' | wc -l)
                echo "- $set_name: $count elements"
            fi
        done

        nolog "Chain configurations:"

        # Create a temporary file for processing
        local tmp_file=$(mktemp)
        nft list table inet "$NFT_TABLE_NAME" > "$tmp_file"

        # Extract chain configurations without element listings
        sed -n '/chain mangle {/,/}/p' "$tmp_file" | grep -v "elements" | grep -v "^[[:space:]]*[0-9]"
        sed -n '/chain proxy {/,/}/p' "$tmp_file" | grep -v "elements" | grep -v "^[[:space:]]*[0-9]"

        # Clean up
        rm -f "$tmp_file"
    else
        # Simple view as originally implemented
        nolog "Sets configuration:"
        nft list table inet "$NFT_TABLE_NAME"
    fi

    nolog "NFT check completed"
}

check_logs() {
    if ! command -v logread > /dev/null 2>&1; then
        nolog "Error: logread command not found"
        return 1
    fi

    local logs
    logs=$(logread | grep -E "netshift|sing-box")

    if [ -z "$logs" ]; then
        nolog "Logs not found"
        return 1
    fi

    # Find the last occurrence of "Starting netshift"
    local start_line
    start_line=$(echo "$logs" | grep -n "netshift.*Starting netshift" | tail -n 1 | cut -d: -f1)

    if [ -n "$start_line" ]; then
        echo "$logs" | tail -n +"$start_line"
    else
        nolog "No 'Starting netshift' message found, showing last 100 lines"
        echo "$logs" | tail -n 100
    fi
}

show_sing_box_config() {
    local sing_box_config_path
    config_get sing_box_config_path "settings" "config_path"
    nolog "Current sing-box configuration:"

    if [ ! -f "$sing_box_config_path" ]; then
        nolog "Configuration file not found"
        return 1
    fi

    jq '
    walk(
        if type == "object" then
            with_entries(
                if .key == "uuid" then
                    .value = "MASKED"
                elif .key == "server" then
                    .value = "MASKED"
                elif .key == "server_name" then
                    .value = "MASKED"
                elif .key == "password" then
                    .value = "MASKED"
                elif .key == "public_key" then
                    .value = "MASKED"
                elif .key == "short_id" then
                    .value = "MASKED"
                elif .key == "fingerprint" then
                    .value = "MASKED"
                elif .key == "server_port" then
                    .value = "MASKED"
                else . end
            )
        else . end
    )' "$sing_box_config_path"
}

show_config() {
    if [ ! -f "$NETSHIFT_CONFIG" ]; then
        nolog "Configuration file not found"
        return 1
    fi

    tmp_config=$(mktemp)

    sed -e 's/\(option proxy_string\).*/\1 '\''MASKED'\''/g' \
        -e '/option outbound_json/,/^}/c\	option outbound_json '\''MASKED'\''' \
        -e 's/\(list urltest_proxy_links\).*/\1 '\''MASKED'\''/g' \
        -e 's/\(list selector_proxy_links\).*/\1 '\''MASKED'\''/g' \
        -e "s@\\(option dns_server '[^/]*\\)/[^']*'@\\1/MASKED'@g" \
        -e "s@\\(option domain_resolver_dns_server '[^/]*\\)/[^']*'@\\1/MASKED'@g" \
        -e 's/\(option yacd_secret_key\).*/\1 '\''MASKED'\''/g' \
        "$NETSHIFT_CONFIG" > "$tmp_config"

    cat "$tmp_config"
    rm -f "$tmp_config"
}

show_version() {
    echo "$NETSHIFT_VERSION"
}

show_sing_box_version() {
    local version
    version=$(sing-box version | head -n 1 | awk '{print $3}')
    echo "$version"
}

show_system_info() {
    echo "=== OpenWrt Version ==="
    grep OPENWRT_RELEASE /etc/os-release | cut -d'"' -f2
    echo
    echo "=== Device Model ==="
    cat /tmp/sysinfo/model
}

get_system_info() {
    local netshift_version netshift_latest_version luci_app_version sing_box_version openwrt_version device_model sing_box_extended

    netshift_version="$NETSHIFT_VERSION"

    # On-demand only: get_system_info must do NO network I/O (the UI calls it on
    # every Manager/Diagnostic mount). The real latest is fetched only by the
    # on-demand "component_action netshift check_update" action (and by
    # global_check, the one-shot SSH diagnostic). The key is kept for backward
    # compatibility (frontend type + global_check jq read it); "unknown" is the
    # zero-network sentinel the UI already understands.
    netshift_latest_version="unknown"

    if [ -f /www/luci-static/resources/view/netshift/main.js ]; then
        luci_app_version=$(grep 'var NETSHIFT_LUCI_APP_VERSION' /www/luci-static/resources/view/netshift/main.js | cut -d'"' -f2)
    else
        luci_app_version="not installed"
    fi

    if command -v sing-box > /dev/null 2>&1; then
        sing_box_version=$(sing-box version 2> /dev/null | head -n 1 | awk '{print $3}')
        [ -z "$sing_box_version" ] && sing_box_version="unknown"
    else
        sing_box_version="not installed"
    fi

    sing_box_extended=0
    if command -v sing-box > /dev/null 2>&1 && is_sing_box_extended "$sing_box_version"; then
        sing_box_extended=1
    fi

    if [ -f /etc/os-release ]; then
        openwrt_version=$(grep OPENWRT_RELEASE /etc/os-release | cut -d'"' -f2)
        [ -z "$openwrt_version" ] && openwrt_version="unknown"
    else
        openwrt_version="unknown"
    fi

    if [ -f /tmp/sysinfo/model ]; then
        device_model=$(cat /tmp/sysinfo/model)
        [ -z "$device_model" ] && device_model="unknown"
    else
        device_model="unknown"
    fi

    echo "{\"netshift_version\": \"$netshift_version\", \"netshift_latest_version\": \"$netshift_latest_version\", \"luci_app_version\": \"$luci_app_version\", \"sing_box_version\": \"$sing_box_version\", \"sing_box_extended\": $sing_box_extended, \"openwrt_version\": \"$openwrt_version\", \"device_model\": \"$device_model\"}" | jq .
}

get_sing_box_status() {
    local running=0
    local enabled=0
    local status=""
    local version=""
    local dns_configured=0

    # Check if service is enabled
    if [ -x /etc/rc.d/S99sing-box ]; then
        enabled=1
    fi

    # Check if service is running
    if pgrep -f "sing-box" > /dev/null; then
        running=1
        version=$(sing-box version | head -n 1 | awk '{print $3}')
    fi

    # Check DNS configuration
    local dns_server
    dns_server=$(uci get dhcp.@dnsmasq[0].server 2> /dev/null)
    if [ "$dns_server" = "127.0.0.42" ]; then
        dns_configured=1
    fi

    # Format status message
    if [ $running -eq 1 ]; then
        if [ $enabled -eq 1 ]; then
            status="running & enabled"
        else
            status="running but disabled"
        fi
    else
        if [ $enabled -eq 1 ]; then
            status="stopped but enabled"
        else
            status="stopped & disabled"
        fi
    fi

    echo "{\"running\":$running,\"enabled\":$enabled,\"status\":\"$status\",\"dns_configured\":$dns_configured}"
}

get_status() {
    local enabled=0
    local status=""

    # Check if service is enabled
    if [ -x /etc/rc.d/S99netshift ]; then
        enabled=1
        status="enabled"
    else
        status="disabled"
    fi

    echo "{\"enabled\":$enabled,\"status\":\"$status\"}"
}

check_dns_available() {
    local dns_type dns_server bootstrap_dns_server
    config_get dns_type "settings" "dns_type"
    config_get dns_server "settings" "dns_server"
    config_get bootstrap_dns_server "settings" "bootstrap_dns_server"

    local dns_status=0
    local dns_on_router=0
    local bootstrap_dns_status=0
    local dhcp_config_status=1
    local domain="google.com"

    # Mask NextDNS ID if present
    local display_dns_server="$dns_server"
    if echo "$dns_server" | grep -q "\.dns\.nextdns\.io$"; then
        local nextdns_id
        nextdns_id=$(echo "$dns_server" | cut -d'.' -f1)
        display_dns_server="$(echo "$nextdns_id" | sed 's/./*/g').dns.nextdns.io"
    elif echo "$dns_server" | grep -q "^dns\.nextdns\.io/"; then
        local masked_path
        masked_path=$(echo "$dns_server" | cut -d'/' -f2- | sed 's/./*/g')
        display_dns_server="dns.nextdns.io/$masked_path"
    fi

    if [ "$dns_type" = "doh" ]; then
        # Check if dns_server already contains a path
        local doh_path="/dns-query"
        if echo "$dns_server" | grep -q "/"; then
            # Path is already present, extract it
            doh_path="/$(echo "$dns_server" | cut -d'/' -f2-)"
            dns_server="$(echo "$dns_server" | cut -d'/' -f1)"
        fi

        if dig @"$dns_server" "$domain" +https="$doh_path" +timeout=2 +tries=1 > /dev/null 2>&1; then
            dns_status=1
        fi
    elif [ "$dns_type" = "dot" ]; then
        if dig @"$dns_server" "$domain" +tls +timeout=2 +tries=1 > /dev/null 2>&1; then
            dns_status=1
        fi
    elif [ "$dns_type" = "udp" ]; then
        if dig @"$dns_server" "$domain" +timeout=2 +tries=1 > /dev/null 2>&1; then
            dns_status=1
        fi
    fi

    # Check if local DNS resolver is working
    if dig @127.0.0.1 "$domain" +timeout=2 +tries=1 > /dev/null 2>&1; then
        dns_on_router=1
    fi

    # Check bootstrap DNS server
    if [ -n "$bootstrap_dns_server" ]; then
        if dig @"$bootstrap_dns_server" "$domain" +timeout=2 +tries=1 > /dev/null 2>&1; then
            bootstrap_dns_status=1
        fi
    fi

    # Check if /etc/config/dhcp has server 127.0.0.42
    config_load dhcp
    config_foreach check_dhcp_has_netshift_dns dnsmasq
    config_load "$NETSHIFT_CONFIG"

    # Effective detour tag the main DNS would use (empty string = direct DNS).
    local dns_via_outbound_tag
    dns_via_outbound_tag="$(_get_dns_detour_tag)"

    echo "{\"dns_type\":\"$dns_type\",\"dns_server\":\"$display_dns_server\",\"dns_status\":$dns_status,\"dns_on_router\":$dns_on_router,\"bootstrap_dns_server\":\"$bootstrap_dns_server\",\"bootstrap_dns_status\":$bootstrap_dns_status,\"dhcp_config_status\":$dhcp_config_status,\"dns_via_outbound_tag\":\"$dns_via_outbound_tag\"}" | jq .
}

check_dhcp_has_netshift_dns() {
    local server_list cachesize noresolv server_found
    config_get server_list "$1" "server"
    config_get cachesize "$1" "cachesize"
    config_get noresolv "$1" "noresolv"

    server_found=0

    if [ -n "$server_list" ]; then
        for server in $server_list; do
            if [ "$server" = "127.0.0.42" ]; then
                server_found=1
                break
            fi
        done
    fi

    if [ "$cachesize" != "0" ] || [ "$noresolv" != "1" ] || [ "$server_found" != "1" ]; then
        dhcp_config_status=0
    fi
}

check_nft_rules() {
    local table_exist=0
    local rules_mangle_exist=0
    local rules_mangle_counters=0
    local rules_mangle_output_exist=0
    local rules_proxy_exist=0
    local rules_proxy_counters=0
    local rules_other_mark_exist=0

    # Generate traffic through NetShiftTable
    curl -m 3 -s "https://$CHECK_PROXY_IP_DOMAIN/check" > /dev/null 2>&1 &
    local pid1=$!
    curl -m 3 -s "https://$FAKEIP_TEST_DOMAIN/check" > /dev/null 2>&1 &
    local pid2=$!

    wait $pid1 2> /dev/null
    wait $pid2 2> /dev/null
    sleep 1

    # Check if NetShiftTable exists
    if nft list table inet "$NFT_TABLE_NAME" > /dev/null 2>&1; then
        table_exist=1

        # Check mangle chain rules
        if nft list chain inet "$NFT_TABLE_NAME" mangle > /dev/null 2>&1; then
            local mangle_output
            mangle_output=$(nft list chain inet "$NFT_TABLE_NAME" mangle)
            if echo "$mangle_output" | grep -q "counter"; then
                rules_mangle_exist=1

                if echo "$mangle_output" | grep "counter" | grep -qv "packets 0 bytes 0"; then
                    rules_mangle_counters=1
                fi
            fi
        fi

        # Check mangle_output chain rules
        if nft list chain inet "$NFT_TABLE_NAME" mangle_output > /dev/null 2>&1; then
            local mangle_output_output
            mangle_output_output=$(nft list chain inet "$NFT_TABLE_NAME" mangle_output)
            if echo "$mangle_output_output" | grep -q "counter"; then
                rules_mangle_output_exist=1
            fi
        fi

        # Check proxy chain rules
        if nft list chain inet "$NFT_TABLE_NAME" proxy > /dev/null 2>&1; then
            local proxy_output
            proxy_output=$(nft list chain inet "$NFT_TABLE_NAME" proxy)
            if echo "$proxy_output" | grep -q "counter"; then
                rules_proxy_exist=1

                if echo "$proxy_output" | grep "counter" | grep -qv "packets 0 bytes 0"; then
                    rules_proxy_counters=1
                fi
            fi
        fi
    fi

    # Check for other mark rules outside NetShiftTable
    nft list tables 2> /dev/null | while read -r _ family table_name; do
        [ -z "$table_name" ] && continue

        [ "$table_name" = "$NFT_TABLE_NAME" ] && continue

        if nft list table "$family" "$table_name" 2> /dev/null | grep -q "meta mark set"; then
            touch /tmp/netshift_mark_check.$$
            break
        fi
    done

    if [ -f /tmp/netshift_mark_check.$$ ]; then
        rules_other_mark_exist=1
        rm -f /tmp/netshift_mark_check.$$
    fi

    echo "{\"table_exist\":$table_exist,\"rules_mangle_exist\":$rules_mangle_exist,\"rules_mangle_counters\":$rules_mangle_counters,\"rules_mangle_output_exist\":$rules_mangle_output_exist,\"rules_proxy_exist\":$rules_proxy_exist,\"rules_proxy_counters\":$rules_proxy_counters,\"rules_other_mark_exist\":$rules_other_mark_exist}" | jq .
}

check_sing_box() {
    local sing_box_installed=0
    local sing_box_version_ok=0
    local sing_box_service_exist=0
    local sing_box_autostart_disabled=0
    local sing_box_process_running=0
    local sing_box_ports_listening=0

    # Check if sing-box is installed
    if command -v sing-box > /dev/null 2>&1; then
        sing_box_installed=1

        # Check version (must be >= 1.12.4)
        local version
        version=$(sing-box version 2> /dev/null | head -n 1 | awk '{print $3}')
        if [ -n "$version" ]; then
            version=$(echo "$version" | sed 's/^v//')
            # Extended cores report e.g. "1.13.12-extended-2.3.2"; the author only changes
            # the trailing "-extended-X.Y.Z" while the leading semver is the true upstream
            # sing-box version. Strip everything from the first '-' so the numeric compare
            # below works for both stock and extended builds.
            version=${version%%-*}
            local major
            local minor
            local patch
            major=$(echo "$version" | cut -d. -f1)
            minor=$(echo "$version" | cut -d. -f2)
            patch=$(echo "$version" | cut -d. -f3)

            # Compare version: must be >= 1.12.4. Each AND-term is grouped in
            # { ...; } so the || branches are independent — POSIX list operators
            # && / || are equal-precedence/left-associative, so without grouping
            # the trailing minor/patch tests would wrongly gate every branch
            # (e.g. 1.13.x and 2.0.0 would evaluate as not-compatible).
            if [ "$major" -gt 1 ] ||
                { [ "$major" -eq 1 ] && [ "$minor" -gt 12 ]; } ||
                { [ "$major" -eq 1 ] && [ "$minor" -eq 12 ] && [ "$patch" -ge 4 ]; }; then
                sing_box_version_ok=1
            fi
        fi
    fi

    # Check if service exists
    if [ -f /etc/init.d/sing-box ]; then
        sing_box_service_exist=1

        if ! /etc/init.d/sing-box enabled 2> /dev/null; then
            sing_box_autostart_disabled=1
        fi
    fi

    # Check if process is running
    if sing_box_process_exists; then
        sing_box_process_running=1
    fi

    # Check if sing-box is listening on required ports
    local port_53_ok=0
    local port_1602_ok=0

    if netstat -ln 2> /dev/null | grep -q "127.0.0.42:53"; then
        port_53_ok=1
    fi

    if netstat -ln 2> /dev/null | grep -q "127.0.0.1:1602"; then
        port_1602_ok=1
    fi

    # Both ports must be listening
    if [ "$port_53_ok" = "1" ] && [ "$port_1602_ok" = "1" ]; then
        sing_box_ports_listening=1
    fi

    echo "{\"sing_box_installed\":$sing_box_installed,\"sing_box_version_ok\":$sing_box_version_ok,\"sing_box_service_exist\":$sing_box_service_exist,\"sing_box_autostart_disabled\":$sing_box_autostart_disabled,\"sing_box_process_running\":$sing_box_process_running,\"sing_box_ports_listening\":$sing_box_ports_listening}" | jq .
}

check_fakeip() {
    local fakeip_address

    fakeip_address="$(dig +short @"$SB_DNS_INBOUND_ADDRESS" "$FAKEIP_TEST_DOMAIN" 2>/dev/null | sed -n '1p')"
    if echo "$fakeip_address" | grep -q '^198\.18\.'; then
        jq -n --arg ip "$fakeip_address" '{fakeip: true, IP: $ip}'
        return 0
    fi

    jq -n --arg ip "$fakeip_address" '{fakeip: false, IP: $ip}'
}

#######################################
# Clash API interface for managing proxies and groups
# Arguments:
#   $1 - Action: get_proxies, get_proxy_latency, get_group_latency, set_group_proxy
#   $2 - Proxy/Group tag (required for latency and set operations)
#   $3 - Timeout in ms (optional, defaults: 2000 for proxy, 5000 for group) or target proxy tag for set_group_proxy
# Outputs:
#   JSON formatted response
# Usage:
#   clash_api get_proxies
#   clash_api get_proxy_latency <proxy_tag> [timeout]
#   clash_api get_group_latency <group_tag> [timeout]
#   clash_api set_group_proxy <group_tag> <proxy_tag>
#######################################

clash_api() {
    local action="$1"
    local clash_api_controller_address CLASH_URL TEST_URL
    clash_api_controller_address="$(get_service_listen_address)"
    if [ -z "$clash_api_controller_address" ]; then
        clash_api_controller_address="127.0.0.1"
    fi
    CLASH_URL="$clash_api_controller_address:$SB_CLASH_API_CONTROLLER_PORT"
    TEST_URL="https://www.gstatic.com/generate_204"

    local enable_yacd_wan_access yacd_secret_key auth_header
    config_get_bool enable_yacd_wan_access "settings" "enable_yacd_wan_access" 0
    config_get yacd_secret_key "settings" "yacd_secret_key"

    if [ "$enable_yacd_wan_access" -eq 1 ]; then
        auth_header="Authorization: Bearer $yacd_secret_key"
    else
        auth_header=""
    fi

    case "$action" in
    get_proxies)
        curl -s --header "$auth_header" "$CLASH_URL/proxies" | jq .
        ;;

    get_proxy_latency)
        local proxy_tag="$2"
        local encoded_proxy_tag
        local timeout="${3:-2000}"

        if [ -z "$proxy_tag" ]; then
            echo '{"error":"proxy_tag required"}' | jq .
            return 1
        fi

        encoded_proxy_tag=$(printf '%s' "$proxy_tag" | jq -sRr @uri)

        curl -G -s "$CLASH_URL/proxies/$encoded_proxy_tag/delay" \
            --header "$auth_header" \
            --data-urlencode "url=$TEST_URL" \
            --data-urlencode "timeout=$timeout" | jq .
        ;;

    get_group_latency)
        local group_tag="$2"
        local encoded_group_tag
        local timeout="${3:-5000}"

        if [ -z "$group_tag" ]; then
            echo '{"error":"group_tag required"}' | jq .
            return 1
        fi

        encoded_group_tag=$(printf '%s' "$group_tag" | jq -sRr @uri)

        curl -G -s "$CLASH_URL/group/$encoded_group_tag/delay" \
            --header "$auth_header" \
            --data-urlencode "url=$TEST_URL" \
            --data-urlencode "timeout=$timeout" | jq .
        ;;

    set_group_proxy)
        local group_tag="$2"
        local proxy_tag="$3"

        local encoded_group_tag payload
        encoded_group_tag=$(printf '%s' "$group_tag" | jq -sRr @uri)
        payload=$(jq -cn --arg name "$proxy_tag" '{name:$name}')

        if [ -z "$group_tag" ] || [ -z "$proxy_tag" ]; then
            echo '{"error":"group_tag and proxy_tag required"}' | jq .
            return 1
        fi

        local response
        response=$(
            curl -X PUT -s -w "\n%{http_code}" "$CLASH_URL/proxies/$encoded_group_tag" \
                --header "$auth_header" \
                --header "Content-Type: application/json" \
                --data-raw "$payload"
        )

        local http_code
        local body
        http_code=$(echo "$response" | tail -n 1)
        body=$(echo "$response" | sed '$d')

        case "$http_code" in
        204)
            jq -n --arg group "$group_tag" --arg proxy "$proxy_tag" '{success:true,group:$group,proxy:$proxy}'
            ;;
        404)
            jq -n --arg group "$group_tag" '{success:false,error:"group_not_found",message:($group + " does not exist")}'
            return 1
            ;;
        400)
            if echo "$body" | grep -q "not found"; then
                jq -n --arg proxy "$proxy_tag" --arg group "$group_tag" '{success:false,error:"proxy_not_found",message:($proxy + " not found in group " + $group)}'
            else
                echo '{"success":false,"error":"bad_request","message":"Invalid request"}' | jq .
            fi
            return 1
            ;;
        *)
            if [ -n "$body" ]; then
                local body_json
                body_json=$(echo "$body" | jq -c .)
                echo "{\"success\":false,\"http_code\":$http_code,\"body\":$body_json}" | jq .
            else
                echo "{\"success\":false,\"http_code\":$http_code}" | jq .
            fi
            return 1
            ;;
        esac
        ;;

    *)
        echo '{"error":"unknown action","available":["get_proxies","get_proxy_latency","get_group_latency","set_group_proxy"]}' | jq .
        return 1
        ;;
    esac
}

print_global() {
    local message="$1"
    echo "$message"
}

global_check() {
    local NETSHIFT_LUCI_VERSION="Unknown"
    [ -n "$1" ] && NETSHIFT_LUCI_VERSION="$1"

    print_global "📡 Global check run!"
    print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━"
    print_global "🛠️ System info"

    local system_info_json
    system_info_json=$(get_system_info)

    if [ -n "$system_info_json" ]; then
    local netshift_version netshift_latest_version luci_app_version sing_box_version openwrt_version device_model sing_box_extended

        netshift_version=$(echo "$system_info_json" | jq -r '.netshift_version // "unknown"')
        # get_system_info no longer fetches the latest (it does no network I/O).
        # global_check is a one-shot SSH diagnostic, so fetch the real latest here
        # itself (shared helper); fall back to "unknown" if GitHub is unreachable.
        netshift_latest_version=$(updates_netshift_latest_tag)
        [ -z "$netshift_latest_version" ] && netshift_latest_version="unknown"
        luci_app_version=$(echo "$system_info_json" | jq -r '.luci_app_version // "unknown"')
        sing_box_version=$(echo "$system_info_json" | jq -r '.sing_box_version // "unknown"')
        openwrt_version=$(echo "$system_info_json" | jq -r '.openwrt_version // "unknown"')
        device_model=$(echo "$system_info_json" | jq -r '.device_model // "unknown"')

        print_global "🕳️ NetShift:      $netshift_version (latest: $netshift_latest_version)"
        print_global "🕳️ LuCI App:      $luci_app_version"
        print_global "📦 Sing-box:      $sing_box_version"
        print_global "🛜 OpenWrt:       $openwrt_version"
        print_global "🛜 Device:        $device_model"
    else
        print_global "❌ Failed to get system info"
    fi

    print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━"
    print_global "➡️ DNS status"

    local dns_check_json
    dns_check_json=$(check_dns_available)

    if [ -n "$dns_check_json" ]; then
        local dns_type dns_server dns_status dns_on_router bootstrap_dns_server bootstrap_dns_status dhcp_config_status
        local dns_via_outbound_tag

        dns_type=$(echo "$dns_check_json" | jq -r '.dns_type // "unknown"')
        dns_server=$(echo "$dns_check_json" | jq -r '.dns_server // "unknown"')
        dns_status=$(echo "$dns_check_json" | jq -r '.dns_status // 0')
        dns_on_router=$(echo "$dns_check_json" | jq -r '.dns_on_router // 0')
        bootstrap_dns_server=$(echo "$dns_check_json" | jq -r '.bootstrap_dns_server // ""')
        bootstrap_dns_status=$(echo "$dns_check_json" | jq -r '.bootstrap_dns_status // 0')
        dhcp_config_status=$(echo "$dns_check_json" | jq -r '.dhcp_config_status // 0')
        dns_via_outbound_tag=$(echo "$dns_check_json" | jq -r '.dns_via_outbound_tag // ""')

        # Bootstrap DNS
        if [ -n "$bootstrap_dns_server" ]; then
            if [ "$bootstrap_dns_status" -eq 1 ]; then
                print_global "✅ Bootstrap DNS: $bootstrap_dns_server"
            else
                print_global "❌ Bootstrap DNS: $bootstrap_dns_server"
            fi
        fi

        # DNS server status
        if [ "$dns_status" -eq 1 ]; then
            print_global "✅ Main DNS: $dns_server [$dns_type]"
        else
            print_global "❌ Main DNS: $dns_server [$dns_type]"
        fi

        # Main DNS detour (DNS-via-outbound)
        if [ -n "$dns_via_outbound_tag" ]; then
            print_global "ℹ️ Main DNS via outbound: $dns_via_outbound_tag"
        else
            print_global "ℹ️ Main DNS: direct"
        fi

        # DNS on router
        if [ "$dns_on_router" -eq 1 ]; then
            print_global "✅ DNS on router"
        else
            print_global "❌ DNS on router"
        fi

        # DHCP configuration check
        local dont_touch_dhcp
        config_get dont_touch_dhcp "settings" "dont_touch_dhcp"

        if [ "$dont_touch_dhcp" = "1" ]; then
            print_global "⚠️ dont_touch_dhcp is enabled. 📄 DHCP config:"
            awk '/^config /{p=($2=="dnsmasq")} p' /etc/config/dhcp
        elif [ "$dhcp_config_status" -eq 0 ]; then
            print_global "❌ DHCP configuration differs from template. 📄 DHCP config:"
            awk '/^config /{p=($2=="dnsmasq")} p' /etc/config/dhcp
        else
            print_global "✅ /etc/config/dhcp"
        fi
    else
        print_global "❌ Failed to get DNS info"
    fi

    print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━"
    print_global "📦 Sing-box status"

    local singbox_check_json
    singbox_check_json=$(check_sing_box)

    if [ -n "$singbox_check_json" ]; then
        local sing_box_installed sing_box_version_ok sing_box_service_exist sing_box_autostart_disabled sing_box_process_running sing_box_ports_listening

        sing_box_installed=$(echo "$singbox_check_json" | jq -r '.sing_box_installed // 0')
        sing_box_version_ok=$(echo "$singbox_check_json" | jq -r '.sing_box_version_ok // 0')
        sing_box_service_exist=$(echo "$singbox_check_json" | jq -r '.sing_box_service_exist // 0')
        sing_box_autostart_disabled=$(echo "$singbox_check_json" | jq -r '.sing_box_autostart_disabled // 0')
        sing_box_process_running=$(echo "$singbox_check_json" | jq -r '.sing_box_process_running // 0')
        sing_box_ports_listening=$(echo "$singbox_check_json" | jq -r '.sing_box_ports_listening // 0')

        if [ "$sing_box_installed" -eq 1 ]; then
            print_global "✅ Sing-box installed"
        else
            print_global "❌ Sing-box installed"
        fi

        if [ "$sing_box_version_ok" -eq 1 ]; then
            print_global "✅ Sing-box version is compatible (newer than 1.12.4)"
        else
            print_global "❌ Sing-box version is not compatible (older than 1.12.4)"
        fi

        if [ "$sing_box_service_exist" -eq 1 ]; then
            print_global "✅ Sing-box service exist"
        else
            print_global "❌ Sing-box service exist"
        fi

        if [ "$sing_box_autostart_disabled" -eq 1 ]; then
            print_global "✅ Sing-box autostart disabled"
        else
            print_global "❌ Sing-box autostart disabled"
        fi

        if [ "$sing_box_process_running" -eq 1 ]; then
            print_global "✅ Sing-box process running"
        else
            print_global "❌ Sing-box process running"
        fi

        if [ "$sing_box_ports_listening" -eq 1 ]; then
            print_global "✅ Sing-box listening ports"
        else
            print_global "❌ Sing-box listening ports"
        fi
    else
        print_global "❌ Failed to get sing-box info"
    fi

    print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━"
    print_global "🧱 NFT rules status"

    local nft_check_json
    nft_check_json=$(check_nft_rules)

    if [ -n "$nft_check_json" ]; then
        local table_exist rules_mangle_exist rules_mangle_counters rules_mangle_output_exist rules_proxy_exist rules_proxy_counters rules_other_mark_exist

        table_exist=$(echo "$nft_check_json" | jq -r '.table_exist // 0')
        rules_mangle_exist=$(echo "$nft_check_json" | jq -r '.rules_mangle_exist // 0')
        rules_mangle_counters=$(echo "$nft_check_json" | jq -r '.rules_mangle_counters // 0')
        rules_mangle_output_exist=$(echo "$nft_check_json" | jq -r '.rules_mangle_output_exist // 0')
        rules_proxy_exist=$(echo "$nft_check_json" | jq -r '.rules_proxy_exist // 0')
        rules_proxy_counters=$(echo "$nft_check_json" | jq -r '.rules_proxy_counters // 0')
        rules_other_mark_exist=$(echo "$nft_check_json" | jq -r '.rules_other_mark_exist // 0')

        if [ "$table_exist" -eq 1 ]; then
            print_global "✅ Table exist"
        else
            print_global "❌ Table exist"
        fi

        if [ "$rules_mangle_exist" -eq 1 ]; then
            print_global "✅ Rules mangle exist"
        else
            print_global "❌ Rules mangle exist"
        fi

        if [ "$rules_mangle_counters" -eq 1 ]; then
            print_global "✅ Rules mangle counters"
        else
            print_global "⚠️  Rules mangle counters"
        fi

        if [ "$rules_mangle_output_exist" -eq 1 ]; then
            print_global "✅ Rules mangle output exist"
        else
            print_global "❌ Rules mangle output exist"
        fi

        if [ "$rules_proxy_exist" -eq 1 ]; then
            print_global "✅ Rules proxy exist"
        else
            print_global "❌ Rules proxy exist"
        fi

        if [ "$rules_proxy_counters" -eq 1 ]; then
            print_global "✅ Rules proxy counters"
        else
            print_global "⚠️  Rules proxy counters"
        fi

        if [ "$rules_other_mark_exist" -eq 1 ]; then
            print_global "⚠️  Additional marking rules found:"
            nft list ruleset | awk '/table inet '"$NFT_TABLE_NAME"'/{flag=1; next} /^table/{flag=0} !flag' | grep -E "mark set|meta mark"
        else
            print_global "✅ Additional marking rules found"
        fi
    else
        print_global "❌ Failed to get NFT rules info"
    fi

    print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━"
    print_global "📄 NetShift config"
    show_config

    # print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━"
    # print_global "🔧 System check"

    # if grep -E "^nameserver\s+([0-9]{1,3}\.){3}[0-9]{1,3}" "$RESOLV_CONF" | grep -vqE "127\.0\.0\.1|0\.0\.0\.0"; then
    #     print_global "❌ /etc/resolv.conf contains external nameserver:"
    #     cat /etc/resolv.conf
    #     echo ""
    # else
    #     print_global "✅ /etc/resolv.conf"
    # fi

    # print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━"
    # print_global "🧱 NFT table"
    # check_nft

    print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━"
    print_global "📄 WAN config"
    if uci show network.wan > /dev/null 2>&1; then
        awk '
            /^config / {
                p = ($2 == "interface" && $3 == "'\''wan'\''")
                proto = ""
            }
            p {
                if ($1 == "option" && $2 == "proto") {
                    proto = $3
                    print
                } else if (proto == "'\''static'\''" && $1 == "option" && ($2 == "ipaddr" || $2 == "netmask" || $2 == "gateway")) {
                    print "        option", $2, "'\''******'\''"
                } else if (proto == "'\''pppoe'\''" && $1 == "option" && ($2 == "username" || $2 == "password")) {
                    print "        option", $2, "'\''******'\''"
                } else {
                    print
                }
            }
        ' /etc/config/network
    else
        print_global "❌ WAN configuration not found"
    fi

    if uci show network | grep -q endpoint_host; then
        uci show network | grep endpoint_host | cut -d'=' -f2 | tr -d "'\" " | while read -r host; do
            if [ "$host" = "engage.cloudflareclient.com" ]; then
                print_global "⚠️ WARP detected: $host"
                continue
            fi

            ip_prefix=$(echo "$host" | cut -d'.' -f1,2)
            if echo "$CLOUDFLARE_OCTETS" | grep -wq "$ip_prefix"; then
                print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━"
                print_global "⚠️ WARP detected: $host"
            fi
        done
    fi

    if uci show network | grep -q route_allowed_ips; then
        uci show network | grep "wireguard_.*\.route_allowed_ips='1'" | cut -d'.' -f1-2 | while read -r peer_section; do
            local allowed_ips
            allowed_ips=$(uci get "${peer_section}.allowed_ips" 2> /dev/null)

            if [ "$allowed_ips" = "0.0.0.0/0" ]; then
                print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━"
                print_global "⚠️ WG Route allowed IP enabled with 0.0.0.0/0"
            fi
        done
    fi

    if [ -f "/etc/init.d/zapret" ]; then
        print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━"
        print_global "⚠️ Zapret detected"
    fi

    print_global "━━━━━━━━━━━━━━━━━━━━━━━━━━━"
    print_global "🥸 FakeIP status"

    local fakeip_check_json
    fakeip_check_json=$(check_fakeip)

    if [ -n "$fakeip_check_json" ]; then
        local fakeip_status

        fakeip_status=$(echo "$fakeip_check_json" | jq -r '.fakeip // false')

        if [ "$fakeip_status" = "true" ]; then
            print_global "✅ Router DNS is routed through sing-box"
        else
            print_global "⚠️ Router DNS is NOT routed through sing-box"
        fi
    else
        print_global "❌ Failed to get FakeIP info"
    fi

    local fakeip_address
    fakeip_address=$(dig +short @127.0.0.42 $FAKEIP_TEST_DOMAIN)

    if echo "$fakeip_address" | grep -q "^198\.18\."; then
        print_global "✅ Sing-box works with FakeIP: $fakeip_address"
    else
        print_global "❌ Sing-box does NOT work with FakeIP: $fakeip_address"
    fi
}

show_help() {
    cat << EOF
Usage: $0 COMMAND

Available commands:
    start                   Start NetShift service
    stop                    Stop NetShift service
    reload                  Reload NetShift configuration
    restart                 Restart NetShift service
    main                    Run main NetShift process
    list_update             Update domain lists
    subscription_update     Update subscription proxies
    check_proxy             Check proxy connectivity
    check_nft               Check NFT rules
    check_nft_rules         Check NFT rules status
    check_sing_box          Check sing-box installation and status
    check_logs              Show NetShift logs from system journal
    check_sing_box_logs     Show sing-box logs
    check_fakeip            Test FakeIP on router
    clash_api               Clash API interface for managing proxies and groups
    show_config             Display current NetShift configuration
    show_version            Show NetShift version
    show_sing_box_config    Show sing-box configuration
    show_sing_box_version   Show sing-box version
    show_system_info        Show system information
    get_status              Get NetShift service status
    get_sing_box_status     Get sing-box service status
    get_system_info         Get system information in JSON format
    check_dns_available     Check DNS server availability
    global_check            Run global system check
    component_action        Run component action: <component> <action>
                            (e.g. sing_box install_extended|install_stable|check_update,
                            subscription clear_cache to wipe all caches + redownload)
    component_action_async  Start component_action in background; echoes a job_id
                            (use with component_action_status to poll the outcome)
    component_action_status Report an async component action by job_id: <job_id>
EOF
}

case "$1" in
start)
    start
    ;;
stop)
    stop
    ;;
reload)
    reload
    ;;
restart)
    restart
    ;;
main)
    main
    ;;
__monitor)
    # Hidden subcommand (task-035): runs the detached sing-box health monitor.
    # Launched only by start_sing_box_monitor via setsid with the procd lock fd
    # (1000) closed, so the long-lived monitor never holds the procd service
    # lock. Not part of the public CLI / help.
    monitor_sing_box
    ;;
list_update)
    list_update
    ;;
subscription_update)
    subscription_update
    ;;
check_proxy)
    check_proxy
    ;;
check_nft)
    check_nft
    ;;
check_nft_rules)
    check_nft_rules
    ;;
check_sing_box)
    check_sing_box
    ;;
check_logs)
    check_logs
    ;;
check_sing_box_logs)
    check_sing_box_logs
    ;;
check_fakeip)
    check_fakeip
    ;;
clash_api)
    clash_api "$2" "$3" "$4"
    ;;
show_config)
    show_config
    ;;
show_version)
    show_version
    ;;
show_sing_box_config)
    show_sing_box_config
    ;;
show_sing_box_version)
    show_sing_box_version
    ;;
show_system_info)
    show_system_info
    ;;
get_status)
    get_status
    ;;
get_sing_box_status)
    get_sing_box_status
    ;;
get_system_info)
    get_system_info
    ;;
check_dns_available)
    check_dns_available
    ;;
global_check)
    global_check "${2:-}"
    ;;
component_action)
    component_action "$2" "$3"
    ;;
component_action_async)
    component_action_async "$2" "$3"
    ;;
component_action_status)
    component_action_status "$2"
    ;;
*)
    show_help
    exit 1
    ;;
esac
