#include <Arduino.h>
#include <ArduinoJson.h>
#include <HTTPClient.h>
#include <MFRC522.h>
#include <Preferences.h>
#include <SPI.h>
#include <WebServer.h>
#include <WiFi.h>
#include <WiFiClientSecure.h>
#include <WiFiServer.h>
#include <esp_system.h>
#include <time.h>

#include "dashboard.h"
#include "login.h"
#include "secrets.h"

namespace {
    constexpr char kFirmwareVersion[] = "esp32-rfid-0.4.0";
    constexpr char kRegisterEndpoint[] = "/api/v1/esp/device/register";
    constexpr char kProvisioningEndpoint[] = "/api/v1/esp/device/provisioning-status";
    constexpr char kHeartbeatEndpoint[] = "/api/v1/esp/device/heartbeat";
    constexpr char kTimeEndpoint[] = "/api/v1/esp/device/time";
    constexpr char kTapEndpoint[] = "/api/v1/esp/device/taps";
    constexpr char kWriteJobEndpoint[] = "/api/v1/esp/device/write-jobs/next";

    constexpr unsigned long kDefaultTimePollMs = 60000;
    constexpr unsigned long kDefaultHeartbeatMs = 30000;
    constexpr unsigned long kDefaultWriteJobPollMs = 10000;
    constexpr unsigned long kProvisionPollMs = 10000;
    constexpr unsigned long kRfidPriorityWindowMs = 1200;

    constexpr unsigned long kInitialWifiConnectTimeoutMs = 60000;
    constexpr unsigned long kWifiConnectGraceMs = 30000;

    constexpr char kPrefsNs[] = "sasa-rfid";
    constexpr char kSessionCookieName[] = "sasa_esp_session";
    constexpr unsigned long kSessionTtlMs = 6UL * 60UL * 60UL * 1000UL;
    constexpr size_t kInteractionHistorySize = 10;

    constexpr uint16_t kEventsPort = 81;
    constexpr size_t kMaxEventClients = 3;
    constexpr unsigned long kEventPushMinMs = 900;
    constexpr unsigned long kEventHeartbeatMs = 12000;
    constexpr unsigned long kEventHeaderTimeoutMs = 80;
    constexpr bool kEnableTapTrace = false;

    WebServer server(80);
    WiFiServer eventsServer(kEventsPort);
    Preferences prefs;
    WiFiClient plainClient;
    WiFiClientSecure secureClient;
    MFRC522 rfid(RFID_SS_PIN, RFID_RST_PIN);

    enum class DeviceMode {
        Setup,
        WaitingApproval,
        Idle,
        SyncingTime,
        Heartbeat,
        PollingWriteJob,
        ReadyToWrite,
        WritingCard,
        ReportingTap,
        Error
    };

    struct Config {
        String apiBaseUrl = API_BASE_URL;
        String deviceId = "";
        String apiKey = ESP_API_KEY;
        String readerType = ESP_READER_TYPE;
        String readerName = "SASA RFID Reader";
        String readerLocation = "";
        String notes = "";
        String firmwareVersion = kFirmwareVersion;
        bool canWriteCards = true;
        String registrationToken = "";
        String provisioningStatus = "unregistered";
        String wifiSsid = WIFI_SSID;
        String wifiPassword = WIFI_PASSWORD;
        bool useTls = ESP_USE_TLS;
        bool tlsInsecure = ESP_TLS_INSECURE;
        bool sendTappedAt = ESP_SEND_TAPPED_AT;
        String dashboardUser = CONFIG_WEB_USERNAME;
        String dashboardPassword = CONFIG_WEB_PASSWORD;
    };

    struct ClockState {
        bool synced = false;
        uint64_t unixMsAtSync = 0;
        uint32_t localMsAtSync = 0;
        uint32_t lastSyncMs = 0;
        unsigned long pollMs = kDefaultTimePollMs;
    };

    struct HeartbeatState {
        bool lastOk = false;
        int lastStatus = 0;
        uint32_t lastAttemptMs = 0;
        uint32_t lastOkMs = 0;
        unsigned long pollMs = kDefaultHeartbeatMs;
    };

    struct WriteJob {
        bool active = false;
        long id = 0;
        String label;
        String userName;
        String payload;
        uint32_t claimedMs = 0;
    };

    struct RfidInteraction {
        bool used = false;
        uint32_t localMs = 0;
        String uid;
        String action;
        String result;
    };

    struct EventClientSlot {
        WiFiClient client;
        bool used = false;
        uint32_t connectedMs = 0;
        uint32_t lastSendMs = 0;
        uint32_t lastHeartbeatMs = 0;
    };

    Config cfg;
    ClockState clockState;
    HeartbeatState heartbeatState;
    WriteJob writeJob;
    DeviceMode mode = DeviceMode::Setup;
    String modeMessage = "Booting";
    String lastCardUid = "";
    uint32_t lastCardMs = 0;
    uint32_t lastWifiAttemptMs = 0;
    uint32_t lastProvisionPollMs = 0;
    uint32_t lastWriteJobPollMs = 0;
    uint32_t lastRfidInteractionMs = 0;
    bool apStarted = false;
    String apSsid;
    String lastHttpError = "";
    String sessionToken = "";
    uint32_t sessionCreatedMs = 0;
    RfidInteraction interactions[kInteractionHistorySize];
    size_t interactionWriteIndex = 0;
    size_t interactionCount = 0;

    bool eventsStarted = false;
    EventClientSlot eventClients[kMaxEventClients];
    uint32_t lastEventPushMs = 0;
    uint32_t lastEventKeepAliveMs = 0;
    uint32_t uiRevision = 1;
    uint32_t lastPushedRevision = 0;

    String pendingRfidEventJson = "";
    uint32_t rfidEventRevision = 0;
    uint32_t lastPushedRfidEventRevision = 0;

    String trimString(String value) {
        value.trim();
        return value;
    }

    bool hasEventClients();

    void tapTrace(uint32_t startedMs, const String &uid, const String &step) {
        if (!kEnableTapTrace) return;

        uint32_t now = millis();

        Serial.printf(
            "[TAP_TRACE] +%lums uid=%s step=%s heap=%lu\n",
            static_cast<unsigned long>(now - startedMs),
            uid.c_str(),
            step.c_str(),
            static_cast<unsigned long>(ESP.getFreeHeap())
        );
    }

    void markUiDirty() {
        uiRevision++;
        if (uiRevision == 0) uiRevision = 1;
    }

    String deviceIdFromChip() {
        char buf[32];
        snprintf(buf, sizeof(buf), "esp32-%06llX", ESP.getEfuseMac() & 0xFFFFFFULL);
        return String(buf);
    }

    String modeName(DeviceMode value) {
        switch (value) {
            case DeviceMode::Setup: return "setup";
            case DeviceMode::WaitingApproval: return "waiting approval";
            case DeviceMode::Idle: return "idle";
            case DeviceMode::SyncingTime: return "syncing time";
            case DeviceMode::Heartbeat: return "heartbeat";
            case DeviceMode::PollingWriteJob: return "checking jobs";
            case DeviceMode::ReadyToWrite: return "ready to write";
            case DeviceMode::WritingCard: return "writing card";
            case DeviceMode::ReportingTap: return "reporting tap";
            case DeviceMode::Error: return "error";
        }
        return "unknown";
    }

    void setMode(DeviceMode newMode, const String &message) {
        if (mode != newMode || modeMessage != message) {
            markUiDirty();
        }

        mode = newMode;
        modeMessage = message;

        Serial.printf("[MODE] %s - %s\n", modeName(mode).c_str(), modeMessage.c_str());
    }

    bool isProvisioned() {
        return trimString(cfg.apiKey).length() > 0;
    }

