#!/usr/bin/env node
import { createReadStream } from "node:fs";
import fs from "node:fs/promises";
import http from "node:http";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { ArchiveCatalog, archiveIdForUrl, isSafeArchiveFileName, normalizeArchiveUrl } from "./archive-catalog.mjs";
import { defaultArchivePath } from "./asset-inliner.mjs";

const PORT = Number(process.env.PORT || 5732);
const ARCHIVE_PATH = path.resolve(process.env.ARCHIVE_PATH || defaultArchivePath());
const ARCHIVE_WORKER_URL = process.env.ARCHIVE_WORKER_URL || "http://127.0.0.1:5733";
const PUBLIC_ARCHIVES_PATH = process.env.PUBLIC_ARCHIVES_PATH || "/archives";
const JOB_TIMEOUT_MS = Number(process.env.ARCHIVE_JOB_TIMEOUT_MS || 120000);
const MAX_BODY_BYTES = 64 * 1024;

const catalog = new ArchiveCatalog({
  archivePath: ARCHIVE_PATH,
  publicBasePath: PUBLIC_ARCHIVES_PATH
});

const jobs = new Map();
const activeJobByUrl = new Map();
let workerQueue = Promise.resolve();

const server = http.createServer(async (req, res) => {
  try {
    await route(req, res);
  } catch (error) {
    sendJson(res, error.statusCode || 500, {
      ok: false,
      error: error.message || "Unexpected error"
    });
  }
});

server.listen(PORT, () => {
  console.log(`archive backend listening on ${PORT}`);
  console.log(`archive path: ${ARCHIVE_PATH}`);
  console.log(`archive worker: ${ARCHIVE_WORKER_URL}`);
});

const cleanupTimer = setInterval(cleanupJobs, 10 * 60 * 1000);
cleanupTimer.unref?.();

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

  if (req.method === "GET" && requestUrl.pathname === "/healthz") {
    sendJson(res, 200, { ok: true });
    return;
  }

  if (req.method === "GET" && requestUrl.pathname === "/api/archives/lookup") {
    const sourceUrl = normalizeArchiveUrl(requestUrl.searchParams.get("url"));
    const archive = await catalog.findByUrl(sourceUrl);
    sendJson(res, 200, {
      ok: true,
      exists: !!archive,
      archive
    });
    return;
  }

  if (req.method === "POST" && requestUrl.pathname === "/api/archives") {
    const body = await readJsonBody(req);
    const sourceUrl = normalizeArchiveUrl(body.url);
    const response = await createOrFindArchive(sourceUrl);
    sendJson(res, response.statusCode, response.body);
    return;
  }

  const jobMatch = requestUrl.pathname.match(/^\/api\/jobs\/([^/]+)$/);
  if (req.method === "GET" && jobMatch) {
    const job = jobs.get(jobMatch[1]);
    if (!job) {
      sendJson(res, 404, { ok: false, error: "Job not found" });
      return;
    }
    sendJson(res, 200, {
      ok: true,
      job: publicJob(job)
    });
    return;
  }

  if (req.method === "GET" && requestUrl.pathname.startsWith(`${PUBLIC_ARCHIVES_PATH}/`)) {
    await serveArchive(requestUrl.pathname.slice(PUBLIC_ARCHIVES_PATH.length + 1), res);
    return;
  }

  sendJson(res, 404, { ok: false, error: "Not found" });
}

async function createOrFindArchive(sourceUrl) {
  const existing = await catalog.findByUrl(sourceUrl);
  if (existing) {
    return {
      statusCode: 200,
      body: {
        ok: true,
        status: "done",
        mode: "existing",
        archive: existing
      }
    };
  }

  const activeJobId = activeJobByUrl.get(sourceUrl);
  const activeJob = activeJobId ? jobs.get(activeJobId) : null;
  if (activeJob && !isTerminal(activeJob.status)) {
    return {
      statusCode: 202,
      body: {
        ok: true,
        status: activeJob.status,
        mode: "active",
        job: publicJob(activeJob)
      }
    };
  }

  const job = {
    id: cryptoRandomId(),
    archiveId: archiveIdForUrl(sourceUrl),
    sourceUrl,
    status: "queued",
    message: "Queued",
    createdAt: new Date().toISOString(),
    startedAt: null,
    updatedAt: new Date().toISOString(),
    finishedAt: null,
    archive: null,
    error: null
  };
  jobs.set(job.id, job);
  activeJobByUrl.set(sourceUrl, job.id);
  enqueueJob(job);

  return {
    statusCode: 202,
    body: {
      ok: true,
      status: job.status,
      mode: "created",
      job: publicJob(job)
    }
  };
}

