import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import { join, normalize, resolve, sep } from "node:path";
import { Readable } from "node:stream";

import { serve } from "@hono/node-server";
import { Hono } from "hono";
import { nanoid } from "nanoid";
import { z } from "zod";

import { env } from "./config.ts";
import {
	enqueueTest,
	getQueueContextForJob,
	getQueueStats,
	getTestState,
	setPublicBaseUrlBuilder,
	startWorker,
} from "./queue.ts";

const app = new Hono();

const testBodySchema = z.object({
	url: z.string().url(),
	device: z.enum(["desktop", "mobile"]).default("desktop"),
	iterations: z.coerce.number().int().min(1).max(5).default(1),
});

app.get("/health", (c) => c.json({ ok: true, ts: new Date().toISOString() }));

app.post("/api/test", async (c) => {
	const json = await c.req.json().catch(() => null);
	const parsed = testBodySchema.safeParse(json);
	if (!parsed.success) {
		return c.json(
			{ error: "Invalid body", details: parsed.error.flatten() },
			400,
		);
	}

	const id = nanoid(12);
	await enqueueTest({
		id,
		url: parsed.data.url,
		device: parsed.data.device,
		iterations: parsed.data.iterations,
		createdAt: new Date().toISOString(),
	});

	return c.json({ id, status: "queued" }, 202);
});

app.get("/api/test/:id", async (c) => {
	const id = c.req.param("id");
	const state = await getTestState(id);
	if (!state) return c.json({ error: "Not found" }, 404);

	// Enrich with live queue context only while the job is waiting in
	// line. Once it starts running we drop these fields — the UI should
	// switch to "Analyse en cours" anyway.
	if (state.status === "queued") {
		const ctx = await getQueueContextForJob(id);
		if (ctx) {
			return c.json({ ...state, ...ctx });
		}
	}

	return c.json(state);
});

app.get("/api/queue/stats", async (c) => {
	const stats = await getQueueStats();
	return c.json(stats);
});

app.get("/api/tests", async (c) => {
	// Lightweight listing — not paginated. Used by the dashboard for
	// debugging; production use should rely on the Next.js DB.
	return c.json([], 200);
});

// Static file serving for /results/<id>/<...path>
//
// We resolve every request to an absolute path, then assert that the
// resolved path is still inside RESULTS_DIR. Anything else gets a 403.
const RESULTS_ROOT = resolve(env.RESULTS_DIR);

app.get("/results/*", async (c) => {
	const rawPath = c.req.path.slice("/results/".length);
	const decoded = decodeURIComponent(rawPath);
	const target = resolve(join(RESULTS_ROOT, normalize(decoded)));

	if (!target.startsWith(RESULTS_ROOT + sep) && target !== RESULTS_ROOT) {
		return c.text("Forbidden", 403);
	}

	try {
		const s = await stat(target);
		if (!s.isFile()) return c.text("Not found", 404);

		const contentType = guessContentType(target);
		const stream = createReadStream(target);
		const webStream = Readable.toWeb(stream) as unknown as ReadableStream;
		return new Response(webStream, {
			status: 200,
			headers: {
				"Content-Type": contentType,
				"Content-Length": String(s.size),
				"Cache-Control": "public, max-age=3600",
			},
		});
	} catch {
		return c.text("Not found", 404);
	}
});

function guessContentType(path: string): string {
	const ext = path.split(".").pop()?.toLowerCase() ?? "";
	switch (ext) {
		case "html":
			return "text/html; charset=utf-8";
		case "json":
		case "har":
			return "application/json";
		case "png":
			return "image/png";
		case "jpg":
		case "jpeg":
			return "image/jpeg";
		case "webp":
			return "image/webp";
		case "css":
			return "text/css";
		case "js":
			return "application/javascript";
		case "svg":
			return "image/svg+xml";
		case "txt":
			return "text/plain; charset=utf-8";
		default:
			return "application/octet-stream";
	}
}

// Boot
const worker = startWorker();
setPublicBaseUrlBuilder((id) => `/results/${id}`);

const server = serve({ fetch: app.fetch, port: env.PORT }, (info) => {
	// eslint-disable-next-line no-console
	console.log(`[sitespeed-worker] listening on http://0.0.0.0:${info.port}`);
});

const shutdown = async (signal: string): Promise<void> => {
	// eslint-disable-next-line no-console
	console.log(`[sitespeed-worker] received ${signal}, shutting down…`);
	server.close();
	await worker.close();
	process.exit(0);
};

process.on("SIGTERM", () => void shutdown("SIGTERM"));
process.on("SIGINT", () => void shutdown("SIGINT"));
