/*
Copyright 2024 New Vector Ltd.
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

import {
    createClient,
    type MatrixClient,
    type LoginFlow,
    OAUTH_AWARE_PREFERRED_FLOW_FIELD,
    type ILoginFlow,
    type LoginRequest,
    type ValidatedAuthMetadata,
    type ISSOFlow,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";

import { type IMatrixClientCreds } from "./utils/createMatrixClient";
import { ModuleRunner } from "./modules/ModuleRunner";
import { getOAuthClientId } from "./utils/oauth/registerClient";
import { type IConfigOptions } from "./IConfigOptions";
import SdkConfig from "./SdkConfig";
import { isUserRegistrationSupported } from "./utils/oauth/isUserRegistrationSupported";

/**
 * Login flows supported by this client
 * LoginFlow type use the client API /login endpoint
 * OAuthNativeFlow is specific to this client
 */
export type ClientLoginFlow = LoginFlow | OAuthNativeFlow;

interface ILoginOptions {
    defaultDeviceDisplayName?: string;
    /**
     * Delegated auth config from server's .well-known.
     *
     * If this property is set, we will attempt an OAuth2 login using the delegated auth settings.
     * The caller is responsible for checking that OAuth2 is enabled in the labs settings.
     */
    delegatedAuthentication?: ValidatedAuthMetadata;
}

export default class Login {
    private flows: Array<ClientLoginFlow> = [];
    private readonly defaultDeviceDisplayName?: string;
    private delegatedAuthentication?: ValidatedAuthMetadata;
    private tempClient: MatrixClient | null = null; // memoize

    public constructor(
        private hsUrl: string,
        private isUrl: string,
        private fallbackHsUrl: string | null,
        opts: ILoginOptions,
    ) {
        this.defaultDeviceDisplayName = opts.defaultDeviceDisplayName;
        this.delegatedAuthentication = opts.delegatedAuthentication;
    }

    public getHomeserverUrl(): string {
        return this.hsUrl;
    }

    public getIdentityServerUrl(): string {
        return this.isUrl;
    }

    public setHomeserverUrl(hsUrl: string): void {
        this.tempClient = null; // clear memoization
        this.hsUrl = hsUrl;
    }

    public setIdentityServerUrl(isUrl: string): void {
        this.tempClient = null; // clear memoization
        this.isUrl = isUrl;
    }

    /**
     * Set delegated authentication config, clears tempClient.
     * @param delegatedAuthentication delegated auth config, from ValidatedServerConfig
     */
    public setDelegatedAuthentication(delegatedAuthentication?: ValidatedAuthMetadata): void {
        this.tempClient = null; // clear memoization
        this.delegatedAuthentication = delegatedAuthentication;
    }

    /**
     * Get a temporary MatrixClient, which can be used for login or register
     * requests.
     * @returns {MatrixClient}
     */
    public createTemporaryClient(): MatrixClient {
        if (!this.tempClient) {
            this.tempClient = createClient({
                baseUrl: this.hsUrl,
                idBaseUrl: this.isUrl,
            });
        }
        return this.tempClient;
    }

    /**
     * Get supported login flows
     * @param isRegistration OPTIONAL used to verify registration is supported in delegated authentication config
     * @returns Promise that resolves to supported login flows
     */
    public async getFlows(isRegistration?: boolean): Promise<Array<ClientLoginFlow>> {
        // try to use oauth2 native flow if we have delegated auth config
        if (this.delegatedAuthentication) {
            try {
                const oauthFlow = await tryInitOAuthNativeFlow(
                    this.delegatedAuthentication,
                    SdkConfig.get().oidc_static_clients,
                    isRegistration,
                );
                return [oauthFlow];
            } catch (error) {
                logger.error("Failed to get OAuth2 native flow", error);
            }
        }

        // OAuth2 native flow not supported, continue with matrix login
        const client = this.createTemporaryClient();
        const { flows }: { flows: LoginFlow[] } = await client.loginFlows();
        // If an m.login.sso flow is present which is also flagged as being for MSC3824 OAuth compatibility then we only
        // return that flow as (per MSC3824) it is the only one that the user should be offered to give the best experience
        const oauthCompatibilityFlow = flows.find(
            (f) => f.type === "m.login.sso" && OAUTH_AWARE_PREFERRED_FLOW_FIELD.findIn(f as ISSOFlow),
        );
        this.flows = oauthCompatibilityFlow ? [oauthCompatibilityFlow] : flows;
        return this.flows;
    }

