/* eslint-disable react/jsx-props-no-spreading */
import * as React from 'react';
import { render, waitForElementToBeRemoved } from '@testing-library/react';
import * as debug from '@atlassian/clientside-extensions-debug';

import { AsyncPanelHandler } from './AsyncPanelHandler';
import type { AsyncPanelRenderExtension, AsyncPanelHandlerProps } from './AsyncPanelHandler';

debug.setLoggingEnabled(true);
debug.setDebugEnabled(true);
// avoid spamming the console with error/warn messages
// eslint-disable-next-line no-underscore-dangle
debug._deregisterDefaultLogger();

const onMountCallbackSpy = jest.fn();
const onUnmountCallbackSpy = jest.fn();
const moduleSpy = jest.fn();

// onAction method of an Extension
const createFakeRenderProvider: () => AsyncPanelRenderExtension = (): AsyncPanelRenderExtension => () =>
    // fake async module
    Promise.resolve({
        __esModule: true,
        default: moduleSpy,
    });

const LOCATION = 'test-location';
const EXTENSION_KEY = 'test-extension';
const TestComponent = (props: AsyncPanelHandlerProps) => {
    return <AsyncPanelHandler {...props} />;
};
TestComponent.defaultProps = {
    renderProvider: createFakeRenderProvider(),
    fallback: <p data-testid="loading">loading</p>,
    location: LOCATION,
    extensionKey: EXTENSION_KEY,
} as Partial<AsyncPanelHandlerProps>;

const getFirstCallArguments = (onDebugSpy: jest.Mock) => onDebugSpy.mock.calls[0][0];