function enqueueJob(job) {
  const run = () => executeJob(job);
  workerQueue = workerQueue.then(run, run);
}

async function executeJob(job) {
  if (job.status !== "queued") {
    return;
  }

  updateJob(job, {
    status: "running",
    message: "Archiving",
    startedAt: new Date().toISOString()
  });

  try {
    const result = await requestWorkerArchive(job.sourceUrl, job.archiveId);
    const archive = await catalog.recordResult(job.sourceUrl, result);
    updateJob(job, {
      status: "done",
      message: "Opening",
      archive,
      finishedAt: new Date().toISOString()
    });
  } catch (error) {
    updateJob(job, {
      status: "failed",
      message: "Failed",
      error: error.message || "Archive failed",
      finishedAt: new Date().toISOString()
    });
  } finally {
    activeJobByUrl.delete(job.sourceUrl);
  }
}

async function requestWorkerArchive(sourceUrl, archiveId) {
  const workerUrl = new URL("/archive", ARCHIVE_WORKER_URL);
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), JOB_TIMEOUT_MS);
  timeout.unref?.();

  try {
    const response = await fetch(workerUrl, {
      method: "POST",
      headers: {
        "content-type": "application/json"
      },
      body: JSON.stringify({ url: sourceUrl, id: archiveId }),
      signal: controller.signal
    });
    const text = await response.text();
    const parsed = parseJson(text);
    if (!response.ok || parsed.ok === false) {
      throw new Error(parsed.error || text || `Worker returned ${response.status}`);
    }
    return parsed.result || parsed;
  } catch (error) {
    if (error.name === "AbortError") {
      throw new Error(`Archive timed out after ${Math.round(JOB_TIMEOUT_MS / 1000)} seconds`);
    }
    throw error;
  } finally {
    clearTimeout(timeout);
  }
}

async function serveArchive(rawFileName, res) {
  let fileName;
  try {
    fileName = decodeURIComponent(rawFileName);
  } catch {
    sendJson(res, 400, { ok: false, error: "Invalid archive path" });
    return;
  }

  if (!isSafeArchiveFileName(fileName)) {
    sendJson(res, 404, { ok: false, error: "Archive not found" });
    return;
  }

  const filePath = path.join(ARCHIVE_PATH, fileName);
  const stat = await fs.stat(filePath).catch(() => null);
  if (!stat?.isFile()) {
    sendJson(res, 404, { ok: false, error: "Archive not found" });
    return;
  }

  const stream = createReadStream(filePath, { encoding: "utf8" });
  stream.on("error", () => {
    if (!res.headersSent) {
      sendJson(res, 404, { ok: false, error: "Archive not found" });
    } else {
      res.destroy();
    }
  });
  res.writeHead(200, {
    "content-type": "text/html; charset=utf-8",
    "cache-control": "no-store"
  });
  stream.pipe(res);
}

function updateJob(job, values) {
  Object.assign(job, values, {
    updatedAt: new Date().toISOString()
  });
}

function publicJob(job) {
  const startedAt = job.startedAt || job.createdAt;
  return {
    id: job.id,
    sourceUrl: job.sourceUrl,
    status: job.status,
    message: job.message,
    createdAt: job.createdAt,
    startedAt,
    updatedAt: job.updatedAt,
    finishedAt: job.finishedAt,
    elapsedMs: startedAt ? Date.now() - Date.parse(startedAt) : 0,
    archive: job.archive,
    error: job.error
  };
}

function isTerminal(status) {
  return status === "done" || status === "failed";
}

function cleanupJobs() {
  const cutoff = Date.now() - 60 * 60 * 1000;
  for (const [id, job] of jobs) {
    if (isTerminal(job.status) && Date.parse(job.finishedAt || job.updatedAt) < cutoff) {
      jobs.delete(id);
    }
  }
}

async function readJsonBody(req) {
  const text = await readRequestBody(req, MAX_BODY_BYTES);
  if (!text.trim()) {
    throw httpError(400, "Request body is required");
  }
  try {
    return JSON.parse(text);
  } catch {
    throw httpError(400, "Request body must be JSON");
  }
}

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).toString("utf8")));
    req.on("error", reject);
  });
}

function sendJson(res, statusCode, payload) {
  res.writeHead(statusCode, {
    "content-type": "application/json; charset=utf-8",
    "cache-control": "no-store"
  });
  res.end(`${JSON.stringify(payload)}\n`);
}

function parseJson(text) {
  try {
    return JSON.parse(text);
  } catch {
    throw new Error(text || "Worker returned invalid JSON");
  }
}

function cryptoRandomId() {
  return randomUUID();
}

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