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

root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=tools/lib/platformio.sh
source "$root/tools/lib/platformio.sh"
# shellcheck source=tools/lib/portal-hardware-session.sh
source "$root/tools/lib/portal-hardware-session.sh"
# shellcheck source=tools/lib/ota-fixture-identity.sh
source "$root/tools/lib/ota-fixture-identity.sh"

# Portal tests exclusively own their portal network, selected Wi-Fi adapter,
# and named serial port. Avoid consuming every host core unless the developer
# explicitly opts in.
export PLATFORMIO_RUN_JOBS="${PLATFORMIO_RUN_JOBS:-2}"

usage() {
    cat <<'USAGE' >&2
Usage:
  ./tools/portal-hardware doctor --client-interface IFACE [--take-over-client-adapter]
  ./tools/portal-hardware up --platform esp8266|esp32 --port /dev/serial/by-id/... \
      --client-interface IFACE [--take-over-client-adapter] [--output DIRECTORY]
  ./tools/portal-hardware run --platform esp8266|esp32 --port /dev/serial/by-id/... \
      --client-interface IFACE [--take-over-client-adapter] [--keep] \
      [--browser auto|skip] [--station-env PATH] [--output DIRECTORY] \
      [--capture-readme-media] [--custom-parameter-stress]
  ./tools/portal-hardware ota --platform esp8266|esp32 --port /dev/serial/by-id/... \
      --client-interface IFACE [--take-over-client-adapter] [--keep] \
      [--output DIRECTORY]
  ./tools/portal-hardware down

Only the named client interface may be disconnected or reconfigured. The tool
refuses the host default-route interface and preserves the created connection
in a 0600 state file until `down` or normal `run`/`ota` cleanup.

`ota` is an opt-in physical A/B test. It erases the explicitly selected test
board's flash, serial-flashes image A, then uses the real portal browser form
to upload image B. It requires the automatic restart and two independent
B-marker responses; a manual reset never makes the test pass. On success the
selected board remains on B in the portal-only fixture.
USAGE
    exit 2
}

command_name="${1:-}"
[[ -n "$command_name" ]] || usage
shift || true

