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

import {
    Authority,
    AuthorizeProtocol,
    ClientConfigurationErrorCodes,
    CommonAuthorizationUrlRequest,
    createClientConfigurationError,
    invokeAsync,
    IPerformanceClient,
    Logger,
    PerformanceEvents,
    PopTokenGenerator,
    ProtocolMode,
    RequestParameterBuilder,
    CommonAuthorizationCodeRequest,
    AuthorizationCodeClient,
    ProtocolUtils,
    ThrottlingUtils,
    AuthorizeResponse,
    ResponseHandler,
    TimeUtils,
    AuthorizationCodePayload,
    ServerAuthorizationTokenResponse,
    Constants,
} from "@azure/msal-common/browser";
import * as BrowserPerformanceEvents from "../telemetry/BrowserPerformanceEvents.js";
import { BrowserConfiguration } from "../config/Configuration.js";
import { ApiId, BrowserConstants } from "../utils/BrowserConstants.js";
import { version } from "../packageMetadata.js";
import { CryptoOps } from "../crypto/CryptoOps.js";
import {
    BrowserAuthErrorCodes,
    createBrowserAuthError,
} from "../error/BrowserAuthError.js";
import { AuthenticationResult } from "../response/AuthenticationResult.js";
import { InteractionHandler } from "../interaction_handler/InteractionHandler.js";
import { BrowserCacheManager } from "../cache/BrowserCacheManager.js";
import { PlatformAuthInteractionClient } from "../interaction_client/PlatformAuthInteractionClient.js";
import { EventHandler } from "../event/EventHandler.js";
import { decryptEarResponse } from "../crypto/BrowserCrypto.js";
import { IPlatformAuthHandler } from "../broker/nativeBroker/IPlatformAuthHandler.js";

/**
 * Parsed representation of the clientdata response parameter from the /authorize endpoint.
 *
 * Format: urlencoded(account_type|error|sub_error|cloud_instance|caller_data_boundary)
 */
type ClientData = {
    /** Account type: MSA, AAD */
    accountType: string;
    /** Error code string (e.g. "0x8004345C" for MSA) */
    error: string;
    /** Sub-error code string (e.g. "0x80043588" for MSA) */
    subError: string;
    /** Cloud instance hostname (e.g. "login.microsoftonline.com") */
    cloudInstance: string;
    /** Caller data boundary (e.g. "none" for MSA) */
    callerDataBoundary: string;
};

const clientDataAccountTypeMapping = new Map([
    ["e", "AAD"],
    ["m", "MSA"],
]);

/**
 * Parses the clientdata response parameter from the /authorize endpoint.
 *
 * Logically, the clientdata value is URL-encoded and pipe-delimited:
 *   urlencoded(account_type | error | sub_error | cloud_instance | caller_data_boundary)
 *
 * In normal browser flows, this value may already have been URL-decoded
 * (e.g. by URLSearchParams). This function will only apply decodeURIComponent
 * when the string appears to contain percent-encoded sequences to avoid
 * double-decoding.
 *
 * @param clientdata - The raw clientdata string from the authorize response
 * @returns Parsed ClientData object, or null if the input is empty/invalid
 */
export function parseClientData(clientdata?: string): ClientData | null {
    if (!clientdata) {
        return null;
    }

    try {
        // Only decode when the string appears to contain percent-encoded sequences
        const shouldDecode = /%(?:[0-9A-Fa-f]{2})/.test(clientdata);
        const decoded = shouldDecode
            ? decodeURIComponent(clientdata)
            : clientdata;
        const parts = decoded.split("|");

        if (parts.length < 5) {
            return null;
        }

        return {
            accountType:
                clientDataAccountTypeMapping.get(parts[0]?.trim() || "") || "",
            error: parts[1]?.trim() || "",
            subError: parts[2]?.trim() || "",
            cloudInstance: parts[3]?.trim() || "",
            callerDataBoundary: parts[4]?.trim() || "",
        };
    } catch {
        return null;
    }
}

