import { randomBytes } from "node:crypto";
import { tourDraftGraphSchema, tourSchema } from "@circuy/contracts";
import { and, count, desc, eq, max } from "drizzle-orm";
import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import { z } from "zod";
import { loadAnalytics, targetLostByTour } from "./analytics.js";
import type { Database } from "./db/client.js";
import {
	extensionTokens,
	organizations,
	projects,
	tourVersions,
	tours,
	users,
} from "./db/schema.js";
import type { Env } from "./env.js";
import { jsonError, parseUuid, zodField } from "./errors.js";
import {
	generateOAuthState,
	generateOAuthVerifier,
	oauthClient,
	parseOAuthProvider,
} from "./oauth.js";
import { sanitizeDraft } from "./sanitize.js";
import {
	type Actor,
	EXT_REDIRECT_COOKIE,
	createExtensionToken,
	createSession,
	destroySession,
	isAllowedExtensionRedirect,
	loadActor,
} from "./session.js";

const NO_STORE = { "cache-control": "private, no-store" };

const createProjectSchema = z.object({
	name: z.string().min(1),
	allowedOrigins: z.array(z.string().url()).default([]),
});

const patchProjectSchema = z.object({
	name: z.string().min(1).optional(),
	allowedOrigins: z.array(z.string().url()).optional(),
});

const createTourSchema = z.object({
	projectId: z.string().uuid(),
	content: z.unknown(),
});

const patchTourSchema = z.object({
	content: z.unknown(),
	updatedAt: z.string().min(1),
	overwrite: z.boolean().optional(),
});

const statusSchema = z.object({
	status: z.enum(["published", "paused", "archived"]),
});

const devAuthSchema = z.object({
	email: z.string().email(),
	extension: z.boolean().optional(),
});

function publicKey(): string {
	return `pk_${randomBytes(18).toString("base64url")}`;
}

async function readJson(c: {
	req: { json: () => Promise<unknown> };
}): Promise<unknown | Response> {
	try {
		return await c.req.json();
	} catch {
		return undefined;
	}
}

function draftError(
	c: Parameters<typeof jsonError>[0],
	parsed: {
		success: false;
		error: { issues: Array<{ message: string; path: PropertyKey[] }> };
	},
	prefix?: string,
) {
	const issue = parsed.error.issues[0];
	const field = issue ? zodField(issue.path) : undefined;
	return jsonError(
		c,
		400,
		"invalid_tour",
		issue?.message ?? "Parcours invalide.",
		field && prefix ? `${prefix}.${field}` : field,
	);
}

