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

import {
    CommonAuthorizationUrlRequest,
    PerformanceCallbackFunction,
    AccountInfo,
    Logger,
    ICrypto,
    IPerformanceClient,
    DEFAULT_CRYPTO_IMPLEMENTATION,
    TimeUtils,
    buildStaticAuthorityOptions,
    Constants,
    BaseAuthRequest,
    AccountFilter,
    AuthError,
    AccountEntityUtils,
    AuthToken,
    enforceResourceParameter,
} from "@azure/msal-common/browser";
import * as RootPerformanceEvents from "../telemetry/BrowserRootPerformanceEvents.js";
import { BrowserConfiguration } from "../config/Configuration.js";
import { INavigationClient } from "../navigation/INavigationClient.js";
import { AuthorizationCodeRequest } from "../request/AuthorizationCodeRequest.js";
import { EndSessionPopupRequest } from "../request/EndSessionPopupRequest.js";
import { EndSessionRequest } from "../request/EndSessionRequest.js";
import { PopupRequest } from "../request/PopupRequest.js";
import { RedirectRequest } from "../request/RedirectRequest.js";
import { SilentRequest } from "../request/SilentRequest.js";
import { SsoSilentRequest } from "../request/SsoSilentRequest.js";
import {
    WrapperSKU,
    InteractionType,
    DEFAULT_REQUEST,
    CacheLookupPolicy,
    ApiId,
} from "../utils/BrowserConstants.js";
import { IController } from "./IController.js";
import { NestedAppOperatingContext } from "../operatingcontext/NestedAppOperatingContext.js";
import { IBridgeProxy } from "../naa/IBridgeProxy.js";
import { CryptoOps } from "../crypto/CryptoOps.js";
import { NestedAppAuthAdapter } from "../naa/mapping/NestedAppAuthAdapter.js";
import { NestedAppAuthError } from "../error/NestedAppAuthError.js";
import { EventHandler } from "../event/EventHandler.js";
import { EventType } from "../event/EventType.js";
import { EventCallbackFunction, EventError } from "../event/EventMessage.js";
import { AuthenticationResult } from "../response/AuthenticationResult.js";
import {
    BrowserCacheManager,
    DEFAULT_BROWSER_CACHE_MANAGER,
} from "../cache/BrowserCacheManager.js";
import { ClearCacheRequest } from "../request/ClearCacheRequest.js";
import * as AccountManager from "../cache/AccountManager.js";
import { AccountContext } from "../naa/BridgeAccountContext.js";
import { InitializeApplicationRequest } from "../request/InitializeApplicationRequest.js";
import { createNewGuid } from "../crypto/BrowserCrypto.js";
import { HandleRedirectPromiseOptions } from "../request/HandleRedirectPromiseOptions.js";

export class NestedAppAuthController implements IController {
    // OperatingContext
    protected readonly operatingContext: NestedAppOperatingContext;

    // BridgeProxy
    protected readonly bridgeProxy: IBridgeProxy;

    // Crypto interface implementation
    protected readonly browserCrypto: ICrypto;

    // Input configuration by developer/user
    protected readonly config: BrowserConfiguration;

    // Storage interface implementation
    protected readonly browserStorage!: BrowserCacheManager;

    // Logger
    protected logger: Logger;

    // Performance telemetry client
    protected readonly performanceClient: IPerformanceClient;

    // EventHandler
    protected readonly eventHandler: EventHandler;

    // NestedAppAuthAdapter
    protected readonly nestedAppAuthAdapter: NestedAppAuthAdapter;

    // currentAccount for NAA apps
    protected currentAccountContext: AccountContext | null;

