#!/usr/bin/env node
import fs from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { Readable } from "node:stream";
import { fileURLToPath } from "node:url";

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.PORT || 5731);
const BACKEND_URL = process.env.BACKEND_URL || "http://127.0.0.1:5732";
const PUBLIC_DIR = path.resolve(__dirname, "..", "public");
const MAX_PROXY_BODY_BYTES = 128 * 1024;

const CONTENT_TYPES = new Map([
  [".css", "text/css; charset=utf-8"],
  [".html", "text/html; charset=utf-8"],
  [".js", "text/javascript; charset=utf-8"],
  [".svg", "image/svg+xml"]
]);

const server = http.createServer(async (req, res) => {
  try {
    await route(req, res);
  } catch (error) {
    res.writeHead(error.statusCode || 500, {
      "content-type": "text/plain; charset=utf-8",
      "cache-control": "no-store"
    });
    res.end(error.message || "Unexpected error");
  }
});

server.listen(PORT, () => {
  console.log(`archive frontend listening on ${PORT}`);
  console.log(`archive backend: ${BACKEND_URL}`);
});

async function route(req, res) {
  const requestUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`);

  if (requestUrl.pathname === "/healthz") {
    res.writeHead(200, {
      "content-type": "application/json; charset=utf-8",
      "cache-control": "no-store"
    });
    res.end('{"ok":true}\n');
    return;
  }

  if (requestUrl.pathname.startsWith("/api/") || requestUrl.pathname.startsWith("/archives/")) {
    await proxyToBackend(req, res, requestUrl);
    return;
  }

  if (req.method !== "GET" && req.method !== "HEAD") {
    throw httpError(405, "Method not allowed");
  }

  if (requestUrl.pathname.startsWith("/assets/")) {
    await serveStatic(requestUrl.pathname, res);
    return;
  }

  await serveStatic("/index.html", res);
}

async function serveStatic(urlPath, res) {
  let decodedPath;
  try {
    decodedPath = decodeURIComponent(urlPath);
  } catch {
    throw httpError(400, "Invalid path");
  }

  const filePath = path.join(PUBLIC_DIR, decodedPath);
  const relative = path.relative(PUBLIC_DIR, filePath);
  if (relative.startsWith("..") || path.isAbsolute(relative)) {
    throw httpError(404, "Not found");
  }

  const bytes = await fs.readFile(filePath).catch((error) => {
    if (error.code === "ENOENT") {
      throw httpError(404, "Not found");
    }
    throw error;
  });

  const type = CONTENT_TYPES.get(path.extname(filePath)) || "application/octet-stream";
  res.writeHead(200, {
    "content-type": type,
    "cache-control": "no-store"
  });
  res.end(bytes);
}

async function proxyToBackend(req, res, requestUrl) {
  const upstreamUrl = new URL(`${requestUrl.pathname}${requestUrl.search}`, BACKEND_URL);
  const headers = {};
  for (const [key, value] of Object.entries(req.headers)) {
    if (["connection", "content-length", "host"].includes(key.toLowerCase())) {
      continue;
    }
    if (Array.isArray(value)) {
      headers[key] = value.join(", ");
    } else if (value !== undefined) {
      headers[key] = value;
    }
  }

  const body = req.method === "GET" || req.method === "HEAD"
    ? undefined
    : await readRequestBody(req, MAX_PROXY_BODY_BYTES);

  const upstream = await fetch(upstreamUrl, {
    method: req.method,
    headers,
    body
  });

  const responseHeaders = {};
  upstream.headers.forEach((value, key) => {
    if (!["connection", "content-encoding", "transfer-encoding"].includes(key.toLowerCase())) {
      responseHeaders[key] = value;
    }
  });

  res.writeHead(upstream.status, responseHeaders);
  if (req.method === "HEAD" || !upstream.body) {
    res.end();
    return;
  }
  Readable.fromWeb(upstream.body).pipe(res);
}

function readRequestBody(req, maxBytes) {
  return new Promise((resolve, reject) => {
    const chunks = [];
    let total = 0;
    req.on("data", (chunk) => {
      total += chunk.length;
      if (total > maxBytes) {
        reject(httpError(413, "Request body is too large"));
        req.destroy();
        return;
      }
      chunks.push(chunk);
    });
    req.on("end", () => resolve(Buffer.concat(chunks)));
    req.on("error", reject);
  });
}

function httpError(statusCode, message) {
  const error = new Error(message);
  error.statusCode = statusCode;
  return error;
}
