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

import { CommonAuthorizationCodeRequest } from "../request/CommonAuthorizationCodeRequest.js";
import { Authority } from "../authority/Authority.js";
import * as RequestParameterBuilder from "../request/RequestParameterBuilder.js";
import * as UrlUtils from "../utils/UrlUtils.js";
import * as Constants from "../utils/Constants.js";
import * as AADServerParamKeys from "../constants/AADServerParamKeys.js";
import {
    buildClientConfiguration,
    ClientConfiguration,
    CommonClientConfiguration,
    isOidcProtocolMode,
} from "../config/ClientConfiguration.js";
import { ServerAuthorizationTokenResponse } from "../response/ServerAuthorizationTokenResponse.js";
import { NetworkResponse } from "../network/NetworkResponse.js";
import { ResponseHandler } from "../response/ResponseHandler.js";
import { AuthenticationResult } from "../response/AuthenticationResult.js";
import {
    ClientAuthErrorCodes,
    createClientAuthError,
} from "../error/ClientAuthError.js";
import { UrlString } from "../url/UrlString.js";
import { CommonEndSessionRequest } from "../request/CommonEndSessionRequest.js";
import { PopTokenGenerator } from "../crypto/PopTokenGenerator.js";
import { AuthorizationCodePayload } from "../response/AuthorizationCodePayload.js";
import * as TimeUtils from "../utils/TimeUtils.js";
import {
    buildClientInfoFromHomeAccountId,
    buildClientInfo,
} from "../account/ClientInfo.js";
import { CcsCredentialType, CcsCredential } from "../account/CcsCredential.js";
import {
    createClientConfigurationError,
    ClientConfigurationErrorCodes,
} from "../error/ClientConfigurationError.js";
import { IPerformanceClient } from "../telemetry/performance/IPerformanceClient.js";
import * as PerformanceEvents from "../telemetry/performance/PerformanceEvents.js";
import { invokeAsync } from "../utils/FunctionWrappers.js";
import { ClientAssertion } from "../account/ClientCredentials.js";
import { getClientAssertion } from "../utils/ClientAssertionUtils.js";
import { getRequestThumbprint } from "../network/RequestThumbprint.js";
import {
    createTokenQueryParameters,
    createTokenRequestHeaders,
    executePostToTokenEndpoint,
} from "../protocol/Token.js";
import { createDiscoveredInstance } from "../authority/AuthorityFactory.js";
import { ServerTelemetryManager } from "../telemetry/server/ServerTelemetryManager.js";
import { Logger } from "../logger/Logger.js";
import { ICrypto } from "../crypto/ICrypto.js";
import { CacheManager } from "../cache/CacheManager.js";
import { INetworkModule } from "../network/INetworkModule.js";
import { version, name } from "../packageMetadata.js";

/**
 * Oauth2.0 Authorization Code client
 * @internal
 */
export class AuthorizationCodeClient {
    // Flag to indicate if client is for hybrid spa auth code redemption
    protected includeRedirectUri: boolean = true;
    private oidcDefaultScopes;

    // Logger object
    public logger: Logger;

    // Application config
    protected config: CommonClientConfiguration;

    // Crypto Interface
    protected cryptoUtils: ICrypto;

    // Storage Interface
    protected cacheManager: CacheManager;

    // Network Interface
    protected networkClient: INetworkModule;

    // Server Telemetry Manager
    protected serverTelemetryManager: ServerTelemetryManager | null;

    // Default authority object
    public authority: Authority;

    // Performance telemetry client
    protected performanceClient: IPerformanceClient;

    constructor(
        configuration: ClientConfiguration,
        performanceClient: IPerformanceClient
    ) {
        // Set the configuration
        this.config = buildClientConfiguration(configuration);

        // Initialize the logger
        this.logger = new Logger(this.config.loggerOptions, name, version);

        // Initialize crypto
        this.cryptoUtils = this.config.cryptoInterface;

        // Initialize storage interface
        this.cacheManager = this.config.storageInterface;

        // Set the network interface
        this.networkClient = this.config.networkInterface;

        // Set TelemetryManager
        this.serverTelemetryManager = this.config.serverTelemetryManager;

        // set Authority
        this.authority = this.config.authOptions.authority;

        // set performance telemetry client
        this.performanceClient = performanceClient;
        this.oidcDefaultScopes =
            this.config.authOptions.authority.options.OIDCOptions?.defaultScopes;
    }

