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

import {
    ICrypto,
    Logger,
    Constants,
    AuthorizationCodeClient,
    AuthError,
    IPerformanceClient,
    PerformanceEvents,
    invokeAsync,
    invoke,
    ProtocolMode,
    CommonAuthorizationUrlRequest,
    AuthorizeProtocol,
    PkceCodes,
} from "@azure/msal-common/browser";
import {
    initializeAuthorizationRequest,
    StandardInteractionClient,
} from "./StandardInteractionClient.js";
import * as BrowserPerformanceEvents from "../telemetry/BrowserPerformanceEvents.js";
import { BrowserConfiguration } from "../config/Configuration.js";
import { BrowserCacheManager } from "../cache/BrowserCacheManager.js";
import { EventHandler } from "../event/EventHandler.js";
import { INavigationClient } from "../navigation/INavigationClient.js";
import {
    createBrowserAuthError,
    BrowserAuthErrorCodes,
} from "../error/BrowserAuthError.js";
import {
    InteractionType,
    ApiId,
    BrowserConstants,
} from "../utils/BrowserConstants.js";
import {
    createHiddenIframe,
    initiateCodeRequest,
    initiateCodeFlowWithPost,
    initiateEarRequest,
    removeHiddenIframe,
} from "../interaction_handler/SilentHandler.js";
import { SsoSilentRequest } from "../request/SsoSilentRequest.js";
import { AuthenticationResult } from "../response/AuthenticationResult.js";
import * as BrowserUtils from "../utils/BrowserUtils.js";
import * as ResponseHandler from "../response/ResponseHandler.js";
import * as Authorize from "../protocol/Authorize.js";
import { generatePkceCodes } from "../crypto/PkceGenerator.js";
import { isPlatformAuthAllowed } from "../broker/nativeBroker/PlatformAuthProvider.js";
import { generateEarKey } from "../crypto/BrowserCrypto.js";
import { IPlatformAuthHandler } from "../broker/nativeBroker/IPlatformAuthHandler.js";
import {
    getDiscoveredAuthority,
    initializeServerTelemetryManager,
} from "./BaseInteractionClient.js";

import type { WaitForBridgeRequest } from "../utils/BrowserUtils.js";

/**
 * Minimal request shape accepted by the iframe-response hook.
 * Intentionally narrow so subclasses can override without needing
 * access to internal resolved-request types.
 *
 * @internal
 */
export type WaitForIframeRequest = WaitForBridgeRequest;

/**
 * Signature of the iframe-response handler supplied by
 * {@link PublicClientApplication} to {@link SilentIframeClient} via the
 * operating context.
 *
 * @internal
 */
export type WaitForIframeResponseFn = (
    iframe: HTMLIFrameElement,
    request: WaitForIframeRequest
) => Promise<string>;

export class SilentIframeClient extends StandardInteractionClient {
    protected apiId: ApiId;
    protected nativeStorage: BrowserCacheManager;
    private readonly waitForIframeResponseHook:
        | WaitForIframeResponseFn
        | undefined;

    constructor(
        config: BrowserConfiguration,
        storageImpl: BrowserCacheManager,
        browserCrypto: ICrypto,
        logger: Logger,
        eventHandler: EventHandler,
        navigationClient: INavigationClient,
        apiId: ApiId,
        performanceClient: IPerformanceClient,
        nativeStorageImpl: BrowserCacheManager,
        correlationId: string,
        platformAuthProvider?: IPlatformAuthHandler,
        waitForIframeResponseHook?: WaitForIframeResponseFn
    ) {
        super(
            config,
            storageImpl,
            browserCrypto,
            logger,
            eventHandler,
            navigationClient,
            performanceClient,
            correlationId,
            platformAuthProvider
        );
        this.apiId = apiId;
        this.nativeStorage = nativeStorageImpl;
        this.waitForIframeResponseHook = waitForIframeResponseHook;
    }

