--[[
	Manifest system for managing shard configuration in DataStore
]]
local DataStoreService = game:GetService("DataStoreService");

-- Constants
local DEFAULT_RECORD_COUNT = 100;
local OFFLINE_ENVIRONMENT = game.GameId == 0;

-- Only initialize DataStore if not in offline environment
local MANIFEST_DATASTORE: DataStore? = nil;
if (not OFFLINE_ENVIRONMENT) then
	local Success, Result = pcall(function()
		return DataStoreService:GetDataStore("_LeaderboardManifest");
	end);
	if (Success) then
		MANIFEST_DATASTORE = Result;
	else
		warn(`[Manifest] Failed to initialize DataStore: {Result}`);
	end;
end;

--[=[
	@within Manifest
	@interface ShardInfo
	@field shardCount number
	@field keySuffix string
	@field hash string
]=]
export type ShardInfo = {
	shardCount: number,
	keySuffix: string,
	hash: string,
}

--[=[
	@within Manifest
	@interface Manifest
	@field maxItems number
	@field shardInfo ShardInfo
	@field shards {[string]: string}
]=]
export type Manifest = {
	maxItems: number,
	shardInfo: ShardInfo,
	shards: {[string]: string},
}

--- @class Manifest
local Manifest = {};

--[=[
	@within Manifest
	@param boardKey string
	@param shardIndex number
	@return string

	Generates a shard key in the format "{boardKey}:shard{shardIndex}"
]=]
local function GetShardKey(boardKey: string, shardIndex: number): string
	return `{boardKey}:shard{shardIndex}`;
end

--[=[
	@within Manifest
	@param maxRecords number
	@return number

	Calculates the recommended shard count based on max records.
	Warns if maxRecords exceeds 100,000 or if shardCount > 16 is recommended.
]=]
function Manifest.CalculateShardCount(maxRecords: number): number
	if maxRecords <= 100 then
		return 1;
	elseif maxRecords >= 101 and maxRecords <= 499 then
		return 2;
	elseif maxRecords >= 500 and maxRecords <= 5000 then
		return 3;
	elseif maxRecords >= 5001 and maxRecords <= 10000 then
		return 5;
	elseif maxRecords >= 10001 and maxRecords <= 30000 then
		return 15;
	elseif maxRecords >= 30001 and maxRecords <= 100000 then
		warn(`[Manifest] MaxRecords of {maxRecords} may quickly reach MemoryStore limits. Consider using fewer records or rotating to a new epoch.`);
		return 16;
	else
		warn(`[Manifest] MaxRecords of {maxRecords} exceeds recommended maximum of 100,000. This may quickly reach MemoryStore limits.`);
		return 16;
	end;
end

--[=[
	@within Manifest
	@param boardKey string
	@param shardCount number
	@param maxItems number
	@return Manifest

	Creates a new manifest with the specified parameters.
]=]
local function CreateManifest(boardKey: string, shardCount: number, maxItems: number): Manifest
	local shards: {[string]: string} = {};
	for Index = 1, shardCount do
		shards[tostring(Index)] = GetShardKey(boardKey, Index);
	end;

	return {
		maxItems = maxItems,
		shardInfo = {
			shardCount = shardCount,
			keySuffix = ":shard",
			hash = "fnv1a32",
		},
		shards = shards,
	};
end

--[=[
	@within Manifest
	@param boardKey string
	@param shardCount number
	@param maxItems number
	@return (Manifest?, number)

	Loads or creates a manifest for the given boardKey.
	Returns the manifest (if created/loaded) and the validated shardCount.
	If manifest exists but shardCount differs, warns and returns existing shardCount.
]=]
function Manifest.LoadOrCreate(boardKey: string, shardCount: number, maxItems: number): (Manifest?, number)
	if (OFFLINE_ENVIRONMENT or not MANIFEST_DATASTORE) then
		return nil, shardCount;
	end;

	local Success, Result = pcall(function()
		return MANIFEST_DATASTORE:GetAsync(boardKey);
	end);

	if (Success and Result) then
		local manifest = Result :: Manifest;
		-- Validate shard count matches
		if (manifest.shardInfo.shardCount ~= shardCount) then
			warn(`[Manifest] Shard count mismatch for "{boardKey}": Existing manifest has {manifest.shardInfo.shardCount} shards, but {shardCount} was requested. Using existing shard count of {manifest.shardInfo.shardCount}. To use a different shard count, rotate to a new epoch by changing the board name.`);
			shardCount = manifest.shardInfo.shardCount;
		end;
		return manifest, shardCount;
	else
		-- Create new manifest
		local manifest = CreateManifest(boardKey, shardCount, maxItems);

		-- Save manifest to DataStore
		local SaveSuccess, SaveResult = pcall(function()
			if (not MANIFEST_DATASTORE) then
				return nil;
			end;
			return MANIFEST_DATASTORE:SetAsync(boardKey, manifest);
		end);

		if (not SaveSuccess) then
			warn(`[Manifest] Failed to save manifest for "{boardKey}": {SaveResult}`);
		end;

		return manifest, shardCount;
	end;
end

Manifest.DEFAULT_RECORD_COUNT = DEFAULT_RECORD_COUNT;
Manifest.GetShardKey = GetShardKey;

return Manifest;


--[[
	Arxk was here
]]

-- DataStoreService to handle longer than 42 days (all time most likely)
local DataStoreService = game:GetService("DataStoreService");

-- Requirements
local Packages = script.Parent:FindFirstChild("Packages") or script.Parent.Parent; -- Development Wally places packages in script.Parent/Packages; Production Wally places packages in script.Parent.Parent
local MemoryShard = require(script.MemoryShard);
local Manifest = require(script.Manifest);
local Promise = require(Packages.Promise);
local Trove = require(Packages.Trove);
local Util = require(script.Parent.Util);
local Logger = require(script.Parent.Logger);
local UserIdsCache = require(script.Parent.UserIdCache);
local Backoff = require(Packages.Backoff);

-- Variables
local Compression = Util.Compression;
local SmartAssert = Util.SmartAssert;
local Cancel = task.cancel;
local Spawn = task.spawn;

-- Constants
local OFFLINE_ENVIRONMENT = game.GameId == 0;
local SHARD_COUNTS = Util.SHARD_COUNTS;

-- Supports Daily, Weekly, Monthly and AllTime currently
--[=[
    @within Board
    @type LeaderboardType "Hourly" | "Daily" | "Weekly" | "Monthly" | "Yearly" | "AllTime" | "Rolling" | string
]=]
export type LeaderboardType = "Hourly" | "Daily" | "Weekly" | "Monthly" | "Yearly" | "AllTime" | "Rolling" | string;

--[=[
    @within Board
    @type Board () -> Board
]=]
export type Board = typeof(setmetatable({} :: BoardArguments, {} :: Object));

--[=[
	@within Board
	@interface BoardArguments
	@field _boardKey string
	@field _storeUsing string
	@field _store (MemoryStoreSortedMap | OrderedDataStore | MemoryShard)?
]=]
export type BoardArguments = {
	_type: LeaderboardType,
	_boardKey: string,
	_storeUsing: string,
	_store: (MemoryStoreSortedMap | OrderedDataStore | MemoryShard)?,
	_logger: Logger.Logger?,
	_boardConfig: BoardConfig?,
	_maxRecords: number?,
}

--[=[
	@within Board
	@interface TopData
	@field Rank number
	@field UserId number
	@field Value number
	@field Username string
	@field DisplayName string
]=]
export type TopData = {
	Rank: number,
	UserId: number,
	Value: number,
	Username: string,
	DisplayName: string,
}

type MemoryShard = MemoryShard.MemoryShard;

--[=[
	@within Board
	@interface Object
	@field __index Object
	@field Update (self: Board, userId: number, value: number | (number) -> (number)) -> Promise.TypedPromise<boolean>
	@field Get (self: Board, amount: number, sortDirection: string) -> Promise.TypedPromise<{TopData}>
	@field Destroy (self: Board) -> ()
	@field new (boardKey: string, leaderboardType: LeaderboardType, rollingExpiry: number?, debugMode: boolean?) -> Board
]=]
type Object = {
	__index: Object,
	Trove: typeof(Trove.new),
	Update: (self: Board, userId: number, value: number | (number) -> (number)) -> Promise.TypedPromise<boolean>,
	Get: (self: Board, amount: number, sortDirection: string) -> Promise.TypedPromise<{TopData}>,
	Destroy: (self: Board) -> (),
	new: (boardKey: string, leaderboardType: LeaderboardType, rollingExpiry: number?, debugMode: boolean?) -> Board,
}

--[=[
	@class Board

	This class is used to create a new leaderboard board.
]=]
local Board: Object = {} :: Object;
Board.__index = Board;

local function Transform(rank: number, key: number, value: number): TopData
	return {
		Rank = rank,
		Value = Compression.Decompress(value),
		UserId = key,
		Username = UserIdsCache:GetNameFromUserId(key),
		DisplayName = "Not supported yet",
	};
end

-- local function GetRequestBudget(typeOfBudget: Enum.DataStoreRequestType): number
-- 	return DataStoreService:GetRequestBudgetForRequestType(typeOfBudget);
-- end

local function ShardCalculation(rollingExpiry: number): number
	return math.round(
		math.max(
			1,
			math.log10(rollingExpiry) - 3
		)
	);
end

local function GetCurrentId(leaderboardType: string)
	local CurrentHour = DateTime.now():ToUniversalTime().Hour;
	local CurrentDay = DateTime.now():ToUniversalTime().Day;
	local CurrentWeek = math.floor(os.date("!*t")["yday"] / 7);
	local CurrentMonth = DateTime.now():ToUniversalTime().Month;
	local CurrentYear = DateTime.now():ToUniversalTime().Year;

	return leaderboardType == "Hourly" and CurrentHour or leaderboardType == "Daily" and CurrentDay or leaderboardType == "Weekly" and CurrentWeek or leaderboardType == "Monthly" and CurrentMonth or leaderboardType == "Yearly" and CurrentYear or "AllTime" and "AllTime";
end

type BoardConfig = {
	Name: string,
	Automation: boolean?,
	MaxRecords: number?,
	DisplayCount: number?,
	SaveInterval: number?,
	RefreshInterval: number?,
	Shards: number?,
}

local function ConstructStore(boardKey: string, leaderboardType: LeaderboardType, rollingExpiry: number?, boardConfig: BoardConfig?, debugMode: boolean?): (string, (MemoryStoreSortedMap | OrderedDataStore | MemoryShard)?)
	-- print(rollingExpiry);
	-- if (leaderboardType == "Hourly" or leaderboardType == "Daily" or leaderboardType == "Weekly" or leaderboardType == "Monthly" or leaderboardType == "Yearly") then
	-- 	return "OrderedDataStore", DataStoreService:GetOrderedDataStore(`{GetCurrentId(leaderboardType)}-{boardKey}`);
	-- end;

	-- If we are using a MemoryStore, we can just update the data
	-- Not a viable solution anymore, limits are too poor
	if (leaderboardType ~= "AllTime" or rollingExpiry) then
		local maxRecords = boardConfig and boardConfig.MaxRecords or Manifest.DEFAULT_RECORD_COUNT;
		local shardCount: number;

		if (boardConfig and boardConfig.Shards) then
			-- Use provided shard count
			shardCount = boardConfig.Shards;
		elseif (rollingExpiry) then
			-- Use rolling expiry calculation
			shardCount = ShardCalculation(rollingExpiry);
		elseif (boardConfig and boardConfig.MaxRecords) then
			-- Calculate shard count from max records
			shardCount = Manifest.CalculateShardCount(maxRecords);
		else
			-- Fall back to default shard counts
			shardCount = SHARD_COUNTS[leaderboardType] or 1;
		end;

		return "MemoryStore", MemoryShard.new(leaderboardType, boardKey, shardCount, maxRecords, rollingExpiry, debugMode);
	end;

	-- If we are in an offline enviornment
	if (OFFLINE_ENVIRONMENT) then
		return "OrderedDataStore", {} :: any
	end;

	local Success, Result = pcall(function()
		return DataStoreService:GetOrderedDataStore(boardKey);
	end);
	if (not Success) then
		warn(`Failed to create OrderedDataStore for "{boardKey}": {Result}`);
		return "OrderedDataStore", {} :: any
	end;

	return "OrderedDataStore", Result;
