#!/bin/sh
# flash.sh — Flash custom Kahf Router firmware onto an OpenWrt router
#
# Downloads the latest sysupgrade image from S3 and flashes it via sysupgrade.
# Resets all config (-n flag) so 99-family-safe runs cleanly on first boot.
# After reboot, streams bootstrap logs so you can watch OAF install live.
#
# Usage:
#   lab/flash.sh [options]
#   lab/flash.sh --ip 192.168.1.1 --password mypass --channel dev
#
# Options:
#   -i, --ip <IP>           Router IP (default: auto-detect via kahf-router.lan, then openwrt.lan)
#   -u, --user <user>       SSH user (default: root)
#   -p, --password <pass>   SSH password
#   -c, --channel <ch>      Channel: dev or prod (default: prod)
#   -h, --help              Show this help

set -eu

# Package/firmware CDN host. Matches KAHF_CDN_DOMAIN in config/lab.env.
KAHF_HOST="${KAHF_HOST:-router.kahf.co}"

# Do NOT use 'set -u' beyond this point — many variables are conditionally
# set inside blocks and referenced later.
set +u

# ── Colours ──────────────────────────────────────────────────────────────────
_R='\033[1;31m' _G='\033[1;32m' _Y='\033[1;33m' _B='\033[1;34m' _N='\033[0m'
info() { printf "${_B}[*]${_N} %s\n" "$*"; }
ok() { printf "${_G}[+]${_N} %s\n" "$*"; }
warn() { printf "${_Y}[!]${_N} %s\n" "$*"; }
die() {
    printf "${_R}[-]${_N} %s\n" "$*" >&2
    exit 1
}
ask() { printf "${_B}  ➜${_N} %s " "$*"; }

# ── Defaults ─────────────────────────────────────────────────────────────────
ROUTER_IP=""
SSH_USER="root"
SSH_PASS=""
CHANNEL="dev"
BUCKET_OVERRIDE=""
LOCAL_FILE=""
FW_OWNED=0  # 1 if we downloaded FW_TMP and must delete it on exit
AUTO_YES=0  # 1 to skip interactive confirmations (--yes flag)

EXPECTED_VERSION="25.12.4"

# ── Device table ───────────────────────────────────────────────────────────
# Two devices ship from this repo today (WR3000K in production, WR1205K
# coming from lab/build-from-source.sh) and are maintained side by side, so a
# single EXPECTED_MODEL string is no longer enough — this used to be exactly
# that: one hardcoded "Tenbay WR3000K" / "KAHF BURAQ6" pair, silently correct
# only because there was nothing else to confuse it with. Every place that
# used to compare against those two constants now goes through
# _device_for_model(), keyed on /tmp/sysinfo/model — the stock/ODM firmware
# reports the DTS model string, and Kahf firmware overwrites
# /tmp/sysinfo/model at preinit with a branded name
# (image-build/files/lib/preinit/03_kahf_model for the WR3000K), so a router
# already running our image reports that instead. Both must map to the same
# device.
#
# Sets DEVICE_SLUG (feeds the S3 channel path "router-<dev|prod>-tenbay-
# <slug>", and is what _validate_channel_device_match checks an operator's
# --bucket/--channel choice against), DEVICE_CONFIG_WIPE_FORCED (see the
# WR1205K-specific confirmation gate in Step 4 below), and PRODUCT_MTD (the
# /dev/mtdN node holding the factory WiFi SSID/PSK — "mtd6" is a fact about
# the WR3000K's OWN partition table, not anything universal about a
# partition labelled "Product"; see the mtd6 read further down for why an
# empty PRODUCT_MTD skips that lookup instead of guessing a number).
# DEVICE_SLUG empty means "model not recognised" — every caller must treat
# that as "cannot safely derive anything about this device," not fall
# through to WR3000K defaults.
_device_for_model() {
    case "$1" in
    "Tenbay WR3000K" | "KAHF BURAQ6")
        DEVICE_SLUG="wr3000k"
        DEVICE_CONFIG_WIPE_FORCED=0
        PRODUCT_MTD="mtd6"
        ;;
    "Tenbay WR1205K")
        DEVICE_SLUG="wr1205k"
        # sysupgrade's metadata for this device carries a compat_version bump
        # ("Config cannot be migrated from swconfig to DSA" — see the WR1205K
        # note in CLAUDE.md's "From-source builds" section). OpenWrt itself
        # refuses to carry config across that boundary, independent of any
        # flag this script passes — so the wipe below is a fact about the
        # hardware/image, not a choice this script is making.
        DEVICE_CONFIG_WIPE_FORCED=1
        # The WR1205K's DTS (firmware/patch/0001-ramips-add-support-for-
        # Tenbay-WR1205K.patch) defines a completely different, shorter
        # static partition table than the WR3000K's — Product is its THIRD
        # partition, not the seventh — and its actual /dev/mtdN number has
        # never been read off real hardware. Left empty on purpose; see the
        # mtd6 read below.
        PRODUCT_MTD=""
        ;;
    # The Kahf-branded name for the WR1205K is not decided yet. Once it is,
    # add it here exactly like "KAHF BURAQ6" above (same DEVICE_SLUG/
    # DEVICE_CONFIG_WIPE_FORCED/PRODUCT_MTD as the stock "Tenbay WR1205K"
    # case) — a router already running Kahf WR1205K firmware still reports
    # the stock DTS string above until then, so nothing is broken by its
    # absence, this is purely a hook for the day the branded string exists.
    # "<Kahf WR1205K branded name>")
    #     DEVICE_SLUG="wr1205k"
    #     DEVICE_CONFIG_WIPE_FORCED=1
    #     PRODUCT_MTD=""
    #     ;;
    *)
        DEVICE_SLUG=""
        DEVICE_CONFIG_WIPE_FORCED=0
        PRODUCT_MTD=""
        ;;
    esac
}

# Every DEVICE_SLUG _device_for_model can produce. Used by
# _validate_channel_device_match to catch an operator's channel naming a
# DIFFERENT recognised device than the one the router reports — kept as one
# list so a third device only needs adding here and to _device_for_model,
# never a third place.
KNOWN_DEVICE_SLUGS="wr3000k wr1205k"

# ── Argument parsing ──────────────────────────────────────────────────────────
usage() {
    cat <<EOF
Usage: $(basename "$0") [options]

Options:
  -i, --ip <IP>           Router IP (default: auto-detect via kahf-router.lan, then openwrt.lan)
  -u, --user <user>       SSH user (default: root)
  -p, --password <pass>   SSH password
  -c, --channel <ch>      Channel: dev or prod (default: prod)
  -b, --bucket <name>     Override the S3 path under the router bucket
                          (e.g. router-prod-tenbay-wr3000k) instead of the
                          one derived from --channel
  -f, --local-file <path>  Flash a locally-built firmware file (skips S3 download)
  -y, --yes               Skip interactive confirmations (for non-interactive use)
  -h, --help              Show this help
EOF
}

