/*
 * 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 type { DocumentNode, Unmasked } from '@apollo/client';
import { t } from '@lingui/core/macro';
import { i18n } from '@/i18n';
import { requestManager } from '@/lib/requests/RequestManager.ts';
import type {
    GetMangaMetaQuery,
    GetMangasBaseQuery,
    GetMangasBaseQueryVariables,
    GetMangasChapterIdsWithStateQuery,
    GetMangasChapterIdsWithStateQueryVariables,
    MangaBaseFieldsFragment,
} from '@/lib/graphql/generated/graphql.ts';
import { Chapters } from '@/features/chapter/services/Chapters.ts';
import { makeToast } from '@/base/utils/Toast.ts';
import { getMetadataServerSettings } from '@/features/settings/services/ServerSettingsMetadata.ts';
import { GET_MANGA_META, GET_MANGAS_BASE } from '@/lib/graphql/manga/MangaQuery.ts';
import { MANGA_BASE_FIELDS, MANGA_MIGRATION_FIELDS } from '@/lib/graphql/manga/MangaFragments.ts';
import type {
    MangaAction,
    MangaArtistInfo,
    MangaAuthorInfo,
    MangaCardMode,
    MangaDownloadInfo,
    MangaGenreInfo,
    MangaIdInfo,
    MangaLocationState,
    MangaSourceNameInfo,
    MangaThumbnailInfo,
    MangaTitleInfo,
    MangaUnreadInfo,
} from '@/features/manga/Manga.types.ts';
import { MangaType } from '@/features/manga/Manga.types.ts';
import {
    MANGA_ACTION_TO_CONFIRMATION_REQUIRED,
    MANGA_ACTION_TO_TRANSLATION,
    MANGA_TAGS_BY_MANGA_TYPE,
    SOURCES_BY_MANGA_TYPE,
} from '@/features/manga/Manga.constants.ts';
import { getErrorMessage } from '@/lib/HelperFunctions.ts';
import { assertIsDefined } from '@/base/Asserts.ts';
import { Confirmation } from '@/base/AppAwaitableComponent.ts';
import { UrlUtil } from '@/lib/UrlUtil.ts';
import { MigrationManager } from '@/features/migration/MigrationManager.ts';
import uniq from 'lodash/fp/uniq';
import { ReactRouter } from '@/lib/react-router/ReactRouter.ts';
import { AppRoutes } from '@/base/AppRoute.constants.ts';
import {
    ChapterOrderBy,
    type ChapterConditionInput,
    type UpdateMangaCategoriesPatchInput,
} from '@/lib/graphql/generated/graphql-base.types.ts';
import { GET_MANGAS_CHAPTER_IDS_WITH_STATE } from '@/lib/graphql/chapter/ChapterQuery.ts';
import { getMangaMetadata } from '@/features/manga/services/MangaMetadata.ts';
import { filterChapters } from '@/features/chapter/utils/ChapterList.util.tsx';

const I18N_PLURAL = 9999;

type DownloadChaptersOptions = {
    size?: number;
    onlyUnread?: boolean;
    downloadAhead?: boolean;
};
type MarkAsReadOptions = { wasManuallyMarkedAsRead: boolean };
type ChangeCategoriesOptions = { changeCategoriesPatch: UpdateMangaCategoriesPatchInput };

type MarkAsReadActionOption = MarkAsReadOptions &
    PropertiesNever<ChangeCategoriesOptions> &
    PropertiesNever<DownloadChaptersOptions>;
type ChangeCategoriesActionOption = PropertiesNever<MarkAsReadOptions> &
    ChangeCategoriesOptions &
    PropertiesNever<DownloadChaptersOptions>;
type MigrateActionOption = PropertiesNever<MarkAsReadOptions> &
    PropertiesNever<ChangeCategoriesOptions> &
    PropertiesNever<DownloadChaptersOptions>;
type DownloadActionOption = PropertiesNever<MarkAsReadOptions> &
    PropertiesNever<ChangeCategoriesOptions> &
    DownloadChaptersOptions;
type DefaultActionOption = Partial<MarkAsReadOptions> &
    Partial<ChangeCategoriesOptions> &
    Partial<DownloadChaptersOptions>;

type PerformActionOptions<Action extends MangaAction> = Action extends 'mark_as_read'
    ? MarkAsReadActionOption
    : Action extends 'change_categories'
      ? ChangeCategoriesActionOption
      : Action extends 'migrate'
        ? MigrateActionOption
        : Action extends 'download'
          ? DownloadActionOption
          : DefaultActionOption;

const ARTIST_AUTHOR_SEPARATOR_REGEX = /\s*[,|、]\s*/;