export function adminRoutes(db: Database, env: Env) {
	const app = new Hono<{ Variables: { actor: Actor } }>()
		.get("/auth/:provider{google|microsoft|github}", async (c) => {
			const provider = parseOAuthProvider(c.req.param("provider"));
			if (!provider) {
				return jsonError(c, 404, "not_found", "Fournisseur inconnu.");
			}
			const client = oauthClient(env, provider);
			if (!client) {
				return jsonError(
					c,
					503,
					"oauth_not_configured",
					"Ce fournisseur n'est pas configure.",
				);
			}
			const state = generateOAuthState();
			const verifier = generateOAuthVerifier();
			const url = client.createAuthorizationURL(state, verifier);
			const cookie = {
				httpOnly: true,
				sameSite: "Lax" as const,
				secure: env.cookieSecure,
				path: "/",
				maxAge: 600,
			};
			setCookie(c, "circuy_oauth_state", state, cookie);
			setCookie(c, "circuy_oauth_verifier", verifier, cookie);
			setCookie(c, "circuy_oauth_provider", provider, cookie);
			return c.redirect(url.toString());
		})
		.get("/auth/:provider{google|microsoft|github}/callback", async (c) => {
			const provider = parseOAuthProvider(c.req.param("provider"));
			if (!provider) {
				return jsonError(c, 404, "not_found", "Fournisseur inconnu.");
			}
			const storedProvider = getCookie(c, "circuy_oauth_provider");
			const state = getCookie(c, "circuy_oauth_state");
			const verifier = getCookie(c, "circuy_oauth_verifier");
			const returnedState = c.req.query("state");
			const code = c.req.query("code");
			deleteCookie(c, "circuy_oauth_state", { path: "/" });
			deleteCookie(c, "circuy_oauth_verifier", { path: "/" });
			deleteCookie(c, "circuy_oauth_provider", { path: "/" });

			if (
				!storedProvider ||
				storedProvider !== provider ||
				!state ||
				!verifier ||
				state !== returnedState ||
				!code
			) {
				return jsonError(
					c,
					400,
					"oauth_failed",
					"La connexion a echoue. Reessayez.",
				);
			}

			const client = oauthClient(env, provider);
			if (!client) {
				return jsonError(
					c,
					503,
					"oauth_not_configured",
					"Ce fournisseur n'est pas configure.",
				);
			}

			try {
				const tokens = await client.validateAuthorizationCode(code, verifier);
				const profile = await client.profile(tokens.accessToken());
				const user = await upsertOAuthUser(
					db,
					provider,
					profile.subject,
					profile.email,
				);
				await createSession(db, c, env, user.id);
				const extRedirect = getCookie(c, EXT_REDIRECT_COOKIE);
				if (extRedirect && isAllowedExtensionRedirect(extRedirect)) {
					deleteCookie(c, EXT_REDIRECT_COOKIE, {
						path: "/",
						secure: env.cookieSecure,
					});
					const token = await createExtensionToken(db, user.id, "Chrome");
					const dest = new URL(extRedirect);
					dest.searchParams.set("token", token);
					return c.redirect(dest.toString());
				}
				return c.redirect(env.appOrigin);
			} catch {
				return jsonError(
					c,
					400,
					"oauth_failed",
					"La connexion a echoue. Reessayez.",
				);
			}
		})
		.post("/auth/dev", async (c) => {
			if (!env.allowDevAuth) {
				return jsonError(c, 404, "not_found", "Ressource introuvable.");
			}
			const raw = await readJson(c);
			if (raw === undefined) {
				return jsonError(c, 400, "invalid_body", "JSON invalide.");
			}
			const parsed = devAuthSchema.safeParse(raw);
			if (!parsed.success) {
				return jsonError(
					c,
					400,
					"invalid_body",
					"Adresse email invalide.",
					"email",
				);
			}
			const user = await upsertOAuthUser(
				db,
				"dev",
				parsed.data.email,
				parsed.data.email,
			);
			const sessionId = await createSession(db, c, env, user.id);
			const extensionToken = parsed.data.extension
				? await createExtensionToken(db, user.id, "dev")
				: undefined;
			return c.json(
				{
					user: {
						id: user.id,
						email: user.email,
						organizationId: user.organizationId,
					},
					sessionId,
					...(extensionToken ? { extensionToken } : {}),
				},
				200,
				NO_STORE,
			);
		})
		.get("/auth/extension", async (c) => {
			const redirectUri = c.req.query("redirect_uri") ?? "";
			if (!isAllowedExtensionRedirect(redirectUri)) {
				return jsonError(
					c,
					400,
					"invalid_body",
					"URL de rappel invalide.",
					"redirect_uri",
				);
			}
			const actor = await loadActor(db, c);
			if (actor) {
				const token = await createExtensionToken(db, actor.userId, "Chrome");
				const dest = new URL(redirectUri);
				dest.searchParams.set("token", token);
				return c.redirect(dest.toString());
			}
			setCookie(c, EXT_REDIRECT_COOKIE, redirectUri, {
				httpOnly: true,
				sameSite: "Lax",
				secure: env.cookieSecure,
				path: "/",
				maxAge: 600,
			});
			const providers = (["google", "microsoft", "github"] as const).filter(
				(provider) => oauthClient(env, provider),
			);
			if (!env.allowDevAuth && providers.length === 0) {
				return jsonError(
					c,
					503,
					"oauth_not_configured",
					"Aucun fournisseur n'est configure.",
				);
			}
			const links = providers
				.map((provider) => `<a href="/v1/auth/${provider}">${provider}</a>`)
				.join(" ");
			const form = env.allowDevAuth
				? `<form method="post" action="/v1/auth/extension/dev">
<label>Email <input name="email" type="email" required></label>
<button type="submit">Connexion</button>
</form>`
				: "";
			return c.html(
				`<!doctype html><meta charset="utf-8"><title>Circuy</title><h1>Circuy</h1>${form}${links ? `<p>${links}</p>` : ""}`,
			);
		})
		.post("/auth/extension/dev", async (c) => {
			if (!env.allowDevAuth) {
				return jsonError(c, 404, "not_found", "Ressource introuvable.");
			}
			const email = await readExtensionDevEmail(c);
			if (!email) {
				return jsonError(
					c,
					400,
					"invalid_body",
					"Adresse email invalide.",
					"email",
				);
			}
			const redirectUri = getCookie(c, EXT_REDIRECT_COOKIE);
			if (!redirectUri || !isAllowedExtensionRedirect(redirectUri)) {
				return jsonError(
					c,
					400,
					"invalid_body",
					"URL de rappel invalide.",
					"redirect_uri",
				);
			}
			const user = await upsertOAuthUser(db, "dev", email, email);
			await createSession(db, c, env, user.id);
			const token = await createExtensionToken(db, user.id, "dev");
			deleteCookie(c, EXT_REDIRECT_COOKIE, {
				path: "/",
				secure: env.cookieSecure,
			});
			const dest = new URL(redirectUri);
			dest.searchParams.set("token", token);
			return c.redirect(dest.toString());
		})
		.use("*", async (c, next) => {
			const path = c.req.path.replace(/^\/v1/, "");
			if (
				path.startsWith("/auth/") &&
				path !== "/auth/me" &&
				path !== "/auth/logout"
			) {
				return next();
			}
			const actor = await loadActor(db, c);
			if (!actor) {
				return jsonError(c, 401, "unauthorized", "Authentification requise.");
			}
			c.set("actor", actor);
			await next();
		})
		.get("/auth/me", (c) => {
			const actor = c.get("actor");
			return c.json(
				{
					user: {
						id: actor.userId,
						email: actor.email,
						organizationId: actor.organizationId,
					},
				},
				200,
				NO_STORE,
			);
		})
		.post("/auth/logout", async (c) => {
			await destroySession(db, c, env);
			return c.body(null, 204, NO_STORE);
		})
		.delete("/auth/me", async (c) => {
			const actor = c.get("actor");
			await destroySession(db, c, env);
			const [row] = await db
				.select({ n: count() })
				.from(users)
				.where(eq(users.organizationId, actor.organizationId));
			if (Number(row?.n ?? 0) <= 1) {
				await db
					.delete(organizations)
					.where(eq(organizations.id, actor.organizationId));
			} else {
				await db.delete(users).where(eq(users.id, actor.userId));
			}
			return c.body(null, 204, NO_STORE);
		})
		.get("/extensions", async (c) => {
			const actor = c.get("actor");
			const rows = await db
				.select({
					id: extensionTokens.id,
					label: extensionTokens.label,
					lastUsedAt: extensionTokens.lastUsedAt,
					createdAt: extensionTokens.createdAt,
				})
				.from(extensionTokens)
				.where(eq(extensionTokens.userId, actor.userId))
				.orderBy(desc(extensionTokens.createdAt));
			return c.json(
				{
					extensions: rows.map((row) => ({
						id: row.id,
						label: row.label,
						lastUsedAt: row.lastUsedAt?.toISOString() ?? null,
						createdAt: row.createdAt.toISOString(),
					})),
				},
				200,
				NO_STORE,
			);
		})
		.delete("/extensions/:id", async (c) => {
			const actor = c.get("actor");
			const id = parseUuid(c.req.param("id"));
			if (!id) return jsonError(c, 404, "not_found", "Jeton introuvable.");
			const deleted = await db
				.delete(extensionTokens)
				.where(
					and(
						eq(extensionTokens.id, id),
						eq(extensionTokens.userId, actor.userId),
					),
				)
				.returning({ id: extensionTokens.id });
			if (!deleted[0]) {
				return jsonError(c, 404, "not_found", "Jeton introuvable.");
			}
			return c.body(null, 204, NO_STORE);
		})
		.get("/projects", async (c) => {
			const actor = c.get("actor");
			const rows = await db
				.select({
					id: projects.id,
					name: projects.name,
					publicKey: projects.publicKey,
					allowedOrigins: projects.allowedOrigins,
					createdAt: projects.createdAt,
				})
				.from(projects)
				.where(eq(projects.organizationId, actor.organizationId));
			return c.json(
				{
					projects: rows.map((row) => ({
						...row,
						createdAt: row.createdAt.toISOString(),
					})),
				},
				200,
				NO_STORE,
			);
		})
		.get("/projects/:id", async (c) => {
			const actor = c.get("actor");
			const id = parseUuid(c.req.param("id"));
			if (!id) return jsonError(c, 404, "not_found", "Projet introuvable.");
			const project = await db
				.select({
					id: projects.id,
					name: projects.name,
					publicKey: projects.publicKey,
					allowedOrigins: projects.allowedOrigins,
					createdAt: projects.createdAt,
				})
				.from(projects)
				.where(
					and(
						eq(projects.id, id),
						eq(projects.organizationId, actor.organizationId),
					),
				)
				.limit(1)
				.then((rows) => rows[0]);
			if (!project)
				return jsonError(c, 404, "not_found", "Projet introuvable.");
			return c.json(
				{
					...project,
					createdAt: project.createdAt.toISOString(),
				},
				200,
				NO_STORE,
			);
		})
		.post("/projects", async (c) => {
			const actor = c.get("actor");
			const raw = await readJson(c);
			if (raw === undefined) {
				return jsonError(c, 400, "invalid_body", "JSON invalide.");
			}
			const parsed = createProjectSchema.safeParse(raw);
			if (!parsed.success) {
				const issue = parsed.error.issues[0];
				return jsonError(
					c,
					400,
					"invalid_body",
					issue?.message ?? "Projet invalide.",
					issue ? zodField(issue.path) : undefined,
				);
			}
			const [project] = await db
				.insert(projects)
				.values({
					organizationId: actor.organizationId,
					name: parsed.data.name,
					publicKey: publicKey(),
					allowedOrigins: parsed.data.allowedOrigins,
				})
				.returning();
			if (!project) {
				return jsonError(c, 500, "internal", "Creation impossible.");
			}
			return c.json(
				{
					id: project.id,
					name: project.name,
					publicKey: project.publicKey,
					allowedOrigins: project.allowedOrigins,
					createdAt: project.createdAt.toISOString(),
				},
				201,
				NO_STORE,
			);
		})
		.patch("/projects/:id", async (c) => {
			const actor = c.get("actor");
			const id = parseUuid(c.req.param("id"));
			if (!id) return jsonError(c, 404, "not_found", "Projet introuvable.");
			const raw = await readJson(c);
			if (raw === undefined) {
				return jsonError(c, 400, "invalid_body", "JSON invalide.");
			}
			const parsed = patchProjectSchema.safeParse(raw);
			if (!parsed.success) {
				const issue = parsed.error.issues[0];
				return jsonError(
					c,
					400,
					"invalid_body",
					issue?.message ?? "Projet invalide.",
					issue ? zodField(issue.path) : undefined,
				);
			}
			const owned = await db
				.select({ id: projects.id })
				.from(projects)
				.where(
					and(
						eq(projects.id, id),
						eq(projects.organizationId, actor.organizationId),
					),
				)
				.limit(1)
				.then((rows) => rows[0]);
			if (!owned) return jsonError(c, 404, "not_found", "Projet introuvable.");

			const [project] = await db
				.update(projects)
				.set({
					...(parsed.data.name !== undefined ? { name: parsed.data.name } : {}),
					...(parsed.data.allowedOrigins !== undefined
						? { allowedOrigins: parsed.data.allowedOrigins }
						: {}),
				})
				.where(eq(projects.id, id))
				.returning();
			if (!project)
				return jsonError(c, 404, "not_found", "Projet introuvable.");
			return c.json(
				{
					id: project.id,
					name: project.name,
					publicKey: project.publicKey,
					allowedOrigins: project.allowedOrigins,
					createdAt: project.createdAt.toISOString(),
				},
				200,
				NO_STORE,
			);
		})
		.get("/projects/:id/tours", async (c) => {
			const actor = c.get("actor");
			const id = parseUuid(c.req.param("id"));
			if (!id) return jsonError(c, 404, "not_found", "Projet introuvable.");
			const project = await db
				.select({ id: projects.id })
				.from(projects)
				.where(
					and(
						eq(projects.id, id),
						eq(projects.organizationId, actor.organizationId),
					),
				)
				.limit(1)
				.then((rows) => rows[0]);
			if (!project)
				return jsonError(c, 404, "not_found", "Projet introuvable.");

			const rows = await db
				.select({
					id: tours.id,
					name: tours.name,
					status: tours.status,
					publishedVersion: tours.publishedVersion,
					updatedAt: tours.updatedAt,
				})
				.from(tours)
				.where(eq(tours.projectId, project.id));
			const lost = await targetLostByTour(db, project.id);
			return c.json(
				{
					tours: rows.map((row) => ({
						...row,
						updatedAt: row.updatedAt.toISOString(),
						targetLost: lost.get(row.id) ?? 0,
					})),
				},
				200,
				NO_STORE,
			);
		})
		.post("/tours", async (c) => {
			const actor = c.get("actor");
			const raw = await readJson(c);
			if (raw === undefined) {
				return jsonError(c, 400, "invalid_body", "JSON invalide.");
			}
			const envelope = createTourSchema.safeParse(raw);
			if (!envelope.success) {
				const issue = envelope.error.issues[0];
				return jsonError(
					c,
					400,
					"invalid_body",
					issue?.message ?? "Requete invalide.",
					issue ? zodField(issue.path) : undefined,
				);
			}
			const draft = tourDraftGraphSchema.safeParse(envelope.data.content);
			if (!draft.success) return draftError(c, draft, "content");
			const content = sanitizeDraft(draft.data);

			const project = await db
				.select({ id: projects.id })
				.from(projects)
				.where(
					and(
						eq(projects.id, envelope.data.projectId),
						eq(projects.organizationId, actor.organizationId),
					),
				)
				.limit(1)
				.then((rows) => rows[0]);
			if (!project)
				return jsonError(c, 404, "not_found", "Projet introuvable.");

			const [tour] = await db
				.insert(tours)
				.values({
					projectId: project.id,
					name: content.name,
					status: "draft",
					draftContent: content,
				})
				.returning();
			if (!tour) return jsonError(c, 500, "internal", "Creation impossible.");
			return c.json(serializeTour(tour), 201, NO_STORE);
		})
		.get("/tours/:id", async (c) => {
			const actor = c.get("actor");
			const tour = await loadTourForOrg(
				db,
				c.req.param("id"),
				actor.organizationId,
			);
			if (!tour) return jsonError(c, 404, "not_found", "Parcours introuvable.");
			const versions = await db
				.select({
					version: tourVersions.version,
					publishedAt: tourVersions.publishedAt,
					publishedBy: users.email,
				})
				.from(tourVersions)
				.leftJoin(users, eq(users.id, tourVersions.publishedBy))
				.where(eq(tourVersions.tourId, tour.id))
				.orderBy(desc(tourVersions.version));
			return c.json(
				{
					...serializeTour(tour),
					versions: versions.map((row) => ({
						version: row.version,
						publishedAt: row.publishedAt.toISOString(),
						publishedBy: row.publishedBy,
					})),
				},
				200,
				NO_STORE,
			);
		})
		.get("/tours/:id/analytics", async (c) => {
			const actor = c.get("actor");
			const tour = await loadTourForOrg(
				db,
				c.req.param("id"),
				actor.organizationId,
			);
			if (!tour) return jsonError(c, 404, "not_found", "Parcours introuvable.");
			const requested = c.req.query("version");
			const version = requested
				? Number(requested)
				: (tour.publishedVersion ?? undefined);
			if (version === undefined || !Number.isInteger(version) || version < 1) {
				return jsonError(
					c,
					404,
					"not_found",
					"Aucune version publiee a analyser.",
				);
			}
			const analytics = await loadAnalytics(db, tour.id, version);
			if (!analytics) {
				return jsonError(c, 404, "not_found", "Version introuvable.");
			}
			return c.json(analytics, 200, NO_STORE);
		})
		.patch("/tours/:id", async (c) => {
			const actor = c.get("actor");
			const existing = await loadTourForOrg(
				db,
				c.req.param("id"),
				actor.organizationId,
			);
			if (!existing)
				return jsonError(c, 404, "not_found", "Parcours introuvable.");

			const raw = await readJson(c);
			if (raw === undefined) {
				return jsonError(c, 400, "invalid_body", "JSON invalide.");
			}
			const envelope = patchTourSchema.safeParse(raw);
			if (!envelope.success) {
				const issue = envelope.error.issues[0];
				return jsonError(
					c,
					400,
					"invalid_body",
					issue?.message ?? "Requete invalide.",
					issue ? zodField(issue.path) : undefined,
				);
			}
			if (
				!envelope.data.overwrite &&
				existing.updatedAt.toISOString() !== envelope.data.updatedAt
			) {
				return jsonError(
					c,
					409,
					"conflict",
					"Le brouillon a ete modifie ailleurs. Rechargez-le ou ecrasez-le.",
					"updatedAt",
				);
			}

			const draft = tourDraftGraphSchema.safeParse(envelope.data.content);
			if (!draft.success) return draftError(c, draft, "content");
			const content = sanitizeDraft(draft.data);

			const [tour] = await db
				.update(tours)
				.set({
					name: content.name,
					draftContent: content,
					updatedAt: new Date(),
				})
				.where(eq(tours.id, existing.id))
				.returning();
			if (!tour) return jsonError(c, 404, "not_found", "Parcours introuvable.");
			return c.json(serializeTour(tour), 200, NO_STORE);
		})
		.post("/tours/:id/publish", async (c) => {
			const actor = c.get("actor");
			const existing = await loadTourForOrg(
				db,
				c.req.param("id"),
				actor.organizationId,
			);
			if (!existing)
				return jsonError(c, 404, "not_found", "Parcours introuvable.");

			const draft = tourDraftGraphSchema.safeParse(existing.draftContent);
			if (!draft.success) return draftError(c, draft);
			const sanitized = sanitizeDraft(draft.data);

			const published = await db.transaction(async (tx) => {
				const locked = await tx
					.select()
					.from(tours)
					.where(eq(tours.id, existing.id))
					.for("update")
					.then((rows) => rows[0]);
				if (!locked) return undefined;

				const [{ highest }] = await tx
					.select({ highest: max(tourVersions.version) })
					.from(tourVersions)
					.where(eq(tourVersions.tourId, locked.id));
				const version = (highest ?? 0) + 1;
				const content = tourSchema.parse({
					...sanitized,
					id: locked.id,
					version,
				});

				await tx.insert(tourVersions).values({
					tourId: locked.id,
					version,
					content,
					publishedBy: actor.userId,
				});
				const [updated] = await tx
					.update(tours)
					.set({
						name: sanitized.name,
						draftContent: sanitized,
						status: "published",
						publishedVersion: version,
						updatedAt: new Date(),
					})
					.where(eq(tours.id, locked.id))
					.returning();
				return updated ? { tour: updated, version } : undefined;
			});

			if (!published) {
				return jsonError(c, 404, "not_found", "Parcours introuvable.");
			}

			return c.json(
				{
					id: published.tour.id,
					version: published.version,
					status: published.tour.status,
					updatedAt: published.tour.updatedAt.toISOString(),
					propagation: {
						typicalSeconds: 60,
						isolatedVisitorSeconds: 600,
					},
				},
				200,
				NO_STORE,
			);
		})
		.post("/tours/:id/status", async (c) => {
			const actor = c.get("actor");
			const existing = await loadTourForOrg(
				db,
				c.req.param("id"),
				actor.organizationId,
			);
			if (!existing)
				return jsonError(c, 404, "not_found", "Parcours introuvable.");

			const raw = await readJson(c);
			if (raw === undefined) {
				return jsonError(c, 400, "invalid_body", "JSON invalide.");
			}
			const parsed = statusSchema.safeParse(raw);
			if (!parsed.success) {
				return jsonError(c, 400, "invalid_body", "Statut invalide.", "status");
			}
			if (
				parsed.data.status === "published" &&
				existing.publishedVersion === null
			) {
				return jsonError(
					c,
					400,
					"invalid_body",
					"Publiez le parcours avant de l'activer.",
					"status",
				);
			}

			const [tour] = await db
				.update(tours)
				.set({ status: parsed.data.status, updatedAt: new Date() })
				.where(eq(tours.id, existing.id))
				.returning();
			if (!tour) return jsonError(c, 404, "not_found", "Parcours introuvable.");
			return c.json(serializeTour(tour), 200, NO_STORE);
		});

	return app;
}

