#include <string.h>

#include "esp_app_desc.h"
#include "esp_event.h"
#include "esp_ota_ops.h"
#include "esp_log.h"
#include "esp_wifi.h"
#include "esp_wifi_default.h"
#include "esp_netif.h"
#include "esp_http_client.h"
#include "esp_crt_bundle.h"
#include "esp_sleep.h"

#include "freertos/FreeRTOS.h"
#include "freertos/event_groups.h"

#include "epd7in3e.h"
#include "status_screen.h"
#include "combo_button.h"
#include "ota_update.h"
#include "board_antenna.h"
#include "battery.h"

#include "frame_client.h"

static const char *TAG = "frame_client";

#define STA_CONNECTED_BIT BIT0
#define STA_FAILED_BIT BIT1

static EventGroupHandle_t s_sta_event_group;

/* Passed as the event-handler arg during frame_wifi_connect_sta so it can
 * apply the cached static IP (fast_cache != NULL) exactly once, on the
 * fast-connect attempt's own WIFI_EVENT_STA_CONNECTED -- fallback attempts
 * leave fast_cache NULL and get normal DHCP. */
typedef struct {
    esp_netif_t *netif;
    const frame_wifi_cache_t *fast_cache;
} sta_connect_ctx_t;

/* Applies a cached static IP right after L2 link-up, skipping DHCP.
 * esp_netif_set_ip_info() only posts IP_EVENT_STA_GOT_IP (which is what
 * unblocks the connect-attempt wait below) once the netif is already
 * "up" -- true by this point, since the internal netif-glue's own
 * WIFI_EVENT_STA_CONNECTED subscriber (registered earlier, in
 * esp_netif_create_default_wifi_sta()) runs before this one and brings
 * the netif up first. Confirmed against ESP-IDF's own
 * examples/protocols/static_ip. */
static void apply_fast_ip(esp_netif_t *netif, const frame_wifi_cache_t *cache)
{
    if (esp_netif_dhcpc_stop(netif) != ESP_OK) {
        return;
    }
    esp_netif_ip_info_t ip_info = {
        .ip.addr = cache->ip,
        .netmask.addr = cache->netmask,
        .gw.addr = cache->gateway,
    };
    if (esp_netif_set_ip_info(netif, &ip_info) != ESP_OK) {
        return;
    }
    if (cache->dns != 0) {
        esp_netif_dns_info_t dns_info = { .ip.type = ESP_IPADDR_TYPE_V4 };
        dns_info.ip.u_addr.ip4.addr = cache->dns;
        esp_netif_set_dns_info(netif, ESP_NETIF_DNS_MAIN, &dns_info);
    }
}

/* Records BSSID/channel/IP/netmask/gateway/DNS from a connection that just
 * succeeded (fast path or normal), for the next wake's fast-connect
 * attempt. Best-effort: any lookup failing here just means next wake
 * falls back to a normal scan+DHCP, not a hard error. */
static void save_wifi_cache(esp_netif_t *netif)
{
    wifi_ap_record_t ap_info;
    if (esp_wifi_sta_get_ap_info(&ap_info) != ESP_OK) {
        return;
    }
    esp_netif_ip_info_t ip_info;
    if (esp_netif_get_ip_info(netif, &ip_info) != ESP_OK) {
        return;
    }
    esp_netif_dns_info_t dns_info = {0};
    esp_netif_get_dns_info(netif, ESP_NETIF_DNS_MAIN, &dns_info);

    frame_wifi_cache_t cache = {0};
    memcpy(cache.bssid, ap_info.bssid, sizeof(cache.bssid));
    cache.channel = ap_info.primary;
    cache.ip = ip_info.ip.addr;
    cache.netmask = ip_info.netmask.addr;
    cache.gateway = ip_info.gw.addr;
    cache.dns = dns_info.ip.u_addr.ip4.addr;
    frame_wifi_cache_save(&cache);
}