    void normalizeProvisioningState() {
        cfg.firmwareVersion = kFirmwareVersion;

        if (trimString(cfg.apiKey).length() > 0) {
            cfg.provisioningStatus = "provisioned";
        } else if (trimString(cfg.registrationToken).length() > 0) {
            if (cfg.provisioningStatus != "rejected") cfg.provisioningStatus = "pending";
        } else {
            cfg.provisioningStatus = "unregistered";
        }
    }

    uint64_t estimatedUnixMs() {
        if (!clockState.synced) return 0;
        return clockState.unixMsAtSync + static_cast<uint32_t>(millis() - clockState.localMsAtSync);
    }

    String isoUtc(uint64_t unixMs) {
        if (unixMs == 0) return "";

        time_t seconds = static_cast<time_t>(unixMs / 1000ULL);
        uint16_t millisPart = static_cast<uint16_t>(unixMs % 1000ULL);
        tm tmUtc;
        gmtime_r(&seconds, &tmUtc);

        char buf[40];
        snprintf(
            buf,
            sizeof(buf),
            "%04d-%02d-%02dT%02d:%02d:%02d.%03u000",
            tmUtc.tm_year + 1900,
            tmUtc.tm_mon + 1,
            tmUtc.tm_mday,
            tmUtc.tm_hour,
            tmUtc.tm_min,
            tmUtc.tm_sec,
            millisPart
        );

        return String(buf);
    }

    void recordInteraction(const String &uid, const String &action, const String &result) {
        RfidInteraction &entry = interactions[interactionWriteIndex];

        entry.used = true;
        entry.localMs = millis();
        entry.uid = uid;
        entry.action = action;
        entry.result = result;

        interactionWriteIndex = (interactionWriteIndex + 1) % kInteractionHistorySize;
        if (interactionCount < kInteractionHistorySize) interactionCount++;

        if (hasEventClients()) {
            DynamicJsonDocument doc(256);
            doc["localMs"] = entry.localMs;
            doc["uid"] = entry.uid;
            doc["action"] = entry.action;
            doc["result"] = entry.result;

            pendingRfidEventJson = "";
            serializeJson(doc, pendingRfidEventJson);
        } else {
            pendingRfidEventJson = "";
        }

        rfidEventRevision++;
        if (rfidEventRevision == 0) rfidEventRevision = 1;

        markUiDirty();
    }

    void loadConfig() {
        prefs.begin(kPrefsNs, true);

        cfg.apiBaseUrl = prefs.getString("api", cfg.apiBaseUrl);
        cfg.deviceId = prefs.getString("id", cfg.deviceId);
        cfg.apiKey = prefs.getString("key", cfg.apiKey);
        cfg.readerType = prefs.getString("type", cfg.readerType);
        cfg.readerName = prefs.getString("name", cfg.readerName);
        cfg.readerLocation = prefs.getString("loc", cfg.readerLocation);
        cfg.notes = prefs.getString("notes", cfg.notes);
        cfg.canWriteCards = prefs.getBool("write", cfg.canWriteCards);
        cfg.registrationToken = prefs.getString("reg", cfg.registrationToken);
        cfg.provisioningStatus = prefs.getString("prov", cfg.provisioningStatus);

        String savedWifiSsid = prefs.getString("ssid", "");
        String savedWifiPassword = prefs.getString("pass", "");

        if (trimString(savedWifiSsid).length() > 0) {
            cfg.wifiSsid = savedWifiSsid;
        }

        if (savedWifiPassword.length() > 0) {
            cfg.wifiPassword = savedWifiPassword;
        }

        cfg.useTls = prefs.getBool("tls", cfg.useTls);
        cfg.tlsInsecure = prefs.getBool("insec", cfg.tlsInsecure);
        cfg.sendTappedAt = prefs.getBool("tap_at", cfg.sendTappedAt);
        cfg.dashboardUser = prefs.getString("web_u", cfg.dashboardUser);
        cfg.dashboardPassword = prefs.getString("web_p", cfg.dashboardPassword);

        prefs.end();

        if (trimString(cfg.deviceId).length() == 0) cfg.deviceId = deviceIdFromChip();
        if (trimString(cfg.readerType).length() == 0) cfg.readerType = "checkin_checkout";

        cfg.firmwareVersion = kFirmwareVersion;
        normalizeProvisioningState();
        markUiDirty();
    }

    void saveConfig() {
        normalizeProvisioningState();

        prefs.begin(kPrefsNs, false);

        prefs.putString("api", cfg.apiBaseUrl);
        prefs.putString("id", cfg.deviceId);
        prefs.putString("key", cfg.apiKey);
        prefs.putString("type", cfg.readerType);
        prefs.putString("name", cfg.readerName);
        prefs.putString("loc", cfg.readerLocation);
        prefs.putString("notes", cfg.notes);
        prefs.putBool("write", cfg.canWriteCards);
        prefs.putString("reg", cfg.registrationToken);
        prefs.putString("prov", cfg.provisioningStatus);
        prefs.putString("ssid", cfg.wifiSsid);
        prefs.putString("pass", cfg.wifiPassword);
        prefs.putBool("tls", cfg.useTls);
        prefs.putBool("insec", cfg.tlsInsecure);
        prefs.putBool("tap_at", cfg.sendTappedAt);
        prefs.putString("web_u", cfg.dashboardUser);
        prefs.putString("web_p", cfg.dashboardPassword);

        prefs.end();

        markUiDirty();
    }

    void configureTls() {
        if (!cfg.useTls) return;

        if (cfg.tlsInsecure) {
            secureClient.setInsecure();
        } else if (strlen(ESP_TLS_ROOT_CA) > 0) {
            secureClient.setCACert(ESP_TLS_ROOT_CA);
        }
    }

    String apiUrl(const char *path) {
        String base = trimString(cfg.apiBaseUrl);
        while (base.endsWith("/")) base.remove(base.length() - 1);
        return base + String(path);
    }

    bool beginHttp(HTTPClient &http, const String &url) {
        static const char *kResponseHeaders[] = {"X-Process-Time-Ms", "Server-Timing"};
        bool ok = cfg.useTls ? http.begin(secureClient, url) : http.begin(plainClient, url);

        if (!ok) {
            lastHttpError = "HTTP begin failed: " + url;
            markUiDirty();
        }

        http.setTimeout(HTTP_TIMEOUT_MS);
        http.setReuse(true);
        http.collectHeaders(kResponseHeaders, 2);
        http.addHeader("Content-Type", "application/json");
        http.addHeader("Accept", "application/json");
        http.addHeader("Connection", "keep-alive");

        return ok;
    }

    bool beginAuthed(HTTPClient &http, const char *path) {
        if (!beginHttp(http, apiUrl(path))) return false;

        http.addHeader("X-ESP-Device-ID", cfg.deviceId);
        http.addHeader("X-ESP-API-Key", cfg.apiKey);

        return true;
    }

    bool beginAuthedTap(HTTPClient &http) {
        if (!beginAuthed(http, kTapEndpoint)) return false;
        http.addHeader("X-ESP-Compact-Response", "1");
        return true;
    }

    bool beginProvisioning(HTTPClient &http, const char *path) {
        if (!beginHttp(http, apiUrl(path))) return false;

        http.addHeader("X-ESP-Device-ID", cfg.deviceId);
        http.addHeader("X-ESP-Registration-Token", cfg.registrationToken);

        return true;
    }

    bool httpOk(int status, const String &body, const String &context) {
        if (status >= 200 && status < 300) {
            if (lastHttpError.length()) markUiDirty();
            lastHttpError = "";
            return true;
        }

        lastHttpError = context + " failed HTTP " + String(status) + ": " + body;
        Serial.println("[HTTP] " + lastHttpError);
        setMode(DeviceMode::Error, lastHttpError);

        return false;
    }