end

--[=[
	@within Board
	@param boardKey string
	@param leaderboardType LeaderboardType
	@param rollingExpiry number?
	@param boardConfig BoardConfig?
	@param debugMode boolean?
	@return Board

	Creates a new board within the Leaderboard.
]=]
function Board.new(boardKey: string, leaderboardType: LeaderboardType, rollingExpiry: number?, boardConfig: BoardConfig?, debugMode: boolean?): Board
	local self = setmetatable({} :: BoardArguments, Board);

	self._boardKey = boardKey;
	self._storeType, self._store = ConstructStore(boardKey, leaderboardType, rollingExpiry, boardConfig, debugMode);
	self._logger = Logger.new(`Board`, debugMode or false);
	self._trove = Trove.new();
	self._boardConfig = boardConfig;
	self._maxRecords = boardConfig and boardConfig.MaxRecords or Manifest.DEFAULT_RECORD_COUNT;

	-- New implementation: we just check if the current id is different from the previous one
	if (leaderboardType == "Hourly" or leaderboardType == "Daily" or leaderboardType == "Weekly" or leaderboardType == "Monthly" or leaderboardType == "Yearly") then
		self._trove:Add(function()
			local CurrentId = GetCurrentId(leaderboardType);
			while (true) do
				local NewId = GetCurrentId(leaderboardType);
				if (NewId ~= CurrentId) then
					CurrentId = NewId;
					self._storeType, self._store = ConstructStore(boardKey, leaderboardType, nil, boardConfig, debugMode);
				end;
				task.wait(5);
			end;
		end);
	end;

	-- Start periodic cleanup for OrderedDataStore
	if (not OFFLINE_ENVIRONMENT and self._storeType == "OrderedDataStore" and self._store) then
		self._trove:Add(task.spawn(function()
			-- Wait a bit before starting cleanup to let the board initialize
			task.wait(60);

			while (true) do
				-- Cleanup interval: every 10 minutes (similar to MemoryShard cleanup frequency)
				task.wait(600);

				-- Use pcall to handle cleanup failures gracefully (rate limits, etc.)
				-- Other servers will also help clean up over time
				local success, err = pcall(function()
					self:_cleanupOrderedDataStore():await();
				end);
				if (not success) then
					self._logger:Log(3, `OrderedDataStore cleanup cycle pcall failed: {err}`);
				end;
			end;
		end));

		self._logger:Log(1, `🕑 Initialized cleanup schedule for {boardKey} with max {self._maxRecords} records`);
	end;

	return self;
end

-- Gets the top data for a specific board
--[=[
	@within Board
	@param amount number
	@param sortDirection string?
	@return Promise.TypedPromise<{TopData}>
	@yields

	Gets the top data for a specific board.
]=]
function Board:Get(amount, sortDirection)
	SmartAssert(type(amount) == "number", "Amount must be a number");
	SmartAssert(amount <= 100, "You can only get the top 100.");
	SmartAssert(amount > 0, "Amount must be greater than 0");
	SmartAssert(type(sortDirection) == "nil" or typeof(sortDirection) == "string", "SortDirection must be a string");
	sortDirection = sortDirection or "Descending";

	return Promise.new(function(resolve, reject)
		local Success, Result = pcall(function()
			if (OFFLINE_ENVIRONMENT) then
				return {} :: { TopData }
			end;

			if (self._storeType == "MemoryStore") then
				-- Use Backoff to retry Get with exponential backoff
				local backoffSuccess, backoffResult = Backoff.exponentialAsync(function()
					local success, result = pcall(function()
						return self._store:Get(amount, sortDirection):awaitValue();
					end);
					if (not success) then
						return false, result; -- Return (false, error) on failure
					end;
					return true, result; -- Return (true, result) on success
				end, {
					maxRetries = 3,
					minInterval = 2,
					maxInterval = 10,
					exponentBase = 2,
					onSuccess = function(attempt, result)
						if attempt > 1 then
							self._logger:Log(1, `MemoryStore Get success on attempt {attempt} for "{self._boardKey}"`)
						end
					end,
					onRetry = function(attempt, lastError, nextWaitSeconds)
						self._logger:Log(2, `Retrying MemoryStore Get for "{self._boardKey}" (attempt {attempt}) after {nextWaitSeconds}s: {lastError}`);
					end,
					onGiveUp = function(attempt, lastError)
						self._logger:Log(3, `Failed to get data from MemoryStore for "{self._boardKey}" after {attempt} attempts: {lastError}`);
					end,
				});

				if (not backoffSuccess) then
					error(backoffResult); -- Throw error to be caught by outer pcall
				end;

				local Result = backoffResult;
				local Promises = {};
				for rank, data in pairs(Result) do
					table.insert(Promises, Promise.new(function(resolve)
						resolve(Transform(rank, data.key, data.value))
					end));
				end;
				return Promise.all(Promises):awaitValue() :: { TopData };
			else -- TODO: This will be where DataStoreShards are used. Make sure that's explicitly stated.
				-- local RequestBudget = GetRequestBudget(Enum.DataStoreRequestType.GetSortedAsync);
				-- if (RequestBudget < 1) then
				-- 	warn(`Skipping getting top data for {self._boardKey} due to insufficient budget`);
				-- 	return {};
				-- end;
				-- Use Backoff to retry GetSortedAsync with exponential backoff
				local backoffSuccess, backoffResult = Backoff.exponentialAsync(function()
					local success, result = pcall(function()
						return self._store:GetSortedAsync(sortDirection ~= "Descending", amount);
					end);
					if (not success) then
						return false, result; -- Return (false, error) on failure
					end;
					return true, result; -- Return (true, result) on success
				end, {
					maxRetries = 3,
					minInterval = 2,
					maxInterval = 10,
					exponentBase = 2,
					onSuccess = function(attempt, result)
						if attempt > 1 then
							self._logger:Log(1, `GetSortedAsync success on attempt {attempt} for "{self._boardKey}"`)
						end
					end,
					onRetry = function(attempt, lastError, nextWaitSeconds)
						self._logger:Log(2, `Retrying GetSortedAsync for "{self._boardKey}" (attempt {attempt}) after {nextWaitSeconds}s: {lastError}`);
					end,
					onGiveUp = function(attempt, lastError)
						self._logger:Log(3, `Failed to get sorted data for "{self._boardKey}" after {attempt} attempts: {lastError}`);
					end,
				});

				if (not backoffSuccess) then
					error(backoffResult); -- Throw error to be caught by outer pcall
				end;

				local Result = backoffResult;
				local Data = Result:GetCurrentPage() :: {any};
				local Promises = {};
				for rank, data in pairs(Data) do
					table.insert(Promises, Promise.new(function(resolve)
						resolve(Transform(rank, data.key, data.value))
					end));
				end;
				return Promise.all(Promises):awaitValue() :: { TopData };
			end;
		end);

		if (not Success) then
			warn(`Leaderboard had trouble getting top data with error: {Result}`);
			return reject(Result);
		end;
		return resolve(Result);
	end) :: Promise.TypedPromise<{ TopData }>;
end

-- Updates the data for a specific board (either MemoryStore (Shards), or OrderedDataStore)
--[=[
	@within Board
	@param userId number
	@param value number | (number) -> (number)
	@return Promise.TypedPromise<boolean>
	@yields

	Updates the data for a specific board (either MemoryStore (Shards), or OrderedDataStore).
]=]
function Board:Update(userId, value)
	SmartAssert(type(userId) == "number", "UserId must be a number");
	SmartAssert(type(value) == "function" or type(value) == "number", "Transformer must be a function or a number");

	-- If we are using a MemoryStore, we can just update the data
	if (self._storeType == "MemoryStore") then
		self._logger:Log(1, `📤 Successfully updated leaderboard data for {userId} in "{self._boardKey}"`);
		return self._store:Set(userId, value);
	end;

	-- local Budget = GetRequestBudget(Enum.DataStoreRequestType.UpdateAsync);
	-- if (Budget < 1) then
	-- 	warn(`Skipping updating data for {userId} in {self._boardKey} due to insufficient budget`);
	-- 	return false;
	-- end;

	-- Using an actual DataStore, we need to set the data
	return Promise.new(function(resolve, reject)
		local Success, Result = pcall(function()
			if (OFFLINE_ENVIRONMENT) then
				return;
			end;

			return self._store:UpdateAsync(userId, function(oldValue)
				oldValue = oldValue and Compression.Decompress(oldValue) or 0;
				local TransformedValue = (type(value) == "function") and value(oldValue) or value;

				-- If their oldValue is greater than the new value, we don't want to update it
				if (oldValue > TransformedValue) then
					return nil;
				end;

				if (type(TransformedValue) == "number") then
					return Compression.Compress(TransformedValue);
				end;
				return nil;
			end);
		end);

		if (not Success) then
			warn(`Leaderboard had trouble updating data with error: {Result}`);
			return reject(Result);
		end;

		self._logger:Log(1, `📤 Successfully updated leaderboard data for "{userId}" in "{self._boardKey}"`);
		return resolve(Result);
	end) :: Promise.TypedPromise<boolean>;
end

--[=[
	@private
	@return Promise.TypedPromise<boolean>
	@yields

	Cleans up excess entries from OrderedDataStore using GetSortedAsync with pagination.
	Removes entries beyond MaxRecords limit, using 1-second delay between deletes.
]=]
function Board:_cleanupOrderedDataStore()
	return Promise.new(function(resolve, reject)
		local success, err = pcall(function()
			if (OFFLINE_ENVIRONMENT or not self._store or self._storeType ~= "OrderedDataStore") then
				return resolve(false);
			end;

			local maxRecords = self._maxRecords or Manifest.DEFAULT_RECORD_COUNT;

			-- Get sorted data with pagination, using pagesize up to MaxRecords
			-- This gives us the top MaxRecords entries (which we want to keep)
			local success, pages = pcall(function()
				return self._store:GetSortedAsync(false, maxRecords); -- false = Descending
			end);

			if (not success) then
				self._logger:Log(3, `Failed to get sorted data for cleanup: {pages}`);
				return resolve(false);
			end;

			-- If there are no more pages, we're done (all entries are within MaxRecords)
			if (pages.IsFinished) then
				return resolve(true);
			end;

			-- Now start removing entries beyond MaxRecords
			-- Advance to next page and remove all entries from subsequent pages
			local removedCount = 0;
			local currentPage = pages;

			-- Process remaining pages and remove entries
			while (not currentPage.IsFinished) do
				-- Advance to next page
				local advanceSuccess, advanceErr = pcall(function()
					currentPage:AdvanceToNextPageAsync();
				end);

				if (not advanceSuccess) then
					-- Rate limit or error, stop cleanup (other servers will help)
					self._logger:Log(2, `Stopped cleanup due to advance error (rate limit?): {advanceErr}`);
					break;
				end;

				local pageData = currentPage:GetCurrentPage();

				-- Remove each entry on this page with 1-second delay between deletes
				for _, entry in pageData do
					-- Wait 1 second before each delete (similar to _valueQueue pattern)
					task.wait(1);

					local deleteSuccess, deleteErr = pcall(function()
						return self._store:RemoveAsync(entry.key);
					end);

					if (deleteSuccess) then
						removedCount += 1;
					else
						-- Rate limit or error, stop cleanup (other servers will help)
						self._logger:Log(2, `Stopped cleanup due to delete error (rate limit?): {deleteErr}`);
						return resolve(true);
					end;
				end;
			end;

			if (removedCount > 0) then
				self._logger:Log(1, `Cleaned up {removedCount} excess entries from OrderedDataStore "{self._boardKey}"`);
			end;

			return resolve(true);
		end);

		if (not success) then
			self._logger:Log(3, `OrderedDataStore cleanup failed: {err}`);
			return resolve(false); -- Don't reject, just log and continue
		end;
	end);
