#include "wifi_component.h"
#ifdef USE_WIFI
#include <cassert>
#include <cinttypes>
#include <cmath>
#include <type_traits>

#ifdef USE_ESP32
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 1, 0)
#include <esp_eap_client.h>
#else
#include <esp_wpa2.h>
#endif
#endif

#if defined(USE_ESP32)
#include <esp_wifi.h>
#endif
#ifdef USE_ESP8266
#include <user_interface.h>
#endif

#include <algorithm>
#include <new>
#include <utility>
#include "lwip/dns.h"
#include "lwip/err.h"

#include "esphome/core/application.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/string_ref.h"
#include "esphome/core/util.h"

#ifdef USE_CAPTIVE_PORTAL
#include "esphome/components/captive_portal/captive_portal.h"
#endif

#ifdef USE_IMPROV
#include "esphome/components/esp32_improv/esp32_improv_component.h"
#endif

#ifdef USE_IMPROV_SERIAL
#include "esphome/components/improv_serial/improv_serial_component.h"
#endif

#ifdef USE_PROVISIONING
#include "esphome/components/provisioning/provisioning.h"
#endif

namespace esphome::wifi {

static const char *const TAG = "wifi";

// CompactString implementation
CompactString::CompactString(const char *str, size_t len) {
  if (len > MAX_LENGTH) {
    len = MAX_LENGTH;  // Clamp to max valid length
  }

  this->length_ = len;
  if (len <= INLINE_CAPACITY) {
    // Store inline with null terminator
    this->is_heap_ = 0;
    if (len > 0) {
      std::memcpy(this->storage_, str, len);
    }
    this->storage_[len] = '\0';
  } else {
    // Heap allocate with null terminator
    this->is_heap_ = 1;
    char *heap_data = new char[len + 1];  // NOLINT(cppcoreguidelines-owning-memory)
    std::memcpy(heap_data, str, len);
    heap_data[len] = '\0';
    this->set_heap_ptr_(heap_data);
  }
}

CompactString::CompactString(const CompactString &other) : CompactString(other.data(), other.size()) {}

CompactString &CompactString::operator=(const CompactString &other) {
  if (this != &other) {
    this->~CompactString();
    new (this) CompactString(other);
  }
  return *this;
}

CompactString::CompactString(CompactString &&other) noexcept : length_(other.length_), is_heap_(other.is_heap_) {
  // Copy full storage (includes null terminator for inline, or pointer for heap)
  std::memcpy(this->storage_, other.storage_, INLINE_CAPACITY + 1);
  other.length_ = 0;
  other.is_heap_ = 0;
  other.storage_[0] = '\0';
}

CompactString &CompactString::operator=(CompactString &&other) noexcept {
  if (this != &other) {
    this->~CompactString();
    new (this) CompactString(std::move(other));
  }
  return *this;
}

CompactString::~CompactString() {
  if (this->is_heap_) {
    delete[] this->get_heap_ptr_();  // NOLINT(cppcoreguidelines-owning-memory)
  }
}

bool CompactString::operator==(const CompactString &other) const {
  return this->size() == other.size() && std::memcmp(this->data(), other.data(), this->size()) == 0;
}
bool CompactString::operator==(const StringRef &other) const {
  return this->size() == other.size() && std::memcmp(this->data(), other.c_str(), this->size()) == 0;
}

/// WiFi Retry Logic - Priority-Based BSSID Selection
///
/// The WiFi component uses a state machine with priority degradation to handle connection failures
/// and automatically cycle through different BSSIDs in mesh networks or multiple configured networks.
///
/// Normal Connection Flow (SCAN_BASED):
/// ┌──────────────────────────────────────────────────────────────────────┐
/// │                      Fast Connect Path (Optional)                    │
/// ├──────────────────────────────────────────────────────────────────────┤
/// │  Entered if: configuration has 'fast_connect: true'                  │
/// │  Optimization to skip scanning when possible:                        │
/// │                                                                      │
/// │  1. INITIAL_CONNECT → Try one of:                                    │
/// │     a) Saved BSSID+channel (from previous boot)                      │
/// │     b) First configured non-hidden network (any BSSID)               │
/// │                          ↓                                           │
/// │     [FAILED] → Check if more configured networks available           │
/// │                          ↓                                           │
/// │  2. FAST_CONNECT_CYCLING_APS → Try remaining configured networks     │
/// │                                (1 attempt each, any BSSID)           │
/// │                          ↓                                           │
/// │     [All Failed] → Fall through to explicit hidden or scanning       │
/// │                                                                      │
/// │  Note: Fast connect data saved from previous successful connection   │
/// └──────────────────────────────────────────────────────────────────────┘
///                          ↓
/// ┌──────────────────────────────────────────────────────────────────────┐
/// │              Explicit Hidden Networks Path (Optional)                │
/// ├──────────────────────────────────────────────────────────────────────┤
/// │  Entered if: first configured network has 'hidden: true'             │
/// │                                                                      │
/// │  1. EXPLICIT_HIDDEN → Try consecutive hidden networks (1 attempt)    │
/// │                       Stop when visible network reached              │
/// │                          ↓                                           │
/// │     Example: Hidden1, Hidden2, Visible1, Hidden3, Visible2           │
/// │              Try: Hidden1, Hidden2 (stop at Visible1)                │
/// │                          ↓                                           │
/// │     [All Failed] → Fall back to scan-based connection                │
/// │                                                                      │
/// │  Note: Fast connect saves BSSID after first successful connection,   │
/// │        so subsequent boots use fast path instead of hidden mode      │
/// └──────────────────────────────────────────────────────────────────────┘
///                          ↓
/// ┌──────────────────────────────────────────────────────────────────────┐
/// │                    Scan-Based Connection Path                        │
/// ├──────────────────────────────────────────────────────────────────────┤
/// │                                                                      │
/// │  1. SCAN → Sort by priority (highest first), then RSSI               │
/// │     ┌─────────────────────────────────────────────────┐              │
/// │     │ scan_result_[0] = Best BSSID (highest priority) │              │
/// │     │ scan_result_[1] = Second best                   │              │
/// │     │ scan_result_[2] = Third best                    │              │
/// │     └─────────────────────────────────────────────────┘              │
/// │                          ↓                                           │
/// │  2. SCAN_CONNECTING → Try scan_result_[0] (2 attempts)               │
/// │                       (Visible1, Visible2 from example above)        │
/// │                          ↓                                           │
/// │  3. FAILED → Decrease priority: 0.0 → -1.0 → -2.0                    │
/// │              (stored in persistent sta_priorities_)                  │
/// │                          ↓                                           │
/// │  4. Check for hidden networks:                                       │
/// │     - If found → RETRY_HIDDEN (try SSIDs not in scan, 1 attempt)     │
/// │       Skip hidden networks before first visible one                  │
/// │       (Skip Hidden1/Hidden2, try Hidden3 from example)               │
/// │     - If none → Skip RETRY_HIDDEN, go to step 5                      │
/// │                          ↓                                           │
/// │  5. FAILED → RESTARTING_ADAPTER                                      │
/// │     - Normal: restart adapter, clear state                           │
/// │     - AP/improv active: skip restart, just disconnect                │
/// │                          ↓                                           │
/// │  6. Loop back to start:                                              │
/// │     - If first network is hidden → EXPLICIT_HIDDEN (retry cycle)     │
/// │     - If AP/improv active → RETRY_HIDDEN (blind retry, see below)    │
/// │     - Otherwise → SCAN_CONNECTING (rescan)                           │
/// │                          ↓                                           │
/// │  7. RESCAN → Apply stored priorities, sort again                     │
/// │     ┌─────────────────────────────────────────────────┐              │
/// │     │ scan_result_[0] = BSSID B (priority 0.0)  ← NEW │              │
/// │     │ scan_result_[1] = BSSID C (priority 0.0)        │              │
/// │     │ scan_result_[2] = BSSID A (priority -2.0) ← OLD │              │
/// │     └─────────────────────────────────────────────────┘              │
/// │                          ↓                                           │
/// │  8. SCAN_CONNECTING → Try scan_result_[0] (next best)                │
/// │                                                                      │
/// │  Key: Priority system cycles through BSSIDs ACROSS scan cycles       │
/// │       Full retry cycle: EXPLICIT_HIDDEN → SCAN → RETRY_HIDDEN        │
/// │       Always try best available BSSID (scan_result_[0])              │
/// └──────────────────────────────────────────────────────────────────────┘
///
/// Retry Phases:
/// - INITIAL_CONNECT: Try saved BSSID+channel (fast_connect), or fall back to normal flow
/// - FAST_CONNECT_CYCLING_APS: Cycle through remaining configured networks (1 attempt each, fast_connect only)
/// - EXPLICIT_HIDDEN: Try consecutive networks marked hidden:true before scanning (1 attempt per SSID)
/// - SCAN_CONNECTING: Connect using scan results (2 attempts per BSSID)
/// - RETRY_HIDDEN: Behavior controlled by RetryHiddenMode:
///   * SCAN_BASED: Try networks not found in scan (truly hidden, 1 attempt per SSID)
///   * BLIND_RETRY: Cycle through ALL networks when scanning disabled (AP active)
/// - RESTARTING_ADAPTER: Restart WiFi adapter to clear stuck state (restart skipped if AP active)
///
/// Hidden Network Handling:
/// - Networks marked 'hidden: true' before first non-hidden → Tried in EXPLICIT_HIDDEN phase
/// - Networks marked 'hidden: true' after first non-hidden → Tried in RETRY_HIDDEN phase
/// - After successful connection, fast_connect saves BSSID → subsequent boots use fast path
/// - Networks not in scan results → Tried in RETRY_HIDDEN phase
/// - Networks visible in scan + not marked hidden → Skipped in RETRY_HIDDEN phase
/// - Networks marked 'hidden: true' always use hidden mode, even if broadcasting SSID
///
/// ┌──────────────────────────────────────────────────────────────────────┐
/// │        Captive Portal / Improv Mode (AP active, scanning disabled)   │
/// ├──────────────────────────────────────────────────────────────────────┤
/// │  When captive_portal or esp32_improv is active, WiFi scanning is     │
/// │  disabled because it disrupts AP clients (radio leaves AP channel    │
/// │  to hop through other channels, causing client disconnections).      │
/// │                                                                      │
/// │  Flow with RetryHiddenMode::BLIND_RETRY:                             │
/// │                                                                      │
/// │  1. RESTARTING_ADAPTER → In this mode, skip adapter restart and      │
/// │     just disconnect (normal mode restarts the adapter)               │
/// │     - Sets retry_hidden_mode_ = BLIND_RETRY                          │
/// │     - Enter extended cooldown (30s vs normal 500ms)                  │
/// │                          ↓                                           │
/// │  2. determine_next_phase_() returns RETRY_HIDDEN (skips scanning)    │
/// │                          ↓                                           │
/// │  3. RETRY_HIDDEN with BLIND_RETRY mode:                              │
/// │     - find_next_hidden_sta_() ignores scan_result_                   │
/// │     - ALL configured networks become candidates                      │
/// │     - Cycles through networks: Net1 → Net2 → Net3 → ...              │
/// │                          ↓                                           │
/// │  4. After exhausting all networks → Back to RESTARTING_ADAPTER       │
/// │     - Loop continues until connection succeeds or user configures    │
/// │       new credentials via captive portal                             │
/// │                                                                      │
/// │  The 30s cooldown gives users time to interact with captive portal   │
/// │  without constant connection attempts disrupting the AP.             │
/// └──────────────────────────────────────────────────────────────────────┘
///
/// ┌──────────────────────────────────────────────────────────────────────┐
/// │              Post-Connect Roaming (for stationary devices)           │
/// ├──────────────────────────────────────────────────────────────────────┤
/// │  Purpose: Handle AP reboot or power loss scenarios where device      │
/// │           connects to suboptimal AP and never switches back          │
/// │                                                                      │
/// │  State Machine (RoamingState):                                       │
/// │                                                                      │
/// │    ┌─────────────────────────────────────────────────────────────┐   │
/// │    │                         IDLE                                │   │
/// │    │  (waiting for 5 min timer, attempts < 3)                    │   │
/// │    └─────────────────────────┬───────────────────────────────────┘   │
/// │                              │ 5 min elapsed, RSSI < -49 dBm         │
/// │                              ↓                                       │
/// │    ┌─────────────────────────────────────────────────────────────┐   │
/// │    │                       SCANNING                              │   │
/// │    │  (attempts++ in check_roaming_ before entering this state)  │   │
/// │    └─────────────────────────┬───────────────────────────────────┘   │
/// │                              │                                       │
/// │               ┌──────────────┼──────────────┐                        │
/// │               ↓              ↓              ↓                        │
/// │         disconnect    no better AP    +10 dB better AP               │
/// │               │              │              │                        │
/// │               ↓              ↓              ↓                        │
/// │    ┌──────────────────────────────┐  ┌──────────────────────────┐    │
/// │    │  → RECONNECTING              │  │        CONNECTING        │    │
/// │    │  (counter preserved)         │  │  (process_roaming_scan_) │    │
/// │    └──────────────────────────────┘  └────────────┬─────────────┘    │
/// │                                                  │                   │
/// │                              ┌───────────────────┴───────────────┐   │
/// │                              ↓                                   ↓   │
/// │                        SUCCESS                              FAILED   │
/// │                              │                                   │   │
/// │                              ↓                                   ↓   │
/// │    ┌──────────────────────────────────┐    ┌─────────────────────────┐
/// │    │  → IDLE                          │    │      RECONNECTING       │
/// │    │  (counter reset to 0)            │    │  (retry_connect called) │
/// │    └──────────────────────────────────┘    └───────────┬─────────────┘
/// │                                                        │             │
/// │                                              ┌─────────┴─────────┐   │
/// │                                              ↓                   ↓   │
/// │                                      on target BSSID    on other AP  │
/// │                                              │                   │   │
/// │                                              ↓                   ↓   │
/// │                                   ┌──────────────────┐ ┌────────────┐│
/// │                                   │  → IDLE          │ │  → IDLE    ││
/// │                                   │  (counter reset) │ │  (counter  ││
/// │                                   │  (roam worked!)  │ │  preserved)││
/// │                                   └──────────────────┘ └────────────┘│
/// │                                                                      │
/// │  Key behaviors:                                                      │
/// │  - After 3 checks: attempts >= 3, stop checking                      │
/// │  - Non-roaming disconnect: clear_roaming_state_() resets counter     │
/// │  - Disconnect during scan (SCANNING→RECONNECTING): counter preserved │
/// │  - Disconnect after scan (within grace period): counter preserved    │
/// │  - Roaming success (CONNECTING→IDLE): counter reset (can roam again) │
/// │  - Roaming success via retry (on target BSSID): counter reset        │
/// │  - Roaming fail (RECONNECTING on other AP): counter preserved        │
/// └──────────────────────────────────────────────────────────────────────┘

#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO
#ifdef USE_WIFI_PHY_MODE
// Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266)
static const LogString *phy_mode_to_log_string(WiFi8266PhyMode mode) {
  if (mode == WIFI_8266_PHY_MODE_11B)
    return LOG_STR("11B");
  if (mode == WIFI_8266_PHY_MODE_11G)
    return LOG_STR("11G");
  if (mode == WIFI_8266_PHY_MODE_11N)
    return LOG_STR("11N");
  return LOG_STR("Auto");
}
#endif
// Use if-chain instead of switch to avoid jump table in RODATA (wastes RAM on ESP8266)
static const LogString *retry_phase_to_log_string(WiFiRetryPhase phase) {
  if (phase == WiFiRetryPhase::INITIAL_CONNECT)
    return LOG_STR("INITIAL_CONNECT");
#ifdef USE_WIFI_FAST_CONNECT
  if (phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS)
    return LOG_STR("FAST_CONNECT_CYCLING");
#endif
  if (phase == WiFiRetryPhase::EXPLICIT_HIDDEN)
    return LOG_STR("EXPLICIT_HIDDEN");
  if (phase == WiFiRetryPhase::SCAN_CONNECTING)
    return LOG_STR("SCAN_CONNECTING");
  if (phase == WiFiRetryPhase::RETRY_HIDDEN)
    return LOG_STR("RETRY_HIDDEN");
  if (phase == WiFiRetryPhase::RESTARTING_ADAPTER)
    return LOG_STR("RESTARTING");
  return LOG_STR("UNKNOWN");
}
#endif  // ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_INFO

bool WiFiComponent::went_through_explicit_hidden_phase_() const {
  // If first configured network is marked hidden, we went through EXPLICIT_HIDDEN phase
  // This means those networks were already tried and should be skipped in RETRY_HIDDEN
  return !this->sta_.empty() && this->sta_[0].get_hidden();
}

int8_t WiFiComponent::find_first_non_hidden_index_() const {
  // Find the first network that is NOT marked hidden:true
  // This is where EXPLICIT_HIDDEN phase would have stopped
  for (size_t i = 0; i < this->sta_.size(); i++) {
    if (!this->sta_[i].get_hidden()) {
      return static_cast<int8_t>(i);
    }
  }
  return -1;  // All networks are hidden
}

// 2 attempts per BSSID in SCAN_CONNECTING phase
// Rationale: This is the ONLY phase where we decrease BSSID priority, so we must be very sure.
// Auth failures are common immediately after scan due to WiFi stack state transitions.
// Trying twice filters out false positives and prevents unnecessarily marking a good BSSID as bad.
// After 2 genuine failures, priority degradation ensures we skip this BSSID on subsequent scans.
static constexpr uint8_t WIFI_RETRY_COUNT_PER_BSSID = 2;

// 1 attempt per SSID in RETRY_HIDDEN phase
// Rationale: Try hidden mode once, then rescan to get next best BSSID via priority system
static constexpr uint8_t WIFI_RETRY_COUNT_PER_SSID = 1;

// 1 attempt per AP in fast_connect mode (INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS)
// Rationale: Fast connect prioritizes speed - try each AP once to find a working one quickly
static constexpr uint8_t WIFI_RETRY_COUNT_PER_AP = 1;

/// Cooldown duration in milliseconds after adapter restart or repeated failures
/// Allows WiFi hardware to stabilize before next connection attempt
static constexpr uint32_t WIFI_COOLDOWN_DURATION_MS = 500;

/// Cooldown duration when fallback AP is active and captive portal may be running
/// Longer interval gives users time to configure WiFi without constant connection attempts
/// While connecting, WiFi can't beacon the AP properly, so needs longer cooldown
static constexpr uint32_t WIFI_COOLDOWN_WITH_AP_ACTIVE_MS = 30000;

/// Timeout for WiFi scan operations
/// This is a fallback in case we don't receive a scan done callback from the WiFi driver.
/// Normal scans complete via callback; this only triggers if something goes wrong.
static constexpr uint32_t WIFI_SCAN_TIMEOUT_MS = 31000;

/// Timeout for WiFi connection attempts
/// This is a fallback in case we don't receive connection success/failure callbacks.
/// Some platforms (especially LibreTiny/Beken) can take 30-60 seconds to connect,
/// particularly with fast_connect enabled where no prior scan provides channel info.
/// Do not lower this value - connection failures are detected via callbacks, not timeout.
/// If this timeout fires prematurely while a connection is still in progress, it causes
/// cascading failures: the subsequent scan will also fail because the WiFi driver is
/// still busy with the previous connection attempt.
static constexpr uint32_t WIFI_CONNECT_TIMEOUT_MS = 46000;

static constexpr uint8_t get_max_retries_for_phase(WiFiRetryPhase phase) {
  switch (phase) {
    case WiFiRetryPhase::INITIAL_CONNECT:
#ifdef USE_WIFI_FAST_CONNECT
    case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS:
#endif
      // INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS both use 1 attempt per AP (fast_connect mode)
      return WIFI_RETRY_COUNT_PER_AP;
    case WiFiRetryPhase::EXPLICIT_HIDDEN:
      // Explicitly hidden network: 1 attempt (user marked as hidden, try once then scan)
      return WIFI_RETRY_COUNT_PER_SSID;
    case WiFiRetryPhase::SCAN_CONNECTING:
      // Scan-based phase: 2 attempts per BSSID (handles transient auth failures after scan)
      return WIFI_RETRY_COUNT_PER_BSSID;
    case WiFiRetryPhase::RETRY_HIDDEN:
      // Hidden network mode: 1 attempt per SSID
      return WIFI_RETRY_COUNT_PER_SSID;
    default:
      return WIFI_RETRY_COUNT_PER_BSSID;
  }
}

static void apply_scan_result_to_params(WiFiAP &params, const WiFiScanResult &scan) {
  params.set_hidden(false);
  params.set_ssid(scan.get_ssid());
  params.set_bssid(scan.get_bssid());
  params.set_channel(scan.get_channel());
}

bool WiFiComponent::needs_scan_results_() const {
  // Only SCAN_CONNECTING phase needs scan results
  if (this->retry_phase_ != WiFiRetryPhase::SCAN_CONNECTING) {
    return false;
  }
  // Need scan if we have no results or no matching networks
  return this->scan_result_.empty() || !this->scan_result_[0].get_matches();
}

bool WiFiComponent::ssid_was_seen_in_scan_(const CompactString &ssid) const {
  // Check if this SSID is configured as hidden
  // If explicitly marked hidden, we should always try hidden mode regardless of scan results
  for (const auto &conf : this->sta_) {
    if (conf.ssid_ == ssid && conf.get_hidden()) {
      return false;  // Treat as not seen - force hidden mode attempt
    }
  }

  // Otherwise, check if we saw it in scan results
  for (const auto &scan : this->scan_result_) {
    if (scan.ssid_ == ssid) {
      return true;
    }
  }
  return false;
}

bool WiFiComponent::needs_full_scan_results_() const {
  // Components that require full scan results (for example, scan result listeners)
  // are expected to call request_wifi_scan_results(), which sets keep_scan_results_.
  if (this->keep_scan_results_) {
    return true;
  }

#ifdef USE_CAPTIVE_PORTAL
  // Captive portal needs full results when active (showing network list to user)
  if (captive_portal::global_captive_portal != nullptr && captive_portal::global_captive_portal->is_active()) {
    return true;
  }
#endif

#ifdef USE_IMPROV_SERIAL
  // Improv serial needs results during provisioning (before connected)
  if (improv_serial::global_improv_serial_component != nullptr && !this->is_connected()) {
    return true;
  }
#endif

#ifdef USE_IMPROV
  // BLE improv also needs results during provisioning
  if (esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active()) {
    return true;
  }
#endif

  return false;
}

bool WiFiComponent::matches_configured_network_(const char *ssid, const uint8_t *bssid) const {
  // Hidden networks in scan results have empty SSIDs - skip them
  if (ssid[0] == '\0') {
    return false;
  }
  for (const auto &sta : this->sta_) {
    // Skip hidden network configs (they don't appear in normal scans)
    if (sta.get_hidden()) {
      continue;
    }
    // For BSSID-only configs (empty SSID), match by BSSID
    if (sta.ssid_.empty()) {
      if (sta.has_bssid() && std::memcmp(sta.get_bssid().data(), bssid, 6) == 0) {
        return true;
      }
      continue;
    }
    // Match by SSID
    if (sta.ssid_ == ssid) {
      return true;
    }
  }
  return false;
}

void __attribute__((flatten)) WiFiComponent::set_sta_priority(bssid_t bssid, int8_t priority) {
  for (auto &it : this->sta_priorities_) {
    if (it.bssid == bssid) {
      it.priority = priority;
      return;
    }
  }
  this->sta_priorities_.push_back(WiFiSTAPriority{
      .bssid = bssid,
      .priority = priority,
  });
}

void WiFiComponent::log_discarded_scan_result_(const char *ssid, const uint8_t *bssid, int8_t rssi, uint8_t channel) {
#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
  // Skip logging during roaming scans to avoid log buffer overflow
  // (roaming scans typically find many networks but only care about same-SSID APs)
  if (this->roaming_state_ == RoamingState::SCANNING) {
    return;
  }
  char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
  format_mac_addr_upper(bssid, bssid_s);
  ESP_LOGV(TAG, "- " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " %ddB Ch:%u", ssid, bssid_s, rssi, channel);
#endif
}

int8_t WiFiComponent::find_next_hidden_sta_(int8_t start_index) {
  // Find next SSID to try in RETRY_HIDDEN phase.
  //
  // This function operates in two modes based on retry_hidden_mode_:
  //
  // 1. SCAN_BASED mode:
  //    After SCAN_CONNECTING phase, only returns networks that were NOT visible
  //    in the scan (truly hidden networks that need probe requests).
  //
  // 2. BLIND_RETRY mode:
  //    When captive portal/improv is active, scanning is skipped to avoid
  //    disrupting the AP. In this mode, ALL configured networks are returned
  //    as candidates, cycling through them sequentially. This allows the device
  //    to keep trying all networks while users configure WiFi via captive portal.
  //
  // Additionally, if EXPLICIT_HIDDEN phase was executed (first network marked hidden:true),
  // those networks are skipped here since they were already tried.
  //
  bool include_explicit_hidden = !this->went_through_explicit_hidden_phase_();
  // Start searching from start_index + 1
  for (size_t i = start_index + 1; i < this->sta_.size(); i++) {
    const auto &sta = this->sta_[i];

    // Skip networks that were already tried in EXPLICIT_HIDDEN phase
    // Those are: networks marked hidden:true that appear before the first non-hidden network
    // If all networks are hidden (first_non_hidden_idx == -1), skip all of them
    if (!include_explicit_hidden && sta.get_hidden()) {
      int8_t first_non_hidden_idx = this->find_first_non_hidden_index_();
      if (first_non_hidden_idx < 0 || static_cast<int8_t>(i) < first_non_hidden_idx) {
        ESP_LOGD(TAG, "Skipping " LOG_SECRET("'%s'") " (explicit hidden, already tried)", sta.ssid_.c_str());
        continue;
      }
    }

    // In BLIND_RETRY mode, treat all networks as candidates
    // In SCAN_BASED mode, only retry networks that weren't seen in the scan
    if (this->retry_hidden_mode_ == RetryHiddenMode::BLIND_RETRY || !this->ssid_was_seen_in_scan_(sta.ssid_)) {
      ESP_LOGD(TAG, "Hidden candidate " LOG_SECRET("'%s'") " at index %d", sta.ssid_.c_str(), static_cast<int>(i));
      return static_cast<int8_t>(i);
    }
    ESP_LOGD(TAG, "Skipping hidden retry for visible network " LOG_SECRET("'%s'"), sta.ssid_.c_str());
  }
  // No hidden SSIDs found
  return -1;
}

void WiFiComponent::start_initial_connection_() {
  // If first network (highest priority) is explicitly marked hidden, try it first before scanning
  // This respects user's priority order when they explicitly configure hidden networks
  if (!this->sta_.empty() && this->sta_[0].get_hidden()) {
    ESP_LOGI(TAG, "Starting with explicit hidden network (highest priority)");
    this->selected_sta_index_ = 0;
    this->retry_phase_ = WiFiRetryPhase::EXPLICIT_HIDDEN;
    WiFiAP params = this->build_params_for_current_phase_();
    this->start_connecting(params);
  } else {
    this->start_scanning();
  }
}

#if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) && ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
static const char *eap_phase2_to_str(esp_eap_ttls_phase2_types type) {
  switch (type) {
    case ESP_EAP_TTLS_PHASE2_PAP:
      return "pap";
    case ESP_EAP_TTLS_PHASE2_CHAP:
      return "chap";
    case ESP_EAP_TTLS_PHASE2_MSCHAP:
      return "mschap";
    case ESP_EAP_TTLS_PHASE2_MSCHAPV2:
      return "mschapv2";
    case ESP_EAP_TTLS_PHASE2_EAP:
      return "eap";
    default:
      return "unknown";
  }
}
#endif

float WiFiComponent::get_setup_priority() const { return setup_priority::WIFI; }

void WiFiComponent::setup() {
  this->wifi_pre_setup_();

#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
  // Create semaphore for high-performance mode requests
  // Start at 0, increment on request, decrement on release
  this->high_performance_semaphore_ = xSemaphoreCreateCounting(UINT32_MAX, 0);
  if (this->high_performance_semaphore_ == nullptr) {
    ESP_LOGE(TAG, "Failed semaphore");
  }

  // Store the configured power save mode as baseline
  this->configured_power_save_ = this->power_save_;
#endif

  if (this->enable_on_boot_) {
#ifdef USE_ESP32
    this->wifi_lazy_init_();
#endif
    this->start();
  } else {
    this->state_ = WIFI_COMPONENT_STATE_DISABLED;
  }
}

void WiFiComponent::start() {
  ESP_LOGCONFIG(TAG, "Starting");
  this->last_connected_ = millis();

  uint32_t hash = this->has_sta() ? App.get_config_version_hash() : 88491487UL;

  this->pref_ = global_preferences->make_preference<wifi::SavedWifiSettings>(hash, true);
#ifdef USE_WIFI_FAST_CONNECT
#ifdef USE_WIFI_FAST_CONNECT_IN_FLASH
  const bool fast_connect_in_flash = true;
#else
  const bool fast_connect_in_flash = false;
#endif
  this->fast_connect_pref_ =
      global_preferences->make_preference<wifi::SavedWifiFastConnectSettings>(hash + 1, fast_connect_in_flash);
#endif

  SavedWifiSettings save{};
  if (this->pref_.load(&save)) {
    ESP_LOGD(TAG, "Loaded settings: %s", save.ssid);

    WiFiAP sta{};
    sta.set_ssid(save.ssid);
    sta.set_password(save.password);
    this->set_sta(sta);
  }

  if (this->has_sta()) {
    this->wifi_sta_pre_setup_();
    if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) {
      ESP_LOGV(TAG, "Setting Output Power Option failed");
    }

#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
    // Synchronize power_save_ with semaphore state before applying
    if (this->high_performance_semaphore_ != nullptr) {
      UBaseType_t semaphore_count = uxSemaphoreGetCount(this->high_performance_semaphore_);
      if (semaphore_count > 0) {
        this->power_save_ = WIFI_POWER_SAVE_NONE;
        this->is_high_performance_mode_ = true;
      } else {
        this->power_save_ = this->configured_power_save_;
        this->is_high_performance_mode_ = false;
      }
    }
#endif
    if (!this->wifi_apply_power_save_()) {
      ESP_LOGV(TAG, "Setting Power Save Option failed");
    }

    this->transition_to_phase_(WiFiRetryPhase::INITIAL_CONNECT);
#ifdef USE_WIFI_FAST_CONNECT
    WiFiAP params;
    bool loaded_fast_connect = this->load_fast_connect_settings_(params);
    // Fast connect optimization: only use when we have saved BSSID+channel data
    // Without saved data, try first configured network or use normal flow
    if (loaded_fast_connect) {
      ESP_LOGI(TAG, "Starting fast_connect (saved) " LOG_SECRET("'%s'"), params.ssid_.c_str());
      this->start_connecting(params);
    } else if (!this->sta_.empty() && !this->sta_[0].get_hidden()) {
      // No saved data, but have configured networks - try first non-hidden network
      ESP_LOGI(TAG, "Starting fast_connect (config) " LOG_SECRET("'%s'"), this->sta_[0].ssid_.c_str());
      this->selected_sta_index_ = 0;
      params = this->build_params_for_current_phase_();
      this->start_connecting(params);
    } else {
      // No saved data and (no networks OR first is hidden) - use normal flow
      this->start_initial_connection_();
    }
#else
    // Without fast_connect: go straight to scanning (or hidden mode if all networks are hidden)
    this->start_initial_connection_();
#endif
#ifdef USE_WIFI_AP
  } else if (this->has_ap()) {
    this->setup_ap_config_();
    if (!std::isnan(this->output_power_) && !this->wifi_apply_output_power_(this->output_power_)) {
      ESP_LOGV(TAG, "Setting Output Power Option failed");
    }
#ifdef USE_CAPTIVE_PORTAL
    if (captive_portal::global_captive_portal != nullptr) {
      this->wifi_sta_pre_setup_();
      this->start_scanning();
      captive_portal::global_captive_portal->start();
    }
#endif
#endif  // USE_WIFI_AP
  }
#ifdef USE_IMPROV
  if (!this->has_sta() && esp32_improv::global_improv_component != nullptr) {
    if (this->wifi_mode_(true, {}))
      esp32_improv::global_improv_component->start();
  }
#endif
  this->wifi_apply_hostname_();
}

