/*
 * Copyright (c) Microsoft Corporation. All rights reserved.
 * Licensed under the MIT License.
 */

import {
    ApiId,
    Constants,
    LOOPBACK_SERVER_CONSTANTS,
} from "../utils/Constants.js";
import {
    AuthenticationResult,
    AuthError,
    Constants as CommonConstants,
    ServerError,
    NativeRequest,
    NativeSignOutRequest,
    AccountInfo,
    INativeBrokerPlugin,
    AuthorizeResponse,
    AADServerParamKeys,
    ServerTelemetryManager,
    AuthorizationCodePayload,
    enforceResourceParameter,
} from "@azure/msal-common/node";
import { Configuration } from "../config/Configuration.js";
import { ClientApplication } from "./ClientApplication.js";
import { IPublicClientApplication } from "./IPublicClientApplication.js";
import { DeviceCodeRequest } from "../request/DeviceCodeRequest.js";
import { CommonDeviceCodeRequest } from "../request/CommonDeviceCodeRequest.js";
import { AuthorizationUrlRequest } from "../request/AuthorizationUrlRequest.js";
import { AuthorizationCodeRequest } from "../request/AuthorizationCodeRequest.js";
import { InteractiveRequest } from "../request/InteractiveRequest.js";
import { NodeAuthError, NodeAuthErrorMessage } from "../error/NodeAuthError.js";
import { LoopbackClient } from "../network/LoopbackClient.js";
import { SilentFlowRequest } from "../request/SilentFlowRequest.js";
import { SignOutRequest } from "../request/SignOutRequest.js";
import { RefreshTokenRequest } from "../request/RefreshTokenRequest.js";
import { ILoopbackClient } from "../network/ILoopbackClient.js";
import { DeviceCodeClient } from "./DeviceCodeClient.js";
import { version } from "../packageMetadata.js";

/**
 * This class is to be used to acquire tokens for public client applications (desktop, mobile). Public client applications
 * are not trusted to safely store application secrets, and therefore can only request tokens in the name of an user.
 * @public
 */