while [ $# -gt 0 ]; do
    case "$1" in
    -i | --ip)
        ROUTER_IP="$2"
        shift 2
        ;;
    -u | --user)
        SSH_USER="$2"
        shift 2
        ;;
    -p | --password)
        SSH_PASS="$2"
        shift 2
        ;;
    -c | --channel)
        CHANNEL="$2"
        shift 2
        ;;
    -b | --bucket)
        BUCKET_OVERRIDE="$2"
        shift 2
        ;;
    -f | --local-file | --file)
        LOCAL_FILE="$2"
        shift 2
        ;;
    -y | --yes)
        AUTO_YES=1
        shift
        ;;
    -h | --help)
        usage
        exit 0
        ;;
    *) die "Unknown option: $1" ;;
    esac
done

if [ -z "${LOCAL_FILE}" ] && [ -z "${BUCKET_OVERRIDE}" ]; then
    case "${CHANNEL}" in
    dev | prod) ;;
    *) die "Invalid channel: '${CHANNEL}' (must be dev or prod)" ;;
    esac
fi
# Single "router" bucket; a path underneath it selects the channel. The path
# is resolved in _resolve_s3_path() below, AFTER SSH is up, because the most
# reliable answer comes from the router itself.
FW_TMP="/tmp/kahf-firmware-$$.bin"

# Refuses an S3 path that names a device slug OTHER than the one this router
# just reported (DEVICE_SLUG, set by _device_for_model in Step 4 — this is
# why _resolve_s3_path is only ever called after that runs). A path naming NO
# recognised slug at all — the generic "main"/"dev" product line, or some
# other operator bucket name — is left alone; this exists only to catch the
# specific, catastrophic mistake of pointing a WR1205K (ramips/mt7621) at a
# WR3000K (mediatek/filogic) channel or vice versa, where "flashing a device
# from a channel other than its own" (the old comment's justification for
# --bucket always winning) stops being a legitimate thing to want at all —
# the two devices don't just differ in feature set, they're different SoCs
# and image formats, and a 404 is the BEST case if this goes wrong.
_validate_channel_device_match() {
    _path="$1"
    [ -z "${DEVICE_SLUG}" ] && return 0
    for _slug in ${KNOWN_DEVICE_SLUGS}; do
        [ "${_slug}" = "${DEVICE_SLUG}" ] && continue
        case "${_path}" in
        *"${_slug}"*)
            die "Refusing to flash: channel path '${_path}' looks like it targets a
     ${_slug} device, but this router reported model '${ACTUAL_MODEL}'
     (device: ${DEVICE_SLUG}). The two devices use different SoCs and image
     formats, so this is exactly the kind of mistake that bricks a router.
     If you really mean to flash mismatched firmware, do it with an explicit
     --local-file and understand the risk yourself — this script will not
     help you do it by S3 channel." ;;
        esac
    done
}

# Resolve which path under the router bucket to flash from.
#
# Precedence, most to least specific:
#   1. --bucket           an explicit operator override always wins for
#                         CHANNEL choice (dev vs. prod on this router's own
#                         device); it does NOT win over the device-mismatch
#                         check above, which runs regardless.
#   2. the router's own   /etc/oaf-update.conf UPDATE_CHANNEL is what THIS
#      UPDATE_CHANNEL     device's updater already pulls from every night, so
#                         it is authoritative for it. Deriving from it makes
#                         the common case correct with no flag at all.
#   3. model-derived      once we know the device (DEVICE_SLUG, set by
#                         _device_for_model in Step 4, before this runs), its
#                         channel path is not a guess — it's
#                         router-<dev|prod>-tenbay-<slug>, always.
#
# Rule 3 used to be a blind guess: `--channel prod` mapped to the GENERIC
# router/main path (the non-per-device product line) and only warned that it
# might be wrong, which was survivable with one device — worst case a 404 on
# a URL the operator never chose. With two devices sharing this script that
# guess is now actively dangerous rather than merely wrong: nothing stops a
# future third device's generic-looking channel from resolving to a REAL (but
# mismatched) firmware instead of 404ing. Since the router's own model tells
# us exactly which device this is, there is no reason to guess for a
# recognised device anymore — derive its channel directly. Only a genuinely
# unrecognised model (DEVICE_SLUG empty) still has nothing to derive from,
# and that now refuses instead of guessing — see the die below.
_resolve_s3_path() {
    if [ -n "${BUCKET_OVERRIDE}" ]; then
        S3_PATH="${BUCKET_OVERRIDE}"
        _validate_channel_device_match "${S3_PATH}"
        info "Channel path: ${S3_PATH} (from --bucket)"
        return 0
    fi

    _router_channel="$(_ssh "sed -n 's/^UPDATE_CHANNEL=//p' /etc/oaf-update.conf 2>/dev/null | head -1" 2>/dev/null | tr -d '\r\"'"'"' ')"
    if [ -n "${_router_channel}" ]; then
        S3_PATH="${_router_channel}"
        _validate_channel_device_match "${S3_PATH}"
        info "Channel path: ${S3_PATH} (from the router's /etc/oaf-update.conf)"
        return 0
    fi

    if [ -n "${DEVICE_SLUG}" ]; then
        if [ "${CHANNEL}" = "prod" ]; then
            S3_PATH="router-prod-tenbay-${DEVICE_SLUG}"
        else
            S3_PATH="router-dev-tenbay-${DEVICE_SLUG}"
        fi
        info "Channel path: ${S3_PATH} (derived from device model + --channel ${CHANNEL})"
        return 0
    fi

    die "Router has no UPDATE_CHANNEL in /etc/oaf-update.conf, and its model
     ('${ACTUAL_MODEL}') is not one this script recognises — there is no safe
     way to guess which per-device S3 channel to flash from (see
     _device_for_model for the recognised list). Pass --bucket explicitly,
     e.g. --bucket router-prod-tenbay-wr3000k."
}

# ── SSH helpers ───────────────────────────────────────────────────────────────
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR"

_ssh() {
    # -n: redirect SSH stdin from /dev/null so interactive read prompts in this
    # script are not consumed by SSH connections
    # shellcheck disable=SC2086
    if [ -n "${SSH_PASS}" ] && command -v sshpass >/dev/null 2>&1; then
        sshpass -p "${SSH_PASS}" ssh -n ${SSH_OPTS} "${SSH_USER}@${ROUTER_IP}" "$@"
    else
        # shellcheck disable=SC2086
        ssh -n ${SSH_OPTS} "${SSH_USER}@${ROUTER_IP}" "$@"
    fi
}

_ssh_pipe_in() {
    # Pipe stdin to a remote file path ($1)
    local remote="$1"
    # shellcheck disable=SC2086,SC2029
    if [ -n "${SSH_PASS}" ] && command -v sshpass >/dev/null 2>&1; then
        sshpass -p "${SSH_PASS}" ssh ${SSH_OPTS} "${SSH_USER}@${ROUTER_IP}" "cat > '${remote}'"
    else
        # shellcheck disable=SC2086,SC2029
        ssh ${SSH_OPTS} "${SSH_USER}@${ROUTER_IP}" "cat > '${remote}'"
    fi
}

