// SPDX-License-Identifier: AGPL-3.0-or-later

import crypto from 'node:crypto';
import {FLUXER_USER_AGENT} from '@fluxer/constants/src/Core';
import {UserAuthenticatorTypes, UserFlags} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import {requireClientIp} from '@fluxer/ip_utils/src/ClientIp';
import {getSameIpDecisionKey} from '@fluxer/ip_utils/src/IpAddress';
import type {ForgotPasswordRequest, ResetPasswordRequest} from '@fluxer/schema/src/domains/auth/AuthSchemas';
import {ms, seconds} from 'itty-time';
import type {ApiContext} from '../ApiContext';
import {createMfaTicket, createPasswordResetToken} from '../BrandedTypes';
import {Logger} from '../Logger';
import type {User} from '../models/User';
import {EXTERNAL_RESPONSE_LIMITS} from '../utils/ExternalResponseLimits';
import * as FetchUtils from '../utils/FetchUtils';
import {hashPassword as hashPasswordUtil, verifyPassword as verifyPasswordUtil} from '../utils/PasswordUtils';
import * as AuthSession from './AuthSession';
import * as AuthUtility from './AuthUtility';

const PWNED_PASSWORDS_TIMEOUT_MS = ms('5 seconds');
const PWNED_PASSWORD_CACHE_MAX_PREFIXES = 128;

interface CacheEntry {
	pwnedSuffixes: ReadonlySet<string>;
	expiresAt: number;
}

class PwnedPasswordCache {
	private cache = new Map<string, CacheEntry>();
	private readonly maxSize: number;
	private readonly ttlMs: number;

	constructor(maxSize = PWNED_PASSWORD_CACHE_MAX_PREFIXES, ttlMs = ms('1 hour')) {
		this.maxSize = maxSize;
		this.ttlMs = ttlMs;
	}

	get(hashPrefix: string): ReadonlySet<string> | undefined {
		const entry = this.cache.get(hashPrefix);
		if (!entry) {
			return undefined;
		}
		if (Date.now() > entry.expiresAt) {
			this.cache.delete(hashPrefix);
			return undefined;
		}
		this.cache.delete(hashPrefix);
		this.cache.set(hashPrefix, entry);
		return entry.pwnedSuffixes;
	}

	set(hashPrefix: string, pwnedSuffixes: ReadonlySet<string>): void {
		if (this.cache.size >= this.maxSize && !this.cache.has(hashPrefix)) {
			const firstKey = this.cache.keys().next().value;
			if (firstKey !== undefined) {
				this.cache.delete(firstKey);
			}
		}
		this.cache.set(hashPrefix, {
			pwnedSuffixes,
			expiresAt: Date.now() + this.ttlMs,
		});
	}

	clear(): void {
		this.cache.clear();
	}
}

interface ForgotPasswordParams {
	data: ForgotPasswordRequest;
	request: Request;
}

interface ResetPasswordParams {
	data: ResetPasswordRequest;
	request: Request;
}

interface VerifyPasswordParams {
	password: string;
	passwordHash: string;
}

type ResetPasswordResult =
	| {
			user_id: string;
			token: string;
	  }
	| {
			mfa: true;
			ticket: string;
			allowed_methods: Array<string>;
			totp: boolean;
			webauthn: boolean;
	  };

const pwnedPasswordCache = new PwnedPasswordCache(PWNED_PASSWORD_CACHE_MAX_PREFIXES, ms('1 hour'));

export function resetPwnedPasswordCacheForTesting(): void {
	pwnedPasswordCache.clear();
}

export async function hashPassword(_ctx: ApiContext, password: string): Promise<string> {
	return hashPasswordUtil(password);
}

export async function verifyPassword(
	_ctx: ApiContext,
	{password, passwordHash}: VerifyPasswordParams,
): Promise<boolean> {
	return verifyPasswordUtil({password, passwordHash});
}