platform=""
port=""
client_interface=""
takeover="no"
keep="no"
browser="auto"
station_env=""
output_dir=""
capture_readme_media="no"
custom_parameter_stress="no"
ota_environment_name=""
ota_firmware_a=""
ota_firmware_b=""
ota_browser_prebuilt="no"
ota_serial_capture_pid=""
ota_serial_capture_log=""
ota_serial_capture_ready=""
ota_serial_capture_status=""
station_handoff_env_file=""
station_handoff_fixture_restore_needed="no"
# `wm_create_portal_connection` sets these before NetworkManager mutates the
# secondary adapter.  Keeping the ownership record in-process means the
# signal trap can clean up the exact pending or active connection immediately.
WM_PORTAL_CONNECTION_UUID=""
WM_PORTAL_CONNECTION_NAME=""
WM_PORTAL_CONNECTION_OWNED="no"
while [[ $# -gt 0 ]]; do
    case "$1" in
        --platform) [[ $# -ge 2 ]] || usage; platform="$2"; shift 2 ;;
        --port) [[ $# -ge 2 ]] || usage; port="$2"; shift 2 ;;
        --client-interface) [[ $# -ge 2 ]] || usage; client_interface="$2"; shift 2 ;;
        --take-over-client-adapter) takeover="yes"; shift ;;
        --keep) keep="yes"; shift ;;
        --browser) [[ $# -ge 2 ]] || usage; browser="$2"; shift 2 ;;
        --station-env) [[ $# -ge 2 ]] || usage; station_env="$2"; shift 2 ;;
        --output) [[ $# -ge 2 ]] || usage; output_dir="$2"; shift 2 ;;
        --capture-readme-media) capture_readme_media="yes"; shift ;;
        --custom-parameter-stress) custom_parameter_stress="yes"; shift ;;
        *) usage ;;
    esac
done

require_common() {
    wm_require ip
    wm_require nmcli
    wm_pio_available
    wm_require docker
    wm_require curl
    docker compose version >/dev/null
}

require_ota_serial_capture() {
    wm_require python3
    python3 -c 'import serial' >/dev/null 2>&1 || {
        echo "Portal OTA serial evidence requires Python pyserial (for example, python3-serial)." >&2
        return 1
    }
}

pio_for_portal_environment() {
    local environment="$1"
    shift

    case "$environment" in
        # Keep the maintained Core 3.3.11 graph in the same persistent cache
        # as the ESP32 A/B fixture. It is never cleared by this test harness.
        esp32|esp32_ota)
            ;;
        *)
            wm_pio "$@"
            return
            ;;
    esac

    local core_dir packages_dir cache_dir
    core_dir="${WIFIMANAGER_PLATFORMIO_CORE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/arduino-framework-platformio/core-3.3.11}"
    packages_dir="${WIFIMANAGER_PLATFORMIO_PACKAGES_DIR:-$core_dir/packages}"
    cache_dir="${WIFIMANAGER_PLATFORMIO_CACHE_DIR:-$core_dir/cache}"
    install -d -m 700 "$core_dir" "$packages_dir" "$cache_dir"
    PLATFORMIO_CORE_DIR="$core_dir" PLATFORMIO_PACKAGES_DIR="$packages_dir" \
        PLATFORMIO_CACHE_DIR="$cache_dir" wm_pio "$@"
}

prepare_output_dir() {
    if [[ -z "$output_dir" ]]; then
        if [[ "$capture_readme_media" == "yes" ]]; then
            output_dir="$root/artifacts/readme-media/$(date -u +%Y%m%dT%H%M%SZ)-$platform"
        else
            output_dir="$(wm_portal_state_root)/runs/$(date -u +%Y%m%dT%H%M%SZ)-$platform"
        fi
    fi
    install -d -m 700 "$output_dir"
    output_dir="$(cd "$output_dir" && pwd)"
}

prepare_ota_identity_input() {
    # The generated A/B marker belongs to this run, never to shared fixture
    # source. Keeping it with the private artifacts prevents cross-run drift.
    export WIFIMANAGER_OTA_IDENTITY_DIR="$output_dir/ota-fixture-input"
    install -d -m 700 "$WIFIMANAGER_OTA_IDENTITY_DIR"
}

validate_run_arguments() {
    [[ "$platform" == "esp8266" || "$platform" == "esp32" ]] || usage
    [[ -n "$client_interface" ]] || usage
    [[ -n "$port" && -e "$port" ]] || {
        echo "Serial port not found: $port" >&2
        exit 1
    }
    [[ "$browser" == "auto" || "$browser" == "skip" ]] || usage
    [[ -z "$station_env" || -r "$station_env" ]] || {
        echo "Station environment file is not readable: $station_env" >&2
        exit 1
    }
    if [[ -n "$station_env" ]]; then
        [[ "$command_name" == "run" ]] || {
            echo "--station-env is supported only by the browser-backed run command." >&2
            exit 2
        }
        [[ "$browser" == "auto" ]] || {
            echo "--station-env requires --browser auto so the real hand-off is exercised." >&2
            exit 2
        }
        [[ "$capture_readme_media" == "no" ]] || {
            echo "--station-env cannot be combined with README media capture." >&2
            exit 2
        }
    fi
    if [[ "$capture_readme_media" == "yes" ]]; then
        [[ "$command_name" == "run" && "$platform" == "esp32" ]] || {
            echo "--capture-readme-media is supported only by run --platform esp32" >&2
            exit 2
        }
        [[ "$browser" == "auto" ]] || {
            echo "--capture-readme-media requires --browser auto" >&2
            exit 2
        }
    fi
    if [[ "$custom_parameter_stress" == "yes" ]]; then
        [[ "$command_name" == "run" && "$platform" == "esp8266" ]] || {
            echo "--custom-parameter-stress is supported only by run --platform esp8266" >&2
            exit 2
        }
        [[ "$browser" == "auto" ]] || {
            echo "--custom-parameter-stress requires --browser auto" >&2
            exit 2
        }
    fi
    if [[ "$command_name" == "ota" ]]; then
        [[ "$browser" == "auto" ]] || {
            echo "OTA coverage always uses the browser; --browser skip is not supported." >&2
            exit 2
        }
        [[ -z "$station_env" ]] || {
            echo "OTA coverage is portal-only and does not accept --station-env." >&2
            exit 2
        }
        [[ "$capture_readme_media" == "no" && "$custom_parameter_stress" == "no" ]] || {
            echo "OTA coverage cannot be combined with README media or parameter-stress modes." >&2
            exit 2
        }
    fi
}

read_station_handoff_value() {
    local requested_key="$1" line value="" found=no
    while IFS= read -r line || [[ -n "$line" ]]; do
        # Accept the documented KEY=VALUE file format without evaluating it as
        # shell code. A value may contain '=' but not a second declaration of
        # the same key, which would make the generated minimal file ambiguous.
        line="${line%$'\r'}"
        case "$line" in
            "$requested_key"=*)
                [[ "$found" == no ]] || {
                    echo "Station environment defines $requested_key more than once." >&2
                    return 1
                }
                value="${line#*=}"
                found=yes
                ;;
        esac
    done <"$station_env"
    [[ "$found" == yes && -n "$value" ]] || {
        echo "Station environment must define a non-empty $requested_key value." >&2
        return 1
    }
    printf '%s' "$value"
}