void WiFiComponent::restart_adapter() {
  ESP_LOGW(TAG, "Restarting adapter");
  this->wifi_mode_(false, {});
  // Clear error flag here because restart_adapter() enters COOLDOWN state,
  // and check_connecting_finished() is called after cooldown without going
  // through start_connecting() first. Without this clear, stale errors would
  // trigger spurious "failed (callback)" logs. The canonical clear location
  // is in start_connecting(); this is the only exception to that pattern.
  this->error_from_callback_ = false;
}

void WiFiComponent::loop() {
  bool events_processed = this->wifi_loop_();
  const uint32_t now = App.get_loop_component_start_time();
  // Connection state can only change when events are processed (ESP-IDF/LibreTiny)
  // or polled (ESP8266/Pico W). Skip the expensive wifi_sta_connect_status_() call
  // when no events arrived and we're already in steady state.
  // Must also run when connected_ is false — after state transitions to STA_CONNECTED,
  // connected_ won't be set until update_connected_state_() runs.
  if (events_processed || !this->connected_) {
    this->update_connected_state_();
  }

  if (this->has_sta()) {
#if defined(USE_WIFI_CONNECT_TRIGGER) || defined(USE_WIFI_DISCONNECT_TRIGGER)
    if (this->is_connected() != this->handled_connected_state_) {
#ifdef USE_WIFI_DISCONNECT_TRIGGER
      if (this->handled_connected_state_) {
        this->disconnect_trigger_.trigger();
      }
#endif
#ifdef USE_WIFI_CONNECT_TRIGGER
      if (!this->handled_connected_state_) {
        this->connect_trigger_.trigger();
      }
#endif
      this->handled_connected_state_ = this->is_connected();
    }
#endif  // USE_WIFI_CONNECT_TRIGGER || USE_WIFI_DISCONNECT_TRIGGER

    switch (this->state_) {
      case WIFI_COMPONENT_STATE_COOLDOWN: {
        this->status_set_warning(LOG_STR("waiting to reconnect"));
        // Skip cooldown if new credentials were provided while connecting
        if (this->skip_cooldown_next_cycle_) {
          this->skip_cooldown_next_cycle_ = false;
          this->check_connecting_finished(now);
          break;
        }
        // Use longer cooldown when captive portal/improv is active to avoid disrupting user config
        bool portal_active = this->is_captive_portal_active_() || this->is_esp32_improv_active_();
        uint32_t cooldown_duration = portal_active ? WIFI_COOLDOWN_WITH_AP_ACTIVE_MS : WIFI_COOLDOWN_DURATION_MS;
        if (now - this->action_started_ > cooldown_duration) {
          // After cooldown we either restarted the adapter because of
          // a failure, or something tried to connect over and over
          // so we entered cooldown. In both cases we call
          // check_connecting_finished to continue the state machine.
          this->check_connecting_finished(now);
        }
        break;
      }
      case WIFI_COMPONENT_STATE_STA_SCANNING: {
        this->status_set_warning(LOG_STR("scanning for networks"));
        this->check_scanning_finished();
        break;
      }
      case WIFI_COMPONENT_STATE_STA_CONNECTING: {
        this->status_set_warning(LOG_STR("associating to network"));
        this->check_connecting_finished(now);
        break;
      }

      case WIFI_COMPONENT_STATE_STA_CONNECTED: {
        // Use cached connected_ set unconditionally at the top of loop()
        if (!this->connected_) {
          ESP_LOGW(TAG, "Connection lost; reconnecting");
          this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING;
          this->retry_connect();
        } else {
          this->status_clear_warning();
          this->last_connected_ = now;

          // Post-connect roaming: check for better AP
          if (this->post_connect_roaming_) {
            if (this->roaming_state_ == RoamingState::SCANNING) {
              if (this->scan_done_) {
                this->process_roaming_scan_();
              }
              // else: scan in progress, wait
            } else if (this->roaming_state_ == RoamingState::IDLE && this->roaming_attempts_ < ROAMING_MAX_ATTEMPTS &&
                       now - this->roaming_last_check_ >= ROAMING_CHECK_INTERVAL && !this->roaming_suppressed_()) {
              this->check_roaming_(now);
            }
          }
        }
        break;
      }
      case WIFI_COMPONENT_STATE_OFF:
      case WIFI_COMPONENT_STATE_AP:
        break;
      case WIFI_COMPONENT_STATE_DISABLED:
        return;
    }

#ifdef USE_WIFI_AP
    if (this->has_ap() && !this->ap_setup_) {
      if (this->ap_timeout_ != 0 && (now - this->last_connected_ > this->ap_timeout_)) {
        ESP_LOGI(TAG, "Starting fallback AP");
        this->setup_ap_config_();
#ifdef USE_CAPTIVE_PORTAL
        if (captive_portal::global_captive_portal != nullptr) {
          // Reset so we force one full scan after captive portal starts
          // (previous scans were filtered because captive portal wasn't active yet)
          this->has_completed_scan_after_captive_portal_start_ = false;
          captive_portal::global_captive_portal->start();
        }
#endif
      }
    }
#endif  // USE_WIFI_AP

#ifdef USE_IMPROV
    if (esp32_improv::global_improv_component != nullptr && !esp32_improv::global_improv_component->is_active() &&
        !esp32_improv::global_improv_component->should_start()) {
      if (now - this->last_connected_ > esp32_improv::global_improv_component->get_wifi_timeout()) {
        if (this->wifi_mode_(true, {}))
          esp32_improv::global_improv_component->start();
      }
    }

#endif

    if (!this->has_ap() && this->reboot_timeout_ != 0) {
      if (now - this->last_connected_ > this->reboot_timeout_) {
        bool suppress = false;
#ifdef USE_PROVISIONING
        // Don't reboot while a provisioning window is pending (device unprovisioned).
        // The device is legitimately waiting to be onboarded (Wi-Fi must come up
        // before the controller can set credentials), and an auto-reboot would reopen
        // the window without the deliberate power cycle / reset that is meant to be
        // required. Resumes normal reboot behavior once provisioned.
        suppress = provisioning::global_provisioning_manager != nullptr &&
                   provisioning::global_provisioning_manager->window_pending();
#endif
        if (!suppress) {
          ESP_LOGE(TAG, "Can't connect; rebooting");
          App.reboot();
        }
      }
    }
  }

#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
  // Check if power save mode needs to be updated based on high-performance requests
  if (this->high_performance_semaphore_ != nullptr) {
    // Semaphore count directly represents active requests (starts at 0, increments on request)
    UBaseType_t semaphore_count = uxSemaphoreGetCount(this->high_performance_semaphore_);

    if (semaphore_count > 0 && !this->is_high_performance_mode_) {
      // Transition to high-performance mode (no power save)
      ESP_LOGV(TAG, "Switching to high-performance mode (%" PRIu32 " active %s)", (uint32_t) semaphore_count,
               semaphore_count == 1 ? "request" : "requests");
      this->power_save_ = WIFI_POWER_SAVE_NONE;
      if (this->wifi_apply_power_save_()) {
        this->is_high_performance_mode_ = true;
      }
    } else if (semaphore_count == 0 && this->is_high_performance_mode_) {
      // Restore to configured power save mode
      ESP_LOGV(TAG, "Restoring power save mode to configured setting");
      this->power_save_ = this->configured_power_save_;
      if (this->wifi_apply_power_save_()) {
        this->is_high_performance_mode_ = false;
      }
    }
  }
#endif
}