_ssh_script() {
    # Runs a heredoc piped on stdin as `sh -s` on the router. Used instead of
    # inlining uci/awk one-liners into an _ssh "..." argument, which needs
    # every remote $ escaped to survive local double-quote parsing — the same
    # quoting trap lab/router-test-deadman.sh's router_ssh "sh -s" <<'REMOTE'
    # pattern exists to avoid. Caller pipes a single-quoted heredoc into this.
    # shellcheck disable=SC2086
    if [ -n "${SSH_PASS}" ] && command -v sshpass >/dev/null 2>&1; then
        sshpass -p "${SSH_PASS}" ssh ${SSH_OPTS} "${SSH_USER}@${ROUTER_IP}" "sh -s"
    else
        # shellcheck disable=SC2086
        ssh ${SSH_OPTS} "${SSH_USER}@${ROUTER_IP}" "sh -s"
    fi
}

# ── IP auto-detection ─────────────────────────────────────────────────────────
detect_ip() {
    local ip=""  # shellcheck disable=SC2034

    # 1. Try kahf-router.lan first (Kahf custom firmware hostname), then fall
    #    back to openwrt.lan (stock OpenWrt). Use hostnames not resolved IPs so
    #    macOS mDNS routes via the correct interface (avoids VPN routing issues)
    if ping -c1 -W2 kahf-router.lan >/dev/null 2>&1; then
        echo "kahf-router.lan"
        return
    fi
    if ping -c1 -W2 openwrt.lan >/dev/null 2>&1; then
        echo "openwrt.lan"
        return
    fi

    # 2. Common OpenWrt default
    if ping -c1 -W2 192.168.1.1 >/dev/null 2>&1; then
        echo "192.168.1.1"
        return
    fi

    # 3. Default gateway
    local gw=""
    gw=$(ip route show default 2>/dev/null | awk '/default via/{print $3}' | head -1 || true)
    if [ -z "$gw" ]; then
        # macOS fallback
        gw=$(route -n get default 2>/dev/null | awk '/gateway:/{print $2}' || true)
    fi
    if [ -n "$gw" ] && ping -c1 -W2 "$gw" >/dev/null 2>&1; then
        echo "$gw"
        return
    fi

    echo ""
}

# ── Cleanup ───────────────────────────────────────────────────────────────────
cleanup() {
    [ "${FW_OWNED}" = "1" ] && rm -f "${FW_TMP}"
}
# EXIT alone is not enough: an untrapped signal terminates the shell WITHOUT
# running the EXIT trap, so a `timeout`/Ctrl-C/SIGHUP kill leaks whatever
# cleanup() would have released. Trap the signals too.
trap cleanup EXIT INT TERM HUP

# ═════════════════════════════════════════════════════════════════════════════
echo ""
echo "╔══════════════════════════════════════════════════════════╗"
echo "║   Kahf Router Firmware Flash                            ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo ""

# ── Step 1: Router IP ────────────────────────────────────────────────────────
if [ -z "${ROUTER_IP:-}" ]; then
    info "Auto-detecting router IP..."
    ROUTER_IP=$(detect_ip)
    if [ -n "${ROUTER_IP}" ]; then
        ok "Detected: ${ROUTER_IP}"
    else
        warn "Could not auto-detect router IP."
        ask "Router IP address:"
        read -r ROUTER_IP
        [ -z "${ROUTER_IP}" ] && die "No IP provided."
    fi
fi

# ── Step 2: SSH password ──────────────────────────────────────────────────────
if [ -z "${SSH_PASS}" ] && [ -t 0 ]; then
    ask "SSH password for ${SSH_USER}@${ROUTER_IP} (leave blank if key auth):"
    # Read without echo
    stty -echo 2>/dev/null || true
    read -r SSH_PASS
    stty echo 2>/dev/null || true
    echo ""
fi

if [ -n "${SSH_PASS}" ] && ! command -v sshpass >/dev/null 2>&1; then
    warn "sshpass not found — SSH will prompt for password interactively."
    warn "Install with: brew install sshpass  OR  apt install sshpass"
    SSH_PASS=""
fi

# ── Step 3: Test SSH connectivity ─────────────────────────────────────────────
info "Testing SSH connection to ${SSH_USER}@${ROUTER_IP}..."
if ! _ssh "echo ok" >/dev/null 2>&1; then
    die "Cannot SSH to ${ROUTER_IP}. Check IP, user, and password."
fi
ok "SSH connection successful"

# ── Step 4: Verify router model ───────────────────────────────────────────────
info "Checking router model..."
# Model must be known BEFORE _resolve_s3_path runs — it derives the
# device-specific channel from DEVICE_SLUG and refuses a channel that names a
# different recognised device (see _validate_channel_device_match). Fetching
# the model first, THEN resolving the path, is a deliberate reorder from the
# old sequence (path resolved, then model merely reported) — that ordering
# worked when there was one device to accidentally target, and stops working
# the moment there are two.
ACTUAL_MODEL=$(_ssh "cat /tmp/sysinfo/model 2>/dev/null || echo unknown" || echo "unknown")
_device_for_model "${ACTUAL_MODEL}"

if [ -n "${DEVICE_SLUG}" ]; then
    ok "Model: ${ACTUAL_MODEL} (device: ${DEVICE_SLUG})"
else
    warn "Model mismatch!"
    warn "  Recognised: Tenbay WR3000K / KAHF BURAQ6 / Tenbay WR1205K"
    warn "  Actual:     ${ACTUAL_MODEL}"
    warn ""
    warn "Flashing the wrong firmware can brick your router."
    # A non-TTY stdin is NOT consent. It used to be -- `[ ! -t 0 ]` sat in this
    # condition, so running under cron, CI, a pipe, or a plain `< /dev/null`
    # auto-approved a flash that can brick the device. --yes is how you say yes.
    if [ "${AUTO_YES}" = "1" ]; then
        warn "Proceeding with override (--yes)..."
    elif [ ! -t 0 ]; then
        die "Refusing to flash: stdin is not a TTY so this prompt cannot be answered,
     and --yes was not given. Re-run with --yes if you really mean it."
    else
        ask "Flash anyway? [y/N]: "
        read -r CONFIRM
        case "${CONFIRM}" in
        y | Y | yes | YES) warn "Proceeding with override..." ;;
        *) die "Aborted." ;;
        esac
    fi
    # DEVICE_SLUG stays empty here — an operator overriding an unrecognised
    # model still has to pass --bucket explicitly below; there is nothing to
    # derive a channel from for a device this script has never heard of.
fi

# ── Step 4b: WR1205K config-wipe acknowledgement ──────────────────────────────
# Up front and separate from the generic "About to flash" gate in Step 9,
# because this isn't the same kind of confirmation. Step 9's wipe is a choice
# this script makes (the -n flag) and is identical for every device. This one
# is a property of the WR1205K's own sysupgrade metadata — the config wipe
# happens whether or not -n is passed, on every flash including a routine
# Kahf-to-Kahf update, and no future flag on this script can change that. An
# operator who only reads Step 9's wording would reasonably think it's the
# same "clean first boot by choice" wipe the WR3000K gets — it is not, and
# conflating them would let a stronger objection than "clean first boot" go
# unheard. This has to run before ANY network activity (S3 resolve/download),
# not just before the final flash, since "up front" is the whole point.
if [ "${DEVICE_CONFIG_WIPE_FORCED}" = "1" ]; then
    warn ""
    warn "This is a WR1205K: flashing it ALWAYS wipes /etc, even on a routine"
    warn "update between two Kahf WR1205K builds. Its sysupgrade metadata"
    warn "carries a compat_version bump (swconfig → DSA) that OpenWrt refuses"
    warn "to migrate config across, independent of any flag this script"
    warn "passes. WiFi, LAN, and every other setting will be lost."
    warn ""
    if [ "${AUTO_YES}" = "1" ]; then
        warn "Proceeding — config wipe acknowledged via --yes."
    elif [ ! -t 0 ]; then
        die "Refusing to flash a WR1205K non-interactively without --yes: this
     always wipes config and stdin is not a TTY to confirm it. Re-run with
     --yes if you understand and accept the wipe."
    else
        ask "Type 'wipe' to confirm you understand config will be erased:"
        read -r CONFIRM_WIPE
        [ "${CONFIRM_WIPE}" = "wipe" ] || die "Aborted (confirmation text did not match)."
    fi