    const char *wifiStatusName(wl_status_t status) {
        switch (status) {
            case WL_IDLE_STATUS: return "idle";
            case WL_NO_SSID_AVAIL: return "ssid not available";
            case WL_SCAN_COMPLETED: return "scan completed";
            case WL_CONNECTED: return "connected";
            case WL_CONNECT_FAILED: return "connect failed";
            case WL_CONNECTION_LOST: return "connection lost";
            case WL_DISCONNECTED: return "disconnected";
            default: return "unknown";
        }
    }

    bool scanForWifiNetwork(const String &targetSsid) {
        Serial.printf("[WiFi] Scanning for SSID '%s'...\n", targetSsid.c_str());

        WiFi.mode(apStarted ? WIFI_AP_STA : WIFI_STA);
        delay(200);

        int count = WiFi.scanNetworks(false, true);

        if (count < 0) {
            Serial.printf("[WiFi] Scan failed: %d\n", count);
            return false;
        }

        Serial.printf("[WiFi] Scan found %d network(s)\n", count);

        bool found = false;

        for (int i = 0; i < count; i++) {
            String ssid = WiFi.SSID(i);
            int32_t rssi = WiFi.RSSI(i);
            int32_t channel = WiFi.channel(i);
            wifi_auth_mode_t auth = WiFi.encryptionType(i);

            Serial.printf(
                "[WiFi]   %2d: '%s' RSSI=%d dBm channel=%d auth=%d%s\n",
                i + 1,
                ssid.c_str(),
                rssi,
                channel,
                static_cast<int>(auth),
                ssid == targetSsid ? "  <-- target" : ""
            );

            if (ssid == targetSsid) {
                found = true;
            }

            delay(5);
        }

        WiFi.scanDelete();

        if (!found) {
            Serial.printf("[WiFi] Target SSID '%s' was NOT found in scan\n", targetSsid.c_str());
        } else {
            Serial.printf("[WiFi] Target SSID '%s' was found\n", targetSsid.c_str());
        }

        return found;
    }

    void ensureWifi(bool force = false) {
        wl_status_t status = WiFi.status();

        if (status == WL_CONNECTED) return;

        uint32_t now = millis();
        String ssid = trimString(cfg.wifiSsid);

        if (ssid.length() == 0) {
            Serial.println("[WiFi] No saved SSID; setup AP will be used");
            return;
        }

        if (
            !force &&
            lastWifiAttemptMs > 0 &&
            static_cast<uint32_t>(now - lastWifiAttemptMs) < kWifiConnectGraceMs
        ) {
            return;
        }

        if (!force && static_cast<uint32_t>(now - lastWifiAttemptMs) < WIFI_RECONNECT_INTERVAL_MS) {
            return;
        }

        lastWifiAttemptMs = now;

        WiFi.persistent(false);
        WiFi.setSleep(false);
        WiFi.setAutoReconnect(true);

        Serial.printf(
            "[WiFi] Connecting to SSID '%s' from status %s...\n",
            ssid.c_str(),
            wifiStatusName(status)
        );

        Serial.printf("[WiFi] Password length: %d\n", cfg.wifiPassword.length());

        WiFi.mode(apStarted ? WIFI_AP_STA : WIFI_STA);
        delay(100);

        WiFi.disconnect(false, false);
        delay(500);

        WiFi.begin(ssid.c_str(), cfg.wifiPassword.c_str());

        markUiDirty();
    }

    void startSetupAp() {
        if (apStarted) return;

        char suffix[7];
        snprintf(suffix, sizeof(suffix), "%06llX", ESP.getEfuseMac() & 0xFFFFFFULL);

        apSsid = String(CONFIG_AP_SSID_PREFIX) + suffix;

        WiFi.mode(WIFI_AP_STA);
        WiFi.softAP(apSsid.c_str(), CONFIG_AP_PASSWORD);

        apStarted = true;
        markUiDirty();

        Serial.printf("[AP] %s started at %s\n", apSsid.c_str(), WiFi.softAPIP().toString().c_str());
    }

    bool waitForWifi(uint32_t timeoutMs) {
        uint32_t start = millis();
        uint32_t lastPrintMs = 0;
        wl_status_t lastStatus = WiFi.status();

        Serial.printf("[WiFi] Waiting up to %u ms for connection...\n", timeoutMs);

        while (static_cast<uint32_t>(millis() - start) < timeoutMs) {
            wl_status_t status = WiFi.status();

            if (status == WL_CONNECTED) {
                Serial.printf(
                    "[WiFi] Connected: %s RSSI=%d dBm\n",
                    WiFi.localIP().toString().c_str(),
                    WiFi.RSSI()
                );

                markUiDirty();
                return true;
            }

            uint32_t now = millis();

            if (status != lastStatus || static_cast<uint32_t>(now - lastPrintMs) >= 3000) {
                lastStatus = status;
                lastPrintMs = now;

                Serial.printf(
                    "[WiFi] Still waiting... status=%s raw=%d elapsed=%lus\n",
                    wifiStatusName(status),
                    static_cast<int>(status),
                    static_cast<unsigned long>((now - start) / 1000UL)
                );
            }

            delay(250);
        }

        Serial.printf(
            "[WiFi] Connection timed out. Final status: %s raw=%d\n",
            wifiStatusName(WiFi.status()),
            static_cast<int>(WiFi.status())
        );

        markUiDirty();

        return false;
    }

    String makeSessionToken() {
        uint64_t mac = ESP.getEfuseMac();

        char buf[80];
        snprintf(
            buf,
            sizeof(buf),
            "%08lX%08lX%08lX%08lX",
            static_cast<unsigned long>(mac & 0xFFFFFFFFULL),
            static_cast<unsigned long>((mac >> 32) & 0xFFFFFFFFULL),
            static_cast<unsigned long>(esp_random()),
            static_cast<unsigned long>(millis())
        );

        return String(buf);
    }

    bool constantTimeEquals(const String &a, const String &b) {
        if (a.length() != b.length()) return false;

        uint8_t diff = 0;

        for (size_t i = 0; i < a.length(); i++) {
            diff |= static_cast<uint8_t>(a[i] ^ b[i]);
        }

        return diff == 0;
    }

    bool sessionExpired() {
        if (sessionToken.length() == 0) return true;
        return static_cast<uint32_t>(millis() - sessionCreatedMs) > kSessionTtlMs;
    }

    String cookieValue(const String &name) {
        if (!server.hasHeader("Cookie")) return "";

        String cookie = server.header("Cookie");
        String needle = name + "=";

        int start = cookie.indexOf(needle);
        if (start < 0) return "";

        start += needle.length();

        int end = cookie.indexOf(';', start);
        if (end < 0) end = cookie.length();

        String value = cookie.substring(start, end);
        value.trim();

        return value;
    }

    bool hasValidSession() {
        if (sessionExpired()) return false;

        String token = cookieValue(kSessionCookieName);
        if (token.length() == 0) return false;

        return constantTimeEquals(token, sessionToken);
    }

    void redirectToLogin() {
        server.sendHeader("Location", "/login");
        server.send(302, "text/plain", "Redirecting to login");
    }

    bool requireAuth() {
        if (hasValidSession()) return true;

        if (server.uri().startsWith("/api/")) {
            server.send(401, "text/plain", "Not signed in");
        } else {
            redirectToLogin();
        }

        return false;
    }

    void sendJson(JsonDocument &doc) {
        String body;
        serializeJson(doc, body);
        server.send(200, "application/json", body);
    }

    bool parseJsonBody(DynamicJsonDocument &doc) {
        DeserializationError err = deserializeJson(doc, server.arg("plain"));

        if (err) {
            server.send(400, "text/plain", "Invalid JSON");
            return false;
        }

        return true;
    }

