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

import {
    AccessTokenEntity,
    ICrypto,
    IdTokenEntity,
    Logger,
    ScopeSet,
    Authority,
    AuthorityOptions,
    ExternalTokenResponse,
    AccountEntity,
    AuthToken,
    RefreshTokenEntity,
    CacheRecord,
    TokenClaims,
    CacheHelpers,
    buildAccountToCache,
    TimeUtils,
} from "@azure/msal-common/browser";
import { BrowserConfiguration } from "../config/Configuration.js";
import type { SilentRequest } from "../request/SilentRequest.js";
import { BrowserCacheManager } from "./BrowserCacheManager.js";
import type { ITokenCache } from "./ITokenCache.js";
import {
    createBrowserAuthError,
    BrowserAuthErrorCodes,
} from "../error/BrowserAuthError.js";
import type { AuthenticationResult } from "../response/AuthenticationResult.js";
import { base64Decode } from "../encode/Base64Decode.js";
import * as BrowserCrypto from "../crypto/BrowserCrypto.js";

export type LoadTokenOptions = {
    clientInfo?: string;
    expiresOn?: number;
    extendedExpiresOn?: number;
};

/**
 * Token cache manager
 */
export class TokenCache implements ITokenCache {
    // Flag to indicate if in browser environment
    public isBrowserEnvironment: boolean;
    // Input configuration by developer/user
    protected config: BrowserConfiguration;
    // Browser cache storage
    private storage: BrowserCacheManager;
    // Logger
    private logger: Logger;
    // Crypto class
    private cryptoObj: ICrypto;

    constructor(
        configuration: BrowserConfiguration,
        storage: BrowserCacheManager,
        logger: Logger,
        cryptoObj: ICrypto
    ) {
        this.isBrowserEnvironment = typeof window !== "undefined";
        this.config = configuration;
        this.storage = storage;
        this.logger = logger;
        this.cryptoObj = cryptoObj;
    }

    // Move getAllAccounts here and cache utility APIs

    /**
     * API to load tokens to msal-browser cache.
     * @param request
     * @param response
     * @param options
     * @returns `AuthenticationResult` for the response that was loaded.
     */
    async loadExternalTokens(
        request: SilentRequest,
        response: ExternalTokenResponse,
        options: LoadTokenOptions
    ): Promise<AuthenticationResult> {
        if (!this.isBrowserEnvironment) {
            throw createBrowserAuthError(
                BrowserAuthErrorCodes.nonBrowserEnvironment
            );
        }

        const correlationId =
            request.correlationId || BrowserCrypto.createNewGuid();

        const idTokenClaims = response.id_token
            ? AuthToken.extractTokenClaims(response.id_token, base64Decode)
            : undefined;

        const authorityOptions: AuthorityOptions = {
            protocolMode: this.config.auth.protocolMode,
            knownAuthorities: this.config.auth.knownAuthorities,
            cloudDiscoveryMetadata: this.config.auth.cloudDiscoveryMetadata,
            authorityMetadata: this.config.auth.authorityMetadata,
            skipAuthorityMetadataCache:
                this.config.auth.skipAuthorityMetadataCache,
        };
        const authority = request.authority
            ? new Authority(
                  Authority.generateAuthority(
                      request.authority,
                      request.azureCloudOptions
                  ),
                  this.config.system.networkClient,
                  this.storage,
                  authorityOptions,
                  this.logger,
                  request.correlationId || BrowserCrypto.createNewGuid()
              )
            : undefined;

        const cacheRecordAccount: AccountEntity = await this.loadAccount(
            request,
            options.clientInfo || response.client_info || "",
            correlationId,
            idTokenClaims,
            authority
        );

        const idToken = await this.loadIdToken(
            response,
            cacheRecordAccount.homeAccountId,
            cacheRecordAccount.environment,
            cacheRecordAccount.realm,
            correlationId
        );

        const accessToken = await this.loadAccessToken(
            request,
            response,
            cacheRecordAccount.homeAccountId,
            cacheRecordAccount.environment,
            cacheRecordAccount.realm,
            options,
            correlationId
        );

        const refreshToken = await this.loadRefreshToken(
            response,
            cacheRecordAccount.homeAccountId,
            cacheRecordAccount.environment,
            correlationId
        );

        return this.generateAuthenticationResult(
            request,
            {
                account: cacheRecordAccount,
                idToken,
                accessToken,
                refreshToken,
            },
            idTokenClaims,
            authority
        );
    }

