/*
 * EMS-ESP - https://github.com/emsesp/EMS-ESP
 * Copyright 2020-2025  emsesp.org
 * 
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

#include "network.h"

#include "emsesp.h"

#ifndef NETWORK_FALLBACK_AP_SSID
#define NETWORK_FALLBACK_AP_SSID "ems-esp"
#endif
#ifndef NETWORK_FALLBACK_AP_PASSWORD
#define NETWORK_FALLBACK_AP_PASSWORD "ems-esp-neo"
#endif

namespace emsesp {

uuid::log::Logger Network::logger_{F_(network), uuid::log::Facility::KERN};

void Network::begin() {
#ifndef EMSESP_STANDALONE
    // pull all settings and store locally
    EMSESP::esp32React.getNetworkSettingsService()->read([&](NetworkSettings & settings) {
        enableMDNS_     = settings.enableMDNS;
        staticIPConfig_ = settings.staticIPConfig;
        localIP_        = settings.localIP;
        gatewayIP_      = settings.gatewayIP;
        subnetMask_     = settings.subnetMask;
        dnsIP1_         = settings.dnsIP1;
        dnsIP2_         = settings.dnsIP2;
        hostname_       = settings.hostname;
        ssid_           = settings.ssid;
        password_       = settings.password;
        bandwidth20_    = settings.bandwidth20;
        nosleep_        = settings.nosleep;
        tx_power_       = settings.tx_power;
        bssid_          = settings.bssid;
    });

    // read Ethernet settings
    EMSESP::webSettingsService.read([&](WebSettings & settings) {
        phy_type_       = settings.phy_type;
        eth_power_      = settings.eth_power;
        eth_phy_addr_   = settings.eth_phy_addr;
        eth_clock_mode_ = settings.eth_clock_mode;
    });

    // get Access Point settings
    EMSESP::esp32React.getAPSettingsService()->read([&](APSettings & settings) {
        ap_provisionMode_ = settings.provisionMode;
        ap_ssid_          = settings.ssid;
        ap_password_      = settings.password;
        ap_channel_       = settings.channel;
        ap_ssid_hidden_   = settings.ssidHidden;
        ap_max_clients_   = settings.maxClients;
        ap_localIP_       = settings.localIP;
        ap_gatewayIP_     = settings.gatewayIP;
        ap_subnetMask_    = settings.subnetMask;
    });

    // set before begin() so the event handlers can race-clear it safely
    wifi_connect_pending_     = false;
    ethernet_connect_pending_ = false;

    phase_ = initialPhase();

    // Initialise WiFi once when the Network service starts
    WiFi.persistent(false);
    WiFi.setAutoReconnect(false);
    WiFi.mode(WIFI_STA);
    WiFi.disconnect(true, true); // wipe old settings in NVS. Will give a warning on boot but can be ignored.
    WiFi.setScanMethod(WIFI_ALL_CHANNEL_SCAN);
    WiFi.setHostname(hostname_.c_str());     // updates shared default_hostname buffer
    WiFi.enableSTA(true);                    // creates the STA netif
    WiFi.STA.setHostname(hostname_.c_str()); // pushes to esp_netif_set_hostname
    WiFi.enableIPv6(true);
    if (staticIPConfig_) {
        WiFi.config(localIP_, gatewayIP_, subnetMask_, dnsIP1_, dnsIP2_); // configure for static IP
    }

    // www.esp32.com/viewtopic.php?t=12055
    if (bandwidth20_) {
        esp_wifi_set_bandwidth(static_cast<wifi_interface_t>(ESP_IF_WIFI_STA), WIFI_BW_HT20);
    } else {
        esp_wifi_set_bandwidth(static_cast<wifi_interface_t>(ESP_IF_WIFI_STA), WIFI_BW_HT40);
    }
    if (nosleep_) {
        WiFi.setSleep(false); // turn off sleep - WIFI_PS_NONE
    }

    // scan settings give connect issues since arduino 2.0.14 and arduino 3.x.x with some wifi systems
    // WiFi.setScanMethod(WIFI_ALL_CHANNEL_SCAN); // default is FAST_SCAN
    // WiFi.setSortMethod(WIFI_CONNECT_AP_BY_SIGNAL); // is default, no need to set

    // avoid duplicate registration, so register the lambdas only once across the lifetime of this Network instance
    if (!wifi_events_registered_) {
        wifi_events_registered_ = true;

        // Defer Tx power setting until STA is actually started
        WiFi.onEvent(
            [this](WiFiEvent_t /*event*/, WiFiEventInfo_t /*info*/) {
#ifdef BOARD_C3_MINI_V1
                // always hardcode Tx power for Wemos C3 Mini v1
                // v1 needs this value, see https://github.com/emsesp/EMS-ESP32/pull/620#discussion_r993173979
                // https://www.wemos.cc/en/latest/c3/c3_mini_1_0_0.html#about-wifi
                WiFi.setTxPower(WIFI_POWER_8_5dBm);
#else
                setWiFiPower(tx_power_);
#endif
            },
            ARDUINO_EVENT_WIFI_STA_START);

        // capture the WIFI_REASON_* code on every STA disconnect event so check_connection() can
        // log a meaningful reason when its periodic poll notices we're no longer associated.
        WiFi.onEvent(
            [this](WiFiEvent_t /*event*/, WiFiEventInfo_t info) {
                last_disconnect_reason_ = info.wifi_sta_disconnected.reason;
                wifi_connect_pending_   = false;
                if (wifi_ever_connected_) {
                    LOG_WARNING("WiFi disconnected (reason: %s)", disconnectReason(last_disconnect_reason_));
                } else {
                    LOG_WARNING("WiFi initial connect attempt failed (reason: %s)", disconnectReason(last_disconnect_reason_));
                }
            },
            ARDUINO_EVENT_WIFI_STA_DISCONNECTED);

        // clear the saved reason and the connect-pending guard on a fresh STA association,
        WiFi.onEvent(
            [this](WiFiEvent_t /*event*/, WiFiEventInfo_t /*info*/) {
                last_disconnect_reason_ = 0;
                wifi_connect_pending_   = false;
                wifi_ever_connected_    = true;
            },
            ARDUINO_EVENT_WIFI_STA_GOT_IP);
    }