WiFiComponent::WiFiComponent() { global_wifi_component = this; }

#ifdef USE_WIFI_11KV_SUPPORT
void WiFiComponent::set_btm(bool btm) { this->btm_ = btm; }
void WiFiComponent::set_rrm(bool rrm) { this->rrm_ = rrm; }
#endif
network::IPAddresses WiFiComponent::get_ip_addresses() {
  if (this->has_sta())
    return this->wifi_sta_ip_addresses();

#ifdef USE_WIFI_AP
  if (this->has_ap())
    return {this->wifi_soft_ap_ip()};
#endif  // USE_WIFI_AP

  return {};
}
network::IPAddress WiFiComponent::get_dns_address(int num) {
  if (this->has_sta())
    return this->wifi_dns_ip_(num);
  return {};
}

#ifdef USE_WIFI_AP
void WiFiComponent::setup_ap_config_() {
  this->wifi_mode_({}, true);

  if (this->ap_setup_)
    return;

  if (this->ap_.ssid_.empty()) {
    // Build AP SSID from app name without heap allocation
    // WiFi SSID max is 32 bytes, with MAC suffix we keep first 25 + last 7
    static constexpr size_t AP_SSID_MAX_LEN = 32;
    static constexpr size_t AP_SSID_PREFIX_LEN = 25;
    static constexpr size_t AP_SSID_SUFFIX_LEN = 7;

    const auto &app_name = App.get_name();
    const char *name_ptr = app_name.c_str();
    size_t name_len = app_name.length();

    if (name_len <= AP_SSID_MAX_LEN) {
      // Name fits, use directly
      this->ap_.set_ssid(name_ptr);
    } else {
      // Name too long, need to truncate into stack buffer
      char ssid_buf[AP_SSID_MAX_LEN + 1];
      if (App.is_name_add_mac_suffix_enabled()) {
        // Keep first 25 chars and last 7 chars (MAC suffix), remove middle
        memcpy(ssid_buf, name_ptr, AP_SSID_PREFIX_LEN);
        memcpy(ssid_buf + AP_SSID_PREFIX_LEN, name_ptr + name_len - AP_SSID_SUFFIX_LEN, AP_SSID_SUFFIX_LEN);
      } else {
        memcpy(ssid_buf, name_ptr, AP_SSID_MAX_LEN);
      }
      ssid_buf[AP_SSID_MAX_LEN] = '\0';
      this->ap_.set_ssid(ssid_buf);
    }
  }
  this->ap_setup_ = this->wifi_start_ap_(this->ap_);

  char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
  ESP_LOGCONFIG(TAG,
                "Setting up AP:\n"
                "  AP SSID: '%s'\n"
                "  AP Password: '%s'\n"
                "  IP Address: %s",
                this->ap_.ssid_.c_str(), this->ap_.password_.c_str(), this->wifi_soft_ap_ip().str_to(ip_buf));

#ifdef USE_WIFI_MANUAL_IP
  auto manual_ip = this->ap_.get_manual_ip();
  if (manual_ip.has_value()) {
    char static_ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
    char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE];
    char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE];
    ESP_LOGCONFIG(TAG,
                  "  AP Static IP: '%s'\n"
                  "  AP Gateway: '%s'\n"
                  "  AP Subnet: '%s'",
                  manual_ip->static_ip.str_to(static_ip_buf), manual_ip->gateway.str_to(gateway_buf),
                  manual_ip->subnet.str_to(subnet_buf));
  }