/* Builds a full URL from cfg->toolsserver + a path (no leading slash),
 * appending cfg->access_token as ?token= if one's set. toolsserver is
 * normally a bare "host:port", defaulting to plain http; it may instead
 * carry an explicit "http://" or "https://" prefix to pick the scheme,
 * e.g. "https://frame.example.com" if a reverse proxy is terminating
 * TLS in front of the tools server. Every URL carries ?id= (the device's
 * MAC-derived identity -- how a multi-frame server tells frames apart
 * and how an unknown frame self-registers) plus &token=: the server-
 * issued per-frame device token once one has been delivered via
 * /frame/config, else the provisioned access token (the legacy shared
 * secret, also what a pre-multi-frame server still expects). This is
 * the one chokepoint all requests go through, so every caller gets both
 * for free instead of needing to remember to add them. */
static void build_url(char *out, size_t out_size, const frame_config_t *cfg, const char *path)
{
    const char *toolsserver = cfg->toolsserver;
    size_t len;
    if (strncmp(toolsserver, "http://", 7) == 0 || strncmp(toolsserver, "https://", 8) == 0) {
        len = (size_t)snprintf(out, out_size, "%s/%s", toolsserver, path);
    } else {
        len = (size_t)snprintf(out, out_size, "http://%s/%s", toolsserver, path);
    }

    char device_id[FRAME_DEVICE_ID_LEN + 1];
    frame_device_id_get(device_id, sizeof(device_id));
    if (len < out_size) {
        len += (size_t)snprintf(out + len, out_size - len, "?id=%s", device_id);
    }

    const char *token = cfg->device_token[0] != '\0' ? cfg->device_token : cfg->access_token;
    if (token[0] != '\0' && len < out_size) {
        snprintf(out + len, out_size - len, "&token=%s", token);
    }
}

/* wifi_sta_config_t's ssid/password fields are fixed-size byte arrays, not
 * necessarily null-terminated (a full 32-char SSID fills the field exactly).
 * snprintf() flags that as a possible truncation at -Werror, so copy by
 * hand instead. */
static void copy_wifi_field(uint8_t *dst, size_t dst_size, const char *src)
{
    size_t len = strnlen(src, dst_size);
    memcpy(dst, src, len);
    if (len < dst_size) {
        dst[len] = '\0';
    }
}

static void sta_event_handler(void *arg, esp_event_base_t event_base,
                               int32_t event_id, void *event_data)
{
    sta_connect_ctx_t *ctx = (sta_connect_ctx_t *)arg;
    if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
        esp_wifi_connect();
    } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_CONNECTED) {
        if (ctx->fast_cache != NULL) {
            apply_fast_ip(ctx->netif, ctx->fast_cache);
        }
    } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
        ESP_LOGW(TAG, "Disconnected from home WiFi");
        xEventGroupSetBits(s_sta_event_group, STA_FAILED_BIT);
    } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
        ip_event_got_ip_t *event = (ip_event_got_ip_t *)event_data;
        ESP_LOGI(TAG, "Got IP: " IPSTR, IP2STR(&event->ip_info.ip));
        xEventGroupSetBits(s_sta_event_group, STA_CONNECTED_BIT);
    }
}

