/**
 * @fileoverview Validates game content for logical consistency.
 *
 * This service provides:
 * - Checks for item and harvestable configuration issues
 * - Warns about missing models, PrimaryParts, and inconsistent reset layers
 * - Ensures harvestables have enough craftable uses
 *
 * @since 1.0.0
 */

import { OnStart, Service } from "@flamework/core";
import Difficulty from "@rbxts/ejt";
import { Environment } from "@rbxts/ui-labs";
import { IS_EDIT } from "shared/Context";
import Item from "shared/item/Item";
import Items from "shared/items/Items";
import HARVESTABLES from "shared/world/harvestable/Harvestable";

/**
 * Ensures that game content is logical and consistent.
 */
@Service()
export default class SanityService implements OnStart {
    /**
     * Checks an item for common configuration mistakes.
     * Warns if the item is a crafting item with a low reset layer,
     * if required items have mismatched reset layers, or if the model is missing.
     *
     * @param item The item to check.
     */
    checkItem(item: Item) {
        // Check if the item is a shop with crafting items
        const shop = item.findTrait("Shop");
        if (shop !== undefined) {
            let isCrafting = false;
            for (const item of shop.items) {
                if (item.difficulty === Difficulty.Miscellaneous) {
                    isCrafting = true;
                    break;
                }
            }

            // Warn if crafting items have a reset layer < 100
            if (isCrafting) {
                for (const item of shop.items) {
                    const resetLayer = item.getResetLayer();
                    if (resetLayer < 100) {
                        warn(
                            `Item ${item.name} (${item.id}) has a reset layer of ${resetLayer}. This is likely a mistake as this is a crafting item and should persist.`,
                        );
                    }
                }
            }
        }

        // Check for mismatched reset layers between item and its requirements
        const a = item.getResetLayer();
        for (const [requiredItemId] of item.requiredItems) {
            const requiredItem = Items.getItem(requiredItemId);
            if (requiredItem === undefined) continue;
            const b = requiredItem.getResetLayer();
            if (a !== b && a < 100 && b < 100) {
                warn(
                    `Item ${item.name} (${item.id}) has a required item ${requiredItem.name} (${requiredItem.id}) with a different reset layer (${a} vs ${b}). This is likely a mistake.`,
                );
            }
        }

        // Check for missing model or PrimaryPart
        const model = item.MODEL;
        if (model === undefined) {
            warn(`Item ${item.name} (${item.id}) has no model. This is likely a mistake.`);
            return;
        }

        if (model.PrimaryPart === undefined) {
            warn(`Item ${item.name} (${item.id}) has no PrimaryPart set. This is likely a mistake.`);
            return;
        }
    }

    /**
     * Checks a harvestable for craftable uses and warns if too few.
     *
     * @param harvestableId The harvestable's ID.
     */
    checkHarvestable(harvestableId: string) {
        const harvestable = HARVESTABLES[harvestableId];
        if (harvestable === undefined) {
            return;
        }

        const item = Items.getItem(harvestableId);

        if (item === undefined) {
            return;
        }

        // Count how many items can be crafted using this harvestable
        let craftables = 0;
        for (const [_, craftable] of Items.itemsPerId) {
            if (craftable.requiredItems.has(item.id)) {
                craftables += 1;
            }
        }
        if (craftables < 5 && item.difficulty === Difficulty.Excavation) {
            warn(
                `Harvestable ${harvestable.name} (${harvestableId}) only has ${craftables} craftables (<5), consider adding more.`,
            );
        }
    }

    check() {
        // Check all items for configuration issues
        for (const [_, item] of Items.itemsPerId) {
            this.checkItem(item);
        }

        // Check all harvestables for craftable uses
        for (const [id] of pairs(HARVESTABLES)) {
            this.checkHarvestable(id as string);
        }
    }

    /**
     * Runs all sanity checks on items and harvestables at startup.
     */
    onStart() {
        if (IS_EDIT) return;

        this.check();
    }
}


//!native
//!optimize 2

/**
 * @fileoverview Core currency management system for the game.
 *
 * This service handles:
 * - Currency balance tracking and manipulation
 * - Purchase transactions and affordability checks
 * - Revenue calculation and tracking
 * - Currency propagation to clients
 * - Historical currency records (most ever, most since reset)
 * - Offline revenue estimation
 *
 * The service uses OnoeNum for big number arithmetic to handle very large currency values
 * that exceed JavaScript's number precision limits.
 *
 * @since 1.0.0
 */

import Signal from "@antivivi/lemon-signal";
import { simpleInterval } from "@antivivi/vrldk";
import { OnStart, Service } from "@flamework/core";
import { OnoeNum } from "@rbxts/serikanum";
import DataService from "server/services/data/DataService";
import Packets from "shared/Packets";
import CurrencyBundle from "shared/currency/CurrencyBundle";
import { CURRENCIES, CURRENCY_DETAILS } from "shared/currency/CurrencyDetails";
import CurrencyMap from "shared/currency/CurrencyMap";
import Queue from "shared/currency/Queue";
import eat from "shared/hamster/eat";

/** Constant zero value for performance optimization. */
const ZERO = new OnoeNum(0);

/**
 * Core currency management service that handles all currency-related operations.
 *
 * Manages multiple currency types with big number support, tracks historical maximums,
 * calculates revenue rates, and synchronizes currency data with clients.
 */
@Service()
export default class CurrencyService implements OnStart {
    // Core Currency Data

    /**
     * Current currency amounts - the main currency storage.
     * Backed by empire data for persistence.
     */
    private readonly currencies: CurrencyMap;

    /**
     * Highest amounts ever reached for each currency.
     * Used for achievements and statistics.
     */
    private readonly mostCurrencies: CurrencyMap;

    /**
     * Highest amounts reached since the last reset.
     * Used for offline revenue calculations.
     */
    private readonly mostCurrenciesSinceReset: CurrencyMap;

    // Public Interface

    /**
     * Public interface for currency balance operations.
     * Provides a convenient API for currency manipulation.
     */
    readonly balance: CurrencyBundle;

    /**
     * Signal fired when currency balances change.
     * Used by other systems to react to currency updates.
     */
    readonly balanceChanged = new Signal<(balance: CurrencyBundle) => void>();

    /**
     * Last propagated currency state for change detection.
     * Prevents unnecessary network updates when currencies haven't changed.
     */
    lastPropagation?: CurrencyMap;

    /**
     * Initializes the CurrencyService with data from DataService.
     * Sets up currency maps and creates the public balance interface.
     *
     * @param dataService Service providing persistent empire data.
     */
    constructor(private dataService: DataService) {
        // Wrap empire data maps for reactive updates
        this.currencies = CurrencyMap.wrap(dataService.empireData.currencies);
        this.mostCurrencies = CurrencyMap.wrap(dataService.empireData.mostCurrencies);
        this.mostCurrenciesSinceReset = CurrencyMap.wrap(dataService.empireData.mostCurrenciesSinceReset);

        // Create public balance interface
        this.balance = new CurrencyBundle(this.currencies, true);
    }

    // Currency Access Methods

    /**
     * Gets the current amount of a specific currency.
     *
     * @param currency The currency type to retrieve.
     * @returns The current amount, or zero if not found.
     */
    get(currency: Currency) {
        return this.balance.get(currency) ?? ZERO;
    }

    /**
     * Sets the amount of a specific currency.
     *
     * @param currency The currency type to set.
     * @param amount The new amount (undefined to remove currency).
     */
    set(currency: Currency, amount: OnoeNum | undefined) {
        this.balance.set(currency, amount);
    }

    /**
     * Increases a currency by a specific amount.
     *
     * @param currency The currency type to increment.
     * @param delta The amount to add.
     */
    increment(currency: Currency, delta: OnoeNum) {
        return this.set(currency, this.get(currency).add(delta));
    }

    /**
     * Replaces all currencies with the provided currency map.
     * Clears existing currencies and sets new values.
     *
     * @param currencies The new currency amounts to set.
     */
    setAll(currencies: CurrencyMap) {
        this.currencies.clear();
        for (const [currency, amount] of currencies) {
            this.currencies.set(currency, amount);
        }
    }

    // Transaction Methods

    /**
     * Checks whether the amount of each currency in the balance satisfies the required currency amounts.
     *
     * @param required The currency amounts needed.
     * @returns A tuple that contains if there is sufficient currencies and the remaining currency amounts after subtraction.
     */
    canAfford(required: CurrencyBundle): LuaTuple<[boolean, CurrencyBundle]> {
        const result = new CurrencyBundle();
        const sufficient = this.balance.canAfford(required.amountPerCurrency, result.amountPerCurrency);
        return $tuple(sufficient, result);
    }

    /**
     * Increments multiple currencies at once.
     *
     * @param delta Map of currencies and amounts to add.
     */
    incrementAll(delta: CurrencyMap) {
        for (const [currency, amount] of delta) {
            this.increment(currency, amount);
        }
    }

    /**
     * Attempts to purchase something with the given price.
     * Deducts currencies if affordable and not marked as free.
     *
     * @param price The cost of the purchase.
     * @returns Whether the purchase was successful.
     */
    purchase(price: CurrencyBundle) {
        const [isSufficient, remaining] = this.canAfford(price);
        if (isSufficient === true) {
            for (const [currency, amount] of remaining.amountPerCurrency) {
                this.set(currency, amount);
            }
            return true;
        }
        return false;
    }

    // Synchronization Methods

    /**
     * Propagates currency changes to clients.
     * Only sends updates when currencies have actually changed to optimize network usage.
     */
    propagate() {
        const currencies = this.currencies;
        if (this.lastPropagation !== undefined && CurrencyMap.equals(this.lastPropagation, currencies)) {
            return;
        }
        this.lastPropagation = table.clone(currencies);

        Packets.balance.set(currencies);
        this.balanceChanged.fire(this.balance);
    }

    /**
     * Naive implementation of offline revenue calculation.
     * Assumes revenue generation over the current reset period is linear.
     * @returns Revenue generated per second.
     */
    getOfflineRevenue() {
        const currentPlaytime = this.dataService.empireData.playtime;
        const lastResetPlaytime = this.dataService.empireData.lastResetPlaytime;
        const diff = math.max(currentPlaytime - lastResetPlaytime, 600); // At least 10 minutes to avoid extreme values

        return new CurrencyBundle(this.mostCurrenciesSinceReset).divConstant(diff);
    }