    /**
     * Acquires a token silently by opening a hidden iframe to the /authorize endpoint with prompt=none or prompt=no_session
     * @param request
     */
    async acquireToken(
        request: SsoSilentRequest
    ): Promise<AuthenticationResult> {
        // Check that we have some SSO data
        if (
            !request.loginHint &&
            !request.sid &&
            (!request.account || !request.account.username)
        ) {
            this.logger.warning(
                "No user hint provided. The authorization server may need more information to complete this request.",
                this.correlationId
            );
        }

        // Check the prompt value
        const inputRequest = { ...request };
        if (inputRequest.prompt) {
            if (
                inputRequest.prompt !== Constants.PromptValue.NONE &&
                inputRequest.prompt !== Constants.PromptValue.NO_SESSION
            ) {
                this.logger.warning(
                    `SilentIframeClient. Replacing invalid prompt '${inputRequest.prompt}' with '${Constants.PromptValue.NONE}'`,
                    this.correlationId
                );
                inputRequest.prompt = Constants.PromptValue.NONE;
            }
        } else {
            inputRequest.prompt = Constants.PromptValue.NONE;
        }

        // Create silent request
        const silentRequest: CommonAuthorizationUrlRequest = await invokeAsync(
            initializeAuthorizationRequest,
            BrowserPerformanceEvents.StandardInteractionClientInitializeAuthorizationRequest,
            this.logger,
            this.performanceClient,
            this.correlationId
        )(
            inputRequest,
            InteractionType.Silent,
            this.config,
            this.browserCrypto,
            this.browserStorage,
            this.logger,
            this.performanceClient,
            this.correlationId
        );
        silentRequest.platformBroker = isPlatformAuthAllowed(
            this.config,
            this.logger,
            this.correlationId,
            this.platformAuthProvider,
            silentRequest.authenticationScheme
        );
        BrowserUtils.preconnect(silentRequest.authority);

        if (this.config.system.protocolMode === ProtocolMode.EAR) {
            return this.executeEarFlow(silentRequest);
        } else {
            return this.executeCodeFlow(silentRequest);
        }
    }

    /**
     * Executes auth code + PKCE flow
     * @param request
     * @returns
     */
    async executeCodeFlow(
        request: CommonAuthorizationUrlRequest
    ): Promise<AuthenticationResult> {
        let authClient: AuthorizationCodeClient | undefined;
        const serverTelemetryManager = initializeServerTelemetryManager(
            this.apiId,
            this.config.auth.clientId,
            this.correlationId,
            this.browserStorage,
            this.logger,
            undefined,
            this.config.system.serverTelemetryEnabled
        );

        try {
            // Initialize the client
            authClient = await invokeAsync(
                this.createAuthCodeClient.bind(this),
                BrowserPerformanceEvents.StandardInteractionClientCreateAuthCodeClient,
                this.logger,
                this.performanceClient,
                request.correlationId
            )({
                serverTelemetryManager,
                requestAuthority: request.authority,
                requestAzureCloudOptions: request.azureCloudOptions,
                requestExtraQueryParameters: request.extraQueryParameters,
                account: request.account,
            });

            return await invokeAsync(
                this.silentTokenHelper.bind(this),
                BrowserPerformanceEvents.SilentIframeClientTokenHelper,
                this.logger,
                this.performanceClient,
                request.correlationId
            )(authClient, request);
        } catch (e) {
            if (e instanceof AuthError) {
                (e as AuthError).correlationId = this.correlationId;
                serverTelemetryManager.cacheFailedRequest(e);
            }

            if (
                !authClient ||
                !(e instanceof AuthError) ||
                e.errorCode !== BrowserConstants.INVALID_GRANT_ERROR
            ) {
                throw e;
            }

            this.performanceClient.addFields(
                {
                    retryError: e.errorCode,
                },
                this.correlationId
            );

            return await invokeAsync(
                this.silentTokenHelper.bind(this),
                BrowserPerformanceEvents.SilentIframeClientTokenHelper,
                this.logger,
                this.performanceClient,
                this.correlationId
            )(authClient, request);
        }
    }