export class PublicClientApplication
    extends ClientApplication
    implements IPublicClientApplication
{
    private nativeBrokerPlugin?: INativeBrokerPlugin;
    private readonly skus: string;
    /**
     * Important attributes in the Configuration object for auth are:
     * - clientID: the application ID of your application. You can obtain one by registering your application with our Application registration portal.
     * - authority: the authority URL for your application.
     *
     * AAD authorities are of the form https://login.microsoftonline.com/\{Enter_the_Tenant_Info_Here\}.
     * - If your application supports Accounts in one organizational directory, replace "Enter_the_Tenant_Info_Here" value with the Tenant Id or Tenant name (for example, contoso.microsoft.com).
     * - If your application supports Accounts in any organizational directory, replace "Enter_the_Tenant_Info_Here" value with organizations.
     * - If your application supports Accounts in any organizational directory and personal Microsoft accounts, replace "Enter_the_Tenant_Info_Here" value with common.
     * - To restrict support to Personal Microsoft accounts only, replace "Enter_the_Tenant_Info_Here" value with consumers.
     *
     * Azure B2C authorities are of the form https://\{instance\}/\{tenant\}/\{policy\}. Each policy is considered
     * its own authority. You will have to set the all of the knownAuthorities at the time of the client application
     * construction.
     *
     * ADFS authorities are of the form https://\{instance\}/adfs.
     */
    constructor(configuration: Configuration) {
        super(configuration);
        if (this.config.broker.nativeBrokerPlugin) {
            if (this.config.broker.nativeBrokerPlugin.isBrokerAvailable) {
                this.nativeBrokerPlugin = this.config.broker.nativeBrokerPlugin;
                this.nativeBrokerPlugin.setLogger(
                    this.config.system.loggerOptions
                );
            } else {
                this.logger.warning(
                    "NativeBroker implementation was provided but the broker is unavailable.",
                    ""
                );
            }
        }
        this.skus = ServerTelemetryManager.makeExtraSkuString({
            libraryName: Constants.MSAL_SKU,
            libraryVersion: version,
        });
    }

    /**
     * Acquires a token from the authority using OAuth2.0 device code flow.
     * This flow is designed for devices that do not have access to a browser or have input constraints.
     * The authorization server issues a DeviceCode object with a verification code, an end-user code,
     * and the end-user verification URI. The DeviceCode object is provided through a callback, and the end-user should be
     * instructed to use another device to navigate to the verification URI to input credentials.
     * Since the client cannot receive incoming requests, it polls the authorization server repeatedly
     * until the end-user completes input of credentials.
     */
    public async acquireTokenByDeviceCode(
        request: DeviceCodeRequest
    ): Promise<AuthenticationResult | null> {
        this.logger.info(
            "acquireTokenByDeviceCode called",
            request.correlationId || ""
        );
        enforceResourceParameter(this.config.auth.isMcp, request);
        const validRequest: CommonDeviceCodeRequest = Object.assign(
            request,
            await this.initializeBaseRequest(request)
        );
        const serverTelemetryManager = this.initializeServerTelemetryManager(
            ApiId.acquireTokenByDeviceCode,
            validRequest.correlationId
        );
        try {
            const discoveredAuthority = await this.createAuthority(
                validRequest.authority,
                validRequest.correlationId,
                undefined,
                request.azureCloudOptions
            );
            const deviceCodeConfig = await this.buildOauthClientConfiguration(
                discoveredAuthority,
                validRequest.correlationId,
                "",
                serverTelemetryManager
            );
            const deviceCodeClient = new DeviceCodeClient(deviceCodeConfig);
            this.logger.verbose(
                "Device code client created",
                validRequest.correlationId
            );
            return await deviceCodeClient.acquireToken(validRequest);
        } catch (e) {
            if (e instanceof AuthError) {
                e.setCorrelationId(validRequest.correlationId);
            }
            serverTelemetryManager.cacheFailedRequest(e as AuthError);
            throw e;
        }
    }

    /**
     * Acquires a token interactively via the browser by requesting an authorization code then exchanging it for a token.
     */
    async acquireTokenInteractive(
        request: InteractiveRequest
    ): Promise<AuthenticationResult> {
        const correlationId =
            request.correlationId || this.cryptoProvider.createNewGuid();
        this.logger.trace("acquireTokenInteractive called", correlationId);
        enforceResourceParameter(this.config.auth.isMcp, request);
        const {
            openBrowser,
            successTemplate,
            errorTemplate,
            windowHandle,
            loopbackClient: customLoopbackClient,
            ...remainingProperties
        } = request;

        if (this.nativeBrokerPlugin) {
            const brokerRequest: NativeRequest = {
                ...remainingProperties,
                clientId: this.config.auth.clientId,
                scopes: request.scopes || CommonConstants.OIDC_DEFAULT_SCOPES,
                redirectUri: request.redirectUri || "",
                authority: request.authority || this.config.auth.authority,
                correlationId: correlationId,
                extraParameters: {
                    ...remainingProperties.extraQueryParameters,
                    ...remainingProperties.extraParameters,
                    [AADServerParamKeys.X_CLIENT_EXTRA_SKU]: this.skus,
                },
                accountId: remainingProperties.account?.nativeAccountId,
            };
            return this.nativeBrokerPlugin.acquireTokenInteractive(
                brokerRequest,
                windowHandle
            );
        }

        if (request.redirectUri) {
            // If it's not a broker fallback scenario, we throw an error
            if (!this.config.broker.nativeBrokerPlugin) {
                throw NodeAuthError.createRedirectUriNotSupportedError();
            }
            // If a redirect URI is provided for a broker flow but MSAL runtime startup failed, we fall back to the browser flow and will ignore the redirect URI provided for the broker flow
            request.redirectUri = "";
        }

        const { verifier, challenge } =
            await this.cryptoProvider.generatePkceCodes();

        const loopbackClient: ILoopbackClient =
            customLoopbackClient || new LoopbackClient();

        let authCodeResponse: AuthorizeResponse = {};
        let authCodeListenerError: AuthError | null = null;
        try {
            const authCodeListener = loopbackClient
                .listenForAuthCode(successTemplate, errorTemplate)
                .then((response) => {
                    authCodeResponse = response;
                })
                .catch((e) => {
                    // Store the promise instead of throwing so we can control when its thrown
                    authCodeListenerError = e;
                });

            // Wait for server to be listening
            const redirectUri = await this.waitForRedirectUri(loopbackClient);

            const validRequest: AuthorizationUrlRequest = {
                ...remainingProperties,
                correlationId: correlationId,
                scopes: request.scopes || CommonConstants.OIDC_DEFAULT_SCOPES,
                redirectUri: redirectUri,
                responseMode: CommonConstants.ResponseMode.QUERY,
                codeChallenge: challenge,
                codeChallengeMethod:
                    CommonConstants.CodeChallengeMethodValues.S256,
            };

            const authCodeUrl = await this.getAuthCodeUrl(validRequest);
            await openBrowser(authCodeUrl);
            await authCodeListener;
            if (authCodeListenerError) {
                throw authCodeListenerError;
            }

            if (authCodeResponse.error) {
                throw new ServerError(
                    authCodeResponse.error,
                    authCodeResponse.error_description,
                    authCodeResponse.suberror
                );
            } else if (!authCodeResponse.code) {
                throw NodeAuthError.createNoAuthCodeInResponseError();
            }

            const clientInfo = authCodeResponse.client_info;
            const tokenRequest: AuthorizationCodeRequest = {
                code: authCodeResponse.code,
                codeVerifier: verifier,
                clientInfo: clientInfo || "",
                ...validRequest,
            };
            return await this.acquireTokenByCode(tokenRequest); // Await this so the server doesn't close prematurely
        } finally {
            loopbackClient.closeServer();
        }
    }

    /**
     * Returns a token retrieved either from the cache or by exchanging the refresh token for a fresh access token. If brokering is enabled the token request will be serviced by the broker.
     * @param request - developer provided SilentFlowRequest
     * @returns
     */
    async acquireTokenSilent(
        request: SilentFlowRequest
    ): Promise<AuthenticationResult> {
        const correlationId =
            request.correlationId || this.cryptoProvider.createNewGuid();
        this.logger.trace("acquireTokenSilent called", correlationId);
        enforceResourceParameter(this.config.auth.isMcp, request);

        if (this.nativeBrokerPlugin) {
            const brokerRequest: NativeRequest = {
                ...request,
                clientId: this.config.auth.clientId,
                scopes: request.scopes || CommonConstants.OIDC_DEFAULT_SCOPES,
                redirectUri: request.redirectUri || "",
                authority: request.authority || this.config.auth.authority,
                correlationId: correlationId,
                extraParameters: {
                    ...request.extraQueryParameters,
                    ...request.extraParameters,
                    [AADServerParamKeys.X_CLIENT_EXTRA_SKU]: this.skus,
                },
                accountId: request.account.nativeAccountId,
                forceRefresh: request.forceRefresh || false,
            };
            return this.nativeBrokerPlugin.acquireTokenSilent(brokerRequest);
        }

        if (request.redirectUri) {
            // If it's not a broker fallback scenario, we throw an error
            if (!this.config.broker.nativeBrokerPlugin) {
                throw NodeAuthError.createRedirectUriNotSupportedError();
            }
            request.redirectUri = "";
        }

        return super.acquireTokenSilent(request);
    }

    /**
     * Acquires a token by exchanging the authorization code received from the first step of OAuth 2.0 Authorization Code Flow.
     * In MCP mode, a resource parameter is required on the request.
     */
    async acquireTokenByCode(
        request: AuthorizationCodeRequest,
        authCodePayLoad?: AuthorizationCodePayload
    ): Promise<AuthenticationResult> {
        enforceResourceParameter(this.config.auth.isMcp, request);
        return super.acquireTokenByCode(request, authCodePayLoad);
    }

    /**
     * Acquires a token by exchanging the refresh token provided for a new set of tokens.
     * In MCP mode, a resource parameter is required on the request.
     */
    async acquireTokenByRefreshToken(
        request: RefreshTokenRequest
    ): Promise<AuthenticationResult | null> {
        enforceResourceParameter(this.config.auth.isMcp, request);
        return super.acquireTokenByRefreshToken(request);
    }

    /**
     * Removes cache artifacts associated with the given account
     * @param request - developer provided SignOutRequest
     * @returns
     */
    async signOut(request: SignOutRequest): Promise<void> {
        if (this.nativeBrokerPlugin && request.account.nativeAccountId) {
            const signoutRequest: NativeSignOutRequest = {
                clientId: this.config.auth.clientId,
                accountId: request.account.nativeAccountId,
                correlationId:
                    request.correlationId ||
                    this.cryptoProvider.createNewGuid(),
            };
            await this.nativeBrokerPlugin.signOut(signoutRequest);
        }

        await this.getTokenCache().removeAccount(
            request.account,
            request.correlationId
        );
    }

    /**
     * Returns all cached accounts for this application. If brokering is enabled this request will be serviced by the broker.
     * @returns
     */
    async getAllAccounts(): Promise<AccountInfo[]> {
        if (this.nativeBrokerPlugin) {
            const correlationId = this.cryptoProvider.createNewGuid();
            return this.nativeBrokerPlugin.getAllAccounts(
                this.config.auth.clientId,
                correlationId
            );
        }

        return this.getTokenCache().getAllAccounts();
    }

    /**
     * Attempts to retrieve the redirectUri from the loopback server. If the loopback server does not start listening for requests within the timeout this will throw.
     * @param loopbackClient - developer provided custom loopback server implementation
     * @returns
     */
    private async waitForRedirectUri(
        loopbackClient: ILoopbackClient
    ): Promise<string> {
        return new Promise<string>((resolve, reject) => {
            let ticks = 0;
            const id = setInterval(() => {
                if (
                    LOOPBACK_SERVER_CONSTANTS.TIMEOUT_MS /
                        LOOPBACK_SERVER_CONSTANTS.INTERVAL_MS <
                    ticks
                ) {
                    clearInterval(id);
                    reject(NodeAuthError.createLoopbackServerTimeoutError());
                    return;
                }

                try {
                    const r = loopbackClient.getRedirectUri();
                    clearInterval(id);
                    resolve(r);
                    return;
                } catch (e) {
                    if (
                        e instanceof AuthError &&
                        e.errorCode ===
                            NodeAuthErrorMessage.noLoopbackServerExists.code
                    ) {
                        // Loopback server is not listening yet
                        ticks++;
                        return;
                    }
                    clearInterval(id);
                    reject(e);
                    return;
                }
            }, LOOPBACK_SERVER_CONSTANTS.INTERVAL_MS);
        });
    }
}
