/* eslint-disable @typescript-eslint/no-explicit-any */
import express, { Request, Response } from 'express';
import * as crypto from 'crypto';
import { Transaction } from 'sequelize';
import { OidcService } from './oidcService';
import { AuditLoggerService } from '#audit/auditLoggerService';
import { AUDIT_EVENTS } from '#audit/auditEvents';
import { PasswordService } from '#services/auth/passwordService';
import EmailVerificationService from '#services/auth/emailVerificationService';
import { AuthService } from '#services/auth/authService';
import { User } from '#models/User';
import { Role } from '#models/Role';
import { UserRole } from '#models/UserRole';
import { UserStatus, UserRoleEnum } from '#constants/auth';
import { UserProvider } from '#providers/UserProvider';
import { UserAuthProvider } from '#providers/UserAuthProvider';
import Config from '#config';
import Logger from '#utils/logger';
import { GenericError } from '#interfaces/error/generic';
import RedisService from '#services/storage/redisService';

const router = express.Router();

const MIN_PASSWORD_LENGTH = 12;
const DEFAULT_TTL_HOURS = 24;

// GET /oidc/interaction/:uid — render login or consent page.
// If the authorization request carried `prompt=none` (silent SSO) the
// interaction will only fire when oidc-provider cannot auto-resolve the
// request (no session, or session lacks the required consent). In that
// case we MUST short-circuit to the corresponding OIDC error — rendering
// a login form here would break silent SSO because the user is hidden
// inside an iframe on the RP and cannot interact.
router.get('/:uid', async (req, res) => {
	try {
		const details = await OidcService.interactionDetails(req, res);
		// eslint-disable-next-line no-console
		console.log('[OIDC] GET /:uid interactionDetails result:', JSON.stringify({ prompt: details.prompt, params: details.params, session: details.session ? { accountId: details.session.accountId } : null }));

		const promptParam = String(
			(details.params as { prompt?: string } | undefined)?.prompt ?? '',
		)
			.trim()
			.toLowerCase();
		const isSilentSSO = promptParam.split(/\s+/).includes('none');

		// prompt=none short-circuit: surface the RFC 6749 / OIDC spec error
		// the RP expects so it can decide whether to fall back to a full
		// interactive login. See OIDC Core 1.0 §3.1.2.1 / §3.3.2.1.
		if (isSilentSSO) {
			const hasSession = Boolean(
				(details.session as { accountId?: string } | undefined)?.accountId,
			);

			// Case 1: silent SSO requested but user has no active session.
			if (!hasSession) {
				return OidcService.interaction_finished(
					req,
					res,
					{
						error: 'login_required',
						error_description: 'End-user authentication is required for silent SSO.',
					},
					{ mergeWithLastSubmission: false },
				);
			}

			// Case 2: session exists but consent is missing — fall through to
			// the consent form below so the user can grant it. If they deny
			// (POST /:uid/cancel) the error is `access_denied` which the RP
			// can interpret as the user actively refusing silent SSO.
			// If the user accepts, the next authorization request with
			// `prompt=none` will succeed without an interaction.
		}

		if (details.prompt.name === 'consent') {
			return res.render('consent', {
				uid: req.params.uid,
				client: details.params.client_id,
				csrfToken: csrfToken(req),
			});
		}

		return res.render('login', {
			uid: req.params.uid,
			client: details.params.client_id,
			error: undefined,
			csrfToken: csrfToken(req),
		});
	} catch (err) {
		Logger.error('[OIDC] GET /:uid failed', err);
		return res.status(500).send('Internal Server Error');
	}
});

// GET /oidc/interaction/:uid/register — render registration page
router.get('/:uid/register', async (req, res) => {
	const emailPrefill = typeof req.query.email === 'string' ? req.query.email : '';
	return res.render('register', {
		uid: req.params.uid,
		client: '',
		prefill: {
			email: emailPrefill,
			first_name: '',
			last_name: '',
			phone: '',
			address: '',
			gender: '',
			date_of_birth: '',
		},
		error: undefined,
		csrfToken: csrfToken(req),
	});
});