export async function isPasswordPwned(_ctx: ApiContext, password: string): Promise<boolean> {
	const hashed = crypto.createHash('sha1').update(password).digest('hex').toUpperCase();
	const hashPrefix = hashed.slice(0, 5);
	const hashSuffix = hashed.slice(5);
	const cachedSuffixes = pwnedPasswordCache.get(hashPrefix);
	if (cachedSuffixes !== undefined) {
		return cachedSuffixes.has(hashSuffix);
	}
	try {
		const response = await fetch(`https://api.pwnedpasswords.com/range/${hashPrefix}`, {
			headers: {
				'User-Agent': FLUXER_USER_AGENT,
				'Add-Padding': 'true',
			},
			signal: AbortSignal.timeout(PWNED_PASSWORDS_TIMEOUT_MS),
		});
		if (!response.ok) {
			Logger.warn(
				{
					status: response.status,
					statusText: response.statusText,
					hashPrefix,
				},
				'Pwned Passwords API returned non-OK status',
			);
			return false;
		}
		const body = await FetchUtils.streamToStringWithLimit(response.body, {
			maxBytes: EXTERNAL_RESPONSE_LIMITS.pwnedPasswordsBytes,
			headers: response.headers,
			url: response.url,
			description: 'Pwned Passwords API response',
		});
		const MAX_PWNED_LINES = 10000;
		const lines = body.split('\n');
		if (lines.length > MAX_PWNED_LINES) {
			Logger.warn(
				{
					lineCount: lines.length,
					maxAllowed: MAX_PWNED_LINES,
					hashPrefix,
				},
				'Pwned Passwords API response exceeded safe line limit, truncating',
			);
		}
		const limit = Math.min(lines.length, MAX_PWNED_LINES);
		const pwnedSuffixes = new Set<string>();
		for (let i = 0; i < limit; i++) {
			const line = lines[i];
			const [hashSuffixLine, count] = line.split(':', 2);
			if (hashSuffixLine.length === hashSuffix.length && Number.parseInt(count, 10) > 0) {
				pwnedSuffixes.add(hashSuffixLine);
			}
		}
		pwnedPasswordCache.set(hashPrefix, pwnedSuffixes);
		return pwnedSuffixes.has(hashSuffix);
	} catch (error) {
		Logger.error({error}, 'Failed to check password against Pwned Passwords API');
		return false;
	}
}

export async function forgotPassword(ctx: ApiContext, {data, request}: ForgotPasswordParams): Promise<void> {
	const {users, email, rateLimit, emailDnsValidation, config} = ctx.services;
	const clientIp = requireClientIp(request, {
		trustClientIpHeader: config.proxy.trust_client_ip_header,
		clientIpHeaderName: config.proxy.client_ip_header,
	});
	const ipLimitConfig = {maxAttempts: 20, windowMs: ms('30 minutes')};
	const emailLimitConfig = {maxAttempts: 5, windowMs: ms('30 minutes')};
	const ipRateLimit = await rateLimit.checkLimit({
		identifier: `password_reset:ip:${getSameIpDecisionKey(clientIp) ?? clientIp}`,
		...ipLimitConfig,
	});
	const emailRateLimit = await rateLimit.checkLimit({
		identifier: `password_reset:email:${data.email.toLowerCase()}`,
		...emailLimitConfig,
	});
	const exceeded = !ipRateLimit.allowed
		? {result: ipRateLimit, config: ipLimitConfig}
		: !emailRateLimit.allowed
			? {result: emailRateLimit, config: emailLimitConfig}
			: null;
	if (exceeded) {
		const retryAfter =
			exceeded.result.retryAfter ?? Math.max(0, Math.ceil((exceeded.result.resetTime.getTime() - Date.now()) / 1000));
		throw new RateLimitError({
			retryAfter,
			limit: exceeded.result.limit,
			resetTime: exceeded.result.resetTime,
		});
	}
	const hasValidDns = await emailDnsValidation.hasValidDnsRecords(data.email);
	if (!hasValidDns) {
		throw InputValidationError.fromCode('email', ValidationErrorCodes.INVALID_EMAIL_ADDRESS);
	}
	const user = await users.findByEmail(data.email);
	if (!user) {
		return;
	}
	AuthUtility.assertNonBotUser(ctx, user);
	const token = createPasswordResetToken(await AuthUtility.generateSecureToken(ctx));
	await users.createPasswordResetToken({
		token_: token,
		user_id: user.id,
		email: user.email!,
	});
	await email.sendPasswordResetEmail(user.email!, user.username, token, user.locale);
}