    /**
     * Executes EAR flow
     * @param request
     */
    async executeEarFlow(
        request: CommonAuthorizationUrlRequest
    ): Promise<AuthenticationResult> {
        const {
            correlationId,
            authority,
            azureCloudOptions,
            extraQueryParameters,
            account,
        } = request;
        const discoveredAuthority = await invokeAsync(
            getDiscoveredAuthority,
            BrowserPerformanceEvents.StandardInteractionClientGetDiscoveredAuthority,
            this.logger,
            this.performanceClient,
            correlationId
        )(
            this.config,
            this.correlationId,
            this.performanceClient,
            this.browserStorage,
            this.logger,
            authority,
            azureCloudOptions,
            extraQueryParameters,
            account
        );

        const earJwk = await invokeAsync(
            generateEarKey,
            BrowserPerformanceEvents.GenerateEarKey,
            this.logger,
            this.performanceClient,
            correlationId
        )();
        const pkceCodes = await invokeAsync(
            generatePkceCodes,
            BrowserPerformanceEvents.GeneratePkceCodes,
            this.logger,
            this.performanceClient,
            correlationId
        )(this.performanceClient, this.logger, correlationId);
        const silentRequest = {
            ...request,
            earJwk: earJwk,
            codeChallenge: pkceCodes.challenge,
        };

        // Create the iframe, register the response listener, then navigate, so the listener is active before the iframe can respond.
        const iframe = createHiddenIframe();

        const responseType = this.config.auth.OIDCOptions.responseMode;
        let responseString: string;
        try {
            const responsePromise = invokeAsync(
                this.waitForIframeResponse.bind(this),
                BrowserPerformanceEvents.SilentHandlerMonitorIframeForHash,
                this.logger,
                this.performanceClient,
                correlationId
            )(iframe, request);
            responsePromise.catch(() => {
                /*
                 * If navigation below throws before responsePromise is awaited,
                 * the listener still rejects on timeout. Swallow it here so it
                 * does not surface as an unhandled rejection; the navigation
                 * error is propagated instead.
                 */
            });

            await invokeAsync(
                initiateEarRequest,
                BrowserPerformanceEvents.SilentHandlerInitiateAuthRequest,
                this.logger,
                this.performanceClient,
                correlationId
            )(
                iframe,
                this.config,
                discoveredAuthority,
                silentRequest,
                this.logger,
                this.performanceClient
            );

            responseString = await responsePromise;
        } finally {
            invoke(
                removeHiddenIframe,
                BrowserPerformanceEvents.RemoveHiddenIframe,
                this.logger,
                this.performanceClient,
                correlationId
            )(iframe);
        }

        const serverParams = invoke(
            ResponseHandler.deserializeResponse,
            BrowserPerformanceEvents.DeserializeResponse,
            this.logger,
            this.performanceClient,
            correlationId
        )(responseString, responseType, this.logger, this.correlationId);

        if (!serverParams.ear_jwe && serverParams.code) {
            // If server doesn't support EAR, they may fallback to auth code flow instead
            const authClient = await invokeAsync(
                this.createAuthCodeClient.bind(this),
                BrowserPerformanceEvents.StandardInteractionClientCreateAuthCodeClient,
                this.logger,
                this.performanceClient,
                correlationId
            )({
                serverTelemetryManager: initializeServerTelemetryManager(
                    this.apiId,
                    this.config.auth.clientId,
                    correlationId,
                    this.browserStorage,
                    this.logger,
                    undefined,
                    this.config.system.serverTelemetryEnabled
                ),
                requestAuthority: request.authority,
                requestAzureCloudOptions: request.azureCloudOptions,
                requestExtraQueryParameters: request.extraQueryParameters,
                account: request.account,
                authority: discoveredAuthority,
            });

            return invokeAsync(
                Authorize.handleResponseCode,
                BrowserPerformanceEvents.HandleResponseCode,
                this.logger,
                this.performanceClient,
                correlationId
            )(
                silentRequest,
                serverParams,
                pkceCodes.verifier,
                this.apiId,
                this.config,
                authClient,
                this.browserStorage,
                this.nativeStorage,
                this.eventHandler,
                this.logger,
                this.performanceClient,
                this.platformAuthProvider
            );
        } else {
            return invokeAsync(
                Authorize.handleResponseEAR,
                BrowserPerformanceEvents.HandleResponseEar,
                this.logger,
                this.performanceClient,
                correlationId
            )(
                silentRequest,
                serverParams,
                this.apiId,
                this.config,
                discoveredAuthority,
                this.browserStorage,
                this.nativeStorage,
                this.eventHandler,
                this.logger,
                this.performanceClient,
                this.platformAuthProvider
            );
        }
    }