#endif

  if (!this->has_sta()) {
    this->state_ = WIFI_COMPONENT_STATE_AP;
  }
}

void WiFiComponent::set_ap(const WiFiAP &ap) {
  this->ap_ = ap;
  this->has_ap_ = true;
}
#endif  // USE_WIFI_AP

void WiFiComponent::init_sta(size_t count) { this->sta_.init(count); }
void WiFiComponent::add_sta(const WiFiAP &ap) { this->sta_.push_back(ap); }
void WiFiComponent::clear_sta() {
  // Clear roaming state - no more configured networks
  this->clear_roaming_state_();
  this->sta_.clear();
  this->selected_sta_index_ = -1;
}
void WiFiComponent::set_sta(const WiFiAP &ap) {
  this->clear_sta();  // Also clears roaming state
  this->init_sta(1);
  this->add_sta(ap);
  this->selected_sta_index_ = 0;
  // When new credentials are set (e.g., from improv), skip cooldown to retry immediately
  this->skip_cooldown_next_cycle_ = true;
}

WiFiAP WiFiComponent::build_params_for_current_phase_() {
  const WiFiAP *config = this->get_selected_sta_();
  if (config == nullptr) {
    ESP_LOGE(TAG, "No valid network config (selected_sta_index_=%d, sta_.size()=%zu)",
             static_cast<int>(this->selected_sta_index_), this->sta_.size());
    // Return empty params - caller should handle this gracefully
    return WiFiAP();
  }

  WiFiAP params = *config;

  switch (this->retry_phase_) {
    case WiFiRetryPhase::INITIAL_CONNECT:
#ifdef USE_WIFI_FAST_CONNECT
    case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS:
#endif
      // Fast connect phases: use config-only (no scan results)
      // BSSID/channel from config if user specified them, otherwise empty
      break;

    case WiFiRetryPhase::EXPLICIT_HIDDEN:
    case WiFiRetryPhase::RETRY_HIDDEN:
      // Hidden network mode: clear BSSID/channel to trigger probe request
      // (both explicit hidden and retry hidden use same behavior)
      params.clear_bssid();
      params.clear_channel();
      break;

    case WiFiRetryPhase::SCAN_CONNECTING:
      // Scan-based phase: always use best scan result (index 0 - highest priority after sorting)
      if (!this->scan_result_.empty()) {
        apply_scan_result_to_params(params, this->scan_result_[0]);
      }
      break;

    case WiFiRetryPhase::RESTARTING_ADAPTER:
      // Should not be building params during restart
      break;
  }

  return params;
}

WiFiAP WiFiComponent::get_sta() const {
  const WiFiAP *config = this->get_selected_sta_();
  return config ? *config : WiFiAP{};
}
void WiFiComponent::save_wifi_sta(const std::string &ssid, const std::string &password) {
  this->save_wifi_sta(ssid.c_str(), password.c_str());
}
void WiFiComponent::save_wifi_sta(const char *ssid, const char *password) {
  SavedWifiSettings save{};  // zero-initialized - all bytes set to \0, guaranteeing null termination
  strncpy(save.ssid, ssid, sizeof(save.ssid) - 1);              // max 32 chars, byte 32 remains \0
  strncpy(save.password, password, sizeof(save.password) - 1);  // max 64 chars, byte 64 remains \0
  this->pref_.save(&save);
  // ensure it's written immediately
  global_preferences->sync();

  WiFiAP sta{};
  sta.set_ssid(ssid);
  sta.set_password(password);
  this->set_sta(sta);

  // Trigger connection attempt (exits cooldown if needed, no-op if already connecting/connected)
  this->connect_soon_();
}

void WiFiComponent::connect_soon_() {
  // Only trigger retry if we're in cooldown - if already connecting/connected, do nothing
  if (this->state_ == WIFI_COMPONENT_STATE_COOLDOWN) {
    ESP_LOGD(TAG, "Exiting cooldown early due to new WiFi credentials");
    this->retry_connect();
  }
}

void WiFiComponent::start_connecting(const WiFiAP &ap) {
  // Log connection attempt at INFO level with priority
  char bssid_s[18];
  int8_t priority = 0;

  if (ap.has_bssid()) {
    format_mac_addr_upper(ap.get_bssid().data(), bssid_s);
    priority = this->get_sta_priority(ap.get_bssid());
  }

  ESP_LOGI(TAG,
           "Connecting to " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") " (priority %d, attempt %u/%u in phase %s)...",
           ap.ssid_.c_str(), ap.has_bssid() ? bssid_s : LOG_STR_LITERAL("any"), priority, this->num_retried_ + 1,
           get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));

#ifdef ESPHOME_LOG_HAS_VERBOSE
  ESP_LOGV(TAG,
           "Connection Params:\n"
           "  SSID: '%s'",
           ap.ssid_.c_str());
  if (ap.has_bssid()) {
    ESP_LOGV(TAG, "  BSSID: %s", bssid_s);
  } else {
    ESP_LOGV(TAG, "  BSSID: Not Set");
  }

#ifdef USE_WIFI_WPA2_EAP
  const auto &eap_opt = ap.get_eap();
  if (eap_opt.has_value()) {
    const EAPAuth &eap_config = *eap_opt;
    // clang-format off
    ESP_LOGV(
        TAG,
        "  WPA2 Enterprise authentication configured:\n"
        "    Identity: " LOG_SECRET("'%s'") "\n"
        "    Username: " LOG_SECRET("'%s'") "\n"
        "    Password: " LOG_SECRET("'%s'"),
        eap_config.identity.c_str(), eap_config.username.c_str(), eap_config.password.c_str());
    // clang-format on
#if defined(USE_ESP32) && defined(USE_WIFI_WPA2_EAP) && ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
    ESP_LOGV(TAG, "    TTLS Phase 2: " LOG_SECRET("'%s'"), eap_phase2_to_str(eap_config.ttls_phase_2));
#endif
    bool ca_cert_present = eap_config.ca_cert != nullptr && strlen(eap_config.ca_cert);
    bool client_cert_present = eap_config.client_cert != nullptr && strlen(eap_config.client_cert);
    bool client_key_present = eap_config.client_key != nullptr && strlen(eap_config.client_key);
    ESP_LOGV(TAG,
             "    CA Cert:     %s\n"
             "    Client Cert: %s\n"
             "    Client Key:  %s",
             ca_cert_present ? "present" : "not present", client_cert_present ? "present" : "not present",
             client_key_present ? "present" : "not present");
  } else {
#endif
    ESP_LOGV(TAG, "  Password: " LOG_SECRET("'%s'"), ap.password_.c_str());
#ifdef USE_WIFI_WPA2_EAP
  }
#endif
  if (ap.has_channel()) {
    ESP_LOGV(TAG, "  Channel: %u", ap.get_channel());
  } else {
    ESP_LOGV(TAG, "  Channel not set");
  }
#ifdef USE_WIFI_MANUAL_IP
  auto manual_ip = ap.get_manual_ip();
  if (manual_ip.has_value()) {
    ManualIP m = *manual_ip;
    char static_ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
    char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE];
    char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE];
    char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE];
    char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE];
    ESP_LOGV(TAG, "  Manual IP: Static IP=%s Gateway=%s Subnet=%s DNS1=%s DNS2=%s", m.static_ip.str_to(static_ip_buf),
             m.gateway.str_to(gateway_buf), m.subnet.str_to(subnet_buf), m.dns1.str_to(dns1_buf),
             m.dns2.str_to(dns2_buf));
  } else
#endif
  {
    ESP_LOGV(TAG, "  Using DHCP IP");
  }
  ESP_LOGV(TAG, "  Hidden: %s", YESNO(ap.get_hidden()));
#endif

  // Clear any stale error from previous connection attempt.
  // This is the canonical location for clearing the flag since all connection
  // attempts go through start_connecting(). The only other clear is in
  // restart_adapter() which enters COOLDOWN without calling start_connecting().
  this->error_from_callback_ = false;

  if (!this->wifi_sta_connect_(ap)) {
    ESP_LOGE(TAG, "wifi_sta_connect_ failed");
    // Enter cooldown to allow WiFi hardware to stabilize
    // (immediate failure suggests hardware not ready, different from connection timeout)
    this->state_ = WIFI_COMPONENT_STATE_COOLDOWN;
  } else {
    this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTING;
  }
  this->action_started_ = millis();
}

const LogString *get_signal_bars(int8_t rssi) {
  // LOWER ONE QUARTER BLOCK
  // Unicode: U+2582, UTF-8: E2 96 82
  // LOWER HALF BLOCK
  // Unicode: U+2584, UTF-8: E2 96 84
  // LOWER THREE QUARTERS BLOCK
  // Unicode: U+2586, UTF-8: E2 96 86
  // FULL BLOCK
  // Unicode: U+2588, UTF-8: E2 96 88
  if (rssi >= -50) {
    return LOG_STR("\033[0;32m"  // green
                   "\xe2\x96\x82"
                   "\xe2\x96\x84"
                   "\xe2\x96\x86"
                   "\xe2\x96\x88"
                   "\033[0m");
  } else if (rssi >= -65) {
    return LOG_STR("\033[0;33m"  // yellow
                   "\xe2\x96\x82"
                   "\xe2\x96\x84"
                   "\xe2\x96\x86"
                   "\033[0;37m"
                   "\xe2\x96\x88"
                   "\033[0m");
  } else if (rssi >= -85) {
    return LOG_STR("\033[0;33m"  // yellow
                   "\xe2\x96\x82"
                   "\xe2\x96\x84"
                   "\033[0;37m"
                   "\xe2\x96\x86"
                   "\xe2\x96\x88"
                   "\033[0m");
  } else {
    return LOG_STR("\033[0;31m"  // red
                   "\xe2\x96\x82"
                   "\033[0;37m"
                   "\xe2\x96\x84"
                   "\xe2\x96\x86"
                   "\xe2\x96\x88"
                   "\033[0m");
  }
}

void WiFiComponent::print_connect_params_() {
  bssid_t bssid = wifi_bssid();
  char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
  format_mac_addr_upper(bssid.data(), bssid_s);
  // Use stack buffers for IP address formatting to avoid heap allocations
  char ip_buf[network::IP_ADDRESS_BUFFER_SIZE];
  for (auto &ip : wifi_sta_ip_addresses()) {
    if (ip.is_set()) {
      ESP_LOGCONFIG(TAG, "  IP Address: %s", ip.str_to(ip_buf));
    }
  }
  int8_t rssi = wifi_rssi();
  // Use stack buffers for SSID and all IP addresses to avoid heap allocations
  char ssid_buf[SSID_BUFFER_SIZE];
  char subnet_buf[network::IP_ADDRESS_BUFFER_SIZE];
  char gateway_buf[network::IP_ADDRESS_BUFFER_SIZE];
  char dns1_buf[network::IP_ADDRESS_BUFFER_SIZE];
  char dns2_buf[network::IP_ADDRESS_BUFFER_SIZE];
  // clang-format off
  ESP_LOGCONFIG(TAG,
                "  SSID: " LOG_SECRET("'%s'") "\n"
                "  BSSID: " LOG_SECRET("%s") "\n"
                "  Hostname: '%s'\n"
                "  Signal strength: %d dB %s\n"
                "  Channel: %" PRId32 "\n"
                "  Subnet: %s\n"
                "  Gateway: %s\n"
                "  DNS1: %s\n"
                "  DNS2: %s",
                wifi_ssid_to(ssid_buf), bssid_s, App.get_name().c_str(), rssi, LOG_STR_ARG(get_signal_bars(rssi)),
                get_wifi_channel(), wifi_subnet_mask_().str_to(subnet_buf), wifi_gateway_ip_().str_to(gateway_buf),
                wifi_dns_ip_(0).str_to(dns1_buf), wifi_dns_ip_(1).str_to(dns2_buf));
  // clang-format on
#ifdef ESPHOME_LOG_HAS_VERBOSE
  if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_bssid()) {
    ESP_LOGV(TAG, "  Priority: %d", this->get_sta_priority(config->get_bssid()));
  }
#endif
#ifdef USE_WIFI_11KV_SUPPORT
  ESP_LOGCONFIG(TAG,
                "  BTM: %s\n"
                "  RRM: %s",
                this->btm_ ? "enabled" : "disabled", this->rrm_ ? "enabled" : "disabled");
#endif
}

void WiFiComponent::enable() {
  if (this->state_ != WIFI_COMPONENT_STATE_DISABLED)
    return;

  ESP_LOGD(TAG, "Enabling");
  this->state_ = WIFI_COMPONENT_STATE_OFF;
#ifdef USE_ESP32
  // Idempotent — only allocates DMA buffers + netifs on the first call. After this,
  // start() can safely run.
  this->wifi_lazy_init_();
#endif
  this->start();
}

