/*
 * 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 { useEffect } from 'react';
import {
    APP_METADATA_KEY_PREFIX,
    METADATA_MIGRATIONS,
    VALID_APP_METADATA_KEYS,
} from '@/features/metadata/Metadata.constants.ts';
import type {
    AppMetadataKeys,
    IMetadataMigration,
    Metadata,
    MetadataHolder,
    MetadataHolderType,
    MetadataKeyValuePair,
} from '@/features/metadata/Metadata.types.ts';
import { extractOriginalKey, getMetadataKey } from '@/features/metadata/Metadata.utils.ts';
import type { MangaIdInfo } from '@/features/manga/Manga.types.ts';
import type { CategoryIdInfo } from '@/features/category/Category.types.ts';
import { defaultPromiseErrorHandler } from '@/lib/DefaultPromiseErrorHandler.ts';
import { getMetadataUpdateFunction } from '@/features/metadata/services/MetadataUpdater.ts';
import { MetadataChunker } from '@/features/metadata/services/MetadataChunker.ts';
import type { SourceIdInfo } from '@/features/source/Source.types.ts';
import type { ChapterIdInfo } from '@/features/chapter/Chapter.types.ts';

const getAppKeyPrefixForMigration = (migrationId: number): string => {
    const appKeyPrefix = METADATA_MIGRATIONS.slice(0, migrationId)
        .reverse()
        .find((migration) => !!migration.appKeyPrefix);

    return appKeyPrefix?.appKeyPrefix?.newPrefix ?? APP_METADATA_KEY_PREFIX;
};

const getAppMetadataFrom = (
    meta: Metadata,
    prefixes: string[] = [],
    appPrefix: string = APP_METADATA_KEY_PREFIX,
): Metadata => {
    const appMetadata: Metadata = {};

    Object.entries(meta).forEach(([key, value]) => {
        if (key.startsWith([appPrefix, ...prefixes].join('_'))) {
            appMetadata[key] = value;
        }
    });

    return appMetadata;
};

const applyAppKeyPrefixMigration = (meta: Metadata, migration: IMetadataMigration): Metadata => {
    const migratedMetadata: Metadata = { ...meta };

    if (!migration.appKeyPrefix) {
        return migratedMetadata;
    }

    const { oldPrefix, newPrefix } = migration.appKeyPrefix;

    const oldAppMetadata = getAppMetadataFrom(meta, undefined, oldPrefix);

    Object.keys(oldAppMetadata).forEach((oldKey) => {
        const keyWithNewPrefix = oldKey.replace(oldPrefix, newPrefix);
        migratedMetadata[keyWithNewPrefix] = oldAppMetadata[oldKey];
    });

    return migratedMetadata;
};

const applyMetadataValueMigration = (meta: Metadata, migration: IMetadataMigration, appKeyPrefix: string): Metadata => {
    const migratedMetadata: Metadata = { ...meta };

    if (!migration.values) {
        return migratedMetadata;
    }

    const appMetadata = getAppMetadataFrom(meta, undefined, appKeyPrefix);
    const metadataValueChanges = migration.values;

    metadataValueChanges.forEach(({ key, oldValue, newValue }) => {
        const migrateValue = (metaKey: string) => {
            if (
                (oldValue === undefined && meta[metaKey] === oldValue) ||
                (oldValue !== undefined && meta[metaKey].match(oldValue))
            ) {
                migratedMetadata[metaKey] =
                    typeof newValue === 'function' ? newValue(meta[metaKey], metaKey, appMetadata) : newValue;
            }
        };

        const migrateValueOfAllAppKeys = key === undefined;
        Object.keys(appMetadata).forEach((metaKey) => {
            if (migrateValueOfAllAppKeys || metaKey.endsWith(key)) {
                migrateValue(metaKey);
            }
        });
    });

    return migratedMetadata;
};

const applyMetadataKeyMigration = (meta: Metadata, migration: IMetadataMigration, appKeyPrefix: string): Metadata => {
    const migratedMetadata: Metadata = { ...meta };

    if (!migration.keys) {
        return migratedMetadata;
    }

    const appMetadata = getAppMetadataFrom(meta, undefined, appKeyPrefix);

    migration.keys.forEach(({ oldKey, newKey }) => {
        Object.keys(appMetadata).forEach((key) => {
            if (!key.endsWith(oldKey)) {
                return;
            }

            const prefixes = key.split('_');
            const prefix = prefixes.slice(0, prefixes.length - 1).join('_');

            const newKeyWithOldKeysPrefix = `${prefix}_${newKey}`;

            migratedMetadata[newKeyWithOldKeysPrefix] = appMetadata[key];
        });
    });

    return migratedMetadata;
};

const applyMetadataDeleteKeysMigration = (
    meta: Metadata,
    migration: IMetadataMigration,
    appKeyPrefix: string,
): Metadata => {
    const migratedMetadata: Metadata = {};

    if (!migration.deleteKeys) {
        return { ...meta };
    }

    const appMetadata = getAppMetadataFrom(meta, undefined, appKeyPrefix);

    Object.keys(appMetadata).forEach((key) => {
        if (migration.deleteKeys?.includes(extractOriginalKey(key))) {
            return;
        }

        migratedMetadata[key] = appMetadata[key];
    });

    return migratedMetadata;
};

const getOutdatedMetadataKeys = (metadata: Metadata | undefined, migrationId: number): AppMetadataKeys[] => {
    if (!metadata) {
        return [];
    }

    const oldAppKeyPrefixes = METADATA_MIGRATIONS.slice(migrationId).reduce((acc, migration) => {
        const oldPrefix = migration.appKeyPrefix?.oldPrefix;
        if (!oldPrefix) {
            return acc;
        }

        return [...acc, oldPrefix];
    }, [] as string[]);

    const keyToDeleteInMigrations = METADATA_MIGRATIONS.slice(migrationId).reduce(
        (acc, migration) => [...acc, ...(migration.deleteKeys ?? [])],
        [] as string[],
    );

    return Object.keys(metadata).filter((key) => {
        const [appKeyPrefixOfKey] = key.split('_');

        const isMetadataKeyOfApp = [...oldAppKeyPrefixes, APP_METADATA_KEY_PREFIX].includes(appKeyPrefixOfKey);
        if (!isMetadataKeyOfApp) {
            return false;
        }

        const isOldKeyPrefix = oldAppKeyPrefixes.includes(appKeyPrefixOfKey);
        if (isOldKeyPrefix) {
            return true;
        }

        const extractedKey = extractOriginalKey(key);

        if (keyToDeleteInMigrations.includes(extractedKey)) {
            return true;
        }

        return !VALID_APP_METADATA_KEYS.includes(extractedKey);
    }) as AppMetadataKeys[];
};

const getNewMetadataKeys = (
    metadata: Metadata | undefined,
    migratedMetadata: Metadata,
    metadataKeyToDelete: string[],
    migrationId: number,
): string[] => {
    if (!metadata) {
        return [];
    }

    const newKeys = METADATA_MIGRATIONS.slice(migrationId).reduce((acc, migration) => {
        if (!migration.keys) {
            return acc;
        }

        const keys = migration.keys.map(({ newKey }) => newKey).filter((key) => key !== undefined);

        return [...acc, ...keys];
    }, [] as string[]);

    return Object.keys(migratedMetadata).filter((metadataKey) => {
        const key = extractOriginalKey(metadataKey);

        const isNewKeyOfAMigration = newKeys.includes(key);
        const isOldKeyOfAMigration = metadataKeyToDelete.includes(key);
        const isAlreadyCommited = Object.hasOwn(metadata, metadataKey);

        return !isAlreadyCommited && isNewKeyOfAMigration && !isOldKeyOfAMigration;
    });
};

const getMetadataKeysWithUpdatedValues = (
    metadata: Metadata | undefined,
    newAndDeletedMetadataKeys: string[],
    migrationId: number,
): string[] => {
    if (!metadata) {
        return [];
    }

    const keysWithUpdatedValues = METADATA_MIGRATIONS.slice(migrationId).reduce((acc, migration) => {
        const keysWithUpdatedValuesOfMigration = Object.keys(metadata).filter((metadataKey) =>
            migration.values?.some(({ key: migrationKey, oldValue }) => {
                const currentValue = metadata[metadataKey];

                const isMigrationForAllKeys = !migrationKey;
                const doesValueMatch =
                    oldValue instanceof RegExp ? currentValue.match(oldValue) : currentValue === oldValue;

                if (isMigrationForAllKeys) {
                    return doesValueMatch;
                }

                return metadataKey.endsWith(migrationKey) && doesValueMatch;
            }),
        );

        return [...acc, ...keysWithUpdatedValuesOfMigration];
    }, [] as string[]);

    return [
        ...new Set([
            ...keysWithUpdatedValues.filter(
                (keyWithUpdatedValue) => !newAndDeletedMetadataKeys.includes(keyWithUpdatedValue),
            ),
        ]),
    ];
};

/**
 * Prevent spamming requests due to frequent metadata reads while the migration hasn't been commited to the server yet
 */
