/*
 * Copyright (C) Contributors to the Suwayomi project
 *
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
 */

import { ErrorLink } from '@apollo/client/link/error';
import { SetContextLink } from '@apollo/client/link/context';
import type { ErrorLike } from '@apollo/client';
import { ApolloClient, ApolloLink, CombinedGraphQLErrors, InMemoryCache, ServerError } from '@apollo/client';
import { filter, firstValueFrom, from, map, switchMap } from 'rxjs';
import UploadHttpLink from 'apollo-upload-client/UploadHttpLink.mjs';
import { GraphQLWsLink } from '@apollo/client/link/subscriptions';
import type { Client } from 'graphql-ws';
import { createClient } from 'graphql-ws';
import { getMainDefinition } from '@apollo/client/utilities';
import { RemoveTypenameFromVariablesLink } from '@apollo/client/link/remove-typename';
import { d } from 'koration';
import { useId } from '@mantine/hooks';
import { useEffect } from 'react';
import type { GraphQLFormattedError } from 'graphql';
import { BaseClient } from '@/lib/requests/client/BaseClient.ts';
import type { TypedTypePolicies } from '@/lib/graphql/generated/apollo-helpers.ts';
import { AuthManager } from '@/features/authentication/AuthManager.ts';
import type { UserRefreshMutation } from '@/lib/graphql/generated/graphql.ts';
import type { AbortableApolloMutationResponse } from '@/lib/requests/RequestManager.ts';
import type { ChapterNodeList } from '@/lib/graphql/generated/graphql-base.types.ts';

const typePolicies: TypedTypePolicies = {
    MangaType: {
        fields: {
            trackRecords: {
                merge(existing, incoming) {
                    const nodes = incoming.nodes ?? existing?.nodes;

                    return {
                        ...existing,
                        ...incoming,
                        totalCount: nodes?.length ?? existing?.totalCount ?? incoming.totalCount,
                        nodes,
                    };
                },
            },
        },
    },
    GlobalMetaType: { keyFields: ['key'] },
    MangaMetaType: { keyFields: ['mangaId', 'key'] },
    ChapterMetaType: { keyFields: ['chapterId', 'key'] },
    CategoryMetaType: { keyFields: ['categoryId', 'key'] },
    SourceMetaType: { keyFields: ['sourceId', 'key'] },
    ExtensionType: { keyFields: ['pkgName'] },
    ExtensionStoreType: { keyFields: ['indexUrl'] },
    AboutServerPayload: { keyFields: [] },
    AboutWebUI: { keyFields: [] },
    WebUIUpdateInfo: { keyFields: [] },
    WebUIUpdateCheck: { keyFields: [] },
    SettingsType: { keyFields: [] },
    DownloadStatus: {
        keyFields: [],
        fields: {
            queue: {
                merge(_existing, incoming) {
                    return incoming;
                },
            },
        },
    },
    DownloadType: { keyFields: ['chapter'] },
    CategoryUpdateType: { keyFields: ['category'] },
    MangaUpdateType: { keyFields: ['manga'] },
    UpdaterJobsInfoType: { keyFields: [] },
    WebUIUpdateStatus: { keyFields: [] },
    UpdateStatus: { keyFields: [] },
    KoSyncStatusPayload: { keyFields: [] },
    SyncStatus: { keyFields: [] },
    Query: {
        fields: {
            manga(_, { args, toReference }) {
                return toReference({
                    __typename: 'MangaType',
                    id: args?.id,
                });
            },
            category(_, { args, toReference }) {
                return toReference({
                    __typename: 'CategoryType',
                    id: args?.id,
                });
            },
            source(_, { args, toReference }) {
                return toReference({
                    __typename: 'SourceType',
                    id: args?.id,
                });
            },
            extension(_, { args, toReference }) {
                return toReference({
                    __typename: 'ExtensionType',
                    pkgName: args?.pkgName,
                });
            },
            extensionStore(_, { args, toReference }) {
                return toReference({
                    __typename: 'ExtensionStoreType',
                    indexUrl: args?.indexUrl,
                });
            },
            meta(_, { args, toReference }) {
                return toReference({
                    __typename: 'GlobalMetaType',
                    key: args?.key,
                });
            },
            downloadStatus: {
                read(_, { toReference }) {
                    return toReference({
                        __typename: 'DownloadStatus',
                        key: {},
                    });
                },
                merge(_, incoming) {
                    return incoming;
                },
            },
            getWebUIUpdateStatus(_, { toReference }) {
                return toReference({
                    __typename: 'WebUIUpdateStatus',
                    key: {},
                });
            },
            updateStatus(_, { toReference }) {
                return toReference({ __typename: 'UpdateStatus', key: {} });
            },
            lastSyncStatus(_, { toReference }) {
                return toReference({ __typename: 'SyncStatus', key: {} });
            },
            mangas: {
                keyArgs: ['condition', 'filter', 'orderBy', 'orderByType', 'order'],
                merge(existing, incoming, { mergeObjects }) {
                    return mergeObjects(existing, incoming);
                },
            },
            chapters: {
                keyArgs: ['condition', 'filter', 'orderBy', 'orderByType', 'order'],
                merge(existing, incoming) {
                    if (existing == null) {
                        return incoming;
                    }

                    const isReFetch = !incoming.pageInfo.hasPreviousPage;
                    const hasLessItems = existing.nodes.length > incoming.nodes.length;

                    const useIncomingResponse = isReFetch && !hasLessItems;
                    if (useIncomingResponse) {
                        return incoming;
                    }

                    const replaceExistingItems = isReFetch && hasLessItems;
                    if (replaceExistingItems) {
                        const existingWithReplacedIncoming: ChapterNodeList = {
                            ...existing,
                            pageInfo: {
                                ...existing.pageInfo,
                                startCursor: incoming.pageInfo.startCursor,
                            },
                            nodes: [...incoming.nodes, ...existing.nodes.slice(incoming.nodes.length)],
                        };

                        return existingWithReplacedIncoming;
                    }

                    const existingWithAppendedIncoming: ChapterNodeList = {
                        ...existing,
                        pageInfo: {
                            ...existing.pageInfo,
                            endCursor: incoming.pageInfo.endCursor,
                            hasNextPage: incoming.pageInfo.hasNextPage,
                        },
                        nodes: [...existing.nodes, ...incoming.nodes],
                    };

                    return existingWithAppendedIncoming;
                },
            },
            settings: {
                merge(existing, incoming) {
                    return {
                        ...(existing ?? {}),
                        ...(incoming ?? {}),
                    };
                },
            },
        },
    },
};