// POST /oidc/interaction/:uid/login — submit login credentials
router.post('/:uid/login', async (req, res) => {
	const { email, password, remember } = req.body as { email?: string; password?: string; remember?: string };

	try {
		const user = await validate_credentials(email ?? '', password ?? '');
		await logAudit(AUDIT_EVENTS.LOGIN_SUCCESS, user.id, req);

		const result = {
			login: {
				accountId: user.id,
				remember: Boolean(remember),
				ts: Math.floor(Date.now() / 1000),
			},
		};

		return OidcService.interaction_finished(req, res, result, { mergeWithLastSubmission: false });
	} catch (err: unknown) {
		await logAudit(AUDIT_EVENTS.LOGIN_FAILED, undefined, req);

		const err_obj = err as { status?: number; httpStatus?: number; message?: string };
		if (err_obj?.status === 429 || err_obj?.httpStatus === 429) {
			return res.status(429).render('login', {
				uid: req.params.uid,
				client: (req.query.client_id as string) ?? '',
				error: err_obj?.message ?? '',
				csrfToken: csrfToken(req),
			});
		}

		return res.status(401).render('login', {
			uid: req.params.uid,
			client: (req.query.client_id as string) ?? '',
			error: 'Email or password is invalid',
			csrfToken: csrfToken(req),
		});
	}
});