async function readExtensionDevEmail(c: {
	req: {
		header: (name: string) => string | undefined;
		json: () => Promise<unknown>;
		parseBody: () => Promise<Record<string, string | File>>;
	};
}): Promise<string | undefined> {
	const contentType = c.req.header("content-type") ?? "";
	if (contentType.includes("application/json")) {
		const raw = await readJson(c);
		const parsed = devAuthSchema.safeParse(raw);
		return parsed.success ? parsed.data.email : undefined;
	}
	try {
		const body = await c.req.parseBody();
		const email = body.email;
		if (typeof email !== "string") return undefined;
		const parsed = devAuthSchema.safeParse({ email });
		return parsed.success ? parsed.data.email : undefined;
	} catch {
		return undefined;
	}
}

async function loadTourForOrg(
	db: Database,
	rawId: string,
	organizationId: string,
) {
	const id = parseUuid(rawId);
	if (!id) return undefined;
	return db
		.select({
			id: tours.id,
			projectId: tours.projectId,
			name: tours.name,
			status: tours.status,
			draftContent: tours.draftContent,
			publishedVersion: tours.publishedVersion,
			createdAt: tours.createdAt,
			updatedAt: tours.updatedAt,
		})
		.from(tours)
		.innerJoin(projects, eq(projects.id, tours.projectId))
		.where(and(eq(tours.id, id), eq(projects.organizationId, organizationId)))
		.limit(1)
		.then((rows) => rows[0]);
}