describe('AsyncPanelHandler', () => {
    const onDebugSpy = jest.fn();
    debug.observeLogger(onDebugSpy);

    beforeEach(() => {
        onMountCallbackSpy.mockReset();
        onUnmountCallbackSpy.mockReset();
        moduleSpy.mockReset();
        onDebugSpy.mockClear();

        moduleSpy.mockImplementation((panelAPI /* context, */ /* data */) => {
            panelAPI.onMount(onMountCallbackSpy);
            panelAPI.onUnmount(onUnmountCallbackSpy);
        });
    });

    it('should load and render the panel content asynchronously', async () => {
        const CUSTOM_CONTENT = `custom content`;
        onMountCallbackSpy.mockImplementationOnce((container: HTMLElement) => {
            container.innerHTML = CUSTOM_CONTENT;
        });

        const { getByText, findByText, queryByTestId } = render(<TestComponent />);

        expect(queryByTestId('loading')).toBeTruthy();
        expect(onMountCallbackSpy).toHaveBeenCalledTimes(0);

        // wait for element to be on the screen
        await findByText(CUSTOM_CONTENT);

        expect(getByText(CUSTOM_CONTENT)).toBeTruthy();
        expect(queryByTestId('loading')).toBeFalsy();
        expect(onMountCallbackSpy).toHaveBeenCalledTimes(1);
    });

    it('should provide an onMount and onUnmount API', async () => {
        const CUSTOM_CONTENT_1 = `custom content 1`;
        const CUSTOM_CONTENT_2 = `custom content 2`;

        const firstRenderProvider = createFakeRenderProvider();
        const secondRenderProvider = createFakeRenderProvider();

        onMountCallbackSpy
            .mockImplementationOnce((container: HTMLElement) => {
                container.innerHTML = CUSTOM_CONTENT_1;
            })
            .mockImplementationOnce((container: HTMLElement) => {
                container.innerHTML = CUSTOM_CONTENT_2;
            });

        const { rerender, findByText, queryByTestId, queryByText } = render(<TestComponent renderProvider={firstRenderProvider} />);

        expect(queryByTestId('loading')).toBeTruthy();
        expect(onMountCallbackSpy).toHaveBeenCalledTimes(0);
        expect(onUnmountCallbackSpy).toHaveBeenCalledTimes(0);

        // wait for element to be on the screen
        await findByText(CUSTOM_CONTENT_1);

        expect(queryByText(CUSTOM_CONTENT_1)).toBeTruthy();
        expect(queryByTestId('loading')).toBeFalsy();
        expect(onMountCallbackSpy).toHaveBeenCalledTimes(1);
        expect(onUnmountCallbackSpy).toHaveBeenCalledTimes(0);

        rerender(<TestComponent renderProvider={secondRenderProvider} />);

        // wait for element to be on the screen
        await findByText(CUSTOM_CONTENT_2);

        expect(queryByText(CUSTOM_CONTENT_2)).toBeTruthy();
        expect(queryByText(CUSTOM_CONTENT_1)).toBeFalsy();
        expect(queryByTestId('loading')).toBeFalsy();
        expect(onMountCallbackSpy).toHaveBeenCalledTimes(2);
        expect(onUnmountCallbackSpy).toHaveBeenCalledTimes(1);
    });

    it('should catch the error if the async module has a wrong shape', async () => {
        const expectedError = {
            level: debug.LogLevel.error,

            message: `The provided dynamic import of extension "${EXTENSION_KEY}" at location "${LOCATION}" does not look like a module that can be asynchronously loaded.`,
            components: ['AsyncPanelHandler'],
            meta: {
                location: LOCATION,
                extension: EXTENSION_KEY,
            },
        };

        expect(onDebugSpy).toHaveBeenCalledTimes(0);

        // @ts-expect-error - ignoring to force assigment of an empty object as a resolved module
        const { getByText } = render(<TestComponent renderProvider={() => Promise.resolve({})} />);

        await waitForElementToBeRemoved(() => getByText('loading'));

        expect(onDebugSpy).toHaveBeenCalledTimes(1);
        expect(getFirstCallArguments(onDebugSpy)).toMatchObject(expectedError);
    });

    it('should catch any error thrown by the render provider', async () => {
        const expectedError = {
            level: debug.LogLevel.error,

            message: `Failed trying to execute the dynamic import from extension "${EXTENSION_KEY}" for the async panel at location "${LOCATION}"`,
            components: ['AsyncPanelHandler'],
            meta: {
                location: LOCATION,
                extension: EXTENSION_KEY,
                error: new Error('test render error'),
            },
        };

        expect(onDebugSpy).toHaveBeenCalledTimes(0);

        const { getByText } = render(
            <TestComponent
                renderProvider={() => {
                    throw new Error('test render error');
                }}
            />,
        );

        await waitForElementToBeRemoved(() => getByText('loading'));

        expect(onDebugSpy).toHaveBeenCalledTimes(1);
        expect(getFirstCallArguments(onDebugSpy)).toMatchObject(expectedError);
    });

    it('should pass the context to the async module when the module was loaded', async () => {
        // given
        const contextValue = {
            myCounter: 1234,
        };

        const contextProvider = () => contextValue;

        onMountCallbackSpy.mockImplementation((container: HTMLElement) => {
            const contextArgPos = 1;
            const lastContext = moduleSpy.mock.calls[moduleSpy.mock.calls.length - 1][contextArgPos];

            container.innerHTML = `<p data-testid="label">My counter is: <strong data-testid="counter">${lastContext.myCounter}</strong></p>`;
        });

        // when
        const { findByTestId, getByTestId } = render(<TestComponent contextProvider={contextProvider} />);

        await findByTestId('counter');

        // then
        expect(getByTestId('label').textContent).toEqual('My counter is: 1234');
        expect(moduleSpy).toHaveBeenLastCalledWith(expect.any(Object), contextValue);
        expect(onDebugSpy).toHaveBeenCalledTimes(0);
    });

    it('should propagate the latest value of the context to the async module', async () => {
        // given
        const contextValue = {
            myCounter: 1234,
        };

        const contextProvider = () => contextValue;

        // when
        const { getByText, rerender } = render(<TestComponent contextProvider={contextProvider} />);

        await waitForElementToBeRemoved(() => getByText('loading'));

        // then
        expect(moduleSpy).toHaveBeenLastCalledWith(expect.any(Object), contextValue);
        expect(onDebugSpy).toHaveBeenCalledTimes(0);

        // New context value
        const newContextValue = 9876;
        const newContextProvider = () => newContextValue;

        rerender(<TestComponent contextProvider={newContextProvider} />);

        expect(moduleSpy).toHaveBeenLastCalledWith(expect.any(Object), newContextValue);
        expect(onDebugSpy).toHaveBeenCalledTimes(0);
    });

    it('should log an error when failed invoking context provider', async () => {
        // given
        const contextProvider = () => {
            throw new SyntaxError();
        };

        // when
        const { getByText } = render(<TestComponent contextProvider={contextProvider} />);

        await waitForElementToBeRemoved(() => getByText('loading'));

        // then
        expect(onDebugSpy).toHaveBeenCalledTimes(1);
        expect(getFirstCallArguments(onDebugSpy)).toMatchObject({
            level: debug.LogLevel.error,

            message: `Failed to invoke "contextProvider" function for extension "${EXTENSION_KEY}" at location "${LOCATION}".\nError: SyntaxError`,
            components: ['AsyncPanelHandler'],
            meta: {
                location: LOCATION,
                extension: EXTENSION_KEY,
            },
        });
    });
});