prepare_station_handoff_env() {
    local ssid password
    ssid="$(read_station_handoff_value WIFI_SSID)" || return 1
    password="$(read_station_handoff_value WIFI_PASSWORD)" || return 1
    station_handoff_env_file="$(mktemp "$output_dir/.portal-station.XXXXXX")" || {
        echo 'Could not create the private station-handoff environment file.' >&2
        return 1
    }
    if ! {
        printf 'WIFI_SSID=%s\n' "$ssid"
        printf 'WIFI_PASSWORD=%s\n' "$password"
    } >"$station_handoff_env_file"; then
        rm -f -- "$station_handoff_env_file"
        station_handoff_env_file=""
        echo 'Could not write the private station-handoff environment file.' >&2
        return 1
    fi
    if ! chmod 600 "$station_handoff_env_file"; then
        rm -f -- "$station_handoff_env_file"
        station_handoff_env_file=""
        echo 'Could not protect the private station-handoff environment file.' >&2
        return 1
    fi
    export PORTAL_STATION_ENV_HOST="$station_handoff_env_file"
}

remove_station_handoff_env() {
    [[ -n "$station_handoff_env_file" ]] || return 0
    if ! rm -f -- "$station_handoff_env_file"; then
        echo 'Could not remove the private station-handoff environment file.' >&2
        return 1
    fi
    station_handoff_env_file=""
    unset PORTAL_STATION_ENV_HOST
}

restore_station_handoff_fixture() {
    local ssid
    [[ "$station_handoff_fixture_restore_needed" == yes ]] || return 0
    # The optional browser hand-off deliberately persists its supplied station
    # credentials long enough to exercise WiFiManager's real connect path.
    # Reflash the same portal-only fixture afterwards: its setup() calls
    # resetSettings(), so the selected test board returns to a no-station
    # state without retaining a developer's Wi-Fi credentials. This is normal
    # test cleanup, not OTA evidence; the OTA command never accepts a station
    # environment and never calls this function.
    printf 'Restoring the clean portal-only fixture after station hand-off.\n'
    if ! pio_for_portal_environment "$platform" run -d "$root/test/portal-harness" -e "$platform" \
        -t upload --upload-port "$port"; then
        echo 'Could not restore the clean portal-only fixture after station hand-off.' >&2
        return 1
    fi
    # The board deliberately disappears during the cleanup reflash. Require
    # its fixture AP to return and be routed through the owned secondary
    # adapter before declaring the developer-supplied credentials scrubbed.
    ssid="$(wm_portal_ssid "$platform")" || return 1
    if ! wm_wait_for_portal_ssid "$client_interface" "$ssid" || \
        ! wm_verify_portal_route "$client_interface" || \
        ! wait_for_portal_scan_ready; then
        echo 'The clean portal-only fixture did not return after station hand-off cleanup.' >&2
        return 1
    fi
    station_handoff_fixture_restore_needed="no"
}

write_readme_media_manifest() {
    local media_dir="$output_dir/readme-media"
    local required
    for required in portal-tour.gif portal-overview.png portal-wifi-settings.png; do
        [[ -s "$media_dir/$required" ]] || {
            echo "README media output is incomplete: $media_dir/$required" >&2
            return 1
        }
    done
    printf '{"schema":1,"project":"WiFiManager","kind":"readme-media","status":"passed","platform":"esp32","created_at":"%s"}\n' \
        "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$media_dir/manifest.json"
    chmod 600 "$media_dir/manifest.json"
}

portal_scan_status() {
    curl --interface "$client_interface" --connect-timeout 3 --max-time 5 \
        --silent --show-error --fail http://192.168.4.1/api/wifi/scan-status 2>/dev/null
}

wait_for_portal_http_ready() {
    # The AP can be visible before the portal server has completed startup.
    # OTA requires the update UI and its HTTP route; it does not require an
    # unrelated background scan to have succeeded.
    local attempt response
    for attempt in $(seq 1 45); do
        response="$(portal_scan_status || true)"
        if [[ "$response" == *'"state"'* ]]; then
            return 0
        fi
        sleep 1
    done
    echo 'Portal HTTP API did not become ready within 45 seconds.' >&2
    return 1
}