fi

# SSH is up and the device is known, so the router can now tell us which
# channel it belongs to. Skipped entirely for --local-file, which never
# touches S3 — resolving a path there would only print a channel line for a
# download that does not happen.
if [ -z "${LOCAL_FILE}" ]; then
    _resolve_s3_path
    S3_BUCKET_NAME="router/${S3_PATH}"
    S3_URL="https://${KAHF_HOST}/${S3_BUCKET_NAME}/firmware/openwrt-sysupgrade.bin"
fi

# ── Step 5: Check current firmware version ───────────────────────────────────
info "Checking current firmware version..."
CURRENT_VERSION=$(_ssh ". /etc/openwrt_release 2>/dev/null && echo \"\${DISTRIB_RELEASE}\"" || echo "unknown")
if [ "${CURRENT_VERSION}" = "${EXPECTED_VERSION}" ]; then
    ok "Router is already running OpenWrt ${EXPECTED_VERSION}"
    # Same reasoning as above: not a TTY does not mean yes.
    if [ "${AUTO_YES}" = "1" ]; then
        warn "Re-flashing (--yes)..."
    elif [ ! -t 0 ]; then
        die "Refusing to re-flash: already on ${EXPECTED_VERSION}, stdin is not a TTY,
     and --yes was not given. Re-run with --yes if you really mean it."
    else
        ask "Already on target version. Re-flash anyway? [y/N]: "
        read -r CONFIRM
        case "${CONFIRM}" in
        y | Y | yes | YES) warn "Re-flashing..." ;;
        *)
            ok "Nothing to do."
            exit 0
            ;;
        esac
    fi
else
    if [ "${CURRENT_VERSION}" = "unknown" ]; then
        warn "Could not read firmware version — proceeding with flash"
    else
        info "Current: ${CURRENT_VERSION} → upgrading to ${EXPECTED_VERSION}"
    fi
fi

# ── Step 6: Acquire firmware ──────────────────────────────────────────────────
if [ -n "${LOCAL_FILE}" ]; then
    [ -f "${LOCAL_FILE}" ] || die "Local firmware file not found: ${LOCAL_FILE}"
    FW_SIZE=$(wc -c < "${LOCAL_FILE}" 2>/dev/null || echo "0")
    [ "${FW_SIZE}" -eq 0 ] && die "Local firmware file is empty: ${LOCAL_FILE}"
    ok "Using local firmware: ${LOCAL_FILE}"
    ok "  Size: $(( FW_SIZE / 1024 / 1024 )) MB"
    FW_TMP="${LOCAL_FILE}"
else
    info "Downloading firmware from S3..."
    info "  Bucket:  ${S3_BUCKET_NAME}"
    info "  URL:     ${S3_URL}"
    echo ""

    # Cache-bust: S3/CDN can serve a stale openwrt-sysupgrade.bin that lags the
    # freshly-published sha256sums, which would (correctly) fail the integrity
    # check below. A unique query string forces the current object.
    _CB="$(date +%s)$$"
    DL_RC=1
    if command -v curl >/dev/null 2>&1; then
        curl -fSL --progress-bar -o "${FW_TMP}" "${S3_URL}?cb=${_CB}" && DL_RC=0 || DL_RC=$?
    elif command -v wget >/dev/null 2>&1; then
        wget -q --show-progress -O "${FW_TMP}" "${S3_URL}?cb=${_CB}" && DL_RC=0 || DL_RC=$?
    else
        die "Neither curl nor wget found."
    fi
    [ "${DL_RC}" -ne 0 ] && die "Download failed (exit=${DL_RC}). Check bucket '${S3_BUCKET_NAME}' has a published firmware."

    FW_SIZE=$(wc -c < "${FW_TMP}" 2>/dev/null || echo "0")
    [ "${FW_SIZE}" -eq 0 ] && die "Downloaded firmware is empty (0 bytes)."
    ok "Downloaded: $(( FW_SIZE / 1024 / 1024 )) MB (${FW_SIZE} bytes)"
    FW_OWNED=1

    # ── Verify image integrity against the published checksum ────────────────
    # Detects corruption/truncation/tampering in transit before we flash. The
    # checksum is published by CI alongside the image; if absent (older build),
    # warn rather than block.
    SUMS_URL="https://${KAHF_HOST}/${S3_BUCKET_NAME}/firmware/sha256sums?cb=${_CB}"
    EXPECTED=""
    if command -v curl >/dev/null 2>&1; then
        EXPECTED=$(curl -fsSL --max-time 15 "${SUMS_URL}" 2>/dev/null | awk 'NR==1{print $1}')
    elif command -v wget >/dev/null 2>&1; then
        EXPECTED=$(wget -qO- "${SUMS_URL}" 2>/dev/null | awk 'NR==1{print $1}')
    fi
    if [ -n "${EXPECTED}" ]; then
        if command -v sha256sum >/dev/null 2>&1; then
            ACTUAL=$(sha256sum "${FW_TMP}" | awk '{print $1}')
        else
            ACTUAL=$(shasum -a 256 "${FW_TMP}" | awk '{print $1}')
        fi
        if [ "${ACTUAL}" = "${EXPECTED}" ]; then
            ok "Image checksum verified (sha256 matches published sha256sums)"
        else
            die "Image checksum MISMATCH — refusing to flash.\n  expected: ${EXPECTED}\n  actual:   ${ACTUAL}\nThe download may be corrupt or tampered. Retry, or check the CDN."
        fi
    else
        warn "No published sha256sums for bucket '${S3_BUCKET_NAME}' — skipping integrity check (older build?)."
    fi
fi

# ── Step 7: Upload firmware to router ────────────────────────────────────────
info "Uploading firmware to router..."
if ! _ssh_pipe_in "/tmp/firmware.bin" <"${FW_TMP}"; then
    die "Firmware upload failed — SSH connection may have dropped."
fi
ok "Firmware uploaded to /tmp/firmware.bin"

# ── Step 8: Validate image on router ─────────────────────────────────────────
info "Validating image on router..."
if ! _ssh "sysupgrade --test /tmp/firmware.bin" 2>&1; then
    die "sysupgrade --test failed — image may be incompatible with this router."
fi
ok "Image validation passed"