    onStart() {
        // Create revenue tracking queues for each currency
        const queuePerCurrency = new Map<Currency, Queue>();
        for (const currency of CURRENCIES) {
            queuePerCurrency.set(currency, new Queue());
        }

        const unimportantCleanup = simpleInterval(() => {
            const revenue = new Map<Currency, OnoeNum>();

            for (const [currency, amount] of this.currencies) {
                // Remove invalid currencies
                if (CURRENCY_DETAILS[currency] === undefined) {
                    this.currencies.delete(currency);
                    continue;
                }

                // Update all-time maximum
                const most = this.mostCurrencies.get(currency);
                if (most === undefined || amount.moreThan(most)) {
                    this.mostCurrencies.set(currency, amount);
                }

                // Update maximum since reset
                const mostSinceReset = this.mostCurrenciesSinceReset.get(currency);
                if (mostSinceReset === undefined || amount.moreThan(mostSinceReset)) {
                    this.mostCurrenciesSinceReset.set(currency, amount);
                }

                // Calculate revenue using queue averaging
                const queue = queuePerCurrency.get(currency);
                if (queue === undefined) {
                    continue;
                }
                queue.addDelta(amount);
                revenue.set(currency, queue.meanDelta());
            }

            // Propagate updates to clients
            Packets.mostBalance.set(this.mostCurrencies);
            Packets.revenue.set(revenue);
        }, 1);

        // Ensure Funds currency exists
        if (!this.currencies.has("Funds")) this.currencies.set("Funds", new OnoeNum(0));

        const importantCleanup = simpleInterval(() => {
            this.propagate();
        }, 0.1);

        Packets.balance.setEntries(this.currencies);

        eat(importantCleanup);
        eat(unimportantCleanup);
    }
}


//!native
//!optimize 2

/**
 * @fileoverview Manages upgrades, their purchase, and synchronization.
 *
 * This service provides:
 * - Tracking and updating upgrade amounts
 * - Handling upgrade purchases and caps
 * - Firing signals and syncing upgrades with clients
 * - Applying upgrade effects (e.g., walk speed)
 *
 * @since 1.0.0
 */

import Signal from "@antivivi/lemon-signal";
import { OnInit, Service } from "@flamework/core";
import { Players, StarterPlayer, Workspace } from "@rbxts/services";
import CurrencyService from "server/services/data/CurrencyService";
import DataService from "server/services/data/DataService";
import { log } from "server/services/permissions/LogService";
import PermissionService from "server/services/permissions/PermissionService";
import eat from "shared/hamster/eat";
import NamedUpgrades from "shared/namedupgrade/NamedUpgrades";
import Packets from "shared/Packets";
import { AREAS } from "shared/world/Area";

/**
 * Service that manages upgrades for the current empire, including purchase and effects.
 */
@Service()
export default class NamedUpgradeService implements OnInit {
    /** Map of upgrade IDs to their current amounts. */
    readonly upgrades: Map<string, number>;

    /** Signal fired when upgrades change. */
    upgradesChanged = new Signal<(upgrades: Map<string, number>) => void>();

    /** Signal fired when an upgrade is bought. */
    upgradeBought = new Signal<(player: Player, upgradeId: string, to: number, from: number) => void>();

    constructor(
        private dataService: DataService,
        private currencyService: CurrencyService,
        private permissionsService: PermissionService,
    ) {
        this.upgrades = this.dataService.empireData.upgrades;
    }

    /**
     * Sets the amount for each upgrade from a map and syncs with clients.
     *
     * @param data Map of upgrade IDs to amounts.
     */
    setAmountPerUpgrade(data: Map<string, number>) {
        this.upgrades.clear();
        data.forEach((value, key) => this.upgrades.set(key, value));
        this.upgradesChanged.fire(data);
        Packets.upgrades.set(data);
    }

    /**
     * Gets the current amount for a specific upgrade.
     *
     * @param upgradeId The upgrade ID.
     */
    getUpgradeAmount(upgradeId: string) {
        return this.upgrades.get(upgradeId) ?? 0;
    }

    /**
     * Sets the amount for a specific upgrade and syncs with clients.
     *
     * @param upgradeId The upgrade ID.
     * @param amount The new amount.
     */
    setUpgradeAmount(upgradeId: string, amount: number) {
        const upgrades = this.upgrades;
        upgrades.set(upgradeId, amount);
        this.upgradesChanged.fire(upgrades);
        Packets.upgrades.set(upgrades);
    }

    /**
     * Attempts to buy an upgrade, checking caps, price, and permissions.
     * Deducts currency and updates state if successful.
     *
     * @param upgradeId The upgrade ID.
     * @param to The target amount (optional).
     * @param player The player buying the upgrade (optional).
     * @param isFree Whether the upgrade is free (optional).
     * @returns True if the upgrade was bought, false otherwise.
     */
    buyUpgrade(upgradeId: string, to?: number, player?: Player, isFree?: boolean) {
        const upgrade = NamedUpgrades.ALL_UPGRADES.get(upgradeId);
        if (upgrade === undefined) {
            return false;
        }
        const current = this.getUpgradeAmount(upgradeId);
        if (to === undefined) {
            to = current + 1;
        }
        const cap = upgrade.cap;
        if (cap !== undefined && to > cap) {
            return false;
        } else if (current >= to) {
            return false;
        }
        const price = to === current + 1 ? upgrade.getPrice(to) : upgrade.getPrice(current + 1, to);
        if (price === undefined) {
            return false;
        }
        let success: boolean;
        if (isFree === true) {
            [success] = this.currencyService.canAfford(price);
        } else {
            success = this.currencyService.purchase(price);
        }

        if (success) {
            this.setUpgradeAmount(upgradeId, to);
            if (player !== undefined) {
                this.upgradeBought.fire(player, upgradeId, to, current);
            }
        }
        return success;
    }

    /**
     * Initializes the NamedUpgradeService, sets up listeners and applies upgrade effects.
     */
    onInit() {
        Packets.upgrades.set(this.upgrades);
        Packets.buyUpgrade.fromClient((player, upgradeId, to) => {
            if (!this.permissionsService.hasPermission(player, "purchase") || to === undefined) {
                return false;
            }
            return this.buyUpgrade(upgradeId, to, player);
        });

        const WALKSPEED_UPGRADES = NamedUpgrades.getUpgrades("WalkSpeed");
        const GRID_SIZE_UPGRADES = NamedUpgrades.getUpgrades("GridSize");

        const onUpgradesChanged = (data: Map<string, number>) => {
            const oldWs = Workspace.GetAttribute("WalkSpeed") as number | undefined;
            let ws = 16;
            WALKSPEED_UPGRADES.forEach((value, key) => (ws = value.apply(ws, data.get(key) ?? 0)));
            Workspace.SetAttribute("WalkSpeed", ws);
            StarterPlayer.CharacterWalkSpeed = ws;
            for (const player of Players.GetPlayers()) {
                const character = player.Character;
                if (character === undefined) continue;
                const humanoid = character.FindFirstChildOfClass("Humanoid");
                if (humanoid === undefined) continue;
                if (humanoid.WalkSpeed === oldWs) {
                    humanoid.WalkSpeed = ws;
                }
            }

            for (const [id, area] of pairs(AREAS)) {
                const gridWorldNode = area.gridWorldNode;
                if (gridWorldNode === undefined) continue;
                const grid = gridWorldNode?.getInstance();
                if (grid === undefined) continue;

                // Calculate the new grid size based on applied upgrades
                let size = gridWorldNode.originalSize;
                if (size === undefined) continue;
                GRID_SIZE_UPGRADES.forEach((upgrade, upgradeId) => {
                    const amount = data.get(upgradeId);
                    if (amount === undefined || size === undefined) return;
                    if (upgrade.area === id) size = upgrade.apply(size, amount);
                });

                // Update the grid size if it has changed
                if (grid.Size !== size) {
                    grid.Size = size;
                }
            }
        };
        const changedConnection = this.upgradesChanged.connect(onUpgradesChanged);
        onUpgradesChanged(this.upgrades);

        const boughtConnection = this.upgradeBought.connect((player, upgrade, to) =>
            log({
                time: tick(),
                type: "Upgrade",
                player: player.UserId,
                upgrade: upgrade,
                amount: to,
            }),
        );
        eat(() => {
            changedConnection.Disconnect();
            boughtConnection.Disconnect();

            // Restore grid sizes to original after cleanup
            for (const [, area] of pairs(AREAS)) {
                const gridWorldNode = area.gridWorldNode;
                if (gridWorldNode === undefined) continue;
                const grid = gridWorldNode?.getInstance();
                if (grid === undefined) continue;
                const size = gridWorldNode.originalSize;
                if (size !== undefined && grid.Size !== size) {
                    grid.Size = size;
                }
            }
        });
    }
}


//!native
//!optimize 2

/**
 * @fileoverview Player session and total playtime tracking system.
 *
 * This service handles:
 * - Total playtime accumulation across all sessions
 * - Current session time tracking
 * - Longest session record keeping
 * - Real-time playtime updates to clients
 * - Persistent storage of playtime statistics
 *
 * The service uses RunService.Heartbeat for accurate time tracking
 * and batches updates every second for performance optimization.
 *
 * @since 1.0.0
 */

import { OnInit, OnStart, Service } from "@flamework/core";
import { RunService } from "@rbxts/services";
import DataService from "server/services/data/DataService";
import eat from "shared/hamster/eat";
import Packets from "shared/Packets";

/**
 * Service for tracking and managing player playtime statistics.
 *
 * Monitors both current session duration and cumulative playtime across
 * all sessions, with automatic persistence and client synchronization.
 */
@Service()
export default class PlaytimeService implements OnInit, OnStart {
    /**
     * Current session time in seconds.
     * Tracks how long the current play session has been active.
     */
    sessionTime = 0;

    /**
     * Initializes the PlaytimeService with data persistence.
     *
     * @param dataService Service providing persistent empire data for playtime storage.
     */
    constructor(private dataService: DataService) {}

    // Playtime Management Methods

    /**
     * Gets the total accumulated playtime across all sessions.
     *
     * @returns Total playtime in seconds.
     */
    getPlaytime() {
        return this.dataService.empireData.playtime;
    }

    /**
     * Sets the total accumulated playtime and updates clients.
     *
     * @param value The new total playtime in seconds.
     */
    setPlaytime(value: number) {
        this.dataService.empireData.playtime = value;
        Packets.empirePlaytime.set(value);
    }

    // Service Lifecycle

    /**
     * Initializes the PlaytimeService and starts the time tracking system.
     * Sets up a heartbeat-based timer that updates playtime statistics every second.
     */
    onInit() {
        // Send initial longest session data to clients
        Packets.longestSessionTime.set(this.dataService.empireData.longestSession);
    }

    onStart() {
        // Start the playtime tracking system
        task.spawn(() => {
            let t = 0; // Time accumulator for batching updates

            // Use heartbeat for precise time tracking
            const conn = RunService.Heartbeat.Connect((dt) => {
                t += dt;

                // Update every second for performance optimization
                if (t > 1) {
                    // Update total playtime
                    const playtime = this.getPlaytime() + t;
                    this.setPlaytime(playtime);

                    // Update current session time
                    const st = this.sessionTime + t;
                    this.sessionTime = st;
                    Packets.sessionTime.set(st);

                    // Check and update longest session record
                    if (this.dataService.empireData.longestSession < st) {
                        this.dataService.empireData.longestSession = st;
                        Packets.longestSessionTime.set(st);
                    }

                    // Reset time accumulator
                    t = 0;
                }
            });
            eat(conn, "Disconnect");
        });
    }
}