#endif
}

// format the BSSID (MAC address) of the network interface
bool Network::formatBSSID(const String & bssid, uint8_t (&mac)[6]) {
#ifndef EMSESP_STANDALONE
    uint tmp[6];
    if (bssid.isEmpty() || sscanf(bssid.c_str(), "%X:%X:%X:%X:%X:%X", &tmp[0], &tmp[1], &tmp[2], &tmp[3], &tmp[4], &tmp[5]) != 6) {
        return false;
    }
    for (uint8_t i = 0; i < 6; i++) {
        mac[i] = static_cast<uint8_t>(tmp[i]);
    }
#endif
    return true;
}

// get the local IP address of the network interface
std::string Network::getLocalIP() const {
    switch (network_iface_) {
#ifndef EMSESP_STANDALONE
    case NetIface::AP:
        return WiFi.softAPIP().toString().c_str();
    case NetIface::WIFI:
        return WiFi.localIP().toString().c_str();
    case NetIface::ETHERNET:
        return ETH.localIP().toString().c_str();
    case NetIface::NONE:
#endif
    default:
        return "";
    }
}

// get the MAC address of the network interface
std::string Network::getMacAddress() const {
    switch (network_iface_) {
#ifndef EMSESP_STANDALONE
    case NetIface::AP:
        return WiFi.softAPmacAddress().c_str();
    case NetIface::WIFI:
        return WiFi.macAddress().c_str();
    case NetIface::ETHERNET:
        return ETH.macAddress().c_str();
    case NetIface::NONE:
#endif
    default:
        return "";
    }
}

// get the number of sessions connected to the AP
uint8_t Network::getStationNum() const {
#ifndef EMSESP_STANDALONE
    return network_iface_ == NetIface::AP ? WiFi.softAPgetStationNum() : 0;
#else
    return 0;
#endif
}