# ── Step 9: Confirm flash ────────────────────────────────────────────────────
echo ""
# WiFi SSID/PSK come from the factory-written Product MTD partition, read at
# first boot by 99-family-safe — NOT from a constant in this script. Read them
# off the router now so the warning states what this flash will actually
# produce. WHICH /dev/mtdN is "Product" is device-specific (PRODUCT_MTD, set
# by _device_for_model above) — skip the read entirely rather than guess a
# number nobody has verified on this device; see the device table's PRODUCT_MTD
# comment for why a wrong guess is worse than not checking. The partition
# itself is a u-boot env blob (NUL-separated key=value); convert NULs to
# newlines, never delete them, or only the first field matches. See
# docs/wifi-provisioning.md.
if [ -n "${PRODUCT_MTD}" ]; then
    _mtd_field() {
        _ssh "dd if=/dev/${PRODUCT_MTD} bs=1k count=128 2>/dev/null | tr '\\0' '\\n' | sed -n 's/^$1=//p' | head -1" 2>/dev/null || true
    }
    PROV_SSID="$(_mtd_field wifi_ssid)"
    PROV_PSK="$(_mtd_field wifi_psk)"
else
    PROV_SSID=""
    PROV_PSK=""
fi

warn "About to flash the router. This will:"
if [ "${DEVICE_CONFIG_WIPE_FORCED}" = "1" ]; then
    # Already acknowledged explicitly in Step 4b, but repeated here (worded
    # for what it IS rather than what this script chose) so the final review
    # before the irreversible sysupgrade call doesn't read as the same
    # optional "-n for a clean boot" line the WR3000K gets below.
    warn "  • Erase all current configuration — unavoidable on this device (see"
    warn "    the config-wipe notice above), not this script's -n choice"
else
    warn "  • Erase all current configuration (clean first boot)"
fi
if [ -n "${PROV_SSID}" ] && [ -n "${PROV_PSK}" ]; then
    warn "  • Re-apply factory WiFi from ${PRODUCT_MTD} — SSID: ${PROV_SSID}  PSK: ${PROV_PSK}"
elif [ -z "${PRODUCT_MTD}" ]; then
    warn "  • WiFi outcome unknown — this device's factory Product MTD partition"
    warn "    is not identified yet (see PRODUCT_MTD in _device_for_model), so"
    warn "    this script cannot predict whether factory WiFi will apply"
else
    warn "  • Reset WiFi to the fallback SSID: Kahf-Router (no wifi_ssid/wifi_psk in ${PRODUCT_MTD})"
fi
warn "  • Reboot the router (~30 seconds)"
warn ""
if [ "${AUTO_YES}" = "1" ] || [ ! -t 0 ]; then
    warn "Proceeding (non-interactive / --yes mode)..."
else
    ask "Proceed with flashing? [y/N]: "
    read -r CONFIRM
    case "${CONFIRM}" in
    y | Y | yes | YES) ;;
    *) die "Aborted." ;;
    esac
fi

# ── Step 10: Flash ────────────────────────────────────────────────────────────
info "Flashing firmware (router will reboot)..."
_ssh "sysupgrade -F -n /tmp/firmware.bin" 2>/dev/null || true # connection drops on reboot
# Brief pause to let sysupgrade begin writing before we start polling
sleep 3

# ── Step 11: Wait for reboot ─────────────────────────────────────────────────
echo ""
info "Waiting for router to go offline..."
TIMEOUT=30
i=0
while [ $i -lt $TIMEOUT ]; do
    if ! ping -c1 -W1 "${ROUTER_IP}" >/dev/null 2>&1; then
        ok "Router offline (${i}s)"
        break
    fi
    sleep 1
    i=$((i + 1))
done
if [ $i -ge $TIMEOUT ]; then
    warn "Router still responding after ${TIMEOUT}s — it may have rebooted very quickly"
fi

info "Waiting for router to come back online (up to 120s)..."
TIMEOUT=120
i=0
while [ $i -lt $TIMEOUT ]; do
    if ping -c1 -W1 "${ROUTER_IP}" >/dev/null 2>&1; then
        ok "Router online after ${i}s"
        break
    fi
    sleep 1
    i=$((i + 1))
done
[ $i -ge $TIMEOUT ] && die "Router did not come back online within ${TIMEOUT}s."

# Give SSH daemon a moment to start
sleep 5

info "Waiting for SSH to be ready..."
i=0
while [ $i -lt 30 ]; do
    if _ssh "echo ok" >/dev/null 2>&1; then
        ok "SSH ready"
        break
    fi
    sleep 2
    i=$((i + 2))
done
[ $i -ge 30 ] && die "SSH not ready after reboot."

# mDNS hostnames (.lan) can flicker during the router's avahi-daemon restart
# after a clean flash.  Resolve once to a numeric IP now while we know SSH
# works, so all subsequent _ssh polling calls never hit name resolution races.
case "${ROUTER_IP}" in
*.lan)
    _ip4=""
    # Try getent first (Linux); fall back to ping on macOS
    _ip4=$(getent hosts "${ROUTER_IP}" 2>/dev/null | awk '{print $1; exit}' || true)
    if [ -z "${_ip4}" ]; then
        _ip4=$(ping -c1 -W2 "${ROUTER_IP}" 2>/dev/null \
            | awk -F'[()]' '/^PING/{print $2; exit}' || true)
    fi
    if [ -n "${_ip4}" ]; then
        ok "Pinned ${ROUTER_IP} → ${_ip4} (avoids mDNS flicker during polling)"
        ROUTER_IP="${_ip4}"
    fi
    ;;
esac

# ── Step 12: Check internet, configure WAN if needed ─────────────────────────
echo ""
info "Checking internet connectivity..."
INET_OK=0
if _ssh "ping -c1 -W5 1.1.1.1 >/dev/null 2>&1"; then
    ok "Internet is reachable"
    INET_OK=1
fi

# DHCP boot-race recovery: after a clean flash the modem needs ~30-60s to
# re-establish upstream.  udhcpc's initial 3-discover burst misses this window
# and enters ~11-minute exponential backoff.  Kicking 'ifup wan' resets the
# DHCP state machine so it sends a fresh DISCOVER immediately.
if [ $INET_OK -eq 0 ]; then
    _wan_proto=$(_ssh "uci -q get network.wan.proto 2>/dev/null || echo unknown")
    if [ "${_wan_proto}" = "dhcp" ]; then
        info "DHCP WAN has no lease yet — kicking DHCP client and waiting up to 90s..."
        _ssh "ifup wan" >/dev/null 2>&1 || true
        _i=0
        while [ $_i -lt 90 ]; do
            sleep 5
            _i=$((_i + 5))
            if _ssh "ping -c1 -W3 1.1.1.1 >/dev/null 2>&1"; then
                ok "Internet is reachable (DHCP lease obtained after ${_i}s retry)"
                INET_OK=1
                break
            fi
            [ $((_i % 15)) -eq 0 ] && info "  Still waiting for DHCP lease... (${_i}s / 90s)"
        done
        [ $INET_OK -eq 0 ] && warn "DHCP lease not obtained within 90s after kick."
    fi
fi