function serializeTour(tour: {
	id: string;
	projectId: string;
	name: string;
	status: string;
	draftContent: unknown;
	publishedVersion: number | null;
	createdAt: Date;
	updatedAt: Date;
}) {
	return {
		id: tour.id,
		projectId: tour.projectId,
		name: tour.name,
		status: tour.status,
		publishedVersion: tour.publishedVersion,
		createdAt: tour.createdAt.toISOString(),
		updatedAt: tour.updatedAt.toISOString(),
		draft: tour.draftContent,
	};
}

async function upsertOAuthUser(
	db: Database,
	provider: string,
	subject: string,
	email: string,
) {
	const existing = await db
		.select()
		.from(users)
		.where(
			and(eq(users.oauthProvider, provider), eq(users.oauthSubject, subject)),
		)
		.limit(1)
		.then((rows) => rows[0]);
	if (existing) return existing;

	const byEmail = await db
		.select()
		.from(users)
		.where(eq(users.email, email))
		.limit(1)
		.then((rows) => rows[0]);
	if (byEmail) {
		const [updated] = await db
			.update(users)
			.set({ oauthProvider: provider, oauthSubject: subject })
			.where(eq(users.id, byEmail.id))
			.returning();
		if (updated) return updated;
	}

	const local = email.split("@")[0] ?? "organisation";
	const [org] = await db
		.insert(organizations)
		.values({ name: `Organisation de ${local}` })
		.returning();
	if (!org) throw new Error("organization insert failed");
	const [user] = await db
		.insert(users)
		.values({
			organizationId: org.id,
			email,
			oauthProvider: provider,
			oauthSubject: subject,
		})
		.returning();
	if (!user) throw new Error("user insert failed");
	return user;
}