// disconnect all WiFi, Eth and AP
// so we can starts searching again to reconnect
void Network::reconnect() {
    LOG_DEBUG("Reconnecting all networks");

#ifndef EMSESP_STANDALONE
    // disconnect WiFi
    if (wifi_connected()) {
        WiFi.disconnect(true, true);
        WiFi.mode(WIFI_STA); // reset mode
    }

    // disconnect AP
    if (WiFi.getMode() & WIFI_AP) {
        stopAP();
    }
#endif

    // reset network state
    network_ip_               = 0;
    network_iface_            = NetIface::NONE;
    has_ipv6_                 = false;
    juststopped_              = false;
    wifi_connect_pending_     = false;
    ethernet_connect_pending_ = false;
    last_disconnect_reason_   = 0;
    connect_retry_            = 0;
    reconnect_count_          = 0;

    // reload the network settings and apply them
    begin();
}

// pick the first phase that has the hardware/config to even be attempted on this device.
// boards without an Ethernet PHY skip straight to WIFI; without a configured SSID, straight to AP
NetPhase Network::initialPhase() const {
    if (phy_type_ != PHY_type::PHY_TYPE_NONE) {
        return NetPhase::ETHERNET;
    }
    if (!ssid_.isEmpty()) {
        return NetPhase::WIFI;
    }
    return NetPhase::AP;
}

// network loop, looking for new and disconnecting networks
void Network::loop() {
#ifndef EMSESP_STANDALONE
    // if we already have a Wifi or Ethernet connection then re-check every NETWORK_RECONNECTION_DELAY_LONG, otherwise NETWORK_RECONNECTION_DELAY_SHORT
    const unsigned long currentMillis = uuid::get_uptime_ms();
    const uint32_t      reconnectDelay =
        (network_iface_ == NetIface::WIFI || network_iface_ == NetIface::ETHERNET) ? NETWORK_RECONNECTION_DELAY_LONG : NETWORK_RECONNECTION_DELAY_SHORT;
    if (!lastConnectionAttempt_ || static_cast<uint32_t>(currentMillis - lastConnectionAttempt_) >= reconnectDelay) {
        lastConnectionAttempt_ = currentMillis;

        // manage the network interfaces: Ethernet, WiFi and AP in that order
        startEthernet(); // Ethernet
        startWIFI();     // WiFi
        startAP();       // Captive Portal (AP)

        // already have a connection: verify it's still alive
        if (network_ip_ != 0 || last_disconnect_reason_ != 0) {
            checkConnection();
        }

        findNetworks(); // detect any new network connections
    }

    // process DNS requests for the captive portal while the soft-AP is up
    if (ap_dnsServer_) {
        ap_dnsServer_->processNextRequest();
    }
#endif
}

// Re-validate the currently active connection
// if a netif is no longer up or has lost its IP (cable unplugged, AP gone, DHCP lease lost, ...) we drop our state so find_networks() can pick up a new one
void Network::checkConnection() {
    if (network_iface_ == NetIface::NONE) {
        return;
    }

#ifndef EMSESP_STANDALONE
    bool still_up = false;
    for (esp_netif_t * netif = esp_netif_next_unsafe(NULL); netif != NULL; netif = esp_netif_next_unsafe(netif)) {
        if (iface_from_desc(esp_netif_get_desc(netif)) != network_iface_) {
            continue;
        }
        esp_netif_ip_info_t ip_info = {};
        if (esp_netif_is_netif_up(netif) && esp_netif_get_ip_info(netif, &ip_info) == ESP_OK && ip_info.ip.addr == network_ip_) {
            still_up = true;
        }
        break; // only one active netif per kind in our world (sta / eth / ap)
    }

    if (!still_up) {
        juststopped_   = true;
        network_ip_    = 0;
        has_ipv6_      = false;
        connect_retry_ = 0;
        // reset the active interface so findNetworks() treats the next link-up (even on the
        // same interface) as a new connection and re-runs the setup, including startmDNS()
        const NetIface lost_iface = network_iface_;
        network_iface_            = NetIface::NONE;
        if (lost_iface == NetIface::ETHERNET) {
            LOG_WARNING("Ethernet connection lost");
            return;
        }
        begin();
    }
#endif
}