import { OnInit, Service } from "@flamework/core";
import Difficulty from "@rbxts/ejt";
import { Players } from "@rbxts/services";
import eat from "shared/hamster/eat";
import getPlayerBackpack from "shared/hamster/getPlayerBackpack";
import { getPlayerCharacter } from "shared/hamster/getPlayerCharacter";
import Packets from "shared/Packets";

const PREVIEW_TOOL_NAME = "BuildPreviewTool";

@Service()
export class BuildPreviewToolService implements OnInit {
    private readonly previewTools = new Map<Player, Tool>();
    private editModePreviewTool: Tool | undefined;
    private readonly activeDifficultyPerPlayer = new Map<Player, string>();
    private editModeActiveDifficulty: string | undefined;

    onInit() {
        eat(
            Packets.setBuildPreviewTool.fromClient((player, difficultyId) => this.handleRequest(player, difficultyId)),
            "Disconnect",
        );

        eat(
            Players.PlayerRemoving.Connect((player) => this.cleanupPlayer(player)),
            "Disconnect",
        );
    }

    private cleanupPlayer(player: Player) {
        this.removePreviewTool(player);
    }

    private handleRequest(player: Player | undefined, difficultyId: string) {
        if (difficultyId === "") {
            this.removePreviewTool(player);
            return;
        }

        if (
            (player !== undefined && this.activeDifficultyPerPlayer.get(player) === difficultyId) ||
            (player === undefined && this.editModeActiveDifficulty === difficultyId)
        ) {
            const tool = player ? this.previewTools.get(player) : this.editModePreviewTool;
            if (tool !== undefined && tool.Parent !== undefined) {
                this.ensureToolEquipped(player, tool, difficultyId);
                return;
            }
        }

        this.givePreviewTool(player, difficultyId);
    }

    private givePreviewTool(player: Player | undefined, difficultyId: string) {
        const color = this.getDifficultyColor(difficultyId);
        let tool = player ? this.previewTools.get(player) : this.editModePreviewTool;
        if (tool === undefined || !tool.IsDescendantOf(game)) {
            if (tool !== undefined) {
                tool.Destroy();
            }
            tool = this.createPreviewTool();
            if (player) {
                this.previewTools.set(player, tool);
            } else {
                this.editModePreviewTool = tool;
            }
        }

        this.configureTool(tool, difficultyId, color);
        this.placeTool(player, tool);
        if (player) {
            this.activeDifficultyPerPlayer.set(player, difficultyId);
        } else {
            this.editModeActiveDifficulty = difficultyId;
        }
    }

    private ensureToolEquipped(player: Player | undefined, tool: Tool, difficultyId: string) {
        const color = this.getDifficultyColor(difficultyId);
        this.configureTool(tool, difficultyId, color);
        this.placeTool(player, tool);
    }

    private createPreviewTool() {
        const tool = new Instance("Tool") as Tool;
        tool.Name = PREVIEW_TOOL_NAME;
        tool.CanBeDropped = false;
        tool.RequiresHandle = true;

        const handle = new Instance("Part");
        handle.Name = "Handle";
        handle.Size = new Vector3(1.5, 1.5, 1.5);
        handle.Material = Enum.Material.Neon;
        handle.CanCollide = false;
        handle.Anchored = false;
        handle.Massless = true;
        handle.Parent = tool;

        return tool;
    }

    private configureTool(tool: Tool, difficultyId: string, color: Color3) {
        tool.SetAttribute("BuildPreviewDifficulty", difficultyId);

        const handle = tool.FindFirstChild("Handle");
        if (handle !== undefined && handle.IsA("Part")) {
            handle.Color = color;
            handle.Material = Enum.Material.Neon;
            handle.Size = new Vector3(1.5, 1.5, 1.5);
            handle.CanCollide = false;
            handle.Anchored = false;
            handle.Massless = true;
            handle.Transparency = 0;
            handle.CastShadow = false;
            handle.Shape = Enum.PartType.Block;
        }
    }

    private placeTool(player: Player | undefined, tool: Tool) {
        const character = getPlayerCharacter(player);
        const humanoid = character?.FindFirstChildOfClass("Humanoid") as Humanoid | undefined;
        const backpack = getPlayerBackpack(player);

        if (character !== undefined && humanoid !== undefined) {
            tool.Parent = character;
            humanoid.EquipTool(tool);
        } else if (backpack !== undefined) {
            tool.Parent = backpack;
        }
    }

    private getDifficultyColor(difficultyId: string) {
        const difficulty = Difficulty.get(difficultyId);
        return difficulty?.color ?? Color3.fromRGB(255, 255, 255);
    }

    private removePreviewTool(player: Player | undefined) {
        const stored = player ? this.previewTools.get(player) : this.editModePreviewTool;
        if (stored !== undefined) {
            stored.Destroy();
            if (player) this.previewTools.delete(player);
        }
        if (player) this.activeDifficultyPerPlayer.delete(player);

        const character = getPlayerCharacter(player);
        if (character !== undefined) {
            const existing = character.FindFirstChild(PREVIEW_TOOL_NAME);
            if (existing !== undefined && existing.IsA("Tool")) {
                existing.Destroy();
            }
        }

        const backpack = getPlayerBackpack(player);
        if (backpack !== undefined) {
            const existing = backpack.FindFirstChild(PREVIEW_TOOL_NAME);
            if (existing !== undefined && existing.IsA("Tool")) {
                existing.Destroy();
            }
        }
    }
}


/**
 * @fileoverview Core marketplace service for managing item listings and transactions.
 *
 * This service handles:
 * - Creating and managing marketplace listings
 * - Processing buyouts and auction bids
 * - Maintaining listing state with DataStore operations
 * - Anti-dupe protection using UpdateAsync
 * - Integration with external trade token system
 * - Automatic listing expiry and cleanup
 *
 * The service uses one DataStore key per auction to minimize dupe risk
 * and ensure atomic operations for all critical modifications.
 *
 * @since 1.0.0
 */

import { OnInit, OnStart, Service } from "@flamework/core";
import { DataStoreService, HttpService, RunService } from "@rbxts/services";
import { $env } from "rbxts-transform-env";
import CurrencyService from "server/services/data/CurrencyService";
import DataService from "server/services/data/DataService";
import ItemService from "server/services/item/ItemService";
import PermissionService from "server/services/permissions/PermissionService";
import { IS_STUDIO } from "shared/Context";
import CurrencyBundle from "shared/currency/CurrencyBundle";
import eat from "shared/hamster/eat";
import MARKETPLACE_CONFIG from "shared/marketplace/MarketplaceListing";
import Packets from "shared/Packets";

/**
 * Core marketplace service handling all marketplace operations.
 */
@Service()
export default class MarketplaceService implements OnInit, OnStart {
    private readonly WEBHOOK = $env.string("TRADE_WEBHOOK");

    private readonly empireData: EmpireData;
    private readonly empireId: string;

    private readonly MARKETPLACE_STORE = (() => {
        try {
            const store = DataStoreService.GetDataStore(MARKETPLACE_CONFIG.DATASTORE_NAME);
            return store;
        } catch (err) {
            if (!IS_STUDIO) warn(`Failed to get Marketplace DataStore: ${err}`);
            return undefined;
        }
    })();

    private readonly INDEX_STORE = (() => {
        try {
            const store = DataStoreService.GetDataStore(MARKETPLACE_CONFIG.INDEX_DATASTORE_NAME);
            return store;
        } catch (err) {
            if (!IS_STUDIO) warn(`Failed to get Marketplace Index DataStore: ${err}`);
            return undefined;
        }
    })();

    private readonly HISTORY_STORE = (() => {
        try {
            const store = DataStoreService.GetDataStore(MARKETPLACE_CONFIG.HISTORY_DATASTORE_NAME);
            return store;
        } catch (err) {
            if (!IS_STUDIO) warn(`Failed to get Marketplace History DataStore: ${err}`);
            return undefined;
        }
    })();

    constructor(
        private readonly currencyService: CurrencyService,
        private readonly dataService: DataService,
        private readonly itemService: ItemService,
        private readonly permissionsService: PermissionService,
    ) {
        this.empireData = this.dataService.empireData;
        this.empireId = this.dataService.empireId;
    }

    onInit() {
        // Set up packet handlers
        Packets.createListing.fromClient((player, uuid, price) => {
            try {
                return this.createListing(player, uuid, price);
            } catch (error) {
                warn("Error creating marketplace listing:", error);
                return false;
            }
        });

        Packets.cancelListing.fromClient((player, uuid) => {
            try {
                return this.cancelListing(player, uuid);
            } catch (error) {
                warn("Error cancelling marketplace listing:", error);
                return false;
            }
        });

        Packets.buyListing.fromClient((player, uuid) => {
            return this.buyListing(player, uuid);
        });

        Packets.searchListings.fromClient((player, searchQuery, page) => {
            return this.searchListings(player, searchQuery, page);
        });
    }

    onStart() {
        // Start cleanup cycle for expired listings every 5 minutes (300 seconds)
        const CLEANUP_INTERVAL = 300; // seconds
        let lastCleanup = 0;
        const connection = RunService.Heartbeat.Connect(() => {
            const now = os.time();
            if (now - lastCleanup >= CLEANUP_INTERVAL) {
                lastCleanup = now;
                this.cleanupExpiredListings();
            }
        });
        eat(connection);

        Packets.empireActiveListings.set(this.empireData.marketplaceListed);
    }

    public searchListings(player: Player | undefined, searchQuery: string, page: number): MarketplaceListing[] {
        if (!this.isOperational(player)) return [];

        return [];
    }