    constructor(operatingContext: NestedAppOperatingContext) {
        this.operatingContext = operatingContext;
        const proxy = this.operatingContext.getBridgeProxy();
        if (proxy !== undefined) {
            this.bridgeProxy = proxy;
        } else {
            throw new Error("unexpected: bridgeProxy is undefined");
        }

        // Set the configuration.
        this.config = operatingContext.getConfig();

        // Initialize logger
        this.logger = this.operatingContext.getLogger();

        // Initialize performance client
        this.performanceClient = this.config.telemetry.client;

        // Initialize the crypto class.
        this.browserCrypto = operatingContext.isBrowserEnvironment()
            ? new CryptoOps(this.logger, this.performanceClient, true)
            : DEFAULT_CRYPTO_IMPLEMENTATION;

        this.eventHandler = new EventHandler(this.logger);
        // Initialize the browser storage class.
        this.browserStorage = this.operatingContext.isBrowserEnvironment()
            ? new BrowserCacheManager(
                  this.config.auth.clientId,
                  this.config.cache,
                  this.browserCrypto,
                  this.logger,
                  this.performanceClient,
                  this.eventHandler,
                  buildStaticAuthorityOptions(this.config.auth)
              )
            : DEFAULT_BROWSER_CACHE_MANAGER(
                  this.config.auth.clientId,
                  this.logger,
                  this.performanceClient,
                  this.eventHandler
              );

        this.nestedAppAuthAdapter = new NestedAppAuthAdapter(
            this.config.auth.clientId,
            this.config.auth.clientCapabilities,
            this.browserCrypto,
            this.logger
        );

        // Set the active account if available
        const accountContext = this.bridgeProxy.getAccountContext();
        this.currentAccountContext = accountContext ? accountContext : null;
    }

    /**
     * Factory function to create a new instance of NestedAppAuthController
     * @param operatingContext
     * @returns Promise<IController>
     */
    static async createController(
        operatingContext: NestedAppOperatingContext
    ): Promise<IController> {
        const controller = new NestedAppAuthController(operatingContext);
        return Promise.resolve(controller);
    }

    /**
     * Specific implementation of initialize function for NestedAppAuthController
     * @returns
     */
    async initialize(
        request?: InitializeApplicationRequest,
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        isBroker?: boolean
    ): Promise<void> {
        const initCorrelationId = request?.correlationId || createNewGuid();
        await this.browserStorage.initialize(initCorrelationId);
        return Promise.resolve();
    }

    /**
     * Validate the incoming request and add correlationId if not present
     * @param request
     * @returns
     */
    private ensureValidRequest<
        T extends
            | SsoSilentRequest
            | SilentRequest
            | PopupRequest
            | RedirectRequest
    >(request: T): T {
        if (request?.correlationId) {
            return request;
        }
        return {
            ...request,
            correlationId: this.browserCrypto.createNewGuid(),
        };
    }

    /**
     * Internal implementation of acquireTokenInteractive flow
     * @param request
     * @returns
     */
    private async acquireTokenInteractive(
        request: PopupRequest | RedirectRequest
    ): Promise<AuthenticationResult> {
        const validRequest = this.ensureValidRequest(request);
        const correlationId = validRequest.correlationId || createNewGuid();

        this.eventHandler.emitEvent(
            EventType.ACQUIRE_TOKEN_START,
            correlationId,
            InteractionType.Popup,
            validRequest
        );

        const atPopupMeasurement = this.performanceClient.startMeasurement(
            RootPerformanceEvents.AcquireTokenPopup,
            correlationId
        );

        atPopupMeasurement.add({ nestedAppAuthRequest: true });

        try {
            enforceResourceParameter(this.config.auth.isMcp, validRequest);
            const naaRequest =
                this.nestedAppAuthAdapter.toNaaTokenRequest(validRequest);
            const reqTimestamp = TimeUtils.nowSeconds();
            const response = await this.bridgeProxy.getTokenInteractive(
                naaRequest
            );
            const result: AuthenticationResult = {
                ...this.nestedAppAuthAdapter.fromNaaTokenResponse(
                    naaRequest,
                    response,
                    reqTimestamp
                ),
            };

            // cache the tokens in the response
            try {
                // cache hydration can fail in JS Runtime scenario that doesn't support full crypto API
                await this.hydrateCache(result, request);
            } catch (error) {
                this.logger.warningPii(
                    `Failed to hydrate cache. Error: ${error}`,
                    correlationId
                );
            }

            // cache the account context in memory after successful token fetch
            this.currentAccountContext = {
                homeAccountId: result.account.homeAccountId,
                environment: result.account.environment,
                tenantId: result.account.tenantId,
            };

            this.eventHandler.emitEvent(
                EventType.ACQUIRE_TOKEN_SUCCESS,
                correlationId,
                InteractionType.Popup,
                result
            );

            atPopupMeasurement.add({
                accessTokenSize: result.accessToken.length,
                idTokenSize: result.idToken.length,
            });

            atPopupMeasurement.end(
                {
                    success: true,
                    requestId: result.requestId,
                },
                undefined,
                result.account
            );

            return result;
        } catch (e) {
            const error =
                e instanceof AuthError
                    ? e
                    : this.nestedAppAuthAdapter.fromBridgeError(e);
            this.eventHandler.emitEvent(
                EventType.ACQUIRE_TOKEN_FAILURE,
                correlationId,
                InteractionType.Popup,
                null,
                e as EventError
            );

            atPopupMeasurement.end(
                {
                    success: false,
                },
                e,
                request.account
            );

            throw error;
        }
    }

