import type postgres from "postgres";
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { createApp } from "./app.js";
import { createDb } from "./db/client.js";
import { migrateDb } from "./db/migrate.js";
import { tourEvents, tours } from "./db/schema.js";
import { loadEnv } from "./env.js";
import { purgeExpiredEvents } from "./purge.js";
import { createRateLimiter } from "./rate-limit.js";

const databaseUrl =
	process.env.DATABASE_URL ?? "postgres://circuy:circuy@localhost:5432/circuy";

const draft = {
	name: "Onboarding",
	trigger: { urlPattern: "/dashboard*" },
	steps: [{ id: "st_intro", type: "modal" as const, title: "Bienvenue" }],
};

function sessionCookie(response: Response): string {
	return (
		response.headers
			.getSetCookie()
			.find((value) => value.startsWith("circuy_session="))
			?.split(";")[0] ?? ""
	);
}

describe("api", () => {
	let client: ReturnType<typeof postgres>;
	let app: ReturnType<typeof createApp>;
	let db: ReturnType<typeof createDb>["db"];

	beforeAll(async () => {
		await migrateDb(databaseUrl);
		({ db, client } = createDb(databaseUrl));
		app = createApp({
			db,
			env: loadEnv({
				NODE_ENV: "test",
				ALLOW_DEV_AUTH: "1",
				APP_ORIGIN: "http://localhost:8787",
			}),
		});
	});

	afterAll(async () => {
		await client?.end();
	});

	beforeEach(async () => {
		await client`TRUNCATE organizations CASCADE`;
	});

	async function login(email: string): Promise<string> {
		const response = await app.request("/v1/auth/dev", {
			method: "POST",
			headers: { "content-type": "application/json" },
			body: JSON.stringify({ email }),
		});
		expect(response.status).toBe(200);
		return sessionCookie(response);
	}

	async function createProject(
		cookie: string,
		name = "Site",
		origins = ["https://app.example.com"],
	) {
		const response = await app.request("/v1/projects", {
			method: "POST",
			headers: { "content-type": "application/json", cookie },
			body: JSON.stringify({ name, allowedOrigins: origins }),
		});
		expect(response.status).toBe(201);
		return (await response.json()) as { id: string; publicKey: string };
	}

	async function createTour(cookie: string, projectId: string) {
		const response = await app.request("/v1/tours", {
			method: "POST",
			headers: { "content-type": "application/json", cookie },
			body: JSON.stringify({ projectId, content: draft }),
		});
		expect(response.status).toBe(201);
		return (await response.json()) as {
			id: string;
			updatedAt: string;
			status: string;
		};
	}

	it("refuse l'administration sans session", async () => {
		const response = await app.request("/v1/auth/me");
		expect(response.status).toBe(401);
		expect(await response.json()).toMatchObject({
			error: { code: "unauthorized" },
		});
	});

	it("refuse un fournisseur OAuth non configure", async () => {
		const response = await app.request("/v1/auth/google");
		expect(response.status).toBe(503);
		expect(await response.json()).toMatchObject({
			error: { code: "oauth_not_configured" },
		});
	});

	it("une organisation ne peut pas lire le parcours d'une autre", async () => {
		const alice = await login("alice@example.com");
		const bob = await login("bob@example.com");
		const project = await createProject(alice);
		const tour = await createTour(alice, project.id);

		const read = await app.request(`/v1/tours/${tour.id}`, {
			headers: { cookie: bob },
		});
		expect(read.status).toBe(404);

		const publish = await app.request(`/v1/tours/${tour.id}/publish`, {
			method: "POST",
			headers: { cookie: bob },
		});
		expect(publish.status).toBe(404);

		const list = await app.request(`/v1/projects/${project.id}/tours`, {
			headers: { cookie: bob },
		});
		expect(list.status).toBe(404);

		const analytics = await app.request(`/v1/tours/${tour.id}/analytics`, {
			headers: { cookie: bob },
		});
		expect(analytics.status).toBe(404);

		const own = await app.request("/v1/projects", { headers: { cookie: bob } });
		expect(own.status).toBe(200);
		expect(await own.json()).toEqual({ projects: [] });

		const stolen = await app.request(`/v1/projects/${project.id}`, {
			headers: { cookie: bob },
		});
		expect(stolen.status).toBe(404);
	});

	it("publie une version immuable et incrémente à la suivante", async () => {
		const cookie = await login("alice@example.com");
		const project = await createProject(cookie);
		const tour = await createTour(cookie, project.id);

		const first = await app.request(`/v1/tours/${tour.id}/publish`, {
			method: "POST",
			headers: { cookie },
		});
		expect(first.status).toBe(200);
		const published = (await first.json()) as {
			version: number;
			propagation: { typicalSeconds: number; isolatedVisitorSeconds: number };
		};
		expect(published.version).toBe(1);
		expect(published.propagation).toEqual({
			typicalSeconds: 60,
			isolatedVisitorSeconds: 600,
		});

		const v1 = await app.request(
			`/v1/p/${project.publicKey}/tours/${tour.id}/1.json`,
		);
		expect(v1.status).toBe(200);
		expect(v1.headers.get("cache-control")).toBe(
			"public, max-age=31536000, immutable",
		);
		expect(v1.headers.get("access-control-allow-origin")).toBe("*");
		const v1Body = (await v1.json()) as { version: number; name: string };
		expect(v1Body).toMatchObject({
			id: tour.id,
			version: 1,
			name: "Onboarding",
		});

		const patched = await app.request(`/v1/tours/${tour.id}`, {
			method: "PATCH",
			headers: { "content-type": "application/json", cookie },
			body: JSON.stringify({
				updatedAt: tour.updatedAt,
				overwrite: true,
				content: { ...draft, name: "Onboarding v2" },
			}),
		});
		expect(patched.status).toBe(200);

		const second = await app.request(`/v1/tours/${tour.id}/publish`, {
			method: "POST",
			headers: { cookie },
		});
		expect(second.status).toBe(200);
		expect(await second.json()).toMatchObject({ version: 2 });

		const stillV1 = await app.request(
			`/v1/p/${project.publicKey}/tours/${tour.id}/1.json`,
		);
		expect(await stillV1.json()).toMatchObject({
			version: 1,
			name: "Onboarding",
		});
		const v2 = await app.request(
			`/v1/p/${project.publicKey}/tours/${tour.id}/2.json`,
		);
		expect(await v2.json()).toMatchObject({
			version: 2,
			name: "Onboarding v2",
		});
	});

	it("sert le manifeste avec les en-tetes de cache et revalide par ETag", async () => {
		const cookie = await login("alice@example.com");
		const project = await createProject(cookie);
		const tour = await createTour(cookie, project.id);
		await app.request(`/v1/tours/${tour.id}/publish`, {
			method: "POST",
			headers: { cookie },
		});

		const manifest = await app.request(
			`/v1/p/${project.publicKey}/manifest.json`,
		);
		expect(manifest.status).toBe(200);
		expect(manifest.headers.get("cache-control")).toBe(
			"public, max-age=60, stale-while-revalidate=600",
		);
		expect(manifest.headers.get("access-control-allow-origin")).toBe("*");
		const etag = manifest.headers.get("etag");
		expect(etag).toMatch(/^".+"$/);
		const body = (await manifest.json()) as {
			tours: Array<{ id: string; version: number }>;
		};
		expect(body.tours).toEqual([
			expect.objectContaining({ id: tour.id, version: 1 }),
		]);

		const cached = await app.request(
			`/v1/p/${project.publicKey}/manifest.json`,
			{ headers: { "if-none-match": etag ?? "" } },
		);
		expect(cached.status).toBe(304);
		expect(cached.headers.get("etag")).toBe(etag);
	});

	it("retire du manifeste un parcours suspendu sans toucher a la version immuable", async () => {
		const cookie = await login("alice@example.com");
		const project = await createProject(cookie);
		const tour = await createTour(cookie, project.id);
		await app.request(`/v1/tours/${tour.id}/publish`, {
			method: "POST",
			headers: { cookie },
		});

		const paused = await app.request(`/v1/tours/${tour.id}/status`, {
			method: "POST",
			headers: { "content-type": "application/json", cookie },
			body: JSON.stringify({ status: "paused" }),
		});
		expect(paused.status).toBe(200);

		const manifest = await app.request(
			`/v1/p/${project.publicKey}/manifest.json`,
		);
		expect(await manifest.json()).toEqual({ tours: [] });

		const version = await app.request(
			`/v1/p/${project.publicKey}/tours/${tour.id}/1.json`,
		);
		expect(version.status).toBe(200);
	});

	it("signale un conflit d'edition quand updatedAt ne correspond plus", async () => {
		const cookie = await login("alice@example.com");
		const project = await createProject(cookie);
		const tour = await createTour(cookie, project.id);

		const conflict = await app.request(`/v1/tours/${tour.id}`, {
			method: "PATCH",
			headers: { "content-type": "application/json", cookie },
			body: JSON.stringify({
				updatedAt: "2000-01-01T00:00:00.000Z",
				content: draft,
			}),
		});
		expect(conflict.status).toBe(409);
		expect(await conflict.json()).toMatchObject({
			error: { code: "conflict", field: "updatedAt" },
		});
	});

	it("refuse de publier un brouillon dont un branchement est rompu", async () => {
		const cookie = await login("alice@example.com");
		const project = await createProject(cookie);
		const [tour] = await db
			.insert(tours)
			.values({
				projectId: project.id,
				name: "Cassé",
				draftContent: {
					name: "Cassé",
					trigger: { urlPattern: "/" },
					steps: [
						{
							id: "st_intro",
							type: "modal",
							title: "Hi",
							next: "st_absent",
						},
					],
				},
			})
			.returning();

		const response = await app.request(`/v1/tours/${tour?.id}/publish`, {
			method: "POST",
			headers: { cookie },
		});
		expect(response.status).toBe(400);
		expect(await response.json()).toMatchObject({
			error: { code: "invalid_tour", field: "steps[0].next" },
		});
	});

	it("ingere un lot d'evenements et refuse une origine etrangere", async () => {
		const cookie = await login("alice@example.com");
		const project = await createProject(cookie);
		const tour = await createTour(cookie, project.id);
		await app.request(`/v1/tours/${tour.id}/publish`, {
			method: "POST",
			headers: { cookie },
		});

		const allowed = await app.request("/v1/events", {
			method: "POST",
			headers: {
				"content-type": "application/json",
				origin: "https://app.example.com",
			},
			body: JSON.stringify({
				key: project.publicKey,
				events: [
					{
						type: "start",
						tourId: tour.id,
						tourVersion: 1,
						sessionId: "sess_1",
					},
				],
			}),
		});
		expect(allowed.status).toBe(204);

		const rejected = await app.request("/v1/events", {
			method: "POST",
			headers: {
				"content-type": "application/json",
				origin: "https://evil.example",
			},
			body: JSON.stringify({
				key: project.publicKey,
				events: [
					{
						type: "start",
						tourId: tour.id,
						tourVersion: 1,
						sessionId: "sess_2",
					},
				],
			}),
		});
		expect(rejected.status).toBe(403);
		expect(await rejected.json()).toMatchObject({
			error: { code: "origin_not_allowed" },
		});
	});

	it("limite le debit d'ingestion par cle publique", async () => {
		const cookie = await login("alice@example.com");
		const project = await createProject(cookie);
		const tour = await createTour(cookie, project.id);
		const limited = createApp({
			db,
			env: loadEnv({
				NODE_ENV: "test",
				ALLOW_DEV_AUTH: "1",
				APP_ORIGIN: "http://localhost:8787",
			}),
			limiter: createRateLimiter(1, 60_000),
		});
		const payload = {
			key: project.publicKey,
			events: [
				{
					type: "start",
					tourId: tour.id,
					tourVersion: 1,
					sessionId: "sess_rl",
				},
			],
		};
		const headers = {
			"content-type": "application/json",
			origin: "https://app.example.com",
		};
		expect(
			(
				await limited.request("/v1/events", {
					method: "POST",
					headers,
					body: JSON.stringify(payload),
				})
			).status,
		).toBe(204);
		const blocked = await limited.request("/v1/events", {
			method: "POST",
			headers,
			body: JSON.stringify(payload),
		});
		expect(blocked.status).toBe(429);
	});

	it("agège le tunnel et les selecteurs rompus par version", async () => {
		const cookie = await login("alice@example.com");
		const project = await createProject(cookie);
		const created = await app.request("/v1/tours", {
			method: "POST",
			headers: { "content-type": "application/json", cookie },
			body: JSON.stringify({
				projectId: project.id,
				content: {
					name: "Onboarding",
					trigger: { urlPattern: "*", frequency: "always" },
					steps: [
						{ id: "st_intro", type: "modal", title: "Bienvenue" },
						{
							id: "st_create",
							type: "tooltip",
							title: "Creez",
							target: { candidates: [{ kind: "css", value: "#gone" }] },
						},
					],
				},
			}),
		});
		expect(created.status).toBe(201);
		const tour = (await created.json()) as { id: string };

		await app.request(`/v1/tours/${tour.id}/publish`, {
			method: "POST",
			headers: { cookie },
		});

		const ingested = await app.request("/v1/events", {
			method: "POST",
			headers: {
				"content-type": "application/json",
				origin: "https://app.example.com",
			},
			body: JSON.stringify({
				key: project.publicKey,
				events: [
					{
						type: "start",
						tourId: tour.id,
						tourVersion: 1,
						sessionId: "s1",
					},
					{
						type: "step_view",
						tourId: tour.id,
						tourVersion: 1,
						stepId: "st_intro",
						sessionId: "s1",
					},
					{
						type: "step_view",
						tourId: tour.id,
						tourVersion: 1,
						stepId: "st_create",
						sessionId: "s1",
					},
					{
						type: "target_lost",
						tourId: tour.id,
						tourVersion: 1,
						stepId: "st_create",
						sessionId: "s1",
					},
					{
						type: "start",
						tourId: tour.id,
						tourVersion: 1,
						sessionId: "s2",
					},
					{
						type: "step_view",
						tourId: tour.id,
						tourVersion: 1,
						stepId: "st_intro",
						sessionId: "s2",
					},
					{
						type: "complete",
						tourId: tour.id,
						tourVersion: 1,
						sessionId: "s2",
					},
				],
			}),
		});
		expect(ingested.status).toBe(204);

		const analytics = await app.request(`/v1/tours/${tour.id}/analytics`, {
			headers: { cookie },
		});
		expect(analytics.status).toBe(200);
		expect(await analytics.json()).toMatchObject({
			version: 1,
			starts: 2,
			completes: 1,
			funnel: [
				{ stepId: "st_intro", sessions: 2, targetLost: 0 },
				{ stepId: "st_create", sessions: 1, targetLost: 1 },
			],
		});

		const list = await app.request(`/v1/projects/${project.id}/tours`, {
			headers: { cookie },
		});
		expect(await list.json()).toMatchObject({
			tours: [{ id: tour.id, targetLost: 1 }],
		});
	});

	it("neutralise un HTML hostile a la publication", async () => {
		const cookie = await login("alice@example.com");
		const project = await createProject(cookie);
		const created = await app.request("/v1/tours", {
			method: "POST",
			headers: { "content-type": "application/json", cookie },
			body: JSON.stringify({
				projectId: project.id,
				content: {
					name: "Onboarding",
					trigger: { urlPattern: "*" },
					steps: [
						{
							id: "st_intro",
							type: "modal",
							title: "Bienvenue",
							body: '<script>alert(1)</script><strong>ok</strong><a href="javascript:alert(1)">x</a>',
						},
					],
				},
			}),
		});
		expect(created.status).toBe(201);
		const stored = (await created.json()) as {
			id: string;
			draft: { steps: Array<{ body?: string }> };
		};
		expect(stored.draft.steps[0]?.body).toContain("<strong>ok</strong>");
		expect(stored.draft.steps[0]?.body).not.toMatch(/script/i);
		expect(stored.draft.steps[0]?.body).not.toMatch(/javascript:/i);

		await app.request(`/v1/tours/${stored.id}/publish`, {
			method: "POST",
			headers: { cookie },
		});
		const version = await app.request(
			`/v1/p/${project.publicKey}/tours/${stored.id}/1.json`,
		);
		expect(version.status).toBe(200);
		const published = (await version.json()) as {
			steps: Array<{ body?: string }>;
		};
		expect(published.steps[0]?.body).toContain("<strong>ok</strong>");
		expect(published.steps[0]?.body).not.toMatch(/script/i);
		expect(published.steps[0]?.body).not.toMatch(/javascript:/i);
	});

	it("authentifie l'extension par jeton Bearer, listable et revocable", async () => {
		const response = await app.request("/v1/auth/dev", {
			method: "POST",
			headers: { "content-type": "application/json" },
			body: JSON.stringify({
				email: "ext@example.com",
				extension: true,
			}),
		});
		expect(response.status).toBe(200);
		const body = (await response.json()) as { extensionToken: string };
		expect(body.extensionToken).toMatch(/^[A-Za-z0-9_-]+$/);

		const me = await app.request("/v1/auth/me", {
			headers: { authorization: `Bearer ${body.extensionToken}` },
		});
		expect(me.status).toBe(200);

		const listed = await app.request("/v1/extensions", {
			headers: { authorization: `Bearer ${body.extensionToken}` },
		});
		expect(listed.status).toBe(200);
		const tokens = (await listed.json()) as {
			extensions: Array<{ id: string }>;
		};
		expect(tokens.extensions).toHaveLength(1);
		expect(JSON.stringify(tokens)).not.toContain(body.extensionToken);

		const revoked = await app.request(
			`/v1/extensions/${tokens.extensions[0]?.id}`,
			{
				method: "DELETE",
				headers: { authorization: `Bearer ${body.extensionToken}` },
			},
		);
		expect(revoked.status).toBe(204);

		const after = await app.request("/v1/auth/me", {
			headers: { authorization: `Bearer ${body.extensionToken}` },
		});
		expect(after.status).toBe(401);
	});

	it("n'emet pas de jeton d'extension pour une connexion dashboard", async () => {
		const cookie = await login("web@example.com");
		const listed = await app.request("/v1/extensions", {
			headers: { cookie },
		});
		expect(await listed.json()).toEqual({ extensions: [] });
	});

	it("redirige le flux web de l'extension avec un jeton", async () => {
		const start = await app.request(
			"/v1/auth/extension?redirect_uri=https://abcd.chromiumapp.org/",
		);
		expect(start.status).toBe(200);
		expect(await start.text()).toContain("Connexion");
		const cookie = start.headers
			.getSetCookie()
			.find((value) => value.startsWith("circuy_ext_redirect="))
			?.split(";")[0];
		expect(cookie).toBeTruthy();

		const finish = await app.request("/v1/auth/extension/dev", {
			method: "POST",
			headers: {
				"content-type": "application/json",
				cookie: cookie ?? "",
			},
			body: JSON.stringify({ email: "flow@example.com" }),
		});
		expect(finish.status).toBe(302);
		const location = finish.headers.get("location") ?? "";
		expect(location).toMatch(
			/^https:\/\/abcd\.chromiumapp\.org\/\?token=[A-Za-z0-9_-]+$/,
		);
	});

	it("refuse une URL de rappel d'extension etrangere", async () => {
		const response = await app.request(
			"/v1/auth/extension?redirect_uri=https://evil.example/",
		);
		expect(response.status).toBe(400);
	});

	it("supprime les evenements de plus de treize mois", async () => {
		const cookie = await login("alice@example.com");
		const project = await createProject(cookie);
		const tour = await createTour(cookie, project.id);
		await app.request(`/v1/tours/${tour.id}/publish`, {
			method: "POST",
			headers: { cookie },
		});

		const old = new Date();
		old.setMonth(old.getMonth() - 14);
		await db.insert(tourEvents).values([
			{
				projectId: project.id,
				tourId: tour.id,
				tourVersion: 1,
				type: "start",
				sessionId: "old",
				occurredAt: old,
			},
			{
				projectId: project.id,
				tourId: tour.id,
				tourVersion: 1,
				type: "start",
				sessionId: "recent",
			},
		]);

		expect(await purgeExpiredEvents(db)).toBe(1);
		const remaining = await db.select().from(tourEvents);
		expect(remaining).toHaveLength(1);
		expect(remaining[0]?.sessionId).toBe("recent");
	});

	it("supprime le compte administrateur et l'organisation s'il est seul", async () => {
		const cookie = await login("gone@example.com");
		const project = await createProject(cookie);
		const deleted = await app.request("/v1/auth/me", {
			method: "DELETE",
			headers: { cookie },
		});
		expect(deleted.status).toBe(204);

		const me = await app.request("/v1/auth/me", { headers: { cookie } });
		expect(me.status).toBe(401);

		const other = await login("other@example.com");
		const stolen = await app.request(`/v1/projects/${project.id}`, {
			headers: { cookie: other },
		});
		expect(stolen.status).toBe(404);
	});
});