    /**
     * Creates a new marketplace listing to sell from the current empire's inventory.
     * @param player The player creating the listing (optional).
     * @param uuid The UUID of the unique item being listed.
     * @param price The price of the listing.
     * @returns True if the listing was created successfully, false otherwise.
     */
    public createListing(player: Player | undefined, uuid: string, price: number): boolean {
        if (!this.isOperational(player)) return false;

        const uniqueItem = this.empireData.items.uniqueInstances.get(uuid);
        if (!uniqueItem) return false; // Empire doesn't own this item
        if (uniqueItem.placed !== undefined) return false; // Item is currently placed

        // Validate price is within allowed range
        if (price < MARKETPLACE_CONFIG.MIN_LISTING_PRICE || price > MARKETPLACE_CONFIG.MAX_LISTING_PRICE) {
            return false;
        }

        // Remove item from empire's inventory
        if (!this.empireData.items.uniqueInstances.delete(uuid)) {
            return false;
        }

        // Create the listing
        let listing: MarketplaceListing | undefined;
        try {
            [listing] = this.MARKETPLACE_STORE!.UpdateAsync(uuid, (oldListing: MarketplaceListing | undefined) => {
                if (oldListing !== undefined && oldListing.bought === false) {
                    warn("Listing creation failed - duplicate UUID");
                    // Listing already exists for this UUID and is still active
                    return $tuple(undefined);
                }

                const created = os.time();
                return $tuple({
                    uuid,
                    price,
                    created,
                    uniqueItem,
                    sellerEmpireId: this.empireId,
                    bought: false,
                });
            });
        } catch (error) {
            warn("Error creating marketplace listing:", error);
        }

        if (listing === undefined || listing.sellerEmpireId !== this.empireId) {
            // Return item to empire's inventory if listing creation failed
            this.empireData.items.uniqueInstances.set(uuid, uniqueItem);
            return false;
        }

        this.empireData.marketplaceListed.set(uuid, listing);
        this.itemService.requestChanges();
        Packets.empireActiveListings.set(this.empireData.marketplaceListed);
        print("Created marketplace listing:", listing);

        return true;
    }

    /**
     * Cancels an existing listing made by the current empire.
     * @param player The player cancelling the listing (optional).
     * @param uuid The UUID of the listing to cancel.
     * @returns True if the listing was cancelled successfully, false otherwise.
     */
    public cancelListing(player: Player | undefined, uuid: string): boolean {
        if (!this.isOperational(player)) return false;

        // Use UpdateAsync to atomically mark listing as inactive and return item
        let listing: MarketplaceListing | undefined;
        try {
            [listing] = this.MARKETPLACE_STORE!.UpdateAsync(uuid, (oldListing: MarketplaceListing | undefined) => {
                if (oldListing === undefined) {
                    print("Cannot cancel listing - does not exist:", uuid);
                    return $tuple(undefined);
                }
                if (oldListing.sellerEmpireId !== this.empireId) {
                    print("Cannot cancel listing - not owned by this empire:", uuid);
                    return $tuple(undefined);
                }

                if (oldListing.lock !== undefined) {
                    const lockAge = os.time() - (oldListing.lockedAt ?? 0);
                    if (lockAge < MARKETPLACE_CONFIG.LOCK_TIMEOUT) {
                        // Listing is locked by another transaction
                        print("Cannot cancel listing - currently locked:", uuid);
                        return $tuple(undefined);
                    }
                }

                oldListing.lock = undefined;
                oldListing.lockedAt = undefined;
                oldListing.bought = true;
                return $tuple(oldListing);
            });
        } catch (error) {
            warn("Error cancelling marketplace listing:", error);
        }

        // Check if cancellation was successful
        if (
            listing === undefined ||
            listing.sellerEmpireId !== this.empireId ||
            listing.lock !== undefined ||
            listing.lockedAt !== undefined ||
            listing.bought !== true
        ) {
            return false;
        }

        // Return item to this empire's inventory
        this.empireData.items.uniqueInstances.set(uuid, listing.uniqueItem);
        this.empireData.marketplaceListed.delete(uuid);
        this.itemService.requestChanges();
        Packets.empireActiveListings.set(this.empireData.marketplaceListed);
        print("Cancelled marketplace listing:", listing);

        return true;
    }

    /**
     * Processes a buyout purchase.
     * @param player The player buying the listing (optional).
     * @param uuid The UUID of the listing to buy.
     * @returns True if the purchase was successful, false otherwise.
     */
    public buyListing(player: Player | undefined, uuid: string): boolean {
        if (!this.isOperational(player)) return false;

        // Create processing token before starting transaction
        const token: TradeToken = {
            buyerEmpireId: this.empireId,
            sellerEmpireId: "", // Will be filled from listing
            uuid,
            price: 0, // Will be filled from listing
            timestamp: os.time(),
            status: "processing",
        };

        try {
            // Validate listing and lock it for processing
            const [listing] = this.MARKETPLACE_STORE!.UpdateAsync(uuid, (listing: MarketplaceListing | undefined) => {
                if (listing === undefined || listing.lock !== undefined || listing.sellerEmpireId === this.empireId) {
                    return $tuple(undefined);
                }

                listing.lock = this.empireId; // Lock the listing to prevent concurrent purchases
                listing.lockedAt = os.time();
                return $tuple(listing);
            });

            // Re-verify information to prevent TOCTOU issues
            if (
                listing === undefined ||
                listing.sellerEmpireId === this.empireId ||
                listing.lock !== this.empireId ||
                listing.bought === true
            ) {
                return false;
            }

            // Update token with actual details
            token.sellerEmpireId = listing.sellerEmpireId;
            token.price = listing.price;

            // Upload processing token to external system
            this.uploadTradeToken(uuid, token);

            // Process payment
            const success = this.currencyService.purchase(new CurrencyBundle().set("Diamonds", listing.price));
            if (!success) {
                token.status = "failed";
                this.uploadTradeToken(uuid, token);

                // Reactivate listing if payment failed
                this.MARKETPLACE_STORE!.UpdateAsync(uuid, (listing: MarketplaceListing | undefined) => {
                    if (
                        listing === undefined ||
                        listing.sellerEmpireId === this.empireId ||
                        listing.lock !== this.empireId ||
                        listing.bought === true
                    ) {
                        return $tuple(undefined);
                    }

                    listing.lock = undefined;
                    listing.lockedAt = undefined;
                    return $tuple(listing);
                });

                return false;
            }

            // Mark listing as sold
            let [newListing] = this.MARKETPLACE_STORE!.UpdateAsync(uuid, (listing: MarketplaceListing | undefined) => {
                if (
                    listing === undefined ||
                    listing.sellerEmpireId === this.empireId ||
                    listing.lock !== this.empireId ||
                    listing.bought === true
                ) {
                    return $tuple(undefined);
                }
                listing.bought = true;
                return $tuple(listing);
            });

            if (
                newListing === undefined ||
                newListing.sellerEmpireId !== listing.sellerEmpireId ||
                newListing.bought !== true
            ) {
                warn("Failed to mark listing as sold:", uuid);
                token.status = "failed";
                this.uploadTradeToken(uuid, token);
                return false;
            }

            // Add item to buyer's inventory
            const empireData = this.dataService.empireData;
            empireData.items.uniqueInstances.set(uuid, listing.uniqueItem);

            // Record transaction
            const transaction: MarketplaceTransaction = {
                uuid,
                baseItemId: listing.uniqueItem.baseItemId,
                sellerEmpireId: listing.sellerEmpireId,
                buyerEmpireId: this.empireId,
                price: listing.price,
                timestamp: os.time(),
            };

            this.HISTORY_STORE!.UpdateAsync(uuid, (history: MarketplaceTransaction[] | undefined) => {
                return $tuple(history ? [...history, transaction] : [transaction]);
            });

            // Complete token
            token.status = "completed";
            this.uploadTradeToken(uuid, token);

            // Send webhook notification
            this.sendTradeWebhook(transaction);
            return true;
        } catch (error) {
            warn("Error processing buyout:", error);
            token.status = "failed";
            this.uploadTradeToken(uuid, token);
            return false;
        }
    }

    /**
     * Uploads a trade token to external system for recovery.
     */
    private uploadTradeToken(tokenId: string, token: TradeToken) {
        if (this.WEBHOOK === undefined) {
            return;
        }

        try {
            const payload = {
                tokenId: tokenId,
                token: token,
                timestamp: os.time(),
            };

            HttpService.PostAsync(this.WEBHOOK, HttpService.JSONEncode(payload), Enum.HttpContentType.ApplicationJson);
        } catch (error) {
            warn("Failed to upload trade token:", error);
        }
    }

    /**
     * Cleans up expired listings made by this empire.
     */
    private cleanupExpiredListings(): void {
        let changed = false;
        for (const [uuid, listing] of this.empireData.marketplaceListed) {
            if (os.time() < listing.created + MARKETPLACE_CONFIG.LISTING_DURATION || listing.bought) {
                continue;
            }

            try {
                let [newListing] = this.MARKETPLACE_STORE!.UpdateAsync(
                    uuid,
                    (oldListing: MarketplaceListing | undefined) => {
                        if (oldListing === undefined) {
                            print("Cannot clean up listing - does not exist:", uuid);
                            return $tuple(undefined);
                        }
                        if (oldListing.sellerEmpireId !== this.empireId) {
                            print("Cannot clean up listing - not owned by this empire:", uuid);
                            return $tuple(undefined);
                        }
                        if (oldListing.bought === true) {
                            print("Cannot clean up listing - already sold:", uuid);
                            return $tuple(undefined);
                        }

                        if (oldListing.lock !== undefined) {
                            const lockAge = os.time() - (oldListing.lockedAt ?? 0);
                            if (lockAge < MARKETPLACE_CONFIG.LOCK_TIMEOUT) {
                                // Listing is locked by another transaction
                                print("Cannot clean up listing - currently locked:", uuid);
                                return $tuple(undefined);
                            }
                        }

                        // Mark listing as expired
                        oldListing.lock = undefined;
                        oldListing.lockedAt = undefined;
                        oldListing.bought = true;
                        return $tuple(oldListing);
                    },
                );

                if (
                    newListing === undefined ||
                    newListing.sellerEmpireId !== this.empireId ||
                    newListing.bought !== true ||
                    newListing.lock !== undefined ||
                    newListing.lockedAt !== undefined
                ) {
                    print("Failed to clean up listing - could not mark as expired:", uuid);
                    continue;
                }

                // Return item to this empire's inventory
                this.empireData.items.uniqueInstances.set(uuid, newListing.uniqueItem);
                this.empireData.marketplaceListed.delete(uuid);
                changed = true;
            } catch (error) {
                warn("Error removing expired listing from DataStore:", error);
                continue;
            }
        }

        if (changed) {
            this.itemService.requestChanges();
            Packets.empireActiveListings.set(this.empireData.marketplaceListed);
        }
    }

    /**
     * Checks if the marketplace is operational for a given player.
     * @param player The player to check permissions for (optional).
     * @returns True if operational, false otherwise.
     */
    private isOperational(player?: Player) {
        if (!MARKETPLACE_CONFIG.ENABLED || !this.MARKETPLACE_STORE || !this.INDEX_STORE || !this.HISTORY_STORE)
            return false;
        if (player !== undefined && !this.permissionsService.hasPermission(player, "marketplace")) return false;
        return true;
    }

