#include "web_server.h"
#include <WebServer.h>
#include "wifi_manager.h"
#include "config.h"
#include "sd_logger.h"
#include "time_manager.h"
#include "html_util.h"
#include <SD.h>
#include <WiFi.h>
#include <time.h>
#include <new>
#include <stdlib.h>

static WebServer server(80);
static const SenderStatus *g_statuses = nullptr;
static uint8_t g_status_count = 0;
static WifiMqttConfig g_config;
static bool g_is_ap = false;
static String g_web_user;
static String g_web_pass;
static const FaultCounters *g_sender_faults = nullptr;
static const FaultType *g_sender_last_errors = nullptr;
static MeterData g_last_batch[NUM_SENDERS][METER_BATCH_MAX_SAMPLES];
static uint8_t g_last_batch_count[NUM_SENDERS] = {};

struct HistoryBin {
  uint32_t ts;
  float value;
  uint32_t count;
};

enum class HistoryMode : uint8_t {
  Avg = 0,
  Max = 1
};

struct HistoryJob {
  bool active;
  bool done;
  bool error;
  String error_msg;
  String device_id;
  HistoryMode mode;
  uint32_t start_ts;
  uint32_t end_ts;
  uint32_t res_sec;
  uint32_t bins_count;
  uint32_t bins_filled;
  uint16_t day_index;
  File file;
  HistoryBin *bins;
};

static HistoryJob g_history = {};
static constexpr size_t SD_LIST_MAX_FILES = 200;
static constexpr size_t SD_DOWNLOAD_MAX_PATH = 160;

static bool auth_required() {
  return g_is_ap ? WEB_AUTH_REQUIRE_AP : WEB_AUTH_REQUIRE_STA;
}

static const char *fault_text(FaultType fault) {
  switch (fault) {
    case FaultType::MeterRead:
      return "meter";
    case FaultType::Decode:
      return "decode";
    case FaultType::LoraTx:
      return "loratx";
    case FaultType::TimeSync:
      return "timesync";
    default:
      return "none";
  }
}

static bool ensure_auth() {
  if (!auth_required()) {
    return true;
  }
  const char *user = g_web_user.c_str();
  const char *pass = g_web_pass.c_str();
  if (server.authenticate(user, pass)) {
    return true;
  }
  server.requestAuthentication(BASIC_AUTH, "DD3", "Authentication required");
  return false;
}

static String html_header(const String &title) {
  String safe_title = html_escape(title);
  String h = "<!DOCTYPE html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>";
  h += "<title>" + safe_title + "</title></head><body>";
  h += "<h2>" + safe_title + "</h2>";
  return h;
}

static String html_footer() {
  return "</body></html>";
}

static String format_faults(uint8_t idx) {
  if (!g_sender_faults || !g_sender_last_errors || idx >= g_status_count) {
    return "";
  }
  String s;
  s += " faults m:";
  s += String(g_sender_faults[idx].meter_read_fail);
  s += " d:";
  s += String(g_sender_faults[idx].decode_fail);
  s += " tx:";
  s += String(g_sender_faults[idx].lora_tx_fail);
  s += " last:";
  s += String(static_cast<uint8_t>(g_sender_last_errors[idx]));
  s += " (" + String(fault_text(g_sender_last_errors[idx])) + ")";
  return s;
}

static bool sanitize_sd_download_path(String &path, String &error) {
  path.trim();
  if (path.length() == 0) {
    error = "empty";
    return false;
  }
  if (path.startsWith("dd3/")) {
    path = "/" + path;
  }
  if (path.length() > SD_DOWNLOAD_MAX_PATH) {
    error = "too_long";
    return false;
  }
  if (!path.startsWith("/dd3/")) {
    error = "prefix";
    return false;
  }
  if (path.indexOf("..") >= 0) {
    error = "dotdot";
    return false;
  }
  if (path.indexOf('\\') >= 0) {
    error = "backslash";
    return false;
  }
  if (path.indexOf("//") >= 0) {
    error = "repeated_slash";
    return false;
  }
  return true;
}

static bool checkbox_checked(const char *name) {
  if (!server.hasArg(name)) {
    return false;
  }
  String val = server.arg(name);
  return val == "on" || val == "true" || val == "1";
}