// set the WiFi TxPower based on the RSSI (signal strength), picking the lowest value
// code is based of RSSI (signal strength) and copied from Tasmota's WiFiSetTXpowerBasedOnRssi() which is copied ESPEasy's ESPEasyWifi.SetWiFiTXpower() function
// Range ESP32  : 2dBm - 20dBm
//  802.11b - wifi1
//  802.11a - wifi2
//  802.11g - wifi3
//  802.11n - wifi4
//  802.11ac - wifi5
//  802.11ax - wifi6
// tx_power is the Tx power to set, 0 for auto
void Network::setWiFiPower(uint8_t tx_power) {
#ifndef EMSESP_STANDALONE
    if (tx_power != 0) {
        if (!WiFi.setTxPower(static_cast<wifi_power_t>(tx_power))) {
            emsesp::EMSESP::logger().warning("Failed to set WiFi Tx Power");
        }
        return;
    }

    int max_tx_pwr = MAX_TX_PWR_DBM_n;         // assume wifi4
    int threshold  = WIFI_SENSITIVITY_n + 120; // Margin in dBm * 10 on top of threshold

    // Assume AP sends with max set by ETSI standard
    // 2.4 GHz: 100 mWatt (20 dBm)
    // US and some other countries allow 1000 mW (30 dBm)
    int rssi    = WiFi.RSSI() * 10;
    int newrssi = rssi - 200; // We cannot send with over 20 dBm, thus it makes no sense to force higher TX power all the time.

    int min_tx_pwr = 0;
    if (newrssi < threshold) {
        min_tx_pwr = threshold - newrssi;
    }
    if (min_tx_pwr > max_tx_pwr) {
        min_tx_pwr = max_tx_pwr;
    }

    // from WiFIGeneric.h use:
    wifi_power_t p = WIFI_POWER_2dBm;
    if (min_tx_pwr > 185)
        p = WIFI_POWER_19_5dBm;
    else if (min_tx_pwr > 170)
        p = WIFI_POWER_18_5dBm;
    else if (min_tx_pwr > 150)
        p = WIFI_POWER_17dBm;
    else if (min_tx_pwr > 130)
        p = WIFI_POWER_15dBm;
    else if (min_tx_pwr > 110)
        p = WIFI_POWER_13dBm;
    else if (min_tx_pwr > 85)
        p = WIFI_POWER_11dBm;
    else if (min_tx_pwr > 70)
        p = WIFI_POWER_8_5dBm;
    else if (min_tx_pwr > 50)
        p = WIFI_POWER_7dBm;
    else if (min_tx_pwr > 20)
        p = WIFI_POWER_5dBm;

#if defined(EMSESP_DEBUG)
    // emsesp::EMSESP::logger().debug("Recommended WiFi Tx Power (set_power %d, new power %d, rssi %d, threshold %d)", min_tx_pwr / 10, p, rssi, threshold);
#endif

    if (!WiFi.setTxPower(p)) {
        LOG_DEBUG("Failed to set WiFi Tx Power");
    }
#endif
}

// start the multicast UDP service so EMS-ESP is discoverable via .local
void Network::startmDNS() const {
#ifndef EMSESP_STANDALONE
    MDNS.end();

    if (!enableMDNS_) {
        return;
    }

    if (!MDNS.begin(emsesp::EMSESP::system_.hostname().c_str())) {
        emsesp::EMSESP::logger().warning("Failed to start mDNS Responder service");
        return;
    }

    std::string address_s = emsesp::EMSESP::system_.hostname() + ".local";

    MDNS.addService("http", "tcp", 80);   // add our web server and rest API
    MDNS.addService("telnet", "tcp", 23); // add our telnet console
    MDNS.addServiceTxt("http", "tcp", "address", address_s.c_str());

    emsesp::EMSESP::logger().info("Starting mDNS Responder service for %s", address_s.c_str());
#endif
}