    void buildStatusDocument(JsonDocument &doc) {
        JsonObject runtime = doc.createNestedObject("runtime");
        runtime["mode"] = modeName(mode);
        runtime["message"] = modeMessage;
        runtime["ok"] = mode != DeviceMode::Error;
        runtime["freeHeap"] = ESP.getFreeHeap();
        runtime["deviceMillis"] = millis();
        runtime["revision"] = uiRevision;
        runtime["eventsPort"] = kEventsPort;
        runtime["eventsClients"] = 0;

        for (size_t i = 0; i < kMaxEventClients; i++) {
            if (eventClients[i].used && eventClients[i].client.connected()) {
                runtime["eventsClients"] = runtime["eventsClients"].as<int>() + 1;
            }
        }

        JsonObject config = doc.createNestedObject("config");
        config["apiBaseUrl"] = cfg.apiBaseUrl;
        config["deviceId"] = cfg.deviceId;
        config["readerType"] = cfg.readerType;
        config["readerName"] = cfg.readerName;
        config["readerLocation"] = cfg.readerLocation;
        config["notes"] = cfg.notes;
        config["firmwareVersion"] = kFirmwareVersion;
        config["canWriteCards"] = cfg.canWriteCards;
        config["useTls"] = cfg.useTls;
        config["tlsInsecure"] = cfg.tlsInsecure;
        config["sendTappedAt"] = cfg.sendTappedAt;

        JsonObject wifi = doc.createNestedObject("wifi");
        wifi["connected"] = WiFi.status() == WL_CONNECTED;
        wifi["status"] = wifiStatusName(WiFi.status());
        wifi["ip"] = WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString() : "";
        wifi["rssi"] = WiFi.status() == WL_CONNECTED ? WiFi.RSSI() : 0;
        wifi["apStarted"] = apStarted;
        wifi["apSsid"] = apSsid;
        wifi["apIp"] = apStarted ? WiFi.softAPIP().toString() : "";
        wifi["hasSavedSsid"] = cfg.wifiSsid.length() > 0;
        wifi["hasSavedPassword"] = cfg.wifiPassword.length() > 0;
        wifi["lastAttemptMs"] = lastWifiAttemptMs;

        JsonObject prov = doc.createNestedObject("provisioning");
        prov["status"] = cfg.provisioningStatus;
        prov["hasRegistrationToken"] = cfg.registrationToken.length() > 0;
        prov["hasApiKey"] = cfg.apiKey.length() > 0;

        JsonObject timeObj = doc.createNestedObject("time");
        timeObj["synced"] = clockState.synced;
        timeObj["serverUnixMs"] = estimatedUnixMs();
        timeObj["lastSyncLocalMs"] = clockState.lastSyncMs;
        timeObj["pollMs"] = clockState.pollMs;

        JsonObject hb = doc.createNestedObject("heartbeat");
        hb["lastStatus"] = heartbeatState.lastStatus;
        hb["lastOk"] = heartbeatState.lastOk;
        hb["lastAttemptMs"] = heartbeatState.lastAttemptMs;
        hb["lastOkMs"] = heartbeatState.lastOkMs;
        hb["pollMs"] = heartbeatState.pollMs;

        JsonObject job = doc.createNestedObject("writeJob");
        job["active"] = writeJob.active;
        job["id"] = writeJob.id;
        job["label"] = writeJob.label;
        job["userName"] = writeJob.userName;
        job["payloadLength"] = writeJob.payload.length();
        job["claimedMs"] = writeJob.claimedMs;

        JsonObject rfidObj = doc.createNestedObject("rfid");
        rfidObj["lastInteractionMs"] = lastRfidInteractionMs;
        JsonArray history = rfidObj.createNestedArray("history");

        for (size_t i = 0; i < interactionCount; i++) {
            size_t idx =
                    (interactionWriteIndex + kInteractionHistorySize - 1 - i) %
                    kInteractionHistorySize;

            const RfidInteraction &entry = interactions[idx];

            if (!entry.used) continue;

            JsonObject item = history.createNestedObject();
            item["localMs"] = entry.localMs;
            item["uid"] = entry.uid;
            item["action"] = entry.action;
            item["result"] = entry.result;
        }
    }

    String makeStatusJson() {
        DynamicJsonDocument doc(6144);
        buildStatusDocument(doc);

        String body;
        serializeJson(doc, body);

        return body;
    }

    void handleLoginPage() {
        if (hasValidSession()) {
            server.sendHeader("Location", "/");
            server.send(302, "text/plain", "Already signed in");
            return;
        }

        server.send_P(200, "text/html", ESP_LOGIN_HTML);
    }

    void handleLogin() {
        DynamicJsonDocument doc(512);

        if (!parseJsonBody(doc)) return;

        String submittedUser = trimString(String((const char *) (doc["username"] | "")));
        String submittedPass = String((const char *) (doc["password"] | ""));

        String expectedUser = cfg.dashboardUser.length() ? cfg.dashboardUser : "admin";
        String expectedPass = cfg.dashboardPassword.length() ? cfg.dashboardPassword : "admin";

        bool ok =
                constantTimeEquals(submittedUser, expectedUser) &&
                constantTimeEquals(submittedPass, expectedPass);

        if (!ok) {
            delay(250);
            server.send(401, "text/plain", "Invalid username or password");
            return;
        }

        sessionToken = makeSessionToken();
        sessionCreatedMs = millis();

        String cookie =
                String(kSessionCookieName) +
                "=" +
                sessionToken +
                "; Path=/; Max-Age=21600; SameSite=Lax";

        server.sendHeader("Set-Cookie", cookie);
        server.send(200, "text/plain", "Signed in");
    }

    void handleLogout() {
        sessionToken = "";
        sessionCreatedMs = 0;

        String cookie =
                String(kSessionCookieName) +
                "=; Path=/; Max-Age=0; SameSite=Lax";

        server.sendHeader("Set-Cookie", cookie);
        server.send(200, "text/plain", "Signed out");
    }

    void handleDashboard() {
        if (!requireAuth()) return;
        server.send_P(200, "text/html", ESP_DASHBOARD_HTML);
    }

    void handleStatus() {
        if (!requireAuth()) return;

        DynamicJsonDocument doc(6144);
        buildStatusDocument(doc);
        sendJson(doc);
    }

    void handleWifiConfig() {
        if (!requireAuth()) return;

        DynamicJsonDocument doc(384);
        doc["wifiSsid"] = cfg.wifiSsid;
        doc["hasWifiPassword"] = cfg.wifiPassword.length() > 0;

        sendJson(doc);
    }