static bool sanitize_history_device_id(const String &input, String &out_device_id) {
  if (sanitize_device_id(input, out_device_id)) {
    return true;
  }
  if (g_statuses) {
    for (uint8_t i = 0; i < g_status_count; ++i) {
      String known = g_statuses[i].last_data.device_id;
      if (input.equalsIgnoreCase(known) && sanitize_device_id(known, out_device_id)) {
        return true;
      }
    }
  }
  return false;
}

static String sanitize_download_filename(const String &input, bool &clean) {
  String out;
  out.reserve(input.length());
  clean = true;
  for (size_t i = 0; i < input.length(); ++i) {
    unsigned char c = static_cast<unsigned char>(input[i]);
    if (c < 32 || c == 127 || c == '"' || c == '\\' || c == '/') {
      out += '_';
      clean = false;
      continue;
    }
    out += static_cast<char>(c);
  }
  out.trim();
  if (out.length() == 0) {
    out = "download.bin";
    clean = false;
  }
  return out;
}

static void history_reset() {
  if (g_history.file) {
    g_history.file.close();
  }
  if (g_history.bins) {
    delete[] g_history.bins;
  }
  g_history = {};
}

static String history_date_from_epoch(uint32_t ts_utc) {
  time_t t = static_cast<time_t>(ts_utc);
  struct tm tm_utc;
  gmtime_r(&t, &tm_utc);
  char buf[16];
  snprintf(buf, sizeof(buf), "%04d-%02d-%02d", tm_utc.tm_year + 1900, tm_utc.tm_mon + 1, tm_utc.tm_mday);
  return String(buf);
}

static bool history_open_next_file() {
  if (!g_history.active || g_history.done || g_history.error) {
    return false;
  }
  if (g_history.file) {
    g_history.file.close();
  }
  uint32_t day_ts = g_history.start_ts + static_cast<uint32_t>(g_history.day_index) * 86400UL;
  if (day_ts > g_history.end_ts) {
    g_history.done = true;
    return false;
  }
  String path = String("/dd3/") + g_history.device_id + "/" + history_date_from_epoch(day_ts) + ".csv";
  g_history.file = SD.open(path.c_str(), FILE_READ);
  g_history.day_index++;
  return true;
}

static bool history_parse_line(const char *line, uint32_t &ts_out, float &p_out) {
  if (!line || line[0] < '0' || line[0] > '9') {
    return false;
  }
  const char *comma = strchr(line, ',');
  if (!comma) {
    return false;
  }
  char ts_buf[16];
  size_t ts_len = static_cast<size_t>(comma - line);
  if (ts_len >= sizeof(ts_buf)) {
    return false;
  }
  memcpy(ts_buf, line, ts_len);
  ts_buf[ts_len] = '\0';
  char *end = nullptr;
  uint32_t ts = static_cast<uint32_t>(strtoul(ts_buf, &end, 10));
  if (end == ts_buf) {
    return false;
  }
  const char *p_start = comma + 1;
  const char *p_end = strchr(p_start, ',');
  char p_buf[16];
  size_t p_len = p_end ? static_cast<size_t>(p_end - p_start) : strlen(p_start);
  if (p_len >= sizeof(p_buf)) {
    return false;
  }
  memcpy(p_buf, p_start, p_len);
  p_buf[p_len] = '\0';
  char *endp = nullptr;
  float p = strtof(p_buf, &endp);
  if (endp == p_buf) {
    return false;
  }
  ts_out = ts;
  p_out = p;
  return true;
}

static void history_tick() {
  if (!g_history.active || g_history.done || g_history.error) {
    return;
  }
  if (!sd_logger_is_ready()) {
    g_history.error = true;
    g_history.error_msg = "sd_not_ready";
    return;
  }

  uint32_t start_ms = millis();
  while (millis() - start_ms < SD_HISTORY_TIME_BUDGET_MS) {
    if (!g_history.file) {
      if (!history_open_next_file()) {
        if (g_history.done) {
          g_history.active = false;
        }
        return;
      }
    }
    if (!g_history.file.available()) {
      g_history.file.close();
      continue;
    }

    char line[160];
    size_t n = g_history.file.readBytesUntil('\n', line, sizeof(line) - 1);
    line[n] = '\0';
    if (n == 0) {
      continue;
    }
    uint32_t ts = 0;
    float p = 0.0f;
    if (!history_parse_line(line, ts, p)) {
      continue;
    }
    if (ts < g_history.start_ts || ts > g_history.end_ts) {
      continue;
    }
    uint32_t idx = (ts - g_history.start_ts) / g_history.res_sec;
    if (idx >= g_history.bins_count) {
      continue;
    }
    HistoryBin &bin = g_history.bins[idx];
    if (bin.count == 0) {
      bin.ts = g_history.start_ts + idx * g_history.res_sec;
      bin.value = p;
      bin.count = 1;
      g_history.bins_filled++;
    } else if (g_history.mode == HistoryMode::Avg) {
      bin.value += p;
      bin.count++;
    } else {
      if (p > bin.value) {
        bin.value = p;
      }
      bin.count++;
    }
  }
}