if [ $INET_OK -eq 0 ]; then
    warn "No internet — WAN needs setup after clean flash."
    info "Current WAN proto: $(_ssh "uci -q get network.wan.proto 2>/dev/null || echo unset")"
    echo ""
    if [ "${AUTO_YES}" = "1" ] || [ ! -t 0 ]; then
        warn "Skipping WAN setup (non-interactive / --yes mode)."
        warn "Configure WAN via the Kahf app — family-safe installs automatically once internet is available."
        exit 0
    else
        ask "Configure PPPoE WAN? [Y/n]:"
        read -r SETUP_PPP
    fi
    case "${SETUP_PPP}" in
    n | N | no | NO)
        warn "Skipping WAN setup."
        echo ""
        ok "Flash complete. Configure WAN via the Kahf app — family-safe installs automatically once internet is available."
        exit 0
        ;;
    *)
        ask "PPPoE username:"
        read -r PPP_USER
        [ -z "${PPP_USER}" ] && die "PPPoE username required."
        ask "PPPoE password:"
        stty -echo 2>/dev/null || true
        read -r PPP_PASS
        stty echo 2>/dev/null || true
        echo ""
        [ -z "${PPP_PASS}" ] && die "PPPoE password required."

        info "Configuring PPPoE..."
        # Write a script to the router to avoid shell injection with special chars
        {
            echo "uci set network.wan.proto='pppoe'"
            printf "uci set network.wan.username='%s'\n" \
                "$(printf '%s' "${PPP_USER}" | sed "s/'/'\\\\''/g")"
            printf "uci set network.wan.password='%s'\n" \
                "$(printf '%s' "${PPP_PASS}" | sed "s/'/'\\\\''/g")"
            echo "uci set network.wan.peerdns='0'"
            echo "uci -q delete network.wan.dns"
            echo "uci add_list network.wan.dns='1.1.1.3'"
            echo "uci add_list network.wan.dns='1.0.0.3'"
            echo "uci commit network"
            echo "ifup wan"
        } | _ssh_pipe_in "/tmp/setup-wan.sh"
        _ssh "sh /tmp/setup-wan.sh; rm -f /tmp/setup-wan.sh"

        info "Waiting for PPPoE to connect (up to 30s)..."
        i=0
        while [ $i -lt 30 ]; do
            if _ssh "ping -c1 -W3 1.1.1.1 >/dev/null 2>&1"; then
                ok "Internet is reachable"
                INET_OK=1
                break
            fi
            sleep 2
            i=$((i + 2))
        done
        [ $INET_OK -eq 0 ] && warn "PPPoE still offline — bootstrap may fail without internet."
        ;;
    esac
fi

# ── Step 13: Stream bootstrap logs ────────────────────────────────────────────
echo ""
ok "Router is up. Streaming bootstrap logs (waiting for OAF install)..."
echo "────────────────────────────────────────────────────────────"

# Stream logs in background, writing to a temp fifo
LOG_FIFO="/tmp/kahf-bootstrap-log-$$"
mkfifo "${LOG_FIFO}" || die "Failed to create FIFO at ${LOG_FIFO}"
# Same reasoning as the trap above, and it matters more here: this one owns a
# backgrounded `ssh ... logread -f`, which never exits on its own. On EXIT-only
# the script being killed by `timeout` (or Ctrl-C) orphaned that ssh, and
# because the orphan still held the inherited stdout pipe, any caller piping
# this script's output — `flash.sh | grep | tail` — blocked forever waiting for
# an EOF that could not arrive. Measured once at 78 minutes past a flash that
# had actually succeeded in under five.
trap '[ "${FW_OWNED}" = "1" ] && rm -f "${FW_TMP}"; rm -f "${LOG_FIFO}"; kill "${LOG_BG_PID:-}" 2>/dev/null || true; wait "${LOG_BG_PID:-}" 2>/dev/null || true' EXIT INT TERM HUP

_ssh "logread -f 2>/dev/null" >"${LOG_FIFO}" &
LOG_BG_PID=$!

# Give logread a moment to establish the stream
sleep 1

# Read from fifo, print relevant lines, exit on completion event.
# Show all router syslog lines that are relevant to the bootstrap process
# so the user sees progress instead of a blank screen.
DONE=0
TIMEOUT=300 # 5 minutes max
START=$(date +%s)
LAST_STATUS=$(date +%s)
LINE_COUNT=0

while IFS= read -r line; do
    LINE_COUNT=$((LINE_COUNT + 1))
    case "${line}" in
    *oaf-bootstrap* | *oaf-update* | *family-safe* | *cron*)
        echo "  ${line}"
        case "${line}" in
        *event=cron_removed*)
            DONE=1
            break
            ;;
        esac
        LAST_STATUS=$(date +%s)
        ;;
    esac
    # Print a periodic status message so the user knows we're alive
    NOW=$(date +%s)
    ELAPSED=$((NOW - START))
    SILENCE=$((NOW - LAST_STATUS))
    if [ $SILENCE -ge 15 ]; then
        printf "  %s  Waiting for bootstrap cron tick... (%ds)\n" "$(date +%H:%M:%S)" "$ELAPSED"
        LAST_STATUS=$NOW
    fi
    [ $ELAPSED -gt $TIMEOUT ] && break
done <"${LOG_FIFO}"

kill "${LOG_BG_PID}" 2>/dev/null || true
wait "${LOG_BG_PID}" 2>/dev/null || true

echo "────────────────────────────────────────────────────────────"