end

-- Destroys the board
--[=[
	@within Board
	Destroys the board.
]=]
function Board:Destroy()
	-- Destroy the trove
	self._trove:Destroy();

	-- Destroy the store
	if (self._storeType == "MemoryStore") then
		self._store:Destroy();
	end;

	-- Destroy the logger
	self._logger:Destroy();

	-- Destroy the board
	setmetatable(self, nil);
end

-- Make indexing the class with the wrong key throw an error
setmetatable(Board, {
	__index = function(_, key)
		error(`Attempt to get Board:{tostring(key)} (not a valid member)`, 2);
	end,
	__newindex = function(_, key, _)
		error(`Attempt to set Board:{tostring(key)} (not a valid member)`, 2);
	end,
})

return table.freeze({
	new = Board.new,
	Get = Board.Get,
	Update = Board.Update,
	Destroy = Board.Destroy,
})


--[=[
    @within Logger
    @interface Object
    @field __index Object
    @field new (moduleName: string, debugEnabled: boolean) -> Logger
    @field Log (self: Logger, logLevel: number, message: string) -> ()
    @field Destroy (self: Logger) -> ()
]=]
type Object = {
    __index: Object,
    new: (moduleName: string, debugEnabled: boolean) -> Logger,
    Log: (self: Logger, logLevel: number, message: string) -> (),
    Destroy: (self: Logger) -> (),
}

--[=[
    @within Logger
    @interface LoggerArguments
    @field _moduleName string
    @field _debugEnabled boolean
]=]
export type LoggerArguments = {
    _moduleName: string,
    _debugEnabled: boolean,
}

--[=[
    @within Logger
    @type Logger () -> Logger
]=]
export type Logger = typeof(setmetatable({} :: LoggerArguments, {} :: Object));

--[=[
    @class Logger
]=]
local Logger: Object = {} :: Object;
Logger.__index = Logger;

--[=[
    @param moduleName string
    @param debugEnabled boolean
    @return Logger

    Constructs a new Logger.
]=]
function Logger.new(moduleName, debugEnabled)
    local self = {};
    self._moduleName = moduleName;
    self._debugEnabled = debugEnabled;
    return setmetatable(self, Logger);
end

--[=[
    @param logLevel number
    @param message string
    @return nil

    Logs a message to the console.
]=]
function Logger:Log(logLevel, message)
    if (not self._debugEnabled) then
        return;
    end;
    if (logLevel == 1) then
        print(string.format("[%s] %s", self._moduleName, message));
    elseif (logLevel == 2) then
        warn(string.format("[%s] %s", self._moduleName, message));
    elseif (logLevel == 3) then
        error(string.format("[%s] %s", self._moduleName, message));
    end;
end

--[=[
    @return nil

    Destroys the Logger.
]=]
function Logger:Destroy()
    setmetatable(self, nil);
end

return table.freeze({
    Log = Logger.Log,
    new = Logger.new,
    Destroy = Logger.Destroy,
})


local Players = game:GetService("Players");

local UserIdsCache = {};
local Processing = {};
local CachedUsernames = {};

local function FetchNameFromAPI(userId: number): string
	local Success, Result = pcall(function()
		return Players:GetNameFromUserIdAsync(userId);
	end);

	return Success, Result;
end;

function UserIdsCache:ProcessIds()
	for UserId in Processing do
		local Success, Result = FetchNameFromAPI(UserId);
		if (Success) then
			CachedUsernames[UserId] = Result;
			Processing[UserId] = nil;
		end;
	end;
end;

function UserIdsCache:GetNameFromUserId(userId: number)
	local player = Players:GetPlayerByUserId(userId);
	if (player) then
		CachedUsernames[userId] = player.Name;
		return player.Name;
	end;

	if (CachedUsernames[userId]) then
		return CachedUsernames[userId];
	else
		local Success, Result = FetchNameFromAPI(userId);
		if (Success) then
			CachedUsernames[userId] = Result;
			return Result;
		end;

		Processing[userId] = true;
		return "Loading...";
	end;
end

task.spawn(function()
	while (true) do
		UserIdsCache:ProcessIds();
		task.wait(10);
	end;
end);

return UserIdsCache;


local Compression = {};
local LOGARITHMIC_BASE = 1.0000001;

-- Not super precise, but it's good enough for our purposes.
function Compression.Compress(x: number): number
	return (x ~= 0 and math.floor(math.log10(x) / math.log10(LOGARITHMIC_BASE)) or 0);
end

function Compression.Decompress(x: number): number
	return (x ~= 0 and math.round(math.pow(LOGARITHMIC_BASE, x)) or 0);
end

return table.freeze(Compression) :: {
	Compress: (number) -> number,
	Decompress: (number) -> number,
}


local byte   = string.byte;
local band   = bit32.band;
local bxor   = bit32.bxor;
local lshift = bit32.lshift;

local BASIS = 0x811c9dc5;

return function (s: string): number
    local h = BASIS;
    local len = #s;

    for i = 1, len do
        h = bxor(h, byte(s, i));

        -- 32-bit unsigned multiply by 0x01000193
        local a = lshift(h, 24);
        local b = lshift(h, 8);
        local c = h * 0x93;
        h = band(a + b + c, 0xFFFFFFFF);
    end;

    return h;
end


local HttpService = game:GetService("HttpService");

-- Requirements
local Compression = require(script.Compression);
local FNV_1A_32 = require(script.FNV_1a);

local Util = {};

-- Constants
local MEMORY_STORE_SERVICE_MAX_EXPIRY = 24 * 60 * 60 * 25;
local MEMORY_STORE_SERVICE_MIN_EXPIRY = 60;
local BOARD_TYPES = {"Hourly", "Daily", "Weekly", "Monthly", "Yearly", "AllTime"};
local DAYS_IN_MONTH = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
local MAX_OVERALL_LEADERBOARDS = 4; -- Overall, you can only create 4 leaderboards using Leaderboard.new, this is to prevent rate limiting
local MAX_BOARDS = 7; -- You can only create up to 7 boards per leaderboard
local SHARD_COUNTS = { -- Feel free to change these based on how many MAU your game does have
	["Hourly"] = 1,
	["Daily"] = 2,
	["Weekly"] = 3,
	["Monthly"] = 4,
}

-- CAUTION: ONLY modify if you know what you are doing
local SHARD_CLEANUP_SETTINGS = {
	MAX_ENTRIES_PER_SHARD = 300, -- Maximum entries per shard before cleanup
	CLEANUP_INTERVAL = 60 * 4, -- Cleanup interval (normally we would want to do this around every 4 minutes)
	CLEANUP_BATCH_SIZE = 200, -- Remove this many excess entries at once (max GetRangeAsync limit)
}

local function GetDaysInMonth(): number
	local CurrentDate = DateTime.now():ToUniversalTime();
	local Month, Year = CurrentDate.Month, CurrentDate.Year;
	if (Month == 2) then -- This is for a leap year
		if (Year % 4 == 0 and (Year % 100 ~= 0 or Year % 400 == 0)) then
			return 29; -- Leap year month (29 days)
		end;
	end;
	return DAYS_IN_MONTH[Month];
end

-- Our own very basic implementation of assert because Roblox refuses to fix theirs
local function SmartAssert(condition: boolean, message: string)
	if (not condition) then
		error(message, 2);
	end;
end

local function Map<T>(tbl: {T}, fn: (T, number) -> (any)): {T}
    local t = {};

    for i, v in tbl do
        t[i] = fn(v, i);
    end;

    return t;
end

local function FoundInTable(tbl: {any}, value: any): (boolean | number, any)
	local function Search(t: {any}, val: any)
		for index, v in t do
			if (v == val) then
				return index, v;
			elseif (type(v) == "table") then
				if Search(v, val) then
					return index, v;
				end;
			end;
		end;
		return false, nil;
	end;

	return Search(tbl, value);
end

local function KeysInDictionary(dictionary: {any}): {any}
	local keys = {};

	for key, _ in dictionary do
		table.insert(keys, key);
	end;

	return keys;
end

local function GenerateGUID(): string
	return HttpService:GenerateGUID(false);
end

Util.SmartAssert = SmartAssert;
Util.Map = Map;
Util.FoundInTable = FoundInTable;
Util.GetDaysInMonth = GetDaysInMonth;
Util.KeysInDictionary = KeysInDictionary;
Util.Compression = Compression;
Util.FNV_1A_32 = FNV_1A_32;
Util.GenerateGUID = GenerateGUID;
Util.BOARD_TYPES = BOARD_TYPES;
Util.MEMORY_STORE_SERVICE_MAX_EXPIRY = MEMORY_STORE_SERVICE_MAX_EXPIRY;
Util.MEMORY_STORE_SERVICE_MIN_EXPIRY = MEMORY_STORE_SERVICE_MIN_EXPIRY;
Util.FALLBACK_EXPIRY_TIMES = {
	["Hourly"] = 3600,
	["Daily"] = 24 * 3600,
	["Weekly"] = 7 * 24 * 3600,
	["Monthly"] = (GetDaysInMonth()) * 24 * 3600, -- Used to have it as 30, but that's not accurate, so we use GetDaysInMonth()
}
Util.MAX_OVERALL_LEADERBOARDS = MAX_OVERALL_LEADERBOARDS;
Util.MAX_BOARDS = MAX_BOARDS;
Util.SHARD_COUNTS = SHARD_COUNTS;
Util.SHARD_CLEANUP_SETTINGS = SHARD_CLEANUP_SETTINGS;

return Util :: {
	SmartAssert: (boolean, string) -> ();
	Map: <T>({T}, (T, number) -> (any)) -> {T};
	FoundInTable: ({any}, any) -> (boolean | number, any);
	GetDaysInMonth: () -> number;
	Compression: typeof(Compression);
	KeysInDictionary: ({any}) -> {any};
	FNV_1A_32: typeof(FNV_1A_32);
	GenerateGUID: () -> string;
	BOARD_TYPES: {string};
	MEMORY_STORE_SERVICE_MAX_EXPIRY: number;
	MEMORY_STORE_SERVICE_MIN_EXPIRY: number;
	FALLBACK_EXPIRY_TIMES: {[string]: number};
	MAX_OVERALL_LEADERBOARDS: number;
	MAX_BOARDS: number;
	SHARD_COUNTS: {[string]: number};
	SHARD_CLEANUP_SETTINGS: {[string]: number};
}


local ReplicatedStorage = game:GetService("ReplicatedStorage");
local Players = game:GetService("Players");
local RunService = game:GetService("RunService");