static String render_sender_block(const SenderStatus &status) {
  String s;
  s += "<div style='margin-bottom:10px;padding:6px;border:1px solid #ccc'>";
  uint8_t idx = 0;
  if (g_statuses) {
    for (uint8_t i = 0; i < g_status_count; ++i) {
      if (&g_statuses[i] == &status) {
        idx = i;
        break;
      }
    }
  }
  String device_id = status.last_data.device_id;
  String device_id_safe = html_escape(device_id);
  String device_id_url = url_encode_component(device_id);
  s += "<strong><a href='/sender/" + device_id_url + "' target='_blank' rel='noopener noreferrer'>" + device_id_safe + "</a></strong>";
  if (status.has_data && status.last_data.link_valid) {
    s += " RSSI:" + String(status.last_data.link_rssi_dbm) + " SNR:" + String(status.last_data.link_snr_db, 1);
  }
  if (status.has_data) {
    s += " err_tx:" + String(status.last_data.err_lora_tx);
    s += " err_last:" + String(static_cast<uint8_t>(status.last_data.last_error));
    s += " (" + String(fault_text(status.last_data.last_error)) + ")";
    s += " rx_reject:" + String(status.last_data.rx_reject_reason);
    s += " (" + String(rx_reject_reason_text(static_cast<RxRejectReason>(status.last_data.rx_reject_reason))) + ")";
  }
  s += format_faults(idx);
  s += "<br>";
  if (!status.has_data) {
    s += "No data";
  } else {
    s += "Energy: " + String(status.last_data.energy_total_kwh, 3) + " kWh<br>";
    s += "Power: " + String(status.last_data.total_power_w, 1) + " W<br>";
    s += "P1/P2/P3: " + String(status.last_data.phase_power_w[0], 1) + " / " + String(status.last_data.phase_power_w[1], 1) +
         " / " + String(status.last_data.phase_power_w[2], 1) + " W<br>";
    s += "Battery: " + String(status.last_data.battery_percent) + "% (" + String(status.last_data.battery_voltage_v, 2) + " V)";
  }
  s += "</div>";
  return s;
}

static void append_sd_listing(String &html, const String &dir_path, uint8_t depth, size_t &count) {
  if (count >= SD_LIST_MAX_FILES || depth > 4) {
    return;
  }
  File dir = SD.open(dir_path.c_str());
  if (!dir || !dir.isDirectory()) {
    return;
  }
  File entry = dir.openNextFile();
  while (entry && count < SD_LIST_MAX_FILES) {
    String name = entry.name();
    String full_path = name;
    if (!full_path.startsWith(dir_path)) {
      if (!dir_path.endsWith("/")) {
        full_path = dir_path + "/" + name;
      } else {
        full_path = dir_path + name;
      }
    }
    if (entry.isDirectory()) {
      html += "<li><strong>" + html_escape(full_path) + "/</strong></li>";
      append_sd_listing(html, full_path, depth + 1, count);
    } else {
      String href = full_path;
      if (!href.startsWith("/")) {
        href = "/" + href;
      }
      String href_enc = url_encode_component(href);
      html += "<li><a href='/sd/download?path=" + href_enc + "' target='_blank' rel='noopener noreferrer'>" + html_escape(full_path) + "</a>";
      html += " (" + String(entry.size()) + " bytes)</li>";
      count++;
    }
    entry = dir.openNextFile();
  }
  dir.close();
}