// WiFi disconnect reason code to string
const char * Network::disconnectReason(uint8_t code) {
#ifndef EMSESP_STANDALONE
    switch (code) {
    case WIFI_REASON_UNSPECIFIED: // = 1,
        return "unspecified";
    case WIFI_REASON_AUTH_EXPIRE: // = 2,
        return "auth expire";
    case WIFI_REASON_AUTH_LEAVE: // = 3,
        return "auth leave";
    case WIFI_REASON_ASSOC_EXPIRE: // = 4,
        return "assoc expired";
    case WIFI_REASON_ASSOC_TOOMANY: // = 5,
        return "assoc too many";
    case WIFI_REASON_NOT_AUTHED: // = 6,
        return "not authenticated";
    case WIFI_REASON_NOT_ASSOCED: // = 7,
        return "not assoc";
    case WIFI_REASON_ASSOC_LEAVE: // = 8,
        return "assoc leave";
    case WIFI_REASON_ASSOC_NOT_AUTHED: // = 9,
        return "assoc not authed";
    case WIFI_REASON_DISASSOC_PWRCAP_BAD: // = 10,
        return "disassoc powerCAP bad";
    case WIFI_REASON_DISASSOC_SUPCHAN_BAD: // = 11,
        return "disassoc supchan bad";
    case WIFI_REASON_IE_INVALID: // = 13,
        return "IE invalid";
    case WIFI_REASON_MIC_FAILURE: // = 14,
        return "MIC failure";
    case WIFI_REASON_4WAY_HANDSHAKE_TIMEOUT: // = 15,
        return "4way handshake timeout";
    case WIFI_REASON_GROUP_KEY_UPDATE_TIMEOUT: // = 16,
        return "group key-update timeout";
    case WIFI_REASON_IE_IN_4WAY_DIFFERS: // = 17,
        return "IE in 4way differs";
    case WIFI_REASON_GROUP_CIPHER_INVALID: // = 18,
        return "group cipher invalid";
    case WIFI_REASON_PAIRWISE_CIPHER_INVALID: // = 19,
        return "pairwise cipher invalid";
    case WIFI_REASON_AKMP_INVALID: // = 20,
        return "AKMP invalid";
    case WIFI_REASON_UNSUPP_RSN_IE_VERSION: // = 21,
        return "unsupported RSN_IE version";
    case WIFI_REASON_INVALID_RSN_IE_CAP: // = 22,
        return "invalid RSN_IE_CAP";
    case WIFI_REASON_802_1X_AUTH_FAILED: // = 23,
        return "802 X1 auth failed";
    case WIFI_REASON_CIPHER_SUITE_REJECTED: // = 24,
        return "cipher suite rejected";
    case WIFI_REASON_BEACON_TIMEOUT: // = 200,
        return "beacon timeout";
    case WIFI_REASON_NO_AP_FOUND: // = 201,
        return "no AP found";
    case WIFI_REASON_AUTH_FAIL: // = 202,
        return "auth fail";
    case WIFI_REASON_ASSOC_FAIL: // = 203,
        return "assoc fail";
    case WIFI_REASON_HANDSHAKE_TIMEOUT: // = 204,
        return "handshake timeout";
    case WIFI_REASON_CONNECTION_FAIL: // 205,
        return "connection fail";
    case WIFI_REASON_AP_TSF_RESET: // 206,
        return "AP tsf reset";
    case WIFI_REASON_ROAMING: // 207,
        return "roaming";
    case WIFI_REASON_ASSOC_COMEBACK_TIME_TOO_LONG: // 208,
        return "assoc comeback time too long";
    case WIFI_REASON_SA_QUERY_TIMEOUT: // 209,
        return "sa query timeout";
    default:
        return "unknown";
    }
#endif

    return "";
}