    /**
     * Verifies SSO capability by making an iframe request to /authorize without exchanging the code for tokens.
     * This is useful for verifying SSO capability in the background without the overhead of a full token exchange.
     * @param request - The SSO silent request
     * @returns true if SSO verification was successful with a valid authorization code, false otherwise
     */
    async verifySso(request: SsoSilentRequest): Promise<boolean> {
        const inputRequest = { ...request };
        if (!inputRequest.prompt) {
            inputRequest.prompt = Constants.PromptValue.NONE;
        }

        // Create silent request
        const silentRequest: CommonAuthorizationUrlRequest = await invokeAsync(
            initializeAuthorizationRequest,
            BrowserPerformanceEvents.StandardInteractionClientInitializeAuthorizationRequest,
            this.logger,
            this.performanceClient,
            this.correlationId
        )(
            inputRequest,
            InteractionType.Silent,
            this.config,
            this.browserCrypto,
            this.browserStorage,
            this.logger,
            this.performanceClient,
            this.correlationId
        );

        const authClient: AuthorizationCodeClient = await invokeAsync(
            this.createAuthCodeClient.bind(this),
            BrowserPerformanceEvents.StandardInteractionClientCreateAuthCodeClient,
            this.logger,
            this.performanceClient,
            this.correlationId
        )({
            serverTelemetryManager: initializeServerTelemetryManager(
                this.apiId,
                this.config.auth.clientId,
                this.correlationId,
                this.browserStorage,
                this.logger,
                undefined,
                this.config.system.serverTelemetryEnabled
            ),
            requestAuthority: silentRequest.authority,
            requestAzureCloudOptions: silentRequest.azureCloudOptions,
            requestExtraQueryParameters: silentRequest.extraQueryParameters,
            account: silentRequest.account,
        });

        const { serverParams } = await this.silentAuthorizeHelper(
            authClient,
            silentRequest
        );

        const correlationId = silentRequest.correlationId;

        // Validate the response - this checks for errors and validates state
        AuthorizeProtocol.validateAuthorizationResponse(
            serverParams,
            silentRequest.state,
            correlationId
        );

        // Verify a valid authorization code is present
        if (!serverParams.code) {
            this.logger.warning(
                "SSO verification response did not contain an authorization code",
                correlationId
            );
            return false;
        }

        this.logger.verbose(
            "SSO verification completed successfully with valid authorization code - skipped token exchange",
            correlationId
        );
        return true;
    }

    /**
     * Currently Unsupported
     */
    logout(): Promise<void> {
        // Synchronous so we must reject
        return Promise.reject(
            createBrowserAuthError(
                BrowserAuthErrorCodes.silentLogoutUnsupported,
                ""
            )
        );
    }

    /**
     * Helper which acquires an authorization code silently using a hidden iframe from given url
     * using the scopes requested as part of the id, and exchanges the code for a set of OAuth tokens.
     * @param navigateUrl
     * @param userRequestScopes
     */
    protected async silentTokenHelper(
        authClient: AuthorizationCodeClient,
        request: CommonAuthorizationUrlRequest
    ): Promise<AuthenticationResult> {
        const { serverParams, pkceCodes } = await this.silentAuthorizeHelper(
            authClient,
            request
        );

        return invokeAsync(
            Authorize.handleResponseCode,
            BrowserPerformanceEvents.HandleResponseCode,
            this.logger,
            this.performanceClient,
            request.correlationId
        )(
            request,
            serverParams,
            pkceCodes.verifier,
            this.apiId,
            this.config,
            authClient,
            this.browserStorage,
            this.nativeStorage,
            this.eventHandler,
            this.logger,
            this.performanceClient,
            this.platformAuthProvider
        );
    }