/**
 * Instruments account type, error, and suberror from clientdata
 */
function instrumentClientData(
    response: AuthorizeResponse,
    correlationId: string,
    performanceClient: IPerformanceClient
): void {
    const parsed = parseClientData(response.clientdata);
    parsed?.accountType &&
        performanceClient.addFields(
            { accountType: parsed.accountType },
            correlationId
        );
    parsed?.error &&
        performanceClient.addFields(
            { serverErrorNo: parsed.error },
            correlationId
        );
    parsed?.subError &&
        performanceClient.addFields(
            { serverSubErrorNo: parsed.subError },
            correlationId
        );
}

/**
 * Returns map of parameters that are applicable to all calls to /authorize whether using PKCE or EAR
 * @param config
 * @param authority
 * @param request
 * @param logger
 * @param performanceClient
 * @returns
 */
async function getStandardParameters(
    config: BrowserConfiguration,
    authority: Authority,
    request: CommonAuthorizationUrlRequest,
    logger: Logger,
    performanceClient: IPerformanceClient
): Promise<Map<string, string>> {
    const parameters = AuthorizeProtocol.getStandardAuthorizeRequestParameters(
        { ...config.auth, authority: authority },
        request,
        logger,
        performanceClient
    );
    RequestParameterBuilder.addLibraryInfo(parameters, {
        sku: BrowserConstants.MSAL_SKU,
        version: version,
        os: "",
        cpu: "",
    });
    if (config.system.protocolMode !== ProtocolMode.OIDC) {
        RequestParameterBuilder.addApplicationTelemetry(
            parameters,
            config.telemetry.application
        );
    }

    if (request.platformBroker) {
        // signal ests that this is a WAM call
        RequestParameterBuilder.addNativeBroker(parameters);

        // instrument JS-platform bridge specific fields
        performanceClient.addFields(
            {
                isPlatformAuthorizeRequest: true,
            },
            request.correlationId
        );

        // pass the req_cnf for POP
        if (
            request.authenticationScheme === Constants.AuthenticationScheme.POP
        ) {
            const cryptoOps = new CryptoOps(logger, performanceClient);
            const popTokenGenerator = new PopTokenGenerator(
                cryptoOps,
                performanceClient
            );

            // req_cnf is always sent as a string for SPAs
            let reqCnfData;
            if (!request.popKid) {
                const generatedReqCnfData = await invokeAsync(
                    popTokenGenerator.generateCnf.bind(popTokenGenerator),
                    PerformanceEvents.PopTokenGenerateCnf,
                    logger,
                    performanceClient,
                    request.correlationId
                )(request, logger);
                reqCnfData = generatedReqCnfData.reqCnfString;
            } else {
                reqCnfData = cryptoOps.encodeKid(request.popKid);
            }
            RequestParameterBuilder.addPopToken(parameters, reqCnfData);
        }
    }

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

    return parameters;
}

/**
 * Gets the full /authorize URL with request parameters when using Auth Code + PKCE
 * @param config
 * @param authority
 * @param request
 * @param logger
 * @param performanceClient
 * @returns
 */
export async function getAuthCodeRequestUrl(
    config: BrowserConfiguration,
    authority: Authority,
    request: CommonAuthorizationUrlRequest,
    logger: Logger,
    performanceClient: IPerformanceClient
): Promise<string> {
    if (!request.codeChallenge) {
        throw createClientConfigurationError(
            ClientConfigurationErrorCodes.pkceParamsMissing,
            request.correlationId
        );
    }

    const parameters = await invokeAsync(
        getStandardParameters,
        BrowserPerformanceEvents.GetStandardParams,
        logger,
        performanceClient,
        request.correlationId
    )(config, authority, request, logger, performanceClient);
    RequestParameterBuilder.addResponseType(
        parameters,
        Constants.OAuthResponseType.CODE
    );

    RequestParameterBuilder.addCodeChallengeParams(
        parameters,
        request.codeChallenge,
        Constants.S256_CODE_CHALLENGE_METHOD
    );

    // Merge extraQueryParameters and extraParameters to be appended to request URL
    RequestParameterBuilder.addExtraParameters(parameters, {
        ...request.extraQueryParameters,
        ...request.extraParameters,
    });

    return AuthorizeProtocol.getAuthorizeUrl(authority, parameters);
}