    /**
     * Internal implementation of acquireTokenSilent flow
     * @param request
     * @returns
     */
    private async acquireTokenSilentInternal(
        request: SilentRequest
    ): Promise<AuthenticationResult> {
        const validRequest = this.ensureValidRequest(request);
        const correlationId = validRequest.correlationId || createNewGuid();
        this.eventHandler.emitEvent(
            EventType.ACQUIRE_TOKEN_START,
            correlationId,
            InteractionType.Silent,
            validRequest
        );

        // Look for tokens in the cache first
        const result = await this.acquireTokenFromCache(validRequest);
        if (result) {
            this.eventHandler.emitEvent(
                EventType.ACQUIRE_TOKEN_SUCCESS,
                correlationId,
                InteractionType.Silent,
                result
            );
            return result;
        }

        // proceed with acquiring tokens via the host
        const ssoSilentMeasurement = this.performanceClient.startMeasurement(
            RootPerformanceEvents.SsoSilent,
            correlationId
        );

        ssoSilentMeasurement.increment({
            visibilityChangeCount: 0,
        });
        ssoSilentMeasurement.add({
            nestedAppAuthRequest: true,
        });

        try {
            enforceResourceParameter(this.config.auth.isMcp, validRequest);
            const naaRequest =
                this.nestedAppAuthAdapter.toNaaTokenRequest(validRequest);
            naaRequest.forceRefresh = validRequest.forceRefresh;
            const reqTimestamp = TimeUtils.nowSeconds();
            const response = await this.bridgeProxy.getTokenSilent(naaRequest);

            const result: AuthenticationResult =
                this.nestedAppAuthAdapter.fromNaaTokenResponse(
                    naaRequest,
                    response,
                    reqTimestamp
                );

            // cache the tokens in the response
            try {
                // cache hydration can fail in JS Runtime scenario that doesn't support full crypto API
                await this.hydrateCache(result, request);
            } catch (error) {
                this.logger.warningPii(
                    `Failed to hydrate cache. Error: ${error}`,
                    correlationId
                );
            }

            // cache the account context in memory after successful token fetch
            this.currentAccountContext = {
                homeAccountId: result.account.homeAccountId,
                environment: result.account.environment,
                tenantId: result.account.tenantId,
            };

            this.eventHandler.emitEvent(
                EventType.ACQUIRE_TOKEN_SUCCESS,
                correlationId,
                InteractionType.Silent,
                result
            );
            ssoSilentMeasurement?.add({
                accessTokenSize: result.accessToken.length,
                idTokenSize: result.idToken.length,
            });
            ssoSilentMeasurement?.end(
                {
                    success: true,
                    requestId: result.requestId,
                },
                undefined,
                result.account
            );
            return result;
        } catch (e) {
            const error =
                e instanceof AuthError
                    ? e
                    : this.nestedAppAuthAdapter.fromBridgeError(e);
            this.eventHandler.emitEvent(
                EventType.ACQUIRE_TOKEN_FAILURE,
                correlationId,
                InteractionType.Silent,
                null,
                e as EventError
            );
            ssoSilentMeasurement?.end(
                {
                    success: false,
                },
                e,
                request.account
            );
            throw error;
        }
    }

