import path from "node:path";
import fs from "node:fs/promises";
import type { 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((import.meta.env.LOG_LEVEL || "info") as log.LogLevelDesc);

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

const expectedHostURL = new URL(
  import.meta.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",
      "hostname",
      "method",
      "content_encoding",
    ] as const,
  }),
  requestDuration: new prom.Histogram({
    name: "homestead_request_duration_seconds",
    help: "Request duration in seconds",
    labelNames: ["path"] as const,
  }),
};

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,
  });
}

async function walkDirectory(root: string) {
  for (let relPath of await fs.readdir(root, { recursive: true })) {
    const absPath = path.join(root, relPath);
    const stat = await fs.stat(absPath);
    if (stat.isFile()) {
      if (relPath.includes("index.html")) {
        const dir = relPath.replace("index.html", "");
        registerFile(relPath, dir, absPath, stat);
      } else {
        registerFile(relPath, relPath, absPath, stat);
      }
    }
  }
}

await walkDirectory("public/");

async function serveFile(
  file: File,
  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 hostname = request.headers.get("host")?.toLowerCase() || "unknown";
    const endTimer = metrics.requestDuration.startTimer({ path: pathname });
    let status;
    let newpath;
    const transaction = Sentry.startTransaction({
      name: pathname,
      op: "http.server",
      description: `${request.method} ${pathname}`,
      tags: {
        url: request.url,
        "http.host": hostname,
        "http.method": request.method,
        "http.user_agent": request.headers.get("user-agent"),
      },
    });
    try {
      if (pathname === "/health") {
        return new Response("OK", { status: (status = 200) });
      } else if (
        config.redirect_other_hostnames &&
        hostname !== expectedHostURL.host
      ) {
        metrics.requests.inc({
          method: request.method,
          hostname,
          content_encoding: "identity",
          path: pathname,
          status_code: (status = 301),
        });
        return new Response("", {
          status,
          headers: {
            location: new URL(pathname, expectedHostURL).toString(),
          },
        });
      }
      const file = files.get(pathname);
      let contentEncoding = "identity";
      let suffix = "";
      if (file && (await file.handle.exists())) {
        if (
          parseIfModifiedSinceHeader(
            request.headers.get("if-modified-since"),
          ) >= file?.mtime.getTime()
        ) {
          metrics.requests.inc({
            method: request.method,
            hostname,
            content_encoding: contentEncoding,
            path: pathname,
            status_code: (status = 304),
          });
          transaction.setHttpStatus(304);
          return new Response("", { status: status, headers: defaultHeaders });
        }
        const encodings = (request.headers.get("accept-encoding") || "")
          .split(",")
          .map((x) => x.trim().toLowerCase());
        if (encodings.includes("br") && files.has(pathname + ".br")) {
          contentEncoding = "br";
          suffix = ".br";
        } else if (encodings.includes("zstd") && files.has(pathname + ".zst")) {
          contentEncoding = "zstd";
          suffix = ".zst";
        } else if (encodings.includes("gzip") && files.has(pathname + ".gz")) {
          contentEncoding = "gzip";
          suffix = ".gz";
        }

        status = 200;
        transaction.setHttpStatus(status);
        metrics.requests.inc({
          method: request.method,
          hostname,
          path: pathname,
          status_code: status,
          content_encoding: contentEncoding,
        });
        const endFile = files.get(pathname + suffix);
        if (!endFile) {
          throw new Error(`File ${pathname} not found`);
        }
        return serveFile(endFile, status, {
          "content-encoding": contentEncoding,
          "content-type": file.type,
        });
      } else {
        if (files.has(pathname + "/")) {
          newpath = pathname + "/";
          metrics.requests.inc({
            method: request.method,
            hostname,
            path: pathname,
            content_encoding: contentEncoding,
            status_code: (status = 302),
          });
          return new Response("", {
            status: status,
            headers: { location: newpath },
          });
        } else if (files.has(pathname.replace(/index.html$/, ""))) {
          newpath = pathname.replace(/index.html$/, "");
          metrics.requests.inc({
            method: request.method,
            hostname,
            path: newpath,
            content_encoding: contentEncoding,
            status_code: (status = 302),
          });
          return new Response("", {
            status: status,
            headers: { location: newpath },
          });
        }
        metrics.requests.inc({
          method: request.method,
          hostname,
          path: pathname,
          status_code: (status = 404),
          content_encoding: contentEncoding,
        });
        transaction.setHttpStatus(status);
        const notfound = files.get("/404.html");
        if (notfound) {
          return serveFile(notfound, status, {
            "content-type": "text/html; charset=utf-8",
          });
        } else {
          log.warn("404.html not found");
          return new Response("404 Not Found", {
            status: status,
            headers: { "content-type": "text/plain", ...defaultHeaders },
          });
        }
      }
    } catch (error) {
      transaction.setHttpStatus((status = 503));
      metrics.requests.inc({
        method: request.method,
        hostname,
        path: pathname,
        status_code: status,
        content_encoding: "identity",
      });
      Sentry.captureException(error);
      console.error("Error", error);
      return new Response("Something went wrong", { status: status });
    } finally {
      const seconds = endTimer();
      metrics.requestDuration.observe(seconds);
      transaction.finish();
      console.info(
        request.method,
        status,
        hostname,
        pathname,
        newpath ? newpath : "",
      );
    }
  },
} satisfies Serve;

export default server;