    /**
     * Sends a webhook notification for a completed trade.
     * @param transaction The transaction details.
     * @return True if the webhook was sent successfully, false otherwise.
     */
    private async sendTradeWebhook(transaction: MarketplaceTransaction): Promise<boolean> {
        try {
            const payload = {
                type: "trade_completed",
                transaction: transaction,
                timestamp: os.time(),
            };

            if (this.WEBHOOK !== undefined) {
                HttpService.PostAsync(
                    this.WEBHOOK,
                    HttpService.JSONEncode(payload),
                    Enum.HttpContentType.ApplicationJson,
                );
                return true;
            }
        } catch (error) {
            warn("Failed to send trade webhook:", error);
        }
        return false;
    }
}


import { OnStart, Service } from "@flamework/core";
import Difficulty from "@rbxts/ejt";
import { OnoeNum } from "@rbxts/serikanum";
import { Players, Workspace } from "@rbxts/services";
import CurrencyService from "server/services/data/CurrencyService";
import DataService from "server/services/data/DataService";
import PlaytimeService from "server/services/data/PlaytimeService";
import ItemService from "server/services/item/ItemService";
import PermissionService from "server/services/permissions/PermissionService";
import CurrencyBundle from "shared/currency/CurrencyBundle";
import DifficultyResearch from "shared/difficulty/DifficultyResearch";
import DifficultyReward from "shared/difficulty/reward/DifficultyReward";
import { getPlayerCharacter } from "shared/hamster/getPlayerCharacter";
import Item from "shared/item/Item";
import Items from "shared/items/Items";
import Packets from "shared/Packets";

type WalkSpeedBuffState = {
    amount: number;
    expiresAt: number;
    connection?: RBXScriptConnection;
};

const ITEM_WEIGHTS = new Map<Item, OnoeNum>();

for (const [, item] of Items.itemsPerId) {
    const layoutOrder = math.max(item.layoutOrder, 0);
    let maxAmount = 100;
    const priceSize = item.pricePerIteration.size();
    if (priceSize > 0) {
        maxAmount = math.min(maxAmount, priceSize);
    }

    let weight = new OnoeNum(1);
    // Layout order factor (higher layout order = more valuable)
    weight = weight.add(new OnoeNum(layoutOrder + 10).pow(1.5));
    // Max amount factor (lower max amount = more valuable)
    weight = weight.add(new OnoeNum(200 - maxAmount).pow(1.4));

    ITEM_WEIGHTS.set(item, weight);
}
const UNIQUE_DIFFICULTIES = DifficultyResearch.collectUniqueDifficulties();
const { getDifficultyRewardById } = DifficultyReward.setupDifficultyRewards();

@Service()
export default class ResearchService implements OnStart {
    private readonly researching: Map<string, number>;
    readonly unlockedDifficulties: Set<string>;
    private readonly difficultyRewardCooldowns: Map<string, number>;
    private readonly difficultyRewardPurchaseCounts: Map<string, number>;
    private readonly walkSpeedBuffs = new Map<number, WalkSpeedBuffState>();

    constructor(
        private readonly currencyService: CurrencyService,
        private readonly dataService: DataService,
        private readonly itemService: ItemService,
        private readonly permissionsService: PermissionService,
        private readonly playtimeService: PlaytimeService,
    ) {
        this.researching = dataService.empireData.items.researching;
        this.unlockedDifficulties = dataService.empireData.unlockedDifficulties;
        this.difficultyRewardCooldowns = dataService.empireData.difficultyRewardCooldowns;
        this.difficultyRewardPurchaseCounts = dataService.empireData.difficultyRewardPurchaseCounts;
    }

    private isItemEligibleForResearch(item: Item) {
        if (item.isA("Unique")) return false;
        if (item.isA("Gear")) return false;
        if (item.difficulty === Difficulty.Bonuses) return false;
        return true;
    }

    reserveItemsForResearch(entries: ReadonlyArray<[string, number]>) {
        if (entries.isEmpty()) return false;

        const aggregated = new Map<string, number>();
        const MAX_ENTRIES = 512;
        let processed = 0;

        for (const [itemId, rawAmount] of entries) {
            if (processed++ >= MAX_ENTRIES) break;
            if (!typeIs(itemId, "string")) continue;
            if (!typeIs(rawAmount, "number")) continue;
            if (rawAmount !== rawAmount || rawAmount === math.huge || rawAmount === -math.huge) continue;
            const sanitized = math.floor(math.clamp(rawAmount, 0, 1e6));
            if (sanitized <= 0) continue;
            aggregated.set(itemId, (aggregated.get(itemId) ?? 0) + sanitized);
        }

        if (aggregated.isEmpty()) return false;

        let changed = false;
        for (const [itemId, amount] of aggregated) {
            const item = Items.getItem(itemId);
            if (item === undefined) continue;
            if (!this.isItemEligibleForResearch(item)) continue;

            const available = this.itemService.getAvailableAmount(item);
            if (available <= 0) continue;

            const toReserve = math.min(amount, available);
            if (toReserve <= 0) continue;

            this.researching.set(itemId, (this.researching.get(itemId) ?? 0) + toReserve);
            changed = true;
        }

        if (!changed) return false;

        Packets.researching.set(this.researching);
        this.broadcastResearchMultiplier();
        return true;
    }

    releaseItemsFromResearch(entries: ReadonlyArray<[string, number]>) {
        if (entries.isEmpty()) return false;

        const aggregated = new Map<string, number>();
        const MAX_ENTRIES = 512;
        let processed = 0;

        for (const [itemId, rawAmount] of entries) {
            if (processed++ >= MAX_ENTRIES) break;
            if (!typeIs(itemId, "string")) continue;
            if (!typeIs(rawAmount, "number")) continue;
            if (rawAmount !== rawAmount || rawAmount === math.huge || rawAmount === -math.huge) continue;
            const sanitized = math.floor(math.clamp(rawAmount, 0, 1e6));
            if (sanitized <= 0) continue;
            aggregated.set(itemId, (aggregated.get(itemId) ?? 0) + sanitized);
        }

        if (aggregated.isEmpty()) return false;

        let changed = false;
        for (const [itemId, amount] of aggregated) {
            const current = this.researching.get(itemId) ?? 0;
            if (current <= 0) continue;

            const toRelease = math.min(amount, current);
            if (toRelease <= 0) continue;

            if (current === toRelease) {
                this.researching.delete(itemId);
            } else {
                this.researching.set(itemId, current - toRelease);
            }
            changed = true;
        }

        if (!changed) return false;

        Packets.researching.set(this.researching);
        this.broadcastResearchMultiplier();
        return true;
    }

    private applyWalkSpeedBuff(player: Player, amount: number, durationSeconds: number) {
        if (amount <= 0 || durationSeconds <= 0) {
            return;
        }

        const userId = player?.UserId ?? 0;
        const now = os.clock();
        const expiresAt = now + durationSeconds;

        let state = this.walkSpeedBuffs.get(userId);
        if (state === undefined) {
            const connection = player?.CharacterAdded.Connect((character) => {
                task.spawn(() => {
                    const humanoid =
                        character.FindFirstChildOfClass("Humanoid") ?? (character.WaitForChild("Humanoid") as Humanoid);
                    if (humanoid === undefined) {
                        return;
                    }

                    task.delay(0.05, () => {
                        const latest = this.walkSpeedBuffs.get(userId);
                        if (latest === undefined) {
                            return;
                        }
                        if (humanoid.Parent === undefined) {
                            return;
                        }
                        humanoid.WalkSpeed += latest.amount;
                    });
                });
            });

            state = { amount: 0, expiresAt, connection };
            this.walkSpeedBuffs.set(userId, state);
        }

        state.amount += amount;
        state.expiresAt = math.max(state.expiresAt, expiresAt);

        const humanoid = getPlayerCharacter(player)?.FindFirstChildOfClass("Humanoid");
        if (humanoid) {
            humanoid.WalkSpeed += amount;
        }

        this.scheduleWalkSpeedBuffRemoval(userId);
    }

    private scheduleWalkSpeedBuffRemoval(userId: number) {
        const state = this.walkSpeedBuffs.get(userId);
        if (state === undefined) {
            return;
        }

        const delaySeconds = math.max(state.expiresAt - os.clock(), 0);
        task.delay(delaySeconds, () => this.tryExpireWalkSpeedBuff(userId));
    }

    private tryExpireWalkSpeedBuff(userId: number) {
        const state = this.walkSpeedBuffs.get(userId);
        if (state === undefined) {
            return;
        }

        const now = os.clock();
        if (now < state.expiresAt) {
            this.scheduleWalkSpeedBuffRemoval(userId);
            return;
        }

        const player = Players.GetPlayerByUserId(userId);
        if (player !== undefined) {
            const humanoid = player.Character?.FindFirstChildOfClass("Humanoid");
            if (humanoid) {
                const baseWalkSpeed = (Workspace.GetAttribute("WalkSpeed") as number) ?? 16;
                const reduced = humanoid.WalkSpeed - state.amount;
                humanoid.WalkSpeed = math.max(reduced, baseWalkSpeed);
            }
        }

        state.connection?.Disconnect();
        this.walkSpeedBuffs.delete(userId);
    }

    private sanitizeRewardAmount(rawAmount?: number) {
        if (rawAmount === undefined) return 1;
        if (rawAmount !== rawAmount || rawAmount === math.huge || rawAmount === -math.huge) return 0;
        return math.floor(math.clamp(rawAmount, 1, 1000));
    }

    /**
     * Checks if the player can forge the specified item in the given amount.
     * @param itemId The ID of the item to forge.
     * @param rawAmount The raw amount of the item to forge.
     * @returns Whether the player can forge the item in the given amount.
     */
    private canForgeItem(itemId: string, rawAmount: number) {
        const amount = this.sanitizeRewardAmount(rawAmount);
        if (amount <= 0) return false;

        const item = Items.getItem(itemId);
        if (item === undefined) return false;

        for (const [requiredItemId, requiredAmount] of item.requiredItems) {
            const requiredItem = Items.getItem(requiredItemId);
            if (requiredItem === undefined) return false;
            const available = this.itemService.getAvailableAmount(requiredItem);
            if (available < requiredAmount * amount) {
                return false;
            }
        }

        let totalPrice = new CurrencyBundle();
        const alreadyBought = this.itemService.getBoughtAmount(item);
        for (let iteration = 1; iteration <= amount; iteration++) {
            const price = item.getPrice(alreadyBought + iteration);
            if (price === undefined) {
                return false;
            }
            totalPrice = totalPrice.add(price);
        }

        const [canAfford] = this.currencyService.canAfford(totalPrice);
        return canAfford === true;
    }

    private forgeItem(itemId: string, rawAmount: number) {
        const amount = this.sanitizeRewardAmount(rawAmount);
        if (amount <= 0) return false;

        const item = Items.getItem(itemId);
        if (item === undefined) return false;

        let forged = false;
        for (let i = 0; i < amount; i++) {
            if (!this.itemService.serverBuy(item).success) {
                break;
            }
            forged = true;
        }

        return forged;
    }