    /**
     * API to acquire a token in exchange of 'authorization_code` acquired by the user in the first leg of the
     * authorization_code_grant
     * @param request
     */
    async acquireToken(
        request: CommonAuthorizationCodeRequest,
        apiId: number,
        authCodePayload?: AuthorizationCodePayload
    ): Promise<AuthenticationResult> {
        if (!request.code) {
            throw createClientAuthError(
                ClientAuthErrorCodes.requestCannotBeMade
            );
        }

        // Check for new cloud instance
        if (authCodePayload && authCodePayload.cloud_instance_host_name) {
            await invokeAsync(
                this.updateTokenEndpointAuthority.bind(this),
                PerformanceEvents.UpdateTokenEndpointAuthority,
                this.logger,
                this.performanceClient,
                request.correlationId
            )(authCodePayload.cloud_instance_host_name, request.correlationId);
        }

        const reqTimestamp = TimeUtils.nowSeconds();
        const response = await invokeAsync(
            this.executeTokenRequest.bind(this),
            PerformanceEvents.AuthClientExecuteTokenRequest,
            this.logger,
            this.performanceClient,
            request.correlationId
        )(this.authority, request, this.serverTelemetryManager);

        // Retrieve requestId from response headers
        const requestId =
            response.headers?.[Constants.HeaderNames.X_MS_REQUEST_ID];

        const responseHandler = new ResponseHandler(
            this.config.authOptions.clientId,
            this.cacheManager,
            this.cryptoUtils,
            this.logger,
            this.performanceClient,
            this.config.serializableCache,
            this.config.persistencePlugin
        );

        // Validate response. This function throws a server error if an error is returned by the server.
        responseHandler.validateTokenResponse(
            response.body,
            request.correlationId
        );

        return invokeAsync(
            responseHandler.handleServerTokenResponse.bind(responseHandler),
            PerformanceEvents.HandleServerTokenResponse,
            this.logger,
            this.performanceClient,
            request.correlationId
        )(
            response.body,
            this.authority,
            reqTimestamp,
            request,
            apiId,
            authCodePayload,
            undefined,
            undefined,
            undefined,
            requestId
        );
    }

    /**
     * Used to log out the current user, and redirect the user to the postLogoutRedirectUri.
     * Default behaviour is to redirect the user to `window.location.href`.
     * @param authorityUri
     */
    getLogoutUri(logoutRequest: CommonEndSessionRequest): string {
        // Throw error if logoutRequest is null/undefined
        if (!logoutRequest) {
            throw createClientConfigurationError(
                ClientConfigurationErrorCodes.logoutRequestEmpty
            );
        }
        const queryString = this.createLogoutUrlQueryString(logoutRequest);

        // Construct logout URI
        return UrlString.appendQueryString(
            this.authority.endSessionEndpoint,
            queryString
        );
    }

    /**
     * Executes POST request to token endpoint
     * @param authority
     * @param request
     */
    private async executeTokenRequest(
        authority: Authority,
        request: CommonAuthorizationCodeRequest,
        serverTelemetryManager: ServerTelemetryManager | null
    ): Promise<NetworkResponse<ServerAuthorizationTokenResponse>> {
        const queryParametersString = createTokenQueryParameters(
            request,
            this.config.authOptions.clientId,
            this.config.authOptions.redirectUri,
            this.performanceClient
        );
        const endpoint = UrlString.appendQueryString(
            authority.tokenEndpoint,
            queryParametersString
        );

        const requestBody = await invokeAsync(
            this.createTokenRequestBody.bind(this),
            PerformanceEvents.AuthClientCreateTokenRequestBody,
            this.logger,
            this.performanceClient,
            request.correlationId
        )(request);

        let ccsCredential: CcsCredential | undefined = undefined;
        if (request.clientInfo) {
            try {
                const clientInfo = buildClientInfo(
                    request.clientInfo,
                    this.cryptoUtils.base64Decode
                );
                ccsCredential = {
                    credential: `${clientInfo.uid}${Constants.CLIENT_INFO_SEPARATOR}${clientInfo.utid}`,
                    type: CcsCredentialType.HOME_ACCOUNT_ID,
                };
            } catch (e) {
                this.logger.verbose(
                    `Could not parse client info for CCS Header: '${e}'`,
                    request.correlationId
                );
            }
        }
        const headers: Record<string, string> = createTokenRequestHeaders(
            this.logger,
            this.config.systemOptions.preventCorsPreflight,
            ccsCredential || request.ccsCredential
        );

        const thumbprint = getRequestThumbprint(
            this.config.authOptions.clientId,
            request
        );

        return invokeAsync(
            executePostToTokenEndpoint,
            PerformanceEvents.AuthorizationCodeClientExecutePostToTokenEndpoint,
            this.logger,
            this.performanceClient,
            request.correlationId
        )(
            endpoint,
            requestBody,
            headers,
            thumbprint,
            request.correlationId,
            this.cacheManager,
            this.networkClient,
            this.logger,
            this.performanceClient,
            serverTelemetryManager
        );
    }

