import { type Job, Queue, Worker } from "bullmq";
import IORedis from "ioredis";

import { env } from "./config.ts";
import { parseRun } from "./parser.ts";
import { runSitespeed } from "./runner.ts";
import type { JobInput, QueueStats, TestResult } from "./types.ts";

export const QUEUE_NAME = "sitespeed-tests";
const STATE_PREFIX = "sitespeed:result:";
const STATE_TTL_SECONDS = 60 * 60 * 24 * 7; // 7 days

// Rough average runtime per job — used to estimate wait time for queued
// users. Based on observed example.com runs (~25-35s). Surface to clients
// via `estimatedWaitMs = position * AVG_DURATION_MS`.
export const AVG_DURATION_MS = 30_000;

// Single shared connection — BullMQ requires maxRetriesPerRequest=null
// on the worker connection, so we use the same for everything for
// simplicity.
const connection = new IORedis(env.REDIS_URL, {
	maxRetriesPerRequest: null,
});

export const queue = new Queue<JobInput>(QUEUE_NAME, { connection });

/** Read the persisted state JSON for a test id (returns null if missing). */
export async function getTestState(id: string): Promise<TestResult | null> {
	const raw = await connection.get(STATE_PREFIX + id);
	if (!raw) return null;
	try {
		return JSON.parse(raw) as TestResult;
	} catch {
		return null;
	}
}

async function saveTestState(state: TestResult): Promise<void> {
	await connection.set(
		STATE_PREFIX + state.id,
		JSON.stringify(state),
		"EX",
		STATE_TTL_SECONDS,
	);
}

/** Public base URL used to build /results/<id>/... links inside responses. */
let publicBaseUrlFn: (id: string) => string = (id) => `/results/${id}`;
export function setPublicBaseUrlBuilder(fn: (id: string) => string): void {
	publicBaseUrlFn = fn;
}

/**
 * Compute live queue context for a queued job.
 *
 * Position is 1-based: 1 = next job to run (or currently running). Active
 * jobs are counted ahead so a queued user sees an honest "you're #N
 * behind M running" picture.
 *
 * Returns null when the job id is neither in the waiting list nor active
 * — caller should treat as "no queue info available" (likely
 * already-completed or unknown).
 */
export async function getQueueContextForJob(jobId: string): Promise<{
	queuePosition: number;
	queueLength: number;
	estimatedWaitMs: number;
} | null> {
	const [waiting, active] = await Promise.all([
		queue.getWaiting(),
		queue.getActive(),
	]);

	const queueLength = waiting.length + active.length;

	// Active job? Position = 1, but it's actually being processed.
	const activeIndex = active.findIndex((j) => j.id === jobId);
	if (activeIndex !== -1) {
		return {
			queuePosition: 1,
			queueLength,
			estimatedWaitMs: 0,
		};
	}

	// Waiting: sort by timestamp ascending so older jobs go first (BullMQ
	// processes FIFO unless priority is set, which we don't use).
	const waitingSorted = [...waiting].sort(
		(a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0),
	);
	const waitingIndex = waitingSorted.findIndex((j) => j.id === jobId);
	if (waitingIndex === -1) return null;

	// Position counts: jobs in front of us in waiting + currently active.
	const aheadInWaiting = waitingIndex;
	const queuePosition = aheadInWaiting + active.length + 1;
	// Wait estimate ignores active job progress (we don't track it) — use
	// a conservative `position * AVG_DURATION_MS`.
	const estimatedWaitMs = queuePosition * AVG_DURATION_MS;

	return { queuePosition, queueLength, estimatedWaitMs };
}

export async function getQueueStats(): Promise<QueueStats> {
	const counts = await queue.getJobCounts("waiting", "active");
	return {
		waiting: counts.waiting ?? 0,
		active: counts.active ?? 0,
		concurrency: env.MAX_CONCURRENT,
		avgDurationMs: AVG_DURATION_MS,
	};
}

export async function enqueueTest(input: JobInput): Promise<void> {
	const initial: TestResult = {
		id: input.id,
		url: input.url,
		browser: "chrome",
		device: input.device,
		iterations: input.iterations,
		status: "queued",
		createdAt: input.createdAt,
		completedAt: null,
		error: null,
		metrics: null,
		lighthouse: null,
		har: null,
		reportUrl: null,
		screenshots: null,
	};
	await saveTestState(initial);
	await queue.add(QUEUE_NAME, input, {
		jobId: input.id,
		removeOnComplete: { count: 100 },
		removeOnFail: { count: 100 },
	});
}

export function startWorker(): Worker<JobInput, void> {
	const worker = new Worker<JobInput, void>(
		QUEUE_NAME,
		async (job: Job<JobInput>) => {
			const data = job.data;
			const current = (await getTestState(data.id)) ?? {
				id: data.id,
				url: data.url,
				browser: "chrome" as const,
				device: data.device,
				iterations: data.iterations,
				status: "queued" as const,
				createdAt: data.createdAt,
				completedAt: null,
				error: null,
				metrics: null,
				lighthouse: null,
				har: null,
				reportUrl: null,
				screenshots: null,
			};

			await saveTestState({ ...current, status: "running" });

			const run = await runSitespeed({
				id: data.id,
				url: data.url,
				device: data.device,
				iterations: data.iterations,
			});

			const publicBase = publicBaseUrlFn(data.id);

			if (run.exitCode !== 0) {
				// Sitespeed sometimes exits non-zero but still produces partial
				// output (e.g. when the page errors but the screenshot was
				// captured). Try to parse; if nothing useful, mark as failed.
				const parsed = await parseRun(run.outputDir, publicBase);
				const hasUsefulData = parsed.metrics !== null || parsed.screenshots !== null;

				await saveTestState({
					...current,
					status: hasUsefulData ? "completed" : "failed",
					completedAt: new Date().toISOString(),
					error: hasUsefulData
						? null
						: extractError(run.stderr) ||
							`sitespeed exited with code ${run.exitCode}`,
					metrics: parsed.metrics,
					lighthouse: parsed.lighthouse,
					har: parsed.har,
					reportUrl: parsed.reportUrl,
					screenshots: parsed.screenshots,
				});
				return;
			}

			const parsed = await parseRun(run.outputDir, publicBase);

			await saveTestState({
				...current,
				status: "completed",
				completedAt: new Date().toISOString(),
				error: null,
				metrics: parsed.metrics,
				lighthouse: parsed.lighthouse,
				har: parsed.har,
				reportUrl: parsed.reportUrl,
				screenshots: parsed.screenshots,
			});
		},
		{
			connection,
			concurrency: env.MAX_CONCURRENT,
		},
	);

	worker.on("failed", async (job, err) => {
		if (!job) return;
		const current = await getTestState(job.data.id);
		if (!current) return;
		await saveTestState({
			...current,
			status: "failed",
			completedAt: new Date().toISOString(),
			error: err.message,
		});
	});

	return worker;
}

function extractError(stderr: string): string | null {
	// Try to surface the most useful line. Sitespeed prints stack traces;
	// the first "Error:" line is usually the actionable one.
	const lines = stderr.split("\n");
	const errLine = lines.find((l) => /error/i.test(l) && !/at\s/.test(l));
	return errLine ? errLine.trim().slice(0, 500) : null;
}
