import { Log } from '@servicetitan/log-service';
import { inject, injectable, optional, provide, Store, SymbolToken } from '@servicetitan/react-ioc';
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, CanceledError } from 'axios';
import { ComponentType, FC, PropsWithChildren } from 'react';
import { loader } from '../loader';
import {
    AuthAdapter,
    BearerTokenAuth,
    TokenServerAuth,
    TokenServerAuthError,
} from './auth-adapters';
export const RETRY_LIMIT = 1;

export interface WithMicroserviceContext {
    authURL?: string;
    baseURL: string;
}

type AuthAdapterCallback = (context: WithMicroserviceContext) => AuthAdapter;

export interface WithMicroserviceOptions<T = {}> {
    authURL?: string;
    axiosInstance?: AxiosInstance;
    baseURL: string;
    tokens?: SymbolToken<string>[];
    component: ComponentType<PropsWithChildren<T>>;
    authAdapter?: 'bearer' | 'token' | AuthAdapterCallback;
    extraHeaders?: { [key: string]: any };
    /**
     * Run authentication method as soon as the component is mounted.
     */
    preAuthenticate?: boolean;
    preAuthenticateCallback?: (error?: any) => void;
}

export interface AxiosRequestConfigAuthentication {
    skipAuth?: boolean;
}

interface AxiosRequestConfigMetadata {
    metadata?: {
        authAdapterId?: symbol;
        retries?: number;
    };
}

type AuthRequestConfig = AxiosRequestConfig &
    AxiosRequestConfigAuthentication &
    AxiosRequestConfigMetadata;