/**
 * Gets the form that will be posted to /authorize with request parameters when using EAR
 */
export async function getEARForm(
    frame: Document,
    config: BrowserConfiguration,
    authority: Authority,
    request: CommonAuthorizationUrlRequest,
    logger: Logger,
    performanceClient: IPerformanceClient
): Promise<HTMLFormElement> {
    if (!request.earJwk) {
        throw createBrowserAuthError(
            BrowserAuthErrorCodes.earJwkEmpty,
            request.correlationId
        );
    }

    const parameters = await getStandardParameters(
        config,
        authority,
        request,
        logger,
        performanceClient
    );

    RequestParameterBuilder.addResponseType(
        parameters,
        Constants.OAuthResponseType.IDTOKEN_TOKEN_REFRESHTOKEN
    );
    RequestParameterBuilder.addEARParameters(parameters, request.earJwk);

    // Also add codeChallenge as backup in case EAR is not supported
    RequestParameterBuilder.addCodeChallengeParams(
        parameters,
        request.codeChallenge,
        Constants.S256_CODE_CHALLENGE_METHOD
    );

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

    const queryParams = new Map<string, string>();
    RequestParameterBuilder.addExtraParameters(
        queryParams,
        request.extraQueryParameters || {}
    );

    // Add correlationId to query params so gateway can propagate it to IDPs
    RequestParameterBuilder.addCorrelationId(
        queryParams,
        request.correlationId
    );

    const url = AuthorizeProtocol.getAuthorizeUrl(authority, queryParams);

    return createForm(frame, url, parameters);
}

/**
 * Gets the form that will be posted to /authorize with request parameters when using POST method
 */
export async function getCodeForm(
    frame: Document,
    config: BrowserConfiguration,
    authority: Authority,
    request: CommonAuthorizationUrlRequest,
    logger: Logger,
    performanceClient: IPerformanceClient
): Promise<HTMLFormElement> {
    const parameters = await getStandardParameters(
        config,
        authority,
        request,
        logger,
        performanceClient
    );

    RequestParameterBuilder.addResponseType(
        parameters,
        Constants.OAuthResponseType.CODE
    );

    RequestParameterBuilder.addCodeChallengeParams(
        parameters,
        request.codeChallenge,
        request.codeChallengeMethod || Constants.S256_CODE_CHALLENGE_METHOD
    );

    // Add extraParameters to the request body
    RequestParameterBuilder.addExtraParameters(parameters, {
        ...request.extraParameters,
    });

    // Add extraQueryParameters to be appended to request URL
    const queryParams = new Map<string, string>();
    RequestParameterBuilder.addExtraParameters(
        queryParams,
        request.extraQueryParameters || {}
    );

    // Add correlationId to query params so gateway can propagate it to IDPs
    RequestParameterBuilder.addCorrelationId(
        queryParams,
        request.correlationId
    );

    const url = AuthorizeProtocol.getAuthorizeUrl(authority, queryParams);

    return createForm(frame, url, parameters);
}

/**
 * Creates form element in the provided document with auth parameters in the post body
 * @param frame
 * @param authorizeUrl
 * @param parameters
 * @returns
 */
function createForm(
    frame: Document,
    authorizeUrl: string,
    parameters: Map<string, string>
): HTMLFormElement {
    const form = frame.createElement("form");
    form.method = "post";
    form.action = authorizeUrl;

    parameters.forEach((value: string, key: string) => {
        const param = frame.createElement("input");
        param.hidden = true;
        param.name = key;
        param.value = value;

        form.appendChild(param);
    });

    frame.body.appendChild(form);
    return form;
}