wait_for_portal_scan_ready() {
    # The normal portal UI suite exercises the automatic scan and still
    # requires a usable result. Keep that coverage separate from HTTP OTA.
    local attempt response
    for attempt in $(seq 1 45); do
        response="$(portal_scan_status || true)"
        if [[ "$response" == *'"state":"complete"'* && "$response" == *'"results_valid":true'* ]]; then
            return 0
        fi
        if [[ "$response" == *'"state":"failed"'* || "$response" == *'"state":"timeout"'* ]]; then
            echo "Portal background scan did not become ready: $response" >&2
            return 1
        fi
        sleep 1
    done
    echo 'Portal did not complete its initial Wi-Fi scan within 45 seconds.' >&2
    return 1
}

report_initial_portal_scan_status() {
    local response
    response="$(portal_scan_status || true)"
    if [[ -n "$response" ]]; then
        printf 'Initial portal scan status: %s\n' "$response"
    else
        echo 'Initial portal scan status was unavailable after portal startup.' >&2
    fi
}

start_portal_session() {
    local environment="${1:-$platform}" erase_before_upload="${2:-no}" expected_a_artifact="${3:-}" capture_ota_serial="${4:-no}" require_scan="${5:-yes}" ssid reconnect_after_drop="no"
    validate_run_arguments
    require_common
    wm_acquire_hardware_lock
    wm_require_no_active_session
    # Choose the real NetworkManager authorization path before inspecting the
    # secondary adapter. A headless shell must not mistake a denied inspection
    # for an idle adapter and then replace a connection it does not own.
    wm_prepare_networkmanager_authorization
    wm_require_client_adapter "$client_interface" "$takeover"
    wm_harness_lock_serial_port "$port"
    prepare_output_dir
    ssid="$(wm_portal_ssid "$platform")"

    if [[ -n "$expected_a_artifact" ]]; then
        # B was built most recently. Restore the generated A identity before
        # PlatformIO verifies and serial-flashes the immutable A artifact.
        wm_write_ota_fixture_identity A
        pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment"
        ota_assert_a_artifact_matches_build "$expected_a_artifact"
    fi
    if [[ "$erase_before_upload" == "yes" ]]; then
        # OTA selection metadata must not survive from a previous fixture run:
        # otherwise a bootloader could select a stale app slot instead of A.
        pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment" -t erase --upload-port "$port"
    fi
    if [[ -n "$expected_a_artifact" ]]; then
        ota_assert_a_artifact_matches_build "$expected_a_artifact"
    fi
    pio_for_portal_environment "$environment" run -d "$root/test/portal-harness" -e "$environment" -t upload --upload-port "$port"
    if [[ "$capture_ota_serial" == yes ]]; then
        # Attach only after PlatformIO releases the serial port. Capture the
        # complete A boot, including portal start and DHCP, before the adapter
        # is asked to associate with the fixture AP.
        start_ota_serial_capture
    fi
    if [[ -n "$expected_a_artifact" ]]; then
        ota_assert_a_artifact_matches_build "$expected_a_artifact"
    fi
    # OTA and the optional station hand-off both deliberately reboot the test
    # board. Let only those owned disposable profiles reassociate after that
    # outage; ordinary portal work keeps the secondary adapter inert after its
    # current run.
    if [[ "$capture_ota_serial" == yes || -n "$station_env" ]]; then
        reconnect_after_drop=yes
    fi
    if ! wm_create_portal_connection "$client_interface" "$ssid" "default1" "$platform" "$reconnect_after_drop"; then
        return 1
    fi
    if ! wm_verify_portal_route "$client_interface"; then
        wm_cleanup_created_connection
        return 1
    fi
    if [[ "$require_scan" == yes ]]; then
        wait_for_portal_scan_ready || {
            wm_cleanup_created_connection
            return 1
        }
    elif ! wait_for_portal_http_ready; then
        wm_cleanup_created_connection
        return 1
    fi
    [[ "$require_scan" == yes ]] || report_initial_portal_scan_status
    printf 'Portal connected on %s. Artifacts: %s\n' "$client_interface" "$output_dir"
}

ota_environment() {
    printf '%s_ota\n' "$platform"
}

ota_marker_field() {
    local response="$1" field="$2"
    case "$field" in
        marker|freeSketchSpace)
            # The marker endpoint is deliberately fixture-only JSON. Parse it
            # as JSON instead of maintaining a second, fragile copy of its
            # wire format in a regular expression.
            python3 -c '
import json
import sys

try:
    value = json.load(sys.stdin)[sys.argv[1]]
