/*
Copyright 2019-2024 New Vector Ltd.

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 React from "react";
import { fireEvent, render, screen, waitForElementToBeRemoved } from "jest-matrix-react";
import { mocked, type MockedObject } from "jest-mock-vitest-adapter";
import fetchMock from "@fetch-mock/jest";
import {
    OAUTH_AWARE_PREFERRED_FLOW_FIELD,
    IdentityProviderBrand,
    type ValidatedAuthMetadata,
    OAuth2Error,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import * as Matrix from "matrix-js-sdk/src/matrix";

import SdkConfig from "../../../../../src/SdkConfig";
import { mkServerConfig, mockPlatformPeg, unmockPlatformPeg } from "../../../../test-utils";
import Login from "../../../../../src/components/structures/auth/Login";
import type BasePlatform from "../../../../../src/BasePlatform";
import * as registerClientUtils from "../../../../../src/utils/oauth/registerClient";
import { makeDelegatedAuthMetadata } from "../../../../test-utils/auth";
import { ModuleApi } from "../../../../../src/modules/Api.ts";

jest.useRealTimers();

const oauthStaticClientsConfig = {
    "https://staticallyregisteredissuer.org/": {
        client_id: "static-clientId-123",
    },
};

describe("Login", function () {
    let platform: MockedObject<BasePlatform>;

    const mockClient = mocked({
        login: jest.fn().mockResolvedValue({}),
        loginFlows: jest.fn(),
    } as unknown as Matrix.MatrixClient);

    beforeEach(function () {
        SdkConfig.put({
            brand: "test-brand",
            disable_custom_urls: true,
            oidc_static_clients: oauthStaticClientsConfig,
        });
        mockClient.login.mockClear().mockResolvedValue({
            access_token: "TOKEN",
            device_id: "IAMADEVICE",
            user_id: "@user:server",
        });
        mockClient.loginFlows.mockClear().mockResolvedValue({ flows: [{ type: "m.login.password" }] });
        jest.spyOn(Matrix, "createClient").mockImplementation((opts) => {
            mockClient.idBaseUrl = opts.idBaseUrl;
            mockClient.baseUrl = opts.baseUrl;
            return mockClient;
        });
        fetchMock.get("https://matrix.org/_matrix/client/versions", {
            unstable_features: {},
            versions: ["v1.1"],
        });
        platform = mockPlatformPeg({
            startSingleSignOn: jest.fn(),
        });
    });

    afterEach(function () {
        SdkConfig.reset(); // we touch the config, so clean up
        unmockPlatformPeg();
    });

    function getRawComponent(
        hsUrl = "https://matrix.org",
        isUrl = "https://vector.im",
        delegatedAuthentication?: ValidatedAuthMetadata,
    ) {
        return (
            <Login
                serverConfig={mkServerConfig(hsUrl, isUrl, delegatedAuthentication)}
                onLoggedIn={() => {}}
                onRegisterClick={() => {}}
                onServerConfigChange={() => {}}
            />
        );
    }

    function getComponent(hsUrl?: string, isUrl?: string, delegatedAuthentication?: ValidatedAuthMetadata) {
        return render(getRawComponent(hsUrl, isUrl, delegatedAuthentication));
    }

    it("should show form with change server link", async () => {
        SdkConfig.put({
            brand: "test-brand",
            disable_custom_urls: false,
        });
        const { container } = getComponent();
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        expect(container.querySelector("form")).toBeTruthy();

        expect(container.querySelector(".mx_ServerPicker_change")).toBeTruthy();
    });

    it("should show register button", async () => {
        const onRegisterClick = jest.fn();
        const { getByText } = render(
            <Login
                serverConfig={mkServerConfig("https://matrix.org", "https://vector.im")}
                onLoggedIn={() => {}}
                onRegisterClick={onRegisterClick}
                onServerConfigChange={() => {}}
            />,
        );
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        fireEvent.click(getByText("Create an account"));
        expect(onRegisterClick).toHaveBeenCalled();
    });

    it("should hide register button", async () => {
        const { queryByText } = render(
            <Login
                serverConfig={mkServerConfig("https://matrix.org", "https://vector.im")}
                onLoggedIn={() => {}}
                onServerConfigChange={() => {}}
            />,
        );
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        expect(queryByText("Create an account")).not.toBeInTheDocument();
    });

    it("should show form without change server link when custom URLs disabled", async () => {
        const { container } = getComponent();
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        expect(container.querySelector("form")).toBeTruthy();
        expect(container.querySelectorAll(".mx_ServerPicker_change")).toHaveLength(0);
    });

    it("should show SSO button if that flow is available", async () => {
        mockClient.loginFlows.mockResolvedValue({ flows: [{ type: "m.login.sso" }] });

        const { container } = getComponent();
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        const ssoButton = container.querySelector(".mx_SSOButton");
        expect(ssoButton).toBeTruthy();
    });

    it("should show both SSO button and username+password if both are available", async () => {
        mockClient.loginFlows.mockResolvedValue({ flows: [{ type: "m.login.password" }, { type: "m.login.sso" }] });

        const { container } = getComponent();
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        expect(container.querySelector("form")).toBeTruthy();

        const ssoButton = container.querySelector(".mx_SSOButton");
        expect(ssoButton).toBeTruthy();
    });

    it("should show multiple SSO buttons if multiple identity_providers are available", async () => {
        mockClient.loginFlows.mockResolvedValue({
            flows: [
                {
                    type: "m.login.sso",
                    identity_providers: [
                        {
                            id: "a",
                            name: "Provider 1",
                        },
                        {
                            id: "b",
                            name: "Provider 2",
                        },
                        {
                            id: "c",
                            name: "Provider 3",
                        },
                    ],
                },
            ],
        });

        const { container } = getComponent();
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        const ssoButtons = container.querySelectorAll(".mx_SSOButton");
        expect(ssoButtons.length).toBe(3);
    });

    it("should show single SSO button if identity_providers is null", async () => {
        mockClient.loginFlows.mockResolvedValue({
            flows: [
                {
                    type: "m.login.sso",
                },
            ],
        });

        const { container } = getComponent();
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        const ssoButtons = container.querySelectorAll(".mx_SSOButton");
        expect(ssoButtons.length).toBe(1);
    });

    it("should handle serverConfig updates correctly", async () => {
        mockClient.loginFlows.mockResolvedValue({
            flows: [
                {
                    type: "m.login.sso",
                },
            ],
        });

        const { container, rerender } = render(getRawComponent());
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        fireEvent.click(container.querySelector(".mx_SSOButton")!);
        expect(platform.startSingleSignOn.mock.calls[0][0].baseUrl).toBe("https://matrix.org");

        fetchMock.get("https://server2/_matrix/client/versions", {
            unstable_features: {},
            versions: ["v1.1"],
        });
        rerender(getRawComponent("https://server2"));
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        fireEvent.click(container.querySelector(".mx_SSOButton")!);
        expect(platform.startSingleSignOn.mock.calls[1][0].baseUrl).toBe("https://server2");
    });

    it("should handle updating to a server with no supported flows", async () => {
        mockClient.loginFlows.mockResolvedValue({
            flows: [
                {
                    type: "m.login.sso",
                },
            ],
        });

        const { container, rerender } = render(getRawComponent());
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        // update the mock for the new server with no supported flows
        mockClient.loginFlows.mockResolvedValue({
            flows: [
                {
                    type: "just something weird",
                },
            ],
        });
        // render with a new server
        rerender(getRawComponent("https://server2"));
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        expect(
            screen.getByText("This homeserver doesn't offer any login flows that are supported by this client."),
        ).toBeInTheDocument();

        // no sso button because server2 doesnt support sso
        expect(container.querySelector(".mx_SSOButton")).not.toBeInTheDocument();
    });

    it("should show single Continue button if OIDC MSC3824 compatibility is given by server", async () => {
        mockClient.loginFlows.mockResolvedValue({
            flows: [
                {
                    type: "m.login.sso",
                    [OAUTH_AWARE_PREFERRED_FLOW_FIELD.name]: true,
                },
                {
                    type: "m.login.password",
                },
            ],
        });

        const { container } = getComponent();
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        const ssoButtons = container.querySelectorAll(".mx_SSOButton");

        expect(ssoButtons.length).toBe(1);
        expect(ssoButtons[0].textContent).toBe("Continue");

        // no password form visible
        expect(container.querySelector("form")).toBeFalsy();
    });

    it("should show branded SSO buttons", async () => {
        const idpsWithIcons = Object.values(IdentityProviderBrand).map((brand) => ({
            id: brand,
            brand,
            name: `Provider ${brand}`,
        }));

        mockClient.loginFlows.mockResolvedValue({
            flows: [
                {
                    type: "m.login.sso",
                    identity_providers: [
                        ...idpsWithIcons,
                        {
                            id: "foo",
                            name: "Provider foo",
                        },
                    ],
                },
            ],
        });

        const { container, getByTestId } = getComponent();
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        for (const idp of idpsWithIcons) {
            const ssoButton = getByTestId(`idp-${idp.id}`);
            expect(ssoButton).toBeTruthy();
            expect(ssoButton.childNodes[0]).toHaveAccessibleName(idp.brand);
        }

        const ssoButtons = container.querySelectorAll(".mx_SSOButton");
        expect(ssoButtons.length).toBe(idpsWithIcons.length + 1);
    });

    it("should display an error when homeserver doesn't offer any supported login flows", async () => {
        mockClient.loginFlows.mockResolvedValue({
            flows: [
                {
                    type: "just something weird",
                },
            ],
        });

        getComponent();
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        expect(
            screen.getByText("This homeserver doesn't offer any login flows that are supported by this client."),
        ).toBeInTheDocument();
    });

    it("should display a connection error when getting login flows fails", async () => {
        mockClient.loginFlows.mockRejectedValue("oups");

        getComponent();
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        expect(
            screen.getByText("There was a problem communicating with the homeserver, please try again later."),
        ).toBeInTheDocument();
    });

    it("should display an error when homeserver fails liveliness check", async () => {
        fetchMock.removeRoutes();
        fetchMock.get("https://matrix.org/_matrix/client/versions", {
            status: 0,
        });
        getComponent();
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        // error displayed
        expect(screen.getByText("Cannot reach homeserver")).toBeInTheDocument();
    });

    it("should reset liveliness error when server config changes", async () => {
        fetchMock.removeRoutes();
        // matrix.org is not alive
        fetchMock.get("https://matrix.org/_matrix/client/versions", {
            status: 400,
        });
        // but server2 is
        fetchMock.get("https://server2/_matrix/client/versions", {
            unstable_features: {},
            versions: ["v1.1"],
        });
        const { rerender } = render(getRawComponent());
        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        // error displayed
        expect(screen.getByText("Cannot reach homeserver")).toBeInTheDocument();

        rerender(getRawComponent("https://server2"));

        await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

        // error cleared
        expect(screen.queryByText("Cannot reach homeserver")).not.toBeInTheDocument();
    });

    describe("OIDC native flow", () => {
        const hsUrl = "https://matrix.org";
        const isUrl = "https://vector.im";
        const issuer = "https://test.com/";
        const delegatedAuth = makeDelegatedAuthMetadata(issuer);
        beforeEach(() => {
            jest.spyOn(logger, "error");
        });

        afterEach(() => {
            jest.spyOn(logger, "error").mockRestore();
        });

        it("should attempt to register oauth client", async () => {
            // dont mock, spy so we can check config values were correctly passed
            jest.spyOn(registerClientUtils, "getOAuthClientId");
            fetchMock.post(delegatedAuth.registration_endpoint!, { status: 500 });
            getComponent(hsUrl, isUrl, delegatedAuth);

            await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

            // tried to register
            expect(fetchMock).toHaveFetched(delegatedAuth.registration_endpoint);
            // called with values from config
            expect(registerClientUtils.getOAuthClientId).toHaveBeenCalledWith(delegatedAuth, oauthStaticClientsConfig);
        });

        it("should fallback to normal login when client registration fails", async () => {
            fetchMock.post(delegatedAuth.registration_endpoint!, { status: 500 });
            getComponent(hsUrl, isUrl, delegatedAuth);

            await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

            // tried to register
            expect(fetchMock).toHaveFetched(delegatedAuth.registration_endpoint);
            expect(logger.error).toHaveBeenCalledWith(
                "Failed to get OAuth2 native flow",
                new Error(OAuth2Error.DynamicRegistrationFailed),
            );

            // continued with normal setup
            expect(mockClient.loginFlows).toHaveBeenCalled();
            // normal password login rendered
            expect(screen.getByLabelText("Username")).toBeInTheDocument();
        });

        // short term during active development, UI will be added in next PRs
        it("should show continue button when oauth native flow is correctly configured", async () => {
            fetchMock.post(delegatedAuth.registration_endpoint!, { client_id: "abc123" });
            getComponent(hsUrl, isUrl, delegatedAuth);

            await waitForElementToBeRemoved(() => screen.queryAllByLabelText("Loading…"));

            // did not continue with matrix login
            expect(mockClient.loginFlows).not.toHaveBeenCalled();
            expect(screen.getByText("Continue")).toBeInTheDocument();
        });
    });

    describe("Module API", () => {
        afterEach(() => {
            ModuleApi.instance.customComponents.registerLoginComponent(undefined as any);
        });

        it("should use registered module renderer", async () => {
            ModuleApi.instance.customComponents.registerLoginComponent(() => <>Test component</>);
            const { getByText } = getComponent();
            expect(getByText("Test component")).toBeTruthy();
        });
    });
});