// POST /oidc/interaction/:uid/register — create account in pending_verification
// status and render the verify-pending screen. Auto-login is intentionally
// skipped; the user must click the link in the verification email first.
router.post('/:uid/register', async (req, res) => {
	const uid = req.params.uid;
	const {
		email,
		username,
		password,
		confirmPassword,
		first_name,
		last_name,
		phone,
		address,
		gender,
		date_of_birth,
		terms_accepted,
	} = req.body as {
		email?: string;
		username?: string;
		password?: string;
		confirmPassword?: string;
		first_name?: string;
		last_name?: string;
		phone?: string;
		address?: string;
		gender?: string;
		date_of_birth?: string;
		terms_accepted?: string | boolean;
	};

	const trimmedEmail = (email ?? '').trim().toLowerCase();
	const trimmedUsername = username?.trim() || null;
	const trimmedFirstName = first_name?.trim() || null;
	const trimmedLastName = last_name?.trim() || null;
	const trimmedPhone = phone?.trim() || null;
	const trimmedAddress = address?.trim() || null;
	const trimmedGender = gender?.trim() || null;
	const trimmedDob = date_of_birth?.trim() || null;
	const termsAccepted = terms_accepted === '1' || terms_accepted === true || terms_accepted === 'on';
	const derivedUsername = trimmedUsername ?? trimmedEmail.split('@')[0] ?? null;

	const clientId = await getClientIdFromUid(uid);

	const renderRegisterError = (error: string) =>
		res.status(400).render('register', {
			uid,
			client: clientId,
			prefill: {
				email: trimmedEmail,
				first_name: trimmedFirstName ?? '',
				last_name: trimmedLastName ?? '',
				phone: trimmedPhone ?? '',
				address: trimmedAddress ?? '',
				gender: trimmedGender ?? '',
				date_of_birth: trimmedDob ?? '',
			},
			error,
			csrfToken: csrfToken(req),
		});

	if (!trimmedEmail || !password) {
		return renderRegisterError('Email and password are required');
	}
	if (password !== confirmPassword) {
		return renderRegisterError('Passwords do not match');
	}
	if (password.length < MIN_PASSWORD_LENGTH) {
		return renderRegisterError(`Password must be at least ${MIN_PASSWORD_LENGTH} characters`);
	}
	if (!termsAccepted) {
		return renderRegisterError('You must accept the terms to create an account');
	}

	const ttlHours = getVerificationTtlHours();

	try {
		const userProvider = new UserProvider();
		const existing = await userProvider.getAll({
			where: { email: trimmedEmail },
			page: 1,
			pageSize: 1,
		});

		if (existing.rows.length > 0) {
			const existingUser = existing.rows[0]!;

			// Active account: user already verified → redirect to login
			if (existingUser.status === UserStatus.ACTIVE) {
				return renderRegisterError('Email already registered. Please sign in instead.');
			}

			// Pending account: same email, not yet verified.
			// Instead of blocking, send a fresh verification email (or re-send
			// the existing one). This handles the case where:
			//  - The user never received the first email (SMTP failure, spam filter)
			//  - The user refreshed the page and tried to register again
			// No new account is created; we reuse the existing pending record.
			const oidcParams = await getOidcParamsFromUid(uid);
		try {
				const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existingUser, ttlHours, {
					uid,
					clientId: oidcParams.client_id,
					redirectUri: oidcParams.redirect_uri,
				});
				const devVerifyUrl = isDevMode()
					? EmailVerificationService.getInstance().buildVerificationUrl(issued.token)
					: null;

				// Persist pending-email state in Redis so the verify-pending page
				// remains functional even after the OIDC interaction session expires
				// (e.g. user refreshes after an hour). TTL = 2 × email token TTL.
				const redis = RedisService.getInstance();
				const pendingKey = `pending_email:${trimmedEmail}`;
				await redis.set(
					pendingKey,
					{ uid, email: trimmedEmail, createdAt: new Date().toISOString() },
					Math.round(ttlHours * 3600 * 2),
				);

				return res.status(200).render('verify-pending', {
					uid,
					client: clientId,
					email: trimmedEmail,
					ttlHours,
					devVerifyUrl,
					error: undefined,
					csrfToken: csrfToken(req),
				});
			} catch (mailErr) {
				Logger.error(`[OIDC register] Pending user ${existingUser.id} — re-send failed`, mailErr);
				// Even if mail fails, surface verify-pending so the user can retry
				return res.status(200).render('verify-pending', {
					uid,
					client: clientId,
					email: trimmedEmail,
					ttlHours,
					devVerifyUrl: null,
					error: 'Could not send verification email. Click "Resend" in a moment.',
					csrfToken: csrfToken(req),
				});
			}
		}

		const passwordHash = await PasswordService.hashPassword(password);
		const userId = crypto.randomUUID();
		const authId = crypto.randomUUID();
		const userRoleId = crypto.randomUUID() as string;
		const parsedDob = trimmedDob ? parseIsoDate(trimmedDob) : null;

		const transaction: Transaction = await userProvider.transaction();
		let createdUserId: ReturnType<typeof crypto.randomUUID> = userId;
		let createdUser: User | null = null;
		try {
			const userInstance = (await userProvider.create(
				{
					id: userId,
					email: trimmedEmail,
					username: derivedUsername,
					first_name: trimmedFirstName,
					last_name: trimmedLastName,
					phone: trimmedPhone,
					address: trimmedAddress,
					date_of_birth: parsedDob as never,
					gender: trimmedGender as never,
					status: UserStatus.PENDING_VERIFICATION,
					email_verified_at: null,
				} as never,
				{ transaction, skipUniqueCheck: true },
			)) as unknown as User;
			createdUserId = userInstance.id as ReturnType<typeof crypto.randomUUID>;
			createdUser = userInstance;

			const userAuthProvider = new UserAuthProvider();
			await userAuthProvider.create(
				{
					id: authId,
					user_id: createdUserId,
					password_hash: passwordHash,
				} as never,
				{ transaction, skipUniqueCheck: true },
			);

			// Assign default 'user' role if it has been seeded. Missing role
			// is non-fatal: the user lands without role-bound claims until
			// the role is provisioned. Surfacing the warning keeps the gap
			// observable without blocking sign-up.
			const defaultRole = await Role.findOne({
				where: { name: UserRoleEnum.USER },
				transaction,
			});
			if (defaultRole) {
				await UserRole.create(
					{
						id: userRoleId,
						user_id: createdUserId,
						role_id: defaultRole.id,
						is_primary: true,
						assigned_at: new Date(),
					} as never,
					{ transaction },
				);
			} else {
				Logger.warn(`[OIDC register] default role '${UserRoleEnum.USER}' not seeded; user ${createdUserId} has no role`);
			}

			await transaction.commit();
		} catch (txErr) {
			await transaction.rollback();
			throw txErr;
		}

		await logAudit(AUDIT_EVENTS.REGISTER_SUCCESS, createdUserId, req);

		const oidcParams = await getOidcParamsFromUid(uid);

		let devVerifyUrl: string | null = null;
		if (createdUser) {
			// Persist pending-email state in Redis so verify-pending works even
			// after the OIDC interaction session expires on refresh.
			const redis = RedisService.getInstance();
			await redis.set(
				`pending_email:${trimmedEmail}`,
				{ uid, email: trimmedEmail, createdAt: new Date().toISOString() },
				Math.round(ttlHours * 3600 * 2),
			);

			try {
				const issued = await EmailVerificationService.getInstance().sendVerificationEmail(createdUser, ttlHours, {
					uid,
					clientId: oidcParams.client_id,
					redirectUri: oidcParams.redirect_uri,
				});
				devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null;
			} catch (mailErr) {
				// Email transport is best-effort: token row was already inserted
				// inside sendVerificationEmail before the SMTP attempt, so the
				// user can still resend or get a fresh link.
				Logger.error(
					`[OIDC register] Failed to dispatch verification email for user ${createdUserId}. ` +
						`Account created but no email was sent.`,
					mailErr,
				);
			}
		}

		return res.status(200).render('verify-pending', {
			uid,
			client: clientId,
			email: trimmedEmail,
			ttlHours,
			devVerifyUrl,
			error: undefined,
			csrfToken: csrfToken(req),
		});
	} catch (err: unknown) {
		await logAudit(AUDIT_EVENTS.REGISTER_FAILED, undefined, req);
		const err_obj = err as { code?: string; message?: string; original?: { code?: string } };
		const is_unique_violation = err_obj?.code === '23505' || err_obj?.original?.code === '23505';
		Logger.warn(`[OIDC register] failed: ${err_obj?.message ?? String(err)}`);
		return renderRegisterError(
			is_unique_violation
				? 'Email or username already exists. Please try again.'
				: 'Unable to create account. Please try again.',
		);
	}
});