esp_err_t frame_wifi_connect_sta(const frame_config_t *cfg)
{
    board_antenna_select_onboard();

    s_sta_event_group = xEventGroupCreate();

    esp_netif_t *sta_netif = esp_netif_create_default_wifi_sta();

    wifi_init_config_t init_cfg = WIFI_INIT_CONFIG_DEFAULT();
    ESP_ERROR_CHECK(esp_wifi_init(&init_cfg));

    sta_connect_ctx_t ctx = { .netif = sta_netif, .fast_cache = NULL };
    esp_event_handler_instance_t wifi_handler;
    esp_event_handler_instance_t ip_handler;
    ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &sta_event_handler, &ctx, &wifi_handler));
    ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP, &sta_event_handler, &ctx, &ip_handler));

    wifi_config_t wifi_config = {0};
    copy_wifi_field(wifi_config.sta.ssid, sizeof(wifi_config.sta.ssid), cfg->sta_ssid);
    copy_wifi_field(wifi_config.sta.password, sizeof(wifi_config.sta.password), cfg->sta_password);

    frame_wifi_cache_t cache;
    bool have_cache = frame_wifi_cache_load(&cache);

    wifi_config_t start_config = wifi_config;
    if (have_cache) {
        /* Known BSSID/channel -- skips the all-channel scan. The IP side
         * of the fast path (skipping DHCP) happens in apply_fast_ip once
         * WIFI_EVENT_STA_CONNECTED confirms this specific AP answered. */
        start_config.sta.bssid_set = true;
        memcpy(start_config.sta.bssid, cache.bssid, sizeof(cache.bssid));
        start_config.sta.channel = cache.channel;
        start_config.sta.scan_method = WIFI_FAST_SCAN;
    }

    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
    ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &start_config));
    ESP_ERROR_CHECK(esp_wifi_start());

    esp_err_t result = ESP_FAIL;

    if (have_cache) {
        ESP_LOGI(TAG, "Connecting to '%s' (fast path: cached BSSID/channel + static IP)", cfg->sta_ssid);
        ctx.fast_cache = &cache;

        xEventGroupClearBits(s_sta_event_group, STA_CONNECTED_BIT | STA_FAILED_BIT);
        esp_wifi_connect();

        EventBits_t bits = xEventGroupWaitBits(s_sta_event_group, STA_CONNECTED_BIT | STA_FAILED_BIT,
                                                pdTRUE, pdFALSE,
                                                pdMS_TO_TICKS(CONFIG_FRAME_STA_CONNECT_TIMEOUT_MS));
        ctx.fast_cache = NULL;

        if (bits & STA_CONNECTED_BIT) {
            result = ESP_OK;
        } else {
            ESP_LOGW(TAG, "Fast-connect attempt failed, falling back to a full scan");
            frame_wifi_cache_clear();
            /* apply_fast_ip's esp_netif_dhcpc_stop() leaves the netif's
             * internal DHCP state STOPPED rather than the default INIT --
             * left alone, the next WIFI_EVENT_STA_CONNECTED would make
             * esp-netif's own glue silently re-post the stale cached IP
             * instead of actually running DHCP (see
             * esp_netif_action_connected in esp_netif_handlers.c). Calling
             * this now (netif is down, mid-retry) just resets that state
             * back to INIT -- confirmed against esp_netif_dhcpc_start's
             * source, doesn't yet touch the network. */
            esp_netif_dhcpc_start(sta_netif);
            ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
        }
    }

    for (int attempt = 1; result != ESP_OK && attempt <= CONFIG_FRAME_STA_CONNECT_MAX_RETRIES; attempt++) {
        ESP_LOGI(TAG, "Connecting to '%s' (attempt %d/%d)", cfg->sta_ssid, attempt,
                 CONFIG_FRAME_STA_CONNECT_MAX_RETRIES);

        xEventGroupClearBits(s_sta_event_group, STA_CONNECTED_BIT | STA_FAILED_BIT);
        esp_wifi_connect();

        EventBits_t bits = xEventGroupWaitBits(s_sta_event_group, STA_CONNECTED_BIT | STA_FAILED_BIT,
                                                pdTRUE, pdFALSE,
                                                pdMS_TO_TICKS(CONFIG_FRAME_STA_CONNECT_TIMEOUT_MS));

        if (bits & STA_CONNECTED_BIT) {
            result = ESP_OK;
            break;
        }
        ESP_LOGW(TAG, "Attempt %d/%d failed", attempt, CONFIG_FRAME_STA_CONNECT_MAX_RETRIES);
    }

    esp_event_handler_instance_unregister(WIFI_EVENT, ESP_EVENT_ANY_ID, wifi_handler);
    esp_event_handler_instance_unregister(IP_EVENT, IP_EVENT_STA_GOT_IP, ip_handler);
    vEventGroupDelete(s_sta_event_group);
    s_sta_event_group = NULL;

    if (result == ESP_OK) {
        save_wifi_cache(sta_netif);
    } else {
        /* Fully tear the WiFi driver back down on failure -- the caller
         * falls back to provisioning, which calls esp_wifi_init() again
         * for AP mode. Leaving the driver merely stopped (rather than
         * deinitialized) made that second esp_wifi_init() call fail with
         * ESP_ERR_INVALID_STATE and abort, confirmed on hardware. */
        esp_wifi_stop();
        esp_wifi_deinit();
        esp_netif_destroy_default_wifi(sta_netif);
    }

    return result;
}