    /**
     * Generates a map for all the params to be sent to the service
     * @param request
     */
    private async createTokenRequestBody(
        request: CommonAuthorizationCodeRequest
    ): Promise<string> {
        const parameters = new Map<string, string>();

        RequestParameterBuilder.addClientId(
            parameters,
            request.embeddedClientId ||
                request.extraParameters?.[AADServerParamKeys.CLIENT_ID] ||
                this.config.authOptions.clientId
        );

        /*
         * For hybrid spa flow, there will be a code but no verifier
         * In this scenario, don't include redirect uri as auth code will not be bound to redirect URI
         */
        if (!this.includeRedirectUri) {
            // Just validate
            if (!request.redirectUri) {
                throw createClientConfigurationError(
                    ClientConfigurationErrorCodes.redirectUriEmpty
                );
            }
        } else {
            // Validate and include redirect uri
            RequestParameterBuilder.addRedirectUri(
                parameters,
                request.redirectUri
            );
        }

        // Add scope array, parameter builder will add default scopes and dedupe
        RequestParameterBuilder.addScopes(
            parameters,
            request.scopes,
            true,
            this.oidcDefaultScopes
        );

        RequestParameterBuilder.addResource(parameters, request.resource);

        // add code: user set, not validated
        RequestParameterBuilder.addAuthorizationCode(parameters, request.code);

        // Add library metadata
        RequestParameterBuilder.addLibraryInfo(
            parameters,
            this.config.libraryInfo
        );
        RequestParameterBuilder.addApplicationTelemetry(
            parameters,
            this.config.telemetry.application
        );
        RequestParameterBuilder.addThrottling(parameters);

        if (this.serverTelemetryManager && !isOidcProtocolMode(this.config)) {
            RequestParameterBuilder.addServerTelemetry(
                parameters,
                this.serverTelemetryManager
            );
        }

        // add code_verifier if passed
        if (request.codeVerifier) {
            RequestParameterBuilder.addCodeVerifier(
                parameters,
                request.codeVerifier
            );
        }

        if (this.config.clientCredentials.clientSecret) {
            RequestParameterBuilder.addClientSecret(
                parameters,
                this.config.clientCredentials.clientSecret
            );
        }

        if (this.config.clientCredentials.clientAssertion) {
            const clientAssertion: ClientAssertion =
                this.config.clientCredentials.clientAssertion;

            RequestParameterBuilder.addClientAssertion(
                parameters,
                await getClientAssertion(
                    clientAssertion.assertion,
                    this.config.authOptions.clientId,
                    request.resourceRequestUri
                )
            );
            RequestParameterBuilder.addClientAssertionType(
                parameters,
                clientAssertion.assertionType
            );
        }

        RequestParameterBuilder.addGrantType(
            parameters,
            Constants.GrantType.AUTHORIZATION_CODE_GRANT
        );
        RequestParameterBuilder.addClientInfo(parameters);

        if (
            request.authenticationScheme === Constants.AuthenticationScheme.POP
        ) {
            const popTokenGenerator = new PopTokenGenerator(
                this.cryptoUtils,
                this.performanceClient
            );

            let reqCnfData;
            if (!request.popKid) {
                const generatedReqCnfData = await invokeAsync(
                    popTokenGenerator.generateCnf.bind(popTokenGenerator),
                    PerformanceEvents.PopTokenGenerateCnf,
                    this.logger,
                    this.performanceClient,
                    request.correlationId
                )(request, this.logger);
                reqCnfData = generatedReqCnfData.reqCnfString;
            } else {
                reqCnfData = this.cryptoUtils.encodeKid(request.popKid);
            }

            // SPA PoP requires full Base64Url encoded req_cnf string (unhashed)
            RequestParameterBuilder.addPopToken(parameters, reqCnfData);
        } else if (
            request.authenticationScheme === Constants.AuthenticationScheme.SSH
        ) {
            if (request.sshJwk) {
                RequestParameterBuilder.addSshJwk(parameters, request.sshJwk);
            } else {
                throw createClientConfigurationError(
                    ClientConfigurationErrorCodes.missingSshJwk
                );
            }
        }

        let ccsCred: CcsCredential | undefined = undefined;
        if (request.clientInfo) {
            try {
                const clientInfo = buildClientInfo(
                    request.clientInfo,
                    this.cryptoUtils.base64Decode
                );
                ccsCred = {
                    credential: `${clientInfo.uid}${Constants.CLIENT_INFO_SEPARATOR}${clientInfo.utid}`,
                    type: CcsCredentialType.HOME_ACCOUNT_ID,
                };
            } catch (e) {
                this.logger.verbose(
                    `Could not parse client info for CCS Header: '${e}'`,
                    request.correlationId
                );
            }
        } else {
            ccsCred = request.ccsCredential;
        }

        // Adds these as parameters in the request instead of headers to prevent CORS preflight request
        if (this.config.systemOptions.preventCorsPreflight && ccsCred) {
            switch (ccsCred.type) {
                case CcsCredentialType.HOME_ACCOUNT_ID:
                    try {
                        const clientInfo = buildClientInfoFromHomeAccountId(
                            ccsCred.credential
                        );
                        RequestParameterBuilder.addCcsOid(
                            parameters,
                            clientInfo
                        );
                    } catch (e) {
                        this.logger.verbose(
                            `Could not parse home account ID for CCS Header: '${e}'`,
                            request.correlationId
                        );
                    }
                    break;
                case CcsCredentialType.UPN:
                    RequestParameterBuilder.addCcsUpn(
                        parameters,
                        ccsCred.credential
                    );
                    break;
            }
        }

        if (request.embeddedClientId) {
            RequestParameterBuilder.addBrokerParameters(
                parameters,
                this.config.authOptions.clientId,
                this.config.authOptions.redirectUri
            );
        }

        if (request.extraParameters) {
            RequestParameterBuilder.addExtraParameters(
                parameters,
                request.extraParameters
            );
        }

        // Add hybrid spa parameters if not already provided
        if (
            request.enableSpaAuthorizationCode &&
            (!request.extraParameters ||
                !request.extraParameters[AADServerParamKeys.RETURN_SPA_CODE])
        ) {
            RequestParameterBuilder.addExtraParameters(parameters, {
                [AADServerParamKeys.RETURN_SPA_CODE]: "1",
            });
        }

        RequestParameterBuilder.instrumentBrokerParams(
            parameters,
            request.correlationId,
            this.performanceClient
        );

        RequestParameterBuilder.addClaims(
            parameters,
            request.claims,
            this.config.authOptions.clientCapabilities,
            request.skipBrokerClaims
        );

        return UrlUtils.mapToQueryString(parameters);
    }

