import axios, { AxiosError } from 'axios';
import { isEqual, trimEnd } from 'es-toolkit';
import { LRUCache } from 'lru-cache';

import { CurlHelper } from './CurlHelper';
import { Client, ClientError, ClientOptions, Store, Token } from './types';

type RefreshSubscriber = (token?: string, error?: any) => void;

const clientCache = new LRUCache<
    string,
    {
        options: ClientOptions;
        client: Client;
    }
>({ max: 100 });

const tokenCache = new LRUCache<string, Token>({ max: 100 });

const getMemoryStore = (options: ClientOptions): Store => ({
    getToken: async (): Promise<Token | undefined> => {
        // eslint-disable-next-line no-console
        const log = options.onLog ?? console.log;
        log('getMemoryStore.getToken', options.name ?? options.fleetId);
        if (tokenCache.has(options.name ?? options.fleetId)) {
            log('getMemoryStore.getToken', tokenCache.get(options.name ?? options.fleetId));
            return tokenCache.get(options.name ?? options.fleetId)!;
        }

        return undefined;
    },
    setToken: async (token: Token): Promise<void> => {
        // eslint-disable-next-line no-console
        const log = options.onLog ?? console.log;
        log('getMemoryStore.setToken', options.name ?? options.fleetId, token);
        tokenCache.set(options.name ?? options.fleetId, token);
    },
});

const formatError = (error: any): ClientError => {
    if (error instanceof AxiosError) {
        return {
            originalError: error.toJSON(),
            formattedError: {
                status: error.response?.status ?? error.status,
                data: error.response?.data,
                message: error.message,
            },
        };
    }

    return {
        formattedError: {},
        originalError: JSON.parse(JSON.stringify(error, Object.getOwnPropertyNames(error))),
    };
};