typedef struct {
    bool reachable;
    uint32_t refresh_interval_s; /* CONFIG_FRAME_SLEEP_INTERVAL_S if absent/unparseable */
    char firmware_version[32];   /* server's uploaded OTA image version; empty if none/unreachable */
    /* Per-frame token the server pushes until this device has
     * authenticated with it once; empty when absent. Persisted via
     * frame_config_set_device_token() and used by build_url() from the
     * next request on. */
    char device_token[FRAME_CFG_TOKEN_MAX_LEN + 1];
} frame_server_config_t;

/* Finds the first integer value associated with "key" in a small JSON
 * blob, e.g. 3600 in {"refresh_interval_s": 3600}. Not a general JSON
 * parser -- just enough for this project's small, flat config response,
 * to avoid pulling in a JSON library for one scalar field. */
static bool json_extract_uint(const char *json, const char *key, uint32_t *out)
{
    char needle[48];
    snprintf(needle, sizeof(needle), "\"%s\"", key);
    const char *pos = strstr(json, needle);
    if (pos == NULL) {
        return false;
    }
    pos = strchr(pos, ':');
    if (pos == NULL) {
        return false;
    }
    pos++;
    while (*pos == ' ') {
        pos++;
    }
    char *end;
    unsigned long value = strtoul(pos, &end, 10);
    if (end == pos) {
        return false;
    }
    *out = (uint32_t)value;
    return true;
}

/* Finds the string value associated with "key" in a small, flat JSON
 * blob, e.g. "San Francisco, CA" in {"location": "San Francisco, CA"}.
 * Same rationale as json_extract_uint() -- not a general parser. Returns
 * false if the key is missing or its value is JSON null. Only unescapes
 * \" -- values from this server need nothing fancier. */
static bool json_extract_string(const char *json, const char *key, char *out, size_t out_size)
{
    char needle[48];
    snprintf(needle, sizeof(needle), "\"%s\"", key);
    const char *pos = strstr(json, needle);
    if (pos == NULL) {
        return false;
    }
    pos = strchr(pos, ':');
    if (pos == NULL) {
        return false;
    }
    pos++;
    while (*pos == ' ') {
        pos++;
    }
    if (strncmp(pos, "null", 4) == 0) {
        return false;
    }
    if (*pos != '"') {
        return false;
    }
    pos++;

    size_t i = 0;
    while (*pos != '\0' && *pos != '"' && i + 1 < out_size) {
        if (pos[0] == '\\' && pos[1] == '"') {
            out[i++] = '"';
            pos += 2;
        } else {
            out[i++] = *pos++;
        }
    }
    out[i] = '\0';
    return true;
}

/* GETs the server's /frame/config -- doubles as both the reachability
 * check (any completed HTTP response means the socket-level connection
 * succeeded), the source of the server-configurable refresh interval,
 * and (via the X-Frame-Version/X-Frame-Board request headers and
 * firmware_version response field) the device's OTA update check --
 * piggybacked on a request already made every wake, no extra round
 * trip. X-Frame-Board lets the server learn which board this device is
 * (CONFIG_FRAME_BOARD_NAME) so it can pick the right Gitea release
 * asset itself, instead of a user manually selecting a board in the
 * web UI. */