    void handleSaveConfig() {
        if (!requireAuth()) return;

        DynamicJsonDocument doc(2048);

        if (!parseJsonBody(doc)) return;

        String submittedApiKey = trimString(String((const char *) (doc["apiKey"] | "")));
        String submittedWifiPassword = String((const char *) (doc["wifiPassword"] | ""));
        String submittedDashboardUser = trimString(String((const char *) (doc["dashboardUser"] | "")));
        String submittedDashboardPassword = String((const char *) (doc["dashboardPassword"] | ""));

        cfg.apiBaseUrl = trimString(String((const char *) (doc["apiBaseUrl"] | cfg.apiBaseUrl.c_str())));
        cfg.deviceId = trimString(String((const char *) (doc["deviceId"] | cfg.deviceId.c_str())));

        if (submittedApiKey.length() > 0) {
            cfg.apiKey = submittedApiKey;
        }

        cfg.readerType = trimString(String((const char *) (doc["readerType"] | "checkin_checkout")));
        cfg.readerName = trimString(String((const char *) (doc["readerName"] | cfg.readerName.c_str())));
        cfg.readerLocation = trimString(String((const char *) (doc["readerLocation"] | cfg.readerLocation.c_str())));
        cfg.notes = trimString(String((const char *) (doc["notes"] | cfg.notes.c_str())));
        cfg.firmwareVersion = kFirmwareVersion;

        String submittedWifiSsid = trimString(String((const char *) (doc["wifiSsid"] | "")));

        if (submittedWifiSsid.length() > 0) {
            cfg.wifiSsid = submittedWifiSsid;
        }

        if (submittedWifiPassword.length() > 0) {
            cfg.wifiPassword = submittedWifiPassword;
        }

        if (submittedDashboardUser.length() > 0) {
            cfg.dashboardUser = submittedDashboardUser;
        }

        if (submittedDashboardPassword.length() > 0) {
            cfg.dashboardPassword = submittedDashboardPassword;
        }

        cfg.canWriteCards = doc["canWriteCards"] | false;
        cfg.useTls = doc["useTls"] | false;
        cfg.tlsInsecure = doc["tlsInsecure"] | false;
        cfg.sendTappedAt = doc["sendTappedAt"] | true;

        if (cfg.deviceId.length() == 0) cfg.deviceId = deviceIdFromChip();
        if (cfg.readerType.length() == 0) cfg.readerType = "checkin_checkout";

        saveConfig();
        configureTls();

        if (WiFi.status() != WL_CONNECTED) {
            ensureWifi(true);
        }

        setMode(isProvisioned() ? DeviceMode::Idle : DeviceMode::Setup, "Configuration saved");
        server.send(200, "text/plain", "Configuration saved");
    }

    bool registerReader(String &message) {
        if (WiFi.status() != WL_CONNECTED) {
            message = "WiFi is not connected";
            return false;
        }

        if (cfg.apiBaseUrl.length() == 0 || cfg.deviceId.length() == 0) {
            message = "Backend URL and device ID are required";
            return false;
        }

        setMode(DeviceMode::Setup, "Registering reader");

        HTTPClient http;

        if (!beginHttp(http, apiUrl(kRegisterEndpoint))) {
            message = lastHttpError;
            return false;
        }

        DynamicJsonDocument req(1024);
        req["device_id"] = cfg.deviceId;
        req["name"] = cfg.readerName;
        req["location"] = cfg.readerLocation;
        req["reader_type"] = cfg.readerType;
        req["can_write_cards"] = cfg.canWriteCards;
        req["firmware_version"] = kFirmwareVersion;
        req["notes"] = cfg.notes;

        String payload;
        serializeJson(req, payload);

        int status = http.POST(payload);
        String body = http.getString();

        http.end();

        if (!httpOk(status, body, "register")) {
            message = lastHttpError;
            return false;
        }

        DynamicJsonDocument resp(1024);

        if (deserializeJson(resp, body)) {
            message = "Registration response was invalid JSON";
            return false;
        }

        cfg.registrationToken = String((const char *) (resp["registration_token"] | ""));
        cfg.provisioningStatus = String((const char *) (resp["provisioning_status"] | "pending"));
        cfg.apiKey = "";

        saveConfig();

        setMode(DeviceMode::WaitingApproval, "Registered. Waiting for admin approval.");

        message = String((const char *) (resp["message"] | "Registered"));

        return true;
    }

    void handleRegister() {
        if (!requireAuth()) return;

        String message;
        bool ok = registerReader(message);

        server.send(ok ? 200 : 400, "text/plain", message);
    }

    bool pollProvisioning() {
        if (isProvisioned() || cfg.registrationToken.length() == 0 || WiFi.status() != WL_CONNECTED) {
            return isProvisioned();
        }

        setMode(DeviceMode::WaitingApproval, "Checking provisioning status");

        HTTPClient http;

        if (!beginProvisioning(http, kProvisioningEndpoint)) {
            return false;
        }

        int status = http.GET();
        String body = http.getString();

        http.end();

        if (!httpOk(status, body, "provisioning")) {
            return false;
        }

        DynamicJsonDocument resp(1024);
        DeserializationError err = deserializeJson(resp, body);

        if (err) {
            Serial.printf("[PROVISION] JSON parse error: %s\n", err.c_str());
            return false;
        }

        cfg.provisioningStatus = String((const char *) (resp["provisioning_status"] | "pending"));

        String parsedApiKey = trimString(String((const char *) (resp["api_key"] | "")));

        if (parsedApiKey.length() == 0) {
            parsedApiKey = trimString(String((const char *) (resp["apiKey"] | "")));
        }

        Serial.printf(
            "[PROVISION] status=%s api_key_present=%s\n",
            cfg.provisioningStatus.c_str(),
            parsedApiKey.length() > 0 ? "true" : "false"
        );

        if (parsedApiKey.length() > 0) {
            cfg.apiKey = parsedApiKey;
            cfg.provisioningStatus = "provisioned";
            cfg.registrationToken = "";

            saveConfig();

            setMode(DeviceMode::Idle, "Provisioned. API key stored.");
        } else if (cfg.provisioningStatus == "rejected") {
            setMode(DeviceMode::Error, "Registration rejected by admin");
        } else if (cfg.provisioningStatus == "provisioned") {
            setMode(DeviceMode::WaitingApproval,
                    "Server says provisioned, but no API key was returned yet. Polling again.");
        } else {
            setMode(DeviceMode::WaitingApproval, "Waiting for admin approval");
        }

        if (parsedApiKey.length() == 0) {
            saveConfig();
        }

        return isProvisioned();
    }

    bool syncTime() {
        if (!isProvisioned()) {
            lastHttpError = "Reader is not provisioned yet; no API key is stored.";
            setMode(DeviceMode::WaitingApproval, lastHttpError);
            return false;
        }

        if (WiFi.status() != WL_CONNECTED) {
            lastHttpError = "WiFi is not connected.";
            setMode(DeviceMode::Error, lastHttpError);
            return false;
        }

        setMode(DeviceMode::SyncingTime, "Syncing clock from server");

        HTTPClient http;

        if (!beginAuthed(http, kTimeEndpoint)) {
            return false;
        }

        int status = http.GET();
        String body = http.getString();

        http.end();

        if (!httpOk(status, body, "time sync")) {
            return false;
        }

        DynamicJsonDocument resp(1024);

        if (deserializeJson(resp, body)) {
            return false;
        }

        uint64_t unixMs = resp["unix_ms"] | 0ULL;
        unsigned long pollSeconds = resp["poll_interval_seconds"] | 60UL;

        if (unixMs > 0) {
            clockState.synced = true;
            clockState.unixMsAtSync = unixMs;
            clockState.localMsAtSync = millis();
            clockState.lastSyncMs = millis();
        }

        clockState.pollMs = (pollSeconds ? pollSeconds : 60UL) * 1000UL;

        setMode(writeJob.active ? DeviceMode::ReadyToWrite : DeviceMode::Idle, "Clock synced");

        markUiDirty();

        return true;
    }

    void handleSyncTime() {
        if (!requireAuth()) return;

        bool ok = syncTime();

        server.send(
            ok ? 200 : 400,
            "text/plain",
            ok ? "Time synced" : ("Time sync failed: " + lastHttpError)
        );
    }