/**
 * Response handler when server returns accountId on the /authorize request
 * @param request
 * @param accountId
 * @param apiId
 * @param config
 * @param browserStorage
 * @param nativeStorage
 * @param eventHandler
 * @param logger
 * @param performanceClient
 * @param nativeMessageHandler
 * @returns
 */
export async function handleResponsePlatformBroker(
    request: CommonAuthorizationUrlRequest,
    accountId: string,
    apiId: ApiId,
    config: BrowserConfiguration,
    browserStorage: BrowserCacheManager,
    nativeStorage: BrowserCacheManager,
    eventHandler: EventHandler,
    logger: Logger,
    performanceClient: IPerformanceClient,
    platformAuthProvider?: IPlatformAuthHandler
): Promise<AuthenticationResult> {
    logger.verbose(
        "Account id found, calling WAM for token",
        request.correlationId
    );

    if (!platformAuthProvider) {
        throw createBrowserAuthError(
            BrowserAuthErrorCodes.nativeConnectionNotEstablished,
            request.correlationId
        );
    }
    const browserCrypto = new CryptoOps(logger, performanceClient);
    const nativeInteractionClient = new PlatformAuthInteractionClient(
        config,
        browserStorage,
        browserCrypto,
        logger,
        eventHandler,
        config.system.navigationClient,
        apiId,
        performanceClient,
        platformAuthProvider,
        accountId,
        nativeStorage,
        request.correlationId
    );
    const { userRequestState } = ProtocolUtils.parseRequestState(
        browserCrypto.base64Decode,
        request.state,
        request.correlationId
    );
    return invokeAsync(
        nativeInteractionClient.acquireToken.bind(nativeInteractionClient),
        BrowserPerformanceEvents.NativeInteractionClientAcquireToken,
        logger,
        performanceClient,
        request.correlationId
    )({
        ...request,
        state: userRequestState,
        prompt: undefined, // Server should handle the prompt, ideally native broker can do this part silently
    });
}

/**
 * Response handler when server returns code on the /authorize request
 * @param request
 * @param response
 * @param codeVerifier
 * @param authClient
 * @param browserStorage
 * @param logger
 * @param performanceClient
 * @returns
 */
export async function handleResponseCode(
    request: CommonAuthorizationUrlRequest,
    response: AuthorizeResponse,
    codeVerifier: string,
    apiId: ApiId,
    config: BrowserConfiguration,
    authClient: AuthorizationCodeClient,
    browserStorage: BrowserCacheManager,
    nativeStorage: BrowserCacheManager,
    eventHandler: EventHandler,
    logger: Logger,
    performanceClient: IPerformanceClient,
    platformAuthProvider?: IPlatformAuthHandler
): Promise<AuthenticationResult> {
    // Remove throttle if it exists
    ThrottlingUtils.removeThrottle(
        browserStorage,
        config.auth.clientId,
        request
    );

    // Instrument clientdata telemetry fields from the authorize response
    instrumentClientData(response, request.correlationId, performanceClient);

    if (response.accountId) {
        return invokeAsync(
            handleResponsePlatformBroker,
            BrowserPerformanceEvents.HandleResponsePlatformBroker,
            logger,
            performanceClient,
            request.correlationId
        )(
            request,
            response.accountId,
            apiId,
            config,
            browserStorage,
            nativeStorage,
            eventHandler,
            logger,
            performanceClient,
            platformAuthProvider
        );
    }
    const authCodeRequest: CommonAuthorizationCodeRequest = {
        ...request,
        code: response.code || "",
        codeVerifier: codeVerifier,
    };
    // Create popup interaction handler.
    const interactionHandler = new InteractionHandler(
        authClient,
        browserStorage,
        authCodeRequest,
        logger,
        performanceClient
    );
    // Handle response from hash string.
    const result = await invokeAsync(
        interactionHandler.handleCodeResponse.bind(interactionHandler),
        BrowserPerformanceEvents.HandleCodeResponse,
        logger,
        performanceClient,
        request.correlationId
    )(response, request, apiId);

    return result;
}