except (json.JSONDecodeError, KeyError, TypeError):
    raise SystemExit(1)

if isinstance(value, bool) or not isinstance(value, (str, int)):
    raise SystemExit(1)
print(value)
' "$field" <<<"$response" 2>/dev/null || true
            ;;
        *)
            echo "Unknown OTA marker field: $field" >&2
            return 2
            ;;
    esac
}

ota_fetch_marker() {
    curl --interface "$client_interface" --connect-timeout 1 --max-time 2 \
        --silent --show-error http://192.168.4.1/api/test/firmware-marker 2>/dev/null
}

wait_for_ota_marker() {
    local expected="$1" deadline response marker
    deadline=$((SECONDS + 75))
    while (( SECONDS < deadline )); do
        response="$(ota_fetch_marker || true)"
        marker="$(ota_marker_field "$response" marker)"
        if [[ "$marker" == "$expected" ]]; then
            printf '%s\n' "$response"
            return 0
        fi
        sleep 0.5
    done
    echo "Portal OTA fixture did not report marker '$expected' within 75 seconds." >&2
    return 1
}

ota_esp32_partition_row() {
    local partition="$1" table="$root/test/portal-harness/partitions/esp32_ota_4m_no_fs.csv"
    [[ -r "$table" ]] || {
        echo "ESP32 OTA partition table is unreadable: $table" >&2
        return 1
    }
    awk -F, -v partition="$partition" '
        function trim(value) {
            gsub(/^[[:space:]]+|[[:space:]]+$/, "", value)
            return value
        }
        /^[[:space:]]*#/ || NF < 5 { next }
        trim($1) == partition {
            printf "%s\t%s\t%s\t%s\n", trim($2), trim($3), trim($4), trim($5)
            exit
        }
    ' "$table"
}

ota_esp32_require_partition() {
    local name="$1" expected_type="$2" expected_subtype="$3" expected_offset="$4" expected_size="$5"
    local row type subtype offset size
    row="$(ota_esp32_partition_row "$name")"
    IFS=$'\t' read -r type subtype offset size <<<"$row"
    [[ "$type" == "$expected_type" && "$subtype" == "$expected_subtype" && \
       "$offset" == "$expected_offset" && "$size" == "$expected_size" ]] || {
        echo "ESP32 OTA partition '$name' must be $expected_type/$expected_subtype at $expected_offset with size $expected_size; found '${row:-missing}'." >&2
        return 1
    }
}

ota_esp32_slot_size() {
    local table="$root/test/portal-harness/partitions/esp32_ota_4m_no_fs.csv"
    local value_a value_b
    [[ -r "$table" ]] || {
        echo "ESP32 OTA partition table is unreadable: $table" >&2
        return 1
    }
    ota_esp32_require_partition nvs data nvs 0x9000 0x5000
    ota_esp32_require_partition otadata data ota 0xe000 0x2000
    ota_esp32_require_partition app0 app ota_0 0x10000 0x1F0000
    ota_esp32_require_partition app1 app ota_1 0x200000 0x1F0000
    value_a="$(ota_esp32_partition_row app0 | awk -F '\t' '{ print $4 }')"
    value_b="$(ota_esp32_partition_row app1 | awk -F '\t' '{ print $4 }')"
    [[ "$value_a" =~ ^0x[0-9A-Fa-f]+$ && "$value_a" == "$value_b" ]] || {
        echo "ESP32 OTA app slots must have equal hexadecimal capacities: app0=$value_a app1=$value_b" >&2
        return 1
    }
    printf '%d\n' "$((value_b))"
}

ota_assert_firmware_fits() {
    local firmware="$1" capacity="$2" label="$3" image_size
    [[ -n "$firmware" && -s "$firmware" ]] || {
        echo "OTA $label firmware artifact is missing." >&2
        return 1
    }
    [[ "$capacity" =~ ^[0-9]+$ && "$capacity" -gt 0 ]] || {
        echo "OTA capacity is invalid: $capacity" >&2
        return 1
    }
    image_size="$(wc -c < "$firmware")"
    if (( image_size > capacity )); then
        echo "OTA $label image is too large: ${image_size} bytes exceeds ${capacity} bytes." >&2
        return 1
    fi
    printf 'OTA %s image fits the available update space: %s <= %s bytes\n' "$label" "$image_size" "$capacity"
}