    /**
     * acquires tokens from cache
     * @param request
     * @returns
     */
    private async acquireTokenFromCache(
        request: SilentRequest
    ): Promise<AuthenticationResult | null> {
        const correlationId = request.correlationId || createNewGuid();
        const atsMeasurement = this.performanceClient.startMeasurement(
            RootPerformanceEvents.AcquireTokenSilent,
            correlationId
        );

        atsMeasurement?.add({
            nestedAppAuthRequest: true,
        });

        // if the request has claims, we cannot look up in the cache
        if (request.claims) {
            this.logger.verbose(
                "Claims are present in the request, skipping cache lookup",
                correlationId
            );
            return null;
        }

        // if the request has forceRefresh, we cannot look up in the cache
        if (request.forceRefresh) {
            this.logger.verbose(
                "forceRefresh is set to true, skipping cache lookup",
                correlationId
            );
            return null;
        }

        // respect cache lookup policy
        let result: AuthenticationResult | null = null;
        if (!request.cacheLookupPolicy) {
            request.cacheLookupPolicy = CacheLookupPolicy.Default;
        }

        switch (request.cacheLookupPolicy) {
            case CacheLookupPolicy.Default:
            case CacheLookupPolicy.AccessToken:
            case CacheLookupPolicy.AccessTokenAndRefreshToken:
                result = await this.acquireTokenFromCacheInternal(request);
                break;
            default:
                return null;
        }

        if (result) {
            this.eventHandler.emitEvent(
                EventType.ACQUIRE_TOKEN_SUCCESS,
                correlationId,
                InteractionType.Silent,
                result
            );
            atsMeasurement.add({
                accessTokenSize: result.accessToken.length,
                idTokenSize: result.idToken.length,
            });
            atsMeasurement.end(
                {
                    success: true,
                },
                undefined,
                result.account
            );
            return result;
        }

        this.logger.warning(
            "Cached tokens are not found for the account, proceeding with silent token request.",
            correlationId
        );

        this.eventHandler.emitEvent(
            EventType.ACQUIRE_TOKEN_FAILURE,
            correlationId,
            InteractionType.Silent,
            null
        );
        atsMeasurement.end(
            {
                success: false,
            },
            undefined,
            request.account
        );

        return null;
    }

    /**
     *
     * @param request
     * @returns
     */
    private async acquireTokenFromCacheInternal(
        request: SilentRequest
    ): Promise<AuthenticationResult | null> {
        // always prioritize the account context from the bridge
        const accountContext =
            this.bridgeProxy.getAccountContext() || this.currentAccountContext;
        const correlationId = request.correlationId || createNewGuid();
        let currentAccount: AccountInfo | null = null;
        if (accountContext) {
            currentAccount = AccountManager.getAccount(
                accountContext,
                this.logger,
                this.browserStorage,
                correlationId
            );
        }

        // fall back to brokering if no cached account is found
        if (!currentAccount) {
            this.logger.verbose(
                "No active account found, falling back to the host",
                correlationId
            );
            return Promise.resolve(null);
        }

        this.logger.verbose(
            "active account found, attempting to acquire token silently",
            correlationId
        );

        const authRequest: BaseAuthRequest = {
            ...request,
            correlationId: correlationId,
            authority: request.authority || currentAccount.environment,
            scopes: request.scopes?.length
                ? request.scopes
                : [...Constants.OIDC_DEFAULT_SCOPES],
        };

        // fetch access token and check for expiry
        const tokenKeys = this.browserStorage.getTokenKeys();
        const cachedAccessToken = this.browserStorage.getAccessToken(
            currentAccount,
            authRequest,
            tokenKeys,
            currentAccount.tenantId
        );

        // If there is no access token, log it and return null
        if (!cachedAccessToken) {
            this.logger.verbose("No cached access token found", correlationId);
            return Promise.resolve(null);
        } else if (
            TimeUtils.wasClockTurnedBack(cachedAccessToken.cachedAt) ||
            TimeUtils.isTokenExpired(
                cachedAccessToken.expiresOn,
                this.config.system.tokenRenewalOffsetSeconds
            )
        ) {
            this.logger.verbose(
                "Cached access token has expired",
                correlationId
            );
            return Promise.resolve(null);
        } else if (authRequest.resource) {
            const requestedResource = authRequest.resource;
            const cachedResource = cachedAccessToken.resource;

            if (!cachedResource || cachedResource !== requestedResource) {
                this.logger.verbose(
                    "Cached access token resource does not match requested resource for MCP flow",
                    correlationId
                );
                return Promise.resolve(null);
            }
        }

        const cachedIdToken = this.browserStorage.getIdToken(
            currentAccount,
            authRequest.correlationId,
            tokenKeys,
            currentAccount.tenantId
        );

        if (!cachedIdToken) {
            this.logger.verbose("No cached id token found", correlationId);
            return Promise.resolve(null);
        }

        return this.nestedAppAuthAdapter.toAuthenticationResultFromCache(
            currentAccount,
            cachedIdToken,
            cachedAccessToken,
            authRequest,
            authRequest.correlationId
        );
    }