    /**
     * Processes a difficulty reward claim using the configured reward definitions.
     * Deducts Difficulty Power, grants the configured payout, and applies cooldowns.
     *
     * @param player The player claiming the reward.
     * @param rewardId The identifier of the reward being claimed.
     * @returns Whether the reward claim succeeded.
     */
    claimDifficultyReward(player: Player, rewardId: string) {
        const reward = getDifficultyRewardById(rewardId);
        if (reward === undefined) {
            return false;
        }

        if (reward.maxClaims !== undefined) {
            const purchaseCount = this.getRewardPurchaseCount(reward.id);
            if (purchaseCount >= reward.maxClaims) {
                return false;
            }
        }

        const now = os.time();
        const cooldownExpiresAt = this.difficultyRewardCooldowns.get(reward.id);
        if (cooldownExpiresAt !== undefined && cooldownExpiresAt > now) {
            return false;
        }

        for (const effect of reward.effects) {
            if (effect.kind === "forgeItem") {
                const amount = effect.amount ?? 1;
                if (!this.canForgeItem(effect.itemId, amount)) {
                    return false;
                }
            }
        }

        const currentDifficultyPower = this.currencyService.get("Difficulty Power");
        let cost: OnoeNum | undefined;

        if (reward.price) {
            if (reward.price.kind === "percentageOfDifficultyPower") {
                const percentage = math.clamp(reward.price.percentage, 0, 1);
                cost = currentDifficultyPower.mul(new OnoeNum(percentage));
                if (reward.price.minimum !== undefined) {
                    const minimum = new OnoeNum(reward.price.minimum);
                    if (cost.lessThan(minimum)) {
                        cost = minimum;
                    }
                }
            } else if (reward.price.kind === "flatDifficultyPower") {
                cost = reward.price.amount;
            }
        }

        if (cost !== undefined) {
            if (cost.lessThan(0) || currentDifficultyPower.lessThan(cost)) return false;

            const newDifficultyPower = currentDifficultyPower.sub(cost);
            this.currencyService.set("Difficulty Power", newDifficultyPower);
        }

        reward.effects.forEach((effect) => {
            switch (effect.kind) {
                case "walkSpeedBuff": {
                    this.applyWalkSpeedBuff(player, effect.amount, effect.durationSeconds);
                    break;
                }
                case "grantItem": {
                    const amount = effect.amount ?? 1;
                    if (amount > 0) {
                        const itemToGrant = Items.getItem(effect.itemId);
                        if (itemToGrant === undefined) throw `Invalid item ID in difficulty reward: ${effect.itemId}`;
                        this.itemService.giveItem(itemToGrant, amount);
                    }
                    break;
                }
                case "forgeItem": {
                    this.forgeItem(effect.itemId, effect.amount ?? 1);
                    break;
                }
                case "redeemRevenue": {
                    const offlineRevenue = this.currencyService.getOfflineRevenue().mulConstant(effect.seconds);
                    const payout = new Map<Currency, OnoeNum>();
                    for (const currency of effect.currencies) {
                        const amount = offlineRevenue.get(currency);
                        if (amount === undefined) continue;
                        payout.set(currency, amount);
                    }
                    if (!payout.isEmpty()) {
                        this.currencyService.incrementAll(payout);
                        Packets.showDifference.toAllClients(payout);
                    }
                    break;
                }
            }
        });

        this.incrementRewardPurchaseCount(reward.id, 1);
        this.currencyService.propagate();

        this.difficultyRewardCooldowns.set(reward.id, now + (reward.cooldownSeconds ?? 0));
        Packets.difficultyRewardCooldowns.set(this.difficultyRewardCooldowns);
        return true;
    }

    private incrementRewardPurchaseCount(rewardId: string, amount: number) {
        if (amount !== amount || amount === math.huge || amount === -math.huge) return;
        const sanitized = math.floor(math.max(amount, 0));
        if (sanitized <= 0) return;
        const current = this.difficultyRewardPurchaseCounts.get(rewardId) ?? 0;
        this.difficultyRewardPurchaseCounts.set(rewardId, current + sanitized);
        Packets.difficultyRewardPurchases.set(this.difficultyRewardPurchaseCounts);
    }

    /**
     * Gets the number of times a player has purchased a specific difficulty reward.
     * @param rewardId The ID of the difficulty reward.
     * @returns The number of times the reward has been purchased.
     */
    getRewardPurchaseCount(rewardId: string) {
        const count = this.difficultyRewardPurchaseCounts.get(rewardId) ?? 0;
        return math.max(count, 0);
    }

    /**
     * Calculates the flat bonus to Difficulty Power generation provided by purchased rewards.
     * @returns The flat bonus to add to Difficulty Power generation.
     */
    getDifficultyPowerBonus() {
        let totalAdd = new OnoeNum(0);
        let totalMul = new OnoeNum(1);
        for (let [rewardId, count] of this.difficultyRewardPurchaseCounts) {
            if (count <= 0) continue;
            const definition = getDifficultyRewardById(rewardId);
            if (definition === undefined) continue;
            // Clamp count to maximum allowed claims to avoid excessive effects
            const maxCount = definition.maxClaims;
            if (maxCount !== undefined) {
                count = math.min(count, maxCount);
            }

            for (const effect of definition.effects) {
                if (effect.kind === "increaseDifficultyPower") {
                    if (effect.add !== undefined) {
                        totalAdd = totalAdd.add(effect.add.mul(count));
                    }
                    if (effect.mul !== undefined) {
                        totalMul = totalMul.mul(effect.mul.pow(count));
                    }
                } else if (effect.kind === "increaseDifficultyPowerFormula") {
                    // Evaluate formula based on the x parameter
                    let xValue = new OnoeNum(0);
                    switch (effect.x) {
                        case "playtime":
                            xValue = new OnoeNum(this.playtimeService.getPlaytime());
                            break;
                        case "difficultyPower":
                            xValue = this.currencyService.get("Difficulty Power");
                            break;
                        default:
                            // Try to get as currency
                            xValue = this.currencyService.get(effect.x as Currency);
                            break;
                    }

                    // Apply cap if specified
                    if (effect.xCap !== undefined && xValue.moreThan(effect.xCap)) {
                        xValue = effect.xCap;
                    }

                    // Evaluate the formula and apply it as a multiplier
                    const formulaResult = effect.formula.evaluate(xValue);
                    if (formulaResult.moreThan(0)) {
                        totalMul = totalMul.mul(formulaResult.pow(count));
                    }
                }
            }
        }
        return $tuple(totalAdd, totalMul);
    }

    /**
     * Calculates the multiplier applied to difficulty power generation based on researching items.
     * Higher difficulty ratings and quantities yield a larger multiplier with diminishing returns.
     */
    calculateResearchMultiplier() {
        let weightedValue = new OnoeNum(0);
        for (const [itemId, amount] of this.researching) {
            if (amount <= 0) continue;

            const item = Items.getItem(itemId);
            if (item === undefined) continue;
            if (!this.isItemEligibleForResearch(item)) continue;

            const perItemWeight = ITEM_WEIGHTS.get(item);
            if (perItemWeight === undefined) continue;
            weightedValue = weightedValue.add(perItemWeight.mul(amount));
        }

        if (weightedValue.lessEquals(0)) return new OnoeNum(1);
        return weightedValue.pow(0.5).div(10);
    }

    /**
     * Broadcasts the current research multiplier to all clients.
     * Called whenever research reservations change.
     */
    private broadcastResearchMultiplier() {
        const multiplier = this.calculateResearchMultiplier();
        Packets.researchMultiplier.set(multiplier);
    }

    /**
     * Updates the list of unlocked difficulties based on the current Difficulty Power.
     */
    updateUnlockedDifficulties() {
        const currentDp = this.currencyService.get("Difficulty Power");

        let changed = false;
        for (let i = 0; i < UNIQUE_DIFFICULTIES.size(); i++) {
            const difficulty = UNIQUE_DIFFICULTIES[i];
            if (this.unlockedDifficulties.has(difficulty.id)) continue;
            const requirement = DifficultyResearch.getDifficultyRequirement(i);
            if (requirement === undefined) continue;
            if (currentDp.lessThan(requirement)) break; // Stop checking further, as they are more expensive
            this.unlockedDifficulties.add(difficulty.id);
            changed = true;
        }
        if (changed) {
            Packets.unlockedDifficulties.set(this.unlockedDifficulties);
        }
    }

    onStart() {
        Packets.claimDifficultyReward.fromClient((player, rewardId) => {
            if (!this.permissionsService.hasPermission(player, "build")) {
                return false;
            }
            return this.claimDifficultyReward(player, rewardId);
        });

        this.broadcastResearchMultiplier();
        this.updateUnlockedDifficulties();
        Packets.unlockedDifficulties.set(this.unlockedDifficulties);
        Packets.researching.set(this.researching);
        Packets.difficultyRewardCooldowns.set(this.difficultyRewardCooldowns);
        Packets.difficultyRewardPurchases.set(this.difficultyRewardPurchaseCounts);
    }
}


/**
 * @fileoverview Manages all leaderboard logic and display in the game.
 *
 * This service handles:
 * - Updating and displaying leaderboards for time played, donations, and all currencies
 * - Interfacing with DataStores for persistent leaderboard data
 * - Resetting and updating leaderboard UI elements
 * - Filtering banned users and handling debug/test modes
 *
 * @since 1.0.0
 */

import { simpleInterval } from "@antivivi/vrldk";
import { OnStart, Service } from "@flamework/core";
import { OnoeNum } from "@rbxts/serikanum";
import { CollectionService, DataStoreService, Players } from "@rbxts/services";
import type { LeaderboardEntry, LeaderboardType } from "client/components/world/leaderboard/Leaderboard";
import DataService from "server/services/data/DataService";
import { getNameFromUserId } from "shared/constants";
import { IS_EDIT, IS_STUDIO } from "shared/Context";
import { CURRENCY_DETAILS } from "shared/currency/CurrencyDetails";
import Leaderstats from "shared/data/Leaderstats";
import eat from "shared/hamster/eat";
import Packets from "shared/Packets";
import Sandbox from "shared/Sandbox";

/**
 * Service that manages all leaderboard logic, including updating DataStores and UI.
 */
@Service()
export class LeaderboardService implements OnStart {
    /** OrderedDataStore for total time leaderboard. */
    readonly totalTimeStore = (() => {
        try {
            const store = DataStoreService.GetOrderedDataStore("TotalTime");
            return store;
        } catch (err) {
            if (!IS_STUDIO) warn("Failed to get TotalTime OrderedDataStore:", err);
            return undefined;
        }
    })();

    /** OrderedDataStore for donations leaderboard. */
    readonly donatedStore = (() => {
        try {
            const store = DataStoreService.GetOrderedDataStore("Donated");
            return store;
        } catch (err) {
            if (!IS_STUDIO) warn("Failed to get Donated OrderedDataStore:", err);
            return undefined;
        }
    })();