build_ota_image() {
    local image="$1" source artifact
    case "$image" in
        A)
            artifact="$output_dir/${platform}-portal-ota-a.bin"
            ota_firmware_a="$artifact"
            ;;
        B)
            artifact="$output_dir/${platform}-portal-ota-b.bin"
            ota_firmware_b="$artifact"
            ;;
        *)
            echo "Unknown portal OTA fixture image: $image" >&2
            return 2
            ;;
    esac
    wm_write_ota_fixture_identity "$image"
    pio_for_portal_environment "$ota_environment_name" run -d "$root/test/portal-harness" -e "$ota_environment_name"
    source="$root/test/portal-harness/.pio/build/$ota_environment_name/firmware.bin"
    [[ -s "$source" ]] || {
        echo "PlatformIO did not produce portal OTA image $image." >&2
        return 1
    }
    install -m 600 "$source" "$artifact"
}

prepare_ota_firmware() {
    local capacity
    ota_environment_name="$(ota_environment)"
    wm_lock_ota_fixture_environment "$ota_environment_name"

    # Capture immutable A before rebuilding the same environment as B. The
    # generated header is the only changed input, so PlatformIO reuses library
    # objects while the browser still mounts a distinct B artifact read-only.
    build_ota_image A
    build_ota_image B
    if cmp -s "$ota_firmware_a" "$ota_firmware_b"; then
        echo "PlatformIO produced identical OTA A and B images." >&2
        return 1
    fi

    if [[ "$platform" == "esp32" ]]; then
        "$root/tools/check-ota-partitions.sh"
        capacity="$(ota_esp32_slot_size)"
        ota_assert_firmware_fits "$ota_firmware_a" "$capacity" A
        ota_assert_firmware_fits "$ota_firmware_b" "$capacity" B
    fi
}

ota_assert_a_artifact_matches_build() {
    local expected_artifact="$1" source
    source="$root/test/portal-harness/.pio/build/$ota_environment_name/firmware.bin"
    [[ -s "$expected_artifact" && -s "$source" ]] || {
        echo "Prepared OTA A artifact or PlatformIO build output is missing." >&2
        return 1
    }
    cmp -s "$expected_artifact" "$source" || {
        echo "PlatformIO's A build no longer matches the immutable artifact prepared before serial flashing." >&2
        return 1
    }
}

validate_running_ota_capacity() {
    local marker_response free_sketch_space capacity
    [[ "$platform" == "esp8266" ]] || return 0
    marker_response="$(wait_for_ota_marker A)"
    free_sketch_space="$(ota_marker_field "$marker_response" freeSketchSpace)"
    [[ "$free_sketch_space" =~ ^[0-9]+$ && "$free_sketch_space" -gt 4096 ]] || {
        echo "ESP8266 fixture did not report usable free sketch space." >&2
        return 1
    }
    # Match WiFiManagerHandlers::handleUpdating(): Update.begin receives this
    # aligned value, not the raw ESP.getFreeSketchSpace() number.
    capacity=$(( (free_sketch_space - 0x1000) & 0xFFFFF000 ))
    ota_assert_firmware_fits "$ota_firmware_b" "$capacity" B
}

configure_ota_harness_environment() {
    export PORTAL_ARTIFACT_DIR="$output_dir"
    export LOCAL_UID="$(id -u)"
    export LOCAL_GID="$(id -g)"
    export PORTAL_BROWSER_MODE=auto
    # OTA is its own test harness. Do not inherit a developer's unrelated media,
    # stress, target, or test-selection setting into a physical firmware run.
    export PORTAL_HARNESS_DOCKER_TARGET=portal-harness
    export PORTAL_CAPTURE_README_MEDIA=0
    export PORTAL_CUSTOM_PARAMETER_STRESS=0
    export PORTAL_TEST_FILE=tests/ota.spec.js
    export PORTAL_PLATFORM="$platform"
    export PORTAL_OTA_FIRMWARE_HOST="$ota_firmware_b"
    export PORTAL_OTA_INITIAL_MARKER=A
    export PORTAL_OTA_EXPECTED_MARKER=B
}

ota_compose_files() {
    printf '%s\n' \
        -f "$root/tests/portal-harness/compose.yaml" \
        -f "$root/tests/portal-harness/compose.ota.yaml"
}

prepare_ota_harness_image() {
    local -a compose_files
    configure_ota_harness_environment
    mapfile -t compose_files < <(ota_compose_files)
    # Build before A is flashed. A cold Playwright build can otherwise consume
    # the finite portal window before the browser has connected.
    docker compose "${compose_files[@]}" build portal-harness
    ota_browser_prebuilt=yes
}