export class Mangas {
    static getIds(mangas: MangaIdInfo[]): number[] {
        return mangas.map((manga) => manga.id);
    }

    static getFromCache<T = MangaBaseFieldsFragment>(
        id: MangaIdInfo['id'],
        fragment: DocumentNode = MANGA_BASE_FIELDS,
        fragmentName: string = 'MANGA_BASE_FIELDS',
    ): Unmasked<T> | null {
        return requestManager.graphQLClient.client.cache.readFragment<T>({
            id: requestManager.graphQLClient.client.cache.identify({
                __typename: 'MangaType',
                id,
            }),
            fragment,
            fragmentName,
        });
    }

    static isNotDownloaded({ downloadCount, chapters: { totalCount } }: MangaDownloadInfo): boolean {
        return totalCount > 0 && downloadCount === 0;
    }

    static getNotDownloaded<Mangas extends MangaDownloadInfo>(mangas: Mangas[]): Mangas[] {
        return mangas.filter(Mangas.isNotDownloaded);
    }

    static isFullyDownloaded({ downloadCount, chapters: { totalCount } }: MangaDownloadInfo): boolean {
        return totalCount > 0 && downloadCount === totalCount;
    }

    static getFullyDownloaded<Mangas extends MangaDownloadInfo>(mangas: Mangas[]): Mangas[] {
        return mangas.filter(Mangas.isFullyDownloaded);
    }

    static isPartiallyDownloaded(manga: MangaDownloadInfo): boolean {
        const {
            chapters: { totalCount },
        } = manga;

        return totalCount > 0 && !Mangas.isNotDownloaded(manga) && !Mangas.isFullyDownloaded(manga);
    }

    static getPartiallyDownloaded<Mangas extends MangaDownloadInfo>(mangas: Mangas[]): Mangas[] {
        return mangas.filter(Mangas.isPartiallyDownloaded);
    }

    static isUnread({ unreadCount, chapters: { totalCount } }: MangaUnreadInfo): boolean {
        return totalCount > 0 && unreadCount === totalCount;
    }

    static getUnread<Mangas extends MangaUnreadInfo>(mangas: Mangas[]): Mangas[] {
        return mangas.filter(Mangas.isUnread);
    }

    static isFullyRead({ unreadCount, chapters: { totalCount } }: MangaUnreadInfo): boolean {
        return totalCount > 0 && unreadCount === 0;
    }

    static getFullyRead<Mangas extends MangaUnreadInfo>(mangas: Mangas[]): Mangas[] {
        return mangas.filter(Mangas.isFullyRead);
    }

    static isPartiallyRead(manga: MangaUnreadInfo): boolean {
        const {
            chapters: { totalCount },
        } = manga;

        return totalCount > 0 && !Mangas.isUnread(manga) && !Mangas.isFullyRead(manga);
    }

    static getPartiallyRead<Mangas extends MangaUnreadInfo>(mangas: Mangas[]): Mangas[] {
        return mangas.filter(Mangas.isPartiallyRead);
    }

    static getThumbnailUrl(manga: Partial<MangaThumbnailInfo>): string {
        const url = UrlUtil.addParams(manga.thumbnailUrl ?? '', {
            fetchedAt: manga.thumbnailUrlLastFetched,
            sourceId: manga.sourceId,
        });

        return requestManager.getValidImgUrlFor(url);
    }

    static getDuplicateLibraryMangas(
        title: string,
    ): ReturnType<typeof requestManager.getMangas<GetMangasBaseQuery, GetMangasBaseQueryVariables>> {
        return requestManager.getMangas<GetMangasBaseQuery, GetMangasBaseQueryVariables>(GET_MANGAS_BASE, {
            condition: { inLibrary: true },
            filter: { title: { likeInsensitive: title } },
        });
    }