local LeaderboardTemplate = ReplicatedStorage:WaitForChild("LeaderboardTemplate");
local Leaderboard = require(ReplicatedStorage:WaitForChild("Leaderboard"));

type TopData = {Leaderboard.TopData};

local DEBUG = RunService:IsStudio();

-- Constants
local Key = "2";
local Leaderboards = {
	["Weekly"] = {
		Name = `Money-Weekly-{Key}`,
		Automation = true,
		MaxRecords = 1000,
		DisplayCount = 50,
		SaveInterval = 30,
		RefreshInterval = 600,
	},
	--[[
	["Monthly"] = {
		Name = `Money-Monthly-{Key}`,
		Automation = true,
		MaxRecords = 500,
		DisplayCount = 50,
		SaveInterval = 30,
		RefreshInterval = 600,
	},]]
	["AllTime"] = {
		Name = `Money-AllTime-{Key}`,
		Automation = true,
		MaxRecords = 500,
		DisplayCount = 50,
		SaveInterval = 30,
		RefreshInterval = 600,
	},
};
local MoneyLeaderboard = Leaderboard.new(Leaderboards, true);

local function UpdateBoard(data: TopData, part: BasePart)
	-- Remove current items
	for _, v in part.UI.List:GetChildren() do
		if (not v:IsA("GuiObject")) then continue end;
		v:Destroy();
	end;

	-- Add new items
	for i, v in data do
		local item = LeaderboardTemplate:Clone();
		item.Name = `Item-${i}`;
		item.Rank.Text = v.Rank;
		item.Username.Text = `{v.Username}`;
		item.LayoutOrder = i
		item["Value"].Text = v.Value;
		item.LayoutOrder = v.Rank;
		item.Parent = part.UI.List;
	end;
end

local function IncrementMoneyTest()
	for _, player in Players:GetPlayers() do
		print("Incrementing money test for player", player.UserId);
		MoneyLeaderboard:IncrementValues(Leaderboards, player.UserId, 111);
	end;

	-- Test userIds
	local FakeId1, FakeId2 = 100, 101;
	MoneyLeaderboard:IncrementValues(Leaderboards, FakeId1, 100);
	MoneyLeaderboard:IncrementValues(Leaderboards, FakeId2, 100);

	-- Larger scale test
	for _ = 1, 100 do
		MoneyLeaderboard:IncrementValues(Leaderboards, math.random(1, 10000000), math.random(1, 1500));
	end;
end

MoneyLeaderboard.Updated:Connect(function(boards)
	-- Returns us a table of all the boards that were updated
	for _, board in boards do
		print(`[Money] Updating board {board.Type} - with {#board.Data} entries!`);
		UpdateBoard(board.Data, workspace.Leaderboards["Money"][board.Type]);
	end;
end);
-- MoneyLeaderboard.BoardUpdated:Connect(function(board)
-- 	print(board.Type, board.Data);
-- end);

task.delay(3, function()
	print("Setting money test");
	MoneyLeaderboard:SetValues("All", 6271821, 1000);
	task.wait(1);
	print(MoneyLeaderboard:GetRecords("All", 100):expect());

	print("Looping")
	local flag = true
	task.delay(5, function()
		flag = false
		print("Flag set to false")
	end)
	while flag do
		task.wait(0.01)
		MoneyLeaderboard:IncrementValues({["Weekly"] = true}, 6271821, 1);
		print("Queue")
		--print(MoneyLeaderboard:GetRecords("All", 100):expect());
	end
end)

--[[task.delay(10, function()
	print("Incrementing money test");
	IncrementMoneyTest();
	print(MoneyLeaderboard:GetRecords("All", 100):expect());
end)]]


local ReplicatedStorage = game:GetService("ReplicatedStorage");
local Players = game:GetService("Players");

local LeaderboardTemplate = ReplicatedStorage:WaitForChild("LeaderboardTemplate");
local Leaderboard = require(ReplicatedStorage:WaitForChild("Leaderboard"));

type TopData = {Leaderboard.TopData};

-- Constants
local Key = "1";
local Leaderboards = {
	["Hourly"] = {
		Name = `Rebirths-Hourly-{Key}`,
		Automation = true,
		MaxRecords = 100,
		DisplayCount = 50,
		SaveInterval = 30,
		RefreshInterval = 600,
	},
	["Daily"] = {
		Name = `Rebirths-Daily-{Key}`,
		Automation = true,
		MaxRecords = 100,
		DisplayCount = 50,
		SaveInterval = 30,
		RefreshInterval = 600,
	},
	["Weekly"] = {
		Name = `Rebirths-Weekly-{Key}`,
		Automation = true,
		MaxRecords = 100,
		DisplayCount = 50,
		SaveInterval = 30,
		RefreshInterval = 600,
	},
	["Monthly"] = {
		Name = `Rebirths-Monthly-{Key}`,
		Automation = true,
		MaxRecords = 100,
		DisplayCount = 50,
		SaveInterval = 30,
		RefreshInterval = 600,
	},
	["AllTime"] = {
		Name = `Rebirths-AllTime-{Key}`,
		Automation = true,
		MaxRecords = 100,
		DisplayCount = 50,
		SaveInterval = 30,
		RefreshInterval = 600,
	},
};
local RebirthsLeaderboard = Leaderboard.new(Leaderboards, true);

local function UpdateBoard(data: TopData, part: BasePart)
	-- Remove current items
	for _, v in part.UI.List:GetChildren() do
		if (not v:IsA("GuiObject")) then continue end;
		v:Destroy();
	end;

	-- Add new items
	for i, v in data do
		local item = LeaderboardTemplate:Clone();
		item.Name = `Item-${i}`;
		item.Rank.Text = v.Rank;
		item.Username.Text = `{v.Username}`;
		item.LayoutOrder = i
		item["Value"].Text = v.Value;
		item.LayoutOrder = v.Rank;
		item.Parent = part.UI.List;
	end;
end

local function IncrementMoneyTest()
	for _, player in Players:GetPlayers() do
		RebirthsLeaderboard:IncrementValues(Leaderboards, player.UserId, 111);
	end;

	--[[
	-- Test userIds
	local FakeId1, FakeId2 = 100, 101;
	RebirthsLeaderboard:IncrementValues(Leaderboards, FakeId1, 100);
	RebirthsLeaderboard:IncrementValues(Leaderboards, FakeId2, 100);
	]]

	--[[ Larger scale test
	for _ = 1, 10 do
		RebirthsLeaderboard:IncrementValues(Leaderboards, math.random(1, 10000000), math.random(1, 1500));
	end;
	]]
end

RebirthsLeaderboard.Updated:Connect(function(boards)
	-- Returns us a table of all the boards that were updated
	for _, board in boards do
		print(`[Rebirths] Updating board {board.Type} - with {#board.Data} entries!`);
		UpdateBoard(board.Data, workspace.Leaderboards["Rebirths"][board.Type]);
	end;

	-- Increment
	IncrementMoneyTest();
end);
-- RebirthsLeaderboard.BoardUpdated:Connect(function(board)
-- 	print(board.Type, board.Data);
-- end);


local ReplicatedStorage = game:GetService("ReplicatedStorage");
local Players = game:GetService("Players");

local LeaderboardTemplate = ReplicatedStorage:WaitForChild("LeaderboardTemplate");
local Leaderboard = require(ReplicatedStorage:WaitForChild("Leaderboard"));

type TopData = {Leaderboard.TopData};

-- Constants
local Key = `Gold-{11}`;
local Leaderboards = {
	["15MinutesRolling"] = {60 * 15, `15MinutesRolling-{Key}`}, -- 15 minutes rolling leaderboard
	["30MinutesRolling"] = {60 * 30, `30MinutesRolling-{Key}`}, -- 30 minutes rolling leaderboard
};
-- Note: Rolling leaderboards use {rollingExpiry, name} format
-- For automation, you can use BoardConfig format instead:
-- ["15MinutesRolling"] = {
--     Name = `15MinutesRolling-{Key}`,
--     Automation = true,
--     MaxRecords = 100,
--     DisplayCount = 50,
--     SaveInterval = 30,
--     RefreshInterval = 600,
-- },
-- But rolling expiry would need to be handled separately
local GoldLeaderboard = Leaderboard.new(Leaderboards, true);

local function UpdateBoard(data: TopData, part: BasePart)
	-- Remove current items
	for _, v in part.UI.List:GetChildren() do
		if (not v:IsA("GuiObject")) then continue end;
		v:Destroy();
	end;

	-- Add new items
	for i, v in data do
		local item = LeaderboardTemplate:Clone();
		item.Name = `Item-${i}`;
		item.Rank.Text = v.Rank;
		item.Username.Text = `{v.Username}`;
		item.LayoutOrder = i
		item["Value"].Text = v.Value;
		item.LayoutOrder = v.Rank;
		item.Parent = part.UI.List;
	end;
end

local function IncrementGoldTest()
	for _, player in Players:GetPlayers() do
		GoldLeaderboard:IncrementValues(Leaderboards, player.UserId, 111);
	end;

	-- Test userIds
	local FakeId1, FakeId2 = 100, 101;
	GoldLeaderboard:IncrementValues(Leaderboards, FakeId1, 100);
	GoldLeaderboard:IncrementValues(Leaderboards, FakeId2, 100);

	-- Larger scale test
	for _ = 1, 10 do
		GoldLeaderboard:IncrementValues(Leaderboards, math.random(1, 10000000), math.random(1, 35345));
	end;
end

GoldLeaderboard.Updated:Connect(function(boards)
	-- Returns us a table of all the boards that were updated
	for _, board in boards do
		print(`[Gold] Updating board {board.Type} - with {#board.Data} entries!`);
		UpdateBoard(board.Data, workspace.Leaderboards["Gold"][board.Type]);
	end;

	-- Increment
	IncrementGoldTest();
end);
-- GoldLeaderboard.BoardUpdated:Connect(function(board)
-- 	print(board.Type, board.Data);
-- end);


local ReplicatedStorage = game:GetService("ReplicatedStorage");
local Players = game:GetService("Players");

local LeaderboardTemplate = ReplicatedStorage:WaitForChild("LeaderboardTemplate");
local Leaderboard = require(ReplicatedStorage:WaitForChild("Leaderboard"));

type TopData = {Leaderboard.TopData};

-- Constants
local Key = "1";
local Leaderboards = {
	["Hourly"] = {
		Name = `Wins-Hourly-{Key}`,
		Automation = true,
		MaxRecords = 100,
		DisplayCount = 50,
		SaveInterval = 30,
		RefreshInterval = 600,
	},
	["Daily"] = {
		Name = `Wins-Daily-{Key}`,
		Automation = true,
		MaxRecords = 100,
		DisplayCount = 50,
		SaveInterval = 30,
		RefreshInterval = 600,
	},
	["Weekly"] = {
		Name = `Wins-Weekly-{Key}`,
		Automation = true,
		MaxRecords = 100,
		DisplayCount = 50,
		SaveInterval = 30,
		RefreshInterval = 600,
	},
	["Monthly"] = {
		Name = `Wins-Monthly-{Key}`,
		Automation = true,
		MaxRecords = 100,
		DisplayCount = 50,
		SaveInterval = 30,
		RefreshInterval = 600,
	},
	["AllTime"] = {
		Name = `Wins-AllTime-{Key}`,
		Automation = true,
		MaxRecords = 100,
		DisplayCount = 50,
		SaveInterval = 30,
		RefreshInterval = 600,
	},
};
local WinsLeaderboard = Leaderboard.new(Leaderboards, true);

