#include <Arduino.h>
#include <SPI.h>
#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <HTTPClient.h>
#include <WiFiClientSecure.h>
#include <Preferences.h>
#include <LittleFS.h>
#include <qrcode.h>
#include "config.h"
#include "epd.h"
#include "operation.h"

// ── Globals ──────────────────────────────────────────────────────────────────

static WebServer  server(80);
static DNSServer  dns;
static Preferences prefs;

static bool  g_provisioning  = false;
static bool  g_connect_req   = false;
static String g_req_ssid;
static String g_req_pass;

// ── QR helpers ───────────────────────────────────────────────────────────────

static void show_ap_qr(const String& apSsid, bool retry = false) {
    // WIFI: QR with embedded WPA2 PSK so the phone joins silently after
     // the user accepts the join prompt — no password entry. T:WPA;P:...
     // is the standard format consumed by iOS Camera and most QR apps.
    String content = "WIFI:T:WPA;S:" + apSsid + ";P:" + String(AP_PASSWORD) + ";;";

    QRCode qr;
    uint8_t buf[qrcode_getBufferSize(5)];
    qrcode_initText(&qr, buf, 5, ECC_LOW, content.c_str());
    if (retry) {
        epd_draw_ap_screen_retry(&qr);
    } else {
        epd_draw_ap_screen(&qr);
    }
}

static void show_setup_qr(const String& mac) {
    String url = String(APP_BASE_URL) + "/setup/" + mac;
    Serial.println("show_setup_qr: " + url);

    QRCode qr;
    uint8_t buf[qrcode_getBufferSize(6)];
    qrcode_initText(&qr, buf, 6, ECC_LOW, url.c_str());
    Serial.println("QR size: " + String(qr.size));
    epd_draw_setup_screen(&qr);
    Serial.println("epd_draw_setup_screen done");
}

// ── Captive portal HTML ───────────────────────────────────────────────────────
// NEW WORDING 2026-05-09 — beta-tester feedback called the previous copy
// "Chinglish." Needs flash test before going to a recipient.

