import { Log } from '@servicetitan/log-service';
import { Provider, symbolToken, useDependencies } from '@servicetitan/react-ioc';
import '@testing-library/jest-dom';
import { renderHook, waitFor } from '@testing-library/react';
import axios, { AxiosRequestConfig, CanceledError } from 'axios';
import { delay, http, HttpResponse } from 'msw';
import { FC, PropsWithChildren } from 'react';
import mock from 'xhr-mock';
import { server } from '../__mocks__/server';
import {
    AxiosRequestConfigAuthentication,
    BearerTokenAuth,
    RETRY_LIMIT,
    TokenServerAuthError,
    withMicroservice,
    WithMicroserviceOptions,
} from '../with-microservice';

describe(`[ajax-handlers] ${withMicroservice.name}`, () => {
    const expectedUsername = 'foo';
    const defaultEndpoint = '/user';
    const baseURL = 'http://localhost';
    const authURL = `${baseURL}/refresh_token/${expectedUsername}`;
    const UnwrappedComponent: FC<PropsWithChildren> = ({ children }) => <div>{children}</div>;

    let requestCounts: Record<string, number>; // tracks how many times each request is made
    let abortController: AbortController;
    let axiosRequestConfig:
        | (AxiosRequestConfig & AxiosRequestConfigAuthentication)
        | (AxiosRequestConfig & AxiosRequestConfigAuthentication)[]
        | undefined;
    let options: WithMicroserviceOptions;
    let requestsCallback: () => Promise<any>;
    let singletons: Parameters<typeof Provider>[0]['singletons'];

    beforeAll(() => {
        mock.teardown();
        server.listen();
        server.events.on('request:start', ({ request }: { request: Request }) => {
            const path = new URL(request.url).pathname;
            requestCounts[path] = (requestCounts[path] ?? 0) + 1;
        });
    });
    afterAll(() => {
        server.close();
        server.events.removeAllListeners('request:start');
    });
    beforeEach(() => {
        jest.clearAllMocks();
        requestCounts = {};
        abortController = new AbortController();
        axiosRequestConfig = undefined;
        options = { baseURL, authURL, component: UnwrappedComponent };
        requestsCallback = makeRequests;
        singletons = undefined;
    });
    afterEach(() => {
        server.resetHandlers();
    });

    function getRequestCount() {
        return requestCounts[defaultEndpoint] ?? 0;
    }

    function getAuthRequestCount() {
        return requestCounts[new URL(authURL).pathname] ?? 0;
    }

    function createAxiosRequestConfig(
        config: Partial<AxiosRequestConfig & AxiosRequestConfigAuthentication> = {}
    ) {
        return {
            baseURL: options.baseURL,
            url: defaultEndpoint,
            method: 'GET',
            signal: abortController.signal,
            ...config,
        };
    }

    async function makeRequests() {
        const config = axiosRequestConfig ?? createAxiosRequestConfig();
        const result = await Promise.all(
            [config].flat().map(opt => (options.axiosInstance ?? axios).request(opt))
        );
        return result.length === 1 ? result[0].data : result.map(({ data }) => data);
    }

    const subject = async () => {
        const Wrapper: FC<PropsWithChildren> = ({ children }) => (
            <Provider singletons={singletons}>{withMicroservice(options)({ children })}</Provider>
        );

        const hook = renderHook(() => () => requestsCallback(), { wrapper: Wrapper });

        await waitFor(() => {
            expect(hook.result.current).not.toBeNull();
        });

        return { hook, getUser: hook.result.current };
    };

    test('makes a request to the authURL to get the auth token, and then uses the auth token in the original request to get the requested data', async () => {
        const { username } = await (await subject()).getUser();

        expect(getRequestCount()).toBe(2);
        expect(getAuthRequestCount()).toBe(1);
        expect(username).toBe(expectedUsername);
    });

    describe('when multiple adapters are registered for the same baseURL', () => {
        const authAdapter = new BearerTokenAuth({ baseURL, authURL });

        beforeEach(async () => {
            await authAdapter.authenticate();
            options.authAdapter = () => authAdapter;
            options.component = withMicroservice(options);
            jest.spyOn(authAdapter, 'interceptRequest');
        });

        test('sends request to only one adapter', async () => {
            await (await subject()).getUser();

            expect(jest.mocked(authAdapter.interceptRequest)).toHaveBeenCalledTimes(1);
        });
    });

    describe('when skipAuth is true', () => {
        beforeEach(() => (axiosRequestConfig = createAxiosRequestConfig({ skipAuth: true })));

        test('does not make auth request', async () => {
            await expect((await subject()).getUser()).rejects.toEqual(
                expect.objectContaining({ message: expect.stringMatching(/401/) })
            );
        });
    });

    test(`throws ${CanceledError.name} when the request is canceled`, async () => {
        const request = (await subject()).getUser();
        abortController.abort();

        await expect(request).rejects.toBeInstanceOf(CanceledError);
    });

    test('removes axios interceptors when component is unmounted', async () => {
        const { hook } = await subject();
        hook.unmount();

        const requestHandlers = axios.interceptors.request.handlers!.filter(h => !!h);
        const responseHandlers = axios.interceptors.response.handlers!.filter(h => !!h);
        expect(requestHandlers).toHaveLength(0);
        expect(responseHandlers).toHaveLength(0);
    });

    test('rejects invalid authAdapter', () => {
        jest.spyOn(console, 'error').mockImplementation(jest.fn()); // suppress uncaught exception error

        expect(() =>
            renderHook(() => null, {
                wrapper: withMicroservice({
                    ...options,
                    authAdapter: 'foo',
                    preAuthenticate: true,
                } as any),
            })
        ).toThrow(/unrecognized authAdapter/);
    });

    describe('when two requests are made simultaneously', () => {
        beforeEach(() => {
            axiosRequestConfig = [createAxiosRequestConfig(), createAxiosRequestConfig()];
        });

        test('makes one auth request', async () => {
            await (await subject()).getUser();

            expect(getAuthRequestCount()).toBe(1);
        });

        test('makes four requests (1st and 2nd fail then retry)', async () => {
            await (await subject()).getUser();

            expect(getRequestCount()).toBe(4);
        });
    });

    describe('when two requests are made sequentially', () => {
        const setup = async () => {
            const { getUser } = await subject();
            await getUser();
            await getUser();
        };

        test('makes one auth request', async () => {
            await setup();

            expect(getAuthRequestCount()).toBe(1);
        });

        test('makes three requests (1st fails and retries, 2nd succeeds)', async () => {
            await setup();

            expect(getRequestCount()).toBe(3);
        });
    });

    describe('when a second request is made while the auth request is in flight', () => {
        beforeEach(() => (options.authURL = `${authURL}?delay=200`));

        async function makeRequestsWithDelay(delay: number) {
            const { getUser, hook } = await subject();

            const promises = [
                getUser(),
                new Promise(resolve => setTimeout(() => resolve(getUser()), delay)),
            ];

            return { promises, hook };
        }

        const setup = async () => {
            return Promise.all((await makeRequestsWithDelay(100)).promises);
        };

        test('makes one auth request', async () => {
            await setup();

            expect(getAuthRequestCount()).toBe(1);
        });

        test('makes three requests (1st fails and retries, 2nd waits for auth)', async () => {
            await setup();

            expect(getRequestCount()).toBe(3);
        });

        describe('when the component is unmounted while the auth request is in flight', () => {
            beforeEach(() => (options.authURL = `${authURL}?delay=100`));

            const unMountSetup = async () => {
                const { hook, promises } = await makeRequestsWithDelay(40);

                setTimeout(() => hook.unmount(), 80);

                return Promise.allSettled(promises);
            };

            /**
             * First api request fires, is 401, authentication fires
             * Second api request fires, waits while authentication is in flight
             * Component is unmounted, all requests are canceled
             */
            test('cancels all requests', async () => {
                const errors = (await unMountSetup()) as PromiseRejectedResult[];

                for (let i = 0; i < 2; i++) {
                    expect(errors[i].reason).toBeInstanceOf(CanceledError);
                }
                expect(getRequestCount()).toBe(1);
            });
        });

        describe('when the authentication response is a 401', () => {
            const setup = async () => {
                return Promise.allSettled((await makeRequestsWithDelay(100)).promises);
            };

            beforeEach(() => {
                server.use(
                    http.get('/refresh_token/:username', async () => {
                        await delay(300);
                        return new HttpResponse(null, { status: 401 });
                    })
                );
            });

            /**
             * First api request fires, is 401, authentication fires
             * Second api request fires, waits while authentication is in flight
             * Authentication is 401, first api request errors with 401
             * Waiting requests are canceled
             */
            test('first request errors with 401 and second is canceled', async () => {
                const errors = (await setup()) as PromiseRejectedResult[];

                expect(errors[0].reason).toEqual(
                    expect.objectContaining({
                        message: expect.stringMatching(/401/),
                        response: expect.objectContaining({
                            config: expect.objectContaining({
                                url: defaultEndpoint,
                            }),
                        }),
                    })
                );
                expect(errors[1].reason).toBeInstanceOf(CanceledError);
                expect(getRequestCount()).toBe(1);
                expect(getAuthRequestCount()).toBe(1);
            });
        });
    });

    describe('when no baseURL is provided in the Axios config, but the requested url starts with the withMicroservice baseURL', () => {
        beforeEach(() => {
            axiosRequestConfig = createAxiosRequestConfig({
                baseURL: undefined,
                url: `${baseURL}${defaultEndpoint}`,
            });
        });

        test('makes auth request', async () => {
            await (await subject()).getUser();

            expect(getAuthRequestCount()).toBe(1);
        });
    });

    describe('when the requested url does not match, nor is part of, the withMicroservice baseURL', () => {
        beforeEach(() => {
            axiosRequestConfig = createAxiosRequestConfig({
                baseURL: 'http://foobar',
                url: '/user',
            });

            server.use(
                http.get('http://foobar/user', () => new HttpResponse(null, { status: 401 }))
            );
        });

        test('throws a 401 error, ignoring the interceptors added for the withMicroservice baseURL', async () => {
            await expect((await subject()).getUser()).rejects.toEqual(
                expect.objectContaining({ message: expect.stringMatching(/401/) })
            );
            expect(getAuthRequestCount()).toBe(0);
        });
    });

    describe('when a request is made with an invalid url', () => {
        beforeEach(() => (axiosRequestConfig = createAxiosRequestConfig({ url: '/broken' })));

        test('throws a Network Error', async () => {
            await expect((await subject()).getUser()).rejects.toEqual(
                expect.objectContaining({ message: 'Network Error' })
            );
        });
    });

    describe('when the auth url receives 401 response', () => {
        beforeEach(() => {
            server.use(
                http.get('/refresh_token/:username', () => new HttpResponse(null, { status: 401 }))
            );
        });

        test("throws the original request's 401 error", async () => {
            await expect((await subject()).getUser()).rejects.toEqual(
                expect.objectContaining({
                    message: expect.stringMatching(/401/),
                    request: expect.objectContaining({
                        responseURL: `${baseURL}${defaultEndpoint}`,
                    }),
                })
            );
        });
    });

    describe('when simultaneous requests are made without an auth token', () => {
        function createRequestConfigsWithDelay(...delays: number[]) {
            return delays.map(delay => createAxiosRequestConfig({ url: `/user?delay=${delay}` }));
        }

        beforeEach(() => (axiosRequestConfig = createRequestConfigsWithDelay(0, 0, 0)));

        test('installs only one request interceptor', async () => {
            await subject();

            const handlers = axios.interceptors.request.handlers!.filter(h => !!h);
            expect(handlers).toHaveLength(1);
        });

        test('installs only one response interceptor', async () => {
            await subject();

            const handlers = axios.interceptors.response.handlers!.filter(h => !!h);
            expect(handlers).toHaveLength(1);
        });

        describe('with the other requests taking longer than the first', () => {
            beforeEach(() => (axiosRequestConfig = createRequestConfigsWithDelay(50, 25, 0)));

            test('makes only one auth request', async () => {
                await (await subject()).getUser();

                expect(getAuthRequestCount()).toBe(1);
            });
        });

        describe('when preAuthenticate is true', () => {
            beforeEach(() => (options.preAuthenticate = true));

            /*
             * Since the auth request will happen on mount, and all other requests wait until
             * it is done, requests will only be made once each instead of twice (totalling 3)
             */
            test('fetches each of the 3 requests only once', async () => {
                await (await subject()).getUser();

                expect(getRequestCount()).toBe(3);
            });

            describe('when preAuthenticateCallback is provided', () => {
                beforeEach(() => (options.preAuthenticateCallback = jest.fn()));

                test('calls preAuthenticateCallback', async () => {
                    await (await subject()).getUser();

                    expect(options.preAuthenticateCallback).toHaveBeenCalled();
                });
            });

            describe('when the preauth request fails', () => {
                const category = 'AjaxHandlers.PreAuthenticate';
                const message = expect.stringMatching(/unexpected error/);
                const error = expect.objectContaining({ message: expect.stringMatching(/401/) });

                let consoleSpy: jest.SpyInstance;

                beforeEach(() => {
                    consoleSpy = jest.spyOn(console, 'error').mockImplementation(jest.fn());
                    server.use(
                        http.get(
                            '/refresh_token/:username',
                            () => new HttpResponse(null, { status: 401 })
                        )
                    );
                });

                test('logs console error', async () => {
                    await subject();

                    expect(consoleSpy).toHaveBeenCalledWith(category, message, { error });
                });

                describe('when Log is provided', () => {
                    const mockLog = { error: jest.fn() };

                    beforeEach(() => (singletons = [{ provide: Log, useValue: mockLog }]));

                    test('calls Log.error instead', async () => {
                        await subject();

                        expect(consoleSpy).not.toHaveBeenCalled();
                        expect(mockLog.error).toHaveBeenCalledWith({ category, message, error });
                    });
                });

                describe('when preAuthenticateCallback is provided', () => {
                    beforeEach(() => (options.preAuthenticateCallback = jest.fn()));

                    test('calls preAuthenticateCallback instead', async () => {
                        await subject();

                        expect(consoleSpy).not.toHaveBeenCalled();
                        expect(options.preAuthenticateCallback).toHaveBeenCalledWith(error);
                    });
                });

                [CanceledError, TokenServerAuthError].forEach(Kind => {
                    describe(`when request fails due to ${Kind.name}`, () => {
                        const mockAdapter = {
                            authenticate: () => {
                                return Promise.reject(new Kind('Oops', null));
                            },
                        };

                        beforeEach(() => {
                            options.authAdapter = () => mockAdapter;
                        });

                        test('does not log error', async () => {
                            await subject();

                            expect(consoleSpy).not.toHaveBeenCalled();
                        });
                    });
                });
            });
        });

        describe('when 401 responses are queued for authentication while authentication is occurring', () => {
            beforeEach(() => {
                options.authURL = `${authURL}?delay=500`;
                requestsCallback = () => {
                    const config = axiosRequestConfig as AxiosRequestConfig[];
                    return Promise.allSettled(config.map(opt => axios.request(opt)));
                };
            });

            /**
             * First, second, and third api request fire
             * First api request is 401, authentication fires
             * Second and third api requests are 401, they are queued
             * Component is unmounted, all requests are canceled
             */
            test('rejects all requests CanceledError when the auth request is canceled', async () => {
                const { getUser, hook } = await subject();

                setTimeout(() => hook.unmount(), 150);

                const errors = (await getUser()) as PromiseRejectedResult[];

                for (let i = 0; i < 3; i++) {
                    expect(errors[i].reason).toBeInstanceOf(CanceledError);
                }
                expect(getRequestCount()).toBe(3);
            });
        });
    });

    describe('when wrapped component is unmounted while a request is in flight', () => {
        const setup = async () => {
            const { hook, getUser } = await subject();
            const user = getUser(); // Takes 100ms to resolve
            hook.unmount();
            return user;
        };

        test('ignores interceptor workflow and auth url is not requested', async () => {
            await waitFor(() =>
                expect(setup()).rejects.toEqual(
                    expect.objectContaining({ message: expect.stringMatching(/401/) })
                )
            );
        });
    });

    describe('when the first auth url returns a 401 response', () => {
        beforeEach(() => {
            server.use(
                http.get(
                    '/refresh_token/:username',
                    () => new HttpResponse(null, { status: 401 }),
                    { once: true }
                )
            );
        });

        test('requests the auth url each time when sequential requests are made without an auth token', async () => {
            await expect((await subject()).getUser()).rejects.toEqual(
                expect.objectContaining({ message: expect.stringMatching(/401/) })
            );
            await (await subject()).getUser();

            expect(getAuthRequestCount()).toBe(2);
        });
    });

    describe("when token is retrieved successfully but it's invalid", () => {
        beforeEach(() => {
            server.use(http.get('/user', () => HttpResponse.json(null, { status: 401 })));
        });

        test('rejects request as 401 after the retry limit is reached', async () => {
            jest.spyOn(console, 'error').mockImplementation(jest.fn()); // suppress "retry limit reached" error

            await expect((await subject()).getUser()).rejects.toEqual(
                expect.objectContaining({ message: expect.stringMatching(/401/) })
            );

            expect(getRequestCount()).toBe(1 + RETRY_LIMIT);
        });
    });

    describe('when tokens are provided', () => {
        const BASE_URL_TOKEN = symbolToken<string>('BASE_URL_TOKEN', false);

        beforeEach(() => (options.tokens = [BASE_URL_TOKEN]));

        const subject = async () => {
            const { result } = renderHook(() => useDependencies(BASE_URL_TOKEN), {
                wrapper: withMicroservice(options),
            });

            await waitFor(() => {
                expect(result.current).not.toBeNull();
            });

            return result.current[0];
        };

        it('assigns the baseURL to the dependency retrieved from that token', async () => {
            const baseUrlFromToken = await subject();

            expect(baseUrlFromToken).toBe(baseURL);
        });
    });

    describe('when an axios instance is provided with a baseURL', () => {
        const baseURL = '/api';
        const authURL = `${baseURL}/refresh_token/${expectedUsername}`;

        beforeEach(() => {
            options.axiosInstance = axios.create({ baseURL });
            options.baseURL = baseURL;
            options.authURL = authURL;

            server.use(
                http.get(
                    `${baseURL}${defaultEndpoint}`,
                    () => {
                        return new HttpResponse(null, { status: 401 });
                    },
                    { once: true }
                ),
                http.get(`${baseURL}${defaultEndpoint}`, () => {
                    return new HttpResponse(null, { status: 200 });
                }),
                http.get(authURL, () => {
                    return new HttpResponse(null, { status: 200 });
                })
            );
        });

        test('makes a request to the endpoint, then authURL, then endpoint', async () => {
            await (await subject()).getUser();

            expect(requestCounts[`${baseURL}${defaultEndpoint}`]).toBe(2);
            expect(requestCounts[authURL]).toBe(1);
        });
    });
});