// WiFi management
void Network::startWIFI() {
#ifndef EMSESP_STANDALONE
    // only run during the WIFI phase; ETHERNET phase keeps WiFi quiet, AP phase has its own bring-up
    if (phase_ != NetPhase::WIFI) {
        return;
    }

    // exit if WiFi or Ethernet is already connected, or if we have no SSID or another Wifi.begin() is already in progress
    if (WiFi.isConnected() || ssid_.isEmpty() || ethernet_connected() || wifi_connect_pending_) {
        return;
    }

    wifi_connect_pending_ = true;

    // attempt to connect to the wifi network
    // the event handlers handle error handling and retries
    uint8_t bssid[6];
    if (formatBSSID(bssid_, bssid)) {
        WiFi.begin(ssid_.c_str(), password_.c_str(), 0, bssid);
    } else {
        WiFi.begin(ssid_.c_str(), password_.c_str());
    }
#endif
}

// Ethernet management
// Brings up the ETH driver / netif exactly once. After ETH.begin() returns true the driver
// continues to run autonomously (link negotiation, DHCP, etc)
void Network::startEthernet() {
#if CONFIG_IDF_TARGET_ESP32
    // only run during the ETHERNET phase; once we've given up on Ethernet, the driver is left
    // running in case the link comes up later, but we no longer try to (re)start it here
    if (phase_ != NetPhase::ETHERNET) {
        return;
    }

    // already up and running, nothing to do
    if (ethernet_connect_pending_ || ethernet_connected()) {
        return;
    }

#ifndef EMSESP_STANDALONE

    // no ethernet present
    if (phy_type_ == PHY_type::PHY_TYPE_NONE) {
        return;
    }

    // reset power and add a delay as ETH doesn't not always start up correctly after a warm boot
    if (eth_power_ != -1) {
        pinMode(eth_power_, OUTPUT);
        digitalWrite(eth_power_, LOW);
        delay(500);
        digitalWrite(eth_power_, HIGH);
    }

    // mdc = 23 = Pin# of the I²C clock signal for the Ethernet PHY
    // mdio = 18 = Pin# of the I²C IO signal for the Ethernet PHY
    // phy_addr = I²C-address of Ethernet PHY (0 or 1 for LAN8720, 31 for TLK110)
    // power = Pin# of the enable signal for the external crystal oscillator (-1 to disable for internal APLL source)
    // type = Type of the Ethernet PHY (LAN8720 or TLK110)
    // clock_mode =
    //  ETH_CLOCK_GPIO0_IN   = 0  RMII clock input to GPIO0
    //  ETH_CLOCK_GPIO0_OUT  = 1  RMII clock output from GPIO0
    //  ETH_CLOCK_GPIO16_OUT = 2  RMII clock output from GPIO16
    //  ETH_CLOCK_GPIO17_OUT = 3  RMII clock output from GPIO17, for 50hz inverted clock
    eth_phy_type_t type = (phy_type_ == PHY_type::PHY_TYPE_LAN8720)  ? ETH_PHY_LAN8720
                          : (phy_type_ == PHY_type::PHY_TYPE_TLK110) ? ETH_PHY_TLK110
                                                                     : ETH_PHY_RTL8201; // Type of the Ethernet PHY (LAN8720 or TLK110)
    if (ETH.begin(type, eth_phy_addr_, 23, 18, eth_power_, (eth_clock_mode_t)eth_clock_mode_)) {
        LOG_DEBUG("Ethernet module found - starting");
        ethernet_connect_pending_ = true;
        ETH.setHostname(hostname_.c_str()); // Push hostname to the ETH netif immediately after it's created
        ETH.enableIPv6(true);
        if (staticIPConfig_) {
            ETH.config(localIP_, gatewayIP_, subnetMask_, dnsIP1_, dnsIP2_);
        }
    } else {
        LOG_ERROR("Failed to start Ethernet module");
    }
#endif
#endif
}

