import React from 'react';
import * as Sentry from '@sentry/browser';
import { screen } from '@testing-library/react';
import * as sentryScrubber from '@theorchard/sentry-scrubber';
import { useAppConfig } from '@theorchard/suite-config';
import * as i18n from '@theorchard/suite-i18n';
import * as ReactDomClient from 'react-dom/client';
import * as app from '../app';
import Datadog from '../datadog';
import { initApplication } from '../initApplication';
import * as modules from '../modules';
import { FrontendAppConfig } from '../types';
import { mergeResourceTypeActions } from '../utils/resourceTypeActions';
import * as routeUtils from '../utils/routing';
import { mergeTypePolicies } from '../utils/typePolicies';

vi.mock('@theorchard/suite-i18n');

vi.mock('react-dom/client', async () => {
    const actual = (await vi.importActual('react-dom/client')) as typeof ReactDomClient;
    return {
        ...actual,
        createRoot: vi.fn(actual.createRoot),
    };
});

vi.mock('@theorchard/sentry-scrubber', () => ({
    createSentryScrubber: vi.fn(),
    SentryScrubber: vi.fn(),
}));

vi.mock('../app', async () => {
    const { PluginManager } = await vi.importActual('../app');
    const MockSuiteApp = (props: unknown) => {
        const config = useAppConfig();
        return (
            <div>
                <div data-testid="config">{JSON.stringify(config, null, 2)}</div>
                <div data-testid="props">{JSON.stringify(props)}</div>
            </div>
        );
    };

    return {
        SuiteApplication: MockSuiteApp,
        PluginManager,
        initApplicationLocale: vi.fn(),
    };
});

const createConfig = (config: Partial<FrontendAppConfig> = {}) => ({
    appName: 'test-app',
    brand: 'apple',
    cdnUrl: 'cdn-url',
    environment: 'dev',
    auth0Audience: 'aud',
    auth0ClientId: '1234',
    auth0Domain: 'domain',
    graphqlUrl: 'gql-url',
    secondaryProfileTypes: [],
    ...config,
});

const noop = async () => await Promise.resolve();

