import path from "node:path";
import fs, { Stats } from "node:fs";
import type { BunFile, Serve } from "bun";
import * as Sentry from "@sentry/node";
import prom from "bun-prometheus-client";
import log from "loglevel";

import config from "./config";

log.setLevel((Bun.env.LOG_LEVEL || "info") as log.LogLevelDesc);

Sentry.init({
  release: `homestead@${Bun.env.FLY_MACHINE_VERSION}`,
  tracesSampleRate: 1.0,
});

const expectedHostURL = new URL(
  Bun.env.NODE_ENV === "production" ? config.base_url : "http://localhost:3000",
);
const defaultHeaders = {
  ...config.extra.headers,
  vary: "Accept-Encoding",
};

type File = {
  filename: string;
  handle: BunFile;
  relPath: string;
  headers?: Record<string, string>;
  type: string;
  size: number;
  mtime: Date;
};

const metrics = {
  requests: new prom.Counter({
    name: "homestead_requests",
    help: "Number of requests by path, status code, and method",
    labelNames: ["path", "status_code", "method", "content_encoding"],
  }),
  requestDuration: new prom.Histogram({
    name: "homestead_request_duration_seconds",
    help: "Request duration in seconds",
    labelNames: ["path"],
  }),
};

let files = new Map<string, File>();

function registerFile(
  path: string,
  pathname: string,
  filename: string,
  stat: Stats,
): void {
  pathname = "/" + (pathname === "." || pathname === "./" ? "" : pathname);

  if (files.get(pathname) !== undefined) {
    console.warn("File already registered:", pathname);
  }
  const handle = Bun.file(filename);
  files.set(pathname, {
    filename,
    relPath: "/" + path,
    handle: handle,
    type: pathname.startsWith("/feed-styles.xsl") ? "text/xsl" : handle.type,
    headers:
      pathname === "/404.html"
        ? Object.assign({}, defaultHeaders, { "cache-control": "no-cache" })
        : undefined,
    size: stat.size,
    mtime: stat.mtime,
  });
}

function walkDirectory(root: string, dir: string) {
  const absDir = path.join(root, dir);
  for (let pathname of fs.readdirSync(absDir)) {
    const relPath = path.join(dir, pathname);
    const absPath = path.join(absDir, pathname);
    const stat = fs.statSync(absPath);
    if (stat.isDirectory()) {
      walkDirectory(root, relPath + path.sep);
    } else if (stat.isFile()) {
      if (pathname.startsWith("index.html")) {
        const dir = relPath.replace("index.html", "");
        registerFile(relPath, dir, absPath, stat);
        if (dir !== "") {
          registerFile(relPath, dir + path.sep, absPath, stat);
        }
      } else {
        registerFile(relPath, relPath, absPath, stat);
      }
    }
  }
}

walkDirectory("public/", "");

async function serveFile(
  file: File | undefined,
  statusCode: number = 200,
  extraHeaders: Record<string, string> = {},
): Promise<Response> {
  return new Response(await file.handle.arrayBuffer(), {
    headers: {
      "last-modified": file.mtime.toUTCString(),
      ...extraHeaders,
      ...(file.headers || defaultHeaders),
    },
    status: statusCode,
  });
}

function parseIfModifiedSinceHeader(header: string | null): number {
  return header ? new Date(header).getTime() + 999 : 0;
}

export const metricsServer = {
  port: 9091,
  fetch: async function (request) {
    const pathname = new URL(request.url).pathname;
    switch (pathname) {
      case "/metrics":
        return new Response(await prom.register.metrics());
      default:
        return new Response("", { status: 404 });
    }
  },
} satisfies Serve;