static frame_server_config_t fetch_frame_config(const frame_config_t *cfg)
{
    frame_server_config_t result = {
        .reachable = false,
        .refresh_interval_s = CONFIG_FRAME_SLEEP_INTERVAL_S,
    };
    result.firmware_version[0] = '\0';
    result.device_token[0] = '\0';

    char url[256];
    build_url(url, sizeof(url), cfg, "frame/config");

    esp_http_client_config_t config = {
        .url = url,
        .method = HTTP_METHOD_GET,
        .timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS,
        .crt_bundle_attach = esp_crt_bundle_attach,
    };
    esp_http_client_handle_t client = esp_http_client_init(&config);
    esp_http_client_set_header(client, "X-Frame-Version", esp_app_get_description()->version);
    esp_http_client_set_header(client, "X-Frame-Board", CONFIG_FRAME_BOARD_NAME);

    esp_err_t err = esp_http_client_open(client, 0);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "Server '%s' not reachable: %s", cfg->toolsserver, esp_err_to_name(err));
        esp_http_client_cleanup(client);
        return result;
    }

    esp_http_client_fetch_headers(client);
    result.reachable = true;

    /* 512 (was 256): the response also carries "device_token" during the
     * one-time identity handshake -- worst case is still well under half
     * of this, the rest is headroom for future fields. */
    char body[512];
    int total = 0;
    int n;
    while (total < (int)sizeof(body) - 1 &&
           (n = esp_http_client_read(client, body + total, sizeof(body) - 1 - total)) > 0) {
        total += n;
    }
    body[total] = '\0';

    esp_http_client_close(client);
    esp_http_client_cleanup(client);

    uint32_t interval;
    if (json_extract_uint(body, "refresh_interval_s", &interval)) {
        result.refresh_interval_s = interval;
    } else {
        ESP_LOGW(TAG, "'%s' response missing refresh_interval_s, using fallback %ds", url,
                 (int)result.refresh_interval_s);
    }
    json_extract_string(body, "firmware_version", result.firmware_version, sizeof(result.firmware_version));
    json_extract_string(body, "device_token", result.device_token, sizeof(result.device_token));

    return result;
}

typedef struct {
    esp_http_client_handle_t client;
} http_read_ctx_t;

/* Pulls the next chunk straight out of the in-progress HTTP response --
 * epd_write_frame() calls this to feed the panel without ever holding
 * the full ~192KB frame in RAM. Just a plain relay: the manage overlay
 * (scan-to-manage QR, battery, location/date, share-QR, named face
 * labels) is composited server-side now (see server/app/manage_overlay.py),
 * baked into the same image bytes as any other render -- this function,
 * like the rest of this file, has no idea an overlay exists. */
static size_t http_read_fn(uint8_t *chunk, size_t chunk_size, void *ctx_)
{
    http_read_ctx_t *ctx = (http_read_ctx_t *)ctx_;
    int n = esp_http_client_read(ctx->client, (char *)chunk, (int)chunk_size);
    return n > 0 ? (size_t)n : 0;
}

/* GETs /frame/image (FETCH_NORMAL), or POSTs /frame/advance or
 * /frame/back to force a move in either direction (FETCH_ADVANCE /
 * FETCH_BACK -- the next-photo / back-photo buttons). manage=true (the
 * manage button) appends &manage=1, telling the server to bake its
 * overlay into this same response instead of returning the bare
 * content -- see server/app/routers/device.py. Returning non-ESP_OK
 * means the panel was never actually refreshed -- epd_display_stream()
 * (see epd7in3e.c) refuses to trigger a physical refresh on a short/
 * wrong-size stream, so a failure here always leaves the visible screen
 * exactly as it was. */