    /**
     * Shared helper that generates PKCE codes, builds the /authorize URL,
     * loads it in a hidden iframe, waits for the redirect-bridge response,
     * and returns the deserialized server parameters along with the PKCE codes
     * and the request that was sent.
     */
    private async silentAuthorizeHelper(
        authClient: AuthorizationCodeClient,
        request: CommonAuthorizationUrlRequest
    ): Promise<{
        serverParams: ReturnType<typeof ResponseHandler.deserializeResponse>;
        pkceCodes: PkceCodes;
        silentRequest: CommonAuthorizationUrlRequest;
    }> {
        const correlationId = request.correlationId;
        const pkceCodes = await invokeAsync(
            generatePkceCodes,
            BrowserPerformanceEvents.GeneratePkceCodes,
            this.logger,
            this.performanceClient,
            correlationId
        )(this.performanceClient, this.logger, correlationId);

        const silentRequest = {
            ...request,
            codeChallenge: pkceCodes.challenge,
        };

        // Create the iframe, register the response listener, then navigate, so the listener is active before the iframe can respond.
        const iframe = createHiddenIframe();

        const responseType = this.config.auth.OIDCOptions.responseMode;
        // Wait for response from the redirect bridge.
        let responseString: string;
        try {
            const responsePromise = invokeAsync(
                this.waitForIframeResponse.bind(this),
                BrowserPerformanceEvents.SilentHandlerMonitorIframeForHash,
                this.logger,
                this.performanceClient,
                correlationId
            )(iframe, request);
            responsePromise.catch(() => {
                /*
                 * If URL creation or navigation below throws before
                 * responsePromise is awaited, the listener still rejects on
                 * timeout. Swallow it here so it does not surface as an
                 * unhandled rejection; the navigation error is propagated
                 * instead.
                 */
            });

            if (request.httpMethod === Constants.HttpMethod.POST) {
                await invokeAsync(
                    initiateCodeFlowWithPost,
                    BrowserPerformanceEvents.SilentHandlerInitiateAuthRequest,
                    this.logger,
                    this.performanceClient,
                    correlationId
                )(
                    iframe,
                    this.config,
                    authClient.authority,
                    silentRequest,
                    this.logger,
                    this.performanceClient
                );
            } else {
                // Create authorize request url
                const navigateUrl = await invokeAsync(
                    Authorize.getAuthCodeRequestUrl,
                    PerformanceEvents.GetAuthCodeUrl,
                    this.logger,
                    this.performanceClient,
                    correlationId
                )(
                    this.config,
                    authClient.authority,
                    silentRequest,
                    this.logger,
                    this.performanceClient
                );

                // Navigate the iframe to the authorize request url
                await invokeAsync(
                    initiateCodeRequest,
                    BrowserPerformanceEvents.SilentHandlerInitiateAuthRequest,
                    this.logger,
                    this.performanceClient,
                    correlationId
                )(iframe, navigateUrl, this.logger, correlationId);
            }

            responseString = await responsePromise;
        } finally {
            invoke(
                removeHiddenIframe,
                BrowserPerformanceEvents.RemoveHiddenIframe,
                this.logger,
                this.performanceClient,
                correlationId
            )(iframe);
        }

        const serverParams = invoke(
            ResponseHandler.deserializeResponse,
            BrowserPerformanceEvents.DeserializeResponse,
            this.logger,
            this.performanceClient,
            correlationId
        )(responseString, responseType, this.logger, this.correlationId);

        return { serverParams, pkceCodes, silentRequest };
    }

    protected async waitForIframeResponse(
        iframe: HTMLIFrameElement,
        request: WaitForIframeRequest
    ): Promise<string> {
        if (this.waitForIframeResponseHook) {
            return this.waitForIframeResponseHook(iframe, request);
        }
        return BrowserUtils.waitForBridgeResponse(
            this.config.system.iframeBridgeTimeout,
            this.logger,
            request,
            this.performanceClient,
            this.config.experimental
        );
    }
}