static void handle_root() {
  if (!ensure_auth()) {
    return;
  }
  String html = html_header("DD3 Bridge Status");
  html += g_is_ap ? "<p>Mode: AP</p>" : "<p>Mode: STA</p>";

  if (g_statuses) {
    for (uint8_t i = 0; i < g_status_count; ++i) {
      html += render_sender_block(g_statuses[i]);
    }
  }

  if (sd_logger_is_ready()) {
    html += "<h3>SD Files</h3><ul>";
    size_t count = 0;
    append_sd_listing(html, "/dd3", 0, count);
    if (count >= SD_LIST_MAX_FILES) {
      html += "<li>Listing truncated...</li>";
    }
    html += "</ul>";
  } else {
    html += "<p>SD: not ready</p>";
  }

  html += "<p><a href='/wifi'>Configure WiFi/MQTT/NTP</a></p>";
  html += "<p><a href='/manual'>Manual</a></p>";
  html += html_footer();
  server.send(200, "text/html", html);
}

static void handle_wifi_get() {
  if (!ensure_auth()) {
    return;
  }
  String html = html_header("WiFi/MQTT Config");
  html += "<form method='POST' action='/wifi'>";
  html += "SSID: <input name='ssid' value='" + html_escape(g_config.ssid) + "'><br>";
  html += "Password: <input name='pass' type='password'> ";
  html += "<label><input type='checkbox' name='clear_wifi_pass'> Clear password</label><br>";
  html += "MQTT Host: <input name='mqhost' value='" + html_escape(g_config.mqtt_host) + "'><br>";
  html += "MQTT Port: <input name='mqport' value='" + String(g_config.mqtt_port) + "'><br>";
  html += "MQTT User: <input name='mquser' value='" + html_escape(g_config.mqtt_user) + "'><br>";
  html += "MQTT Pass: <input name='mqpass' type='password'> ";
  html += "<label><input type='checkbox' name='clear_mqtt_pass'> Clear password</label><br>";
  html += "NTP Server 1: <input name='ntp1' value='" + html_escape(g_config.ntp_server_1) + "'><br>";
  html += "NTP Server 2: <input name='ntp2' value='" + html_escape(g_config.ntp_server_2) + "'><br>";
  html += "<hr>";
  html += "Web UI User: <input name='webuser' value='" + html_escape(g_config.web_user) + "'><br>";
  html += "Web UI Pass: <input name='webpass' type='password'> ";
  html += "<label><input type='checkbox' name='clear_web_pass'> Clear password</label><br>";
  html += "<div style='font-size:12px;color:#666;'>Leaving password blank keeps the existing one.</div>";
  html += "<button type='submit'>Save</button>";
  html += "</form>";
  html += html_footer();
  server.send(200, "text/html", html);
}

static void handle_wifi_post() {
  if (!ensure_auth()) {
    return;
  }
  WifiMqttConfig cfg = g_config;
  cfg.ntp_server_1 = g_config.ntp_server_1.length() > 0 ? g_config.ntp_server_1 : "pool.ntp.org";
  cfg.ntp_server_2 = g_config.ntp_server_2.length() > 0 ? g_config.ntp_server_2 : "time.nist.gov";
  cfg.ssid = server.arg("ssid");
  String wifi_pass = server.arg("pass");
  if (checkbox_checked("clear_wifi_pass")) {
    cfg.password = "";
  } else if (wifi_pass.length() > 0) {
    cfg.password = wifi_pass;
  }
  cfg.mqtt_host = server.arg("mqhost");
  cfg.mqtt_port = static_cast<uint16_t>(server.arg("mqport").toInt());
  cfg.mqtt_user = server.arg("mquser");
  String mqtt_pass = server.arg("mqpass");
  if (checkbox_checked("clear_mqtt_pass")) {
    cfg.mqtt_pass = "";
  } else if (mqtt_pass.length() > 0) {
    cfg.mqtt_pass = mqtt_pass;
  }
  String web_user = server.arg("webuser");
  if (web_user.length() > 0) {
    cfg.web_user = web_user;
  }
  String web_pass = server.arg("webpass");
  if (checkbox_checked("clear_web_pass")) {
    cfg.web_pass = "";
  } else if (web_pass.length() > 0) {
    cfg.web_pass = web_pass;
  }
  if (server.arg("ntp1").length() > 0) {
    cfg.ntp_server_1 = server.arg("ntp1");
  }
  if (server.arg("ntp2").length() > 0) {
    cfg.ntp_server_2 = server.arg("ntp2");
  }
  cfg.valid = true;
  g_config = cfg;
  g_web_user = cfg.web_user;
  g_web_pass = cfg.web_pass;
  wifi_save_config(cfg);
  server.send(200, "text/html", "<html><body>Saved. Rebooting...</body></html>");
  delay(1000);
  ESP.restart();
}