void WiFiComponent::disable() {
  if (this->state_ == WIFI_COMPONENT_STATE_DISABLED)
    return;

  ESP_LOGD(TAG, "Disabling");
  this->state_ = WIFI_COMPONENT_STATE_DISABLED;
  this->wifi_disconnect_();
  this->wifi_mode_(false, false);
}

bool WiFiComponent::is_disabled() { return this->state_ == WIFI_COMPONENT_STATE_DISABLED; }

void WiFiComponent::start_scanning() {
  this->action_started_ = millis();
  ESP_LOGD(TAG, "Starting scan");
  this->wifi_scan_start_(this->passive_scan_);
  this->state_ = WIFI_COMPONENT_STATE_STA_SCANNING;
}

/// Comparator for WiFi scan result sorting - determines which network should be tried first
/// Returns true if 'a' should be placed before 'b' in the sorted order (a is "better" than b)
///
/// Sorting logic (in priority order):
/// 1. Matching networks always ranked before non-matching networks
/// 2. For matching networks: Priority first (CRITICAL - tracks failure history)
/// 3. RSSI as tiebreaker for equal priority or non-matching networks
///
/// WHY PRIORITY MUST BE CHECKED FIRST:
/// The priority field tracks connection failure history via priority degradation:
/// - Initial priority: 0.0 (from config or default)
/// - Each connection failure: priority -= 1.0 (becomes -1.0, -2.0, -3.0, etc.)
/// - Failed BSSIDs sorted lower → naturally try different BSSID on next scan
///
/// This enables automatic BSSID cycling for various real-world failure scenarios:
/// - Crashed/hung AP (visible but not responding)
/// - Misconfigured mesh node (accepts auth but no DHCP/routing)
/// - Capacity limits (AP refuses new clients)
/// - Rogue AP (same SSID, wrong password or malicious)
/// - Intermittent hardware issues (flaky radio, overheating)
///
/// Example mesh network: 3 APs with same SSID "home", all at priority 0.0 initially
/// - Try strongest BSSID A (sorted by RSSI) → fails → priority A becomes -1.0
/// - Next scan: BSSID B and C (priority 0.0) sorted BEFORE A (priority -1.0)
/// - Try next strongest BSSID B → succeeds or fails and gets deprioritized
/// - System naturally cycles through all BSSIDs via priority degradation
/// - Eventually finds working AP or tries all options before restarting adapter
///
/// If we checked RSSI first (Bug in PR #9963):
/// - Same failed BSSID would keep being selected if it has strongest signal
/// - Device stuck connecting to crashed AP with -30dBm while working AP at -50dBm ignored
/// - Priority degradation would be useless
/// - Mesh networks would never recover from single AP failure
[[nodiscard]] inline static bool wifi_scan_result_is_better(const WiFiScanResult &a, const WiFiScanResult &b) {
  // Matching networks always come before non-matching
  if (a.get_matches() && !b.get_matches())
    return true;
  if (!a.get_matches() && b.get_matches())
    return false;

  // Both matching: check priority first (tracks connection failures via priority degradation)
  // Priority is decreased when a BSSID fails to connect, so lower priority = previously failed
  if (a.get_matches() && b.get_matches() && a.get_priority() != b.get_priority()) {
    return a.get_priority() > b.get_priority();
  }

  // Use RSSI as tiebreaker (for equal-priority matching networks or all non-matching networks)
  return a.get_rssi() > b.get_rssi();
}

// Helper function for insertion sort of WiFi scan results
// Using insertion sort instead of std::stable_sort saves flash memory
// by avoiding template instantiations (std::rotate, std::stable_sort, lambdas)
// IMPORTANT: This sort is stable (preserves relative order of equal elements)
//
// Uses raw memcpy instead of copy assignment to avoid CompactString's
// destructor/constructor overhead (heap delete[]/new[] for long SSIDs).
// Copy assignment calls ~CompactString() then placement-new for every shift,
// which means delete[]/new[] per shift for heap-allocated SSIDs. With 70+
// networks (e.g., captive portal showing full scan results), this caused
// event loop blocking from hundreds of heap operations in a tight loop.
//
// This is safe because we're permuting elements within the same array —
// each slot is overwritten exactly once, so no ownership duplication occurs.
// All members of WiFiScanResult are either trivially copyable (bssid, channel,
// rssi, priority, flags) or CompactString, which stores either inline data or
// a heap pointer — never a self-referential pointer (unlike std::string's SSO
// on some implementations). This was not possible before PR#13472 replaced
// std::string with CompactString, since std::string's internal layout is
// implementation-defined and may use self-referential pointers.
//
// TODO: If C++ standardizes std::trivially_relocatable, add the assertion for
// WiFiScanResult/CompactString here to formally express the memcpy safety guarantee.
template<typename VectorType> static void insertion_sort_scan_results(VectorType &results) {
  // memcpy-based sort requires no self-referential pointers or virtual dispatch.
  // These static_asserts guard the assumptions. If any fire, the memcpy sort
  // must be reviewed for safety before updating the expected values.
  //
  // No vtable pointers (memcpy would corrupt vptr)
  static_assert(!std::is_polymorphic<WiFiScanResult>::value, "WiFiScanResult must not have vtable");
  static_assert(!std::is_polymorphic<CompactString>::value, "CompactString must not have vtable");
  // Standard layout ensures predictable memory layout with no virtual bases
  // and no mixed-access-specifier reordering
  static_assert(std::is_standard_layout<WiFiScanResult>::value, "WiFiScanResult must be standard layout");
  static_assert(std::is_standard_layout<CompactString>::value, "CompactString must be standard layout");
  // Size checks catch added/removed fields that may need safety review
  static_assert(sizeof(WiFiScanResult) == 32, "WiFiScanResult size changed - verify memcpy sort is still safe");
  static_assert(sizeof(CompactString) == 20, "CompactString size changed - verify memcpy sort is still safe");
  // Alignment must match for reinterpret_cast of key_buf to be valid
  static_assert(alignof(WiFiScanResult) <= alignof(std::max_align_t), "WiFiScanResult alignment exceeds max_align_t");
  const size_t size = results.size();
  constexpr size_t elem_size = sizeof(WiFiScanResult);
  // Suppress warnings for intentional memcpy on non-trivially-copyable type.
  // Safety is guaranteed by the static_asserts above and the permutation invariant.
  // NOLINTNEXTLINE(bugprone-undefined-memory-manipulation)
  auto *memcpy_fn = &memcpy;
  for (size_t i = 1; i < size; i++) {
    alignas(WiFiScanResult) uint8_t key_buf[elem_size];
    memcpy_fn(key_buf, &results[i], elem_size);
    const auto &key = *reinterpret_cast<const WiFiScanResult *>(key_buf);
    int32_t j = i - 1;

    // Move elements that are worse than key to the right
    // For stability, we only move if key is strictly better than results[j]
    while (j >= 0 && wifi_scan_result_is_better(key, results[j])) {
      memcpy_fn(&results[j + 1], &results[j], elem_size);
      j--;
    }
    memcpy_fn(&results[j + 1], key_buf, elem_size);
  }
}

// Helper function to log matching scan results - marked noinline to prevent re-inlining into loop
//
// IMPORTANT: This function deliberately uses a SINGLE log call to minimize blocking.
// In environments with many matching networks (e.g., 18+ mesh APs), multiple log calls
// per network would block the main loop for an unacceptable duration. Each log call
// has overhead from UART transmission, so combining INFO+DEBUG into one line halves
// the blocking time. Do NOT split this into separate ESP_LOGI/ESP_LOGD calls.
__attribute__((noinline)) static void log_scan_result(const WiFiScanResult &res) {
  char bssid_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
  auto bssid = res.get_bssid();
  format_mac_addr_upper(bssid.data(), bssid_s);

#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_DEBUG
  // Single combined log line with all details when DEBUG enabled
  ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s Ch:%2u %3ddB P:%d", res.get_ssid().c_str(),
           res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s,
           LOG_STR_ARG(get_signal_bars(res.get_rssi())), res.get_channel(), res.get_rssi(), res.get_priority());
#else
  ESP_LOGI(TAG, "- '%s' %s" LOG_SECRET("(%s) ") "%s", res.get_ssid().c_str(),
           res.get_is_hidden() ? LOG_STR_LITERAL("(HIDDEN) ") : LOG_STR_LITERAL(""), bssid_s,
           LOG_STR_ARG(get_signal_bars(res.get_rssi())));
#endif
}

void WiFiComponent::check_scanning_finished() {
  if (!this->scan_done_) {
    if (millis() - this->action_started_ > WIFI_SCAN_TIMEOUT_MS) {
      ESP_LOGE(TAG, "Scan timeout");
      this->retry_connect();
    }
    return;
  }
  this->scan_done_ = false;
  this->has_completed_scan_after_captive_portal_start_ =
      true;  // Track that we've done a scan since captive portal started
  this->retry_hidden_mode_ = RetryHiddenMode::SCAN_BASED;

  if (this->scan_result_.empty()) {
    ESP_LOGW(TAG, "No networks found");
    this->retry_connect();
    return;
  }

  ESP_LOGD(TAG, "Found networks:");
  {
    ScanResultsLock lock(this);
    for (auto &res : this->scan_result_) {
      for (auto &ap : this->sta_) {
        if (res.matches(ap)) {
          res.set_matches(true);
          // Cache priority lookup - do single search instead of 2 separate searches
          const bssid_t &bssid = res.get_bssid();
          if (!this->has_sta_priority(bssid)) {
            this->set_sta_priority(bssid, ap.get_priority());
          }
          res.set_priority(this->get_sta_priority(bssid));
          break;
        }
      }
    }

    // Sort scan results using insertion sort for better memory efficiency
    insertion_sort_scan_results(this->scan_result_);
  }

  // Log matching networks (non-matching already logged at VERBOSE in scan callback)
  for (auto &res : this->scan_result_) {
    if (res.get_matches()) {
      log_scan_result(res);
    }
  }

  // SYNCHRONIZATION POINT: Establish link between scan_result_[0] and selected_sta_index_
  // After sorting, scan_result_[0] contains the best network. Now find which sta_[i] config
  // matches that network and record it in selected_sta_index_. This keeps the two indices
  // synchronized so build_params_for_current_phase_() can safely use both to build connection parameters.
  const WiFiScanResult &scan_res = this->scan_result_[0];
  bool found_match = false;
  if (scan_res.get_matches()) {
    for (size_t i = 0; i < this->sta_.size(); i++) {
      if (scan_res.matches(this->sta_[i])) {
        // Safe cast: sta_.size() limited to MAX_WIFI_NETWORKS (127) in __init__.py validation
        // No overflow check needed - YAML validation prevents >127 networks
        this->selected_sta_index_ = static_cast<int8_t>(i);  // Links scan_result_[0] with sta_[i]
        found_match = true;
        break;
      }
    }
  }

  if (!found_match) {
    ESP_LOGW(TAG, "No matching network found");
    // No scan results matched our configured networks - transition directly to hidden mode
    // Don't call retry_connect() since we never attempted a connection (no BSSID to penalize)
    this->transition_to_phase_(WiFiRetryPhase::RETRY_HIDDEN);
    // If no hidden networks to try, skip connection attempt (will be handled on next loop)
    if (this->selected_sta_index_ == -1) {
      return;
    }
    // Now start connection attempt in hidden mode
  } else if (this->transition_to_phase_(WiFiRetryPhase::SCAN_CONNECTING)) {
    return;  // scan started, wait for next loop iteration
  }

  yield();

  WiFiAP params = this->build_params_for_current_phase_();
  // Ensure we're in SCAN_CONNECTING phase when connecting with scan results
  // (needed when scan was started directly without transition_to_phase_, e.g., initial scan)
  this->start_connecting(params);
}