    /** OrderedDataStore for level leaderboard. */
    readonly levelStore = (() => {
        try {
            const store = DataStoreService.GetOrderedDataStore("Level");
            return store;
        } catch (err) {
            if (!IS_STUDIO) warn("Failed to get Level OrderedDataStore:", err);
            return undefined;
        }
    })();

    /** List of banned user IDs (excluded from leaderboards). */
    banned = [1900444407];

    /** Debug flag for verbose output and studio testing. */
    debug = false;

    /** Set of DataStores that have been flushed (for deletion). */
    readonly flushed = new Set<OrderedDataStore>();

    /** Cached leaderboard data for React components */
    private readonly leaderboardData = new Map<LeaderboardType, LeaderboardEntry[]>();

    constructor(private readonly dataService: DataService) {}

    /**
     * Converts DataStore entries to LeaderboardEntry format.
     * @param lbDatas Raw leaderboard data from DataStore
     * @returns Formatted leaderboard entries
     */
    private convertToLeaderboardEntries(lbDatas: { key: string; value: unknown }[]): LeaderboardEntry[] {
        const entries = new Array<LeaderboardEntry>();
        for (let i = 0; i < lbDatas.size(); i++) {
            const data = lbDatas[i];
            if (data !== undefined) {
                entries.push({
                    place: i + 1,
                    name: data.key,
                    amount: data.value as number,
                });
            }
        }
        return entries;
    }

    /**
     * Deletes an entry from a DataStore, optionally by name.
     * @param store The OrderedDataStore
     * @param name The name to delete (optional)
     */
    deleteEntry(store: OrderedDataStore, name?: string) {
        if (!this.flushed.has(store)) {
            task.spawn(() => {
                for (const prevName of this.dataService.empireData.previousNames) {
                    store.RemoveAsync(prevName);
                }
            });
            this.flushed.add(store);
            return;
        }

        if (name !== undefined) store.RemoveAsync(name);
    }

    /**
     * Updates a leaderboard DataStore with a new value, or removes it if amount is undefined.
     * @param store The OrderedDataStore
     * @param name The name to update
     * @param amount The value to set (optional)
     * @returns The current page of sorted leaderboard data
     */
    private updateLeaderboardStore(store: OrderedDataStore, name?: string, amount?: number) {
        if (name !== undefined && (!IS_STUDIO || this.debug === true)) {
            if (amount === undefined) store.RemoveAsync(name);
            else store.SetAsync(name, amount);
        }
        const data = store.GetSortedAsync(false, 100);
        return data.GetCurrentPage();
    }

    /**
     * Updates all leaderboards, optionally deleting entries.
     * @param deleteEntries The name to delete (optional)
     */
    updateLeaderboards(deleteEntries?: string) {
        if (this.totalTimeStore === undefined || this.levelStore === undefined || this.donatedStore === undefined) {
            return;
        }

        const profile = this.dataService.empireData;
        if (this.banned.includes(profile.owner)) {
            return;
        }
        const name = profile.name;

        if (deleteEntries) {
            this.deleteEntry(this.totalTimeStore, deleteEntries);
            this.deleteEntry(this.levelStore, deleteEntries);
        }
        this.leaderboardData.set(
            "TimePlayed",
            this.convertToLeaderboardEntries(
                this.updateLeaderboardStore(this.totalTimeStore, name, new OnoeNum(profile.playtime).toSingle()),
            ),
        );
        this.leaderboardData.set(
            "Level",
            this.convertToLeaderboardEntries(
                this.updateLeaderboardStore(this.levelStore, name, new OnoeNum(profile.level).toSingle()),
            ),
        );

        for (const lb of CollectionService.GetTagged("Leaderboard")) {
            const currency = lb.Name as Currency;
            if (CURRENCY_DETAILS[currency] === undefined) continue;

            const mostCurrencies = profile.mostCurrencies.get(currency);
            const amt = mostCurrencies === undefined ? undefined : new OnoeNum(mostCurrencies).toSingle();
            const store = DataStoreService.GetOrderedDataStore(currency);

            if (deleteEntries) this.deleteEntry(store, deleteEntries);
            this.leaderboardData.set(
                currency,
                this.convertToLeaderboardEntries(this.updateLeaderboardStore(store, name, amt)),
            );
        }

        for (const player of Players.GetPlayers()) {
            this.donatedStore.SetAsync(
                tostring(player.UserId),
                new OnoeNum((Leaderstats.getLeaderstat(player, "Donated") as number | undefined) ?? 0).toSingle(),
            );
        }
        this.leaderboardData.set(
            "Donated",
            this.convertToLeaderboardEntries(
                this.donatedStore
                    .GetSortedAsync(false, 100)
                    .GetCurrentPage()
                    .map((value) => {
                        return { key: getNameFromUserId(tonumber(value.key) ?? 0), value: value.value };
                    }),
            ),
        );
        Packets.leaderboardData.set(this.leaderboardData);
    }

    onStart() {
        if (Sandbox.getEnabled() || IS_EDIT) return;

        this.updateLeaderboards();
        eat(simpleInterval(() => this.updateLeaderboards(), 180));
    }
}


/**
 * @fileoverview Manages system and private chat messaging for players.
 *
 * This service:
 * - Sends private and server system messages
 * - Creates and manages private chat channels for players
 * - Integrates with shared chat packet system
 *
 * @since 1.0.0
 */

import { OnStart, Service } from "@flamework/core";
import { MessagingService, Players, TextChatService, TextService } from "@rbxts/services";
import DataService from "server/services/data/DataService";
import { OnPlayerAdded } from "server/services/ModdingService";
import { RobotoSlab } from "shared/asset/GameFonts";
import { getNameFromUserId, getTextChannels } from "shared/constants";
import { IS_EDIT, IS_STUDIO } from "shared/Context";
import eat from "shared/hamster/eat";
import Packets from "shared/Packets";

/**
 * Service for sending system and private chat messages to players.
 */
@Service()
export default class ChatHookService implements OnStart, OnPlayerAdded {
    readonly GLOBAL_CHANNEL = (() => {
        if (IS_EDIT) return undefined;
        const globalChatChannel = new Instance("TextChannel");
        globalChatChannel.Name = "Global";
        globalChatChannel.Parent = getTextChannels();
        return globalChatChannel;
    })();

    constructor(private dataService: DataService) {}

    /**
     * Sends a private system message to a player.
     *
     * @param player Target player
     * @param message Message text
     * @param metadata Optional message metadata
     */
    sendPrivateMessage(player: Player | undefined, message: string, metadata?: string, channel?: TextChannel) {
        if (IS_EDIT) {
            print(message);
            return;
        }
        if (player === undefined) return;
        channel ??= getTextChannels().WaitForChild("RBXGeneral") as TextChannel;
        Packets.systemMessageSent.toClient(player, channel.Name, message, metadata ?? "");
    }

    /**
     * Sends a system message to the server's general chat.
     *
     * @param message Message text
     * @param metadata Optional message metadata
     */
    sendServerMessage(message: string, metadata?: string, channel?: TextChannel) {
        if (IS_EDIT) {
            print(message);
            return;
        }

        channel ??= getTextChannels().WaitForChild("RBXGeneral") as TextChannel;
        Packets.systemMessageSent.toAllClients(channel.Name, message, metadata ?? "");
    }

    onPlayerAdded(player: Player): void {
        if (IS_EDIT) return;

        this.GLOBAL_CHANNEL?.AddUserAsync(player.UserId);

        // Send join message to server chat
        this.sendServerMessage(
            `${player.DisplayName} (@${player.Name}) joined the empire!`,
            "color:100,255,100", // Light green color
        );
    }

    onStart() {
        const chatWindowConfiguration = TextChatService.FindFirstChildOfClass("ChatWindowConfiguration");
        if (chatWindowConfiguration) {
            chatWindowConfiguration.FontFace = RobotoSlab;
        }
        const chatInputBarConfiguration = TextChatService.FindFirstChildOfClass("ChatInputBarConfiguration");
        if (chatInputBarConfiguration) {
            chatInputBarConfiguration.FontFace = RobotoSlab;
        }
        const channelTabsConfiguration = TextChatService.FindFirstChildOfClass("ChannelTabsConfiguration");
        if (channelTabsConfiguration) {
            channelTabsConfiguration.FontFace = RobotoSlab;
            channelTabsConfiguration.Enabled = true;
        }
        const bubbleChatConfiguration = TextChatService.FindFirstChildOfClass("BubbleChatConfiguration");
        if (bubbleChatConfiguration) {
            bubbleChatConfiguration.FontFace = RobotoSlab;
        }

        // TODO: Check if global chat works
        if (!IS_EDIT) {
            const connection = MessagingService.SubscribeAsync("GlobalChat", (message) => {
                if (this.dataService.empireData.globalChat !== true) return;
                const data = message.Data as { player: number; message: string };
                if (this.dataService.empireData.blocking.has(data.player)) return;
                for (const player of Players.GetPlayers()) {
                    if (player.UserId === data.player) {
                        return;
                    }
                }
                const name = getNameFromUserId(data.player);
                this.sendServerMessage(
                    `${name}:  ${data.message}`,
                    "tag:hidden;color:180,180,180;",
                    this.GLOBAL_CHANNEL,
                );
            });

            let counter = 0;
            const globalMessageConnection = Packets.sendGlobalMessage.fromClient((player, message) => {
                if (this.dataService.empireData.globalChat === true && message.sub(1, 1) !== "/") {
                    ++counter;
                    task.delay(5, () => --counter);
                    if (counter > 5) {
                        return;
                    }
                    task.spawn(() => {
                        if (IS_STUDIO) {
                            print(`[Global] ${player.Name}: ${message}`);
                        }
                        MessagingService.PublishAsync("GlobalChat", {
                            player: player.UserId,
                            message: TextService.FilterStringAsync(
                                message,
                                player.UserId,
                            ).GetNonChatStringForBroadcastAsync(),
                        });
                    });
                }
            });

            eat(connection, "Disconnect");
            eat(globalMessageConnection, "Disconnect");

            // Handle player leaving
            const playerLeavingConnection = Players.PlayerRemoving.Connect((player) => {
                this.sendServerMessage(
                    `${player.DisplayName} (@${player.Name}) left the empire.`,
                    "color:255,150,150", // Light red color
                );
            });

            eat(playerLeavingConnection, "Disconnect");
        }
    }
}