static void handle_sender() {
  if (!ensure_auth()) {
    return;
  }
  if (!g_statuses) {
    server.send(404, "text/plain", "No senders");
    return;
  }
  String uri = server.uri();
  String device_id = uri.substring(String("/sender/").length());
  String device_id_url = url_encode_component(device_id);
  for (uint8_t i = 0; i < g_status_count; ++i) {
    if (device_id.equalsIgnoreCase(g_statuses[i].last_data.device_id)) {
      String html = html_header("Sender " + device_id);
      html += render_sender_block(g_statuses[i]);
      html += "<h3>History (Power)</h3>";
      html += "<div>";
      html += "Days: <input id='hist_days' type='number' min='1' max='" + String(SD_HISTORY_MAX_DAYS) + "' value='7' style='width:60px'> ";
      html += "Res(min): <input id='hist_res' type='number' min='" + String(SD_HISTORY_MIN_RES_MIN) + "' value='5' style='width:60px'> ";
      html += "<select id='hist_mode'><option value='avg'>avg</option><option value='max'>max</option></select> ";
      html += "<button onclick='drawHistory()'>Draw</button>";
      html += "<div id='hist_status' style='font-size:12px;margin-top:4px;color:#666;'></div>";
      html += "<canvas id='hist_canvas' width='320' height='140' style='width:100%;max-width:520px;border:1px solid #ccc;margin-top:6px;'></canvas>";
      html += "</div>";
      html += "<script>";
      html += "const deviceId='" + device_id_url + "';";
      html += "let histTimer=null;";
      html += "function histStatus(msg){document.getElementById('hist_status').textContent=msg;}";
      html += "function drawHistory(){";
      html += "const days=document.getElementById('hist_days').value;";
      html += "const res=document.getElementById('hist_res').value;";
      html += "const mode=document.getElementById('hist_mode').value;";
      html += "histStatus('Starting...');";
      html += "fetch(`/history/start?device_id=${deviceId}&days=${days}&res=${res}&mode=${mode}`)";
      html += ".then(r=>r.json()).then(j=>{";
      html += "if(!j.ok){histStatus('Error: '+(j.error||'failed'));return;}";
      html += "if(histTimer){clearInterval(histTimer);}";
      html += "histTimer=setInterval(()=>fetchHistory(),1000);";
      html += "fetchHistory();";
      html += "});";
      html += "}";
      html += "function fetchHistory(){";
      html += "fetch(`/history/data?device_id=${deviceId}`).then(r=>r.json()).then(j=>{";
      html += "if(!j.ready){histStatus(j.error?('Error: '+j.error):('Processing... '+(j.progress||0)+'%'));return;}";
      html += "if(histTimer){clearInterval(histTimer);histTimer=null;}";
      html += "renderChart(j.series);";
      html += "histStatus('Done');";
      html += "});";
      html += "}";
      html += "function renderChart(series){";
      html += "const canvas=document.getElementById('hist_canvas');";
      html += "const w=canvas.clientWidth;const h=canvas.clientHeight;";
      html += "canvas.width=w;canvas.height=h;";
      html += "const ctx=canvas.getContext('2d');";
      html += "ctx.clearRect(0,0,w,h);";
      html += "if(!series||series.length===0){ctx.fillText('No data',10,20);return;}";
      html += "let min=Infinity,max=-Infinity;";
      html += "for(const p of series){if(p[1]===null)continue; if(p[1]<min)min=p[1]; if(p[1]>max)max=p[1];}";
      html += "if(!isFinite(min)||!isFinite(max)){ctx.fillText('No data',10,20);return;}";
      html += "if(min===max){min=0;}";
      html += "ctx.strokeStyle='#333';ctx.lineWidth=1;ctx.beginPath();";
      html += "let first=true;";
      html += "for(let i=0;i<series.length;i++){";
      html += "const v=series[i][1];";
      html += "if(v===null)continue;";
      html += "const x=(i/(series.length-1))* (w-2) + 1;";
      html += "const y=h-2-((v-min)/(max-min))*(h-4);";
      html += "if(first){ctx.moveTo(x,y);first=false;} else {ctx.lineTo(x,y);} }";
      html += "ctx.stroke();";
      html += "ctx.fillStyle='#666';ctx.fillText(min.toFixed(0)+'W',4,h-4);";
      html += "ctx.fillText(max.toFixed(0)+'W',4,12);";
      html += "}";
      html += "</script>";
      if (g_last_batch_count[i] > 0) {
        html += "<h3>Last batch (" + String(g_last_batch_count[i]) + " samples)</h3>";
        html += "<table border='1' cellspacing='0' cellpadding='3'>";
        html += "<tr><th>#</th><th>ts</th><th>e_kwh</th><th>p_w</th><th>p1_w</th><th>p2_w</th><th>p3_w</th>";
        html += "<th>bat_v</th><th>bat_pct</th><th>rssi</th><th>snr</th><th>err_tx</th><th>err_last</th><th>rx_reject</th></tr>";
        for (uint8_t r = 0; r < g_last_batch_count[i]; ++r) {
          const MeterData &d = g_last_batch[i][r];
          html += "<tr>";
          html += "<td>" + String(r) + "</td>";
          html += "<td>" + String(d.ts_utc) + "</td>";
          html += "<td>" + String(d.energy_total_kwh, 3) + "</td>";
          html += "<td>" + String(d.total_power_w, 1) + "</td>";
          html += "<td>" + String(d.phase_power_w[0], 1) + "</td>";
          html += "<td>" + String(d.phase_power_w[1], 1) + "</td>";
          html += "<td>" + String(d.phase_power_w[2], 1) + "</td>";
          html += "<td>" + String(d.battery_voltage_v, 2) + "</td>";
          html += "<td>" + String(d.battery_percent) + "</td>";
          html += "<td>" + String(d.link_rssi_dbm) + "</td>";
          html += "<td>" + String(d.link_snr_db, 1) + "</td>";
          html += "<td>" + String(d.err_lora_tx) + "</td>";
          html += "<td>" + String(static_cast<uint8_t>(d.last_error)) + " (" + String(fault_text(d.last_error)) + ")</td>";
          html += "<td>" + String(d.rx_reject_reason) + " (" +
                  String(rx_reject_reason_text(static_cast<RxRejectReason>(d.rx_reject_reason))) + ")</td>";
          html += "</tr>";
        }
        html += "</table>";
      }
      html += html_footer();
      server.send(200, "text/html", html);
      return;
    }
  }
  server.send(404, "text/plain", "Not found");
}