local function UpdateBoard(data: TopData, part: BasePart)
	-- Remove current items
	for _, v in part.UI.List:GetChildren() do
		if (not v:IsA("GuiObject")) then continue end;
		v:Destroy();
	end;

	-- Add new items
	for i, v in data do
		local item = LeaderboardTemplate:Clone();
		item.Name = `Item-${i}`;
		item.Rank.Text = v.Rank;
		item.Username.Text = `{v.Username}`;
		item.LayoutOrder = i
		item["Value"].Text = v.Value;
		item.LayoutOrder = v.Rank;
		item.Parent = part.UI.List;
	end;
end

local function IncrementMoneyTest()
	for _, player in Players:GetPlayers() do
		WinsLeaderboard:IncrementValues(Leaderboards, player.UserId, 111);
	end;

	-- Test userIds
	local FakeId1, FakeId2 = 100, 101;
	WinsLeaderboard:IncrementValues(Leaderboards, FakeId1, 100);
	WinsLeaderboard:IncrementValues(Leaderboards, FakeId2, 100);

	-- Larger scale test
	for _ = 1, 10 do
		WinsLeaderboard:IncrementValues(Leaderboards, math.random(1, 10000000), math.random(1, 1500));
	end;
end

WinsLeaderboard.Updated:Connect(function(boards)
	-- Returns us a table of all the boards that were updated
	for _, board in boards do
		print(`[Wins] Updating board {board.Type} - with {#board.Data} entries!`);
		UpdateBoard(board.Data, workspace.Leaderboards["Wins"][board.Type]);
	end;

	-- Increment
	IncrementMoneyTest();
end);
-- WinsLeaderboard.BoardUpdated:Connect(function(board)
-- 	print(board.Type, board.Data);
-- end);


--[[
	Filter methods for building database queries.

	@module Rest.QueryFilters
	@private
]]
local QueryFilters = {}
local Http = game:GetService("HttpService")

--[[
	Adds an equality filter (equals).

	@param column string -- Column name to filter
	@param value any -- Value to compare against
	@return table -- Returns self for method chaining
]]
function QueryFilters:eq(column: string, value: any)
    return self:filter(column, "eq", value)
end

--[[
	Adds an inequality filter (not equals).

	@param column string -- Column name to filter
	@param value any -- Value to compare against
	@return table -- Returns self for method chaining
]]
function QueryFilters:neq(column: string, value: any)
    return self:filter(column, "neq", value)
end

--[[
	Adds a greater than filter.

	@param column string -- Column name to filter
	@param value any -- Value to compare against
	@return table -- Returns self for method chaining
]]
function QueryFilters:gt(column: string, value: any)
    return self:filter(column, "gt", value)
end

--[[
	Adds a greater than or equal filter.

	@param column string -- Column name to filter
	@param value any -- Value to compare against
	@return table -- Returns self for method chaining
]]
function QueryFilters:gte(column: string, value: any)
    return self:filter(column, "gte", value)
end

--[[
	Adds a less than filter.

	@param column string -- Column name to filter
	@param value any -- Value to compare against
	@return table -- Returns self for method chaining
]]
function QueryFilters:lt(column: string, value: any)
    return self:filter(column, "lt", value)
end

--[[
	Adds a less than or equal filter.

	@param column string -- Column name to filter
	@param value any -- Value to compare against
	@return table -- Returns self for method chaining
]]
function QueryFilters:lte(column: string, value: any)
    return self:filter(column, "lte", value)
end

--[[
	Adds a LIKE pattern match filter (case-sensitive).

	@param column string -- Column name to filter
	@param pattern string -- SQL LIKE pattern (use % as wildcard)
	@return table -- Returns self for method chaining
]]
function QueryFilters:like(column: string, pattern: string)
    return self:filter(column, "like", pattern)
end

--[[
	Adds a case-insensitive LIKE pattern match filter.

	@param column string -- Column name to filter
	@param pattern string -- SQL LIKE pattern (use % as wildcard)
	@return table -- Returns self for method chaining
]]
function QueryFilters:ilike(column: string, pattern: string)
    return self:filter(column, "ilike", pattern)
end

--[[
	Adds an IS NULL or IS TRUE/FALSE filter.

	@param column string -- Column name to filter
	@param value boolean | "null" -- Value to compare against (true, false, or "null")
	@return table -- Returns self for method chaining
]]
function QueryFilters:is(column: string, value: boolean | "null")
    return self:filter(column, "is", value)
end

--[[
	Adds a contains filter for array/range/jsonb columns.

	@param column string -- Column name to filter
	@param value any -- Value that the column should contain
	@return table -- Returns self for method chaining
]]
function QueryFilters:contains(column: string, value: any)
    -- If the value is a dictionary or an array, handle it
    if typeof(value) == "table" then
        -- Check if the table is an array or dictionary
        local isArray = true
        for k, _ in pairs(value) do
            if typeof(k) ~= "number" then
                isArray = false
                break
            end
        end

        if isArray then
            local arrayStr = "{" .. table.concat(value, ",") .. "}"
            return self:filter(column, "cs", arrayStr)
        else
            return self:filter(column, "cs", Http:JSONEncode(value))
        end
    end

    -- If the value is a string, use it directly
    if typeof(value) == "string" then
        return self:filter(column, "cs", value)
    end

    -- For other types, json stringify the value
    return self:filter(column, "cs", Http:JSONEncode(value))
end

--[[
	Adds a contained by filter for array/range/jsonb columns.

	@param column string -- Column name to filter
	@param value any -- Value that should contain the column
	@return table -- Returns self for method chaining
]]
function QueryFilters:containedBy(column: string, value: any)
    -- If the value is a dictionary or an array, handle it
    if typeof(value) == "table" then
        -- Check if the table is an array or dictionary
        local isArray = true
        for k, _ in pairs(value) do
            if typeof(k) ~= "number" then
                isArray = false
                break
            end
        end

        if isArray then
            local arrayStr = "{" .. table.concat(value, ",") .. "}"
            return self:filter(column, "cd", arrayStr)
        else
            return self:filter(column, "cd", Http:JSONEncode(value))
        end
    end

    -- If the value is a string, use it directly
    if typeof(value) == "string" then
        return self:filter(column, "cd", value)
    end

    -- For other types, json stringify the value
    return self:filter(column, "cd", Http:JSONEncode(value))
end

--[[
	Adds a range strictly right of filter.

	@param column string -- Column name to filter (must be a range type)
	@param range string -- Range value to compare against
	@return table -- Returns self for method chaining
]]
function QueryFilters:rangeGt(column: string, range: string)
    return self:filter(column, "sr", range)
end

--[[
	Adds a range does not extend left of filter.

	@param column string -- Column name to filter (must be a range type)
	@param range string -- Range value to compare against
	@return table -- Returns self for method chaining
]]
function QueryFilters:rangeGte(column: string, range: string)
    return self:filter(column, "nxl", range)
end

--[[
	Adds a range strictly left of filter.

	@param column string -- Column name to filter (must be a range type)
	@param range string -- Range value to compare against
	@return table -- Returns self for method chaining
]]
function QueryFilters:rangeLt(column: string, range: string)
    return self:filter(column, "sl", range)
end

--[[
	Adds a range does not extend right of filter.

	@param column string -- Column name to filter (must be a range type)
	@param range string -- Range value to compare against
	@return table -- Returns self for method chaining
]]
function QueryFilters:rangeLte(column: string, range: string)
    return self:filter(column, "nxr", range)
end

--[[
	Adds a range adjacent to filter.

	@param column string -- Column name to filter (must be a range type)
	@param range string -- Range value to compare against
	@return table -- Returns self for method chaining
]]
function QueryFilters:rangeAdjacent(column: string, range: string)
    return self:filter(column, "adj", range)
end

--[[
	Adds a range overlaps filter.

	@param column string -- Column name to filter (must be a range type)
	@param value any -- Range value to compare against
	@return table -- Returns self for method chaining
]]
function QueryFilters:overlaps(column: string, value: any)
    return self:filter(column, "ov", value)
end

--[[
	Adds a negated filter.

	@param column string -- Column name to filter
	@param operator string -- Operator to negate (e.g., "eq", "gt", "like")
	@param value any -- Value to compare against
	@return table -- Returns self for method chaining
]]
function QueryFilters:not_(column: string, operator: string, value: any)
    return self:filter(column, "not." .. operator, value)
end

return QueryFilters


--[[
	Mutation methods for inserting, updating, and deleting data.

	@module Rest.QueryMutations
	@private
]]
local QueryMutations = {}

local Http = game:GetService("HttpService")

-- Re-export types for internal use
local Types = require(script.Parent.Types)
export type InsertOptions = Types.InsertOptions
export type UpdateOptions = Types.UpdateOptions
export type DeleteOptions = Types.DeleteOptions

--[[
	Inserts one or more rows into the table.
	Returns an InsertQuery type that only allows return modifiers.

	@param data table | {table} -- Single row or array of rows to insert
	@param options InsertOptions? -- Insert options
	@return InsertQuery -- Returns insert-specific query
]]
function QueryMutations:insert(data: table | {table}, options: InsertOptions)
    self.method = "POST"
    self.body = data
    self._insertOptions = options

    -- Set default returning behavior if not specified
    if not options or options.returning == nil then
        self._insertOptions = self._insertOptions or {}
        self._insertOptions.returning = "representation"
    end

    return setmetatable(self, getmetatable(self) --[[@as InsertQuery]])
end

--[[
	Updates rows in the table.
	Returns a UpdateQuery that allows filters.

	@param data table -- Data to update
	@param options UpdateOptions? -- Update options
	@return UpdateQuery -- Returns filterable query
]]
function QueryMutations:update(data: table, options: UpdateOptions)
    self.method = "PATCH"
    self.body = data
    self._updateOptions = options

    -- Set default returning behavior if not specified
    if not options or options.returning == nil then
        self._updateOptions = self._updateOptions or {}
        self._updateOptions.returning = "representation"
    end

    return setmetatable(self, getmetatable(self) --[[@as UpdateQuery]])
end

--[[
	Deletes rows from the table.
	Returns a FilterableQuery that allows filters.

	@param options DeleteOptions? -- Delete options
	@return FilterableQuery -- Returns filterable query
]]
function QueryMutations:delete(options: DeleteOptions)
    self.method = "DELETE"
    self._deleteOptions = options

    -- Set default returning behavior if not specified
    if not options or options.returning == nil then
        self._deleteOptions = self._deleteOptions or {}
        self._deleteOptions.returning = "representation"
    end

    -- Keep the same metatable (FilterableQuery)
    return self
end

--[[
	Upserts one or more rows (insert or update on conflict).
	Returns an UpsertQuery type that only allows return modifiers.

	@param data table | {table} -- Single row or array of rows to upsert
	@param options InsertOptions? -- Upsert options
	@return UpsertQuery -- Returns upsert-specific query
]]
function QueryMutations:upsert(data: table | {table}, options: InsertOptions)
    self.method = "POST"
    self.body = data

    -- Ensure upsert option is set
    self._insertOptions = options or {}
    self._insertOptions.upsert = true

    -- Set default returning behavior if not specified
    if not options or options.returning == nil then
        self._insertOptions.returning = "representation"
    end

    -- Change the metatable to UpsertQuery to restrict available methods
    return setmetatable(self, getmetatable(self) --[[@as UpsertQuery]])
end

return QueryMutations


--[[
	Filter operators and utilities for building query filters.

	@module Rest.QueryOperators
	@private
]]
local QueryOperators = {}

local Http = game:GetService("HttpService")

-- Re-export types for internal use
local Types = require(script.Parent.Types)
export type OrFilterOptions = Types.OrFilterOptions