    bool sendHeartbeat() {
        if (!isProvisioned() || WiFi.status() != WL_CONNECTED) {
            return false;
        }

        DeviceMode prior = mode;
        String priorMessage = modeMessage;

        setMode(DeviceMode::Heartbeat, "Sending heartbeat");

        HTTPClient http;

        if (!beginAuthed(http, kHeartbeatEndpoint)) {
            return false;
        }

        DynamicJsonDocument req(512);
        req["mode"] = modeName(prior);
        req["message"] = priorMessage;
        req["wifi_rssi"] = WiFi.RSSI();
        req["free_heap"] = ESP.getFreeHeap();
        req["firmware_version"] = kFirmwareVersion;

        if (writeJob.active) {
            req["active_write_job_id"] = writeJob.id;
        }

        String payload;
        serializeJson(req, payload);

        int status = http.POST(payload);
        String body = http.getString();

        http.end();

        heartbeatState.lastAttemptMs = millis();
        heartbeatState.lastStatus = status;

        if (!httpOk(status, body, "heartbeat")) {
            heartbeatState.lastOk = false;
            markUiDirty();
            return false;
        }

        DynamicJsonDocument resp(768);

        if (!deserializeJson(resp, body)) {
            heartbeatState.pollMs =
                    (static_cast<unsigned long>(resp["heartbeat_interval_seconds"] | 30UL)) *
                    1000UL;

            clockState.pollMs =
                    (static_cast<unsigned long>(resp["time_poll_interval_seconds"] | 60UL)) *
                    1000UL;

            if (heartbeatState.pollMs < 10000UL) heartbeatState.pollMs = 10000UL;
            if (clockState.pollMs < 30000UL) clockState.pollMs = 30000UL;
        }

        heartbeatState.lastOk = true;
        heartbeatState.lastOkMs = millis();

        setMode(writeJob.active ? DeviceMode::ReadyToWrite : DeviceMode::Idle, "Heartbeat OK");

        markUiDirty();

        return true;
    }

    String normalizeUid(const MFRC522::Uid &uid) {
        String out;

        for (byte i = 0; i < uid.size; i++) {
            if (uid.uidByte[i] < 0x10) out += "0";
            out += String(uid.uidByte[i], HEX);
        }

        out.toUpperCase();

        return out;
    }

    bool duplicateCard(const String &uid) {
        uint32_t now = millis();

        if (
            uid == lastCardUid &&
            static_cast<uint32_t>(now - lastCardMs) < RFID_DUPLICATE_SUPPRESS_MS
        ) {
            return true;
        }

        lastCardUid = uid;
        lastCardMs = now;

        return false;
    }

    bool reportTap(const String &uid) {
        uint32_t tapStartedMs = lastRfidInteractionMs ? lastRfidInteractionMs : millis();

        tapTrace(tapStartedMs, uid, "reportTap-enter");

        if (!isProvisioned() || WiFi.status() != WL_CONNECTED) {
            tapTrace(tapStartedMs, uid, "offline-or-not-provisioned");
            recordInteraction(uid, "tap skipped", "Reader is offline or not provisioned");
            tapTrace(tapStartedMs, uid, "recordInteraction-offline-finished");
            return false;
        }

        setMode(DeviceMode::ReportingTap, "Reporting RFID tap " + uid);
        tapTrace(tapStartedMs, uid, "mode-reporting-set");

        HTTPClient http;

        tapTrace(tapStartedMs, uid, "http-begin-start");

        if (!beginAuthedTap(http)) {
            tapTrace(tapStartedMs, uid, "http-begin-failed");
            recordInteraction(uid, "tap failed", lastHttpError);
            tapTrace(tapStartedMs, uid, "recordInteraction-http-begin-failed-finished");
            return false;
        }

        tapTrace(tapStartedMs, uid, "http-begin-finished");

        DynamicJsonDocument req(512);
        req["card_uid"] = uid;
        req["reader_type"] = cfg.readerType;

        if (cfg.sendTappedAt && clockState.synced) {
            req["tapped_at"] = isoUtc(estimatedUnixMs());
        }

        tapTrace(tapStartedMs, uid, "json-build-finished");

        String payload;
        serializeJson(req, payload);

        tapTrace(tapStartedMs, uid, "json-serialize-finished");

        int status = http.POST(payload);

        tapTrace(tapStartedMs, uid, "http-post-finished-status-" + String(status));

        String processTimeMs = http.header("X-Process-Time-Ms");
        String serverTiming = http.header("Server-Timing");
        if (processTimeMs.length()) {
            tapTrace(tapStartedMs, uid, "resp-header-x-process-time-ms-" + processTimeMs);
        }
        if (serverTiming.length()) {
            String normalizedServerTiming = serverTiming;
            normalizedServerTiming.replace(" ", "");
            tapTrace(tapStartedMs, uid, "resp-header-server-timing-" + normalizedServerTiming);
        }

        String body = http.getString();

        tapTrace(tapStartedMs, uid, "http-body-read-finished-bytes-" + String(body.length()));

        http.end();

        tapTrace(tapStartedMs, uid, "http-end-finished");

        if (!httpOk(status, body, "tap")) {
            tapTrace(tapStartedMs, uid, "http-status-not-ok");
            recordInteraction(uid, "tap failed", lastHttpError);
            tapTrace(tapStartedMs, uid, "recordInteraction-http-failed-finished");
            return false;
        }

        DynamicJsonDocument resp(192);

        tapTrace(tapStartedMs, uid, "response-json-parse-start");

        if (deserializeJson(resp, body)) {
            tapTrace(tapStartedMs, uid, "response-json-parse-failed");
            recordInteraction(uid, "tap failed", "Tap response was invalid JSON");
            tapTrace(tapStartedMs, uid, "recordInteraction-json-failed-finished");
            return false;
        }

        tapTrace(tapStartedMs, uid, "response-json-parse-finished");

        String action = String((const char *) (resp["a"] | resp["action"] | "unknown"));
        String result = String((const char *) (resp["m"] | resp["message"] | ""));

        tapTrace(tapStartedMs, uid, "result-build-finished");

        recordInteraction(uid, action, result);

        tapTrace(tapStartedMs, uid, "recordInteraction-finished-sse-event-queued");

        setMode(DeviceMode::Idle, "Tap processed: " + action);

        tapTrace(tapStartedMs, uid, "mode-idle-set");

        return true;
    }

    bool pollWriteJob() {
        if (
            !cfg.canWriteCards ||
            writeJob.active ||
            !isProvisioned() ||
            WiFi.status() != WL_CONNECTED
        ) {
            return false;
        }

        setMode(DeviceMode::PollingWriteJob, "Checking for queued card write");

        HTTPClient http;

        if (!beginAuthed(http, kWriteJobEndpoint)) {
            return false;
        }

        int status = http.GET();
        String body = http.getString();

        http.end();

        if (!httpOk(status, body, "write job poll")) {
            return false;
        }

        String trimmed = trimString(body);

        if (trimmed.length() == 0 || trimmed == "null") {
            setMode(DeviceMode::Idle, "No write job queued");
            return false;
        }

        DynamicJsonDocument resp(2048);

        if (deserializeJson(resp, body)) {
            return false;
        }

        writeJob.active = true;
        writeJob.id = resp["id"] | 0;
        writeJob.label = String((const char *) (resp["label"] | ""));
        writeJob.payload = String((const char *) (resp["write_payload"] | ""));
        writeJob.claimedMs = millis();

        DynamicJsonDocument payloadDoc(1024);

        if (!deserializeJson(payloadDoc, writeJob.payload)) {
            writeJob.userName = String((const char *) (payloadDoc["user_name"] | ""));
        }

        setMode(DeviceMode::ReadyToWrite, "Write job ready. Tap a card now.");

        markUiDirty();

        return true;
    }

    void handlePollJob() {
        if (!requireAuth()) return;

        bool ok = pollWriteJob();

        server.send(
            200,
            "text/plain",
            ok ? "Write job claimed" : "No write job available"
        );
    }

    bool completeWriteJob(bool success, const String &uid, const String &error) {
        if (!writeJob.active) return false;

        String endpoint =
                "/api/v1/esp/device/write-jobs/" +
                String(writeJob.id) +
                "/complete";

        HTTPClient http;

        if (!beginAuthed(http, endpoint.c_str())) {
            return false;
        }

        DynamicJsonDocument req(512);
        req["success"] = success;

        if (success) {
            req["card_uid"] = uid;
        } else {
            req["error_message"] = error;
        }

        String payload;
        serializeJson(req, payload);

        int status = http.POST(payload);
        String body = http.getString();

        http.end();

        return httpOk(status, body, "write complete");
    }