run_ota_harness() {
    local -a compose_files
    configure_ota_harness_environment
    mapfile -t compose_files < <(ota_compose_files)
    [[ "$ota_browser_prebuilt" == yes ]] || {
        echo "Portal OTA browser image was not prepared before A was flashed." >&2
        return 1
    }
    docker compose "${compose_files[@]}" run --rm portal-harness
}

start_ota_serial_capture() {
    local attempt
    ota_serial_capture_log="$output_dir/serial-ota.log"
    ota_serial_capture_ready="$output_dir/.serial-ota.ready"
    ota_serial_capture_status="$output_dir/serial-ota-capture.log"
    rm -f -- "$ota_serial_capture_ready"
    install -m 600 /dev/null "$ota_serial_capture_log"
    install -m 600 /dev/null "$ota_serial_capture_status"
    python3 "$root/tools/capture-serial.py" \
        --port "$port" \
        --output "$ota_serial_capture_log" \
        --ready-file "$ota_serial_capture_ready" \
        --deadline-seconds 600 \
        >>"$ota_serial_capture_status" 2>&1 &
    ota_serial_capture_pid=$!

    for attempt in $(seq 1 50); do
        [[ -f "$ota_serial_capture_ready" ]] && return 0
        if ! kill -0 "$ota_serial_capture_pid" >/dev/null 2>&1; then
            wait "$ota_serial_capture_pid" || true
            ota_serial_capture_pid=""
            echo "Portal OTA serial recorder exited before it became ready: $ota_serial_capture_status" >&2
            return 1
        fi
        sleep 0.1
    done
    echo "Portal OTA serial recorder did not become ready: $ota_serial_capture_status" >&2
    stop_ota_serial_capture || true
    return 1
}

require_ota_serial_capture_running() {
    [[ -n "$ota_serial_capture_pid" ]] && kill -0 "$ota_serial_capture_pid" >/dev/null 2>&1 || {
        echo "Portal OTA serial recorder stopped unexpectedly: $ota_serial_capture_status" >&2
        return 1
    }
}

stop_ota_serial_capture() {
    local status=0
    [[ -n "$ota_serial_capture_pid" ]] || return 0
    if kill -0 "$ota_serial_capture_pid" >/dev/null 2>&1; then
        kill -TERM "$ota_serial_capture_pid" >/dev/null 2>&1 || status=1
    fi
    if ! wait "$ota_serial_capture_pid"; then
        status=1
    fi
    ota_serial_capture_pid=""
    (( status == 0 )) || return "$status"
}

finish_portal_session() {
    # A normal run already owns an in-process record. `down` starts fresh, so
    # load its retained exact record and select the scoped authorization path
    # before attempting deletion.
    if [[ "${WM_PORTAL_CONNECTION_OWNED:-no}" != "yes" ]]; then
        wm_load_state
        wm_prepare_networkmanager_authorization
        WM_PORTAL_CONNECTION_OWNED=yes
    fi
    wm_cleanup_created_connection
}

cleanup_portal_session() {
    local status="${1:-$?}"
    # A signal must never fall through into the rest of the hardware run, and
    # a recursive EXIT trap must not obscure the original status.
    trap - EXIT HUP INT TERM
    stop_ota_serial_capture || true
    wm_remove_ota_fixture_identity
    if ! restore_station_handoff_fixture; then
        (( status != 0 )) || status=1
    fi
    if ! remove_station_handoff_env; then
        (( status != 0 )) || status=1
    fi
    if [[ "${WM_PORTAL_CONNECTION_OWNED:-no}" == "yes" ]] && \
        { (( status != 0 )) || { [[ "$command_name" != "up" && "$keep" != "yes" ]]; }; }; then
        if ! wm_cleanup_created_connection; then
            # Preserve an existing run failure, but never turn an otherwise
            # successful browser/OTA run into a claimed pass when its owned
            # NetworkManager connection could not be removed.
            (( status != 0 )) || status=1
        fi
    fi
    exit "$status"
}

trap 'cleanup_portal_session "$?"' EXIT
trap 'cleanup_portal_session 129' HUP
trap 'cleanup_portal_session 130' INT
trap 'cleanup_portal_session 143' TERM