--[[
	Adds a filter with the specified operator to the query.

	@param self table -- Query instance
	@param column string -- Column name to filter
	@param operator string -- Operator to use
	@param value any -- Value to compare against
	@return table -- Returns self for method chaining
]]
function QueryOperators:filter(column: string, operator: string, value: any)
    local key = Http:UrlEncode(tostring(column))
    local val = Http:UrlEncode(tostring(value))
    local filterStr = ("%s=%s.%s"):format(key, operator, val)
    self.query = (not self.query or self.query == "") and ("?" .. filterStr) or (self.query .. "&" .. filterStr)
    return self
end

--[[
	Adds an IN list filter to match against multiple values.

	@param self table -- Query instance
	@param column string -- Column name to filter
	@param values {any} -- Array of values to match against
	@return table -- Returns self for method chaining
]]
function QueryOperators:in_(column: string, values: {any})
    local key = Http:UrlEncode(tostring(column))
    local encodedValues = {}
    for _, v in ipairs(values) do
        local valStr = tostring(v)
        -- As per PostgREST docs, strings containing commas must be double-quoted.
        if type(v) == "string" and valStr:find(",", 1, true) then
            table.insert(encodedValues, ('"%s"'):format(valStr))
        else
            table.insert(encodedValues, Http:UrlEncode(valStr))
        end
    end
    local filterStr = key .. "=in.(" .. table.concat(encodedValues, ",") .. ")"
    if not self.query or self.query == "" then
        self.query = "?" .. filterStr
    else
        self.query = self.query .. "&" .. filterStr
    end
    return self
end

--[[
	Adds an OR filter with optional foreign table reference.

	@param self table -- Query instance
	@param filters string -- Filter string in PostgREST format
	@param options OrFilterOptions? -- Optional configuration
	@return table -- Returns self for method chaining
]]
function QueryOperators:or_(filters: string, options: OrFilterOptions)
    local filterStr = "or=(" .. filters .. ")"
    if options and options.foreignTable then
        filterStr = options.foreignTable .. "." .. filterStr
    end

    self.query = (not self.query or self.query == "") and ("?" .. filterStr) or (self.query .. "&" .. filterStr)
    return self
end

--[[
	Adds multiple equality filters at once.

	@param self table -- Query instance
	@param query {[string]: any} -- Table of column-value pairs for equality filters
	@return table -- Returns self for method chaining
]]
function QueryOperators:match(query: {[string]: any})
    for column, value in pairs(query) do
        self:eq(column, value)
    end
    return self
end

return QueryOperators


local Types = {}

---------------------------------------------------------------------
-- Base execution interface (available on final stage & also included
-- in intermediate stages so execute() is available anywhere).
---------------------------------------------------------------------
export type BaseExec = {
	-- Execute the query; returns (result, error?)
	execute: (self: any) -> (any, string?),
	-- Execute with typed result (consumer interprets)
	executeTyped: (self: any) -> (any, string?),
}

---------------------------------------------------------------------
-- Option types
---------------------------------------------------------------------
export type OrFilterOptions = {
	foreignTable: string?,
}

export type OrderOptions = {
	ascending: boolean?,
	nullsFirst: boolean?,
	foreignTable: string?,
	referencedTable: string?,
}

export type LimitOptions = {
	foreignTable: string?,
	referencedTable: string?,
}

export type RangeOptions = {
	foreignTable: string?,
	referencedTable: string?,
}

export type ExplainOptions = {
	analyze: boolean?,
	verbose: boolean?,
	settings: boolean?,
	buffers: boolean?,
	wal: boolean?,
	format: "text" | "json" | "yaml"?,
}

export type InsertOptions = {
	returning: "minimal" | "representation"?,
	count: "exact" | "planned" | "estimated"?,
	defaultToNull: boolean?,
	onConflict: string?,
	ignoreDuplicates: boolean?,
	upsert: boolean?,
}

export type UpdateOptions = {
	returning: "minimal" | "representation"?,
	count: "exact" | "planned" | "estimated"?,
}

export type DeleteOptions = {
	returning: "minimal" | "representation"?,
	count: "exact" | "planned" | "estimated"?,
}

---------------------------------------------------------------------
-- Generic Filter stage (parametrized with Next)
-- Filters return Next. To allow repeated filters and modifiers
-- at the same stage we will use self-referential Next types where needed.
---------------------------------------------------------------------
export type FilterStage<Next> = BaseExec & {
	eq: (self: any, column: string, value: any) -> Next,
	neq: (self: any, column: string, value: any) -> Next,
	gt: (self: any, column: string, value: any) -> Next,
	gte: (self: any, column: string, value: any) -> Next,
	lt: (self: any, column: string, value: any) -> Next,
	lte: (self: any, column: string, value: any) -> Next,
	like: (self: any, column: string, pattern: string) -> Next,
	ilike: (self: any, column: string, pattern: string) -> Next,
	is: (self: any, column: string, value: boolean | "null") -> Next,
	in_: (self: any, column: string, values: {any}) -> Next,
	contains: (self: any, column: string, value: any) -> Next,
	containedBy: (self: any, column: string, value: any) -> Next,
	rangeGt: (self: any, column: string, range: string) -> Next,
	rangeGte: (self: any, column: string, range: string) -> Next,
	rangeLt: (self: any, column: string, range: string) -> Next,
	rangeLte: (self: any, column: string, range: string) -> Next,
	rangeAdjacent: (self: any, column: string, range: string) -> Next,
	overlaps: (self: any, column: string, value: any) -> Next,
	match: (self: any, query: {[string]: any}) -> Next,
	not_: (self: any, column: string, operator: string, value: any) -> Next,
	filter: (self: any, column: string, operator: string, value: any) -> Next,
	or_: (self: any, filters: string, options: OrFilterOptions?) -> Next,
}

---------------------------------------------------------------------
-- Modifier stage for SELECT/RPC (non-final modifiers).
-- Transitioning modifiers (order/limit/range) return ModifierStage.
-- Finalizers (single/maybeSingle/csv/returns/explain) collapse to FinalStage.
---------------------------------------------------------------------
export type ModifierStage = BaseExec & {
	-- Non-final modifiers (remain in modifier stage)
	order: (self: any, column: string, options: OrderOptions?) -> ModifierStage,
	limit: (self: any, count: number, options: LimitOptions?) -> ModifierStage,
	range: (self: any, from: number, to: number, options: RangeOptions?) -> ModifierStage,

	-- Finalizers (collapse to FinalStage)
	single: (self: any) -> FinalStage,
	maybeSingle: (self: any) -> FinalStage,
	csv: (self: any) -> FinalStage,
	returns: (self: any, representation: "minimal" | "representation") -> FinalStage,
	overrideTypes: (self: any, schema: table) -> FinalStage,
	explain: (self: any, options: ExplainOptions?) -> FinalStage,
}

---------------------------------------------------------------------
-- Mutation initial stage (for insert/upsert/update/delete before select)
-- This stage exposes returns/csv/execute but NOT single/maybeSingle.
-- Calling select(...) transitions to MutationSelectStage (which exposes single/maybeSingle).
---------------------------------------------------------------------
export type MutationInitialStage = BaseExec & {
	returns: (self: any, representation: "minimal" | "representation") -> FinalStage,
	csv: (self: any) -> FinalStage,
	select: (self: any, columns: string) -> MutationSelectStage,
}

---------------------------------------------------------------------
-- MutationSelectStage: result of calling .select(...) on a mutation.
-- This stage exposes single/maybeSingle which are finalizers that collapse
-- directly to FinalStage (execute-only).
---------------------------------------------------------------------
export type MutationSelectStage = BaseExec & {
	single: (self: any) -> FinalStage,
	maybeSingle: (self: any) -> FinalStage,
	returns: (self: any, representation: "minimal" | "representation") -> FinalStage,
	csv: (self: any) -> FinalStage,
	-- execute available as well
}

---------------------------------------------------------------------
-- Final stage: the chain collapsed to execution only.
-- Only execute() and executeTyped() are available here.
---------------------------------------------------------------------
export type FinalStage = {
	execute: (self: any) -> (any, string?),
	executeTyped: (self: any) -> (any, string?),
}

---------------------------------------------------------------------
-- SELECT types
-- select(...) -> SelectFilterStage (filters chainable).
-- Transitioning non-final modifiers move to ModifierStage.
-- Finalizers collapse to FinalStage.
---------------------------------------------------------------------
export type SelectModifierStage = ModifierStage

export type SelectFilterStage = FilterStage<SelectFilterStage> & {
	-- Transitioning non-final modifiers to move to SelectModifierStage
	order: (self: any, column: string, options: OrderOptions?) -> SelectModifierStage,
	limit: (self: any, count: number, options: LimitOptions?) -> SelectModifierStage,
	range: (self: any, from: number, to: number, options: RangeOptions?) -> SelectModifierStage,

	-- Finalizers collapse to FinalStage
	single: (self: any) -> FinalStage,
	maybeSingle: (self: any) -> FinalStage,
	csv: (self: any) -> FinalStage,
	returns: (self: any, representation: "minimal" | "representation") -> FinalStage,
	overrideTypes: (self: any, schema: table) -> FinalStage,
	explain: (self: any, options: ExplainOptions?) -> FinalStage,
}

---------------------------------------------------------------------
-- RPC types — mirror SELECT semantics and finalizers
---------------------------------------------------------------------
export type RPCModifierStage = ModifierStage

export type RPCFilterStage = FilterStage<RPCFilterStage> & {
	order: (self: any, column: string, options: OrderOptions?) -> RPCModifierStage,
	limit: (self: any, count: number, options: LimitOptions?) -> RPCModifierStage,
	range: (self: any, from: number, to: number, options: RangeOptions?) -> RPCModifierStage,

	-- Finalizers collapse to FinalStage
	single: (self: any) -> FinalStage,
	maybeSingle: (self: any) -> FinalStage,
	csv: (self: any) -> FinalStage,
	returns: (self: any, representation: "minimal" | "representation") -> FinalStage,
	overrideTypes: (self: any, schema: table) -> FinalStage,
	explain: (self: any, options: ExplainOptions?) -> FinalStage,

	-- Optional convenience
	get: (self: any) -> RPCFilterStage,
}

---------------------------------------------------------------------
-- INSERT / UPSERT (start in MutationInitialStage)
---------------------------------------------------------------------
export type InsertQuery = MutationInitialStage
export type UpsertQuery = MutationInitialStage

---------------------------------------------------------------------
-- UPDATE / DELETE (filters first, then mutation select or direct finalizers)
-- - update(...).eq(...).returns() -> FinalStage
-- - update(...).eq(...).select(...).single() -> FinalStage
-- .select() on mutation returns MutationSelectStage (NO filters)
---------------------------------------------------------------------
export type UpdateFilterStage = FilterStage<UpdateFilterStage> & {
	select: (self: any, columns: string) -> MutationSelectStage,
	returns: (self: any, representation: "minimal" | "representation") -> FinalStage,
	csv: (self: any) -> FinalStage,
}

export type UpdateQuery = UpdateFilterStage

export type DeleteFilterStage = FilterStage<DeleteFilterStage> & {
	select: (self: any, columns: string) -> MutationSelectStage,
	returns: (self: any, representation: "minimal" | "representation") -> FinalStage,
	csv: (self: any) -> FinalStage,
}

export type DeleteQuery = DeleteFilterStage