static const char PORTAL_HTML[] PROGMEM = R"html(
<!DOCTYPE html><html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>WeVisto Setup</title>
<style>
  body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;
       min-height:100vh;margin:0;background:#fdf6ee;color:#3a2e22}
  .card{width:100%;max-width:340px;margin:1rem;padding:1.5rem;background:#fff9f2;
        border:1px solid #e8d9c4;border-radius:16px}
  h1{font-size:1.25rem;margin:0 0 .5rem}
  .sub{font-size:.875rem;color:#8a7060;margin:0 0 1.25rem;line-height:1.4}
  label{display:block;font-size:.8125rem;font-weight:600;color:#8a7060;margin-bottom:.3rem}
  input{width:100%;min-height:44px;padding:0 .875rem;border:1px solid #e8d9c4;
        border-radius:10px;background:#fff;font-size:1rem;color:#3a2e22;box-sizing:border-box;margin-bottom:1rem}
  button{width:100%;min-height:44px;background:#c97c3a;color:#fff;border:none;
         border-radius:9999px;font-size:1rem;font-weight:700;cursor:pointer}
  p.foot{font-size:.875rem;color:#8a7060;margin-top:1rem;text-align:center;line-height:1.4}
</style>
</head>
<body>
<div class="card">
  <h1>Connect your frame</h1>
  <p class="sub">Type the name and password of your home WiFi so the frame can join it.</p>
  <form method="POST" action="/connect">
    <label for="s">Home WiFi name</label>
    <input id="s" name="ssid" type="text" autocomplete="off" placeholder="e.g. HomeNetwork">
    <label for="p">Home WiFi password</label>
    <input id="p" name="pass" type="password" autocomplete="off">
    <button type="submit">Connect</button>
  </form>
  <p class="foot">Once it connects, the frame's screen will update with the next step.</p>
</div>
</body></html>
)html";

static const char CONNECTING_HTML[] PROGMEM = R"html(
<!DOCTYPE html><html><head><meta charset="UTF-8">
<title>Connecting…</title>
<style>body{font-family:system-ui,sans-serif;display:flex;align-items:center;justify-content:center;
min-height:100vh;margin:0;background:#fdf6ee;color:#3a2e22;text-align:center}
.card{padding:2rem;max-width:320px}p{line-height:1.5}</style></head>
<body><div class="card"><h2>Connecting…</h2>
<p>The frame is joining your WiFi. Look at the frame's screen — a new QR code will appear there for the next step.</p></div></body></html>
)html";

// ── Web server handlers ───────────────────────────────────────────────────────

// In-memory ring buffer of provisioning events (HTTP requests + WiFi
// events) so the user can browse them at http://192.168.4.1/log
// without needing serial. 32 entries is plenty for one provisioning
// session and stays well under the ESP32's heap budget.
static constexpr size_t LOG_CAP = 32;
static String g_log[LOG_CAP];
static size_t g_log_head = 0;
static size_t g_log_count = 0;

static void log_event(const String& line) {
    Serial.println(line);
    g_log[g_log_head] = String(millis()) + "ms " + line;
    g_log_head = (g_log_head + 1) % LOG_CAP;
    if (g_log_count < LOG_CAP) g_log_count++;
}

static void log_provisioning_request() {
    String method = server.method() == HTTP_GET   ? "GET"
                  : server.method() == HTTP_POST  ? "POST"
                  : server.method() == HTTP_HEAD  ? "HEAD"
                  : "OTHER";
    log_event("[http] " + method + " " + server.uri() +
              " host=" + server.hostHeader() +
              " ua=" + server.header("User-Agent"));
}

static void handle_root() {
    log_provisioning_request();
    // no-store stops iOS from caching a CNA result across joins to the
    // same SSID name (e.g. after a forget+rejoin cycle) — we always want
    // the captive banner to fire, never the cached "this network was
    // fine last time" answer.
    server.sendHeader("Cache-Control", "no-store, must-revalidate");
    server.send_P(200, "text/html", PORTAL_HTML);
}

static void handle_connect() {
    log_provisioning_request();
    if (!server.hasArg("ssid") || server.arg("ssid").isEmpty()) {
        server.send(400, "text/plain", "Missing ssid");
        return;
    }
    g_req_ssid = server.arg("ssid");
    g_req_pass = server.arg("pass");
    g_connect_req = true;
    server.send_P(200, "text/html", CONNECTING_HTML);
}

// CNA probe handler — log the hit and 302-redirect to "/". The redirect
// is the trigger iOS / Android / Windows actually look for to raise the
// captive banner. Serving the portal HTML inline (200 OK) didn't work
// reliably for our user; aqua-iq uses 302 on the same paths and the
// captive UI fires every time, so match that.
static void handle_captive() {
    log_provisioning_request();
    server.sendHeader("Location", "http://" AP_IP "/", true);
    server.sendHeader("Cache-Control", "no-store, must-revalidate");
    server.send(302, "text/plain", "");
}

// Diagnostic page — plain text dump of the ring buffer, oldest first.
// User browses to http://192.168.4.1/log after joining the AP. Tells
// us whether iOS hit us at all and which CNA paths it tried.
static void handle_log() {
    String body = "WeVisto provisioning log\n";
    body += "uptime=" + String(millis()) + "ms entries=" + String(g_log_count) + "/" + String(LOG_CAP) + "\n\n";
    size_t start = (g_log_count < LOG_CAP) ? 0 : g_log_head;
    for (size_t i = 0; i < g_log_count; i++) {
        body += g_log[(start + i) % LOG_CAP] + "\n";
    }
    server.sendHeader("Cache-Control", "no-store");
    server.send(200, "text/plain", body);
}

static void on_ap_event(WiFiEvent_t event, WiFiEventInfo_t info) {
    switch (event) {
        case ARDUINO_EVENT_WIFI_AP_START:
            log_event("[wifi] AP started");
            break;
        case ARDUINO_EVENT_WIFI_AP_STACONNECTED:
            log_event("[wifi] station associated");
            break;
        case ARDUINO_EVENT_WIFI_AP_STAIPASSIGNED:
            log_event("[wifi] DHCP lease handed out");
            break;
        case ARDUINO_EVENT_WIFI_AP_STADISCONNECTED:
            log_event("[wifi] station disconnected");
            break;
        default:
            break;
    }
}

// ── WiFi provisioning ─────────────────────────────────────────────────────────

static void enter_provisioning(const String& mac, bool retry = false) {
    // Derive AP name from last 4 hex chars of MAC (no colons)
    String suffix = mac;
    suffix.replace(":", "");
    suffix = suffix.substring(suffix.length() - 4);
    suffix.toUpperCase();
    String apSsid = "WeVisto-" + suffix;

    Serial.println(retry ? "AP (retry): " + apSsid : "AP: " + apSsid);

    // Register handlers BEFORE softAP comes up so a fast-joining iOS
    // device that probes immediately never hits a connection-refused.
    // iOS CNA probes the moment DHCP completes; if the first probe
    // fails the network sometimes gets marked as broken and the
    // captive banner never fires.
    //
    // Every CNA probe path (Apple, Android, Windows) is routed straight
    // to handle_root — i.e. 200 OK with the portal HTML body. We used
    // to 302-redirect to "/", which works on a desktop browser but is
    // unreliable from the iOS lock-screen-camera join path: iOS
    // occasionally treats the redirect as "internet works" and skips
    // the captive banner. A 200 with a non-"Success" body is the
    // canonical signal that this is a captive network.
    server.on("/",                          HTTP_GET,  handle_root);
    server.on("/connect",                   HTTP_POST, handle_connect);
    server.on("/log",                       HTTP_GET,  handle_log);
    // CNA probe paths — return 302 redirect to "/" rather than 200 + portal
    // body. This is the aqua-iq / WiFiManager pattern that empirically fires
    // the iOS captive banner reliably; serving the portal body inline at
    // these endpoints didn't.
    server.on("/hotspot-detect.html",       handle_captive);  // iOS / macOS
    server.on("/library/test/success.html", handle_captive);  // iOS legacy
    server.on("/generate_204",              handle_captive);  // Android
    server.on("/gen_204",                   handle_captive);  // Android variant
    server.on("/ncsi.txt",                  handle_captive);  // Windows
    server.on("/connecttest.txt",           handle_captive);  // Windows 10+
    server.onNotFound(handle_captive);

    // WebServer doesn't capture arbitrary request headers unless told to
    const char* tracked_headers[] = { "User-Agent", "Host" };
    server.collectHeaders(tracked_headers, 2);

    WiFi.onEvent(on_ap_event);
    WiFi.mode(WIFI_AP);
    // WPA2-PSK secured AP on channel 6 — matches aqua-iq's working
    // configuration. softAP signature: ssid, psk, channel, ssid_hidden,
    // max_connection. Channel 6 is the "middle" 2.4 GHz channel, less
    // contested than the default 1 in dense WiFi areas.
    WiFi.softAP(apSsid.c_str(), AP_PASSWORD, AP_CHANNEL);

    // Power-save can park the radio between beacons and drop iOS CNA
    // probes that arrive during a sleep window — keep the AP fully awake
    // so the captive-detect window is deterministic.
    WiFi.setSleep(false);

    // Settle delay — softAPIP is valid immediately, but DHCP server and
    // beacon stability take a moment to come up cleanly. aqua-iq's
    // NetworkManager+dnsmasq pattern needs ~3 s here; the ESP softAP
    // stack is faster, but a 1.5 s pad still saves us from edge-case
    // races where a phone associates and starts DHCP before the AP
    // stack is fully ready.
    delay(AP_SETTLE_MS);

    server.begin();
    dns.setErrorReplyCode(DNSReplyCode::NoError);
    dns.start(53, "*", WiFi.softAPIP());

    // On retry, repaint with red accents + "Connection Failed — try again"
    // label so the user has a clear visual signal that their last credential
    // entry didn't work. On first entry, paint the standard yellow Step 1/2.
    epd_init();
    show_ap_qr(apSsid, retry);
    epd_sleep();

    g_provisioning = true;
}

static bool attempt_wifi(const String& ssid, const String& pass) {
    return ::attempt_wifi(ssid.c_str(), pass.c_str());
}

// ── Normal operation ──────────────────────────────────────────────────────────

static void normal_operation(const String& mac) {
    String url = String(APP_BASE_URL) + "/api/device/" + mac + "/image";

    WiFiClientSecure client;
    client.setInsecure();  // V1: no cert pinning for personal-scale device

    // Bootstrap loop: normal_operation_impl deep-sleeps (never returns) once
    // we've received our first image. While in the pre-image window it
    // returns instead, so we keep WiFi up and retry on a short interval —
    // way faster end-to-end than waiting through a deep-sleep + reconnect
    // for every "no image yet" poll. The loop body is extracted into
    // bootstrap_loop() (operation.h) so it's unit-testable.
    bootstrap_loop([&]() {
        HTTPClient http;
        http.begin(client, url);
        normal_operation_impl(mac, http, url, prefs);
    });
}

// ── Setup ─────────────────────────────────────────────────────────────────────

void setup() {
    Serial.begin(115200);
    Serial.println("pictureFrame boot");

    // BOOT button is panel-agnostic; everything else (CS/DC/RST/BUSY + SPI)
    // is panel-private and lives in the active driver's epd_setup_pins().
    pinMode(PIN_BTN_RESET, INPUT_PULLUP);
    epd_setup_pins();

    LittleFS.begin(true);  // format on first use

    // Check reset button: if held at boot, wipe credentials
    bool clear_creds = check_reset_button();

    prefs.begin(NVS_NAMESPACE, false);
    if (clear_creds) {
        prefs.clear();
        Serial.println("Credentials cleared — entering provisioning");
    }

    String mac = WiFi.macAddress();
    String ssid = prefs.getString(NVS_KEY_SSID, "");
    String pass = prefs.getString(NVS_KEY_PASS, "");
    prefs.end();

    if (ssid.isEmpty()) {
        enter_provisioning(mac);
        return;
    }

    // Attempt to join saved network — uses the shared attempt_wifi() so the
    // fast-fail-on-WL_CONNECT_FAILED behavior covers boot-with-stored-creds
    // too, not just the captive-portal submission path.
    Serial.println("[wifi] connecting to ssid=" + ssid);
    if (attempt_wifi(ssid, pass)) {
        Serial.println("[wifi] connected ip=" + WiFi.localIP().toString());
        normal_operation(mac);
        // normal_operation calls deep_sleep — never returns
    } else {
        Serial.println("[wifi] failed — entering provisioning");
        enter_provisioning(mac);
    }
}

// ── Loop (provisioning mode only) ────────────────────────────────────────────

void loop() {
    if (!g_provisioning) return;

    dns.processNextRequest();
    server.handleClient();

    if (!g_connect_req) return;
    g_connect_req = false;

    dns.stop();
    server.stop();

    String mac = WiFi.macAddress();
    bool ok = attempt_wifi(g_req_ssid, g_req_pass);

    if (ok) {
        // Save credentials for future boots, plus the just-provisioned flag.
        // The server uses that flag to decide whether to serve a (possibly
        // stale) prior-owner image or hold off until the user re-claims via
        // /setup/{mac}. The flag persists in NVS across reboots and only
        // clears when the server returns X-Claimed: 1 (see operation.h).
        prefs.begin(NVS_NAMESPACE, false);
        prefs.putString(NVS_KEY_SSID, g_req_ssid);
        prefs.putString(NVS_KEY_PASS, g_req_pass);
        prefs.putInt(NVS_KEY_JUST_PROVISIONED, 1);
        prefs.end();

        // Show Phase 2 QR and transition to polling loop
        epd_init();
        show_setup_qr(mac);
        epd_sleep();

        g_provisioning = false;

        // Go straight into the polling loop. The 15s FIRST_IMAGE_POLL_INTERVAL_MS
        // bootstrap keeps the QR painted (204s don't redraw the panel) until
        // the user claims via /setup/{mac} and the server's bootstrap-bypass
        // serves an image — no need for an artificial display delay here.
        normal_operation(mac);
    } else {
        // Connection failed — go back into AP mode with the red retry screen
        // so the user sees "Connection Failed — try again" and can rescan.
        // No epd_fill(COLOR_RED) detour: that would obliterate the QR for
        // ~20 s and force a second redraw to put it back. One repaint into
        // the retry screen is faster and clearer.
        enter_provisioning(mac, /*retry=*/true);
    }
}
