import { randomBytes } from "node:crypto";
import { verifyWebAuthnAuthenticationChallenge, webAuthnAuthenticationOptions, } from "../access/webauthn.js";
import { friendlyErrorText } from "../core/error-messages.js";
import { objectRecord, optionalStringField, readJsonBody, sendJson } from "./web-dashboard-http.js";
import { consumeRateLimit, rateLimitStatus, resetRateLimit } from "./web-rate-limit.js";
import { firstRunSetupTokenError } from "./web-first-run-setup-policy.js";
const pendingLoginChallenges = new Map();
const LOGIN_FAILED_ATTEMPT_LIMIT = 5;
const LOGIN_FAILED_ATTEMPT_WINDOW_MS = 30 * 60 * 1000;
export async function handleFirstRunSetup(req, res, options) {
    const { users } = options;
    if (users.hasAdminUser()) {
        sendJson(res, 409, { error: "Admin user already exists." });
        return;
    }
    const body = await readJsonBody(req);
    const email = optionalStringField(body, "email") ?? "";
    const displayName = optionalStringField(body, "displayName") ?? email;
    const password = optionalStringField(body, "password") ?? "";
    const setupToken = optionalStringField(body, "setupToken") ?? "";
    const setupTokenError = firstRunSetupTokenError(setupToken, options.firstRunSetupToken);
    if (setupTokenError) {
        options.audit({
            action: "auth_login_failed",
            status: "denied",
            channelId: "web",
            contextKey: "web",
            description: `Rejected first-run setup for ${email || "unknown"}`,
        });
        sendJson(res, 403, { error: setupTokenError });
        return;
    }
    if (!email || !password || password.length < 12) {
        sendJson(res, 400, { error: "Email and a password with at least 12 characters are required." });
        return;
    }
    const authUser = users.createAdmin({ email, displayName, password });
    const session = users.createWebSession(authUser.user.id, sessionMetadata(req, false));
    options.audit({
        action: "user_created",
        status: "ok",
        channelId: "web",
        contextKey: "web",
        actor: webActivityActor(authUser),
        actorId: authUser.user.id,
        actorRole: authUser.groups.map((group) => group.name).join(", "),
        description: `First admin created: ${authUser.user.email}`,
    });
    options.recordActivity({
        source: "web",
        status: "info",
        type: "first_run_admin_created",
        threadId: null,
        actor: webActivityActor(authUser),
        detail: authUser.user.email,
    });
    options.setSessionCookie(res, session.token, req);
    sendJson(res, 201, options.currentUserDto(authUser, undefined, session.token));
}
export async function handleLogin(req, res, options) {
    const body = await readJsonBody(req);
    const email = optionalStringField(body, "email");
    const password = optionalStringField(body, "password");
    const rateLimitKey = loginRateLimitKey(req);
    const limited = rateLimitStatus(options.loginAttempts, rateLimitKey);
    if (limited.limited) {
        options.audit({
            action: "auth_login_failed",
            status: "denied",
            channelId: "web",
            contextKey: "web",
            description: `Rate limited login attempt from ${loginRateLimitIp(req)}`,
            detail: `${Math.ceil((limited.retryAfterMs ?? 0) / 1000)}s retry-after`,
        });
        sendJson(res, 429, { error: "Too many login attempts. Try again later.", retryAfterMs: limited.retryAfterMs });
        return;
    }
    if (!options.users.hasAdminUser()) {
        sendJson(res, 503, { error: "No admin user exists. Run nordrelay user create-admin first." });
        return;
    }
    const authUser = email && password ? options.users.verifyPassword(email, password) : null;
    if (!authUser) {
        const failed = consumeFailedLoginAttempt(options.loginAttempts, rateLimitKey);
        options.audit({
            action: "auth_login_failed",
            status: failed.limited ? "denied" : "failed",
            channelId: "web",
            contextKey: "web",
            description: `Failed login for ${email ?? "unknown"}`,
            detail: failed.limited ? `${Math.ceil((failed.retryAfterMs ?? 0) / 1000)}s retry-after` : undefined,
        });
        if (failed.limited) {
            sendJson(res, 429, { error: "Too many login attempts. Try again later.", retryAfterMs: failed.retryAfterMs });
            return;
        }
        sendJson(res, 401, { error: "Invalid credentials" });
        return;
    }
    resetRateLimit(options.loginAttempts, rateLimitKey);
    const mfa = options.users.mfaStatus(authUser.user.id);
    if (mfa.totpEnabled || mfa.recoveryCodesRemaining > 0 || (options.webAuthnEnabled && mfa.webAuthnCredentials.length > 0)) {
        await requestMfa(req, res, options, authUser);
        return;
    }
    await completeLogin(req, res, options, authUser, false);
}
export async function handleLoginMfa(req, res, options) {
    const body = await readJsonBody(req);
    const challengeId = optionalStringField(body, "challengeId") ?? "";
    const code = optionalStringField(body, "code") ?? "";
    const pending = consumeLoginChallenge(challengeId);
    if (!pending) {
        sendJson(res, 400, { error: "MFA challenge expired. Sign in again." });
        return;
    }
    const method = options.users.verifyMfaCode(pending.userId, code);
    if (!method) {
        pendingLoginChallenges.set(challengeId, pending);
        options.audit({
            action: "auth_login_failed",
            status: "failed",
            channelId: "web",
            contextKey: "web",
            description: `Failed MFA login for ${pending.email}`,
        });
        sendJson(res, 401, { error: "Invalid authenticator or recovery code." });
        return;
    }
    await completeMfaLogin(req, res, options, pending, `MFA ${method}`);
}
export async function handleLoginWebAuthnVerify(req, res, options) {
    if (!options.webAuthnEnabled) {
        sendJson(res, 404, { error: "Passkeys are disabled." });
        return;
    }
    const body = await readJsonBody(req);
    const challengeId = optionalStringField(body, "challengeId") ?? "";
    const pending = consumeLoginChallenge(challengeId);
    if (!pending?.expectedChallenge) {
        sendJson(res, 400, { error: "Passkey challenge expired. Sign in again." });
        return;
    }
    const response = objectRecord(body?.response);
    const credentialId = optionalStringField(response, "id") ?? optionalStringField(response, "rawId") ?? "";
    const credential = options.users.getWebAuthnCredential(credentialId);
    if (!credential || credential.userId !== pending.userId) {
        pendingLoginChallenges.set(challengeId, pending);
        sendJson(res, 401, { error: "Unknown passkey." });
        return;
    }
    try {
        const verification = await verifyWebAuthnAuthenticationChallenge({
            rp: options.webAuthnRp(req),
            response: response,
            credential,
            expectedChallenge: pending.expectedChallenge,
        });
        if (!verification.verified || verification.newCounter === undefined) {
            pendingLoginChallenges.set(challengeId, pending);
            sendJson(res, 401, { error: "Passkey verification failed." });
            return;
        }
        options.users.updateWebAuthnCredentialUse(credential.credentialId, verification.newCounter);
        await completeMfaLogin(req, res, options, pending, "Passkey");
    }
    catch (error) {
        pendingLoginChallenges.set(challengeId, pending);
        sendJson(res, 401, { error: friendlyErrorText(error) });
    }
}
async function requestMfa(req, res, options, authUser) {
    const mfa = options.users.mfaStatus(authUser.user.id);
    const challengeId = randomBytes(18).toString("base64url");
    const pending = {
        userId: authUser.user.id,
        email: authUser.user.email,
        expiresAt: Date.now() + 5 * 60 * 1000,
    };
    let webAuthnOptions;
    if (options.webAuthnEnabled && mfa.webAuthnCredentials.length > 0) {
        try {
            webAuthnOptions = await webAuthnAuthenticationOptions({
                rp: options.webAuthnRp(req),
                credentials: options.users.listWebAuthnCredentials(authUser.user.id),
            });
            pending.expectedChallenge = webAuthnOptions.challenge;
        }
        catch (error) {
            options.audit({
                action: "auth_mfa_required",
                status: "failed",
                channelId: "web",
                contextKey: "web",
                actor: webActivityActor(authUser),
                actorId: authUser.user.id,
                actorRole: authUser.groups.map((group) => group.name).join(", "),
                description: `Passkey challenge unavailable for ${authUser.user.email}`,
                detail: friendlyErrorText(error),
            });
        }
    }
    pendingLoginChallenges.set(challengeId, pending);
    options.audit({
        action: "auth_mfa_required",
        status: "ok",
        channelId: "web",
        contextKey: "web",
        actor: webActivityActor(authUser),
        actorId: authUser.user.id,
        actorRole: authUser.groups.map((group) => group.name).join(", "),
        description: `MFA required for ${authUser.user.email}`,
    });
    sendJson(res, 200, {
        mfaRequired: true,
        challengeId,
        methods: {
            totp: mfa.totpEnabled,
            recovery: mfa.recoveryCodesRemaining > 0,
            webAuthn: Boolean(webAuthnOptions),
        },
        webAuthnOptions,
    });
}
async function completeMfaLogin(req, res, options, pending, detail) {
    const authUser = options.users.authenticatedUserById(pending.userId);
    if (!authUser) {
        sendJson(res, 401, { error: "User is disabled or missing." });
        return;
    }
    options.audit({
        action: "auth_mfa_verified",
        status: "ok",
        channelId: "web",
        contextKey: "web",
        actor: webActivityActor(authUser),
        actorId: authUser.user.id,
        actorRole: authUser.groups.map((group) => group.name).join(", "),
        description: `${detail} verified for ${authUser.user.email}`,
    });
    await completeLogin(req, res, options, authUser, true);
}
async function completeLogin(req, res, options, authUser, mfaVerified) {
    const session = options.users.createWebSession(authUser.user.id, sessionMetadata(req, mfaVerified));
    options.audit({
        action: "auth_login",
        status: "ok",
        channelId: "web",
        contextKey: "web",
        actor: webActivityActor(authUser),
        actorId: authUser.user.id,
        actorRole: authUser.groups.map((group) => group.name).join(", "),
        description: `Login ${authUser.user.email}`,
    });
    options.recordActivity({
        source: "web",
        status: "info",
        type: "auth_login",
        threadId: null,
        actor: webActivityActor(authUser),
        detail: authUser.user.email,
    });
    options.setSessionCookie(res, session.token, req);
    sendJson(res, 200, options.currentUserDto(authUser, undefined, session.token));
}
function consumeLoginChallenge(challengeId) {
    const pending = pendingLoginChallenges.get(challengeId);
    pendingLoginChallenges.delete(challengeId);
    return pending && pending.expiresAt >= Date.now() ? pending : null;
}
function sessionMetadata(req, mfaVerified) {
    const userAgent = headerValue(req, "user-agent") || undefined;
    return {
        userAgent,
        ipAddress: requestIp(req),
        deviceName: deviceNameFromUserAgent(userAgent),
        mfaVerified,
    };
}
function requestIp(req) {
    const forwarded = headerValue(req, "x-forwarded-for").split(",")[0]?.trim();
    return forwarded || req.socket.remoteAddress || undefined;
}
function loginRateLimitKey(req) {
    return `ip:${loginRateLimitIp(req)}`;
}
function loginRateLimitIp(req) {
    return req.socket.remoteAddress || "unknown";
}
function consumeFailedLoginAttempt(buckets, key) {
    const result = consumeRateLimit(buckets, key, LOGIN_FAILED_ATTEMPT_LIMIT, LOGIN_FAILED_ATTEMPT_WINDOW_MS, LOGIN_FAILED_ATTEMPT_WINDOW_MS);
    if (result.limited) {
        return result;
    }
    const bucket = buckets.get(key);
    if (bucket && bucket.count >= LOGIN_FAILED_ATTEMPT_LIMIT && !bucket.blockedUntil) {
        bucket.blockedUntil = bucket.resetAt;
    }
    return result;
}
function deviceNameFromUserAgent(userAgent) {
    if (!userAgent)
        return undefined;
    if (/Firefox/i.test(userAgent))
        return "Firefox";
    if (/Edg\//i.test(userAgent))
        return "Microsoft Edge";
    if (/Chrome|Chromium/i.test(userAgent))
        return "Chrome";
    if (/Safari/i.test(userAgent))
        return "Safari";
    return userAgent.split(/\s+/).slice(0, 2).join(" ").slice(0, 80);
}
function webActivityActor(authUser) {
    return {
        channel: "web",
        id: authUser.user.id,
        label: authUser.user.displayName || authUser.user.email,
        username: authUser.user.email,
    };
}
function headerValue(req, name) {
    const value = req.headers[name.toLowerCase()];
    return Array.isArray(value) ? value[0] ?? "" : value ?? "";
}