void WiFiComponent::dump_config() {
  char mac_s[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
  ESP_LOGCONFIG(TAG,
                "WiFi:\n"
                "  Local MAC: %s\n"
                "  Connected: %s",
                get_mac_address_pretty_into_buffer(mac_s), YESNO(this->is_connected()));
  if (this->is_disabled()) {
    ESP_LOGCONFIG(TAG, "  Disabled");
    return;
  }
#if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G)
  const char *band_mode_s;
  switch (this->band_mode_) {
    case WIFI_BAND_MODE_2G_ONLY:
      band_mode_s = "2.4GHz";
      break;
    case WIFI_BAND_MODE_5G_ONLY:
      band_mode_s = "5GHz";
      break;
    case WIFI_BAND_MODE_AUTO:
    default:
      band_mode_s = "Auto";
      break;
  }
  ESP_LOGCONFIG(TAG, "  Band Mode: %s", band_mode_s);
#endif
#ifdef USE_WIFI_PHY_MODE
  ESP_LOGCONFIG(TAG, "  PHY Mode: %s", LOG_STR_ARG(phy_mode_to_log_string(this->phy_mode_)));
#endif
  if (this->is_connected()) {
    this->print_connect_params_();
  }
}

void WiFiComponent::check_connecting_finished(uint32_t now) {
  auto status = this->wifi_sta_connect_status_();

  if (status == WiFiSTAConnectStatus::CONNECTED) {
    char ssid_buf[SSID_BUFFER_SIZE];
    if (wifi_ssid_to(ssid_buf)[0] == '\0') {
      ESP_LOGW(TAG, "Connection incomplete");
      this->retry_connect();
      return;
    }

    ESP_LOGI(TAG, "Connected");
    // Warn if we had to retry with hidden network mode for a network that's not marked hidden
    // Only warn if we actually connected without scan data (SSID only), not if scan succeeded on retry
    if (const WiFiAP *config = this->get_selected_sta_(); this->retry_phase_ == WiFiRetryPhase::RETRY_HIDDEN &&
                                                          config && !config->get_hidden() &&
                                                          this->scan_result_.empty()) {
      ESP_LOGW(TAG, LOG_SECRET("'%s'") " should be marked hidden", config->ssid_.c_str());
    }
    // Reset to initial phase on successful connection (don't log transition, just reset state)
    this->retry_phase_ = WiFiRetryPhase::INITIAL_CONNECT;
    this->num_retried_ = 0;
    if (this->has_ap()) {
#ifdef USE_CAPTIVE_PORTAL
      if (this->is_captive_portal_active_()) {
        captive_portal::global_captive_portal->end();
      }
#endif
      ESP_LOGD(TAG, "Disabling AP");
      this->wifi_mode_({}, false);
    }
#ifdef USE_IMPROV
    if (this->is_esp32_improv_active_()) {
      esp32_improv::global_improv_component->stop();
    }
#endif

    this->state_ = WIFI_COMPONENT_STATE_STA_CONNECTED;
    // Refresh is_connected() cache; loop()'s refresh ran before this transition.
    this->update_connected_state_();
    this->num_retried_ = 0;
    this->print_connect_params_();

    // Reset roaming state on successful connection
    this->roaming_last_check_ = now;
    // Only preserve attempts if reconnecting after a failed roam attempt
    // This prevents ping-pong between APs when a roam target is unreachable
    if (this->roaming_state_ == RoamingState::CONNECTING) {
      // Successful roam to better AP on first try - reset attempts so we can roam again later
      ESP_LOGD(TAG, "Roam successful");
      this->roaming_attempts_ = 0;
    } else if (this->roaming_state_ == RoamingState::RECONNECTING) {
      // Check if we ended up on the roam target despite needing a retry
      // (e.g., first connect failed but scan-based retry found and connected to the same better AP)
      bssid_t current_bssid = this->wifi_bssid();
      if (this->roaming_target_bssid_ != bssid_t{} && current_bssid == this->roaming_target_bssid_) {
        char bssid_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];
        format_mac_addr_upper(current_bssid.data(), bssid_buf);
        ESP_LOGD(TAG, "Roam successful (via retry, attempt %u/%u) to %s", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS,
                 bssid_buf);
        this->roaming_attempts_ = 0;
      } else if (this->roaming_target_bssid_ != bssid_t{}) {
        // Failed roam to specific target, reconnected to different AP - keep attempts to prevent ping-pong
        ESP_LOGD(TAG, "Reconnected after failed roam (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
      } else {
        // Reconnected after scan-induced disconnect (no roam target) - keep attempts
        ESP_LOGD(TAG, "Reconnected after roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
      }
    } else {
      // Normal connection (boot, credentials changed, etc.)
      this->roaming_attempts_ = 0;
    }
    this->roaming_state_ = RoamingState::IDLE;
    this->roaming_target_bssid_ = {};
    this->roaming_scan_end_ = 0;

    // Clear all priority penalties - the next reconnect will happen when an AP disconnects,
    // which means the landscape has likely changed and previous tracked failures are stale
    this->clear_all_bssid_priorities_();

#ifdef USE_WIFI_FAST_CONNECT
    this->save_fast_connect_settings_();
#endif

    this->release_scan_results_();

#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
    // Notify listeners now that state machine has reached STA_CONNECTED
    // This ensures wifi.connected condition returns true in listener automations
    this->notify_connect_state_listeners_();
#endif

#if defined(USE_ESP8266) && defined(USE_WIFI_IP_STATE_LISTENERS) && defined(USE_WIFI_MANUAL_IP)
    // On ESP8266, GOT_IP event may not fire for static IP configurations,
    // so notify IP state listeners here as a fallback.
    if (const WiFiAP *config = this->get_selected_sta_(); config && config->get_manual_ip().has_value()) {
      this->notify_ip_state_listeners_();
    }
#endif

    return;
  }

  if (now - this->action_started_ > WIFI_CONNECT_TIMEOUT_MS) {
    ESP_LOGW(TAG, "Connection timeout, aborting connection attempt");
    this->wifi_disconnect_();
    this->retry_connect();
    return;
  }

  if (this->error_from_callback_) {
    // ESP8266: logging done in callback, listeners deferred via pending_.disconnect
    // Other platforms: just log generic failure message
#ifndef USE_ESP8266
    ESP_LOGW(TAG, "Connecting to network failed (callback)");
#endif
    this->retry_connect();
    return;
  }

  if (status == WiFiSTAConnectStatus::CONNECTING) {
    return;
  }

  if (status == WiFiSTAConnectStatus::ERROR_NETWORK_NOT_FOUND) {
    ESP_LOGW(TAG, "Network no longer found");
    this->retry_connect();
    return;
  }

  if (status == WiFiSTAConnectStatus::ERROR_CONNECT_FAILED) {
    ESP_LOGW(TAG, "Connecting to network failed");
    this->retry_connect();
    return;
  }

  ESP_LOGW(TAG, "Unknown connection status %d", (int) status);
  this->retry_connect();
}

/// Determine the next retry phase based on current state and failure conditions
/// This function examines the current retry phase, number of retries, and failure reasons
/// to decide what phase to move to next. It does not modify any state - it only returns
/// the recommended next phase.
///
/// @return The next WiFiRetryPhase to transition to (may be same as current phase if should retry)
WiFiRetryPhase WiFiComponent::determine_next_phase_() {
  switch (this->retry_phase_) {
    case WiFiRetryPhase::INITIAL_CONNECT:
#ifdef USE_WIFI_FAST_CONNECT
    case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS:
      // INITIAL_CONNECT and FAST_CONNECT_CYCLING_APS: no retries, try next AP or fall back to scan
      if (this->selected_sta_index_ < static_cast<int8_t>(this->sta_.size()) - 1) {
        return WiFiRetryPhase::FAST_CONNECT_CYCLING_APS;  // Move to next AP
      }
#endif
      // Check if we should try explicit hidden networks before scanning
      // This handles reconnection after connection loss where first network is hidden
      if (!this->sta_.empty() && this->sta_[0].get_hidden()) {
        return WiFiRetryPhase::EXPLICIT_HIDDEN;
      }
      // No more APs to try, fall back to scan
      return WiFiRetryPhase::SCAN_CONNECTING;

    case WiFiRetryPhase::EXPLICIT_HIDDEN: {
      // Try all explicitly hidden networks before scanning
      if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) {
        return WiFiRetryPhase::EXPLICIT_HIDDEN;  // Keep retrying same SSID
      }

      // Exhausted retries on current SSID - check for more explicitly hidden networks
      // Stop when we reach a visible network (proceed to scanning)
      size_t next_index = this->selected_sta_index_ + 1;
      if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) {
        // Found another explicitly hidden network
        return WiFiRetryPhase::EXPLICIT_HIDDEN;
      }

      // No more consecutive explicitly hidden networks
      // If ALL networks are hidden, skip scanning and go directly to restart
      if (this->find_first_non_hidden_index_() < 0) {
        return WiFiRetryPhase::RESTARTING_ADAPTER;
      }
      // Otherwise proceed to scanning for non-hidden networks
      return WiFiRetryPhase::SCAN_CONNECTING;
    }

    case WiFiRetryPhase::SCAN_CONNECTING:
      // If scan found no networks or no matching networks, skip to hidden network mode
      if (this->scan_result_.empty() || !this->scan_result_[0].get_matches()) {
        return WiFiRetryPhase::RETRY_HIDDEN;
      }

      if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_BSSID) {
        return WiFiRetryPhase::SCAN_CONNECTING;  // Keep retrying same BSSID
      }

      // Exhausted retries on current BSSID (scan_result_[0])
      // Its priority has been decreased, so on next scan it will be sorted lower
      // and we'll try the next best BSSID.
      // Check if there are any potentially hidden networks to try
      if (this->find_next_hidden_sta_(-1) >= 0) {
        return WiFiRetryPhase::RETRY_HIDDEN;  // Found hidden networks to try
      }
      // No hidden networks - always go through RESTARTING_ADAPTER phase
      // This ensures num_retried_ gets reset and a fresh scan is triggered
      // The actual adapter restart will be skipped if captive portal/improv is active
      return WiFiRetryPhase::RESTARTING_ADAPTER;

    case WiFiRetryPhase::RETRY_HIDDEN:
      // If no hidden SSIDs to try (selected_sta_index_ == -1), skip directly to rescan
      if (this->selected_sta_index_ >= 0) {
        if (this->num_retried_ + 1 < WIFI_RETRY_COUNT_PER_SSID) {
          return WiFiRetryPhase::RETRY_HIDDEN;  // Keep retrying same SSID
        }

        // Exhausted retries on current SSID - check if there are more potentially hidden SSIDs to try
        if (this->selected_sta_index_ < static_cast<int8_t>(this->sta_.size()) - 1) {
          // Check if find_next_hidden_sta_() would actually find another hidden SSID
          // as it might have been seen in the scan results and we want to skip those
          // otherwise we will get stuck in RETRY_HIDDEN phase
          if (this->find_next_hidden_sta_(this->selected_sta_index_) != -1) {
            // More hidden SSIDs available - stay in RETRY_HIDDEN, advance will happen in retry_connect()
            return WiFiRetryPhase::RETRY_HIDDEN;
          }
        }
      }
      // Exhausted all potentially hidden SSIDs - always go through RESTARTING_ADAPTER
      // This ensures num_retried_ gets reset and a fresh scan is triggered
      // The actual adapter restart will be skipped if captive portal/improv is active
      return WiFiRetryPhase::RESTARTING_ADAPTER;

    case WiFiRetryPhase::RESTARTING_ADAPTER:
      // After restart, go back to explicit hidden if we went through it initially
      if (this->went_through_explicit_hidden_phase_()) {
        return WiFiRetryPhase::EXPLICIT_HIDDEN;
      }
      // Skip scanning when captive portal/improv is active to avoid disrupting AP,
      // BUT only if we've already completed at least one scan AFTER the portal started.
      // When captive portal first starts, scan results may be filtered/stale, so we need
      // to do one full scan to populate available networks for the captive portal UI.
      //
      // WHY SCANNING DISRUPTS AP MODE:
      // WiFi scanning requires the radio to leave the AP's channel and hop through
      // other channels to listen for beacons. During this time (even for passive scans),
      // the AP cannot service connected clients - they experience disconnections or
      // timeouts. On ESP32, even passive scans cause brief but noticeable disruptions
      // that break captive portal HTTP requests and DNS lookups.
      //
      // BLIND RETRY MODE:
      // When captive portal/improv is active, we use RETRY_HIDDEN as a "try all networks
      // blindly" mode. Since retry_hidden_mode_ is set to BLIND_RETRY (in RESTARTING_ADAPTER
      // transition), find_next_hidden_sta_() will treat ALL configured networks as
      // candidates, cycling through them without requiring scan results.
      //
      // This allows users to configure WiFi via captive portal while the device keeps
      // attempting to connect to all configured networks in sequence.
      // Captive portal needs scan results to show available networks.
      // If captive portal is active, only skip scanning if we've done a scan after it started.
      // If only improv is active (no captive portal), skip scanning since improv doesn't need results.
      if (this->is_captive_portal_active_()) {
        if (this->has_completed_scan_after_captive_portal_start_) {
          return WiFiRetryPhase::RETRY_HIDDEN;
        }
        // Need to scan for captive portal
      } else if (this->is_esp32_improv_active_()) {
        // Improv doesn't need scan results
        return WiFiRetryPhase::RETRY_HIDDEN;
      }
      return WiFiRetryPhase::SCAN_CONNECTING;
  }

  // Should never reach here
  return WiFiRetryPhase::SCAN_CONNECTING;
}

/// Transition from current retry phase to a new phase with logging and phase-specific setup
/// This function handles the actual state change, including:
/// - Logging the phase transition
/// - Resetting the retry counter
/// - Performing phase-specific initialization (e.g., advancing AP index, starting scans)
///
/// @param new_phase The phase we're transitioning TO
/// @return true if connection attempt should be skipped (scan started or no networks to try)
///         false if caller can proceed with connection attempt
bool WiFiComponent::transition_to_phase_(WiFiRetryPhase new_phase) {
  WiFiRetryPhase old_phase = this->retry_phase_;

  // No-op if staying in same phase
  if (old_phase == new_phase) {
    return false;
  }

  ESP_LOGD(TAG, "Retry phase: %s → %s", LOG_STR_ARG(retry_phase_to_log_string(old_phase)),
           LOG_STR_ARG(retry_phase_to_log_string(new_phase)));

  this->retry_phase_ = new_phase;
  this->num_retried_ = 0;  // Reset retry counter on phase change

  // Phase-specific setup
  switch (new_phase) {
#ifdef USE_WIFI_FAST_CONNECT
    case WiFiRetryPhase::FAST_CONNECT_CYCLING_APS: {
      // Move to next configured AP - clear old scan data so new AP is tried with config only
      this->selected_sta_index_++;
      ScanResultsLock lock(this);
      this->scan_result_.clear();
      break;
    }
#endif

    case WiFiRetryPhase::EXPLICIT_HIDDEN:
      // Starting explicit hidden phase - reset to first network
      this->selected_sta_index_ = 0;
      break;

    case WiFiRetryPhase::SCAN_CONNECTING:
      // Transitioning to scan-based connection
#ifdef USE_WIFI_FAST_CONNECT
      if (old_phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS) {
        ESP_LOGI(TAG, "Fast connect exhausted, falling back to scan");
      }
#endif
      // Trigger scan if we don't have scan results OR if transitioning from phases that need fresh scan
      if (this->scan_result_.empty() || old_phase == WiFiRetryPhase::EXPLICIT_HIDDEN ||
          old_phase == WiFiRetryPhase::RETRY_HIDDEN || old_phase == WiFiRetryPhase::RESTARTING_ADAPTER) {
        this->selected_sta_index_ = -1;  // Will be set after scan completes
        this->start_scanning();
        return true;  // Started scan, wait for completion
      }
      // Already have scan results - selected_sta_index_ should already be synchronized
      // (set in check_scanning_finished() when scan completed)
      // No need to reset it here
      break;

    case WiFiRetryPhase::RETRY_HIDDEN:
      // Always reset to first candidate when entering this phase.
      // This phase can be entered from:
      // - SCAN_CONNECTING: normal flow, find_next_hidden_sta_() skips networks visible in scan
      // - RESTARTING_ADAPTER: captive portal active, find_next_hidden_sta_() tries ALL networks
      //
      // The retry_hidden_mode_ controls the behavior:
      // - SCAN_BASED: scan_result_ is checked, visible networks are skipped
      // - BLIND_RETRY: scan_result_ is ignored, all networks become candidates
      // We don't clear scan_result_ here - the mode controls whether it's consulted.
      this->selected_sta_index_ = this->find_next_hidden_sta_(-1);

      if (this->selected_sta_index_ == -1) {
        ESP_LOGD(TAG, "All SSIDs visible or already tried, skipping hidden mode");
      }
      break;

    case WiFiRetryPhase::RESTARTING_ADAPTER:
      // Skip actual adapter restart if captive portal/improv is active
      // This allows state machine to reset num_retried_ and trigger fresh scan
      // without disrupting the captive portal/improv connection
      if (!this->is_captive_portal_active_() && !this->is_esp32_improv_active_()) {
        this->restart_adapter();
      } else {
        // Even when skipping full restart, disconnect to clear driver state
        // Without this, platforms like LibreTiny may think we're still connecting
        this->wifi_disconnect_();
      }
      // Clear scan flag - we're starting a new retry cycle
      // This is critical for captive portal/improv flow: when determine_next_phase_()
      // returns RETRY_HIDDEN (because scanning is skipped), find_next_hidden_sta_()
      // will see BLIND_RETRY mode and treat ALL networks as candidates,
      // effectively cycling through all configured networks without scan results.
      this->retry_hidden_mode_ = RetryHiddenMode::BLIND_RETRY;
      // Always enter cooldown after restart (or skip-restart) to allow stabilization
      // Use extended cooldown when AP is active to avoid constant scanning that blocks DNS
      this->state_ = WIFI_COMPONENT_STATE_COOLDOWN;
      this->action_started_ = millis();
      // Return true to indicate we should wait (go to COOLDOWN) instead of immediately connecting
      return true;

    default:
      break;
  }

  return false;  // Did not start scan, can proceed with connection
}