// check if the network is connected and set network_ip_ / network_iface_ / has_ipv6_
// Iterates over every esp-netif that exists, prioritizing Ethernet > WiFi > AP
void Network::findNetworks() {
#ifndef EMSESP_STANDALONE

    struct NetInfo {
        esp_ip4_addr_t ip;
        esp_ip6_addr_t ip6;
        char           desc[8];
        bool           has_ipv6;
    } info = {};

    // Preference order: ETHERNET > WIFI (STA) > AP
    auto iface_priority = [](NetIface iface) -> uint8_t {
        switch (iface) {
        case NetIface::ETHERNET:
            return 3;
        case NetIface::WIFI:
            return 2;
        case NetIface::AP:
            return 1;
        case NetIface::NONE:
        default:
            return 0;
        }
    };

    NetIface best_iface = NetIface::NONE;
    for (esp_netif_t * netif = esp_netif_next_unsafe(NULL); netif != NULL; netif = esp_netif_next_unsafe(netif)) {
        const char *        desc    = esp_netif_get_desc(netif);
        bool                is_up   = esp_netif_is_netif_up(netif);
        esp_netif_ip_info_t ip_info = {};
        esp_err_t           err     = esp_netif_get_ip_info(netif, &ip_info);

        if (!is_up || err != ESP_OK || ip_info.ip.addr == 0) {
            continue;
        }

        const NetIface candidate = iface_from_desc(desc);
        if (iface_priority(candidate) <= iface_priority(best_iface)) {
            continue; // already have something at least as good
        }

        info.ip = ip_info.ip;
        if (desc) {
            strlcpy(info.desc, desc, sizeof(info.desc));
        }
        info.has_ipv6 = (esp_netif_get_ip6_linklocal(netif, &info.ip6) == ESP_OK);

        best_iface = candidate;
        if (best_iface == NetIface::ETHERNET) {
            break; // top priority, can't be beaten by anything later in the list
        }
    }

    // if we have a connection and it's a new one, set it up
    if (best_iface != NetIface::NONE && best_iface != network_iface_) {
        network_ip_    = info.ip.addr;
        network_iface_ = iface_from_desc(info.desc); // "sta"/"ap"/"eth*"
        has_ipv6_      = info.has_ipv6;
        connect_retry_ = 0;

        // sync the phase to the interface that actually came up. ETH can come up late
        // (e.g. cable plugged in after we'd already moved on to WiFi/AP) and we want the
        // next disconnect-driven retry cycle to start from the right place.
        if (network_iface_ == NetIface::ETHERNET) {
            phase_ = NetPhase::ETHERNET;
        } else if (network_iface_ == NetIface::WIFI) {
            phase_ = NetPhase::WIFI;
        } else if (network_iface_ == NetIface::AP) {
            phase_ = NetPhase::AP;
        }
        if (!ethernet_ever_connected_ && ethernet_connected()) {
            ethernet_ever_connected_ = true;
        }
        LOG_INFO("Network connected via %s (IP: " IPSTR ")",
                 network_iface_ == NetIface::ETHERNET ? "Ethernet"
                 : network_iface_ == NetIface::WIFI   ? "WiFi"
                 : network_iface_ == NetIface::AP     ? "AP"
                                                      : "unknown",
                 IP2STR(&info.ip));

        // if we have a Eth or Wifi connection and the AP is running, stop it
        if (network_iface_ != NetIface::AP && WiFi.getMode() & WIFI_AP) {
            stopAP();
        }

        // count the number of restarts (for Wifi and Eth)
        if (juststopped_) {
            juststopped_ = false;
            reconnect_count_++;
        }

        // start mDNS for any real network interface (skip the SoftAP since the captive portal handles its own DNS)
        if (enableMDNS_ && network_iface_ != NetIface::AP && network_iface_ != NetIface::NONE) {
            startmDNS();
        }

        // fetch the versions.json file from emsesp.org
        EMSESP::webStatusService.schedule_versions_refresh();

        return;
    }

    // fallback, reset network state if nothing found
    if (best_iface == NetIface::NONE) {
        network_ip_    = 0;
        network_iface_ = NetIface::NONE;
        has_ipv6_      = false;
        connect_retry_++;
        LOG_DEBUG("Looking for network interfaces (retry #%d, phase=%s)",
                  connect_retry_,
                  phase_ == NetPhase::ETHERNET ? "Ethernet"
                  : phase_ == NetPhase::WIFI   ? "WiFi"
                                               : "AP");

        // give up on this interface and try the next one. ETHERNET -> WIFI (or straight to AP if no SSID configured); WIFI -> AP.
        if (connect_retry_ >= MAX_NETWORK_RECONNECTION_ATTEMPTS && phase_ != NetPhase::AP) {
            if (phase_ == NetPhase::ETHERNET) {
                if (ssid_.isEmpty()) {
                    if (!ethernet_ever_connected_) {
                        LOG_WARNING("Ethernet failed to connect after %u attempts, falling back to AP", connect_retry_);
                        phase_ = NetPhase::AP;
                    }
                } else {
                    LOG_WARNING("Ethernet failed to connect after %u attempts, switching to WiFi", connect_retry_);
                    phase_ = NetPhase::WIFI;
                }
                ethernet_connect_pending_ = false;
            } else if (phase_ == NetPhase::WIFI) {
                if (!wifi_ever_connected_) {
                    LOG_WARNING("WiFi failed to connect after %u attempts, falling back to AP", connect_retry_);
                    phase_ = NetPhase::AP;
                }
                wifi_connect_pending_ = false;
            }
            connect_retry_ = 0;
        }
    }

#endif
    return;
}