    static async getChapterIdsWithState(
        mangaIds: number[],
        {
            excludedScanlators,
            ...state
        }: Pick<ChapterConditionInput, 'isRead' | 'isDownloaded' | 'isBookmarked'> & {
            excludedScanlators?: string[];
        },
    ): Promise<GetMangasChapterIdsWithStateQuery['chapters']['nodes']> {
        const { data } = await requestManager.getChapters<
            GetMangasChapterIdsWithStateQuery,
            GetMangasChapterIdsWithStateQueryVariables
        >(
            GET_MANGAS_CHAPTER_IDS_WITH_STATE,
            {
                filter: { mangaId: { in: mangaIds }, scanlator: { notIncludesInsensitiveAny: excludedScanlators } },
                condition: { ...state },
                order: [{ by: ChapterOrderBy.SourceOrder }],
            },
            {
                fetchPolicy: 'network-only',
            },
        ).response;

        return data?.chapters.nodes ?? [];
    }

    static async downloadChapters(
        mangaIds: number[],
        { size, onlyUnread, downloadAhead = false }: DownloadChaptersOptions = {},
        disableConfirmation?: boolean,
    ): Promise<void> {
        return Mangas.executeAction(
            'download',
            I18N_PLURAL,
            async () => {
                const isSingleManga = mangaIds.length === 1;

                // This should be cached by the time this function gets called, so it shouldn't trigger an actual request
                const mangaResponse = await (isSingleManga
                    ? requestManager.getManga<GetMangaMetaQuery>(GET_MANGA_META, mangaIds[0]).response
                    : Promise.resolve(null));

                const meta = mangaResponse?.data?.manga ? getMangaMetadata(mangaResponse.data.manga) : null;

                const [chaptersToConsider, unReadDownloadedChapters] = await Promise.all([
                    Mangas.getChapterIdsWithState(mangaIds, {
                        isRead: onlyUnread ? false : undefined,
                        isDownloaded: false,
                    }),
                    downloadAhead
                        ? Mangas.getChapterIdsWithState(mangaIds, {
                              isRead: false,
                              isDownloaded: true,
                          })
                        : [],
                ]);

                // TODO - Filter for scanlators directly in getChapterIdsWithState once fixed on the server
                const filteredChaptersToConsider = meta ? filterChapters(chaptersToConsider, meta) : chaptersToConsider;
                const filteredUnReadDownloadedChapters = meta
                    ? filterChapters(unReadDownloadedChapters, meta)
                    : unReadDownloadedChapters;

                type MangaIdToDownloadSize = [MangaId: string, DownloadSize: number | undefined];

                const mangaIdToDefaultDownloadSize = mangaIds.map((mangaId) => [
                    String(mangaId),
                    size,
                ]) satisfies MangaIdToDownloadSize[];

                const mangaIdToChaptersToConsider = Object.groupBy(
                    filteredChaptersToConsider,
                    ({ mangaId }) => mangaId,
                );
                const mangaIdToUnReadDownloadedChapters = Object.groupBy(
                    filteredUnReadDownloadedChapters,
                    ({ mangaId }) => mangaId,
                );

                const mangaIdToDownloadSize = Object.entries(mangaIdToUnReadDownloadedChapters).map(
                    ([mangaId, downloadedChapters = []]) => {
                        const downloadAheadSize = Math.max(
                            0,
                            (size ?? downloadedChapters.length) - downloadedChapters.length,
                        );
                        const actualSize = downloadAhead ? downloadAheadSize : size;

                        return [mangaId, actualSize];
                    },
                ) satisfies MangaIdToDownloadSize[];

                const mangaIdToActualDownloadSize = Object.entries(
                    Object.fromEntries([...mangaIdToDefaultDownloadSize, ...mangaIdToDownloadSize]),
                ) satisfies MangaIdToDownloadSize[];

                const chaptersToDownload = mangaIdToActualDownloadSize
                    .flatMap(([mangaId, actualSize]) => {
                        const mangaChapters = mangaIdToChaptersToConsider[Number(mangaId)] ?? [];

                        if (!mangaChapters.length) {
                            return [];
                        }

                        const shouldDownloadAll = actualSize === undefined;
                        if (shouldDownloadAll) {
                            return mangaChapters;
                        }

                        const uniqueMangaChapters = Chapters.removeDuplicates(mangaChapters[0], mangaChapters);
                        const uniqueMangaChaptersToDownload = uniqueMangaChapters.slice(0, actualSize);

                        return Chapters.addDuplicates(uniqueMangaChaptersToDownload, mangaChapters);
                    })
                    .filter(Chapters.isDownloadable);

                if (!chaptersToDownload.length) {
                    return Promise.resolve();
                }

                try {
                    await Chapters.download(Chapters.getIds(chaptersToDownload), disableConfirmation);
                } catch (e) {
                    // Ignore - Error gets handled in Chapters
                }
            },
            true,
            true,
        );
    }