const commitedMigrations = new Set<string>();
const commitMigratedMetadata = (
    type: MetadataHolderType,
    metadataHolder:
        | MetadataHolder
        | (MangaIdInfo & MetadataHolder)
        | (ChapterIdInfo & MetadataHolder)
        | (CategoryIdInfo & MetadataHolder)
        | (SourceIdInfo & MetadataHolder)
        | undefined,
    migratedMetadata: Metadata,
    useEffectFn: typeof useEffect = (fn: () => void) => fn(),
): void => {
    const rawMetadata = metadataHolder?.meta;
    const metadata = MetadataChunker.reassembleAllChunkedValues(rawMetadata);

    const migrationId = Number(metadata?.[getMetadataKey('migration')] ?? 1);

    const metadataKeysToDelete = getOutdatedMetadataKeys(metadata, migrationId);
    const newMetadataKeys = getNewMetadataKeys(metadata, migratedMetadata, metadataKeysToDelete, migrationId);
    const metadataKeysWithUpdatedValues = getMetadataKeysWithUpdatedValues(
        metadata,
        [...metadataKeysToDelete, ...newMetadataKeys],
        migrationId,
    );
    const metadataToUpdate = [...newMetadataKeys, ...metadataKeysWithUpdatedValues].map((key) => [
        key,
        migratedMetadata[key],
    ]) as MetadataKeyValuePair[];

    // Expand app keys to chunk keys for deletion (e.g., some_key -> some_key_0, some_key_1, some_key_2, ..., some_key_n, some_key_length
    const keysToDelete = metadataKeysToDelete.flatMap((fullKey) => {
        if (rawMetadata && MetadataChunker.isChunkedInMetadata(rawMetadata, fullKey)) {
            const count = MetadataChunker.getExistingChunkCount(rawMetadata, fullKey);
            const chunkKeys = Array.from({ length: count }, (_, i) => MetadataChunker.getChunkIndexKey(fullKey, i));
            return [MetadataChunker.getChunkLengthKey(fullKey), ...chunkKeys];
        }
        return [fullKey];
    }) as AppMetadataKeys[];

    const updateMetadata = getMetadataUpdateFunction(type, metadataHolder ?? { id: -1, meta: {} });

    useEffectFn(() => {
        (async () => {
            const itemMigrationKey = `${type}_${type === 'global' ? '' : (metadataHolder as { id: any } | undefined)?.id}`;

            const commitMigration = !commitedMigrations.has(itemMigrationKey);
            if (!commitMigration) {
                return;
            }

            const isMetadataAlreadyMigrated = !metadata || migrationId >= METADATA_MIGRATIONS.length;

            const isCommitRequired = !isMetadataAlreadyMigrated;
            if (!isCommitRequired) {
                return;
            }

            commitedMigrations.add(itemMigrationKey);

            try {
                await updateMetadata({
                    update: metadataToUpdate,
                    delete: keysToDelete,
                    migrate: [['migration', METADATA_MIGRATIONS.length]],
                    isMetadataKey: true,
                });
            } catch (e) {
                commitedMigrations.delete(itemMigrationKey);

                defaultPromiseErrorHandler(
                    `MetadataMigrations#commitMigrateMetadata(${type}, ${(metadataHolder as { id: any })?.id})`,
                )(e);
            }
        })();
    });
};