// POST /oidc/interaction/:uid/resend-verification — mint a fresh token and
// re-render the verify-pending screen. The response is identical for known
// and unknown emails to avoid leaking account existence.
//
// When the OIDC interaction uid has expired (e.g. user refreshed after hours)
// we fall back to the uid stored in Redis under pending_email:<email>.
router.post('/:uid/resend-verification', async (req, res) => {
	const uid = req.params.uid;
	const emailRaw = (req.body as { email?: string }).email ?? '';
	const trimmedEmail = emailRaw.trim().toLowerCase();

	const ttlHours = getVerificationTtlHours();
	const redis = RedisService.getInstance();

	// Try to use the most recent uid for this email from Redis.
	// If the OIDC session uid has expired, the Redis uid is the fallback.
	const redisData = await redis.get<{ uid: string; email: string; createdAt: string }>(
		`pending_email:${trimmedEmail}`,
	);
	const resolvedUid = redisData?.uid ?? uid;

	let clientId = '';
	try {
		clientId = (await getClientIdFromUid(resolvedUid)) ?? '';
	} catch {
		clientId = '';
	}

	const renderPending = (body: {
		email: string;
		ttlHours: number;
		devVerifyUrl?: string | null;
		error?: string;
	}) =>
		res.status(200).render('verify-pending', {
			uid: resolvedUid,
			client: clientId,
			email: body.email,
			ttlHours: body.ttlHours,
			devVerifyUrl: body.devVerifyUrl ?? null,
			error: body.error,
			csrfToken: csrfToken(req),
		});

	if (!trimmedEmail) {
		return renderPending({
			email: '',
			ttlHours,
			error: 'Missing email. Please re-open the verification screen.',
		});
	}

	try {
		const existing = await User.findOne({ where: { email: trimmedEmail } });

		// Intentionally do not leak whether the account exists: any caller
		// receives the same "check your inbox" screen, regardless of whether
		// the user is unknown, already verified, or not pending.
		if (!existing || existing.status !== UserStatus.PENDING_VERIFICATION) {
			return renderPending({ email: trimmedEmail, ttlHours });
		}

		const oidcParams = await getOidcParamsFromUid(resolvedUid);

		const issued = await EmailVerificationService.getInstance().sendVerificationEmail(existing, ttlHours, {
			uid: resolvedUid,
			clientId: oidcParams.client_id,
			redirectUri: oidcParams.redirect_uri,
		});
		const devVerifyUrl = isDevMode() ? EmailVerificationService.getInstance().buildVerificationUrl(issued.token) : null;

		// Keep Redis fresh with the current uid
		await redis.set(
			`pending_email:${trimmedEmail}`,
			{ uid: resolvedUid, email: trimmedEmail, createdAt: new Date().toISOString() },
			Math.round(ttlHours * 3600 * 2),
		);

		return renderPending({
			email: trimmedEmail,
			ttlHours,
			devVerifyUrl,
		});
	} catch (err) {
		Logger.error('[OIDC resend-verification] failed', err);
		return renderPending({
			email: trimmedEmail,
			ttlHours,
			error: 'Could not resend verification email. Please try again later.',
		});
	}
});

// POST /oidc/interaction/:uid/confirm — approve consent
router.post('/:uid/confirm', async (req, res) => {
	try {
		const details = await OidcService.interactionDetails(req, res);
		const { prompt, grantId, session, params } = details;

		let grant: any;
		if (grantId) {
			grant = await OidcService.getInstance().Grant.find(grantId);
		} else {
			grant = new (OidcService.getInstance().Grant as any)({
				accountId: session.accountId,
				clientId: params.client_id,
			});
		}

		if (prompt.details?.missingOIDCScope) {
			grant.addOIDCScope(prompt.details.missingOIDCScope.join(' '));
		}
		if (prompt.details?.missingOIDCClaims) {
			grant.addOIDCClaims(prompt.details.missingOIDCClaims);
		}
		if (prompt.details?.missingResourceScopes) {
			for (const [indicator, scopes] of Object.entries(prompt.details.missingResourceScopes as Record<string, string[]>)) {
				grant.addResourceScope(indicator, scopes.join(' '));
			}
		}

		const result = { consent: { grantId: await grant.save() } };
		return OidcService.interaction_finished(req, res, result, { mergeWithLastSubmission: true });
	} catch (err) {
		Logger.error('[OIDC] POST /:uid/confirm failed', err);
		return res.status(500).send('Internal Server Error');
	}
});