static void handle_manual() {
  if (!ensure_auth()) {
    return;
  }
  String html = html_header("DD3 Manual");
  html += "<ul>";
  html += "<li>Energy: total kWh since meter start.</li>";
  html += "<li>Power: total active power in W.</li>";
  html += "<li>P1/P2/P3: phase power in W.</li>";
  html += "<li>Battery: percent with voltage in V.</li>";
  html += "<li>RSSI/SNR: LoRa link quality from last packet.</li>";
  html += "<li>err_tx: sender-side LoRa TX error counter.</li>";
  html += "<li>err_last: last error code (0=None, 1=MeterRead, 2=Decode, 3=LoraTx, 4=TimeSync).</li>";
  html += "<li>rx_reject: last RX reject reason (0=None, 1=crc_fail, 2=bad_protocol_version, 3=wrong_role, 4=wrong_payload_type, 5=length_mismatch, 6=device_id_mismatch, 7=batch_id_mismatch).</li>";
  html += "<li>faults m/d/tx: receiver-side counters (meter read fails, decode fails, LoRa TX fails).</li>";
  html += "<li>faults last: last receiver-side error code (same mapping as err_last).</li>";
  html += "</ul>";
  html += html_footer();
  server.send(200, "text/html", html);
}

static void handle_history_start() {
  if (!ensure_auth()) {
    return;
  }
  if (!sd_logger_is_ready()) {
    server.send(200, "application/json", "{\"ok\":false,\"error\":\"sd_not_ready\"}");
    return;
  }
  if (!time_is_synced()) {
    server.send(200, "application/json", "{\"ok\":false,\"error\":\"time_not_synced\"}");
    return;
  }
  String device_id_arg = server.arg("device_id");
  String device_id;
  if (!sanitize_history_device_id(device_id_arg, device_id)) {
    server.send(200, "application/json", "{\"ok\":false,\"error\":\"bad_device_id\"}");
    return;
  }
  uint16_t days = static_cast<uint16_t>(server.arg("days").toInt());
  uint16_t res_min = static_cast<uint16_t>(server.arg("res").toInt());
  String mode_str = server.arg("mode");
  if (device_id.length() == 0 || days == 0 || res_min == 0) {
    server.send(200, "application/json", "{\"ok\":false,\"error\":\"bad_params\"}");
    return;
  }
  if (days > SD_HISTORY_MAX_DAYS) {
    days = SD_HISTORY_MAX_DAYS;
  }
  if (res_min < SD_HISTORY_MIN_RES_MIN) {
    res_min = SD_HISTORY_MIN_RES_MIN;
  }
  uint32_t bins = (static_cast<uint32_t>(days) * 24UL * 60UL) / res_min;
  if (bins == 0 || bins > SD_HISTORY_MAX_BINS) {
    String resp = String("{\"ok\":false,\"error\":\"too_many_bins\",\"max_bins\":") + SD_HISTORY_MAX_BINS + "}";
    server.send(200, "application/json", resp);
    return;
  }

  history_reset();
  g_history.active = true;
  g_history.done = false;
  g_history.error = false;
  g_history.device_id = device_id;
  g_history.mode = (mode_str == "max") ? HistoryMode::Max : HistoryMode::Avg;
  g_history.res_sec = static_cast<uint32_t>(res_min) * 60UL;
  g_history.bins_count = bins;
  g_history.day_index = 0;
  g_history.bins = new (std::nothrow) HistoryBin[bins];
  if (!g_history.bins) {
    g_history.error = true;
    g_history.error_msg = "oom";
    server.send(200, "application/json", "{\"ok\":false,\"error\":\"oom\"}");
    return;
  }
  for (uint32_t i = 0; i < bins; ++i) {
    g_history.bins[i] = {};
  }
  g_history.end_ts = time_get_utc();
  uint32_t span = static_cast<uint32_t>(days) * 86400UL;
  g_history.start_ts = g_history.end_ts > span ? (g_history.end_ts - span) : 0;
  if (g_history.res_sec > 0) {
    g_history.start_ts = (g_history.start_ts / g_history.res_sec) * g_history.res_sec;
  }

  String resp = String("{\"ok\":true,\"bins\":") + bins + "}";
  server.send(200, "application/json", resp);
}