void WiFiComponent::clear_all_bssid_priorities_() {
  if (!this->sta_priorities_.empty()) {
    decltype(this->sta_priorities_)().swap(this->sta_priorities_);
  }
}

/// Clear BSSID priority tracking if all priorities are at minimum (saves memory)
/// At minimum priority, all BSSIDs are equally bad, so priority tracking is useless
/// Called after failed connection attempts
void WiFiComponent::clear_priorities_if_all_min_() {
  if (this->sta_priorities_.empty()) {
    return;
  }

  int8_t first_priority = this->sta_priorities_[0].priority;

  // Only clear if all priorities have been decremented to the minimum value
  // At this point, all BSSIDs have been equally penalized and priority info is useless
  if (first_priority != std::numeric_limits<int8_t>::min()) {
    return;
  }

  for (const auto &pri : this->sta_priorities_) {
    if (pri.priority != first_priority) {
      return;  // Not all same, nothing to do
    }
  }

  // All priorities are at minimum - clear the vector to save memory and reset
  ESP_LOGD(TAG, "Clearing BSSID priorities (all at minimum)");
  this->clear_all_bssid_priorities_();
}

/// Log failed connection attempt and decrease BSSID priority to avoid repeated failures
/// This function identifies which BSSID was attempted (from scan results or config),
/// decreases its priority by 1.0 to discourage future attempts, and logs the change.
///
/// The priority degradation system ensures that failed BSSIDs are automatically sorted
/// lower in subsequent scans, naturally cycling through different APs without explicit
/// BSSID tracking within a scan cycle.
///
/// Priority sources:
/// - SCAN_CONNECTING phase: Uses BSSID from scan_result_[0] (best match after sorting)
/// - Other phases: Uses BSSID from config if explicitly specified by user or fast_connect
///
/// If no BSSID is available (SSID-only connection), priority adjustment is skipped.
///
/// IMPORTANT: Priority is only decreased on the LAST attempt for a BSSID in SCAN_CONNECTING phase.
/// This prevents false positives from transient WiFi stack state issues after scanning.
/// Single failures don't necessarily mean the AP is bad - two genuine failures provide
/// higher confidence before degrading priority and skipping the BSSID in future scans.
void WiFiComponent::log_and_adjust_priority_for_failed_connect_() {
  // Determine which BSSID we tried to connect to
  optional<bssid_t> failed_bssid;

  if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) {
    // Scan-based phase: always use best result (index 0)
    failed_bssid = this->scan_result_[0].get_bssid();
  } else if (const WiFiAP *config = this->get_selected_sta_(); config && config->has_bssid()) {
    // Config has specific BSSID (fast_connect or user-specified)
    failed_bssid = config->get_bssid();
  }

  if (!failed_bssid.has_value()) {
    return;  // No BSSID to penalize
  }

  // Get SSID for logging (use pointer to avoid copy)
  const char *ssid = nullptr;
  if (this->retry_phase_ == WiFiRetryPhase::SCAN_CONNECTING && !this->scan_result_.empty()) {
    ssid = this->scan_result_[0].ssid_.c_str();
  } else if (const WiFiAP *config = this->get_selected_sta_()) {
    ssid = config->ssid_.c_str();
  }

  // Only decrease priority on the last attempt for this phase
  // This prevents false positives from transient WiFi stack issues
  uint8_t max_retries = get_max_retries_for_phase(this->retry_phase_);
  bool is_last_attempt = (this->num_retried_ + 1 >= max_retries);

  // Decrease priority only on last attempt to avoid false positives from transient failures
  int8_t old_priority = this->get_sta_priority(failed_bssid.value());
  int8_t new_priority = old_priority;

  if (is_last_attempt) {
    // Decrease priority, but clamp to int8_t::min to prevent overflow
    new_priority =
        (old_priority > std::numeric_limits<int8_t>::min()) ? (old_priority - 1) : std::numeric_limits<int8_t>::min();
    this->set_sta_priority(failed_bssid.value(), new_priority);
  }
  char bssid_s[18];
  format_mac_addr_upper(failed_bssid.value().data(), bssid_s);
  ESP_LOGD(TAG, "Failed " LOG_SECRET("'%s'") " " LOG_SECRET("(%s)") ", priority %d → %d", ssid != nullptr ? ssid : "",
           bssid_s, old_priority, new_priority);

  // After adjusting priority, check if all priorities are now at minimum
  // If so, clear the vector to save memory and reset for fresh start
  this->clear_priorities_if_all_min_();
}