// POST /oidc/interaction/:uid/cancel — deny consent
router.post('/:uid/cancel', async (req, res) => {
	return OidcService.interaction_finished(
		req,
		res,
		{ error: 'access_denied', error_description: 'User cancelled the request' },
		{ mergeWithLastSubmission: false },
	);
});

// ── Helpers ──────────────────────────────────────────────────────────────────

function csrfToken(req: express.Request): string {
	return (req as { csrfToken?: () => string }).csrfToken?.() ?? '';
}

function isDevMode(): boolean {
	return process.env.NODE_ENV !== 'production';
}

function getVerificationTtlHours(): number {
	const fromConfig = (Config as unknown as { emailVerification?: { tokenTtlHours?: number } })
		?.emailVerification?.tokenTtlHours;
	return typeof fromConfig === 'number' && fromConfig > 0 ? fromConfig : DEFAULT_TTL_HOURS;
}

function parseIsoDate(value: string): Date | null {
	if (!value) return null;
	const d = new Date(value);
	return Number.isNaN(d.getTime()) ? null : d;
}

/**
 * Resolve the OIDC client_id for a given interaction uid. The cookie-based
 * `interactionDetails` flow needs the original request, which we do not have
 * from a POST handler; instead we go straight to the provider's Interaction
 * model which persists the raw params. Failure here is non-fatal — the
 * client label is decorative on the verify-pending screen.
 */
async function getClientIdFromUid(uid: string): Promise<string> {
	try {
		const provider = OidcService.getInstance();
		const interaction = await provider.Interaction.find(uid);
		const params = (interaction?.params as { client_id?: string } | undefined) ?? undefined;
		return params?.client_id ?? '';
	} catch (err) {
		Logger.warn(
			`[OIDC] getClientIdFromUid failed for ${uid}: ${err instanceof Error ? err.message : String(err)}`,
		);
		return '';
	}
}

/**
 * Resolve the full OIDC authorization params for a given interaction uid.
 * Used by POST handlers to extract redirect_uri for storing in the
 * email verification token so that auto-login can resume the interaction.
 */
async function getOidcParamsFromUid(uid: string): Promise<{ client_id: string; redirect_uri?: string }> {
	try {
		const provider = OidcService.getInstance();
		const interaction = await provider.Interaction.find(uid);
		const params = (interaction?.params as { client_id?: string; redirect_uri?: string } | undefined) ?? {};
		return { client_id: params?.client_id ?? '', redirect_uri: params?.redirect_uri };
	} catch (err) {
		Logger.warn(
			`[OIDC] getOidcParamsFromUid failed for ${uid}: ${err instanceof Error ? err.message : String(err)}`,
		);
		return { client_id: '' };
	}
}

async function validate_credentials(
	email: string,
	password: string,
): Promise<{ id: string; email: string; username: string; status: string }> {
	try {
		const result = await AuthService.login({
			email,
			password,
			platform: 'web',
		});
		return {
			id: result.user.id,
			email: result.user.email,
			username: result.user.username ?? '',
			status: result.user.status ?? 'active',
		};
	} catch (err) {
		if (err instanceof GenericError) {
			err.httpStatus = 401;
			throw err;
		}
		throw new GenericError('INVALID_CREDENTIALS');
	}
}

async function logAudit(eventType: string, userId: string | undefined, req: express.Request): Promise<void> {
	if (!Config.audit.enabled) return;
	try {
		const audit = new AuditLoggerService();
		const logEntry: {
			eventType: string;
			userId?: string;
			status: 'success' | 'failed';
			ip?: string;
			userAgent?: string;
			requestId?: string;
		} = {
			eventType,
			status: eventType.endsWith('_SUCCESS') ? 'success' : 'failed',
		};
		if (userId) logEntry.userId = userId;
		if (req.ip) logEntry.ip = req.ip;
		if (req.headers['user-agent']) logEntry.userAgent = req.headers['user-agent'];
		if ((req as any)?.requestId) logEntry.requestId = (req as any).requestId;
		await audit.log(logEntry);
	} catch (err) {
		// Don't let audit failures break the auth flow.
		Logger.warn(`[OIDC] audit log failed for ${eventType}: ${err instanceof Error ? err.message : String(err)}`);
	}
}

export default router;