if [ $DONE -eq 1 ]; then
    echo ""
    ok "Bootstrap complete! OAF and family-safe are installed."
    echo ""

    # ── Step 14: Post-bootstrap validation ────────────────────────────────
    FAIL=0
    UPDATE_OK=1
    DNSMASQ_OK=0
    GW_REACHABLE=0
    DNS_OK=0

    # 14a: Verify oaf-update.sh completed successfully.
    #
    # We already know it finished because cron_removed only fires after
    # oaf-bootstrap.sh's "sh ${TMP}" returns 0.  But verify by checking
    # syslog for the event=finish line — no process detection needed.
    info "Verifying oaf-update result..."
    _finish=$(_ssh "logread | grep 'oaf-update.*event=finish' | tail -1" 2>/dev/null || echo "")
    if [ -n "${_finish}" ]; then
        ok "oaf-update.sh completed successfully"
    else
        # event=finish not in syslog yet — unlikely but poll for it
        info "  Waiting for event=finish in syslog..."
        UPDATE_TIMEOUT=120
        _i=0
        while [ $_i -lt $UPDATE_TIMEOUT ]; do
            _finish=$(_ssh "logread | grep 'oaf-update.*event=finish' | tail -1" 2>/dev/null || echo "")
            if [ -n "${_finish}" ]; then
                ok "oaf-update.sh completed successfully (${_i}s)"
                break
            fi
            if [ $((_i % 15)) -eq 0 ]; then
                info "  oaf-update.sh still running... (${_i}s / ${UPDATE_TIMEOUT}s)"
            fi
            sleep 5
            _i=$((_i + 5))
        done
        if [ $_i -ge $UPDATE_TIMEOUT ]; then
            warn "oaf-update.sh event=finish not found in syslog within ${UPDATE_TIMEOUT}s"
            UPDATE_OK=0; FAIL=1
        fi
    fi

    # 14b: Wait for dnsmasq to be running and listening
    info "Checking dnsmasq..."
    DNS_TIMEOUT=60
    _i=0
    while [ $_i -lt $DNS_TIMEOUT ]; do
        _dnsmasq=$(_ssh 'pgrep dnsmasq >/dev/null 2>&1 && netstat -tuln 2>/dev/null | grep -q ":53 " && echo 1 || echo 0')
        if [ "${_dnsmasq}" = "1" ]; then
            ok "dnsmasq is running and listening on port 53"
            DNSMASQ_OK=1
            break
        fi
        if [ $((_i % 10)) -eq 0 ] && [ $_i -gt 0 ]; then
            info "  dnsmasq not ready yet... (${_i}s / ${DNS_TIMEOUT}s)"
        fi
        sleep 3
        _i=$((_i + 3))
    done
    if [ $_i -ge $DNS_TIMEOUT ]; then
        warn "dnsmasq not ready within ${DNS_TIMEOUT}s"
        FAIL=1
    fi

    # 14c: Test WAN gateway reachability
    info "Testing WAN gateway reachability..."
    _gw=$(_ssh 'ip route show default 2>/dev/null | awk "/default/{print \$3}"' 2>/dev/null || echo "")
    if [ -n "${_gw}" ]; then
        if _ssh "ping -c1 -W5 ${_gw} >/dev/null 2>&1"; then
            ok "WAN gateway reachable (${_gw})"
            GW_REACHABLE=1
        else
            warn "WAN gateway not reachable (${_gw})"
            FAIL=1
        fi
    else
        warn "No default gateway found"
        FAIL=1
    fi

    # 14d: Test DNS resolution
    info "Testing DNS resolution..."

    # Test via dnsmasq (router's own resolver)
    _dns_result=$(_ssh 'nslookup google.com 127.0.0.1 >/dev/null 2>&1 && echo ok || echo fail' 2>/dev/null || echo "fail")
    if [ "${_dns_result}" = "ok" ]; then
        ok "DNS resolution working (via dnsmasq)"
        DNS_OK=1
    else
        warn "DNS resolution failed (via dnsmasq)"
        FAIL=1
    fi

    # Also test direct upstream DNS if gateway is reachable
    if [ $GW_REACHABLE -eq 1 ] && [ $DNS_OK -eq 0 ]; then
        info "  Testing direct upstream DNS..."
        _dns_direct=$(_ssh 'nslookup google.com 1.1.1.3 >/dev/null 2>&1 && echo ok || echo fail' 2>/dev/null || echo "fail")
        if [ "${_dns_direct}" = "ok" ]; then
            ok "Direct upstream DNS works (1.1.1.3) — dnsmasq may need a moment"
            DNS_OK=1
        else
            warn "Direct upstream DNS also failed"
        fi
    fi

    # 14e: Report which DNS transport the router selected (DoT/DoH/plaintext).
    # This is set by apply_encrypted_upstream after its latency-gated probing.
    info "Checking DNS upstream protocol..."
    DNS_MODE=$(_ssh 'uci -q get family-safe.resolver.upstream_mode 2>/dev/null' 2>/dev/null || echo "")
    DNS_LAT=$(_ssh 'uci -q get family-safe.resolver.upstream_latency_ms 2>/dev/null' 2>/dev/null || echo "")
    case "${DNS_MODE}" in
        dot)
            ok "DNS protocol: DoT (stubby)${DNS_LAT:+ — avg ${DNS_LAT}ms}" ;;
        doh)
            ok "DNS protocol: DoH (https-dns-proxy)${DNS_LAT:+ — avg ${DNS_LAT}ms}" ;;
        plaintext)
            # DNS still works (filtered family resolvers) — acceptable fallback,
            # not a failure. The on-router guard retries encrypted every 15 min.
            warn "DNS protocol: plaintext (filtered family DNS) — encrypted DoT/DoH unavailable; guard will retry" ;;
        *)
            warn "DNS protocol: unknown (family-safe.resolver.upstream_mode unset) — package may still be settling" ;;
    esac

    # 14f: Turn on both Wi-Fi radios. A radio can look fine in UCI
    # (disabled=0, ubus network.wireless status up=true) while hostapd
    # silently failed to bring it up under the current regdomain — the only
    # tell is iwinfo reporting "Channel: 0 (unknown GHz)" (field-confirmed on
    # a real WR3000K, see CLAUDE.md's regdomain/channel notes). So this both
    # flips any disabled=1 radio on AND verifies every present radio is
    # actually broadcasting, rather than trusting UCI/ubus alone.
    #
    # "2 radios, 2.4GHz + 5GHz" below is a UCI/mac80211-level expectation (two
    # wifi-device sections, two live bands) that is device-agnostic on
    # purpose — see lab/test.sh's matching Wi-Fi Radios section for why the
    # WR1205K's single MT7615 DBDC chip is expected to reach the same shape
    # via mt76's primary+ext-phy split, and why that is unconfirmed on real
    # WR1205K hardware rather than assumed.
    info "Checking Wi-Fi radios..."
    RADIO_OK=0
    _radio_enable="$(_ssh_script <<'REMOTE'
changed=0
for r in $(uci show wireless 2>/dev/null | sed -n 's/^wireless\.\([A-Za-z0-9_]\{1,\}\)=wifi-device$/\1/p'); do
    [ "${r#radio}" = "$r" ] && continue
    d=$(uci -q get "wireless.$r.disabled" 2>/dev/null)
    if [ "$d" = "1" ]; then
        uci set "wireless.$r.disabled=0"
        changed=1
    fi
done
if [ "$changed" = "1" ]; then
    uci commit wireless
    wifi reload >/dev/null 2>&1 || wifi up >/dev/null 2>&1 || true
fi
echo "CHANGED=$changed"
REMOTE
)" || true
    case "${_radio_enable}" in
        *CHANGED=1*) info "  A disabled radio was re-enabled — waiting for it to come up..." ;;
    esac

    # Bounded poll (not a blind sleep): `wifi reload` returns once netifd has
    # accepted the config, but the actual radio bring-up and regulatory hint
    # land asynchronously after that (same reasoning as the country-change
    # wait in lib-family-safe.sh).
    _radio_report=""
    _i=0
    while [ ${_i} -lt 20 ]; do
        _radio_report="$(_ssh_script <<'REMOTE'
n=0
dis=""
for r in $(uci show wireless 2>/dev/null | sed -n 's/^wireless\.\([A-Za-z0-9_]\{1,\}\)=wifi-device$/\1/p'); do
    [ "${r#radio}" = "$r" ] && continue
    n=$((n + 1))
    d=$(uci -q get "wireless.$r.disabled" 2>/dev/null)
    if [ "$d" = "1" ]; then
        dis="$dis $r"
    fi