export const server = {
  fetch: async function (request) {
    const url = new URL(request.url);
    const pathname = url.pathname.replace(/\/\/+/g, "/");
    const endTimer = metrics.requestDuration.startTimer({ path: pathname });
    const transaction = Sentry.startTransaction({
      name: pathname,
      op: "http.server",
      description: `${request.method} ${pathname}`,
      tags: {
        url: request.url,
        "http.host": request.headers.get("host"),
        "http.method": request.method,
        "http.user_agent": request.headers.get("user-agent"),
      },
    });
    try {
      if (pathname === "/health") {
        return new Response("OK", { status: 200 });
      } else if (
        config.redirect_other_hostnames &&
        request.headers.get("host") !== expectedHostURL.host
      ) {
        return new Response("", {
          status: 301,
          headers: {
            location: new URL(pathname, expectedHostURL).toString(),
          },
        });
      }
      const file = files.get(pathname);
      if (file && (await file.handle.exists())) {
        if (
          parseIfModifiedSinceHeader(
            request.headers.get("if-modified-since"),
          ) >= file?.mtime.getTime()
        ) {
          metrics.requests.inc({
            method: request.method,
            path: pathname,
            status_code: 304,
          });
          transaction.setHttpStatus(304);
          return new Response("", { status: 304, headers: defaultHeaders });
        }
        const encodings = (request.headers.get("accept-encoding") || "")
          .split(",")
          .map((x) => x.trim().toLowerCase());
        if (encodings.includes("br") && files.has(pathname + ".br")) {
          transaction.setHttpStatus(200);
          metrics.requests.inc({
            method: request.method,
            path: pathname,
            status_code: 200,
            content_encoding: "br",
          });
          return serveFile(files.get(pathname + ".br"), 200, {
            "content-encoding": "br",
            "content-type": file.type,
          });
        } else if (encodings.includes("zstd") && files.has(pathname + ".zst")) {
          transaction.setHttpStatus(200);
          metrics.requests.inc({
            method: request.method,
            path: pathname,
            status_code: 200,
            content_encoding: "zst",
          });
          return serveFile(files.get(pathname + ".zst"), 200, {
            "content-encoding": "zstd",
            "content-type": file.type,
          });
        } else if (encodings.includes("gzip") && files.has(pathname + ".gz")) {
          transaction.setHttpStatus(200);
          metrics.requests.inc({
            method: request.method,
            path: pathname,
            status_code: 200,
            content_encoding: "gzip",
          });
          return serveFile(files.get(pathname + ".gz"), 200, {
            "content-encoding": "gzip",
            "content-type": file.type,
          });
        }
        transaction.setHttpStatus(200);
        metrics.requests.inc({
          method: request.method,
          path: pathname,
          status_code: 200,
          content_encoding: "identity",
        });
        return serveFile(file, 200, {
          "content-type": file.type,
        });
      } else {
        if (files.has(pathname + "/")) {
          log.info(`Redirecting to: ${pathname + "/"}`);
          metrics.requests.inc({
            method: request.method,
            path: pathname,
            status_code: 302,
          });
          return new Response("", {
            status: 302,
            headers: { location: pathname + "/" },
          });
        }
        metrics.requests.inc({
          method: request.method,
          path: pathname,
          status_code: 404,
          content_encoding: "identity",
        });
        transaction.setHttpStatus(404);
        const notfound = files.get("/404.html");
        if (notfound) {
          return serveFile(notfound, 404, {
            "content-type": "text/html; charset=utf-8",
          });
        } else {
          log.warn("404.html not found");
          return new Response("404 Not Found", {
            status: 404,
            headers: { "content-type": "text/plain", ...defaultHeaders },
          });
        }
      }
    } catch (error) {
      transaction.setHttpStatus(503);
      metrics.requests.inc({
        method: request.method,
        path: pathname,
        status_code: 503,
        content_encoding: "identity",
      });
      Sentry.captureException(error);
      return new Response("Something went wrong", { status: 503 });
    } finally {
      const seconds = endTimer();
      metrics.requestDuration.observe(seconds);
      transaction.finish();
    }
  },
} satisfies Serve;