/*
 *     The Peacock Project - a HITMAN server replacement.
 *     Copyright (C) 2021-2024 The Peacock Project Team
 *
 *     This program is free software: you can redistribute it and/or modify
 *     it under the terms of the GNU Affero General Public License as published by
 *     the Free Software Foundation, either version 3 of the License, or
 *     (at your option) any later version.
 *
 *     This program is distributed in the hope that it will be useful,
 *     but WITHOUT ANY WARRANTY; without even the implied warranty of
 *     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *     GNU Affero General Public License for more details.
 *
 *     You should have received a copy of the GNU Affero General Public License
 *     along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

import {
    getParentLocationByName,
    getSubLocationByName,
} from "../contracts/dataGen"
import { log, LogLevel } from "../loggingInterop"
import { getVersionedConfig } from "../configSwizzleManager"
import { getUserData } from "../databaseHandler"
import {
    LocationMasteryData,
    MasteryData,
    MasteryDrop,
    MasteryPackage,
    MasteryPackageDrop,
    UnlockableMasteryData,
} from "../types/mastery"
import {
    CompletionData,
    GameVersion,
    ProgressionData,
    Unlockable,
} from "../types/types"
import {
    clampValue,
    DEFAULT_MASTERY_MAXLEVEL,
    isSniperLocation,
    xpRequiredForEvergreenLevel,
    xpRequiredForLevel,
    xpRequiredForSniperLevel,
} from "../utils"

import { getUnlockablesById } from "../inventory"
import assert from "assert"

export class MasteryService {
    /**
     * @Key1 Game version.
     * @Key2 The parent location Id.
     * @Value A `MasteryPackage` object.
     */
    protected masteryPackages: Record<
        GameVersion,
        Map<string, MasteryPackage>
    > = {
        h1: new Map(),
        h2: new Map(),
        h3: new Map(),
        scpc: new Map(),
    }
    /**
     * @Key1 Game version.
     * @Key2 Unlockable Id.
     * @Value A `MasteryPackage` object.
     */
    private unlockableMasteryData: Record<
        GameVersion,
        Map<string, UnlockableMasteryData>
    > = {
        h1: new Map(),
        h2: new Map(),
        h3: new Map(),
        scpc: new Map(),
    }

    registerMasteryData(masteryPackage: MasteryPackage) {
        for (const gv of masteryPackage.GameVersions) {
            this.masteryPackages[gv].set(
                masteryPackage.LocationId,
                masteryPackage,
            )

            /**
             * Generates the same data in a reverse order. It could be considered redundant but this allows for
             * faster access to location and level based on unlockable ID, avoiding big-O operation for `getMasteryForUnlockable`
             */
            if (masteryPackage.SubPackages) {
                for (const subPkg of masteryPackage.SubPackages) {
                    for (const drop of subPkg.Drops) {
                        this.unlockableMasteryData[gv].set(drop.Id, {
                            Location: masteryPackage.LocationId,
                            SubPackageId: subPkg.Id,
                            Level: drop.Level,
                        })
                    }
                }
            } else {
                for (const drop of masteryPackage.Drops) {
                    this.unlockableMasteryData[gv].set(drop.Id, {
                        Location: masteryPackage.LocationId,
                        Level: drop.Level,
                    })
                }
            }
        }
    }

    /**
     * Returns mastery data for unlockable, if there's any
     * @param unlockable
     * @param gameVersion
     */
    getMasteryForUnlockable(
        unlockable: Unlockable,
        gameVersion: GameVersion,
    ): UnlockableMasteryData | undefined {
        return this.unlockableMasteryData[gameVersion].get(unlockable.Id)
    }

    /**
     * Exact same thing as {@link getMasteryData}.
     */
    getMasteryDataForDestination(
        locationParentId: string,
        gameVersion: GameVersion,
        userId: string,
        difficulty?: string,
    ): MasteryData[] {
        return this.getMasteryData(
            locationParentId,
            gameVersion,
            userId,
            difficulty,
        )
    }

    getMasteryDataForSubPackage(
        locationParentId: string,
        subPackageId: string,
        gameVersion: GameVersion,
        userId: string,
    ): MasteryData {
        // Since we're getting a subpackage, we know there will only be one entry in this array.
        // If the array is empty it will return undefined.
        return this.getMasteryData(
            locationParentId,
            gameVersion,
            userId,
            subPackageId,
        )[0]
    }

    getMasteryDataForLocation(
        locationId: string,
        gameVersion: GameVersion,
        userId: string,
    ): LocationMasteryData {
        const location =
            getSubLocationByName(locationId, gameVersion) ??
            getParentLocationByName(locationId, gameVersion)

        assert.ok(location, "cannot get mastery data for unknown location")

        const masteryData = this.getMasteryData(
            location.Properties.ParentLocation ?? location.Id,
            gameVersion,
            userId,
        )

        return {
            Location: location,
            MasteryData: masteryData,
        }
    }

    /**
     * Get generic completion data stored in a user's profile. Called by both `getLocationCompletion` and `getFirearmCompletion`.
     * @param userId The id of the user.
     * @param gameVersion The game version.
     * @param locationParentId The location's parent ID, used for progression storage @since v7.0.0
     * @param maxLevel The max level for this progression.
     * @param levelToXpRequired A function to get the XP required for a level.
     * @param subPackageId The subpackage id you want.
     */
    private getCompletionData(
        userId: string,
        gameVersion: GameVersion,
        locationParentId: string,
        maxLevel: number,
        levelToXpRequired: (level: number) => number,
        subPackageId?: string,
    ) {
        // Get the user profile
        const userProfile = getUserData(userId, gameVersion)

        const parent =
            userProfile.Extensions.progression.Locations[locationParentId]

        const completionData: ProgressionData = subPackageId
            ? (parent[subPackageId as keyof typeof parent] as ProgressionData)
            : (parent as ProgressionData)

        const nextLevel: number = clampValue(
            completionData.Level + 1,
            1,
            maxLevel,
        )

        const nextLevelXp: number = levelToXpRequired(nextLevel)

        const thisLevelXp: number = levelToXpRequired(completionData.Level)

        return {
            Level: completionData.Level,
            MaxLevel: maxLevel,
            XP: completionData.Xp,
            PreviouslySeenXp: completionData.PreviouslySeenXp,
            Completion:
                (completionData.Xp - thisLevelXp) / (nextLevelXp - thisLevelXp),
            XpLeft: nextLevelXp - completionData.Xp,
        }
    }

    /**
     * Get the completion data for a location.
     * @param locationParentId The parent Id of the location.
     * @param subLocationId The id of the sublocation.
     * @param gameVersion The game version.
     * @param userId The id of the user.
     * @param contractType The type of the contract, only used to distinguish evergreen from other types (default).
     * @param subPackageId The id of the subpackage you want.
     * @returns The CompletionData object.
     */
    getLocationCompletion(
        locationParentId: string,
        subLocationId: string,
        gameVersion: GameVersion,
        userId: string,
        contractType = "mission",
        subPackageId?: string,
    ): CompletionData | undefined {
        // Get the mastery data
        const masteryPkg = this.getMasteryPackage(locationParentId, gameVersion)

        // We use the result from this function a bit, so we're just caching it
        const isSniper = isSniperLocation(locationParentId)

        if (!masteryPkg || (masteryPkg.SubPackages && !subPackageId)) {
            return undefined
        }

        const subPackage = masteryPkg.SubPackages
            ? masteryPkg.SubPackages.filter((pkg) => pkg.Id === subPackageId)[0]
            : undefined

        if (!subPackage && subPackageId) {
            return undefined
        }

        // TODO: Refactor this into the new inventory system?
        const name = isSniper
            ? getVersionedConfig<Unlockable[]>(
                  "SniperUnlockables",
                  gameVersion,
                  false,
              ).find((unlockable) => unlockable.Id === subPackageId)?.Properties
                  .Name
            : undefined

        return {
            ...this.getCompletionData(
                userId,
                gameVersion,
                locationParentId,
                (subPackage ? subPackage.MaxLevel : masteryPkg.MaxLevel) ||
                    DEFAULT_MASTERY_MAXLEVEL,
                contractType === "sniper"
                    ? xpRequiredForSniperLevel
                    : contractType === "evergreen"
                    ? xpRequiredForEvergreenLevel
                    : xpRequiredForLevel,
                subPackageId,
            ),
            Id: isSniper ? subPackageId! : masteryPkg.LocationId,
            SubLocationId: isSniper ? "" : subLocationId,
            HideProgression: masteryPkg.HideProgression || false,
            IsLocationProgression: !isSniper,
            Name: name!,
        }
    }

    getMasteryPackage(
        locationParentId: string,
        gameVersion: GameVersion,
    ): MasteryPackage | undefined {
        return this.masteryPackages[gameVersion].get(locationParentId)
    }

    private processDrops(
        curLevel: number,
        drops: MasteryPackageDrop[],
        unlockableMap: Map<string, Unlockable>,
    ): MasteryDrop[] {
        return drops
            .filter((drop) => {
                if (!unlockableMap.has(drop.Id)) {
                    log(LogLevel.DEBUG, `No unlockable found for ${drop.Id}`)

                    return false
                }

                return true
            })
            .map((drop) => {
                const unlockable: Unlockable = unlockableMap.get(drop.Id)!

                return {
                    IsLevelMarker: false,
                    Unlockable: unlockable,
                    Level: drop.Level,
                    IsLocked: drop.Level > curLevel,
                    TypeLocaKey: `UI_MENU_PAGE_MASTERY_UNLOCKABLE_NAME_${unlockable.Type}`,
                }
            })
    }

    private getMasteryData(
        locationParentId: string,
        gameVersion: GameVersion,
        userId: string,
        subPackageId?: string,
    ): MasteryData[] {
        // Get the mastery data
        const masteryPkg: MasteryPackage | undefined = this.getMasteryPackage(
            locationParentId,
            gameVersion,
        )

        if (!masteryPkg || (!masteryPkg.Drops && !masteryPkg.SubPackages)) {
            return []
        }

        // We use the result from this function a lot in here, so we're just "caching" it
        const isSniper = isSniperLocation(locationParentId)

        // Put all Ids into a set for quick lookup
        let dropIdSet: Set<string>

        if (masteryPkg.SubPackages) {
            dropIdSet = new Set(
                masteryPkg.SubPackages.map((pkg) =>
                    subPackageId && pkg.Id !== subPackageId
                        ? []
                        : pkg.Drops.map((drop) => drop.Id),
                ).reduce((a, e) => a.concat(e)),
            )

            // Add the main unlockable to the set to generate the page properly
            if (isSniper) {
                masteryPkg.SubPackages.forEach((pkg) => dropIdSet.add(pkg.Id))
            }

            if (!dropIdSet || dropIdSet.size === 0) {
                log(
                    LogLevel.ERROR,
                    "Unknown subPackageId specified for location",
                )

                return []
            }
        } else {
            dropIdSet = new Set(masteryPkg.Drops.map((drop) => drop.Id))
        }

        // Get all unlockables with matching Ids
        const unlockableData = getUnlockablesById(
            Array.from(dropIdSet),
            gameVersion,
        )

        // Put all unlockabkes in a map for quick lookup
        const mapped: [string, Unlockable][] = unlockableData.map(
            (unlockable) => {
                return [unlockable?.Id, unlockable] as unknown as [
                    string,
                    Unlockable,
                ]
            },
        )

        const unlockableMap: Map<string, Unlockable> = new Map(mapped)

        const masteryData: MasteryData[] = []

        if (masteryPkg.SubPackages) {
            for (const subPkg of masteryPkg.SubPackages) {
                if (subPackageId && subPkg.Id !== subPackageId) continue

                const completionData = this.getLocationCompletion(
                    locationParentId,
                    locationParentId,
                    gameVersion,
                    userId,
                    isSniper
                        ? "sniper"
                        : locationParentId.includes("SNUG")
                        ? "evergreen"
                        : "mission",
                    subPkg.Id,
                )

                if (completionData) {
                    masteryData.push({
                        CompletionData: completionData,
                        Drops: this.processDrops(
                            completionData.Level,
                            subPkg.Drops,
                            unlockableMap,
                        ),
                        Unlockable: isSniper
                            ? unlockableMap.get(subPkg.Id)
                            : undefined,
                    })
                }
            }
        } else {
            // All sniper locations are subpackages, so we don't need to add "sniper"
            // to the contractType expression.
            const completionData = this.getLocationCompletion(
                locationParentId,
                locationParentId,
                gameVersion,
                userId,
                locationParentId.includes("SNUG") ? "evergreen" : "mission",
            )

            if (completionData) {
                masteryData.push({
                    CompletionData: completionData,
                    Drops: this.processDrops(
                        completionData.Level,
                        masteryPkg.Drops || [],
                        unlockableMap,
                    ),
                })
            }
        }

        return masteryData
    }
}