/**
 * Response handler when server returns ear_jwe on the /authorize request
 * @param request
 * @param response
 * @param apiId
 * @param config
 * @param authority
 * @param browserStorage
 * @param nativeStorage
 * @param eventHandler
 * @param logger
 * @param performanceClient
 * @param nativeMessageHandler
 * @returns
 */
export async function handleResponseEAR(
    request: CommonAuthorizationUrlRequest,
    response: AuthorizeResponse,
    apiId: ApiId,
    config: BrowserConfiguration,
    authority: Authority,
    browserStorage: BrowserCacheManager,
    nativeStorage: BrowserCacheManager,
    eventHandler: EventHandler,
    logger: Logger,
    performanceClient: IPerformanceClient,
    platformAuthProvider?: IPlatformAuthHandler
): Promise<AuthenticationResult> {
    // Remove throttle if it exists
    ThrottlingUtils.removeThrottle(
        browserStorage,
        config.auth.clientId,
        request
    );

    // Instrument clientdata telemetry fields from the authorize response
    instrumentClientData(response, request.correlationId, performanceClient);

    // Validate state & check response for errors
    AuthorizeProtocol.validateAuthorizationResponse(
        response,
        request.state,
        request.correlationId
    );

    if (!response.ear_jwe) {
        throw createBrowserAuthError(
            BrowserAuthErrorCodes.earJweEmpty,
            request.correlationId
        );
    }

    if (!request.earJwk) {
        throw createBrowserAuthError(
            BrowserAuthErrorCodes.earJwkEmpty,
            request.correlationId
        );
    }

    const decryptedData = JSON.parse(
        await invokeAsync(
            decryptEarResponse,
            BrowserPerformanceEvents.DecryptEarResponse,
            logger,
            performanceClient,
            request.correlationId
        )(request.earJwk, response.ear_jwe)
    ) as AuthorizeResponse & ServerAuthorizationTokenResponse;

    if (decryptedData.accountId) {
        return invokeAsync(
            handleResponsePlatformBroker,
            BrowserPerformanceEvents.HandleResponsePlatformBroker,
            logger,
            performanceClient,
            request.correlationId
        )(
            request,
            decryptedData.accountId,
            apiId,
            config,
            browserStorage,
            nativeStorage,
            eventHandler,
            logger,
            performanceClient,
            platformAuthProvider
        );
    }

    const responseHandler = new ResponseHandler(
        config.auth.clientId,
        browserStorage,
        new CryptoOps(logger, performanceClient),
        logger,
        performanceClient,
        null,
        null
    );

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

    // Temporary until response handler is refactored to be more flow agnostic.
    const additionalData: AuthorizationCodePayload = {
        code: "",
        state: request.state,
        nonce: request.nonce,
        client_info: decryptedData.client_info,
        cloud_graph_host_name: decryptedData.cloud_graph_host_name,
        cloud_instance_host_name: decryptedData.cloud_instance_host_name,
        cloud_instance_name: decryptedData.cloud_instance_name,
        msgraph_host: decryptedData.msgraph_host,
    };

    return (await invokeAsync(
        responseHandler.handleServerTokenResponse.bind(responseHandler),
        PerformanceEvents.HandleServerTokenResponse,
        logger,
        performanceClient,
        request.correlationId
    )(
        decryptedData,
        authority,
        TimeUtils.nowSeconds(),
        request,
        apiId,
        additionalData,
        undefined,
        undefined,
        undefined,
        undefined
    )) as AuthenticationResult;
}