    public loginViaPassword(
        username: string | undefined,
        phoneCountry: string | undefined,
        phoneNumber: string | undefined,
        password: string,
    ): Promise<IMatrixClientCreds> {
        const isEmail = !!username && username.indexOf("@") > 0;

        let identifier;
        if (phoneCountry && phoneNumber) {
            identifier = {
                type: "m.id.phone",
                country: phoneCountry,
                phone: phoneNumber,
                // XXX: Synapse historically wanted `number` and not `phone`
                number: phoneNumber,
            };
        } else if (isEmail) {
            identifier = {
                type: "m.id.thirdparty",
                medium: "email",
                address: username,
            };
        } else {
            identifier = {
                type: "m.id.user",
                user: username,
            };
        }

        const loginParams = {
            password,
            identifier,
            initial_device_display_name: this.defaultDeviceDisplayName,
        };

        const tryFallbackHs = (originalError: Error): Promise<IMatrixClientCreds> => {
            return sendLoginRequest(this.fallbackHsUrl!, this.isUrl, "m.login.password", loginParams).catch(
                (fallbackError) => {
                    logger.log("fallback HS login failed", fallbackError);
                    // throw the original error
                    throw originalError;
                },
            );
        };

        let originalLoginError: Error | null = null;
        return sendLoginRequest(this.hsUrl, this.isUrl, "m.login.password", loginParams)
            .catch((error) => {
                originalLoginError = error;
                if (error.httpStatus === 403) {
                    if (this.fallbackHsUrl) {
                        return tryFallbackHs(originalLoginError!);
                    }
                }
                throw originalLoginError;
            })
            .catch((error) => {
                logger.log("Login failed", error);
                throw error;
            });
    }
}

/**
 * Describes the OAuth2 native login flow
 * Separate from js-sdk's `LoginFlow` as this does not use the same /login flow
 * to which that type belongs.
 */
export interface OAuthNativeFlow extends ILoginFlow {
    type: "oauthNativeFlow";
    // this client's id as registered with the configured OAuth2 OP
    clientId: string;
}
/**
 * Prepares an OAuthNativeFlow for logging into the server.
 *
 * Finds a static clientId for configured issuer, or attempts dynamic registration with the OP, and wraps the
 * results.
 *
 * @param delegatedAuthConfig  Auth config from ValidatedServerConfig
 * @param staticOAuthClientIds static client config from config.json, used during client registration with OP
 * @param isRegistration true when we are attempting registration
 * @returns Promise<OAuthNativeFlow> when oauth native authentication flow is supported and correctly configured
 * @throws when client can't register with OP, or any unexpected error
 */
const tryInitOAuthNativeFlow = async (
    delegatedAuthConfig: ValidatedAuthMetadata,
    staticOAuthClientIds?: IConfigOptions["oidc_static_clients"],
    isRegistration?: boolean,
): Promise<OAuthNativeFlow> => {
    // if registration is not supported, bail before attempting to get the clientId
    if (isRegistration && !isUserRegistrationSupported(delegatedAuthConfig)) {
        throw new Error("Registration is not supported by OP");
    }
    const clientId = await getOAuthClientId(delegatedAuthConfig, staticOAuthClientIds);

    const flow = {
        type: "oauthNativeFlow",
        clientId,
    } as OAuthNativeFlow;

    return flow;
};

/**
 * Send a login request to the given server, and format the response
 * as a MatrixClientCreds
 *
 * @param {string} hsUrl   the base url of the Homeserver used to log in.
 * @param {string} isUrl   the base url of the default identity server
 * @param {string} loginType the type of login to do
 * @param {ILoginParams} loginParams the parameters for the login
 *
 * @returns {IMatrixClientCreds}
 */
export async function sendLoginRequest(
    hsUrl: string,
    isUrl: string | undefined,
    loginType: string,
    loginParams: Omit<LoginRequest, "type">,
): Promise<IMatrixClientCreds> {
    const client = createClient({
        baseUrl: hsUrl,
        idBaseUrl: isUrl,
    });

    const data = await client.login(loginType, loginParams);

    const wellknown = data.well_known;
    if (wellknown) {
        if (wellknown["m.homeserver"]?.["base_url"]) {
            hsUrl = wellknown["m.homeserver"]["base_url"];
            logger.log(`Overrode homeserver setting with ${hsUrl} from login response`);
        }
        if (wellknown["m.identity_server"]?.["base_url"]) {
            // TODO: should we prompt here?
            isUrl = wellknown["m.identity_server"]["base_url"];
            logger.log(`Overrode IS setting with ${isUrl} from login response`);
        }
    }

    const creds: IMatrixClientCreds = {
        homeserverUrl: hsUrl,
        identityServerUrl: isUrl,
        userId: data.user_id,
        deviceId: data.device_id,
        accessToken: data.access_token,
    };

    ModuleRunner.instance.extensions.cryptoSetup.examineLoginResponse(data, creds);

    return creds;
}
