import { randomBytes, createHash } from 'node:crypto';
import { hashPassword, validatePasswordPolicy } from './password.js';
import { getActuateConfig } from '../config/runtime.js';
import { resolveEmailSender } from '../email/sender.js';
const TOKEN_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
/** Generate a cryptographically random reset token and its SHA-256 hash. */
export function generateResetToken() {
    const raw = randomBytes(32).toString('hex');
    const hash = createHash('sha256').update(raw).digest('hex');
    return { raw, hash };
}
/** Hash a raw token for lookup (same algorithm used during generation). */
export function hashToken(raw) {
    return createHash('sha256').update(raw).digest('hex');
}
/**
 * Create a password reset request. Generates a token, stores the hash,
 * and sends the reset email. Always succeeds from the caller's perspective
 * to prevent user enumeration.
 */
export async function createPasswordReset(db, email, config) {
    // Normalise the email so signup and reset agree on the same canonical form.
    // Without this a user who signed up with "Brad@Example.com " can lose access
    // simply because the lookup uses a slightly different string.
    const normalized = email.toLowerCase().trim();
    if (!normalized)
        return;
    const user = await db.user.findFirst({
        where: { email: { equals: normalized, mode: 'insensitive' } },
    });
    if (!user || !user.isActive)
        return;
    await db.passwordResetToken.updateMany({
        where: { userId: user.id, usedAt: null },
        data: { usedAt: new Date() },
    });
    const { raw, hash } = generateResetToken();
    await db.passwordResetToken.create({
        data: {
            userId: user.id,
            tokenHash: hash,
            expiresAt: new Date(Date.now() + TOKEN_EXPIRY_MS),
        },
    });
    // Prefer an explicit platform adapter passed by the caller, otherwise fall
    // back to a sender registered by the email plugin. Without this fallback,
    // apps that configure email purely through `emailPlugin()` (the common case)
    // would silently never send reset mail.
    const sender = config.platform?.email ?? resolveEmailSender();
    if (sender) {
        const base = config.siteUrl.replace(/\/$/, '');
        const adminPath = (config.adminPath ?? '/admin').replace(/\/$/, '');
        const resetUrl = `${base}${adminPath}/reset-password?token=${raw}`;
        const { passwordResetTemplate } = await import('./reset-email.js');
        const tpl = passwordResetTemplate({ resetUrl, userName: user.name });
        await sender.send({
            to: user.email,
            subject: tpl.subject,
            html: tpl.html,
            text: tpl.text,
        });
    }
}
/**
 * Execute a password reset: validate the token, update the password,
 * mark the token as used, and revoke all existing sessions.
 */
export async function executePasswordReset(db, rawToken, newPassword) {
    const tokenHash = hashToken(rawToken);
    const resetToken = await db.passwordResetToken.findFirst({
        where: { tokenHash, usedAt: null },
        include: { user: true },
    });
    if (!resetToken) {
        return { success: false, error: 'Invalid or expired reset link.' };
    }
    if (resetToken.expiresAt < new Date()) {
        return { success: false, error: 'This reset link has expired. Please request a new one.' };
    }
    if (!resetToken.user.isActive) {
        return { success: false, error: 'This account has been deactivated.' };
    }
    // Enforce the same password policy that the rest of the app uses. Without
    // this, the reset endpoint becomes a back-door for weak passwords. We use
    // a sensible default; integrators that want stricter rules should call
    // this through their own wrapper.
    const cmsConfig = getActuateConfig();
    const passwordPolicy = cmsConfig?.auth?.passwordPolicy ?? {
        minLength: 12,
        requireUppercase: true,
        requireLowercase: true,
        requireNumbers: true,
        requireSpecialChars: false,
    };
    const policy = validatePasswordPolicy(newPassword, passwordPolicy);
    if (!policy.valid) {
        return { success: false, error: policy.errors[0] ?? 'Password does not meet requirements.' };
    }
    const passwordHash = await hashPassword(newPassword);
    // If this account has a pending invite, completing the reset *is* the
    // acceptance: mark the invite ACCEPTED and stamp the user as accepted +
    // approved. Done in the same transaction so password + acceptance commit
    // atomically. A normal (non-invite) reset matches no invite and is unaffected.
    let pendingInvite = null;
    try {
        pendingInvite =
            (await db.invite?.findFirst?.({
                where: { email: { equals: resetToken.user.email, mode: 'insensitive' }, status: 'PENDING' },
            })) ?? null;
    }
    catch {
        pendingInvite = null;
    }
    const userData = { passwordHash };
    const ops = [];
    if (pendingInvite) {
        userData.acceptedInviteAt = new Date();
        userData.isApproved = true;
        userData.emailVerified = true;
        ops.push(db.invite.update({
            where: { id: pendingInvite.id },
            data: { status: 'ACCEPTED', acceptedAt: new Date(), acceptedUserId: resetToken.userId },
        }));
    }
    await db.$transaction([
        db.user.update({ where: { id: resetToken.userId }, data: userData }),
        db.passwordResetToken.update({
            where: { id: resetToken.id },
            data: { usedAt: new Date() },
        }),
        db.session.updateMany({
            where: { userId: resetToken.userId, revokedAt: null },
            data: { revokedAt: new Date() },
        }),
        ...ops,
    ]);
    return { success: true };
}
//# sourceMappingURL=reset.js.map