static void handle_history_data() {
  if (!ensure_auth()) {
    return;
  }
  String device_id_arg = server.arg("device_id");
  String device_id;
  if (!sanitize_history_device_id(device_id_arg, device_id)) {
    server.send(200, "application/json", "{\"ready\":false,\"error\":\"bad_device_id\"}");
    return;
  }
  if (!g_history.bins || device_id.length() == 0 || device_id != g_history.device_id) {
    server.send(200, "application/json", "{\"ready\":false,\"error\":\"no_job\"}");
    return;
  }
  if (g_history.error) {
    String resp = String("{\"ready\":false,\"error\":\"") + g_history.error_msg + "\"}";
    server.send(200, "application/json", resp);
    return;
  }
  if (g_history.active && !g_history.done) {
    uint32_t progress = g_history.bins_count == 0 ? 0 : (g_history.bins_filled * 100UL / g_history.bins_count);
    String resp = String("{\"ready\":false,\"progress\":") + progress + "}";
    server.send(200, "application/json", resp);
    return;
  }

  server.setContentLength(CONTENT_LENGTH_UNKNOWN);
  server.send(200, "application/json", "");
  server.sendContent("{\"ready\":true,\"series\":[");
  bool first = true;
  for (uint32_t i = 0; i < g_history.bins_count; ++i) {
    const HistoryBin &bin = g_history.bins[i];
    if (!first) {
      server.sendContent(",");
    }
    first = false;
    float value = NAN;
    if (bin.count > 0) {
      value = (g_history.mode == HistoryMode::Avg) ? (bin.value / static_cast<float>(bin.count)) : bin.value;
    }
    if (bin.count == 0) {
      server.sendContent(String("[") + bin.ts + ",null]");
    } else {
      server.sendContent(String("[") + bin.ts + "," + String(value, 2) + "]");
    }
  }
  server.sendContent("]}");
}