/// Handle target advancement or retry counter increment when staying in the same phase
/// This function is called when a connection attempt fails and determine_next_phase_() indicates
/// we should stay in the current phase. It decides whether to:
/// - Advance to the next target (AP in fast_connect, SSID in hidden mode)
/// - Or increment the retry counter to try the same target again
///
/// Phase-specific behavior:
/// - FAST_CONNECT_CYCLING_APS: Always advance to next AP (no retries per AP)
/// - RETRY_HIDDEN: Advance to next SSID after exhausting retries on current SSID
/// - Other phases: Increment retry counter (will retry same target)
void WiFiComponent::advance_to_next_target_or_increment_retry_() {
  WiFiRetryPhase current_phase = this->retry_phase_;

  // Check if we need to advance to next AP/SSID within the same phase
#ifdef USE_WIFI_FAST_CONNECT
  if (current_phase == WiFiRetryPhase::FAST_CONNECT_CYCLING_APS) {
    // Fast connect: always advance to next AP (no retries per AP)
    this->selected_sta_index_++;
    this->num_retried_ = 0;
    ESP_LOGD(TAG, "Next AP in %s", LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
    return;
  }
#endif

  if (current_phase == WiFiRetryPhase::EXPLICIT_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) {
    // Explicit hidden: exhausted retries on current SSID, find next explicitly hidden network
    // Stop when we reach a visible network (proceed to scanning)
    size_t next_index = this->selected_sta_index_ + 1;
    if (next_index < this->sta_.size() && this->sta_[next_index].get_hidden()) {
      this->selected_sta_index_ = static_cast<int8_t>(next_index);
      this->num_retried_ = 0;
      ESP_LOGD(TAG, "Next explicit hidden network at index %d", static_cast<int>(next_index));
      return;
    }
    // No more consecutive explicit hidden networks found - fall through to trigger phase change
  }

  if (current_phase == WiFiRetryPhase::RETRY_HIDDEN && this->num_retried_ + 1 >= WIFI_RETRY_COUNT_PER_SSID) {
    // Hidden mode: exhausted retries on current SSID, find next potentially hidden SSID
    // If first network is marked hidden, we went through EXPLICIT_HIDDEN phase
    // In that case, skip networks marked hidden:true (already tried)
    // Otherwise, include them (they haven't been tried yet)
    int8_t next_index = this->find_next_hidden_sta_(this->selected_sta_index_);
    if (next_index != -1) {
      // Found another potentially hidden SSID
      this->selected_sta_index_ = next_index;
      this->num_retried_ = 0;
      return;
    }
    // No more potentially hidden SSIDs - set selected_sta_index_ to -1 to trigger phase change
    // This ensures determine_next_phase_() will skip the RETRY_HIDDEN logic and transition out
    this->selected_sta_index_ = -1;
    // Return early - phase change will happen on next wifi_loop() iteration
    return;
  }

  // Don't increment retry counter if we're in a scan phase with no valid targets
  if (this->needs_scan_results_()) {
    return;
  }

  // Increment retry counter to try the same target again
  this->num_retried_++;
  ESP_LOGD(TAG, "Retry attempt %u/%u in phase %s", this->num_retried_ + 1,
           get_max_retries_for_phase(this->retry_phase_), LOG_STR_ARG(retry_phase_to_log_string(this->retry_phase_)));
}

void WiFiComponent::retry_connect() {
  // Handle roaming state transitions - preserve attempts counter to prevent ping-pong
  // to unreachable APs after ROAMING_MAX_ATTEMPTS failures
  if (this->roaming_state_ == RoamingState::CONNECTING) {
    // Roam connection failed - transition to reconnecting
    ESP_LOGD(TAG, "Roam failed, reconnecting (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
    this->roaming_state_ = RoamingState::RECONNECTING;
  } else if (this->roaming_state_ == RoamingState::SCANNING) {
    // Disconnected during roam scan - transition to RECONNECTING so the attempts
    // counter is preserved when reconnection succeeds (IDLE would reset it)
    ESP_LOGD(TAG, "Disconnected during roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
    this->roaming_state_ = RoamingState::RECONNECTING;
  } else if (this->roaming_state_ == RoamingState::IDLE) {
    // Check if a roaming scan recently completed - on ESP8266, going off-channel
    // during scan can cause a delayed Beacon Timeout 8-20 seconds after scan finishes.
    // Transition to RECONNECTING so the attempts counter is preserved on reconnect.
    if (this->roaming_scan_end_ != 0 && millis() - this->roaming_scan_end_ < ROAMING_SCAN_GRACE_PERIOD) {
      ESP_LOGD(TAG, "Disconnect after roam scan (attempt %u/%u)", this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
      this->roaming_state_ = RoamingState::RECONNECTING;
    } else {
      // Not a roaming-triggered reconnect, reset state
      this->clear_roaming_state_();
    }
  }
  // RECONNECTING: keep state and counter, still trying to reconnect

  this->log_and_adjust_priority_for_failed_connect_();

  // Determine next retry phase based on current state
  WiFiRetryPhase current_phase = this->retry_phase_;
  WiFiRetryPhase next_phase = this->determine_next_phase_();

  // Handle phase transitions (transition_to_phase_ handles same-phase no-op internally)
  if (this->transition_to_phase_(next_phase)) {
    return;  // Scan started or adapter restarted (which sets its own state)
  }

  if (next_phase == current_phase) {
    this->advance_to_next_target_or_increment_retry_();
  }

  yield();
  // Check if we have a valid target before building params
  // After exhausting all networks in a phase, selected_sta_index_ may be -1
  // In that case, skip connection and let next wifi_loop() handle phase transition
  if (this->selected_sta_index_ >= 0) {
    WiFiAP params = this->build_params_for_current_phase_();
    this->start_connecting(params);
  }
}

void WiFiComponent::set_reboot_timeout(uint32_t reboot_timeout) { this->reboot_timeout_ = reboot_timeout; }
void WiFiComponent::set_power_save_mode(WiFiPowerSaveMode power_save) {
  this->power_save_ = power_save;
#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
  this->configured_power_save_ = power_save;
#endif
}

void WiFiComponent::set_passive_scan(bool passive) { this->passive_scan_ = passive; }

bool WiFiComponent::is_captive_portal_active_() {
#ifdef USE_CAPTIVE_PORTAL
  return captive_portal::global_captive_portal != nullptr && captive_portal::global_captive_portal->is_active();
#else
  return false;
#endif
}
bool WiFiComponent::is_esp32_improv_active_() {
#ifdef USE_IMPROV
  return esp32_improv::global_improv_component != nullptr && esp32_improv::global_improv_component->is_active();
#else
  return false;
#endif
}

#if defined(USE_ESP32) && defined(USE_WIFI_RUNTIME_POWER_SAVE)
bool WiFiComponent::request_high_performance() {
  // Already configured for high performance - request satisfied
  if (this->configured_power_save_ == WIFI_POWER_SAVE_NONE) {
    return true;
  }

  // Semaphore initialization failed
  if (this->high_performance_semaphore_ == nullptr) {
    return false;
  }

  // Give the semaphore (non-blocking). This increments the count.
  bool success = xSemaphoreGive(this->high_performance_semaphore_) == pdTRUE;

  // Wake the main loop so the switch to high-performance mode is applied on the
  // next tick instead of waiting up to loop_interval.
  if (success) {
    App.wake_loop_threadsafe();
  }

  return success;
}

bool WiFiComponent::release_high_performance() {
  // Already configured for high performance - nothing to release
  if (this->configured_power_save_ == WIFI_POWER_SAVE_NONE) {
    return true;
  }

  // Semaphore initialization failed
  if (this->high_performance_semaphore_ == nullptr) {
    return false;
  }

  // Take the semaphore (non-blocking). This decrements the count.
  return xSemaphoreTake(this->high_performance_semaphore_, 0) == pdTRUE;
}
#endif  // USE_ESP32 && USE_WIFI_RUNTIME_POWER_SAVE

#ifdef USE_WIFI_FAST_CONNECT
bool WiFiComponent::load_fast_connect_settings_(WiFiAP &params) {
  SavedWifiFastConnectSettings fast_connect_save{};

  if (this->fast_connect_pref_.load(&fast_connect_save)) {
    // Validate saved AP index
    if (fast_connect_save.ap_index < 0 || static_cast<size_t>(fast_connect_save.ap_index) >= this->sta_.size()) {
      ESP_LOGW(TAG, "AP index out of bounds");
      return false;
    }

    // Set selected index for future operations (save, retry, etc)
    this->selected_sta_index_ = fast_connect_save.ap_index;

    // Copy entire config, then override with fast connect data
    params = this->sta_[fast_connect_save.ap_index];

    // Override with saved BSSID/channel from fast connect (SSID/password/etc already copied from config)
    bssid_t bssid{};
    std::copy(fast_connect_save.bssid, fast_connect_save.bssid + 6, bssid.begin());
    params.set_bssid(bssid);
    params.set_channel(fast_connect_save.channel);
    // Fast connect uses specific BSSID+channel, not hidden network probe (even if config has hidden: true)
    params.set_hidden(false);

    ESP_LOGD(TAG, "Loaded fast_connect settings");
#if defined(USE_ESP32) && defined(SOC_WIFI_SUPPORT_5G)
    if ((this->band_mode_ == WIFI_BAND_MODE_5G_ONLY && fast_connect_save.channel < FIRST_5GHZ_CHANNEL) ||
        (this->band_mode_ == WIFI_BAND_MODE_2G_ONLY && fast_connect_save.channel >= FIRST_5GHZ_CHANNEL)) {
      ESP_LOGW(TAG, "Saved channel %u not allowed by band mode, ignoring fast_connect", fast_connect_save.channel);
      this->selected_sta_index_ = -1;
      return false;
    }
#endif
    return true;
  }

  return false;
}

void WiFiComponent::save_fast_connect_settings_() {
  bssid_t bssid = wifi_bssid();
  uint8_t channel = get_wifi_channel();
  // selected_sta_index_ is always valid here (called only after successful connection)
  // Fallback to 0 is defensive programming for robustness
  int8_t ap_index = this->selected_sta_index_ >= 0 ? this->selected_sta_index_ : 0;

  // Skip save if settings haven't changed (compare with previously saved settings to reduce flash wear)
  SavedWifiFastConnectSettings previous_save{};
  if (this->fast_connect_pref_.load(&previous_save) && memcmp(previous_save.bssid, bssid.data(), 6) == 0 &&
      previous_save.channel == channel && previous_save.ap_index == ap_index) {
    return;  // No change, nothing to save
  }

  SavedWifiFastConnectSettings fast_connect_save{};
  memcpy(fast_connect_save.bssid, bssid.data(), 6);
  fast_connect_save.channel = channel;
  fast_connect_save.ap_index = ap_index;

  this->fast_connect_pref_.save(&fast_connect_save);

  ESP_LOGD(TAG, "Saved fast_connect settings");
}
#endif

void WiFiAP::set_ssid(const std::string &ssid) { this->ssid_ = CompactString(ssid.c_str(), ssid.size()); }
void WiFiAP::set_ssid(const char *ssid) { this->ssid_ = CompactString(ssid, strlen(ssid)); }
void WiFiAP::set_bssid(const bssid_t &bssid) { this->bssid_ = bssid; }
void WiFiAP::clear_bssid() { this->bssid_ = {}; }
void WiFiAP::set_password(const std::string &password) {
  this->password_ = CompactString(password.c_str(), password.size());
}
void WiFiAP::set_password(const char *password) { this->password_ = CompactString(password, strlen(password)); }
#ifdef USE_WIFI_WPA2_EAP
void WiFiAP::set_eap(optional<EAPAuth> eap_auth) { this->eap_ = std::move(eap_auth); }
#endif
void WiFiAP::set_channel(uint8_t channel) { this->channel_ = channel; }
void WiFiAP::clear_channel() { this->channel_ = 0; }
#ifdef USE_WIFI_MANUAL_IP
void WiFiAP::set_manual_ip(optional<ManualIP> manual_ip) { this->manual_ip_ = manual_ip; }
#endif
void WiFiAP::set_hidden(bool hidden) { this->hidden_ = hidden; }
const bssid_t &WiFiAP::get_bssid() const { return this->bssid_; }
bool WiFiAP::has_bssid() const { return this->bssid_ != bssid_t{}; }
#ifdef USE_WIFI_WPA2_EAP
const optional<EAPAuth> &WiFiAP::get_eap() const { return this->eap_; }
#endif
#ifdef USE_WIFI_MANUAL_IP
const optional<ManualIP> &WiFiAP::get_manual_ip() const { return this->manual_ip_; }
#endif
bool WiFiAP::get_hidden() const { return this->hidden_; }

WiFiScanResult::WiFiScanResult(const bssid_t &bssid, const char *ssid, size_t ssid_len, uint8_t channel, int8_t rssi,
                               bool with_auth, bool is_hidden)
    : bssid_(bssid),
      channel_(channel),
      rssi_(rssi),
      ssid_(ssid, ssid_len),
      with_auth_(with_auth),
      is_hidden_(is_hidden) {}
bool WiFiScanResult::matches(const WiFiAP &config) const {
  if (config.get_hidden()) {
    // User configured a hidden network, only match actually hidden networks
    // don't match SSID
    if (!this->is_hidden_)
      return false;
  } else if (!config.ssid_.empty()) {
    // check if SSID matches
    if (this->ssid_ != config.ssid_)
      return false;
  } else {
    // network is configured without SSID - match other settings
  }
  // If BSSID configured, only match for correct BSSIDs
  if (config.has_bssid() && config.get_bssid() != this->bssid_)
    return false;

#ifdef USE_WIFI_WPA2_EAP
  // BSSID requires auth but no PSK or EAP credentials given
  if (this->with_auth_ && (config.password_.empty() && !config.get_eap().has_value()))
    return false;

  // BSSID does not require auth, but PSK or EAP credentials given
  if (!this->with_auth_ && (!config.password_.empty() || config.get_eap().has_value()))
    return false;
#else
  // If PSK given, only match for networks with auth (and vice versa)
  if (config.password_.empty() == this->with_auth_)
    return false;
#endif

  // If channel configured, only match networks on that channel.
  if (config.has_channel() && config.get_channel() != this->channel_) {
    return false;
  }
  return true;
}
bool WiFiScanResult::get_matches() const { return this->matches_; }
void WiFiScanResult::set_matches(bool matches) { this->matches_ = matches; }
const bssid_t &WiFiScanResult::get_bssid() const { return this->bssid_; }
uint8_t WiFiScanResult::get_channel() const { return this->channel_; }
int8_t WiFiScanResult::get_rssi() const { return this->rssi_; }
bool WiFiScanResult::get_with_auth() const { return this->with_auth_; }
bool WiFiScanResult::get_is_hidden() const { return this->is_hidden_; }

bool WiFiScanResult::operator==(const WiFiScanResult &rhs) const { return this->bssid_ == rhs.bssid_; }

void WiFiComponent::clear_roaming_state_() {
  this->roaming_attempts_ = 0;
  this->roaming_last_check_ = 0;
  this->roaming_scan_end_ = 0;
  this->roaming_target_bssid_ = {};
  this->roaming_state_ = RoamingState::IDLE;
}

void WiFiComponent::release_scan_results_() {
  if (!this->keep_scan_results_) {
    ScanResultsLock lock(this);
#if defined(USE_RP2) || defined(USE_ESP32)
    // std::vector - use swap trick since shrink_to_fit is non-binding
    decltype(this->scan_result_)().swap(this->scan_result_);
#else
    // FixedVector::release() frees all memory
    this->scan_result_.release();
#endif
  }
}

#ifdef USE_WIFI_CONNECT_STATE_LISTENERS
void WiFiComponent::notify_connect_state_listeners_() {
  if (!this->pending_.connect_state)
    return;
  this->pending_.connect_state = false;
  // Get current SSID and BSSID from the WiFi driver
  char ssid_buf[SSID_BUFFER_SIZE];
  const char *ssid = this->wifi_ssid_to(ssid_buf);
  bssid_t bssid = this->wifi_bssid();
  for (auto *listener : this->connect_state_listeners_) {
    listener->on_wifi_connect_state(StringRef(ssid, strlen(ssid)), bssid);
  }
}

void WiFiComponent::notify_disconnect_state_listeners_() {
  constexpr uint8_t empty_bssid[6] = {};
  for (auto *listener : this->connect_state_listeners_) {
    listener->on_wifi_connect_state(StringRef(), empty_bssid);
  }
}
#endif  // USE_WIFI_CONNECT_STATE_LISTENERS

#ifdef USE_WIFI_IP_STATE_LISTENERS
void WiFiComponent::notify_ip_state_listeners_() {
  for (auto *listener : this->ip_state_listeners_) {
    listener->on_ip_state(this->wifi_sta_ip_addresses(), this->get_dns_address(0), this->get_dns_address(1));
  }
}
#endif  // USE_WIFI_IP_STATE_LISTENERS

#ifdef USE_WIFI_SCAN_RESULTS_LISTENERS
void WiFiComponent::notify_scan_results_listeners_() {
  for (auto *listener : this->scan_results_listeners_) {
    listener->on_wifi_scan_results(this->scan_result_);
  }
}
#endif  // USE_WIFI_SCAN_RESULTS_LISTENERS

void WiFiComponent::check_roaming_(uint32_t now) {
  // Guard: not for hidden networks (may not appear in scan)
  const WiFiAP *selected = this->get_selected_sta_();
  if (selected == nullptr || selected->get_hidden()) {
    this->roaming_attempts_ = ROAMING_MAX_ATTEMPTS;  // Stop checking forever
    return;
  }

  this->roaming_last_check_ = now;
  this->roaming_attempts_++;

  // Guard: skip scan if signal is already good (no meaningful improvement possible)
  int8_t rssi = this->wifi_rssi();
  if (rssi > ROAMING_GOOD_RSSI) {
    ESP_LOGD(TAG, "Roam check skipped, signal good (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_,
             ROAMING_MAX_ATTEMPTS);
    return;
  }

  ESP_LOGD(TAG, "Roam scan (%d dBm, attempt %u/%u)", rssi, this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
  this->roaming_state_ = RoamingState::SCANNING;
  this->wifi_scan_start_(this->passive_scan_);
}

void WiFiComponent::process_roaming_scan_() {
  this->scan_done_ = false;
  // Default to IDLE - will be set to CONNECTING if we find a better AP
  this->roaming_state_ = RoamingState::IDLE;
  // Record when scan completed so delayed disconnects (e.g., ESP8266 Beacon Timeout)
  // can be attributed to the scan and avoid resetting the attempts counter
  this->roaming_scan_end_ = millis();

  // Get current connection info
  int8_t current_rssi = this->wifi_rssi();
  // Guard: must still be connected (RSSI may have become invalid during scan)
  if (current_rssi == WIFI_RSSI_DISCONNECTED) {
    this->release_scan_results_();
    return;
  }

  char ssid_buf[SSID_BUFFER_SIZE];
  StringRef current_ssid(this->wifi_ssid_to(ssid_buf));
  bssid_t current_bssid = this->wifi_bssid();

  // Find best candidate: same SSID, different BSSID
  const WiFiScanResult *best = nullptr;
  char bssid_buf[MAC_ADDRESS_PRETTY_BUFFER_SIZE];

  for (const auto &result : this->scan_result_) {
    // Must be same SSID, different BSSID
    if (result.ssid_ != current_ssid || result.get_bssid() == current_bssid)
      continue;

#if ESPHOME_LOG_LEVEL >= ESPHOME_LOG_LEVEL_VERBOSE
    format_mac_addr_upper(result.get_bssid().data(), bssid_buf);
    ESP_LOGV(TAG, "Roam candidate %s %d dBm", bssid_buf, result.get_rssi());
#endif

    // Track the best candidate
    if (best == nullptr || result.get_rssi() > best->get_rssi()) {
      best = &result;
    }
  }

  // Check if best candidate meets minimum improvement threshold
  const WiFiAP *selected = this->get_selected_sta_();
  int8_t improvement = (best == nullptr) ? 0 : best->get_rssi() - current_rssi;
  if (selected == nullptr || improvement < ROAMING_MIN_IMPROVEMENT) {
    ESP_LOGV(TAG, "Roam best %+d dB (need +%d), attempt %u/%u", improvement, ROAMING_MIN_IMPROVEMENT,
             this->roaming_attempts_, ROAMING_MAX_ATTEMPTS);
    this->release_scan_results_();
    return;
  }

  format_mac_addr_upper(best->get_bssid().data(), bssid_buf);
  ESP_LOGI(TAG, "Roaming to %s (%+d dB)", bssid_buf, improvement);

  WiFiAP roam_params = *selected;
  apply_scan_result_to_params(roam_params, *best);

  // Mark as roaming attempt - affects retry behavior if connection fails
  this->roaming_state_ = RoamingState::CONNECTING;
  this->roaming_target_bssid_ = best->get_bssid();  // Must read before releasing scan results

  this->release_scan_results_();

  // Connect directly - wifi_sta_connect_ handles disconnect internally
  this->start_connecting(roam_params);
}

WiFiComponent *global_wifi_component;  // NOLINT(cppcoreguidelines-avoid-non-const-global-variables)

}  // namespace esphome::wifi
#endif