export const applyMetadataMigrations = (
    type: MetadataHolderType,
    metadataHolder?:
        | MetadataHolder
        | (MangaIdInfo & MetadataHolder)
        | (ChapterIdInfo & MetadataHolder)
        | (CategoryIdInfo & MetadataHolder)
        | (SourceIdInfo & MetadataHolder),
    useEffectFn: typeof useEffect = (fn: () => void) => fn(),
): Metadata | undefined => {
    const rawMeta = metadataHolder?.meta ?? {};
    const meta = MetadataChunker.reassembleAllChunkedValues(rawMeta) ?? {};

    const migrationIdKey = getMetadataKey('migration');
    const appliedMigrationId = Number.isNaN(Number(meta[migrationIdKey]))
        ? 0
        : Math.max(0, Number(meta[migrationIdKey]));

    const migratedMetadata = METADATA_MIGRATIONS.slice(appliedMigrationId).reduce(
        (metadataToMigrate, migration, index) => {
            const migrationId = index + 1;

            const appKeyPrefixForMigration = getAppKeyPrefixForMigration(migrationId);
            const appKeyPrefixMigrated = applyAppKeyPrefixMigration(metadataToMigrate, migration);
            const metadataValuesMigrated = applyMetadataValueMigration(
                appKeyPrefixMigrated,
                migration,
                appKeyPrefixForMigration,
            );
            const metadataKeysMigrated = applyMetadataKeyMigration(
                metadataValuesMigrated,
                migration,
                appKeyPrefixForMigration,
            );
            const metadataKeysDeletedMigrated = applyMetadataDeleteKeysMigration(
                metadataKeysMigrated,
                migration,
                appKeyPrefixForMigration,
            );

            return metadataKeysDeletedMigrated;
        },
        meta,
    );

    commitMigratedMetadata(type, metadataHolder, migratedMetadata, useEffectFn);

    return migratedMetadata;
};