    /**
     * Helper function to load account to msal-browser cache
     * @param idToken
     * @param environment
     * @param clientInfo
     * @param authorityType
     * @param requestHomeAccountId
     * @returns `AccountEntity`
     */
    private async loadAccount(
        request: SilentRequest,
        clientInfo: string,
        correlationId: string,
        idTokenClaims?: TokenClaims,
        authority?: Authority
    ): Promise<AccountEntity> {
        this.logger.verbose("TokenCache - loading account");

        if (request.account) {
            const accountEntity = AccountEntity.createFromAccountInfo(
                request.account
            );
            await this.storage.setAccount(accountEntity, correlationId);
            return accountEntity;
        } else if (!authority || (!clientInfo && !idTokenClaims)) {
            this.logger.error(
                "TokenCache - if an account is not provided on the request, authority and either clientInfo or idToken must be provided instead."
            );
            throw createBrowserAuthError(
                BrowserAuthErrorCodes.unableToLoadToken
            );
        }

        const homeAccountId = AccountEntity.generateHomeAccountId(
            clientInfo,
            authority.authorityType,
            this.logger,
            this.cryptoObj,
            idTokenClaims
        );

        const claimsTenantId = idTokenClaims?.tid;

        const cachedAccount = buildAccountToCache(
            this.storage,
            authority,
            homeAccountId,
            base64Decode,
            correlationId,
            idTokenClaims,
            clientInfo,
            authority.hostnameAndPort,
            claimsTenantId,
            undefined, // authCodePayload
            undefined, // nativeAccountId
            this.logger
        );

        await this.storage.setAccount(cachedAccount, correlationId);
        return cachedAccount;
    }

    /**
     * Helper function to load id tokens to msal-browser cache
     * @param idToken
     * @param homeAccountId
     * @param environment
     * @param tenantId
     * @returns `IdTokenEntity`
     */
    private async loadIdToken(
        response: ExternalTokenResponse,
        homeAccountId: string,
        environment: string,
        tenantId: string,
        correlationId: string
    ): Promise<IdTokenEntity | null> {
        if (!response.id_token) {
            this.logger.verbose("TokenCache - no id token found in response");
            return null;
        }

        this.logger.verbose("TokenCache - loading id token");
        const idTokenEntity = CacheHelpers.createIdTokenEntity(
            homeAccountId,
            environment,
            response.id_token,
            this.config.auth.clientId,
            tenantId
        );

        await this.storage.setIdTokenCredential(idTokenEntity, correlationId);
        return idTokenEntity;
    }

    /**
     * Helper function to load access tokens to msal-browser cache
     * @param request
     * @param response
     * @param homeAccountId
     * @param environment
     * @param tenantId
     * @returns `AccessTokenEntity`
     */
    private async loadAccessToken(
        request: SilentRequest,
        response: ExternalTokenResponse,
        homeAccountId: string,
        environment: string,
        tenantId: string,
        options: LoadTokenOptions,
        correlationId: string
    ): Promise<AccessTokenEntity | null> {
        if (!response.access_token) {
            this.logger.verbose(
                "TokenCache - no access token found in response"
            );
            return null;
        } else if (!response.expires_in) {
            this.logger.error(
                "TokenCache - no expiration set on the access token. Cannot add it to the cache."
            );
            return null;
        } else if (
            !response.scope &&
            (!request.scopes || !request.scopes.length)
        ) {
            this.logger.error(
                "TokenCache - scopes not specified in the request or response. Cannot add token to the cache."
            );
            return null;
        }

        this.logger.verbose("TokenCache - loading access token");

        const scopes = response.scope
            ? ScopeSet.fromString(response.scope)
            : new ScopeSet(request.scopes);
        const expiresOn =
            options.expiresOn || response.expires_in + TimeUtils.nowSeconds();

        const extendedExpiresOn =
            options.extendedExpiresOn ||
            (response.ext_expires_in || response.expires_in) +
                TimeUtils.nowSeconds();

        const accessTokenEntity = CacheHelpers.createAccessTokenEntity(
            homeAccountId,
            environment,
            response.access_token,
            this.config.auth.clientId,
            tenantId,
            scopes.printScopes(),
            expiresOn,
            extendedExpiresOn,
            base64Decode
        );

        await this.storage.setAccessTokenCredential(
            accessTokenEntity,
            correlationId
        );
        return accessTokenEntity;
    }