    bool writeMifareClassic(const String &payload, String &error) {
        const size_t maxBytes = 16 * 45;

        if (payload.length() + 2 > maxBytes) {
            error = "Payload too large for MIFARE Classic demo writer";
            return false;
        }

        MFRC522::MIFARE_Key key;

        for (byte i = 0; i < 6; i++) {
            key.keyByte[i] = 0xFF;
        }

        byte blockData[16];
        size_t offset = 0;
        uint16_t payloadLen = payload.length();
        bool wroteLength = false;

        for (
            byte block = 4;
            block < 64 && (offset < payload.length() || !wroteLength);
            block++
        ) {
            if ((block + 1) % 4 == 0) continue;

            memset(blockData, 0, sizeof(blockData));

            byte start = 0;

            if (!wroteLength) {
                blockData[0] = payloadLen & 0xFF;
                blockData[1] = (payloadLen >> 8) & 0xFF;
                start = 2;
                wroteLength = true;
            }

            for (byte i = start; i < 16 && offset < payload.length(); i++) {
                blockData[i] = static_cast<byte>(payload[offset++]);
            }

            MFRC522::StatusCode auth =
                    rfid.PCD_Authenticate(
                        MFRC522::PICC_CMD_MF_AUTH_KEY_A,
                        block,
                        &key,
                        &rfid.uid
                    );

            if (auth != MFRC522::STATUS_OK) {
                error = "Auth failed at block " + String(block);
                return false;
            }

            MFRC522::StatusCode written = rfid.MIFARE_Write(block, blockData, 16);

            if (written != MFRC522::STATUS_OK) {
                error = "Write failed at block " + String(block);
                return false;
            }

            yield();
        }

        return true;
    }

    void handleCardForWrite(const String &uid) {
        setMode(DeviceMode::WritingCard, "Writing queued card " + uid);

        String error;
        bool success = false;

        MFRC522::PICC_Type type = rfid.PICC_GetType(rfid.uid.sak);

        if (
            type == MFRC522::PICC_TYPE_MIFARE_1K ||
            type == MFRC522::PICC_TYPE_MIFARE_4K
        ) {
            success = writeMifareClassic(writeJob.payload, error);
        } else {
            error = "Unsupported RFID card type for writing";
        }

        bool reported = completeWriteJob(success, uid, error);

        String action = success ? "write completed" : "write failed";
        String result = success ? ("Wrote " + writeJob.label) : error;

        if (!reported) {
            result += " · result could not be reported";
        }

        recordInteraction(uid, action, result);

        if (reported) {
            writeJob = WriteJob{};
            setMode(DeviceMode::Idle, success ? "Card write completed" : "Card write failed");
        } else {
            setMode(DeviceMode::Error, "Card write result could not be reported");
        }

        markUiDirty();
    }

    void pollRfid() {
        if (!rfid.PICC_IsNewCardPresent()) return;

        uint32_t tapStartedMs = millis();

        Serial.printf(
            "[TAP_TRACE] +0ms uid=? step=card-present heap=%lu\n",
            static_cast<unsigned long>(ESP.getFreeHeap())
        );

        if (!rfid.PICC_ReadCardSerial()) {
            Serial.printf(
                "[TAP_TRACE] +%lums uid=? step=read-card-serial-failed heap=%lu\n",
                static_cast<unsigned long>(millis() - tapStartedMs),
                static_cast<unsigned long>(ESP.getFreeHeap())
            );
            return;
        }

        lastRfidInteractionMs = millis();
        markUiDirty();

        String uid = normalizeUid(rfid.uid);

        tapTrace(tapStartedMs, uid, "uid-read");

        if (!duplicateCard(uid)) {
            tapTrace(tapStartedMs, uid, "not-duplicate");

            if (writeJob.active) {
                tapTrace(tapStartedMs, uid, "write-handler-start");
                handleCardForWrite(uid);
                tapTrace(tapStartedMs, uid, "write-handler-finished");
            } else {
                tapTrace(tapStartedMs, uid, "report-tap-start");
                reportTap(uid);
                tapTrace(tapStartedMs, uid, "report-tap-finished");
            }
        } else {
            tapTrace(tapStartedMs, uid, "duplicate-suppressed");
        }

        rfid.PICC_HaltA();
        rfid.PCD_StopCrypto1();

        tapTrace(tapStartedMs, uid, "rfid-cleanup-finished");
    }

    void handleClearProvisioning() {
        if (!requireAuth()) return;

        cfg.apiKey = "";
        cfg.registrationToken = "";
        cfg.provisioningStatus = "unregistered";
        writeJob = WriteJob{};

        saveConfig();

        setMode(DeviceMode::Setup, "Provisioning cleared");

        server.send(200, "text/plain", "Provisioning cleared");
    }

    void handleCancelLocalJob() {
        if (!requireAuth()) return;

        writeJob = WriteJob{};

        setMode(DeviceMode::Idle, "Local write job cancelled");

        markUiDirty();

        server.send(200, "text/plain", "Local write job cancelled");
    }

    void handleReboot() {
        if (!requireAuth()) return;

        server.send(200, "text/plain", "Rebooting");

        delay(400);

        ESP.restart();
    }

    void startWeb() {
        const char *headers[] = {"Cookie"};
        server.collectHeaders(headers, 1);

        server.on("/login", HTTP_GET, handleLoginPage);
        server.on("/api/login", HTTP_POST, handleLogin);
        server.on("/api/logout", HTTP_POST, handleLogout);

        server.on("/", HTTP_GET, handleDashboard);
        server.on("/api/status", HTTP_GET, handleStatus);
        server.on("/api/wifi-config", HTTP_GET, handleWifiConfig);
        server.on("/api/config", HTTP_POST, handleSaveConfig);
        server.on("/api/register", HTTP_POST, handleRegister);
        server.on("/api/sync-time", HTTP_POST, handleSyncTime);
        server.on("/api/poll-job", HTTP_POST, handlePollJob);
        server.on("/api/clear-provisioning", HTTP_POST, handleClearProvisioning);
        server.on("/api/cancel-local-job", HTTP_POST, handleCancelLocalJob);
        server.on("/api/reboot", HTTP_POST, handleReboot);

        server.begin();

        eventsServer.begin();
        eventsServer.setNoDelay(true);
        eventsStarted = true;

        Serial.printf("[WEB] HTTP dashboard started on port 80\n");
        Serial.printf("[SSE] Live status stream started on port %u\n", kEventsPort);
    }

    void initRfid() {
        if (RFID_USE_CUSTOM_SPI_PINS) {
            SPI.begin(RFID_SCK_PIN, RFID_MISO_PIN, RFID_MOSI_PIN, RFID_SS_PIN);
        } else {
            SPI.begin();
        }

        rfid.PCD_Init();
    }

    int findFreeEventSlot() {
        for (size_t i = 0; i < kMaxEventClients; i++) {
            if (!eventClients[i].used || !eventClients[i].client.connected()) {
                return static_cast<int>(i);
            }
        }

        return -1;
    }

    void closeEventSlot(size_t i) {
        if (eventClients[i].used) {
            eventClients[i].client.stop();
        }

        eventClients[i].used = false;
        eventClients[i].connectedMs = 0;
        eventClients[i].lastSendMs = 0;
        eventClients[i].lastHeartbeatMs = 0;

        markUiDirty();
    }

    bool readEventRequest(WiFiClient &client) {
        uint32_t start = millis();
        String firstLine = "";

        while (client.connected() && static_cast<uint32_t>(millis() - start) < kEventHeaderTimeoutMs) {
            while (client.available()) {
                String line = client.readStringUntil('\n');
                line.trim();

                if (firstLine.length() == 0) {
                    firstLine = line;
                }

                if (line.length() == 0) {
                    return firstLine.startsWith("GET /events");
                }

                yield();
            }

            delay(1);
        }

        return firstLine.startsWith("GET /events");
    }