export class GraphQLClient extends BaseClient<ApolloClient, ApolloClient.Options, null> {
    readonly fetcher = null;

    public client!: ApolloClient;

    private wsClient!: Client;

    private wsClientAliveCheckInterval: NodeJS.Timeout | undefined = undefined;

    private activeConnectionSubscriptions = new Map<string, () => void>();

    constructor(handleRefreshToken: (refreshToken: string) => AbortableApolloMutationResponse<UserRefreshMutation>) {
        super(handleRefreshToken);

        this.createClient();
    }

    public override getBaseUrl(): string {
        return `${super.getBaseUrl()}/api/graphql`;
    }

    override reset(): void {
        super.reset();

        this.client.clearStore();
        this.client.stop();

        this.resetWsClient(false);
        this.createClient();
    }

    private resetWsClient(recreateClient: boolean): void {
        this.wsClient.dispose();
        this.wsClient.terminate();

        if (!recreateClient) {
            return;
        }

        this.createWSClient(false);
        this.client.setLink(this.createLink());

        this.restartAllSubscriptions();
    }

    private restartAllSubscriptions(): void {
        this.activeConnectionSubscriptions.forEach((callback) => callback());
    }

    protected override shouldQueueRequest(operationName: string | undefined): boolean {
        const authOperations = ['GET_ABOUT', 'USER_LOGIN', 'USER_REFRESH'];
        if (authOperations.includes(operationName!)) {
            return false;
        }

        return super.shouldQueueRequest();
    }

    private createAuthGuardLink() {
        return new ApolloLink((operation, forward) => {
            const { operationName } = operation;

            if (this.shouldQueueRequest(operationName)) {
                return from(this.enqueueRequest(() => firstValueFrom(forward(operation)), operationName));
            }

            return forward(operation);
        });
    }

    protected override getOriginFromUrl(operation: string): string {
        return operation;
    }

    private getRateLimitOrigin(operation: ApolloLink.Operation): string {
        return `${operation.operationName}::${JSON.stringify(operation.variables)}`;
    }

    private isRateLimitError(error: ErrorLike): boolean {
        if (CombinedGraphQLErrors.is(error)) {
            return error.errors.some(
                (gqlError) =>
                    gqlError.message.toLowerCase().includes('http 429') ||
                    gqlError.message.toLowerCase().includes('http error 429') ||
                    gqlError.message.toLowerCase().includes('too many requests'),
            );
        }

        if (ServerError.is(error)) {
            return error.statusCode === 429;
        }

        return false;
    }

    private isAuthError(errors: readonly GraphQLFormattedError[]): boolean {
        return errors.some((graphQLError) =>
            graphQLError.message.includes('suwayomi.tachidesk.server.user.UnauthorizedException'),
        );
    }