export async function validateResetToken(ctx: ApiContext, token: string): Promise<boolean> {
	const {users} = ctx.services;
	const tokenData = await users.getPasswordResetToken(token);
	if (!tokenData) {
		return false;
	}
	const user = await users.findUnique(tokenData.userId);
	if (!user) {
		return false;
	}
	if (user.flags & UserFlags.DELETED) {
		return false;
	}
	return true;
}

export async function resetPassword(
	ctx: ApiContext,
	{data, request}: ResetPasswordParams,
): Promise<ResetPasswordResult> {
	const {users} = ctx.services;
	const tokenData = await users.getPasswordResetToken(data.token);
	if (!tokenData) {
		throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_RESET_TOKEN);
	}
	const user = await users.findUnique(tokenData.userId);
	if (!user) {
		throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_RESET_TOKEN);
	}
	AuthUtility.assertNonBotUser(ctx, user);
	if (user.flags & UserFlags.DELETED) {
		throw InputValidationError.fromCode('token', ValidationErrorCodes.INVALID_OR_EXPIRED_RESET_TOKEN);
	}
	await AuthUtility.handleBanStatus(ctx, user);
	if (await isPasswordPwned(ctx, data.password)) {
		throw InputValidationError.fromCode('password', ValidationErrorCodes.PASSWORD_IS_TOO_COMMON);
	}
	const newPasswordHash = await hashPassword(ctx, data.password);
	const updatedUser = await users.patchUpsert(
		user.id,
		{
			password_hash: newPasswordHash,
			password_last_changed_at: new Date(),
		},
		user.toRow(),
	);
	await AuthSession.terminateAllUserSessions(ctx, user.id);
	await users.deletePasswordResetToken(data.token);
	const hasMfa =
		updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.TOTP) ||
		updatedUser.authenticatorTypes.has(UserAuthenticatorTypes.WEBAUTHN);
	if (hasMfa) {
		return await createMfaTicketResponse(ctx, updatedUser);
	}
	const [token] = await AuthSession.createAuthSession(ctx, {
		user: updatedUser,
		origin: AuthSession.resolveSessionOrigin(ctx, request),
	});
	return {user_id: updatedUser.id.toString(), token};
}

async function createMfaTicketResponse(
	ctx: ApiContext,
	user: User,
): Promise<{
	mfa: true;
	ticket: string;
	allowed_methods: Array<string>;
	totp: boolean;
	webauthn: boolean;
}> {
	const {users, cache} = ctx.services;
	const ticket = createMfaTicket(await AuthUtility.generateSecureToken(ctx));
	await cache.set(`mfa-ticket:${ticket}`, user.id.toString(), seconds('5 minutes'));
	const credentials = await users.listWebAuthnCredentials(user.id);
	const hasWebauthn = credentials.length > 0;
	const hasTotp = user.authenticatorTypes.has(UserAuthenticatorTypes.TOTP);
	const allowedMethods: Array<string> = [];
	if (hasTotp) allowedMethods.push('totp');
	if (hasWebauthn) allowedMethods.push('webauthn');
	return {
		mfa: true,
		ticket: ticket,
		allowed_methods: allowedMethods,
		totp: hasTotp,
		webauthn: hasWebauthn,
	};
}