done
echo "RADIO_COUNT=$n"
echo "DISABLED_RADIOS=${dis:-none}"
iwinfo 2>/dev/null | awk '
/Channel:/ {
    for (i = 1; i <= NF; i++) {
        if ($i == "Channel:") {
            ch = $(i + 1)
            ghz = $(i + 2)
            gsub(/[()]/, "", ghz)
            print "CHANNEL=" ch " GHZ=" ghz
        }
    }
}'
REMOTE
)" || true
        _channels=$(echo "${_radio_report}" | grep '^CHANNEL=' || true)
        _dead_now=$(echo "${_channels}" | grep -c '^CHANNEL=0 ' || true)
        _live_now=$(echo "${_channels}" | grep -vc '^CHANNEL=0 ' || true)
        [ "${_live_now}" -gt 0 ] && [ "${_dead_now}" = "0" ] && break
        sleep 3
        _i=$((_i + 3))
    done

    RADIO_COUNT=$(echo "${_radio_report}" | sed -n 's/^RADIO_COUNT=//p')
    DISABLED_RADIOS=$(echo "${_radio_report}" | sed -n 's/^DISABLED_RADIOS=//p')
    _channels=$(echo "${_radio_report}" | grep '^CHANNEL=' || true)
    [ -z "${RADIO_COUNT}" ] && RADIO_COUNT=0
    [ -z "${DISABLED_RADIOS}" ] && DISABLED_RADIOS="none"

    if [ "${RADIO_COUNT}" -lt 2 ]; then
        warn "Only ${RADIO_COUNT} Wi-Fi radio(s) found in UCI — expected 2 (2.4GHz + 5GHz)"
    elif [ "${DISABLED_RADIOS}" != "none" ]; then
        warn "Radio(s) still disabled after enable attempt:${DISABLED_RADIOS}"
    else
        _has_dead=$(echo "${_channels}" | grep -c '^CHANNEL=0 ' || true)
        _has_5g=$(echo "${_channels}" | grep -c 'GHZ=5\.' || true)
        if [ "${_has_dead}" -gt 0 ]; then
            warn "A radio is enabled but not broadcasting (Channel: 0) — check regdomain/channel legality"
        elif [ "${_has_5g}" = "0" ]; then
            warn "No 5GHz radio detected among live channels"
        else
            ok "Both Wi-Fi radios are on"
            RADIO_OK=1
        fi
        echo "${_channels}" | while IFS= read -r _cl; do [ -n "${_cl}" ] && info "    ${_cl}"; done
    fi
    [ "${RADIO_OK}" = "0" ] && FAIL=1

    # ── Validation summary ──────────────────────────────────────────────
    echo ""
    echo "════════════════════════════════════════════════════════════"
    if [ $FAIL -eq 0 ]; then
        ok "All checks passed — router is fully operational"
    else
        warn "Some checks failed — router may need manual attention"
    fi
    printf "  oaf-update.sh : %s\n" "$([ $UPDATE_OK -eq 0 ] && printf "${_Y}TIMEOUT${_N}" || printf "${_G}OK${_N}")"
    printf "  dnsmasq        : %s\n" "$([ $DNSMASQ_OK -eq 0 ] && printf "${_Y}FAIL${_N}" || printf "${_G}OK${_N}")"
    printf "  WAN gateway    : %s\n" "$([ $GW_REACHABLE -eq 0 ] && printf "${_Y}FAIL${_N}" || printf "${_G}OK${_N}")"
    printf "  DNS resolution : %s\n" "$([ $DNS_OK -eq 0 ] && printf "${_Y}FAIL${_N}" || printf "${_G}OK${_N}")"
    printf "  Wi-Fi radios   : %s\n" "$([ "${RADIO_OK}" = "0" ] && printf "${_Y}FAIL${_N}" || printf "${_G}OK${_N}")"
    case "${DNS_MODE}" in
        dot|doh)   printf "  DNS protocol   : ${_G}%s${_N}%s\n" "${DNS_MODE}" "${DNS_LAT:+ (${DNS_LAT}ms)}" ;;
        plaintext) printf "  DNS protocol   : ${_Y}plaintext${_N} (encrypted unavailable)\n" ;;
        *)         printf "  DNS protocol   : ${_Y}unknown${_N}\n" ;;
    esac
    echo "════════════════════════════════════════════════════════════"
    echo ""

    # ── Router summary ────────────────────────────────────────────────
    info "Router summary:"
    # Use individual _ssh calls (not heredoc) since _ssh uses -n (no stdin)
    printf "  Model   : %s\n" "$(_ssh 'cat /tmp/sysinfo/model 2>/dev/null')"
    printf "  Firmware: %s\n" "$(_ssh 'grep DISTRIB_DESCRIPTION /etc/openwrt_release | cut -d= -f2 | tr -d "\\x27"')"
    _FS_VER="$(_ssh "apk info family-safe 2>/dev/null | head -1 | sed 's/^family-safe-//' | cut -d' ' -f1" 2>/dev/null || true)"
    [ -n "${_FS_VER}" ] || _FS_VER="$(_ssh "opkg list-installed 2>/dev/null | awk '/^family-safe /{print \$3}'" 2>/dev/null || true)"
    printf "  FamilySafe: %s\n" "${_FS_VER:-not installed}"
    _OAF_VER="$(_ssh "apk info appfilter 2>/dev/null | head -1 | sed 's/^appfilter-//' | cut -d' ' -f1" 2>/dev/null || true)"
    [ -n "${_OAF_VER}" ] || _OAF_VER="$(_ssh "opkg list-installed 2>/dev/null | awk '/^appfilter /{print \$3}'" 2>/dev/null || true)"
    printf "  OAF     : %s\n" "${_OAF_VER:-not installed}"
    printf "  DNS     : %s\n" "${DNS_MODE:-unknown}${DNS_LAT:+ (${DNS_LAT}ms avg)}"
    # Report the credentials the router ACTUALLY came up with, plus whether they
    # came from factory mtd6 provisioning or the fallback — printing a hardcoded
    # SSID here would be wrong on every provisioned unit.
    _LIVE_SSID="$(_ssh 'uci get wireless.default_radio0.ssid 2>/dev/null')"
    _LIVE_PSK="$(_ssh 'uci get wireless.default_radio0.key 2>/dev/null')"
    _LIVE_SRC="$(_ssh 'sed -n "s/^source=//p" /etc/kahf-credentials.txt 2>/dev/null')"
    printf "  WiFi    : %s / %s (%s)\n" \
        "${_LIVE_SSID:-unknown}" \
        "$(_ssh 'uci get wireless.default_radio0.encryption 2>/dev/null')" \
        "${_LIVE_SRC:-source unknown}"
    printf "  WiFi PSK: %s\n" "${_LIVE_PSK:-unknown}"
    echo ""
    if [ "${_LIVE_SRC}" = "fallback" ]; then
        # This reads /etc/kahf-credentials.txt, written by the router's OWN
        # first-boot script (99-family-safe) using whichever MTD node IT
        # knows is "Product" for its own hardware — not PRODUCT_MTD above,
        # which is this script's (possibly unverified) guess. Don't name a
        # specific partition here; it would be wrong on a device whose
        # on-router partition differs from what this script assumes.
        warn "WiFi came up on the FALLBACK shared credentials — the factory"
        warn "Product partition had no valid wifi_ssid/wifi_psk. On a production"
        warn "unit that is a provisioning defect (see docs/wifi-provisioning.md),"
        warn "not a normal outcome."
    fi
    ok "Flash complete. Connect to WiFi: ${_LIVE_SSID:-<unknown>} / ${_LIVE_PSK:-<unknown>}"
else
    warn "Bootstrap did not complete within ${TIMEOUT}s."
    warn "The OAF install may still be running on the router."
    warn "Check logs on the router:"
    warn "  ssh root@${ROUTER_IP} \"logread | grep oaf-bootstrap\""
    exit 1
fi