    private createErrorLink() {
        return new ErrorLink(({ error, operation, forward }) => {
            if (this.isRateLimitError(error)) {
                this.addRateLimit(
                    this.getRateLimitOrigin(operation),
                    operation.getContext()?.headers?.get?.('Retry-After'),
                );

                return from(this.awaitRateLimit(this.getRateLimitOrigin(operation))).pipe(
                    switchMap(() => forward(operation)),
                );
            }

            if (!CombinedGraphQLErrors.is(error)) {
                return undefined;
            }

            if (!this.isAuthError(error.errors)) {
                return undefined;
            }

            return from(BaseClient.refreshAccessToken(this.handleRefreshToken)).pipe(
                filter(Boolean),
                map((result) => {
                    this.restartAllSubscriptions();
                    return result;
                }),
                switchMap(() => forward(operation)),
            );
        });
    }

    private createAuthLink() {
        return new SetContextLink(({ headers }) => {
            const isAuthRequired = AuthManager.isAuthRequired();
            const accessToken = AuthManager.getAccessToken();

            return {
                credentials: 'include',
                headers: {
                    ...headers,
                    ...(isAuthRequired && accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
                },
            };
        });
    }

    private createUploadLink() {
        return new UploadHttpLink({ uri: () => this.getBaseUrl() });
    }

    private createWSLink() {
        return new GraphQLWsLink(this.wsClient);
    }

    private createLink() {
        const removeTypenameLink = new RemoveTypenameFromVariablesLink();

        return ApolloLink.split(
            ({ query }) => {
                const definition = getMainDefinition(query);
                return definition.kind === 'OperationDefinition' && definition.operation === 'subscription';
            },
            this.createWSLink(),
            ApolloLink.from([
                this.createAuthGuardLink(),
                this.createErrorLink(),
                this.createAuthLink(),
                removeTypenameLink,
                this.createUploadLink(),
            ]),
        );
    }

    private createWSClient(lazy: boolean = true): void {
        const heartbeatInterval = d(20).seconds.inWholeMilliseconds;

        this.wsClient = createClient({
            lazy,
            url: () => this.getBaseUrl().replaceAll(/http(|s)/g, 'ws'),
            keepAlive: heartbeatInterval,
            retryAttempts: Number.MAX_SAFE_INTEGER,
            shouldRetry: () => true,
            retryWait: async (retries) => {
                const delay = Math.min(d(1).seconds.inWholeMilliseconds * 2 ** retries, heartbeatInterval);

                return new Promise((resolve) => {
                    setTimeout(resolve, delay);
                });
            },
            connectionParams: () => {
                const isAuthRequired = AuthManager.isAuthRequired();
                const accessToken = AuthManager.getAccessToken();

                return {
                    Authorization: isAuthRequired && accessToken ? accessToken : undefined,
                };
            },
        });

        let triedForcedReconnection = false;
        let lastHeartbeat: number = Date.now();
        this.wsClient.on('message', async (e) => {
            lastHeartbeat = Date.now();
            triedForcedReconnection = false;

            if (e.type !== 'error') {
                return;
            }

            if (!AuthManager.isRefreshingToken() && this.isAuthError(e.payload)) {
                try {
                    await BaseClient.refreshAccessToken(this.handleRefreshToken);
                    this.resetWsClient(true);
                } catch (_) {
                    // Ignore
                }
            }
        });

        const checkHeartbeatInterval = heartbeatInterval + d(30).seconds.inWholeMilliseconds;
        clearInterval(this.wsClientAliveCheckInterval);
        this.wsClientAliveCheckInterval = setInterval(() => {
            const isHeartbeatMissing = Date.now() - lastHeartbeat > checkHeartbeatInterval * 1.1;
            if (!isHeartbeatMissing) {
                return;
            }

            if (!triedForcedReconnection) {
                triedForcedReconnection = true;
                this.wsClient.terminate();

                return;
            }

            clearInterval(this.wsClientAliveCheckInterval);
            this.resetWsClient(true);
        }, checkHeartbeatInterval);
    }

    protected createClient(createWsClientLazily?: boolean) {
        this.createWSClient(createWsClientLazily);
        this.client = new ApolloClient({
            cache: new InMemoryCache({ typePolicies }),
            devtools: { enabled: true },
            link: this.createLink(),
        });
    }

    public override updateConfig() {}

    public useRestartSubscription(restart: () => void) {
        const id = useId();

        this.activeConnectionSubscriptions.set(id, () => {
            restart();
        });

        useEffect(
            () => () => {
                this.activeConnectionSubscriptions.delete(id);
            },
            [id],
        );
    }
}