    /**
     * acquireTokenPopup flow implementation
     * @param request
     * @returns
     */
    async acquireTokenPopup(
        request: PopupRequest
    ): Promise<AuthenticationResult> {
        return this.acquireTokenInteractive(request);
    }

    /**
     * acquireTokenRedirect flow is not supported in nested app auth
     * @param request
     */
    acquireTokenRedirect(request: RedirectRequest): Promise<void> {
        throw NestedAppAuthError.createUnsupportedError(request?.correlationId);
    }

    /**
     * acquireTokenSilent flow implementation
     * @param silentRequest
     * @returns
     */
    async acquireTokenSilent(
        silentRequest: SilentRequest
    ): Promise<AuthenticationResult> {
        return this.acquireTokenSilentInternal(silentRequest);
    }

    /**
     * Hybrid flow is not currently supported in nested app auth
     * @param request
     */
    acquireTokenByCode(
        request: AuthorizationCodeRequest
    ): Promise<AuthenticationResult> {
        throw NestedAppAuthError.createUnsupportedError(request?.correlationId);
    }

    /**
     * Adds event callbacks to array
     * @param callback
     * @param eventTypes
     */
    addEventCallback(
        callback: EventCallbackFunction,
        eventTypes?: Array<EventType>
    ): string | null {
        return this.eventHandler.addEventCallback(callback, eventTypes);
    }

    /**
     * Removes callback with provided id from callback array
     * @param callbackId
     */
    removeEventCallback(callbackId: string): void {
        this.eventHandler.removeEventCallback(callbackId);
    }

    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    addPerformanceCallback(callback: PerformanceCallbackFunction): string {
        // No request context: callback registration is not per-request
        throw NestedAppAuthError.createUnsupportedError();
    }

    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    removePerformanceCallback(callbackId: string): boolean {
        // No request context: callback registration is not per-request
        throw NestedAppAuthError.createUnsupportedError();
    }

    // #region Account APIs

    /**
     * Returns all the accounts in the cache that match the optional filter. If no filter is provided, all accounts are returned.
     * @param accountFilter - (Optional) filter to narrow down the accounts returned
     * @returns Array of AccountInfo objects in cache
     */
    getAllAccounts(accountFilter?: AccountFilter): AccountInfo[] {
        return AccountManager.getAllAccounts(
            this.logger,
            this.browserStorage,
            this.isBrowserEnv(),
            createNewGuid(),
            accountFilter
        );
    }

    /**
     * Returns the first account found in the cache that matches the account filter passed in.
     * @param accountFilter
     * @returns The first account found in the cache matching the provided filter or null if no account could be found.
     */
    getAccount(accountFilter: AccountFilter): AccountInfo | null {
        return AccountManager.getAccount(
            accountFilter,
            this.logger,
            this.browserStorage,
            createNewGuid()
        );
    }

    /**
     * Sets the account to use as the active account. If no account is passed to the acquireToken APIs, then MSAL will use this active account.
     * @param account
     */
    setActiveAccount(account: AccountInfo | null): void {
        /*
         * StandardController uses this to allow the developer to set the active account
         * in the nested app auth scenario the active account is controlled by the app hosting the nested app
         */
        return AccountManager.setActiveAccount(
            account,
            this.browserStorage,
            createNewGuid()
        );
    }