---------------------------------------------------------------------
-- ROOT QUERY
---------------------------------------------------------------------
export type RootQuery = {
	-- SELECT: returns SelectFilterStage (filters chainable)
	select: (self: any, columns: string) -> SelectFilterStage,

	-- INSERT: returns InsertQuery (mutation initial stage)
	insert: (self: any, values: {any}, options: InsertOptions?) -> InsertQuery,

	-- UPDATE: returns UpdateQuery (filters chainable; mutation select/finalizers)
	update: (self: any, values: {any}, options: UpdateOptions?) -> UpdateQuery,

	-- DELETE: returns DeleteQuery (filters chainable; mutation select/finalizers)
	delete: (self: any, options: DeleteOptions?) -> DeleteQuery,

	-- UPSERT: returns UpsertQuery (mutation initial stage)
	upsert: (self: any, values: {any}, options: InsertOptions?) -> UpsertQuery,

	-- RPC: returns RPCFilterStage (filters chainable; finals collapse)
	rpc: (self: any, fnName: string, params: {any}?) -> RPCFilterStage,
}

---------------------------------------------------------------------
-- RestClient
---------------------------------------------------------------------
export type RestClient = {
	from: (self: RestClient, tableName: string) -> RootQuery,
}

return Types


--[[
	Utility functions for the Supabase REST client.

	@module Rest.Utils
	@private
]]
local Utils = {}

--[[
	Attempts to load user-provided type definitions from a `Database.Types` ModuleScript.

	@param scriptRoot Instance? -- The script root to search from
	@return table? -- Loaded types module or nil if not found
]]
function Utils.tryLoadTypes(scriptRoot)
    local candidates = {}
    if scriptRoot then
        table.insert(candidates, scriptRoot:FindFirstChild("Database.Types"))
        if scriptRoot.Parent then
            table.insert(candidates, scriptRoot.Parent:FindFirstChild("Database.Types"))
            if scriptRoot.Parent.Parent then
                table.insert(candidates, scriptRoot.Parent.Parent:FindFirstChild("Database.Types"))
            end
        end
    end

    for _, candidate in ipairs(candidates) do
        if candidate then
            local ok, mod = pcall(require, candidate)
            if ok and type(mod) == "table" then
                return mod
            end
        end
    end

    return nil
end

--[[
	Validates a value against an expected type definition.

	@param value any -- The value to validate
	@param expected any -- Expected type definition
	@return boolean -- True if value matches expected type
]]
function Utils.validateValue(value, expected)
    local t = type(value)
    if expected == "any" then
        return true
    end
    if type(expected) == "string" then
        return t == expected
    end
    if type(expected) == "table" then
        if t ~= "table" then return false end
        for k, v in pairs(expected) do
            if not Utils.validateValue(value[k], v) then
                return false
            end
        end
        return true
    end
    return false
end

return Utils


--[[
	The main REST client class for interacting with Supabase PostgREST API.

	@module Rest
]]
local Rest = {}
Rest.__index = Rest

-- Import dependencies
local Utils = require(script.Utils)
local Types = require(script.Types)
local QueryBuilder = require(script.QueryBuilder)
local RPCBuilder = require(script.RPCBuilder)

-- Re-export types
export type OrFilterOptions = Types.OrFilterOptions
export type InsertOptions = Types.InsertOptions
export type UpdateOptions = Types.UpdateOptions
export type DeleteOptions = Types.DeleteOptions
export type OrderOptions = Types.OrderOptions
export type LimitOptions = Types.LimitOptions
export type RangeOptions = Types.RangeOptions
export type ExplainOptions = Types.ExplainOptions
export type RootQuery = Types.RootQuery
export type InsertQuery = Types.InsertQuery
export type UpdateQuery = Types.UpdateQuery
export type DeleteQuery = Types.DeleteQuery
export type UpsertQuery = Types.UpsertQuery
export type RestClient = Types.RestClient
export type RPCFilterStage = Types.RPCFilterStage

--[[
	Creates a new Supabase REST client.

	@param baseUrl string -- The base URL of your Supabase project
	@param key string -- Your Supabase API key (anon key)
	@return RestClient -- A new REST client instance

	@example
	local supabase = Rest.new("https://your-project.supabase.co", "your-anon-key")
]]
function Rest.new(baseUrl: string, key: string): RestClient
    local self = setmetatable({}, Rest)

    self.baseUrl = baseUrl
    self.key = key

    -- Attempt to load types from a `Database.Types` module placed near this script.
    self._typesModule = Utils.tryLoadTypes(script)

    -- RPC method to call Postgres functions directly
    function self:rpc(functionName: string, params: table?): RPCFilterStage
        return RPCBuilder.new(self.baseUrl, self.key, functionName, params, self.typesModule)
    end

    return (self :: any) :: RestClient
end

--[[
	Creates a query builder for a specific table.

	@param tableName string -- The name of the table to query
	@return RootQuery -- A query builder instance for the specified table

	@example
	local query = supabase:from("users")
		:select("*")
		:eq("status", "active")
		:execute()
]]
function Rest:from(tableName: string): RootQuery
    return QueryBuilder.new(self.baseUrl, self.key, tableName, self._typesModule)
end

return Rest


--!strict
-- ModuleScript: RestTypesTest.luau
-- Provides: RestTypesTest.run()

local RestTypesTest = {}

---------------------------------------------------------------------
-- Utilities
---------------------------------------------------------------------

local function hasKey(t: table, k: string)
	return t[k] ~= nil
end

---------------------------------------------------------------------
-- Runtime mocks required for chaining validation
---------------------------------------------------------------------

local FinalStageMock = {
	execute = function(self)
		return {}, nil
	end,
	executeTyped = function(self)
		return {}, nil
	end,
}

-- Mutation.select() stage
local MutationSelectStageMock: any = {}
function MutationSelectStageMock:single()
	return FinalStageMock
end
function MutationSelectStageMock:maybeSingle()
	return FinalStageMock
end
function MutationSelectStageMock:returns()
	return FinalStageMock
end
function MutationSelectStageMock:csv()
	return FinalStageMock
end
function MutationSelectStageMock:execute()
	return FinalStageMock.execute(self)
end
function MutationSelectStageMock:executeTyped()
	return FinalStageMock.executeTyped(self)
end

-- Mutation initial stage
local MutationInitialStageMock: any = {}
function MutationInitialStageMock:returns()
	return FinalStageMock
end
function MutationInitialStageMock:csv()
	return FinalStageMock
end
function MutationInitialStageMock:select()
	return MutationSelectStageMock
end
function MutationInitialStageMock:execute()
	return FinalStageMock.execute(self)
end
function MutationInitialStageMock:executeTyped()
	return FinalStageMock.executeTyped(self)
end

-- UpdateFilterStage
local UpdateFilterStageMock: any = {}

-- chainable filters
function UpdateFilterStageMock:eq() return UpdateFilterStageMock end
function UpdateFilterStageMock:neq() return UpdateFilterStageMock end
function UpdateFilterStageMock:gt() return UpdateFilterStageMock end
function UpdateFilterStageMock:gte() return UpdateFilterStageMock end
function UpdateFilterStageMock:lt() return UpdateFilterStageMock end
function UpdateFilterStageMock:lte() return UpdateFilterStageMock end
function UpdateFilterStageMock:in_() return UpdateFilterStageMock end
function UpdateFilterStageMock:contains() return UpdateFilterStageMock end
function UpdateFilterStageMock:or_() return UpdateFilterStageMock end

-- transition modifiers
function UpdateFilterStageMock:returns() return FinalStageMock end
function UpdateFilterStageMock:csv() return FinalStageMock end
function UpdateFilterStageMock:select() return MutationSelectStageMock end
function UpdateFilterStageMock:execute() return FinalStageMock.execute(self) end
function UpdateFilterStageMock:executeTyped() return FinalStageMock.executeTyped(self) end

-- Delete mirrors Update
local DeleteFilterStageMock: any = {}
for k, v in pairs(UpdateFilterStageMock) do
	DeleteFilterStageMock[k] = v
end

-- Select modifier stage
local SelectModifierStageMock: any = {}
function SelectModifierStageMock:order() return SelectModifierStageMock end
function SelectModifierStageMock:limit() return SelectModifierStageMock end
function SelectModifierStageMock:range() return SelectModifierStageMock end

function SelectModifierStageMock:single() return FinalStageMock end
function SelectModifierStageMock:maybeSingle() return FinalStageMock end
function SelectModifierStageMock:csv() return FinalStageMock end
function SelectModifierStageMock:returns() return FinalStageMock end

function SelectModifierStageMock:execute() return FinalStageMock.execute(self) end
function SelectModifierStageMock:executeTyped() return FinalStageMock.executeTyped(self) end

-- Select filter stage
local SelectFilterStageMock: any = {}

-- chainable filters
function SelectFilterStageMock:eq() return SelectFilterStageMock end
function SelectFilterStageMock:neq() return SelectFilterStageMock end
function SelectFilterStageMock:gt() return SelectFilterStageMock end
function SelectFilterStageMock:gte() return SelectFilterStageMock end
function SelectFilterStageMock:lt() return SelectFilterStageMock end
function SelectFilterStageMock:lte() return SelectFilterStageMock end
function SelectFilterStageMock:in_() return SelectFilterStageMock end
function SelectFilterStageMock:contains() return SelectFilterStageMock end

-- transitioning modifiers
function SelectFilterStageMock:order() return SelectModifierStageMock end
function SelectFilterStageMock:limit() return SelectModifierStageMock end
function SelectFilterStageMock:range() return SelectModifierStageMock end

function SelectFilterStageMock:single() return FinalStageMock end
function SelectFilterStageMock:maybeSingle() return FinalStageMock end
function SelectFilterStageMock:csv() return FinalStageMock end
function SelectFilterStageMock:returns() return FinalStageMock end

function SelectFilterStageMock:execute() return FinalStageMock.execute(self) end
function SelectFilterStageMock:executeTyped() return FinalStageMock.executeTyped(self) end

-- RPC mirrors select
local RPCFilterStageMock = SelectFilterStageMock

---------------------------------------------------------------------
-- Rest client mock
---------------------------------------------------------------------
local RestClientMock: any = {}
function RestClientMock:from()
	return {
		select = function(_, _cols) return SelectFilterStageMock end,
		insert = function(_, _values) return MutationInitialStageMock end,
		update = function(_, _values) return UpdateFilterStageMock end,
		delete = function(_, _opts) return DeleteFilterStageMock end,
		upsert = function(_, _values) return MutationInitialStageMock end,
		rpc = function(_, _fnName, _params) return RPCFilterStageMock end,
	}
end

---------------------------------------------------------------------
-- All tests executed here
---------------------------------------------------------------------
local function runTests()
	local client = RestClientMock
	local root = client:from()

	-- update without select
	local u = root:update({ name = "x" })
	assert(hasKey(u, "select"), "update should expose select")
	assert(not hasKey(u, "single"), "update should NOT expose single initially")
	assert(not hasKey(u, "maybeSingle"), "update should NOT expose maybeSingle initially")

	-- update.select chain
	local usel = u:select("*")
	assert(hasKey(usel, "single"), "update.select must expose single")
	assert(hasKey(usel, "maybeSingle"), "update.select must expose maybeSingle")

	local finalU = usel:single()
	assert(hasKey(finalU, "execute"), "final stage must expose execute")
	assert(not hasKey(finalU, "select"), "final must not expose select")

	-- insert
	local ins = root:insert({ a = 1 })
	assert(not hasKey(ins, "single"), "insert initial must NOT expose single")
	assert(hasKey(ins, "select"), "insert initial must expose select")

	local insSel = ins:select("*")
	assert(hasKey(insSel, "maybeSingle"), "insert.select must expose maybeSingle")
	local insFinal = insSel:maybeSingle()
	assert(hasKey(insFinal, "execute"), "insert final must expose execute")

	-- delete
	local d = root:delete()
	assert(not hasKey(d, "single"), "delete initial must NOT expose single")
	assert(hasKey(d, "select"), "delete must expose select")

	-- select filter → modifier → final
	local s = root:select("*")
	local s2 = s:eq("id", 1):gt("age", 10)
	local smod = s2:order("name")
	assert(hasKey(smod, "limit"), "modifier stage must allow more modifiers")

	local sfinal = smod:single()
	assert(hasKey(sfinal, "execute"), "select final must expose execute")
	assert(not hasKey(sfinal, "eq"), "final stage must NOT allow filters")

	print("[RestTypesTest] All runtime shape tests passed successfully.")