    void writeEventHeaders(WiFiClient &client) {
        client.print(
            "HTTP/1.1 200 OK\r\n"
            "Content-Type: text/event-stream\r\n"
            "Cache-Control: no-cache, no-store, must-revalidate\r\n"
            "Connection: keep-alive\r\n"
            "Access-Control-Allow-Origin: *\r\n"
            "X-Accel-Buffering: no\r\n"
            "\r\n"
        );

        client.print("retry: 2500\n\n");
    }

    void sendNamedEventPayload(EventClientSlot &slot, const char *eventName, const String &json) {
        if (!slot.used || !slot.client.connected()) return;

        slot.client.print("event: ");
        slot.client.print(eventName);
        slot.client.print("\n");
        slot.client.print("data: ");
        slot.client.print(json);
        slot.client.print("\n\n");
        slot.client.flush();

        slot.lastSendMs = millis();
        slot.lastHeartbeatMs = slot.lastSendMs;
    }

    void sendEventPayload(EventClientSlot &slot, const String &json) {
        sendNamedEventPayload(slot, "status", json);
    }

    void sendEventComment(EventClientSlot &slot) {
        if (!slot.used || !slot.client.connected()) return;

        slot.client.print(": keep-alive\n\n");
        slot.client.flush();

        slot.lastHeartbeatMs = millis();
    }

    bool hasEventClients() {
        for (size_t i = 0; i < kMaxEventClients; i++) {
            if (eventClients[i].used && eventClients[i].client.connected()) {
                return true;
            }
        }

        return false;
    }

    void serviceEventStream() {
        if (!eventsStarted) return;

        WiFiClient incoming = eventsServer.available();

        if (incoming) {
            incoming.setTimeout(1);

            if (!readEventRequest(incoming)) {
                incoming.print("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
                incoming.stop();
            } else {
                int slot = findFreeEventSlot();

                if (slot < 0) {
                    incoming.print("HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\nToo many dashboard clients");
                    incoming.stop();
                } else {
                    closeEventSlot(slot);

                    eventClients[slot].client = incoming;
                    eventClients[slot].used = true;
                    eventClients[slot].connectedMs = millis();

                    writeEventHeaders(eventClients[slot].client);

                    String json = makeStatusJson();
                    sendEventPayload(eventClients[slot], json);

                    markUiDirty();

                    Serial.printf("[SSE] Dashboard client connected in slot %d\n", slot);
                }
            }
        }

        uint32_t now = millis();

        for (size_t i = 0; i < kMaxEventClients; i++) {
            if (eventClients[i].used && !eventClients[i].client.connected()) {
                Serial.printf("[SSE] Dashboard client disconnected from slot %u\n", static_cast<unsigned>(i));
                closeEventSlot(i);
            }
        }

        if (!hasEventClients()) {
            lastPushedRfidEventRevision = rfidEventRevision;
            return;
        }

        if (
            rfidEventRevision != lastPushedRfidEventRevision &&
            pendingRfidEventJson.length() > 0
        ) {
            for (size_t i = 0; i < kMaxEventClients; i++) {
                sendNamedEventPayload(eventClients[i], "rfid", pendingRfidEventJson);
            }

            lastPushedRfidEventRevision = rfidEventRevision;
        }

        bool shouldPush =
            uiRevision != lastPushedRevision &&
            static_cast<uint32_t>(now - lastEventPushMs) >= kEventPushMinMs;

        bool shouldHeartbeat =
            static_cast<uint32_t>(now - lastEventKeepAliveMs) >= kEventHeartbeatMs;

        if (shouldPush) {
            String json = makeStatusJson();

            for (size_t i = 0; i < kMaxEventClients; i++) {
                sendEventPayload(eventClients[i], json);
            }

            lastPushedRevision = uiRevision;
            lastEventPushMs = now;
        } else if (shouldHeartbeat) {
            for (size_t i = 0; i < kMaxEventClients; i++) {
                sendEventComment(eventClients[i]);
            }

            lastEventKeepAliveMs = now;
        }
    }
}

void setup() {
    Serial.begin(115200);
    delay(200);

    Serial.println("\n[SASA] ESP RFID starting");

    loadConfig();
    configureTls();

    WiFi.persistent(false);
    WiFi.setSleep(false);
    WiFi.mode(WIFI_STA);

    String bootSsid = trimString(cfg.wifiSsid);

    Serial.printf("[CONFIG] WiFi SSID: '%s'\n", bootSsid.c_str());
    Serial.printf("[CONFIG] WiFi password length: %d\n", cfg.wifiPassword.length());

    ensureWifi(true);

    if (!waitForWifi(kInitialWifiConnectTimeoutMs)) {
        if (bootSsid.length() > 0) {
            scanForWifiNetwork(bootSsid);
        }

        startSetupAp();
        setMode(DeviceMode::Setup, "Connect to setup AP and configure WiFi/server");
    } else {
        Serial.printf("[WiFi] Connected at %s\n", WiFi.localIP().toString().c_str());

        setMode(
            isProvisioned() ? DeviceMode::Idle : DeviceMode::Setup,
            isProvisioned() ? "Ready" : "Configure or register reader"
        );
    }

    initRfid();
    startWeb();

    if (WiFi.status() == WL_CONNECTED) {
        Serial.printf("[WEB] LAN: http://%s\n", WiFi.localIP().toString().c_str());
        Serial.printf("[SSE] LAN stream: http://%s:%u/events\n", WiFi.localIP().toString().c_str(), kEventsPort);
    } else {
        Serial.println("[WEB] LAN unavailable because WiFi is not connected");
    }

    if (apStarted) {
        Serial.printf("[WEB] AP: http://%s\n", WiFi.softAPIP().toString().c_str());
        Serial.printf("[SSE] AP stream: http://%s:%u/events\n", WiFi.softAPIP().toString().c_str(), kEventsPort);
    }
}

void loop() {
    server.handleClient();
    serviceEventStream();

    ensureWifi();

    if (
        WiFi.status() != WL_CONNECTED &&
        !apStarted &&
        lastWifiAttemptMs > 0 &&
        static_cast<uint32_t>(millis() - lastWifiAttemptMs) >= kInitialWifiConnectTimeoutMs
    ) {
        startSetupAp();
    }

    pollRfid();

    uint32_t now = millis();

    bool recentRfidActivity =
            static_cast<uint32_t>(now - lastRfidInteractionMs) <
            kRfidPriorityWindowMs;

    if (
        !isProvisioned() &&
        cfg.registrationToken.length() &&
        static_cast<uint32_t>(now - lastProvisionPollMs) >= kProvisionPollMs
    ) {
        lastProvisionPollMs = now;
        pollProvisioning();
    }

    if (
        isProvisioned() &&
        !recentRfidActivity &&
        static_cast<uint32_t>(now - heartbeatState.lastAttemptMs) >=
        heartbeatState.pollMs
    ) {
        heartbeatState.lastAttemptMs = now;
        sendHeartbeat();
    }

    if (
        isProvisioned() &&
        !recentRfidActivity &&
        static_cast<uint32_t>(now - clockState.lastSyncMs) >= clockState.pollMs
    ) {
        clockState.lastSyncMs = now;
        syncTime();
    }

    if (
        isProvisioned() &&
        !recentRfidActivity &&
        cfg.canWriteCards &&
        !writeJob.active &&
        static_cast<uint32_t>(now - lastWriteJobPollMs) >= kDefaultWriteJobPollMs
    ) {
        lastWriteJobPollMs = now;
        pollWriteJob();
    }

    delay(5);
}