describe('initApplication', () => {
    let root: HTMLDivElement;

    beforeEach(() => {
        root = document.createElement('div');
        root.id = 'root';
        document.body.append(root);

        routeUtils.clearRoutes();
        window.initFrontend = noop;
        vi.spyOn(Datadog, 'init').mockImplementation();
        vi.spyOn(Sentry, 'init').mockImplementation();
    });

    afterEach(() => {
        vi.clearAllMocks();
        root.remove();
    });

    test('defines "initFrontend" on window', () => {
        initApplication();
        expect(window.initFrontend).toBeDefined();
        expect(window.initFrontend).not.toBe(noop);
    });

    test('applies alias options to config', async () => {
        const title = 'appapp';
        const featureFlagFilter = 'app_';
        const profileType = 'App';
        const config = createConfig({ appTitle: 'ppappa' });

        initApplication({ title, featureFlagFilter, profileType });
        await window.initFrontend(config, root);

        const element = await screen.findByTestId('config');
        const finalConfig = JSON.parse(element.innerHTML);

        expect(finalConfig).toEqual({
            ...config,
            appVersion: '1.0.0',
            appTitle: title,
            featureFlagFilter,
            defaultProfileType: profileType,
        });
    });

    test('renders "SuiteApplication" with routes', async () => {
        const config = createConfig();

        const moduleRoute = {
            path: '/module-one/page',
            page: () => <>module page</>,
            moduleName: 'module-one',
        };

        const rootRoute = { path: '/', page: () => <>home</> };

        routeUtils.registerModuleRoute('module-one', moduleRoute);

        initApplication({ routes: [rootRoute] });

        await window.initFrontend(config, root);

        const element = await screen.findByTestId('props');
        const { routes: appRoutes } = JSON.parse(element.innerHTML);

        expect(appRoutes).toEqual([
            {
                ...rootRoute,
                page: undefined, // because of the json serialization
            },
            {
                ...moduleRoute,
                page: undefined,
            },
        ]);
    });

    test('renders "SuiteApplication" with apollo type policies', async () => {
        const config = createConfig();

        const moduleTypePolicy = {
            SomeModuleEntityType: {},
        };

        const rootTypePolicy = { SomeRootEntityType: {} };

        vi.spyOn(modules, 'getModuleTypePolicies').mockReturnValue(moduleTypePolicy);

        initApplication({ apollo: { typePolicies: rootTypePolicy } });

        await window.initFrontend(config, root);

        const element = await screen.findByTestId('props');
        const {
            apollo: { typePolicies },
        } = JSON.parse(element.innerHTML);

        expect(typePolicies).toEqual(mergeTypePolicies(rootTypePolicy, moduleTypePolicy));
    });

    test('initializes app localization', async () => {
        const config = createConfig();
        const defaultLocale = 'nb';

        initApplication({
            localization: { defaultLocale, brand: config.brand },
        });
        await window.initFrontend(config, root);

        expect(app.initApplicationLocale).toHaveBeenCalledWith({
            fixedLocale: undefined,
            defaultLocale,
            brand: config.brand,
        });
    });

    test('calls "onInit" on all plugins', async () => {
        const pluginOneConfig = { fromPlugin: 'nay' };
        const pluginTwoConfig = { fromPlugin: 'yay' };
        const pluginOne = {
            onInit: vi.fn().mockReturnValue(pluginOneConfig),
        };
        const pluginTwo = {
            onInit: vi.fn().mockResolvedValue(pluginTwoConfig),
        };

        const config = createConfig({ appTitle: 'AppWithPlugins' });
        initApplication({
            plugins: [pluginOne, pluginTwo],
        });

        await window.initFrontend(config, root);

        expect(pluginOne.onInit).toHaveBeenCalledTimes(1);
        expect(pluginOne.onInit).toHaveBeenCalledWith({ config }, undefined);

        expect(pluginTwo.onInit).toHaveBeenCalledTimes(1);
        expect(pluginTwo.onInit).toHaveBeenCalledWith({ config }, pluginOneConfig);
    });

    test('initializes "Sentry"', async () => {
        const sentryDsn = '1234567';
        const gitCommit = 'abc';
        const config = createConfig({ sentryDsn, gitCommit });
        initApplication();
        await window.initFrontend(config, root);

        expect(Sentry.init).toHaveBeenCalledWith(
            expect.objectContaining({
                dsn: sentryDsn,
                release: gitCommit,
            })
        );
    });

    test('initializes "Sentry" with sensitive scrubbing when sensitiveScrubFields provided', async () => {
        const sentryDsn = '1234567';
        const gitCommit = 'abc';
        const sensitiveScrubFields = ['password', 'token', 'secret'];
        const mockBeforeSendHandler = vi.fn();

        vi.spyOn(sentryScrubber, 'createSentryScrubber').mockReturnValue({
            beforeSendHandler: mockBeforeSendHandler,
        } as unknown as sentryScrubber.SentryScrubber);

        const config = createConfig({ sentryDsn, gitCommit });
        initApplication({ sensitiveScrubFields });
        await window.initFrontend(config, root);

        expect(sentryScrubber.createSentryScrubber).toHaveBeenCalledWith({
            sensitiveFields: sensitiveScrubFields,
        });

        expect(Sentry.init).toHaveBeenCalledWith(
            expect.objectContaining({
                dsn: sentryDsn,
                release: gitCommit,
                beforeSend: mockBeforeSendHandler,
            })
        );
    });

    test('initializes "Sentry" with ignoreErrors when provided', async () => {
        const sentryDsn = '1234567';
        const gitCommit = 'abc';
        const ignoreErrors = [/ResizeObserver loop/, 'Network request failed'];

        const config = createConfig({ sentryDsn, gitCommit });
        initApplication({ ignoreErrors });
        await window.initFrontend(config, root);

        expect(Sentry.init).toHaveBeenCalledWith(
            expect.objectContaining({
                dsn: sentryDsn,
                release: gitCommit,
                ignoreErrors,
            })
        );
    });

    test('initializes "Sentry" with ignoreErrors undefined when not provided', async () => {
        const config = createConfig();
        initApplication();
        await window.initFrontend(config, root);

        expect(Sentry.init).toHaveBeenCalledWith(
            expect.objectContaining({
                ignoreErrors: undefined,
            })
        );
    });

    test('initializes "Sentry" with both ignoreErrors and sensitiveScrubFields', async () => {
        const sentryDsn = '1234567';
        const gitCommit = 'abc';
        const sensitiveScrubFields = ['password'];
        const ignoreErrors = [/ResizeObserver loop/];
        const mockBeforeSendHandler = vi.fn();

        vi.spyOn(sentryScrubber, 'createSentryScrubber').mockReturnValue({
            beforeSendHandler: mockBeforeSendHandler,
        } as unknown as sentryScrubber.SentryScrubber);

        const config = createConfig({ sentryDsn, gitCommit });
        initApplication({ sensitiveScrubFields, ignoreErrors });
        await window.initFrontend(config, root);

        expect(Sentry.init).toHaveBeenCalledWith(
            expect.objectContaining({
                dsn: sentryDsn,
                release: gitCommit,
                ignoreErrors,
                beforeSend: mockBeforeSendHandler,
            })
        );
    });

    test('initializes "Datadog"', async () => {
        const config = createConfig({ datadogApplicationId: 'test' });
        initApplication();
        await window.initFrontend(config, root);

        expect(Datadog.init).toHaveBeenCalledWith(
            {
                ...config,
                appTitle: config.appName,
            },
            { ignoreErrors: undefined }
        );
    });

    test('passes ignoreErrors through to "Datadog"', async () => {
        const ignoreErrors = [/ResizeObserver loop/, 'Network request failed'];
        const config = createConfig({ datadogApplicationId: 'test' });
        initApplication({ ignoreErrors });
        await window.initFrontend(config, root);

        expect(Datadog.init).toHaveBeenCalledWith(expect.anything(), { ignoreErrors });
    });

    test('invokes react-dom createRoot', async () => {
        const config = createConfig();
        initApplication();
        await window.initFrontend(config, root);

        expect(ReactDomClient.createRoot).toHaveBeenCalledTimes(1);
    });

    describe('with "domRender" callback', () => {
        test('invokes custom render function', async () => {
            const config = createConfig();
            const domRenderFn = vi.fn();

            initApplication({}, domRenderFn);
            await window.initFrontend(config, root);

            expect(domRenderFn).toHaveBeenCalledTimes(1);
        });
    });

    describe('with "locale" in config', () => {
        test('fixates locale', async () => {
            const defaultLocale = 'nb';
            const locale = 'es';
            const config = createConfig({ locale });

            initApplication({
                localization: { defaultLocale, brand: config.brand },
            });
            await window.initFrontend(config, root);

            expect(app.initApplicationLocale).toHaveBeenCalledWith({
                fixedLocale: locale,
                defaultLocale,
                brand: config.brand,
            });
        });
    });

    describe('with "locales" in config', () => {
        beforeEach(() => {
            vi.spyOn(i18n, 'appendTranslations');
        });

        test('registers locales', async () => {
            const locales = { en: { term: 'translation' } };
            const config = createConfig();

            initApplication({
                locales,
            });

            await window.initFrontend(config, root);

            expect(i18n.appendTranslations).toHaveBeenCalledWith(locales);
        });
    });

    describe('with "module" in options', () => {
        test('filters module routes to the given module name', async () => {
            const moduleOne = 'module-one';
            const moduleTwo = 'module-two';
            const config = createConfig({ module: moduleTwo });

            const moduleOneRoute = {
                path: '/module-one/page',
                page: () => <>module page</>,
                moduleName: moduleOne,
            };

            const moduleTwoRoute = {
                path: '/module-two/page',
                page: () => <>module page</>,
                moduleName: moduleTwo,
            };

            const rootRoute = { path: '/', page: () => <>home</> };

            routeUtils.registerModuleRoute(moduleOne, moduleOneRoute);
            routeUtils.registerModuleRoute(moduleTwo, moduleTwoRoute);

            initApplication({
                routes: [rootRoute],
            });

            await window.initFrontend(config, root);

            const element = await screen.findByTestId('props');
            const { routes: appRoutes } = JSON.parse(element.innerHTML);

            expect(appRoutes).toEqual([
                {
                    ...rootRoute,
                    page: undefined, // because of the json serialization
                },
                {
                    ...moduleTwoRoute,
                    page: undefined,
                },
            ]);
        });
    });

    describe('on exception', () => {
        test('renders fatal error message page', async () => {
            const config = createConfig();
            const pluginName = 'Bad plugin';
            const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);

            initApplication({
                plugins: [
                    {
                        name: pluginName,
                        onInit: async () => await Promise.reject(new Error('Failed to init')),
                    },
                ],
            });

            await window.initFrontend(config, root);

            expect(await screen.findByTestId('FatalErrorPage')).toBeInTheDocument();
            expect(consoleSpy).toHaveBeenCalledWith(
                expect.objectContaining({
                    message: `The plugin "${pluginName}" failed while executing the "onInit" callback.`,
                })
            );
            consoleSpy.mockRestore();
        });
    });

    describe('returned render function', () => {
        test('invokes react createRoot', async () => {
            const config = createConfig();

            const render = initApplication();

            await render(config);

            expect(ReactDomClient.createRoot).toHaveBeenCalledTimes(1);
        });

        test('lets you specify a custom react dom render function', async () => {
            const config = createConfig();
            const domRenderFn = vi.fn();

            const render = initApplication();
            await render(config, domRenderFn);

            expect(domRenderFn).toHaveBeenCalledTimes(1);
        });

        test('throws if no "root" element is found', async () => {
            const config = createConfig();
            const domRenderFn = vi.fn();

            const render = initApplication();

            root.id = 'rot';

            await expect(async () => await render(config, domRenderFn)).rejects.toThrow(
                'No app root element with id: "root"'
            );

            root.id = 'root';
        });
    });

    test('merges module resourceTypeActions with root resourceTypeActions', async () => {
        const config = createConfig();

        const moduleResourceTypeActions = [
            { resourceType: 'sound_recording', action: 'create' },
            { resourceType: 'sound_recording', action: 'update' },
        ];

        const rootResourceTypeActions = [
            { resourceType: 'artist', action: 'update' },
            { resourceType: 'artist', action: 'create' },
        ];

        vi.spyOn(modules, 'getModuleResourceTypeActions').mockReturnValue(
            moduleResourceTypeActions
        );

        initApplication({ resourceTypeActions: rootResourceTypeActions });

        await window.initFrontend(config, root);

        const element = await screen.findByTestId('props');
        const { resourceTypeActions } = JSON.parse(element.innerHTML);

        expect(resourceTypeActions).toEqual(
            mergeResourceTypeActions(rootResourceTypeActions, moduleResourceTypeActions)
        );
    });
});