end

---------------------------------------------------------------------
-- Public API
---------------------------------------------------------------------
function RestTypesTest.run()
	runTests()
end

return RestTypesTest


--[[
	Main Supabase client module that provides unified access to all Supabase services.

	This module serves as the primary entry point for the Supabase client, providing:
	- Database queries via PostgREST (select, insert, update, delete, upsert)
	- PostgreSQL function calls (RPC)
	- Edge Functions invocation
	- Comprehensive unit testing capabilities

	The client is designed for server-side use only to prevent API key exposure.

	@module Supabase
	@see Rest
	@see EdgeFunctions
	@see UnitTests
]]

local Rest = require(script.Rest)
local EdgeFunctions = require(script.EdgeFunctions)
local UnitTests = require(script.UnitTests)
local RunService = game:GetService("RunService")

local Supabase = {}
Supabase.__index = Supabase

-- Import types from Rest module for type safety and autocomplete
local RestTypes = require(script.Rest.Types)
export type RootQuery = RestTypes.RootQuery
export type RPCFilterStage = RestTypes.RPCFilterStage

--[[
	Supabase client type definition.

	@type SupabaseClient
	@within Supabase
	@property rest Rest.RestClient -- REST API client for database operations
	@property storage Storage.StorageClient -- Storage client for file operations
	@property functions EdgeFunctions.FunctionsClient -- Edge Functions client
	@property _unitTests UnitTests.UnitTests -- Internal unit testing module

	@method from (tableName: string) -> RootQuery -- Creates a query builder for the specified table
	@method rpc (functionName: string, params: {[string]: any}?) -> RPCFilterStage -- Calls a PostgreSQL function
]]
export type SupabaseClient = {
	rest: Rest.RestClient,
	functions: EdgeFunctions.FunctionsClient,
	_unitTests: UnitTests.UnitTests,

	from: (self: SupabaseClient, tableName: string) -> RootQuery,
	rpc: (self: SupabaseClient, functionName: string, params: {[string]: any}?) -> RPCFilterStage,
}

--[[
	Creates a new Supabase client instance.

	This constructor initializes all service clients (REST, Storage, Edge Functions)
	and sets up the necessary configuration. It also includes safety checks to
	ensure the client is only used on the server side to prevent API key leakage.

	@param baseUrl string -- The base URL of your Supabase project (e.g., "https://your-project.supabase.co")
	@param key string -- Your Supabase API key (anon or service role key)
	@return SupabaseClient -- A new Supabase client instance

	@error "Supabase client must be created on the server" if called from client-side code
]]
function Supabase.new(baseUrl: string, key: string): SupabaseClient
	local self = setmetatable({}, Supabase)

	-- Ensure this client is only created on the server to avoid leaking keys.
	if not RunService:IsServer() then
		error("Supabase client must be created on the server (server-side only). Do NOT use on client-side code.")
	end

	self.url = baseUrl
	self.key = key

	-- Initialize service clients
	self.rest = Rest.new(baseUrl, key)
	self.functions = EdgeFunctions.new(baseUrl, key)

	--[[
		Proxy method for table queries.

		Provides a convenient interface for building database queries against a specific table.
		This method follows the chainable pattern: client:from("table"):select("*"):execute()

		@param tableName string -- Name of the database table to query
		@return RootQuery -- A query builder instance for the specified table
	]]
	function self:from(tableName: string): RootQuery
		return self.rest:from(tableName)
	end

	--[[
		Proxy method for PostgreSQL function calls (RPC).

		Allows calling PostgreSQL functions with optional parameters. Functions can return
		scalar values, JSON objects, or table sets that can be further filtered.

		@param functionName string -- Name of the PostgreSQL function to call
		@param params {[string]: any}? -- Optional parameters to pass to the function
		@return RPCQuery -- An RPC query builder instance

		@example
		-- Call a function with parameters
		client:rpc("calculate_total", { price = 99.99, quantity = 3 }):execute()

		-- Call a function that returns a table and apply filters
		client:rpc("get_active_users"):eq("status", "active"):limit(10):execute()
	]]
	function self:rpc(functionName: string, params: {[string]: any}?): RootQuery
		return self.rest:rpc(functionName, params)
	end

	-- Internal unit testing module for comprehensive validation
	self._unitTests = UnitTests.new(self)

	return (self :: any) :: SupabaseClient
end

--[[
	Type-safe factory method for creating a Supabase client.

	This method provides a more idiomatic configuration interface similar to the
	TypeScript Supabase client. It validates configuration parameters and ensures
	server-side execution before delegating to the main constructor.

	@param config { url: string, apiKey: string } -- Configuration table
	@param config.url string -- The base URL of your Supabase project
	@param config.apiKey string -- Your Supabase API key
	@return SupabaseClient -- A new Supabase client instance

	@error "createClient expects a config table" if config is not a table
	@error "config.url must be a string" if url is invalid
	@error "config.apiKey must be a string" if apiKey is invalid
	@error "Supabase.createClient must be called from server-side code" if called from client

	@example
	local client = Supabase.createClient({
		url = "https://your-project.supabase.co",
		apiKey = "your-anon-or-service-role-key",
	})
]]
function Supabase.createClient(config: { url: string, apiKey: string }): SupabaseClient
	assert(type(config) == "table", "createClient expects a config table")
	assert(type(config.url) == "string", "config.url must be a string")
	assert(type(config.apiKey) == "string", "config.apiKey must be a string")
	if not RunService:IsServer() then
		error("Supabase.createClient must be called from server-side code. Do NOT call from client scripts.")
	end

	return Supabase.new(config.url, config.apiKey)
end

return Supabase


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

local HttpService = game:GetService("HttpService")
local LocalizationService = game:GetService("LocalizationService")
local RunService = game:GetService("RunService")

type ShardData = {
	{
		id: number,
		displayName: string,
		countries: { string },
	}
}

local BUILTIN_DATA = require(script.shardData) :: ShardData
local DEFAULT_SHARD_JSON = "https://oss.playcurrent.com/shards/data/shards.json"

local ShardData: ShardData = BUILTIN_DATA

local function getShardForCountry(country: string): number
	for _, shard in ShardData do
		for _, shardCountry in shard.countries do
			if shardCountry == country then
				return shard.id
			end
		end
	end

	warn("[Shards] No shard found for country", country, "defaulting to Other shard")

	return -1
end

local function getShardForPlayer(player: Player): number
	local country = LocalizationService:GetCountryRegionForPlayerAsync(player)

	return getShardForCountry(country)
end

local function getShardName(id: number): string
	for _, shard in ShardData do
		if shard.id == id then
			return shard.displayName
		end
	end

	return "Other"
end

local function loadRemoteShardData(url: string?)
	if RunService:IsClient() then
		error("can't load remote shard data on the client", 2)
	end

	local success, result = pcall(function()
		return HttpService:GetAsync(url or DEFAULT_SHARD_JSON)
	end)

	if success then
		-- we are very much just trusting this data, but the official data will always be correct, so only user-provided
		-- URLs are sketchy
		ShardData = HttpService:JSONDecode(result)
	else
		warn(`[Shards] Could not load remote shard data, will use existing data instead: {result}`)
	end
end

return {
	getShardForCountry = getShardForCountry,
	getShardForPlayer = getShardForPlayer,
	getShardName = getShardName,

	loadRemoteShardData = loadRemoteShardData,
}


local Spring = require(script.Parent:WaitForChild("Spring"))

type Spring = Spring.Spring

export type AnimChainImpl = {
	-- Metatable members

	__index: AnimChain,

	-- Public members

	new: (animators: { Spring | Tween }?) -> AnimChain,
	AndThen: (AnimChain, callback: () -> ()) -> AnimChain,
	Await: (AnimChain) -> AnimChain,

	-- Private members

	_addAnimator: (AnimChain, animator: Spring | Tween) -> (),
	_processCallbackQueue: (AnimChain) -> (),
	_waitForAnimationsCompleted: (AnimChain) -> (),
}
export type AnimChain = typeof(setmetatable(
	{} :: {
		-- Constructed public members

		-- Constructed private members
		_animators: { Spring | Tween },
		_callbackQueue: { () -> () },
		_processingQueue: boolean,
	},
	{} :: AnimChainImpl
))

--[=[
	@class AnimChain

	An object that can be used to link functionality to the end of an
	animation. `:AndThen()` can be used to link a callback, and `:Await()` can
	be used to wait for the animation to complete. These links are processed
	in the order they are called in.
]=]
local AnimChain: AnimChainImpl = {} :: AnimChainImpl
AnimChain.__index = AnimChain

function AnimChain:_addAnimator(animator: Spring | Tween)
	table.insert(self._animators, animator)
end

function AnimChain:_processCallbackQueue()
	if not self._processingQueue then
		self._processingQueue = true

		while self._callbackQueue[1] do
			table.remove(self._callbackQueue, 1)()
		end

		self._processingQueue = false
	end
end

function AnimChain:_waitForAnimationsCompleted()
	for _, animator: Spring | Tween in ipairs(self._animators) do
		if typeof(animator) == "table" then -- Spring
			while animator:IsAnimating(0.01) do
				task.wait()
			end
		elseif typeof(animator) == "Instance" then -- Tween
			while
				animator.PlaybackState ~= Enum.PlaybackState.Completed
				and animator.PlaybackState ~= Enum.PlaybackState.Cancelled
			do
				animator:GetPropertyChangedSignal("PlaybackState"):Wait()
			end
		end
	end
end

--[=[
	Links a callback to be executed after all animations in the chain have
	completed.

	@param callback () -> () -- The callback to execute
	@return AnimChain -- The current AnimChain object
]=]
function AnimChain:AndThen(callback: () -> ()): AnimChain
	task.spawn(function()
		table.insert(self._callbackQueue, callback)

		self:_waitForAnimationsCompleted()

		self:_processCallbackQueue()
	end)

	return self :: AnimChain
end

--[=[
	Yields the current thread until all animations in the chain have completed,
	then returns the current AnimChain object.

	@return AnimChain -- The current AnimChain object
]=]
function AnimChain:Await(): AnimChain
	local locked = true
	local unlock = function()
		locked = false
	end

	table.insert(self._callbackQueue, unlock)

	self:_waitForAnimationsCompleted()

	task.spawn(self._processCallbackQueue, self)

	while locked do
		task.wait()
	end

	return self :: AnimChain
end

--[=[
	Constructs a new AnimChain object.

	@param animators { Spring | Tween }? -- A list of animators to add to the chain
	@return AnimChain -- The constructed AnimChain object
]=]
function AnimChain.new(animators: { Spring | Tween }?)
	local self = setmetatable({}, AnimChain)

	self._animators = animators or {}
	self._callbackQueue = {}
	self._processingQueue = false

	return self
end

return AnimChain