import { SignJWT, jwtVerify } from 'jose';
import { createSession } from './session.js';
import { encryptSecret } from '../security/secret-storage.js';
import { createLogger } from '../diagnostics/logger.js';
const logger = createLogger('oauth');
const PROVIDER_URLS = {
    google: {
        authorize: 'https://accounts.google.com/o/oauth2/v2/auth',
        token: 'https://oauth2.googleapis.com/token',
        userinfo: 'https://www.googleapis.com/oauth2/v3/userinfo',
        scopes: 'openid email profile',
    },
    github: {
        authorize: 'https://github.com/login/oauth/authorize',
        token: 'https://github.com/login/oauth/access_token',
        userinfo: 'https://api.github.com/user',
        scopes: 'read:user user:email',
    },
    microsoft: {
        authorize: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
        token: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
        userinfo: 'https://graph.microsoft.com/v1.0/me',
        scopes: 'openid email profile',
    },
};
function base64url(buffer) {
    const bytes = new Uint8Array(buffer);
    let binary = '';
    for (let i = 0; i < bytes.length; i++) {
        binary += String.fromCharCode(bytes[i]);
    }
    return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function generateCodeVerifier() {
    const bytes = crypto.getRandomValues(new Uint8Array(32));
    return base64url(bytes.buffer);
}
export async function generateCodeChallenge(codeVerifier) {
    const encoded = new TextEncoder().encode(codeVerifier);
    const digest = await crypto.subtle.digest('SHA-256', encoded);
    return base64url(digest);
}
export async function generateState(provider, codeVerifier, returnTo, secret, nonce) {
    const secretKey = new TextEncoder().encode(secret);
    const payload = nonce
        ? { provider, codeVerifier, returnTo, nonce }
        : { provider, codeVerifier, returnTo };
    return new SignJWT({ ...payload })
        .setProtectedHeader({ alg: 'HS256' })
        .setIssuedAt()
        .setExpirationTime('10m')
        .setIssuer('actuate-cms')
        .sign(secretKey);
}
/** Generate a random base64url nonce suitable for binding state to a browser cookie. */
export function generateOAuthNonce() {
    const bytes = crypto.getRandomValues(new Uint8Array(16));
    return base64url(bytes.buffer);
}
/**
 * Thrown when an OAuth state token verifies cryptographically but its decoded
 * payload doesn't match the expected `OAuthState` shape. Mapped to a 400 by
 * the callback handler — the user is then bounced back to the login page.
 */
export class InvalidOAuthStateError extends Error {
    constructor(reason) {
        super(`OAuth state is malformed: ${reason}`);
        this.name = 'InvalidOAuthStateError';
    }
}
function assertOAuthState(payload) {
    if (typeof payload !== 'object' || payload === null) {
        throw new InvalidOAuthStateError('payload is not an object');
    }
    const p = payload;
    if (typeof p.provider !== 'string' || p.provider.length === 0) {
        throw new InvalidOAuthStateError('missing or invalid `provider`');
    }
    if (typeof p.codeVerifier !== 'string' || p.codeVerifier.length === 0) {
        throw new InvalidOAuthStateError('missing or invalid `codeVerifier`');
    }
    if (typeof p.returnTo !== 'string') {
        throw new InvalidOAuthStateError('missing or invalid `returnTo`');
    }
    if (p.nonce !== undefined && typeof p.nonce !== 'string') {
        throw new InvalidOAuthStateError('`nonce`, when present, must be a string');
    }
}
export async function verifyState(stateToken, secret) {
    const secretKey = new TextEncoder().encode(secret);
    const { payload } = await jwtVerify(stateToken, secretKey, { issuer: 'actuate-cms' });
    assertOAuthState(payload);
    // Return only the validated fields; strip extras (jose injects iat/exp/iss).
    const safe = {
        provider: payload.provider,
        codeVerifier: payload.codeVerifier,
        returnTo: payload.returnTo,
    };
    if (payload.nonce !== undefined)
        safe.nonce = payload.nonce;
    return safe;
}
export function getAuthorizationUrl(provider, config, state, codeChallenge) {
    const urls = PROVIDER_URLS[provider];
    const params = new URLSearchParams({
        response_type: 'code',
        client_id: config.clientId,
        redirect_uri: config.redirectUri,
        scope: urls.scopes,
        state,
        code_challenge: codeChallenge,
        code_challenge_method: 'S256',
    });
    return `${urls.authorize}?${params.toString()}`;
}
export async function exchangeCodeForTokens(provider, code, codeVerifier, config) {
    const urls = PROVIDER_URLS[provider];
    const body = new URLSearchParams({
        grant_type: 'authorization_code',
        code,
        redirect_uri: config.redirectUri,
        client_id: config.clientId,
        client_secret: config.clientSecret,
        code_verifier: codeVerifier,
    });
    const headers = {
        'Content-Type': 'application/x-www-form-urlencoded',
    };
    if (provider === 'github') {
        headers['Accept'] = 'application/json';
    }
    const response = await fetch(urls.token, { method: 'POST', headers, body: body.toString() });
    if (!response.ok) {
        const text = await response.text();
        throw new Error(`Token exchange failed (${response.status}): ${text}`);
    }
    return response.json();
}
export async function getUserProfile(provider, accessToken) {
    const urls = PROVIDER_URLS[provider];
    const response = await fetch(urls.userinfo, {
        headers: { Authorization: `Bearer ${accessToken}` },
    });
    if (!response.ok) {
        throw new Error(`Failed to fetch user profile (${response.status})`);
    }
    const data = await response.json();
    if (provider === 'github') {
        let email = data.email ?? '';
        if (!email) {
            const emailRes = await fetch('https://api.github.com/user/emails', {
                headers: {
                    Authorization: `Bearer ${accessToken}`,
                    Accept: 'application/vnd.github+json',
                },
            });
            if (emailRes.ok) {
                const emails = (await emailRes.json());
                const primary = emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified);
                if (primary)
                    email = primary.email;
            }
        }
        return {
            id: String(data.id),
            email,
            name: data.name ?? data.login ?? '',
            avatar: data.avatar_url,
        };
    }
    if (provider === 'microsoft') {
        return {
            id: data.id,
            email: data.mail ?? data.userPrincipalName ?? '',
            name: data.displayName ?? '',
        };
    }
    // Google
    return {
        id: data.sub,
        email: data.email ?? '',
        name: data.name ?? '',
        avatar: data.picture,
    };
}
export async function handleOAuthCallback(provider, code, stateToken, providers, secret, db, options = {}) {
    const state = await verifyState(stateToken, secret);
    if (state.provider !== provider) {
        throw new Error('Provider mismatch in OAuth state');
    }
    // Bind the state to the browser that initiated the flow. If the state was
    // signed with a nonce, the same value must come back in the cookie that the
    // /auth/oauth/:provider handler set.
    if (state.nonce) {
        if (!options.expectedNonce || options.expectedNonce !== state.nonce) {
            throw new Error('OAuth state nonce mismatch — possible cross-site flow injection');
        }
    }
    const providerType = provider;
    const providerConfig = providers[providerType];
    if (!providerConfig) {
        throw new Error(`OAuth provider "${provider}" is not configured`);
    }
    const tokens = await exchangeCodeForTokens(providerType, code, state.codeVerifier, providerConfig);
    const profile = await getUserProfile(providerType, tokens.access_token);
    if (!profile.email) {
        throw new Error('OAuth provider did not return an email address');
    }
    const normalizedEmail = profile.email.toLowerCase().trim();
    // Lookup precedence:
    //   1. An existing OAuth account with the same (provider, providerAccountId).
    //      This is the only safe auto-link target — the provider asserted that
    //      this account is the same one the user previously linked.
    //   2. Otherwise, an existing local user with the same email — but ONLY if
    //      they have no password set (i.e., this is a returning OAuth-only user
    //      whose account was provisioned by an earlier login). Linking a
    //      password-protected account requires the user to be already
    //      authenticated (use `linkAccount()` for that flow).
    //   3. Otherwise, self-signup if `allowSelfSignup` is true; otherwise reject.
    const oauthAccount = await db.oAuthAccount
        ?.findUnique?.({
        where: { provider_providerAccountId: { provider, providerAccountId: profile.id } },
        include: { user: true },
    })
        .catch(() => null);
    let user = oauthAccount?.user ?? null;
    if (!user) {
        const candidate = await db.user.findFirst({
            where: { email: { equals: normalizedEmail, mode: 'insensitive' } },
        });
        if (candidate) {
            const hasPassword = typeof candidate.passwordHash === 'string' && candidate.passwordHash.length > 0;
            if (hasPassword) {
                // Refuse to silently link a password-protected account on the basis of
                // a matched email. The legitimate user must explicitly link their
                // account from inside the admin (an authenticated session).
                throw new Error('An account already exists for this email. Sign in with your password and link your account from Settings to enable OAuth.');
            }
            user = candidate;
        }
    }
    if (!user) {
        if (!options.allowSelfSignup) {
            throw new Error('No account found for this email. Ask an administrator to invite you before signing in with OAuth.');
        }
        if (options.onProvision) {
            await options.onProvision(profile, provider);
        }
        user = await db.user.create({
            data: {
                email: normalizedEmail,
                name: profile.name,
                role: 'CLIENT',
                isActive: true,
                passwordHash: null,
            },
        });
    }
    // Ensure an OAuthAccount row exists for this (provider, providerAccountId)
    // and store the access/refresh tokens encrypted at rest. Use upsert against
    // the unique compound index so we never overwrite a different user's link.
    if (db.oAuthAccount?.upsert) {
        const accessTokenEncrypted = tokens.access_token
            ? await encryptSecret(tokens.access_token)
            : null;
        const refreshTokenEncrypted = tokens.refresh_token
            ? await encryptSecret(tokens.refresh_token)
            : null;
        await db.oAuthAccount
            .upsert({
            where: { provider_providerAccountId: { provider, providerAccountId: profile.id } },
            create: {
                userId: user.id,
                provider,
                providerAccountId: profile.id,
                accessToken: accessTokenEncrypted,
                refreshToken: refreshTokenEncrypted,
            },
            update: {
                accessToken: accessTokenEncrypted,
                refreshToken: refreshTokenEncrypted,
            },
        })
            .catch((err) => {
            logger.error('Failed to persist OAuthAccount', {
                reason: err instanceof Error ? err.message : String(err),
            });
        });
    }
    const oauthSession = await db.session.create({
        data: {
            userId: user.id,
            expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
        },
    });
    const token = await createSession({ userId: user.id, role: user.role, sessionId: oauthSession.id }, { secret });
    return {
        token,
        user: { id: user.id, email: user.email, name: user.name, role: user.role },
    };
}
/** @deprecated Use `initiateOAuth` pattern — kept for backward compat with auth/index.ts re-exports */
export async function initiateOAuth(_providerConfig, _callbackUrl) {
    throw new Error('Use generateState + getAuthorizationUrl instead');
}
/** @deprecated Use `handleOAuthCallback` — kept for backward compat with auth/index.ts re-exports */
export async function handleCallback(_providerConfig, _code, _state) {
    throw new Error('Use handleOAuthCallback instead');
}
/** Link an OAuth account to an existing, already-authenticated user. */
export async function linkAccount(userId, result, db) {
    const d = db;
    const accessToken = result.accessToken ? await encryptSecret(result.accessToken) : null;
    const refreshToken = result.refreshToken ? await encryptSecret(result.refreshToken) : null;
    if (d.oAuthAccount?.upsert) {
        await d.oAuthAccount.upsert({
            where: {
                provider_providerAccountId: {
                    provider: result.provider,
                    providerAccountId: result.providerAccountId,
                },
            },
            create: {
                userId,
                provider: result.provider,
                providerAccountId: result.providerAccountId,
                accessToken,
                refreshToken,
                expiresAt: result.expiresAt ?? null,
            },
            update: {
                userId,
                accessToken,
                refreshToken,
                expiresAt: result.expiresAt ?? null,
            },
        });
    }
}
//# sourceMappingURL=oauth.js.map