    /**
     * Helper function to load refresh tokens to msal-browser cache
     * @param request
     * @param response
     * @param homeAccountId
     * @param environment
     * @returns `RefreshTokenEntity`
     */
    private async loadRefreshToken(
        response: ExternalTokenResponse,
        homeAccountId: string,
        environment: string,
        correlationId: string
    ): Promise<RefreshTokenEntity | null> {
        if (!response.refresh_token) {
            this.logger.verbose(
                "TokenCache - no refresh token found in response"
            );
            return null;
        }

        this.logger.verbose("TokenCache - loading refresh token");
        const refreshTokenEntity = CacheHelpers.createRefreshTokenEntity(
            homeAccountId,
            environment,
            response.refresh_token,
            this.config.auth.clientId,
            response.foci,
            undefined, // userAssertionHash
            response.refresh_token_expires_in
        );

        await this.storage.setRefreshTokenCredential(
            refreshTokenEntity,
            correlationId
        );
        return refreshTokenEntity;
    }

    /**
     * Helper function to generate an `AuthenticationResult` for the result.
     * @param request
     * @param idTokenObj
     * @param cacheRecord
     * @param authority
     * @returns `AuthenticationResult`
     */
    private generateAuthenticationResult(
        request: SilentRequest,
        cacheRecord: CacheRecord & { account: AccountEntity },
        idTokenClaims?: TokenClaims,
        authority?: Authority
    ): AuthenticationResult {
        let accessToken: string = "";
        let responseScopes: Array<string> = [];
        let expiresOn: Date | null = null;
        let extExpiresOn: Date | undefined;

        if (cacheRecord?.accessToken) {
            accessToken = cacheRecord.accessToken.secret;
            responseScopes = ScopeSet.fromString(
                cacheRecord.accessToken.target
            ).asArray();
            // Access token expiresOn stored in seconds, converting to Date for AuthenticationResult
            expiresOn = TimeUtils.toDateFromSeconds(
                cacheRecord.accessToken.expiresOn
            );
            extExpiresOn = TimeUtils.toDateFromSeconds(
                cacheRecord.accessToken.extendedExpiresOn
            );
        }

        const accountEntity = cacheRecord.account;

        return {
            authority: authority ? authority.canonicalAuthority : "",
            uniqueId: cacheRecord.account.localAccountId,
            tenantId: cacheRecord.account.realm,
            scopes: responseScopes,
            account: accountEntity.getAccountInfo(),
            idToken: cacheRecord.idToken?.secret || "",
            idTokenClaims: idTokenClaims || {},
            accessToken: accessToken,
            fromCache: true,
            expiresOn: expiresOn,
            correlationId: request.correlationId || "",
            requestId: "",
            extExpiresOn: extExpiresOn,
            familyId: cacheRecord.refreshToken?.familyId || "",
            tokenType: cacheRecord?.accessToken?.tokenType || "",
            state: request.state || "",
            cloudGraphHostName: accountEntity.cloudGraphHostName || "",
            msGraphHost: accountEntity.msGraphHost || "",
            fromNativeBroker: false,
        };
    }
}