// access point (soft-AP) and the captive portal
void Network::startAP() {
#ifndef EMSESP_STANDALONE
    // only start AP as a fallback once both the Ethernet and WiFi phases have given up
    if (phase_ != NetPhase::AP) {
        return;
    }

    // Don't start AP if wired/Wi-Fi STA is serving the network
    if (ap_provisionMode_ == AP_MODE_NEVER || network_connected()) {
        return;
    }

    // Captive-portal DNS is already bound to the softAP interface
    if (ap_dnsServer_) {
        return;
    }

    if (!WiFi.softAPConfig(ap_localIP_, ap_gatewayIP_, ap_subnetMask_)) {
        LOG_DEBUG("softAPConfig failed");
        return;
    }
    esp_wifi_set_bandwidth(static_cast<wifi_interface_t>(ESP_IF_WIFI_AP), WIFI_BW_HT20);

    // WiFi.softAPenableIPv6();

    if (!WiFi.softAP(ap_ssid_.c_str(), ap_password_.c_str(), ap_channel_, ap_ssid_hidden_, ap_max_clients_)) {
        LOG_ERROR("softAP failed; check SSID/password in AP settings");
        WiFi.softAPdisconnect(true);
        return;
    }
#if CONFIG_IDF_TARGET_ESP32C3
    WiFi.setTxPower(WIFI_POWER_8_5dBm); // https://www.wemos.cc/en/latest/c3/c3_mini_1_0_0.html#about-wifi
#endif
    const IPAddress apIp = WiFi.softAPIP();
    if (static_cast<uint32_t>(apIp) == 0) {
        LOG_DEBUG("SoftAP has no IPv4 yet; skipping captive-portal DNS for now.");
        WiFi.softAPdisconnect(true);
        return;
    }
    LOG_INFO("Starting Access Point with captive portal on %u.%u.%u.%u", apIp[0], apIp[1], apIp[2], apIp[3]);

    ap_dnsServer_ = new DNSServer;
    if (!ap_dnsServer_) {
        LOG_DEBUG("Out of memory starting captive-portal DNSServer");
        WiFi.softAPdisconnect(true);
        return;
    }
    ap_dnsServer_->start(DNS_PORT, "*", apIp);
#endif
}

// stop AP
void Network::stopAP() {
    LOG_INFO("Stopping Access Point");
#ifndef EMSESP_STANDALONE
    WiFi.softAPdisconnect(true);
    if (ap_dnsServer_) {
        ap_dnsServer_->stop();
        delete ap_dnsServer_;
        ap_dnsServer_ = nullptr;
    }
#endif
}

} // namespace emsesp
