/*
Copyright 2023 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import { Crypto } from "@peculiar/webcrypto";
import { logger } from "matrix-js-sdk/src/logger";
import * as MatrixJs from "matrix-js-sdk/src/matrix";
import { setCrypto } from "matrix-js-sdk/src/crypto/crypto";
import * as MatrixCryptoAes from "matrix-js-sdk/src/crypto/aes";

import StorageEvictedDialog from "../src/components/views/dialogs/StorageEvictedDialog";
import { restoreFromLocalStorage, setLoggedIn } from "../src/Lifecycle";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
import Modal from "../src/Modal";
import * as StorageManager from "../src/utils/StorageManager";
import { getMockClientWithEventEmitter, mockPlatformPeg } from "./test-utils";

const webCrypto = new Crypto();

const windowCrypto = window.crypto;

describe("Lifecycle", () => {
    const mockPlatform = mockPlatformPeg();

    const realLocalStorage = global.localStorage;

    const mockClient = getMockClientWithEventEmitter({
        stopClient: jest.fn(),
        removeAllListeners: jest.fn(),
        clearStores: jest.fn(),
        getAccountData: jest.fn(),
        getUserId: jest.fn(),
        getDeviceId: jest.fn(),
        isVersionSupported: jest.fn().mockResolvedValue(true),
        getCrypto: jest.fn(),
        getClientWellKnown: jest.fn(),
        getThirdpartyProtocols: jest.fn(),
        store: {
            destroy: jest.fn(),
        },
    });

    beforeEach(() => {
        // stub this
        jest.spyOn(MatrixClientPeg, "replaceUsingCreds").mockImplementation(() => {});
        jest.spyOn(MatrixClientPeg, "start").mockResolvedValue(undefined);

        // reset any mocking
        // @ts-ignore mocking
        delete global.localStorage;
        global.localStorage = realLocalStorage;

        setCrypto(webCrypto);
        // @ts-ignore mocking
        delete window.crypto;
        window.crypto = webCrypto;

        jest.spyOn(MatrixCryptoAes, "encryptAES").mockRestore();
    });

    afterAll(() => {
        setCrypto(windowCrypto);

        // @ts-ignore unmocking
        delete window.crypto;
        window.crypto = windowCrypto;
    });

    const initLocalStorageMock = (mockStore: Record<string, unknown> = {}): void => {
        jest.spyOn(localStorage.__proto__, "getItem")
            .mockClear()
            .mockImplementation((key: unknown) => mockStore[key as string] ?? null);
        jest.spyOn(localStorage.__proto__, "removeItem")
            .mockClear()
            .mockImplementation((key: unknown) => {
                const { [key as string]: toRemove, ...newStore } = mockStore;
                mockStore = newStore;
                return toRemove;
            });
        jest.spyOn(localStorage.__proto__, "setItem")
            .mockClear()
            .mockImplementation((key: unknown, value: unknown) => {
                mockStore[key as string] = value;
            });
    };

    const initSessionStorageMock = (mockStore: Record<string, unknown> = {}): void => {
        jest.spyOn(sessionStorage.__proto__, "getItem")
            .mockClear()
            .mockImplementation((key: unknown) => mockStore[key as string] ?? null);
        jest.spyOn(sessionStorage.__proto__, "removeItem")
            .mockClear()
            .mockImplementation((key: unknown) => {
                const { [key as string]: toRemove, ...newStore } = mockStore;
                mockStore = newStore;
                return toRemove;
            });
        jest.spyOn(sessionStorage.__proto__, "setItem")
            .mockClear()
            .mockImplementation((key: unknown, value: unknown) => {
                mockStore[key as string] = value;
            });
        jest.spyOn(sessionStorage.__proto__, "clear").mockClear();
    };

    const initIdbMock = (mockStore: Record<string, Record<string, unknown>> = {}): void => {
        jest.spyOn(StorageManager, "idbLoad")
            .mockClear()
            .mockImplementation(
                // @ts-ignore mock type
                async (table: string, key: string) => mockStore[table]?.[key] ?? null,
            );
        jest.spyOn(StorageManager, "idbSave")
            .mockClear()
            .mockImplementation(
                // @ts-ignore mock type
                async (tableKey: string, key: string, value: unknown) => {
                    const table = mockStore[tableKey] || {};
                    table[key as string] = value;
                    mockStore[tableKey] = table;
                },
            );
        jest.spyOn(StorageManager, "idbDelete").mockClear().mockResolvedValue(undefined);
    };

    const homeserverUrl = "https://server.org";
    const identityServerUrl = "https://is.org";
    const userId = "@alice:server.org";
    const deviceId = "abc123";
    const accessToken = "test-access-token";
    const localStorageSession = {
        mx_hs_url: homeserverUrl,
        mx_is_url: identityServerUrl,
        mx_user_id: userId,
        mx_device_id: deviceId,
    };
    const idbStorageSession = {
        account: {
            mx_access_token: accessToken,
        },
    };
    const credentials = {
        homeserverUrl,
        identityServerUrl,
        userId,
        deviceId,
        accessToken,
    };

    const encryptedTokenShapedObject = {
        ciphertext: expect.any(String),
        iv: expect.any(String),
        mac: expect.any(String),
    };

    describe("restoreFromLocalStorage()", () => {
        beforeEach(() => {
            initLocalStorageMock();
            initSessionStorageMock();
            initIdbMock();

            jest.clearAllMocks();
            jest.spyOn(logger, "log").mockClear();

            jest.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient);

            // stub this out
            jest.spyOn(Modal, "createDialog").mockReturnValue(
                // @ts-ignore allow bad mock
                { finished: Promise.resolve([true]) },
            );
        });

        it("should return false when localStorage is not available", async () => {
            // @ts-ignore dirty mocking
            delete global.localStorage;
            // @ts-ignore dirty mocking
            global.localStorage = undefined;

            expect(await restoreFromLocalStorage()).toEqual(false);
        });

        it("should return false when no session data is found in local storage", async () => {
            expect(await restoreFromLocalStorage()).toEqual(false);
            expect(logger.log).toHaveBeenCalledWith("No previous session found.");
        });

        it("should abort login when we expect to find an access token but don't", async () => {
            initLocalStorageMock({ mx_has_access_token: "true" });

            await expect(() => restoreFromLocalStorage()).rejects.toThrow();
            expect(Modal.createDialog).toHaveBeenCalledWith(StorageEvictedDialog);
            expect(mockClient.clearStores).toHaveBeenCalled();
        });

        describe("when session is found in storage", () => {
            beforeEach(() => {
                initLocalStorageMock(localStorageSession);
                initIdbMock(idbStorageSession);
            });

            describe("guest account", () => {
                it("should ignore guest accounts when ignoreGuest is true", async () => {
                    initLocalStorageMock({ ...localStorageSession, mx_is_guest: "true" });

                    expect(await restoreFromLocalStorage({ ignoreGuest: true })).toEqual(false);
                    expect(logger.log).toHaveBeenCalledWith(`Ignoring stored guest account: ${userId}`);
                });

                it("should restore guest accounts when ignoreGuest is false", async () => {
                    initLocalStorageMock({ ...localStorageSession, mx_is_guest: "true" });

                    expect(await restoreFromLocalStorage({ ignoreGuest: false })).toEqual(true);

                    expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith(
                        expect.objectContaining({
                            userId,
                            guest: true,
                        }),
                    );
                    expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "true");
                });
            });

            describe("without a pickle key", () => {
                it("should persist credentials", async () => {
                    expect(await restoreFromLocalStorage()).toEqual(true);

                    expect(localStorage.setItem).toHaveBeenCalledWith("mx_user_id", userId);
                    expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_access_token", "true");
                    expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "false");
                    expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId);

                    expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
                    // dont put accessToken in localstorage when we have idb
                    expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken);
                });

                it("should persist access token when idb is not available", async () => {
                    jest.spyOn(StorageManager, "idbSave").mockRejectedValue("oups");
                    expect(await restoreFromLocalStorage()).toEqual(true);

                    expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
                    // put accessToken in localstorage as fallback
                    expect(localStorage.setItem).toHaveBeenCalledWith("mx_access_token", accessToken);
                });

                it("should create new matrix client with credentials", async () => {
                    expect(await restoreFromLocalStorage()).toEqual(true);

                    expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith({
                        userId,
                        accessToken,
                        homeserverUrl,
                        identityServerUrl,
                        deviceId,
                        freshLogin: false,
                        guest: false,
                        pickleKey: undefined,
                    });
                });

                it("should remove fresh login flag from session storage", async () => {
                    expect(await restoreFromLocalStorage()).toEqual(true);

                    expect(sessionStorage.removeItem).toHaveBeenCalledWith("mx_fresh_login");
                });

                it("should start matrix client", async () => {
                    expect(await restoreFromLocalStorage()).toEqual(true);

                    expect(MatrixClientPeg.start).toHaveBeenCalled();
                });
            });

            describe("with a pickle key", () => {
                beforeEach(async () => {
                    initLocalStorageMock({});
                    initIdbMock({});
                    // setup storage with a session with encrypted token
                    await setLoggedIn(credentials);
                });

                it("should persist credentials", async () => {
                    expect(await restoreFromLocalStorage()).toEqual(true);

                    expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_access_token", "true");

                    // token encrypted and persisted
                    expect(StorageManager.idbSave).toHaveBeenCalledWith(
                        "account",
                        "mx_access_token",
                        encryptedTokenShapedObject,
                    );
                });

                it("should persist access token when idb is not available", async () => {
                    // dont fail for pickle key persist
                    jest.spyOn(StorageManager, "idbSave").mockImplementation(
                        async (table: string, key: string | string[]) => {
                            if (table === "account" && key === "mx_access_token") {
                                throw new Error("oups");
                            }
                        },
                    );

                    expect(await restoreFromLocalStorage()).toEqual(true);

                    expect(StorageManager.idbSave).toHaveBeenCalledWith(
                        "account",
                        "mx_access_token",
                        encryptedTokenShapedObject,
                    );
                    // put accessToken in localstorage as fallback
                    expect(localStorage.setItem).toHaveBeenCalledWith("mx_access_token", accessToken);
                });

                it("should create new matrix client with credentials", async () => {
                    expect(await restoreFromLocalStorage()).toEqual(true);

                    expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith({
                        userId,
                        // decrypted accessToken
                        accessToken,
                        homeserverUrl,
                        identityServerUrl,
                        deviceId,
                        freshLogin: true,
                        guest: false,
                        pickleKey: expect.any(String),
                    });
                });
            });
        });
    });

    describe("setLoggedIn()", () => {
        beforeEach(() => {
            initLocalStorageMock();
            initSessionStorageMock();
            initIdbMock();

            jest.clearAllMocks();
            jest.spyOn(logger, "log").mockClear();

            jest.spyOn(MatrixJs, "createClient").mockReturnValue(mockClient);
            // remove any mock implementations
            jest.spyOn(mockPlatform, "createPickleKey").mockRestore();
            // but still spy and call through
            jest.spyOn(mockPlatform, "createPickleKey");
        });

        it("should remove fresh login flag from session storage", async () => {
            await setLoggedIn(credentials);

            expect(sessionStorage.removeItem).toHaveBeenCalledWith("mx_fresh_login");
        });

        it("should start matrix client", async () => {
            await setLoggedIn(credentials);

            expect(MatrixClientPeg.start).toHaveBeenCalled();
        });

        describe("without a pickle key", () => {
            beforeEach(() => {
                jest.spyOn(mockPlatform, "createPickleKey").mockResolvedValue(null);
            });

            it("should persist credentials", async () => {
                await setLoggedIn(credentials);

                expect(localStorage.setItem).toHaveBeenCalledWith("mx_user_id", userId);
                expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_access_token", "true");
                expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "false");
                expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId);

                expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
                // dont put accessToken in localstorage when we have idb
                expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken);
            });

            it("should remove any access token from storage when there is none in credentials and idb save fails", async () => {
                jest.spyOn(StorageManager, "idbSave").mockRejectedValue("oups");
                await setLoggedIn({
                    ...credentials,
                    // @ts-ignore
                    accessToken: undefined,
                });

                expect(localStorage.removeItem).toHaveBeenCalledWith("mx_has_access_token");
                expect(localStorage.removeItem).toHaveBeenCalledWith("mx_access_token");
            });

            it("should clear stores", async () => {
                await setLoggedIn(credentials);

                expect(StorageManager.idbDelete).toHaveBeenCalledWith("account", "mx_access_token");
                expect(sessionStorage.clear).toHaveBeenCalled();
                expect(mockClient.clearStores).toHaveBeenCalled();
            });

            it("should create new matrix client with credentials", async () => {
                expect(await setLoggedIn(credentials)).toEqual(mockClient);

                expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith({
                    userId,
                    accessToken,
                    homeserverUrl,
                    identityServerUrl,
                    deviceId,
                    freshLogin: true,
                    guest: false,
                    pickleKey: null,
                });
            });
        });

        describe("with a pickle key", () => {
            it("should not create a pickle key when credentials do not include deviceId", async () => {
                await setLoggedIn({
                    ...credentials,
                    deviceId: undefined,
                });

                // unpickled access token saved
                expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
                expect(mockPlatform.createPickleKey).not.toHaveBeenCalled();
            });

            it("creates a pickle key with userId and deviceId", async () => {
                await setLoggedIn(credentials);

                expect(mockPlatform.createPickleKey).toHaveBeenCalledWith(userId, deviceId);
            });

            it("should persist credentials", async () => {
                await setLoggedIn(credentials);

                expect(localStorage.setItem).toHaveBeenCalledWith("mx_user_id", userId);
                expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_access_token", "true");
                expect(localStorage.setItem).toHaveBeenCalledWith("mx_is_guest", "false");
                expect(localStorage.setItem).toHaveBeenCalledWith("mx_device_id", deviceId);

                expect(localStorage.setItem).toHaveBeenCalledWith("mx_has_pickle_key", "true");
                expect(StorageManager.idbSave).toHaveBeenCalledWith(
                    "account",
                    "mx_access_token",
                    encryptedTokenShapedObject,
                );
                expect(StorageManager.idbSave).toHaveBeenCalledWith(
                    "pickleKey",
                    [userId, deviceId],
                    expect.any(Object),
                );
                // dont put accessToken in localstorage when we have idb
                expect(localStorage.setItem).not.toHaveBeenCalledWith("mx_access_token", accessToken);
            });

            it("should persist token when encrypting the token fails", async () => {
                jest.spyOn(MatrixCryptoAes, "encryptAES").mockRejectedValue("MOCK REJECT ENCRYPTAES");
                await setLoggedIn(credentials);

                // persist the unencrypted token
                expect(StorageManager.idbSave).toHaveBeenCalledWith("account", "mx_access_token", accessToken);
            });

            it("should persist token in localStorage when idb fails to save token", async () => {
                // dont fail for pickle key persist
                jest.spyOn(StorageManager, "idbSave").mockImplementation(
                    async (table: string, key: string | string[]) => {
                        if (table === "account" && key === "mx_access_token") {
                            throw new Error("oups");
                        }
                    },
                );
                await setLoggedIn(credentials);

                // put plain accessToken in localstorage when we dont have idb
                expect(localStorage.setItem).toHaveBeenCalledWith("mx_access_token", accessToken);
            });

            it("should remove any access token from storage when there is none in credentials and idb save fails", async () => {
                // dont fail for pickle key persist
                jest.spyOn(StorageManager, "idbSave").mockImplementation(
                    async (table: string, key: string | string[]) => {
                        if (table === "account" && key === "mx_access_token") {
                            throw new Error("oups");
                        }
                    },
                );
                await setLoggedIn({
                    ...credentials,
                    // @ts-ignore
                    accessToken: undefined,
                });

                expect(localStorage.removeItem).toHaveBeenCalledWith("mx_has_access_token");
                expect(localStorage.removeItem).toHaveBeenCalledWith("mx_access_token");
            });

            it("should create new matrix client with credentials", async () => {
                expect(await setLoggedIn(credentials)).toEqual(mockClient);

                expect(MatrixClientPeg.replaceUsingCreds).toHaveBeenCalledWith({
                    userId,
                    accessToken,
                    homeserverUrl,
                    identityServerUrl,
                    deviceId,
                    freshLogin: true,
                    guest: false,
                    pickleKey: expect.any(String),
                });
            });
        });
    });
});