    /**
     * Gets the currently active account
     */
    getActiveAccount(): AccountInfo | null {
        return AccountManager.getActiveAccount(
            this.browserStorage,
            createNewGuid()
        );
    }

    // #endregion

    handleRedirectPromise(
        options?: HandleRedirectPromiseOptions // eslint-disable-line @typescript-eslint/no-unused-vars
    ): Promise<AuthenticationResult | null> {
        return Promise.resolve(null);
    }
    loginPopup(
        request?: PopupRequest | undefined // eslint-disable-line @typescript-eslint/no-unused-vars
    ): Promise<AuthenticationResult> {
        return this.acquireTokenInteractive(request || DEFAULT_REQUEST);
    }
    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    loginRedirect(request?: RedirectRequest | undefined): Promise<void> {
        throw NestedAppAuthError.createUnsupportedError(request?.correlationId);
    }
    logoutRedirect(
        logoutRequest?: EndSessionRequest | undefined
    ): Promise<void> {
        throw NestedAppAuthError.createUnsupportedError(
            logoutRequest?.correlationId
        );
    }
    logoutPopup(
        logoutRequest?: EndSessionPopupRequest | undefined
    ): Promise<void> {
        throw NestedAppAuthError.createUnsupportedError(
            logoutRequest?.correlationId
        );
    }
    ssoSilent(
        // eslint-disable-next-line @typescript-eslint/no-unused-vars
        request: Partial<
            Omit<
                CommonAuthorizationUrlRequest,
                | "responseMode"
                | "earJwk"
                | "codeChallenge"
                | "codeChallengeMethod"
                | "platformBroker"
            >
        >
    ): Promise<AuthenticationResult> {
        return this.acquireTokenSilentInternal(request as SilentRequest);
    }

    /**
     * Returns the logger instance
     */
    public getLogger(): Logger {
        return this.logger;
    }

    /**
     * Replaces the default logger set in configurations with new Logger with new configurations
     * @param logger Logger instance
     */
    setLogger(logger: Logger): void {
        this.logger = logger;
    }

    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    initializeWrapperLibrary(sku: WrapperSKU, version: string): void {
        /*
         * Standard controller uses this to set the sku and version of the wrapper library in the storage
         * we do nothing here
         */
        return;
    }

    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    setNavigationClient(navigationClient: INavigationClient): void {
        this.logger.warning(
            "setNavigationClient is not supported in nested app auth",
            ""
        );
    }

    getConfiguration(): BrowserConfiguration {
        return this.config;
    }

    isBrowserEnv(): boolean {
        return this.operatingContext.isBrowserEnvironment();
    }

    getBrowserCrypto(): ICrypto {
        return this.browserCrypto;
    }

    getPerformanceClient(): IPerformanceClient {
        // Accessor stub with no request context
        throw NestedAppAuthError.createUnsupportedError();
    }

    getRedirectResponse(): Map<string, Promise<AuthenticationResult | null>> {
        // Accessor stub with no request context
        throw NestedAppAuthError.createUnsupportedError();
    }

    // eslint-disable-next-line @typescript-eslint/no-unused-vars
    async clearCache(logoutRequest?: ClearCacheRequest): Promise<void> {
        throw NestedAppAuthError.createUnsupportedError(
            logoutRequest?.correlationId
        );
    }

    async hydrateCache(
        result: AuthenticationResult,
        request:
            | SilentRequest
            | SsoSilentRequest
            | RedirectRequest
            | PopupRequest
    ): Promise<void> {
        this.logger.verbose("hydrateCache called", result.correlationId);

        const accountEntity =
            AccountEntityUtils.createAccountEntityFromAccountInfo(
                result.account,
                result.cloudGraphHostName,
                result.msGraphHost
            );
        await this.browserStorage.setAccount(
            accountEntity,
            result.correlationId,
            AuthToken.isKmsi(result.idTokenClaims),
            ApiId.hydrateCache
        );
        return this.browserStorage.hydrateCache(result, request);
    }
}