/**
 * @fileoverview Handles chat commands and permissions.
 *
 * This service handles:
 * - Registration and management of all chat commands in the game
 * - Permission-based command access control (levels 0-4)
 * - Player and empire management commands
 * - Moderation and administrative tools
 * - Game state manipulation and debugging utilities
 * - Server navigation and teleportation commands
 *
 * Commands are organized by permission levels:
 * - Level 0: Basic player commands (help, join, logs, voting)
 * - Level 1: Trusted player commands (block, invite, empire tools)
 * - Level 2: Moderator commands (kick, restrict, teleport)
 * - Level 3: Manager commands (ban, currency/item management)
 * - Level 4: Owner commands (admin tools, server management)
 *
 * The service integrates with multiple game systems including permissions,
 * currency, items, areas, quests, and player data management.
 *
 * @since 1.0.0
 */

import { OnInit, Service } from "@flamework/core";
import { Players, TextChatService } from "@rbxts/services";
import APIExposeService from "server/services/APIExposeService";
import ChatHookService from "server/services/permissions/ChatHookService";
import PermissionService from "server/services/permissions/PermissionService";
import Command from "shared/commands/Command";
import { IS_EDIT } from "shared/Context";
import eat from "shared/hamster/eat";

/**
 * Service that provides comprehensive chat command functionality with permission-based access control.
 *
 * Manages the complete command system including registration, permission checking,
 * and integration with all major game systems for moderation and administration.
 */
@Service()
export default class CommandsService implements OnInit {
    constructor(
        private readonly chatHookService: ChatHookService,
        private readonly permissionService: PermissionService,
    ) {}

    parseCommandInvocation(command: Command, player: Player, unfilteredText: string) {
        const params = unfilteredText.split(" ");
        params.remove(0);
        const pLevel = this.permissionService.getPermissionLevel(player.UserId);
        if (pLevel < command.permissionLevel) {
            this.chatHookService.sendPrivateMessage(
                player,
                "You do not have access to this command.",
                "color:255,43,43",
            );
            return;
        }
        command.execute(player, ...params);
    }

    /**
     * Registers a new chat command.
     *
     * @param command The command to register
     */
    private registerCommand(command: Command) {
        if (IS_EDIT) return;

        const textChatCommand = new Instance("TextChatCommand");
        textChatCommand.PrimaryAlias = "/" + command.id;
        textChatCommand.SecondaryAlias = "/" + command.aliases[0] || "";
        textChatCommand.Name = command.id + "Command";
        const connection = textChatCommand.Triggered.Connect((origin, text) =>
            this.parseCommandInvocation(command, Players.WaitForChild(origin.Name) as Player, text),
        );
        eat(connection, "Disconnect");
        textChatCommand.Parent = TextChatService.WaitForChild("TextChatCommands");
    }

    /**
     * Finds players based on the given string.
     *
     * @param sender The player who initiated the command
     * @param str The string to search for
     * @returns An array of matching players
     */
    findPlayers(sender: Player | undefined, str: string): Player[] {
        switch (str) {
            case "me":
                return sender ? [sender] : [];
            case "others":
                return Players.GetPlayers().filter((value) => value !== sender);
            case "all":
                return Players.GetPlayers();
            case undefined:
                return [];
            default:
                for (const player of Players.GetPlayers()) {
                    if (str.lower() === player.Name.lower().sub(1, str.size())) {
                        return [player];
                    }
                }
                break;
        }
        return [];
    }

    /**
     * Formats a player's name and ID for display.
     *
     * @param name The name of the player
     * @param id The ID of the player
     * @returns A formatted string containing the player's name and ID
     */
    fp(name: string, id: number) {
        return name + " (ID: " + id + ")";
    }

    /**
     * Obtain a player's user ID from their name or mention.
     *
     * @param p The player name or mention
     * @param useId Whether to use the ID directly
     * @returns The user ID of the player
     */
    id(p: string, useId: string) {
        p = p.gsub("@", "")[0];
        return useId === "true" ? tonumber(p) : Players.GetUserIdFromNameAsync(p);
    }

    /**
     * Initializes the CommandsService and registers all chat commands.
     *
     * Creates and registers commands organized by permission levels:
     * - Level 0: Basic player commands (help, join, logs, voting)
     * - Level 1: Trusted player commands (block, invite, empire management)
     * - Level 2: Moderator commands (kick, restrict, player management)
     * - Level 3: Manager commands (ban, currency/item management, admin tools)
     * - Level 4: Owner commands (server management, advanced debugging)
     *
     * Each command includes automatic permission checking and appropriate
     * error messages for unauthorized access attempts.
     */
    onInit() {
        Command.listAllCommands().forEach((command) => {
            this.registerCommand(command);
        });
    }
}


import { BaseOnoeNum } from "@rbxts/serikanum";
import { simpleInterval } from "@antivivi/vrldk";
import { OnStart, Service } from "@flamework/core";
import DataService from "server/services/data/DataService";
import eat from "shared/hamster/eat";
import Packets from "shared/Packets";

declare global {
    interface Log {
        /**
         * Timestamp of the log entry.
         */
        time: number;

        /**
         * Type of the log entry.
         */
        type: string;

        /**
         * ID of the player associated with the log entry.
         */
        player?: number;

        /**
         * ID of the player who performed the action.
         */
        actor?: number;

        /**
         * ID of the player who received the action.
         */
        recipient?: number;

        /**
         * X coordinates of the action.
         */
        x?: number;

        /**
         * Y coordinates of the action.
         */
        y?: number;

        /**
         * Z coordinates of the action.
         */
        z?: number;

        /**
         * Area of the action.
         */
        area?: string;

        /**
         * Upgrade related to the action.
         */
        upgrade?: string;

        /**
         * Item related to the action.
         */
        item?: string;

        /**
         * List of items related to the action.
         */
        items?: string[];

        /**
         * Layer related to the action.
         */
        layer?: string;

        /**
         * Amount related to the action.
         *
         * @see {@link infAmount} for bigger numbers.
         */
        amount?: number;

        /**
         * Amount related to the action.
         *
         * @see {@link amount} for smaller numbers.
         */
        infAmount?: BaseOnoeNum;

        /**
         * Currency related to the action.
         */
        currency?: Currency;
    }
}

const PENDING = new Array<Log>();

/**
 * Function to log actions within the game.
 * This function is assigned in the LogService constructor.
 * @param log The log entry to be recorded.
 */
export let log = (log: Log) => {
    PENDING.push(log);
};

@Service()
export default class LogService implements OnStart {
    readonly unpropagatedLogs = new Array<Log>();

    constructor(private dataService: DataService) {
        log = (log: Log) => {
            const data = this.dataService.empireData;
            data.logs = data.logs.filter((value) => tick() - value.time < 604800);
            log.player ??= 1021594469;
            data.logs.push(log);
            this.unpropagatedLogs.push(log);
        };
        for (const logEntry of PENDING) {
            log(logEntry);
        }
    }

    onStart() {
        Packets.getLogs.fromClient(() => this.dataService.empireData.logs);

        eat(
            simpleInterval(() => {
                if (this.unpropagatedLogs.size() === 0) return;
                Packets.logsAdded.toAllClients(this.unpropagatedLogs);
                this.unpropagatedLogs.clear();
            }, 1),
        );
    }
}


/**
 * @fileoverview Handles Roblox developer product purchases and processing.
 *
 * This service:
 * - Registers product purchase handlers for specific product IDs
 * - Processes receipts and delegates to the correct handler
 * - Ensures product purchases are handled securely and robustly
 *
 * @since 1.0.0
 */

import { OnStart, Service } from "@flamework/core";
import { MarketplaceService, Players } from "@rbxts/services";
import { IS_EDIT } from "shared/Context";

declare global {
    /** Function type for handling product purchases. */
    type ProductFunction = (receiptInfo: ReceiptInfo, player: Player) => Enum.ProductPurchaseDecision;
}

/**
 * Service that manages developer product purchase handling.
 */
@Service()
export default class ProductService implements OnStart {
    /**
     * Map of product IDs to their purchase handling functions.
     */
    readonly productFunctions = new Map<number, ProductFunction>();

    /**
     * Registers a function to handle purchases for a specific product ID.
     *
     * @param productID The Roblox product ID to handle.
     * @param productFunction The function to call when this product is purchased.
     */
    setProductFunction(productID: number, productFunction: ProductFunction) {
        this.productFunctions.set(productID, productFunction);
    }

    onStart() {
        if (!IS_EDIT) {
            MarketplaceService.ProcessReceipt = (receiptInfo: ReceiptInfo) => {
                const productFunction = this.productFunctions.get(receiptInfo.ProductId);
                const player = Players.GetPlayerByUserId(receiptInfo.PlayerId);
                if (productFunction === undefined || player === undefined) {
                    print(receiptInfo);
                    return Enum.ProductPurchaseDecision.NotProcessedYet;
                }
                return productFunction(receiptInfo, player);
            };
        }
    }
}


import { OnStart, Service } from "@flamework/core";
import { CollectionService } from "@rbxts/services";
import DataService from "server/services/data/DataService";
import { IS_EDIT } from "shared/Context";
import Packets from "shared/Packets";
import { AREAS } from "shared/world/Area";

@Service()
export default class AreaService implements OnStart {
    private readonly UNLOCKED_AREAS: Set<AreaId>;

    constructor(private dataService: DataService) {
        this.UNLOCKED_AREAS = this.dataService.empireData.unlockedAreas;
    }

    /**
     * Unlocks a given area, updates state, and notifies clients.
     * @param area The area ID to unlock.
     * @return True if the area was newly unlocked, false if it was already unlocked.
     */
    unlockArea(area: AreaId) {
        const areas = this.UNLOCKED_AREAS;
        if (areas.has(area)) {
            return false;
        }

        areas.add(area);
        Packets.unlockedAreas.set(areas);
        Packets.areaUnlocked.toAllClients(area);
        return true;
    }

    /**
     * Locks a given area, updates state, and disables it in the world.
     * @param area The area ID to lock.
     * @return True if the area was locked, false if it was not previously unlocked.
     */
    lockArea(area: AreaId) {
        const areas = this.UNLOCKED_AREAS;
        const success = areas.delete(area);
        Packets.unlockedAreas.set(areas);
        return success;
    }

    onStart() {
        Packets.tpToArea.fromClient((player, areaId) => {
            const character = player.Character;
            const area = AREAS[areaId];
            const spawnLocation = area.spawnLocationWorldNode?.getInstance();

            if (
                character === undefined ||
                !this.dataService.empireData.unlockedAreas.has(areaId) ||
                spawnLocation === undefined
            ) {
                return false;
            }

            character.PivotTo(spawnLocation.CFrame);
            return true;
        });

        for (const [areaId, area] of pairs(AREAS)) {
            if (area.defaultUnlocked) {
                this.UNLOCKED_AREAS.add(areaId);
            }
        }
        Packets.unlockedAreas.set(this.UNLOCKED_AREAS);

        if (!IS_EDIT) {
            for (const instance of CollectionService.GetTagged("Unanchored")) {
                if (instance.IsA("BasePart")) {
                    instance.Anchored = false;
                }
            }
        }
    }
}