#include "TBeamWeb.h"

#include <SD.h>
#include <stdarg.h>

namespace tbeam {

TBeamWeb* TBeamWeb::active_ = nullptr;

TBeamWeb::TBeamWeb(Print& diagnostic) : diagnostic_(diagnostic), server_(80) {}

bool TBeamWeb::begin(TBeamStorage& storage, const WebConfig& config) {
  storage_ = &storage;
  config_ = config;
  clearError();

  snprintf(ssid_, sizeof(ssid_), "%s-%s", config_.ssidPrefix ? config_.ssidPrefix : "TBEAM", config_.boardId ? config_.boardId : "NODE");

  WiFi.mode(WIFI_AP);
  WiFi.setSleep(false);

  ip_ = IPAddress(192, 168, config_.ipOctet, 1);
  const IPAddress gateway(192, 168, config_.ipOctet, 1);
  const IPAddress netmask(255, 255, 255, 0);
  if (!WiFi.softAPConfig(ip_, gateway, netmask)) {
    setError("WiFi softAPConfig failed");
    return false;
  }

  bool apOk = false;
  if (config_.password && strlen(config_.password) >= 8) {
    apOk = WiFi.softAP(ssid_, config_.password);
  } else {
    apOk = WiFi.softAP(ssid_);
  }
  if (!apOk) {
    setError("WiFi softAP failed");
    return false;
  }
  ip_ = WiFi.softAPIP();

  active_ = this;
  server_.on("/", HTTP_GET, handleRootThunk);
  server_.on("/status", HTTP_GET, handleStatusThunk);
  server_.on("/files", HTTP_GET, handleFilesThunk);
  server_.on("/download", HTTP_GET, handleDownloadThunk);
  server_.on("/delete", HTTP_POST, handleDeleteThunk);
  server_.onNotFound(handleNotFoundThunk);
  server_.begin(config_.port);

  ready_ = true;
  logf("web: AP started ssid=%s ip=%s port=%u", ssid_, ip_.toString().c_str(), (unsigned)config_.port);
  return true;
}

void TBeamWeb::update() {
  if (ready_) {
    server_.handleClient();
  }
}

void TBeamWeb::stop() {
  if (ready_) {
    server_.stop();
    WiFi.softAPdisconnect(true);
  }
  ready_ = false;
}

uint8_t TBeamWeb::stationCount() const {
  return (uint8_t)WiFi.softAPgetStationNum();
}

void TBeamWeb::handleRootThunk() {
  if (active_) active_->handleRoot();
}

void TBeamWeb::handleStatusThunk() {
  if (active_) active_->handleStatus();
}

void TBeamWeb::handleFilesThunk() {
  if (active_) active_->handleFiles();
}

void TBeamWeb::handleDownloadThunk() {
  if (active_) active_->handleDownload();
}

void TBeamWeb::handleDeleteThunk() {
  if (active_) active_->handleDelete();
}

void TBeamWeb::handleNotFoundThunk() {
  if (active_) active_->handleNotFound();
}

void TBeamWeb::handleRoot() {
  bool countTruncated = false;
  const bool sdReady = storage_ && storage_->ready();
  const size_t logsCount = sdReady ? countDirectoryEntries("/logs", 250, &countTruncated) : 0;

  String body;
  body.reserve(2048);
  body += F("<!doctype html><html><head><meta charset='utf-8'>");
  body += F("<meta name='viewport' content='width=device-width,initial-scale=1'>");
  body += F("<title>T-Beam Status</title></head><body>");
  body += F("<h1>T-Beam Status</h1>");
  body += F("<p>SSID: ");
  body += htmlEscape(ssid_);
  body += F("</p><p>IP: ");
  body += htmlEscape(ip_.toString());
  body += F("</p><p>SD: ");
  body += sdReady ? F("mounted") : F("not mounted");
  body += F("</p><p>Log entries: ");
  if (sdReady) {
    body += String((unsigned long)logsCount);
    if (countTruncated) {
      body += F("+");
    }
  } else {
    body += F("unavailable");
  }
  body += F("</p><p>Stations: ");
  body += String(stationCount());
  body += F("</p><p><a href='/files?path=/logs'>Files</a> ");
  body += F("<a href='/status'>Status</a></p>");
  body += F("</body></html>");
  server_.send(200, "text/html", body);
}

void TBeamWeb::handleStatus() {
  String body;
  body.reserve(512);
  body += F("{\"ready\":");
  body += ready_ ? F("true") : F("false");
  body += F(",\"ssid\":\"");
  body += htmlEscape(ssid_);
  body += F("\",\"ip\":\"");
  body += ip_.toString();
  body += F("\",\"stations\":");
  body += String(stationCount());
  body += F(",\"sd_ready\":");
  body += (storage_ && storage_->ready()) ? F("true") : F("false");
  body += F("}");
  server_.send(200, "application/json", body);
}

void TBeamWeb::handleFiles() {
  if (!storage_ || !storage_->ready()) {
    server_.send(503, "text/plain", "SD not mounted\n");
    return;
  }

  char path[128];
  const String requested = server_.hasArg("path") ? server_.arg("path") : String("/logs");
  if (!normalizePath(requested, path, sizeof(path))) {
    server_.send(400, "text/plain", "invalid path\n");
    return;
  }

  String body;
  body.reserve(8192);
  body += F("<!doctype html><html><head><meta charset='utf-8'>");
  body += F("<meta name='viewport' content='width=device-width,initial-scale=1'>");
  body += F("<title>SD Files</title></head><body>");
  body += F("<h1>SD Files</h1><p>Path: ");
  body += htmlEscape(path);
  body += F("</p><p><a href='/'>Home</a> <a href='/files?path=/'>Root</a> <a href='/files?path=/logs'>Logs</a></p><ul>");
  listDirectoryHtml(body, path, 0);
  body += F("</ul></body></html>");
  server_.send(200, "text/html", body);
}

void TBeamWeb::handleDownload() {
  if (!storage_ || !storage_->ready()) {
    server_.send(503, "text/plain", "SD not mounted\n");
    return;
  }
  if (!server_.hasArg("path")) {
    server_.send(400, "text/plain", "missing path\n");
    return;
  }

  char path[128];
  if (!normalizePath(server_.arg("path"), path, sizeof(path))) {
    server_.send(400, "text/plain", "invalid path\n");
    return;
  }

  File file = SD.open(path, FILE_READ);
  if (!file || file.isDirectory()) {
    file.close();
    server_.send(404, "text/plain", "file not found\n");
    return;
  }

  String filename(path);
  const int slash = filename.lastIndexOf('/');
  if (slash >= 0) {
    filename.remove(0, slash + 1);
  }
  server_.sendHeader("Content-Disposition", String("attachment; filename=\"") + filename + "\"");
  server_.streamFile(file, contentTypeFor(path));
  file.close();
}

void TBeamWeb::handleDelete() {
  if (!config_.enableDelete) {
    server_.send(403, "text/plain", "delete disabled\n");
    return;
  }
  if (!storage_ || !storage_->ready()) {
    server_.send(503, "text/plain", "SD not mounted\n");
    return;
  }
  if (!server_.hasArg("path")) {
    server_.send(400, "text/plain", "missing path\n");
    return;
  }

  char path[128];
  if (!normalizePath(server_.arg("path"), path, sizeof(path))) {
    server_.send(400, "text/plain", "invalid path\n");
    return;
  }

  if (!storage_->removeFile(path)) {
    server_.send(500, "text/plain", String("delete failed: ") + storage_->lastError() + "\n");
    return;
  }
  server_.sendHeader("Location", "/files?path=/logs");
  server_.send(303, "text/plain", "deleted\n");
}

void TBeamWeb::handleNotFound() {
  server_.send(404, "text/plain", "not found\n");
}

void TBeamWeb::listDirectoryHtml(String& body, const char* path, uint8_t depth) {
  File dir = SD.open(path, FILE_READ);
  if (!dir) {
    body += F("<li>open failed</li>");
    return;
  }

  if (!dir.isDirectory()) {
    body += F("<li>");
    body += htmlEscape(path);
    body += F(" ");
    body += String((unsigned long)dir.size());
    body += F(" bytes <a href='/download?path=");
    body += urlEncode(path);
    body += F("'>download</a></li>");
    dir.close();
    return;
  }

  File entry = dir.openNextFile();
  while (entry) {
    const String name = entry.name();
    const int slash = name.lastIndexOf('/');
    const String displayName = slash >= 0 ? name.substring(slash + 1) : (name.length() ? name : String("(unnamed)"));
    String childPath;
    if (name.startsWith("/")) {
      childPath = name;
    } else if (strcmp(path, "/") == 0) {
      childPath = String("/") + name;
    } else {
      childPath = String(path) + "/" + name;
    }
    body += F("<li>");
    if (entry.isDirectory()) {
      body += F("<a href='/files?path=");
      body += urlEncode(childPath);
      body += F("'>");
      body += htmlEscape(displayName);
      body += F("/</a>");
      if (depth > 0) {
        body += F("<ul>");
        listDirectoryHtml(body, childPath.c_str(), depth - 1);
        body += F("</ul>");
      }
    } else {
      body += htmlEscape(displayName);
      body += F(" ");
      body += String((unsigned long)entry.size());
      body += F(" bytes <a href='/download?path=");
      body += urlEncode(childPath);
      body += F("'>download</a>");
      if (config_.enableDelete) {
        body += F(" <form method='post' action='/delete' style='display:inline'>");
        body += F("<input type='hidden' name='path' value='");
        body += htmlEscape(childPath);
        body += F("'><button type='submit'>delete</button></form>");
      }
    }
    body += F("</li>");
    entry.close();
    entry = dir.openNextFile();
  }
  dir.close();
}

size_t TBeamWeb::countDirectoryEntries(const char* path, size_t maxEntries, bool* truncated) {
  if (truncated) {
    *truncated = false;
  }
  if (maxEntries == 0) {
    return 0;
  }
  File dir = SD.open(path, FILE_READ);
  if (!dir) {
    return 0;
  }
  if (!dir.isDirectory()) {
    dir.close();
    return 1;
  }

  size_t count = 0;
  File entry = dir.openNextFile();
  while (entry) {
    entry.close();
    ++count;
    if (count >= maxEntries) {
      File extra = dir.openNextFile();
      if (extra) {
        if (truncated) {
          *truncated = true;
        }
        extra.close();
      }
      break;
    }
    entry = dir.openNextFile();
  }
  dir.close();
  return count;
}

bool TBeamWeb::normalizePath(const String& input, char* out, size_t outSize) const {
  if (!out || outSize < 2 || input.length() == 0 || input.indexOf("..") >= 0) {
    return false;
  }
  const int n = input[0] == '/' ? snprintf(out, outSize, "%s", input.c_str()) : snprintf(out, outSize, "/%s", input.c_str());
  return n > 0 && (size_t)n < outSize;
}

String TBeamWeb::htmlEscape(const String& in) const {
  String out;
  out.reserve(in.length() + 8);
  for (size_t i = 0; i < in.length(); ++i) {
    const char c = in[i];
    if (c == '&') out += F("&amp;");
    else if (c == '<') out += F("&lt;");
    else if (c == '>') out += F("&gt;");
    else if (c == '"') out += F("&quot;");
    else if (c == '\'') out += F("&#39;");
    else out += c;
  }
  return out;
}

String TBeamWeb::urlEncode(const String& in) const {
  String out;
  char hex[4];
  for (size_t i = 0; i < in.length(); ++i) {
    const unsigned char c = (unsigned char)in[i];
    if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '/' || c == '~') {
      out += (char)c;
    } else {
      snprintf(hex, sizeof(hex), "%%%02X", c);
      out += hex;
    }
  }
  return out;
}

String TBeamWeb::contentTypeFor(const String& path) const {
  if (path.endsWith(".html")) return "text/html";
  if (path.endsWith(".csv")) return "text/csv";
  if (path.endsWith(".json")) return "application/json";
  if (path.endsWith(".txt") || path.endsWith(".log")) return "text/plain";
  return "application/octet-stream";
}

void TBeamWeb::setError(const char* message) {
  strlcpy(lastError_, message ? message : "", sizeof(lastError_));
}

void TBeamWeb::clearError() {
  lastError_[0] = '\0';
}

void TBeamWeb::logf(const char* fmt, ...) {
  if (!config_.enableSerialLog) {
    return;
  }
  char msg[196];
  va_list args;
  va_start(args, fmt);
  vsnprintf(msg, sizeof(msg), fmt, args);
  va_end(args);
  diagnostic_.printf("[%10lu][web] %s\r\n", (unsigned long)millis(), msg);
}

}  // namespace tbeam