static esp_err_t fetch_and_display(const frame_config_t *cfg, fetch_action_t action, bool manage)
{
    const char *path = "frame/image";
    if (action == FETCH_ADVANCE) {
        path = "frame/advance";
    } else if (action == FETCH_BACK) {
        path = "frame/back";
    }

    char url[256];
    build_url(url, sizeof(url), cfg, path);
    if (manage) {
        size_t len = strlen(url);
        if (len + strlen("&manage=1") < sizeof(url)) {
            strcpy(url + len, "&manage=1");
        }
    }

    esp_http_client_config_t config = {
        .url = url,
        .method = action == FETCH_NORMAL ? HTTP_METHOD_GET : HTTP_METHOD_POST,
        .timeout_ms = CONFIG_FRAME_FETCH_TIMEOUT_MS,
        .crt_bundle_attach = esp_crt_bundle_attach,
    };
    esp_http_client_handle_t client = esp_http_client_init(&config);

    esp_err_t err = esp_http_client_open(client, 0);
    if (err != ESP_OK) {
        ESP_LOGE(TAG, "Failed to open '%s': %s", url, esp_err_to_name(err));
        esp_http_client_cleanup(client);
        return err;
    }

    int content_length = esp_http_client_fetch_headers(client);
    int status = esp_http_client_get_status_code(client);
    if (status != 200) {
        ESP_LOGE(TAG, "'%s' returned HTTP %d", url, status);
        esp_http_client_close(client);
        esp_http_client_cleanup(client);
        return ESP_FAIL;
    }
    ESP_LOGI(TAG, "Fetching frame (%d bytes) from '%s'", content_length, url);

    http_read_ctx_t ctx = { .client = client };
    uint32_t crc = 0;
    err = epd_write_frame(http_read_fn, &ctx, &crc);

    esp_http_client_close(client);
    esp_http_client_cleanup(client);

    if (err != ESP_OK) {
        return err;
    }

    uint32_t previous_crc;
    if (frame_config_get_last_display_crc32(&previous_crc) == ESP_OK && previous_crc == crc) {
        /* Same photo already on screen (e.g. redisplayed after a reboot,
         * before the refresh interval elapsed server-side) -- skip the
         * physical refresh, avoiding its visible flash and 15-30s
         * duration for no visual change. */
        ESP_LOGI(TAG, "Frame unchanged since last display, skipping refresh");
        return ESP_OK;
    }

    err = epd_turn_on_display();
    if (err == ESP_OK) {
        frame_config_set_last_display_crc32(crc);
    }
    return err;
}

#define MANAGE_MENU_TIMEOUT_MS 30000
#define MANAGE_MENU_POLL_MS 150
#define MANAGE_MENU_DEBOUNCE_MS 30

/* Polls the manage button for up to timeout_ms for a new press. On
 * detecting one, waits for release before returning true, so a single
 * physical press-and-release is always exactly one event to the caller
 * -- without that, a press held across multiple poll intervals would
 * register as multiple escalations. Returns false if timeout_ms elapses
 * with no press. */
static bool wait_for_button_press(uint32_t timeout_ms)
{
    uint32_t elapsed_ms = 0;
    while (elapsed_ms < timeout_ms) {
        vTaskDelay(pdMS_TO_TICKS(MANAGE_MENU_POLL_MS));
        elapsed_ms += MANAGE_MENU_POLL_MS;

        if (!combo_button_is_pressed()) {
            continue;
        }
        vTaskDelay(pdMS_TO_TICKS(MANAGE_MENU_DEBOUNCE_MS));
        if (!combo_button_is_pressed()) {
            continue; /* noise, not a real press */
        }
        while (combo_button_is_pressed()) {
            vTaskDelay(pdMS_TO_TICKS(MANAGE_MENU_POLL_MS));
        }
        return true;
    }
    return false;
}

/* Runs the manage-button view: fetches once with manage=1 (the server
 * bakes its whole overlay -- scan-to-manage QR, battery, location/date/
 * share-QR, every named face label, no more RAM-driven cap on how many --
 * into the response), shows it, then waits up to 30s for either another
 * press or the timeout before reverting to a plain fetch. Device stays
 * awake throughout (doesn't sleep the panel or the chip). Returns
 * non-ESP_OK only if the manage fetch itself failed; a revert failure
 * after that is logged but doesn't count as an overall failure --
 * something was already shown successfully, which was the point of the
 * button. */