    static async deleteChapters(mangaIds: number[], disableConfirmation?: boolean): Promise<void> {
        return Mangas.executeAction(
            'delete',
            I18N_PLURAL,
            async () => {
                const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isDownloaded: true });

                try {
                    await Chapters.delete(Chapters.getIds(chapters), disableConfirmation);
                } catch (e) {
                    // Ignore - Error gets handled in Chapters
                }
            },
            true,
            true,
        );
    }

    static async markAsRead(
        mangaIds: number[],
        wasManuallyMarkedAsRead: boolean = false,
        disableConfirmation?: boolean,
    ): Promise<void> {
        return Mangas.executeAction(
            'mark_as_read',
            I18N_PLURAL,
            async () => {
                const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isRead: false });

                try {
                    await Chapters.markAsRead(
                        chapters,
                        wasManuallyMarkedAsRead,
                        mangaIds.length === 1 ? mangaIds[0] : undefined,
                        disableConfirmation,
                    );
                } catch (e) {
                    // Ignore - Error gets handled in Chapters
                }
            },
            true,
            true,
        );
    }

    static async markAsUnread(mangaIds: number[], disableConfirmation?: boolean): Promise<void> {
        return Mangas.executeAction(
            'mark_as_unread',
            I18N_PLURAL,
            async () => {
                const chapters = await Mangas.getChapterIdsWithState(mangaIds, { isRead: true });

                try {
                    await Chapters.markAsUnread(Chapters.getIds(chapters), disableConfirmation);
                } catch (e) {
                    // Ignore - Error gets handled in Chapters
                }
            },
            true,
            true,
        );
    }

    static async removeFromLibrary(mangaIds: number[], disableConfirmation?: boolean): Promise<void> {
        const { removeMangaFromCategories } = await getMetadataServerSettings();
        return Mangas.executeAction(
            'remove_from_library',
            mangaIds.length,
            () =>
                requestManager.updateMangas(mangaIds, {
                    updateMangas: { inLibrary: false },
                    updateMangasCategories: removeMangaFromCategories ? { clearCategories: true } : undefined,
                }).response,
            disableConfirmation,
        );
    }

    static async changeCategories(
        mangaIds: number[],
        patch: UpdateMangaCategoriesPatchInput,
        disableConfirmation?: boolean,
    ): Promise<void> {
        return Mangas.executeAction(
            'change_categories',
            mangaIds.length,
            () => requestManager.updateMangasCategories(mangaIds, patch).response,
            disableConfirmation,
        );
    }

    static async migrate(mangaIds: MangaIdInfo['id'][]): Promise<void> {
        if (MigrationManager.isActive()) {
            makeToast(t`A migration is already in progress`, 'error');
            return;
        }

        const mangas = mangaIds.map((mangaId) => {
            const manga = Mangas.getFromCache(mangaId, MANGA_MIGRATION_FIELDS, 'MANGA_MIGRATION_FIELDS');
            assertIsDefined(manga);

            return manga;
        });
        const sourceIds = uniq(mangas.map((manga) => manga.sourceId));

        MigrationManager.selectSources(sourceIds);
        MigrationManager.selectMangas(mangas);

        const isBulkMigration = mangas.length > 1;
        if (isBulkMigration) {
            ReactRouter.navigate(AppRoutes.migrate.path);
        }
    }

    private static async executeAction(
        action: MangaAction,
        itemCount: number,
        fnToExecute: () => Promise<unknown>,
        disableConfirmation?: boolean,
        errorOnlyToast?: boolean,
    ): Promise<void> {
        const { always, bulkAction, bulkActionCountForce } = MANGA_ACTION_TO_CONFIRMATION_REQUIRED[action];
        const requiresConfirmation =
            !disableConfirmation &&
            (always || (bulkAction && itemCount > 1) || (bulkActionCountForce && itemCount >= bulkActionCountForce));
        const confirmationMessage = MANGA_ACTION_TO_TRANSLATION[action].confirmation;

        try {
            if (requiresConfirmation) {
                assertIsDefined(confirmationMessage);

                try {
                    await Confirmation.show({
                        title: t`Are you sure?`,
                        /* lingui-extract-ignore */
                        message: i18n.t({ ...confirmationMessage, values: { count: itemCount } }),
                        actions: {
                            confirm: {
                                title: t`Ok`,
                            },
                        },
                    });
                } catch (_) {
                    return;
                }
            }

            await fnToExecute();

            if (errorOnlyToast) {
                return;
            }

            makeToast(
                /* lingui-extract-ignore */
                i18n.t({ ...MANGA_ACTION_TO_TRANSLATION[action].success, values: { count: itemCount } }),
                'success',
            );
        } catch (e) {
            makeToast(
                /* lingui-extract-ignore */
                i18n.t({ ...MANGA_ACTION_TO_TRANSLATION[action].error, values: { count: itemCount } }),
                'error',
                getErrorMessage(e),
            );
            throw e;
        }
    }

    static async performAction<Action extends MangaAction>(
        action: Action,
        mangaIds: number[],
        {
            wasManuallyMarkedAsRead,
            changeCategoriesPatch,
            downloadAhead,
            onlyUnread,
            size,
        }: PerformActionOptions<Action>,
        disableConfirmation?: boolean,
    ): Promise<void> {
        switch (action) {
            case 'download':
                return Mangas.downloadChapters(mangaIds, { downloadAhead, onlyUnread, size }, disableConfirmation);
            case 'delete':
                return Mangas.deleteChapters(mangaIds, disableConfirmation);
            case 'mark_as_read':
                return Mangas.markAsRead(mangaIds, wasManuallyMarkedAsRead!, disableConfirmation);
            case 'mark_as_unread':
                return Mangas.markAsUnread(mangaIds, disableConfirmation);
            case 'remove_from_library':
                return Mangas.removeFromLibrary(mangaIds, disableConfirmation);
            case 'change_categories':
                return Mangas.changeCategories(mangaIds, changeCategoriesPatch!, disableConfirmation);
            case 'migrate': {
                return Mangas.migrate(mangaIds);
            }
            default:
                throw new Error(`Mangas::performAction: unknown action "${action}"`);
        }
    }

    static getType(manga: MangaGenreInfo & MangaSourceNameInfo): MangaType {
        if (Mangas.isType(manga, MangaType.MANGA)) {
            return MangaType.MANGA;
        }

        if (Mangas.isType(manga, MangaType.COMIC)) {
            return MangaType.COMIC;
        }

        if (Mangas.isType(manga, MangaType.WEBTOON)) {
            return MangaType.WEBTOON;
        }

        if (Mangas.isType(manga, MangaType.MANHWA)) {
            return MangaType.MANHWA;
        }

        if (Mangas.isType(manga, MangaType.MANHUA)) {
            return MangaType.MANHUA;
        }

        return MangaType.MANGA;
    }

    static isType(manga: MangaGenreInfo & MangaSourceNameInfo, type: MangaType): boolean {
        const isMatchByGenre = manga.genre.some((genre) =>
            MANGA_TAGS_BY_MANGA_TYPE[type].some((tag) => genre.toLowerCase().includes(tag.toLowerCase())),
        );
        const isMatchBySource = SOURCES_BY_MANGA_TYPE[type].includes(manga.source?.name.toLowerCase() ?? '');

        return isMatchByGenre || isMatchBySource;
    }

    static isLongStripType(manga: MangaGenreInfo & MangaSourceNameInfo): boolean {
        return (
            Mangas.isType(manga, MangaType.WEBTOON) ||
            Mangas.isType(manga, MangaType.MANHWA) ||
            Mangas.isType(manga, MangaType.MANHUA)
        );
    }

    static getArtists<Manga extends MangaArtistInfo>(manga: Manga): string[] | undefined {
        return manga.artist?.split(ARTIST_AUTHOR_SEPARATOR_REGEX);
    }

    static getAuthors<Manga extends MangaAuthorInfo>(manga: Manga): string[] | undefined {
        return manga.author?.split(ARTIST_AUTHOR_SEPARATOR_REGEX);
    }

    static createLocationState<Manga extends MangaTitleInfo>(
        manga: Manga,
        mode: MangaCardMode | undefined,
    ): MangaLocationState {
        return {
            mangaTitle: manga.title,
            mode,
        };
    }
}