export function withMicroservice<T>(options: WithMicroserviceOptions<T>) {
    const {
        authURL,
        axiosInstance = axios,
        baseURL,
        tokens = [],
        component: UnwrappedComponent,
        authAdapter = 'bearer',
        extraHeaders = undefined,
    } = options;

    @injectable()
    class MicroserviceAuthStore extends Store {
        private authAdapter: AuthAdapter;
        private authAdapterId: symbol;
        private authenticatePromise?: Promise<void>;
        private axiosRequestInterceptor?: number;
        private axiosResponseInterceptor?: number;
        private isMounted?: boolean;
        private readonly log?: Log;
        private requestQueue: { retry: () => void; reject: () => void; cancel: () => void }[] = [];

        constructor(@optional() @inject(Log) log?: Log) {
            super();

            const context: WithMicroserviceContext = { authURL, baseURL };
            this.authAdapter = this.createAuthAdapter(authAdapter, context);
            this.authAdapterId = Symbol('authAdapterId');
            this.log = log;
        }

        initialize() {
            this.isMounted = true;
            this.addInterceptors();

            /*
             * When using TokenServerAuth, we need to always authenticate before making requests.
             * If we do not, then there is a chance that the microservice is already authenticated
             * with the wrong user. Because of this, requests may never be 401s, resulting in
             * authentication never happening, and the incorrect user remaining. Ensuring
             * authentication immediately on mount ensures that authentication occurs before making
             * requests, and ensures that the correct user is being used.
             */
            const preAuthenticate =
                this.authAdapter instanceof TokenServerAuth || options.preAuthenticate;

            if (preAuthenticate) {
                this.authenticate()
                    .then(() => {
                        options.preAuthenticateCallback?.();
                    })
                    .catch(error => {
                        if (options.preAuthenticateCallback) {
                            options.preAuthenticateCallback(error);
                            return;
                        }
                        if (
                            error instanceof TokenServerAuthError ||
                            error instanceof CanceledError
                        ) {
                            return;
                        }
                        const category = 'AjaxHandlers.PreAuthenticate';
                        const message = 'unexpected error, check token server configuration';
                        if (this.log) {
                            this.log.error({ category, message, error });
                        } else {
                            // eslint-disable-next-line no-console
                            console.error(category, message, { error });
                        }
                    });
            }
        }

        dispose() {
            try {
                this.authAdapter.onDestroy?.();
            } catch (e) {
                // eslint-disable-next-line no-console
                console.error('Error destroying auth adapter', e);
            }

            this.authenticatePromise = undefined;
            this.removeInterceptors();
            this.isMounted = false;
        }

        private get isAuthenticating() {
            return Boolean(this.authenticatePromise);
        }

        private createAuthAdapter(
            authAdapter: WithMicroserviceOptions<any>['authAdapter'],
            context: WithMicroserviceContext
        ): AuthAdapter {
            if (typeof authAdapter === 'function') {
                return authAdapter(context);
            }
            if (authAdapter === 'bearer') {
                return new BearerTokenAuth(context);
            }
            if (authAdapter === 'token') {
                return new TokenServerAuth(context);
            }
            throw new Error(`unrecognized authAdapter: ${authAdapter}`);
        }

        private addInterceptors() {
            this.addRequestInterceptor();
            this.addResponseInterceptor();
        }

        private removeInterceptors() {
            this.removeRequestInterceptor();
            this.removeResponseInterceptor();
        }

        private addRequestInterceptor() {
            this.axiosRequestInterceptor = axiosInstance.interceptors.request.use(async config => {
                if (!this.shouldInterceptRequest(config)) {
                    return config;
                }

                this.claim(config);
                config.suppressShowError = [401];

                if (this.isAuthenticating) {
                    try {
                        // Wait for authentication to complete
                        await this.authenticatePromise;
                    } catch {
                        // If authentication was canceled or fails, cancel this request
                        const controller = new AbortController();
                        controller.abort();
                        return {
                            ...config,
                            signal: controller.signal,
                        };
                    }
                }

                this.authAdapter.interceptRequest?.(config);
                this.addExtraHeaders(config);
                return config;
            });
        }

        private addResponseInterceptor() {
            this.axiosResponseInterceptor = axiosInstance.interceptors.response.use(
                response => response,
                (axiosError: AxiosError) => {
                    if (!this.shouldInterceptResponseError(axiosError)) {
                        return Promise.reject(axiosError);
                    }

                    const config: AuthRequestConfig = axiosError.response?.config ?? {};

                    if (this.isAuthenticating) {
                        // Queue up request while authenticate is in progress (triggered by another request)
                        return new Promise((resolve, reject) => {
                            this.requestQueue.push({
                                retry: () => resolve(this.fetchRequestWithRetryMetadata(config)),
                                reject: () => reject(axiosError),
                                cancel: () => reject(new CanceledError()),
                            });
                        });
                    }

                    if (this.authAdapter.didAuthenticationHappenWhileRequestInFlight?.(config)) {
                        return this.fetchRequestWithRetryMetadata(config);
                    }

                    return this.authenticate(config)
                        .catch(e => {
                            // If authentication was canceled, cancel all api requests (current and queued)
                            if (axios.isCancel(e)) {
                                this.requestQueue.forEach(request => request.cancel());
                                return Promise.reject(e);
                            }

                            // Otherwise, authentication failed, so reject all with original error (401)
                            this.requestQueue.forEach(request => request.reject());
                            /*
                             * Reject with 401 error from original request instead of auth error
                             * This is because if auth fails, the code that called the request that
                             * required authentication should not need to be aware of how to handle
                             * the auth error, but only a 401 error.
                             */
                            return Promise.reject(axiosError);
                        })
                        .then(() => {
                            /*
                             * If any other requests requiring authentication were attempting to be made
                             * while this request was running, they were queued and will now be run now
                             * that the authentication has finished.
                             */
                            this.requestQueue.forEach(request => request.retry());
                            this.requestQueue = [];
                            return this.fetchRequestWithRetryMetadata(config);
                        });
                }
            );
        }

        private removeRequestInterceptor() {
            if (this.axiosRequestInterceptor !== undefined) {
                axiosInstance.interceptors.request.eject(this.axiosRequestInterceptor);
            }
        }

        private removeResponseInterceptor() {
            if (this.axiosResponseInterceptor !== undefined) {
                axiosInstance.interceptors.response.eject(this.axiosResponseInterceptor);
            }
        }

        private shouldInterceptRequest(config: AuthRequestConfig) {
            return (
                this.hasClaimed(config) ||
                (this.isUnclaimed(config) && !config.skipAuth && this.baseURLMatchesConfig(config))
            );
        }

        private shouldInterceptResponseError(error: AxiosError) {
            const response = error.response;
            const config: AuthRequestConfig = response?.config ?? {};

            if ((config.metadata?.retries ?? 0) >= RETRY_LIMIT) {
                // eslint-disable-next-line no-console
                console.error(`withMicroservice has reached request retry limit for ${config.url}`);
                return false;
            }

            return (
                !!this.isMounted &&
                !axios.isCancel(error) &&
                this.hasClaimed(config) &&
                response?.status === 401
            );
        }

        private fetchRequestWithRetryMetadata(config: AuthRequestConfig) {
            config.metadata ??= {};
            config.metadata.retries = (config.metadata.retries ?? 0) + 1;
            return axiosInstance(config);
        }

        private claim(config: AuthRequestConfig) {
            config.metadata ??= {};
            config.metadata.authAdapterId = this.authAdapterId;
        }

        private hasClaimed(config: AuthRequestConfig) {
            return config.metadata?.authAdapterId === this.authAdapterId;
        }

        private isUnclaimed(config: AuthRequestConfig) {
            return !config.metadata?.authAdapterId;
        }

        private authenticate(config?: AxiosRequestConfig) {
            if (!config?.hideGlobalLoader) {
                loader.show();
            }

            this.authenticatePromise = this.authAdapter.authenticate().finally(() => {
                this.authenticatePromise = undefined;
                if (!config?.hideGlobalLoader) {
                    loader.hide();
                }
            });
            return this.authenticatePromise;
        }

        private addExtraHeaders(config: AxiosRequestConfig) {
            if (!extraHeaders) {
                return;
            }

            config.headers = {
                ...config.headers,
                ...extraHeaders,
            };
        }

        private baseURLMatchesConfig(config: AxiosRequestConfig) {
            return config.baseURL === baseURL || config.url?.startsWith(baseURL);
        }
    }

    const apiBaseUrlConfigs = tokens.map(token => ({
        provide: token,
        useValue: baseURL,
    }));

    const WrappedComponent = provide({
        singletons: [MicroserviceAuthStore, ...apiBaseUrlConfigs],
    })(UnwrappedComponent) as FC<PropsWithChildren<T>>;

    return WrappedComponent;
}