static esp_err_t run_management_menu(const frame_config_t *cfg, fetch_action_t action)
{
    esp_err_t err = fetch_and_display(cfg, action, true);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "Could not fetch manage view (%s), showing photo normally", esp_err_to_name(err));
        return fetch_and_display(cfg, action, false);
    }

    ESP_LOGI(TAG, "Showing manage view, waiting up to 30s");
    wait_for_button_press(MANAGE_MENU_TIMEOUT_MS);

    esp_err_t revert_err = fetch_and_display(cfg, FETCH_NORMAL, false);
    if (revert_err != ESP_OK) {
        ESP_LOGW(TAG, "Failed to revert manage view (%s)", esp_err_to_name(revert_err));
    }
    return ESP_OK;
}

/* Runs the appropriate fetch for this cycle: a plain fetch, or -- if
 * show_management_qr -- the manage view (see run_management_menu()). */
static esp_err_t run_fetch_cycle(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
{
    if (!show_management_qr) {
        return fetch_and_display(cfg, action, false);
    }
    return run_management_menu(cfg, action);
}

/* Reports the battery percent to the server (POST /frame/battery).
 * Best-effort only: a battery report must never fail a photo cycle, so
 * every failure here is just a warning. No-op for percent < 0. */
static void report_battery(const frame_config_t *cfg, int percent)
{
    if (percent < 0) {
        return;
    }

    char url[256];
    build_url(url, sizeof(url), cfg, "frame/battery");

    char body[48];
    int body_len = snprintf(body, sizeof(body), "{\"percent\": %d}", percent);

    esp_http_client_config_t config = {
        .url = url,
        .method = HTTP_METHOD_POST,
        .timeout_ms = CONFIG_FRAME_SERVER_CHECK_TIMEOUT_MS,
        .crt_bundle_attach = esp_crt_bundle_attach,
    };
    esp_http_client_handle_t client = esp_http_client_init(&config);
    esp_http_client_set_header(client, "Content-Type", "application/json");

    esp_err_t err = esp_http_client_open(client, body_len);
    if (err != ESP_OK) {
        ESP_LOGW(TAG, "Battery report failed to connect: %s", esp_err_to_name(err));
        esp_http_client_cleanup(client);
        return;
    }
    esp_http_client_write(client, body, body_len);
    int status = esp_http_client_fetch_headers(client) >= 0 ? esp_http_client_get_status_code(client) : -1;
    if (status != 200) {
        ESP_LOGW(TAG, "Battery report returned HTTP %d", status);
    } else {
        ESP_LOGI(TAG, "Reported battery %d%% to server", percent);
    }
    esp_http_client_close(client);
    esp_http_client_cleanup(client);
}

void frame_client_run(const frame_config_t *cfg, fetch_action_t action, bool show_management_qr)
{
    esp_err_t epd_err = epd_init();
    bool have_display = (epd_err == ESP_OK);
    if (!have_display) {
        ESP_LOGW(TAG, "EPD init failed (%s), continuing without display", esp_err_to_name(epd_err));
    }

    /* Always show the status screen on the first successful connection
     * after (re)provisioning, regardless of outcome -- confirms the
     * connection worked. Skipped on later wakes to save a refresh, except
     * when something's actually wrong (handled below). */
    bool first_connection = !frame_config_has_connected_once();
    if (first_connection) {
        frame_config_mark_connected_once();
        if (have_display) {
            status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_PENDING);
        }
    }

    /* The image fetch goes before the config check, not after. It has a
     * far more generous timeout (CONFIG_FRAME_FETCH_TIMEOUT_MS, 15s by
     * default, vs. the config check's 3s), so it comfortably absorbs the
     * extra connection-setup latency that's common on the very first
     * request after waking from a long deep sleep (stale ARP entries and
     * the like) -- confirmed on hardware: the config check's tight
     * timeout was intermittently tripping on exactly that latency while
     * it went first, even though the image fetch right after it (on an
     * already-warm connection) never had trouble. Trade-off: on a fully
     * down server, the device now waits up to the image fetch's longer
     * timeout to notice, instead of the config check's shorter one --
     * worth it to stop false-failing on the common case. */
    bool image_ok = true;
    if (have_display) {
        esp_err_t fetch_err = run_fetch_cycle(cfg, action, show_management_qr);
        image_ok = (fetch_err == ESP_OK);
        if (!image_ok) {
            /* epd_display_stream() never triggers a physical refresh on a
             * failed/short/wrong-size stream (see epd7in3e.c), so the
             * visible screen is guaranteed untouched here -- always safe
             * to show what went wrong instead of leaving stale content
             * with no indication anything failed. */
            ESP_LOGW(TAG, "Fetch/display failed (%s), retrying sooner", esp_err_to_name(fetch_err));
            /* Covers the fast-connect cache's blind spot: WiFi can report
             * a successful connection (cached static IP "worked" at the
             * link layer) while the cached IP is actually stale/dead at
             * the network layer -- this is the first real evidence of
             * that, since it's the first thing that actually talks to the
             * server. Clearing here means next wake gets a clean scan +
             * DHCP instead of repeating the same silent failure. */
            frame_wifi_cache_clear();
            status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_FAILED);
        } else if (first_connection) {
            status_screen_show(cfg->sta_ssid, STATUS_OK, cfg->toolsserver, STATUS_OK);
        }
    }

    /* Only worth asking for the refresh interval if the image fetch
     * actually worked -- a failed fetch already means CONFIG_FRAME_RETRY_INTERVAL_S,
     * so there's nothing to gain from a config request whose result would
     * just be discarded. */
    uint32_t sleep_seconds = CONFIG_FRAME_RETRY_INTERVAL_S;
    if (image_ok) {
        /* A full fetch/display cycle just succeeded -- exactly the proof
         * of life needed to confirm a freshly-OTA'd image is good.
         * No-op if this image was already marked valid (i.e. every
         * normal boot, not just the one right after an update). */
        esp_ota_mark_app_valid_cancel_rollback();

        /* Read now, not at boot: the photo (and, if shown, the manage
         * overlay -- entirely server-composited now, using the server's
         * own last-known battery value, not a local reading, see
         * server/app/manage_overlay.py) is already on the panel, so
         * there's no display deadline to beat.
         * Reading here instead of right after waking sidesteps taking the
         * ADC sample while the rail's still settling from whatever the
         * boot/reset just did, with no need to guess a settle delay --
         * the fetch/display work already done this cycle is the delay.
         * Still safe re: the battery/button pin sharing (battery.h) --
         * every button check main.c does happens well before this, at
         * the very start of boot. */
        int battery_percent = battery_read_percent();
        report_battery(cfg, battery_percent);
        frame_server_config_t server_cfg = fetch_frame_config(cfg);
        sleep_seconds = server_cfg.reachable ? server_cfg.refresh_interval_s : CONFIG_FRAME_RETRY_INTERVAL_S;

        /* One-time identity handshake: the server pushes this frame's
         * own token until we've authenticated with it once. Persist it
         * and use it immediately (the OTA below is part of this same
         * cycle) via a local working copy -- cfg itself is const. */
        frame_config_t updated_cfg;
        if (server_cfg.device_token[0] != '\0' &&
            strcmp(server_cfg.device_token, cfg->device_token) != 0) {
            frame_config_set_device_token(server_cfg.device_token);
            updated_cfg = *cfg;
            snprintf(updated_cfg.device_token, sizeof(updated_cfg.device_token), "%s",
                     server_cfg.device_token);
            cfg = &updated_cfg;
        }

        /* Last, deliberately -- the photo's already on screen and the
         * battery report already sent, so a reboot here (whether OTA
         * succeeds or the device is mid-update) never loses either. */
        ota_update_if_available(cfg, server_cfg.firmware_version);
    }

    if (have_display) {
        epd_sleep();
    }

    ESP_LOGI(TAG, "Deep sleeping for %u seconds", (unsigned)sleep_seconds);
    esp_sleep_enable_timer_wakeup((uint64_t)sleep_seconds * 1000000ULL);
    esp_deep_sleep_start();
}