case "$command_name" in
    doctor)
        [[ -n "$client_interface" ]] || usage
        require_common
        wm_acquire_hardware_lock
        # Doctor is read-only, but it must validate the same authorization
        # path as a real portal command before it calls an adapter safe.
        wm_prepare_networkmanager_authorization
        wm_require_client_adapter "$client_interface" "$takeover"
        wm_report_networkmanager_authorization
        printf 'Portal hardware prerequisites are ready. Main route is untouched; client adapter: %s\n' "$client_interface"
        ;;
    up)
        start_portal_session
        ;;
    down)
        [[ -z "$platform$port$client_interface$station_env$output_dir" ]] || usage
        wm_acquire_hardware_lock
        if ! finish_portal_session; then
            trap - EXIT HUP INT TERM
            exit 1
        fi
        echo 'Portal client connection removed.'
        ;;
    run)
        start_portal_session
        export PORTAL_ARTIFACT_DIR="$output_dir"
        export LOCAL_UID="$(id -u)"
        export LOCAL_GID="$(id -g)"
        export PORTAL_BROWSER_MODE="$browser"
        # A normal run is the full portal suite; do not let a prior OTA shell
        # environment restrict it to one spec.
        export PORTAL_TEST_FILE=''
        export PORTAL_CUSTOM_PARAMETER_STRESS=0
        if [[ "$custom_parameter_stress" == "yes" ]]; then
            export PORTAL_CUSTOM_PARAMETER_STRESS=1
        fi
        export PORTAL_PLATFORM="$platform"
        if [[ "$capture_readme_media" == "yes" ]]; then
            export PORTAL_CAPTURE_README_MEDIA=1
            export PORTAL_HARNESS_DOCKER_TARGET=media
        else
            export PORTAL_CAPTURE_README_MEDIA=0
            export PORTAL_HARNESS_DOCKER_TARGET=portal-harness
        fi
        compose_files=(-f "$root/tests/portal-harness/compose.yaml")
        if [[ -n "$station_env" ]]; then
            prepare_station_handoff_env
            compose_files+=(-f "$root/tests/portal-harness/compose.station.yaml")
            # Once the browser can receive the private station credentials,
            # every exit path must return the board to the fixture's clean
            # portal-only state. The EXIT trap covers a browser failure; the
            # explicit success-path call below keeps a cleanup failure from
            # being announced as a passing test.
            station_handoff_fixture_restore_needed=yes
        fi
        # The test-harness source is copied into the image; rebuild with Docker cache so
        # this invocation always tests the checked-out files, not a stale image.
        docker compose "${compose_files[@]}" build portal-harness
        docker compose "${compose_files[@]}" run --rm portal-harness
        restore_station_handoff_fixture
        remove_station_handoff_env
        if [[ "$capture_readme_media" == "yes" ]]; then
            write_readme_media_manifest
        fi
        if [[ "$keep" != "yes" ]] && ! finish_portal_session; then
            trap - EXIT HUP INT TERM
            exit 1
        fi
        printf 'Portal test harness passed. Artifacts: %s\n' "$output_dir"
        if [[ "$keep" == "yes" ]]; then
            printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n'
        fi
        ;;
    ota)
        # Acquire the same board/adapter lock before *any* A/B preparation.
        # Otherwise two OTA invocations can collide in .pio or create a
        # same-second artifact directory before either reaches serial flash.
        validate_run_arguments
        require_common
        wm_acquire_hardware_lock
        wm_require_no_active_session
        wm_prepare_networkmanager_authorization
        wm_require_client_adapter "$client_interface" "$takeover"
        require_ota_serial_capture
        prepare_output_dir
        prepare_ota_identity_input
        prepare_ota_firmware
        prepare_ota_harness_image
        start_portal_session "$ota_environment_name" yes "$ota_firmware_a" yes no

        # A must be visible through the real portal before a B upload can be
        # meaningful. On ESP8266 the marker also reports the actual active
        # sketch-space limit, which is checked before the browser sees B.
        wait_for_ota_marker A >/dev/null
        validate_running_ota_capacity
        run_ota_harness

        # The browser test harness has already required an outage and two B
        # observations. Repeat the host-side observation after the container
        # exits so the named secondary adapter is also proven to rediscover
        # the returned AP and see B. This is AP routing, not a LAN IP shortcut.
        wm_wait_for_portal_ssid "$client_interface" "$(wm_portal_ssid "$platform")"
        wm_verify_portal_route "$client_interface"
        wait_for_ota_marker B >/dev/null
        require_ota_serial_capture_running
        sleep 1
        wait_for_ota_marker B >/dev/null
        require_ota_serial_capture_running
        stop_ota_serial_capture
        if [[ "$keep" != "yes" ]] && ! finish_portal_session; then
            trap - EXIT HUP INT TERM
            exit 1
        fi
        printf 'Portal HTTP OTA test harness passed. Artifacts: %s\n' "$output_dir"
        if [[ "$keep" == "yes" ]]; then
            printf 'Portal session remains connected; run ./tools/portal-hardware down when finished.\n'
        fi
        ;;
    *) usage ;;
esac