static void handle_sd_download() {
  if (!ensure_auth()) {
    return;
  }
  if (!sd_logger_is_ready()) {
    server.send(404, "text/plain", "SD not ready");
    return;
  }
  String path = server.arg("path");
  String error;
  if (!sanitize_sd_download_path(path, error)) {
    if (SERIAL_DEBUG_MODE) {
      Serial.printf("sd: reject path '%s' reason=%s\n", path.c_str(), error.c_str());
    }
    server.send(400, "text/plain", "Invalid path");
    return;
  }
  File f = SD.open(path.c_str(), FILE_READ);
  if (!f) {
    server.send(404, "text/plain", "Not found");
    return;
  }
  size_t size = f.size();
  String filename = path.substring(path.lastIndexOf('/') + 1);
  bool name_clean = true;
  (void)name_clean;
  String safe_name = sanitize_download_filename(filename, name_clean);
  String cd = "attachment; filename=\"" + safe_name + "\"; filename*=UTF-8''" + url_encode_component(safe_name);
  server.sendHeader("Content-Disposition", cd);
  server.setContentLength(size);
  const char *content_type = "application/octet-stream";
  if (filename.endsWith(".csv")) {
    content_type = "text/csv";
  } else if (filename.endsWith(".txt")) {
    content_type = "text/plain";
  }
  server.send(200, content_type, "");
  WiFiClient client = server.client();
  uint8_t buf[512];
  while (f.available()) {
    size_t n = f.read(buf, sizeof(buf));
    if (n == 0) {
      break;
    }
    client.write(buf, n);
    delay(0);
  }
  f.close();
}

void web_server_set_config(const WifiMqttConfig &config) {
  g_config = config;
  g_web_user = config.web_user;
  g_web_pass = config.web_pass;
}

void web_server_set_sender_faults(const FaultCounters *faults, const FaultType *last_errors) {
  g_sender_faults = faults;
  g_sender_last_errors = last_errors;
}

void web_server_set_last_batch(uint8_t sender_index, const MeterData *samples, size_t count) {
  if (!samples || sender_index >= NUM_SENDERS) {
    return;
  }
  if (count > METER_BATCH_MAX_SAMPLES) {
    count = METER_BATCH_MAX_SAMPLES;
  }
  g_last_batch_count[sender_index] = static_cast<uint8_t>(count);
  for (size_t i = 0; i < count; ++i) {
    g_last_batch[sender_index][i] = samples[i];
  }
}

void web_server_begin_ap(const SenderStatus *statuses, uint8_t count) {
  g_statuses = statuses;
  g_status_count = count;
  g_is_ap = true;

  server.on("/", handle_root);
  server.on("/manual", handle_manual);
  server.on("/history/start", handle_history_start);
  server.on("/history/data", handle_history_data);
  server.on("/sd/download", handle_sd_download);
  server.on("/wifi", HTTP_GET, handle_wifi_get);
  server.on("/wifi", HTTP_POST, handle_wifi_post);
  server.on("/sender/", handle_sender);
  server.onNotFound([]() {
    if (server.uri().startsWith("/sender/")) {
      handle_sender();
      return;
    }
    server.send(404, "text/plain", "Not found");
  });
  server.begin();
}

void web_server_begin_sta(const SenderStatus *statuses, uint8_t count) {
  g_statuses = statuses;
  g_status_count = count;
  g_is_ap = false;

  server.on("/", handle_root);
  server.on("/manual", handle_manual);
  server.on("/sender/", handle_sender);
  server.on("/history/start", handle_history_start);
  server.on("/history/data", handle_history_data);
  server.on("/sd/download", handle_sd_download);
  server.on("/wifi", HTTP_GET, handle_wifi_get);
  server.on("/wifi", HTTP_POST, handle_wifi_post);
  server.onNotFound([]() {
    if (server.uri().startsWith("/sender/")) {
      handle_sender();
      return;
    }
    server.send(404, "text/plain", "Not found");
  });
  server.begin();
}

void web_server_loop() {
  history_tick();
  server.handleClient();
}