const getClient = (options: ClientOptions): Client => {
    if (clientCache.has(options.name ?? options.fleetId)) {
        const { options: cachedOptions, client } = clientCache.get(options.fleetId)!;
        if (isEqual(cachedOptions, options)) {
            return client;
        }
    }

    const client = axios.create({
        baseURL: trimEnd(options.baseUrl, '/'),
        timeout: 30000,
        headers: {
            'Cache-Control': 'no-cache',
            'Content-Type': 'application/json',
            'X-Api-Key': options.apiKey,
        },
        withCredentials: false,
    }) as Client;
    client.clientOptions = options;

    const clientCredentialsAuthentification = async (): Promise<string> => {
        const params = new URLSearchParams();
        params.append('client_id', options.clientId);
        params.append('client_secret', options.clientSecret);
        params.append('securityOptions', 'SSL_OP_NO_SSLv3');
        params.append('grant_type', 'client_credentials');

        const { data: token } = await axios.post(
            `${trimEnd(options.baseUrl, '/')}/auth/realms/${options.fleetMaster ?? options.fleetId}/protocol/openid-connect/token`,
            params,
            {
                timeout: 30000,
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                withCredentials: false,
            }
        );
        const store = options.store ?? getMemoryStore(options);
        await store.setToken({
            accessToken: token.access_token,
            refreshToken: token.refresh_token,
        });
        return token.access_token;
    };

    const refreshTokenAuthentification = async (): Promise<string> => {
        const store = options.store ?? getMemoryStore(options);
        const oldToken = await store.getToken();
        if (!oldToken?.refreshToken) {
            throw new Error('No refresh token available');
        }
        const params = new URLSearchParams();
        params.append('client_id', options.clientId);
        params.append('client_secret', options.clientSecret);
        params.append('securityOptions', 'SSL_OP_NO_SSLv3');
        params.append('grant_type', 'refresh_token');
        params.append('refresh_token', oldToken.refreshToken);

        const { data: token } = await axios.post(
            `${trimEnd(options.baseUrl, '/')}/auth/realms/${options.fleetMaster ?? options.fleetId}/protocol/openid-connect/token`,
            params,
            {
                timeout: 30000,
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                withCredentials: false,
            }
        );
        await store.setToken({
            accessToken: token.access_token,
            refreshToken: token.refresh_token,
        });
        return token.access_token;
    };

    client.signInWithPassword = async (username: string, password: string): Promise<Token> => {
        if (!options.secure) {
            throw new Error('Not secure');
        }
        const params = new URLSearchParams();
        params.append('client_id', options.clientId);
        params.append('client_secret', options.clientSecret);
        params.append('securityOptions', 'SSL_OP_NO_SSLv3');
        params.append('grant_type', 'password');
        params.append('username', username);
        params.append('password', password);

        const { data: token } = await axios.post(
            `${trimEnd(options.baseUrl, '/')}/auth/realms/${options.fleetMaster ?? options.fleetId}/protocol/openid-connect/token`,
            params,
            {
                timeout: 30000,
                headers: {
                    'Content-Type': 'application/x-www-form-urlencoded',
                },
                withCredentials: false,
            }
        );

        const newToken: Token = {
            accessToken: token.access_token,
            refreshToken: token.refresh_token,
        };
        const store = options.store ?? getMemoryStore(options);
        await store.setToken(newToken);

        return newToken;
    };

    client.interceptors.request.use(async (request) => {
        const newRequest = request;
        const store = options.store ?? getMemoryStore(options);
        const token = await store.getToken();
        if (token?.accessToken) {
            newRequest.headers.Authorization = `Bearer ${token.accessToken}`;
        }
        if (options.logCurl) {
            const curl = new CurlHelper(newRequest).generateCommand();
            if (options.onLog) options.onLog({ curl, message: 'getClient > Curl command' });
            // eslint-disable-next-line no-console
            else console.log({ curl, message: 'getClient > Curl command' });
        }

        return newRequest;
    });

    let isRefreshing = false;
    let refreshSubscribers: RefreshSubscriber[] = [];

    const executorRefresh = (config: any) => {
        return new Promise((resolve, reject) => {
            refreshSubscribers.push((token, error) => {
                if (error) {
                    reject(formatError(error));
                    return;
                }
                resolve(client.request(config));
            });
        });
    };

    client.interceptors.response.use(
        (response) => {
            if (options.logResponse) {
                const finalUrl = new CurlHelper(response.config).getBuiltURL();
                const { data, headers } = response;
                const dataLog =
                    response.config.responseType !== 'arraybuffer' && response.config.responseType !== 'blob'
                        ? data
                        : 'ArrayBuffer or blob';
                if (options.onLog) options.onLog({ finalUrl, data: dataLog, headers, message: 'getClient > Response' });
                // eslint-disable-next-line no-console
                else console.log({ finalUrl, data: dataLog, headers, message: 'getClient > Response' });
            }
            return response;
        },
        (error) => {
            const { config, response: { status } = { status: 500 } } = error;
            const originalRequest = config;

            if (originalRequest.attemptCount === undefined) {
                originalRequest.attemptCount = 0;
            }

            if (originalRequest.attemptCount === 5) {
                return Promise.reject(formatError(error));
            }

            if (status === 401) {
                originalRequest.attemptCount += 1;
                if (!isRefreshing) {
                    isRefreshing = true;

                    let authentification: () => Promise<string>;
                    if (options.secure) {
                        authentification = refreshTokenAuthentification;
                    } else {
                        authentification = async () => {
                            const store = options.store ?? getMemoryStore(options);
                            const token = await store.getToken();
                            if (!token?.refreshToken) {
                                return clientCredentialsAuthentification();
                            }
                            return refreshTokenAuthentification();
                        };
                    }

                    authentification()
                        .then((accessToken) => {
                            refreshSubscribers.forEach((cb) => cb(accessToken));
                        })
                        .catch((errorAuth) => {
                            refreshSubscribers.forEach((cb) => cb(undefined, errorAuth));
                        })
                        .finally(() => {
                            isRefreshing = false;
                            refreshSubscribers = [];
                        });
                }

                return executorRefresh(originalRequest);
            }

            return Promise.reject(formatError(error));
        }
    );

    clientCache.set(options.name ?? options.fleetId, { options, client });

    return client;
};

export default getClient;