    /**
     * This API validates the `EndSessionRequest` and creates a URL
     * @param request
     */
    private createLogoutUrlQueryString(
        request: CommonEndSessionRequest
    ): string {
        const parameters = new Map<string, string>();

        if (request.postLogoutRedirectUri) {
            RequestParameterBuilder.addPostLogoutRedirectUri(
                parameters,
                request.postLogoutRedirectUri
            );
        }

        if (request.correlationId) {
            RequestParameterBuilder.addCorrelationId(
                parameters,
                request.correlationId
            );
        }

        if (request.idTokenHint) {
            RequestParameterBuilder.addIdTokenHint(
                parameters,
                request.idTokenHint
            );
        }

        if (request.state) {
            RequestParameterBuilder.addState(parameters, request.state);
        }

        if (request.logoutHint) {
            RequestParameterBuilder.addLogoutHint(
                parameters,
                request.logoutHint
            );
        }

        if (request.extraQueryParameters) {
            RequestParameterBuilder.addExtraParameters(
                parameters,
                request.extraQueryParameters
            );
        }

        if (this.config.authOptions.instanceAware) {
            RequestParameterBuilder.addInstanceAware(parameters);
        }

        return UrlUtils.mapToQueryString(parameters);
    }

    /**
     * Updates the authority to the cloud instance provided in the authorization response
     * @param cloudInstanceHostName - cloud instance host name from authorization code payload
     * @param correlationId - request correlation id
     */
    private async updateTokenEndpointAuthority(
        cloudInstanceHostName: string,
        correlationId: string
    ): Promise<void> {
        const cloudInstanceAuthorityUri = `https://${cloudInstanceHostName}/${this.authority.tenant}/`;
        const cloudInstanceAuthority = await createDiscoveredInstance(
            cloudInstanceAuthorityUri,
            this.networkClient,
            this.cacheManager,
            this.authority.options,
            this.logger,
            correlationId,
            this.performanceClient
        );
        this.authority = cloudInstanceAuthority;
    }
}
