--!strict

export type LeaderboardInfo = {
	rank: number?,
	score: number?,
	statName: string,
}

export type LeaderboardEntry = {
	userId: number,
	score: number,
	rank: number,
}

local Leaderboard = {}

local function ToLeaderboardInfo(statName: string, statData: any): LeaderboardInfo?
	if typeof(statData) ~= "table" then
		return nil
	end

	return {
		rank = statData.rank,
		score = statData.score,
		statName = statName,
	}
end

function Leaderboard.GetLeaderboardInfo(replica: any?, statName: string): LeaderboardInfo?
	if not replica then
		return nil
	end

	local leaderboardRanks = (replica.Data :: any)._LeaderboardRanks
	if typeof(leaderboardRanks) ~= "table" then
		return nil
	end

	local statData = leaderboardRanks[statName]
	return ToLeaderboardInfo(statName, statData)
end

function Leaderboard.GetLeaderboard(replica: any?, statName: string, limit: number?): {LeaderboardEntry}
	if not replica then
		return {}
	end

	local leaderboards = (replica.Data :: any)._Leaderboards
	if typeof(leaderboards) ~= "table" then
		return {}
	end

	local statLeaderboard = leaderboards[statName]
	if typeof(statLeaderboard) ~= "table" then
		return {}
	end

	local entries: {LeaderboardEntry} = {}
	for _, entry in statLeaderboard do
		if typeof(entry) == "table" and typeof(entry.userId) == "number" and typeof(entry.score) == "number" and typeof(entry.rank) == "number" then
			entries[#entries + 1] = {
				userId = entry.userId,
				score = entry.score,
				rank = entry.rank,
			}
		end
	end

	local requestedLimit = if typeof(limit) == "number" and limit > 0 then math.floor(limit) else #entries
	local clampedLimit = math.min(requestedLimit, #entries)

	return table.move(entries, 1, clampedLimit, 1, {})
end

return Leaderboard


--!strict

export type PathCacheModule = {
	GetPathKeys: (path: string) -> {string},
	ClearCache: () -> (),
}

local function CreatePathCache(maxPathCacheSize: number): PathCacheModule
	local pathCache: {[string]: {string}} = {}

	local function GetPathKeys(path: string): {string}
		local cached = pathCache[path]
		if cached then
			return cached
		end

		local keys = string.split(path, ".")
		pathCache[path] = keys

		local cacheSize = 0
		for _ in pathCache do
			cacheSize += 1
		end

		if cacheSize > maxPathCacheSize then
			local keysToRemove = {}
			local count = 0
			for cachedPath in pathCache do
				table.insert(keysToRemove, cachedPath)
				count += 1
				if count > 100 then
					break
				end
			end

			for _, keyToRemove in keysToRemove do
				pathCache[keyToRemove] = nil
			end
		end

		return keys
	end

	local function ClearCache(): ()
		table.clear(pathCache)
	end

	return {
		GetPathKeys = GetPathKeys,
		ClearCache = ClearCache,
	}
end

return CreatePathCache


--!strict

local Validation = {}

function Validation.ValidateReplica(replica: any?): boolean
	return replica ~= nil and replica:IsActive()
end

function Validation.WaitForData(
	dataReady: () -> boolean,
	validateReplica: (replica: any?) -> boolean,
	getReplica: () -> any?,
	readyEvent: BindableEvent?
): boolean
	if dataReady() and validateReplica(getReplica()) then
		return true
	end

	if readyEvent ~= nil then
		readyEvent.Event:Wait()
		if dataReady() and validateReplica(getReplica()) then
			return true
		end
	end

	while not (dataReady() and validateReplica(getReplica())) do
		task.wait(0.05)
	end

	return true
end

return Validation


--!strict

local Array = {}

function Array.AddToArray(replica: any, pathKeys: {string}, item: any): (boolean, string?)
	local success, result = pcall(replica.TableInsert, replica, pathKeys, item)
	if not success then
		return false, result
	end

	return success, nil
end

function Array.RemoveFromArray(replica: any, pathKeys: {string}, index: number): (boolean, string?)
	local success, result = pcall(replica.TableRemove, replica, pathKeys, index)
	if not success then
		return false, result
	end

	return success, nil
end

function Array.UpdateArrayItem(replica: any, pathKeys: {string}, index: number, newItem: any): boolean
	local fullPath: {string | number} = table.clone(pathKeys)
	table.insert(fullPath, index)

	local success = pcall(replica.Set, replica, fullPath :: any, newItem)
	return success
end

return Array


--!strict

export type BatchOperation = {
	path: string,
	value: any,
}

export type BatchModule = {
	ScheduleBatchOperation: (player: Player, operation: BatchOperation) -> (),
	ProcessBatchOperations: (player: Player) -> (),
	FlushBatch: (player: Player) -> (),
	ClearPlayerBatch: (player: Player) -> (),
}

local function CreateBatchModule(
	config: any,
	getPathKeys: (path: string) -> {string},
	validateReplica: (replica: any?) -> boolean,
	getReplica: (player: Player) -> any?,
	onBatchCommitted: ((Player, {BatchOperation}) -> ())?
): BatchModule
	local batchOperations: {[Player]: {BatchOperation}} = {}
	local batchScheduled: {[Player]: thread?} = {}

	local function ProcessBatchOperations(player: Player): ()
		local playerBatch = batchOperations[player]
		if not playerBatch or #playerBatch == 0 then
			return
		end

		local replica = getReplica(player)
		if not replica or not validateReplica(replica) then
			warn(`[PlayerState] Cannot process batch operations - invalid replica for {player.Name}`)
			batchOperations[player] = nil
			batchScheduled[player] = nil

			return
		end

		local groupedOps: {[string]: {[string]: any}} = {}
		local singleOps: {{path: {string}, value: any}} = {}
		local rootLevelOps: {[string]: any} = {}

		for i = 1, #playerBatch do
			local operation = playerBatch[i]
			local pathKeys = getPathKeys(operation.path)

			if #pathKeys == 1 then
				rootLevelOps[pathKeys[1]] = operation.value
			elseif #pathKeys == 2 then
				local rootPath = pathKeys[1]
				if not groupedOps[rootPath] then
					groupedOps[rootPath] = {}
				end
				groupedOps[rootPath][pathKeys[2]] = operation.value
			else
				singleOps[#singleOps + 1] = {path = pathKeys, value = operation.value}
			end
		end

		replica:BeginBatch()

		if next(rootLevelOps) then
			local success = pcall(replica.SetValues, replica, {}, rootLevelOps)
			if not success then
				warn(`[PlayerState] Batch root SetValues failed for {player.Name}`)
			end
		end

		for rootPath, values in groupedOps do
			local success = pcall(replica.SetValues, replica, {rootPath}, values)
			if not success then
				warn(`[PlayerState] Batch grouped SetValues failed for {player.Name}`)
			end
		end

		for i = 1, #singleOps do
			local operation = singleOps[i]
			local success = pcall(replica.Set, replica, operation.path, operation.value)
			if not success then
				warn(`[PlayerState] Batch Set failed for {player.Name}`)
			end
		end

		replica:EndBatch()

		local committedOps = table.create(#playerBatch)
		table.move(playerBatch, 1, #playerBatch, 1, committedOps)
		if onBatchCommitted then
			onBatchCommitted(player, committedOps)
		end

		batchOperations[player] = nil
		batchScheduled[player] = nil
	end

	local function ScheduleBatchOperation(player: Player, operation: BatchOperation): ()
		local playerBatch = batchOperations[player]
		if not playerBatch then
			playerBatch = {}
			batchOperations[player] = playerBatch
		end

		playerBatch[#playerBatch + 1] = operation

		if #playerBatch >= config.BatchSize then
			local scheduledThread = batchScheduled[player]
			if scheduledThread then
				task.cancel(scheduledThread)
				batchScheduled[player] = nil
			end
			ProcessBatchOperations(player)
			return
		end

		if not batchScheduled[player] then
			batchScheduled[player] = task.delay(config.BatchDelay, function()
				ProcessBatchOperations(player)
			end)
		end
	end

	local function FlushBatch(player: Player): ()
		local scheduledThread = batchScheduled[player]
		if scheduledThread then
			task.cancel(scheduledThread)
			batchScheduled[player] = nil
		end

		ProcessBatchOperations(player)
	end

	local function ClearPlayerBatch(player: Player): ()
		if batchOperations[player] then
			ProcessBatchOperations(player)
			batchOperations[player] = nil
		end
		local scheduledThread = batchScheduled[player]
		if scheduledThread then
			task.cancel(scheduledThread)
			batchScheduled[player] = nil
		end
	end

	return {
		ScheduleBatchOperation = ScheduleBatchOperation,
		ProcessBatchOperations = ProcessBatchOperations,
		FlushBatch = FlushBatch,
		ClearPlayerBatch = ClearPlayerBatch,
	}
end

return CreateBatchModule


--!strict

local DataUtils = {}

local INT32_MAX = 2147483647
local INT32_MIN = -2147483648
local DEFAULT_RUNTIME_NON_PERSISTENT_ROOTS = {
	"session",
	"_LeaderboardRanks",
	"_Leaderboards",
	"_Leaderboard",
}

function DataUtils.GetAppropriateValueType(value: number): string
	if value % 1 ~= 0 then
		return "NumberValue"
	elseif value > INT32_MAX or value < INT32_MIN then
		return "NumberValue"
	else
		return "IntValue"
	end
end

function DataUtils.GetNestedValue(data: any, pathKeys: {string}): any
	local current = data

	for i = 1, #pathKeys do
		local key = pathKeys[i]
		if typeof(current) ~= "table" then
			return nil
		end
		current = current[key]
		if current == nil then
			return nil
		end
	end

	return current
end

function DataUtils.CleanData(data: any, template: any): ()
	for key in data do
		if template[key] == nil then
			data[key] = nil
		elseif typeof(data[key]) == "table" and typeof(template[key]) == "table" then
			if next(template[key]) == nil then
				continue
			end

			DataUtils.CleanData(data[key], template[key])
		end
	end
end

local function GetPathKeys(path: string): {string}
	local keys: {string} = {}
	for segment in string.gmatch(path, "[^%.]+") do
		table.insert(keys, segment)
	end

	return keys
end

local function GetNestedPathValue(data: any, pathKeys: {string}): any
	local current = data

	for i = 1, #pathKeys do
		if typeof(current) ~= "table" then
			return nil
		end

		current = current[pathKeys[i]]
		if current == nil then
			return nil
		end
	end

	return current
end

local function SetNestedPathValue(data: any, pathKeys: {string}, value: any): boolean
	if typeof(data) ~= "table" or #pathKeys == 0 then
		return false
	end

	local current = data
	for i = 1, #pathKeys - 1 do
		local key = pathKeys[i]
		if typeof(current[key]) ~= "table" then
			current[key] = {}
		end

		current = current[key]
	end

	current[pathKeys[#pathKeys]] = value

	return true
end

local function DeepClone(value: any): any
	if typeof(value) ~= "table" then
		return value
	end

	local clone = {}
	for key, child in value do
		clone[key] = DeepClone(child)
	end

	return clone
end

function DataUtils.IsPathInRuntimeRoots(path: string, runtimeRoots: {string}?): boolean
	if typeof(path) ~= "string" or path == "" then
		return false
	end

	if typeof(runtimeRoots) ~= "table" then
		return false
	end

	for _, root in runtimeRoots do
		if typeof(root) == "string" and root ~= "" then
			if path == root then
				return true
			end

			if string.sub(path, 1, #root + 1) == root .. "." then
				return true
			end
		end
	end

	return false
end

function DataUtils.ResolveRuntimeNonPersistentRoots(runtimeRoots: {string}?): {string}
	local resolved: {string} = {}
	local seen: {[string]: boolean} = {}

	if typeof(runtimeRoots) == "table" then
		for _, root in runtimeRoots do
			if typeof(root) == "string" and root ~= "" and not seen[root] then
				table.insert(resolved, root)
				seen[root] = true
			end
		end
	end

	for _, root in DEFAULT_RUNTIME_NON_PERSISTENT_ROOTS do
		if not seen[root] then
			table.insert(resolved, root)
			seen[root] = true
		end
	end

	return resolved
end

function DataUtils.RemoveRuntimeRoots(data: any, runtimeRoots: {string}?): boolean
	if typeof(data) ~= "table" or typeof(runtimeRoots) ~= "table" then
		return false
	end

	local removed = false
	for _, rootPath in runtimeRoots do
		if typeof(rootPath) ~= "string" or rootPath == "" then
			continue
		end

		local pathKeys = GetPathKeys(rootPath)
		if #pathKeys == 1 then
			local key = pathKeys[1]
			if data[key] ~= nil then
				data[key] = nil
				removed = true
			end
		else
			local parentPath = table.move(pathKeys, 1, #pathKeys - 1, 1, {})
			local parent = GetNestedPathValue(data, parentPath)
			local key = pathKeys[#pathKeys]

			if typeof(parent) == "table" and parent[key] ~= nil then
				parent[key] = nil
				removed = true
			end
		end
	end

	return removed
end

function DataUtils.CaptureAndRemoveRuntimeRoots(data: any, runtimeRoots: {string}?): {[string]: any}
	local captured: {[string]: any} = {}
	if typeof(data) ~= "table" or typeof(runtimeRoots) ~= "table" then
		return captured
	end

	for _, rootPath in runtimeRoots do
		if typeof(rootPath) ~= "string" or rootPath == "" then
			continue
		end

		local pathKeys = GetPathKeys(rootPath)
		local currentValue = GetNestedPathValue(data, pathKeys)
		if currentValue == nil then
			continue
		end

		captured[rootPath] = currentValue
		DataUtils.RemoveRuntimeRoots(data, {rootPath})
	end

	return captured
end

function DataUtils.RestoreRuntimeRoots(data: any, captured: {[string]: any}?): ()
	if typeof(data) ~= "table" or typeof(captured) ~= "table" then
		return
	end

	for rootPath, value in captured do
		if typeof(rootPath) ~= "string" or rootPath == "" then
			continue
		end

		local pathKeys = GetPathKeys(rootPath)
		SetNestedPathValue(data, pathKeys, value)
	end
end

function DataUtils.AttachRuntimeSaveProtection(profile: any, runtimeRoots: {string}?): ()
	local capturedRuntimeData: {[string]: any}? = nil

	local onSave = profile and (profile :: any).OnSave
	if onSave and onSave.Connect then
		onSave:Connect(function()
			local data = (profile :: any).Data
			capturedRuntimeData = DataUtils.CaptureAndRemoveRuntimeRoots(data, runtimeRoots)
		end)
	end

	local onAfterSave = profile and (profile :: any).OnAfterSave
	if onAfterSave and onAfterSave.Connect then
		onAfterSave:Connect(function()
			local data = (profile :: any).Data
			DataUtils.RestoreRuntimeRoots(data, capturedRuntimeData)
			capturedRuntimeData = nil
		end)
	end
end

function DataUtils.ApplyRuntimeRootDefaults(data: any, template: any, runtimeRoots: {string}?): ()
	if typeof(data) ~= "table" or typeof(template) ~= "table" or typeof(runtimeRoots) ~= "table" then
		return
	end

	for _, rootPath in runtimeRoots do
		if typeof(rootPath) ~= "string" or rootPath == "" then
			continue
		end

		local pathKeys = GetPathKeys(rootPath)
		local templateValue = GetNestedPathValue(template, pathKeys)
		if templateValue ~= nil then
			SetNestedPathValue(data, pathKeys, DeepClone(templateValue))
		end
	end
end

function DataUtils.RemoveRuntimeLeaderboardData(data: any): boolean
	return DataUtils.RemoveRuntimeRoots(data, {
		"_LeaderboardRanks",
		"_Leaderboards",
		"_Leaderboard",
	})
end

return DataUtils


--!strict

local Players = game:GetService("Players")
local DataUtils = require(script.Parent.DataUtils)

local DataWipe = {}

function DataWipe.WipePlayerData(
	player: Player,
	config: any,
	getProfile: (player: Player) -> any?,
	getReplica: (player: Player) -> any?,
	validatePlayer: (player: Player) -> boolean,
	validateProfile: (profile: any?) -> boolean,
	validateReplica: (replica: any?) -> boolean
): boolean
	if not validatePlayer(player) then
		warn("[PlayerState] Cannot wipe data - invalid player")
		return false
	end

	local profile = getProfile(player)
	if not validateProfile(profile) then
		warn(`[PlayerState] No valid profile found for {player.Name}`)
		return false
	end

	local success = pcall(function()
		table.clear(profile.Data)
		for key, value in config.Profile.Template do
			profile.Data[key] = value
		end
	end)

	if not success then
		warn(`[PlayerState] Failed to wipe data for {player.Name}`)
		return false
	end

	local replica = getReplica(player)
	if validateReplica(replica) then
		pcall(replica.SetValues, replica, {}, profile.Data)
	end

	print(`[PlayerState] Successfully wiped data for {player.Name}`)

	if player:IsDescendantOf(Players) then
		player:Kick("Your data has been reset. Please rejoin to continue.")
	end

	return true
end

function DataWipe.WipeOfflinePlayerData(
	userId: number,
	config: any,
	profileStore: any,
	wipePlayerData: (player: Player) -> boolean,
	cleanData: (data: any, template: any) -> ()
): boolean
	if not userId or userId == 0 then
		warn("[PlayerState] Invalid userId for WipeOfflinePlayerData")
		return false
	end

	for _, player in Players:GetPlayers() do
		if player.UserId == userId then
			return wipePlayerData(player)
		end
	end

	local profileKey = config.Profile.Key .. "_" .. tostring(userId)

	local offlineProfile = profileStore:GetAsync(profileKey)

	if offlineProfile and offlineProfile.Session then
		local success = profileStore:MessageAsync(profileKey, {
			Type = "WipeData",
		})

		if success then
			print(`[PlayerState] Sent wipe data message to active profile for user {userId}`)
			return true
		else
			warn(`[PlayerState] Failed to send wipe message to active profile for user {userId}`)
			return false
		end
	end

	local success, profile = pcall(function()
		return profileStore:StartSessionAsync(profileKey)
	end)

	if not success or not profile then
		warn(`[PlayerState] Failed to start profile session for offline userId {userId}`)
		return false
	end

	profile:AddUserId(userId)
	profile:Reconcile()

	cleanData(profile.Data, config.Profile.Template)

	local wipeSuccess = pcall(function()
		for key, value in config.Profile.Template do
			profile.Data[key] = value
		end
	end)

	if not wipeSuccess then
		warn(`[PlayerState] Failed to wipe offline data for userId {userId}`)
		profile:EndSession()
		return false
	end

	local runtimeRoots = DataUtils.ResolveRuntimeNonPersistentRoots(config.RuntimeNonPersistentRoots)
	local capturedRuntimeData = DataUtils.CaptureAndRemoveRuntimeRoots(profile.Data, runtimeRoots)
	profile:Save()
	DataUtils.RestoreRuntimeRoots(profile.Data, capturedRuntimeData)
	profile:EndSession()

	print(`[PlayerState] Successfully wiped offline data for userId {userId}`)
	return true
end

return DataWipe


--!strict

local Dict = {}

local function getParentAndDictSlot(replicaData: any, pathKeys: {string}): (any?, string?)
	if #pathKeys == 0 then
		return nil, nil
	end

	local current = replicaData
	for i = 1, #pathKeys - 1 do
		local k = pathKeys[i]
		if typeof(current) ~= "table" then
			return nil, nil
		end
		current = current[k]
		if typeof(current) ~= "table" then
			return nil, nil
		end
	end

	if typeof(current) ~= "table" then
		return nil, nil
	end

	return current, pathKeys[#pathKeys]
end

function Dict.SetInDict(replica: any, pathKeys: {string}, key: string, value: any): boolean
	local parent, slotKey = getParentAndDictSlot(replica.Data, pathKeys)
	if parent == nil or slotKey == nil then
		return false
	end

	local dict = parent[slotKey]

	if typeof(dict) == "table" then
		local fullPath = table.clone(pathKeys)
		table.insert(fullPath, key)

		local success = pcall(replica.Set, replica, fullPath, value)
		return success
	elseif dict == nil then
		local success = pcall(replica.Set, replica, pathKeys, { [key] = value })
		return success
	end

	return false
end

function Dict.RemoveFromDict(replica: any, pathKeys: {string}, key: string): boolean
	local parent, slotKey = getParentAndDictSlot(replica.Data, pathKeys)
	if parent == nil or slotKey == nil then
		return false
	end

	local dict = parent[slotKey]
	if typeof(dict) ~= "table" then
		return false
	end

	local fullPath = table.clone(pathKeys)
	table.insert(fullPath, key)

	local success = pcall(replica.Set, replica, fullPath, nil)
	return success
end

return Dict


--!strict

local Players = game:GetService("Players")
local DataStoreService = game:GetService("DataStoreService")
local DataUtils = require(script.Parent.DataUtils)

export type LeaderboardEntry = {
	userId: number,
	score: number,
	rank: number,
}

export type LeaderboardModule = {
	InitializeLeaderboards: () -> (),
	IsStatTracked: (statName: string) -> boolean,
	UpdateAllLeaderboards: () -> (),
	GetLeaderboardInternal: (statName: string, limit: number) -> {LeaderboardEntry},
	UpdateReplicaLeaderboardScore: (replica: any, statName: string, score: number) -> (),
	SetupLeaderboardRankSaveProtection: (profile: any) -> (),
	SyncPlayerLeaderboardRanks: () -> (),
	StartPeriodicUpdates: () -> (),
	UpdatePlayerLeaderboard: (player: Player) -> (),
	CancelPeriodicUpdates: () -> (),
	EnqueueTrackedStatsFromReplica: (replica: any) -> (),
}

local function CreateLeaderboardModule(
	config: any,
	validatePlayer: (player: Player) -> boolean,
	validateReplica: (replica: any?) -> boolean,
	getReplicas: () -> {[Player]: any},
	getNestedValue: (data: any, path: string) -> any
): LeaderboardModule
	local leaderboardStores: {[string]: any} = {}
	local leaderboardCache: {[string]: {data: {LeaderboardEntry}, lastUpdate: number}} = {}
	local periodicUpdateThread: thread?
	local fullWriteUpdateThread: thread?
	local trackedStatSet: {[string]: boolean} = {}

	local WRITE_INTERVAL_SECONDS = 0.12
	local lastWrittenScore: {[string]: number} = {}
	local pendingWrites: {[string]: {store: any, userIdKey: string, value: number}} = {}
	local writeWorkerStarted = false

	local function RunLeaderboardWriteWorker()
		while true do
			task.wait(WRITE_INTERVAL_SECONDS)
			local key, entry = next(pendingWrites)

			if key and entry then
				pendingWrites[key] = nil

				local success = pcall(function()
					entry.store:SetAsync(entry.userIdKey, entry.value)
				end)

				if success then
					lastWrittenScore[key] = entry.value
				else
					pendingWrites[key] = entry
				end
			end
		end
	end

	local function EnqueueLeaderboardWrite(statName: string, store: any, userId: number, value: number)
		local key = statName .. "|" .. tostring(userId)
		if pendingWrites[key] == nil and lastWrittenScore[key] == value then
			return
		end

		pendingWrites[key] = {
			store = store,
			userIdKey = tostring(userId),
			value = value,
		}

		if not writeWorkerStarted then
			writeWorkerStarted = true
			task.spawn(RunLeaderboardWriteWorker)
		end
	end

	local function CopyLeaderboardEntries(entries: {LeaderboardEntry}): {LeaderboardEntry}
		local copiedEntries: {LeaderboardEntry} = {}
		for i, entry in entries do
			copiedEntries[i] = {
				userId = entry.userId,
				score = entry.score,
				rank = entry.rank,
			}
		end

		return copiedEntries
	end

	local function AreLeaderboardEntriesEqual(a: any, b: {LeaderboardEntry}): boolean
		if typeof(a) ~= "table" or #a ~= #b then
			return false
		end

		for i, entry in b do
			local currentEntry = a[i]
			if typeof(currentEntry) ~= "table"
				or currentEntry.userId ~= entry.userId
				or currentEntry.score ~= entry.score
				or currentEntry.rank ~= entry.rank
			then
				return false
			end
		end

		return true
	end

	local function InitializeLeaderboards()
		table.clear(trackedStatSet)

		if not config.Leaderboard.Enabled then
			return
		end

		for _, statName in (config.Leaderboard.TrackedStats) do
			trackedStatSet[statName] = true
			local storeName = config.Leaderboard.DataStoreName .. "_" .. statName:gsub("%.", "_")

			leaderboardStores[statName] = DataStoreService:GetOrderedDataStore(storeName)
		end

		if not writeWorkerStarted then
			writeWorkerStarted = true

			task.spawn(RunLeaderboardWriteWorker)
		end
	end

	local function IsStatTracked(statName: string): boolean
		if not config.Leaderboard or not config.Leaderboard.Enabled then
			return false
		end

		return trackedStatSet[statName] == true
	end

	local function UpdateAllLeaderboards(): ()
		if not config.Leaderboard.Enabled then return end

		local replicas = getReplicas()
		for _, statName in config.Leaderboard.TrackedStats do
			local store = leaderboardStores[statName]
			if not store then continue end

			for _, player in Players:GetPlayers() do
				if not validatePlayer(player) then continue end

				local replica = replicas[player]
				if not validateReplica(replica) then continue end

				local value = getNestedValue(replica.Data, statName)
				if typeof(value) == "number" then
					local storeValue = (value % 1 == 0) and value or math.floor(value)

					EnqueueLeaderboardWrite(statName, store, player.UserId, storeValue)
				end
			end
		end
	end

	local function GetLeaderboardInternal(statName: string, limit: number): {LeaderboardEntry}
		limit = limit or 100

		local store = leaderboardStores[statName]
		if not store then
			return {}
		end

		local cached = leaderboardCache[statName]
		local currentTime = os.time()

		if cached and (currentTime - cached.lastUpdate) < 15 then
			return table.move(cached.data, 1, math.min(limit, #cached.data), 1, {})
		end

		local success, pages = pcall(function()
			return store:GetSortedAsync(false, math.max(limit, 100))
		end)

		if not success then
			if cached then
				return table.move(cached.data, 1, math.min(limit, #cached.data), 1, {})
			end

			return {}
		end

		local topPlayers: {LeaderboardEntry} = {}
		local data = pages:GetCurrentPage()

		for i, entry in data do
			local userId = tonumber(entry.key)
			if userId then
				topPlayers[#topPlayers + 1] = {
					userId = userId,
					score = entry.value,
					rank = i,
				}
			end
		end

		leaderboardCache[statName] = {
			data = topPlayers,
			lastUpdate = currentTime,
		}

		return table.move(topPlayers, 1, math.min(limit, #topPlayers), 1, {})
	end

	local function UpdateReplicaLeaderboardScore(replica: any, statName: string, score: number): ()
		if not config.Leaderboard.Enabled or not IsStatTracked(statName) then return end
		if not validateReplica(replica) then return end

		local data = replica.Data :: any
		local currentRanks = data._LeaderboardRanks
		if typeof(currentRanks) ~= "table" then
			currentRanks = {}
		end

		local currentStatRank = currentRanks[statName]
		if typeof(currentStatRank) == "table" and currentStatRank.score == score then
			return
		end

		local newRanks = table.clone(currentRanks :: any)
		newRanks[statName] = {
			rank = if typeof(currentStatRank) == "table" then currentStatRank.rank else nil,
			score = score,
		}

		pcall(replica.Set, replica, {"_LeaderboardRanks"}, newRanks)

		local tags = replica.Tags
		local userId = if typeof(tags) == "table" then (tags :: any).UserId else nil
		if typeof(userId) ~= "number" then return end

		local store = leaderboardStores[statName]
		if not store then return end

		local storeValue = (score % 1 == 0) and score or math.floor(score)

		EnqueueLeaderboardWrite(statName, store, userId, storeValue)
	end

	local function EnqueueTrackedStatsFromReplica(replica: any): ()
		if not config.Leaderboard.Enabled then return end
		if not validateReplica(replica) then return end

		local tags = replica.Tags
		local userId = if typeof(tags) == "table" then (tags :: any).UserId else nil
		if typeof(userId) ~= "number" then return end

		for _, statName in config.Leaderboard.TrackedStats do
			local store = leaderboardStores[statName]
			if not store then continue end

			local value = getNestedValue(replica.Data, statName)
			if typeof(value) == "number" then
				local storeValue = (value % 1 == 0) and value or math.floor(value)

				EnqueueLeaderboardWrite(statName, store, userId, storeValue)
			end
		end
	end

	local function SetupLeaderboardRankSaveProtection(profile: any): ()
		if not config.Leaderboard.Enabled then return end

		local runtimeRoots = {"_LeaderboardRanks", "_Leaderboards", "_Leaderboard"}
		DataUtils.AttachRuntimeSaveProtection(profile, runtimeRoots)
	end

	local function SyncPlayerLeaderboardRanks(): ()
		if not config.Leaderboard.Enabled then return end

		local replicas = getReplicas()
		for _, statName in config.Leaderboard.TrackedStats do
			local leaderboard = GetLeaderboardInternal(statName, 100)
			local leaderboardEntries = CopyLeaderboardEntries(leaderboard)

			local playerRanks: {[number]: {rank: number, score: number}} = {}
			for _, entry in leaderboard do
				playerRanks[entry.userId] = {
					rank = entry.rank,
					score = entry.score,
				}
			end

			for _, player in Players:GetPlayers() do
				if not validatePlayer(player) then
					continue
				end

				local replica = replicas[player]
				if not validateReplica(replica) then
					continue
				end

				local rankInfo = playerRanks[player.UserId]
				local currentRanks = (replica.Data :: any)._LeaderboardRanks
				local currentLeaderboards = (replica.Data :: any)._Leaderboards
				if typeof(currentRanks) ~= "table" then
					currentRanks = {}
				end
				if typeof(currentLeaderboards) ~= "table" then
					currentLeaderboards = {}
				end

				local currentStatRank = currentRanks[statName]
				local currentStatLeaderboard = currentLeaderboards[statName]

				if not AreLeaderboardEntriesEqual(currentStatLeaderboard, leaderboardEntries) then
					local newLeaderboards = table.clone(currentLeaderboards)
					newLeaderboards[statName] = CopyLeaderboardEntries(leaderboardEntries)

					pcall(replica.Set, replica, {"_Leaderboards"}, newLeaderboards)
				end

				if rankInfo then
					if typeof(currentStatRank) ~= "table"
						or currentStatRank.rank ~= rankInfo.rank
						or currentStatRank.score ~= rankInfo.score
					then
						local newRanks = table.clone(currentRanks)
						newRanks[statName] = rankInfo

						pcall(replica.Set, replica, {"_LeaderboardRanks"}, newRanks)
					end
				else
					local value = getNestedValue(replica.Data, statName)
					if typeof(value) == "number" then
						if typeof(currentStatRank) ~= "table" or currentStatRank.score ~= value then
							local newRanks = table.clone(currentRanks)
							newRanks[statName] = {
								rank = nil,
								score = value,
							}

							pcall(replica.Set, replica, {"_LeaderboardRanks"}, newRanks)
						end
					end
				end
			end
		end
	end

	local function StartPeriodicUpdates(): ()
		if periodicUpdateThread then
			task.cancel(periodicUpdateThread)

			periodicUpdateThread = nil
		end
		if fullWriteUpdateThread then
			task.cancel(fullWriteUpdateThread)

			fullWriteUpdateThread = nil
		end

		task.spawn(function()
			SyncPlayerLeaderboardRanks()
		end)

		periodicUpdateThread = task.spawn(function()
			while true do
				task.wait(config.Leaderboard.SyncInterval)

				SyncPlayerLeaderboardRanks()
			end
		end)

		local lb = config.Leaderboard :: any
		local fullWriteSec = lb.FullLeaderboardWriteInterval
		if fullWriteSec == nil and type(lb.ReconcileInterval) == "number" then
			fullWriteSec = lb.ReconcileInterval
		end
		if fullWriteSec == nil then
			fullWriteSec = 600
		end
		if type(fullWriteSec) == "number" and fullWriteSec > 0 then
			fullWriteUpdateThread = task.spawn(function()
				while true do
					task.wait(fullWriteSec)

					UpdateAllLeaderboards()
				end
			end)
		end
	end

	local function UpdatePlayerLeaderboard(player: Player): ()
		if not config.Leaderboard.Enabled or not config.Leaderboard.UpdateOnPlayerLeave then return end
		if not validatePlayer(player) then return end

		local replicas = getReplicas()
		local replica = replicas[player]
		if not validateReplica(replica) then return end

		for _, statName in config.Leaderboard.TrackedStats do
			local store = leaderboardStores[statName]
			if not store then continue end

			local value = getNestedValue(replica.Data, statName)
			if typeof(value) == "number" then
				local storeValue = (value % 1 == 0) and value or math.floor(value)

				EnqueueLeaderboardWrite(statName, store, player.UserId, storeValue)
			end
		end
	end

	local function CancelPeriodicUpdates(): ()
		if periodicUpdateThread then
			task.cancel(periodicUpdateThread)

			periodicUpdateThread = nil
		end
		if fullWriteUpdateThread then
			task.cancel(fullWriteUpdateThread)

			fullWriteUpdateThread = nil
		end
	end

	return {
		InitializeLeaderboards = InitializeLeaderboards,
		IsStatTracked = IsStatTracked,
		UpdateAllLeaderboards = UpdateAllLeaderboards,
		GetLeaderboardInternal = GetLeaderboardInternal,
		UpdateReplicaLeaderboardScore = UpdateReplicaLeaderboardScore,
		SetupLeaderboardRankSaveProtection = SetupLeaderboardRankSaveProtection,
		SyncPlayerLeaderboardRanks = SyncPlayerLeaderboardRanks,
		StartPeriodicUpdates = StartPeriodicUpdates,
		UpdatePlayerLeaderboard = UpdatePlayerLeaderboard,
		CancelPeriodicUpdates = CancelPeriodicUpdates,
		EnqueueTrackedStatsFromReplica = EnqueueTrackedStatsFromReplica,
	}
end

return CreateLeaderboardModule


--!strict

local Leaderstats = {}

local INT32_MAX = 2147483647
local INT32_MIN = -2147483648

local function GetAppropriateValueType(value: number): string
	if value % 1 ~= 0 then
		return "NumberValue"
	elseif value > INT32_MAX or value < INT32_MIN then
		return "NumberValue"
	else
		return "IntValue"
	end
end

function Leaderstats.CreateLeaderstats(player: Player, profileData: any, getNestedValue: (data: any, path: string) -> any): ()
	local leaderstatsConfig = profileData.leaderstats
	if not leaderstatsConfig then
		return
	end

	local leaderstats = Instance.new("Folder")
	leaderstats.Name = "leaderstats"
	leaderstats.Parent = player

	for displayName, dataPath in leaderstatsConfig do
		local currentValue = getNestedValue(profileData, dataPath)

		if typeof(currentValue) == "number" then
			local valueType = GetAppropriateValueType(currentValue)
			local valueObject = Instance.new(valueType)
			local numericValue = valueObject :: any

			numericValue.Value = currentValue
			valueObject.Name = displayName
			valueObject.Parent = leaderstats
		else
			local stringValue = Instance.new("StringValue")

			stringValue.Value = tostring(currentValue or "")
			stringValue.Name = displayName
			stringValue.Parent = leaderstats
		end
	end
end

function Leaderstats.UpdateLeaderstatsForPath(player: Player, profileData: any, path: string, newValue: any, validateProfile: (profile: any?) -> boolean): ()
	local leaderstats = player:FindFirstChild("leaderstats")
	if not leaderstats then return end

	local leaderstatsConfig = profileData.leaderstats
	if not leaderstatsConfig then return end

	for displayName, configPath in leaderstatsConfig do
		if configPath == path then
			local valueObject = leaderstats:FindFirstChild(displayName)
			if valueObject then
				if typeof(newValue) == "number" then
					local requiredType = GetAppropriateValueType(newValue)
					local currentType = valueObject.ClassName

					if currentType ~= requiredType then
						valueObject:Destroy()
						local newValueObject = Instance.new(requiredType)
						local newNumericValue = newValueObject :: any

						newNumericValue.Value = newValue
						newValueObject.Name = displayName
						newValueObject.Parent = leaderstats
					else
						local numericValue = valueObject :: any
						numericValue.Value = newValue
					end
				elseif valueObject:IsA("StringValue") then
					(valueObject :: StringValue).Value = tostring(newValue or "")
				end
			end

			break
		end
	end
end

function Leaderstats.SetupLeaderstatsSync(player: Player, replica: any, getProfile: (player: Player) -> any?, validateProfile: (profile: any?) -> boolean, getNestedValue: (data: any, path: string) -> any): ()
	local profile = getProfile(player)
	if not profile or not validateProfile(profile) then
		return
	end

	local leaderstatsConfig = (replica.Data :: any).leaderstats
	if not leaderstatsConfig then return end

	Leaderstats.CreateLeaderstats(player, profile.Data, getNestedValue)

	local originalSet = replica.Set
	local originalSetValues = replica.SetValues

	replica.Set = function(self, path: {string}, value: any)
		local success, err = pcall(originalSet, self, path, value)
		if not success then
			warn(`[PlayerState] Set operation failed for {player.Name}: {err}`)
			return success
		end

		local pathString = table.concat(path, ".")
		Leaderstats.UpdateLeaderstatsForPath(player, profile.Data, pathString, value, validateProfile)
		return success
	end

	replica.SetValues = function(self, path: {string}, values: {[string]: any})
		local success, err = pcall(originalSetValues, self, path, values)
		if not success then
			warn(`[PlayerState] SetValues operation failed for {player.Name}: {err}`)
			return success
		end

		local basePath = table.concat(path, ".")
		for key, value in values do
			local fullPath = basePath == "" and key or basePath .. "." .. key
			Leaderstats.UpdateLeaderstatsForPath(player, profile.Data, fullPath, value, validateProfile)
		end
		return success
	end
end

return Leaderstats


--!strict

local DataStoreService = game:GetService("DataStoreService")

local DataUtils = require(script.Parent.DataUtils)

local LegacyMigration = {}

export type MigrationResult = {
	applied: boolean,
	legacyFound: boolean,
	markerWritten: boolean,
	keyUsed: string?,
	strategy: string,
	markerPath: string,
	continuous: boolean,
	reason: string,
}

local function DeepClone(value: any): any
	if typeof(value) ~= "table" then
		return value
	end

	local cloned = {}
	for key, child in value do
		cloned[key] = DeepClone(child)
	end

	return cloned
end

local function DeepEqual(a: any, b: any): boolean
	if a == b then
		return true
	end

	if typeof(a) ~= "table" or typeof(b) ~= "table" then
		return false
	end

	for key, value in a do
		if not DeepEqual(value, b[key]) then
			return false
		end
	end

	for key, value in b do
		if not DeepEqual(a[key], value) then
			return false
		end
	end

	return true
end

local function ParsePath(path: string): {string}
	local keys = {}
	for segment in string.gmatch(path, "[^%.]+") do
		table.insert(keys, segment)
	end

	return keys
end

local function GetPath(data: any, path: string): any
	if typeof(data) ~= "table" then
		return nil
	end

	local keys = ParsePath(path)
	if #keys == 0 then
		return nil
	end

	return DataUtils.GetNestedValue(data, keys)
end

local function SetPath(data: any, path: string, value: any): boolean
	if typeof(data) ~= "table" then
		return false
	end

	local keys = ParsePath(path)
	if #keys == 0 then
		return false
	end

	local current = data
	for i = 1, #keys - 1 do
		local key = keys[i]
		if typeof(current[key]) ~= "table" then
			current[key] = {}
		end
		current = current[key]
	end

	current[keys[#keys]] = value

	return true
end

local function GetConfigValue(config: any, key: string, defaultValue: any): any
	if typeof(config) ~= "table" then
		return defaultValue
	end

	if config[key] ~= nil then
		return config[key]
	end

	return defaultValue
end

local function NormalizeStrategy(value: any): string
	if typeof(value) ~= "string" then
		return "Auto"
	end

	local normalized = string.lower(value)
	if normalized == "auto" then
		return "Auto"
	elseif normalized == "overwrite" then
		return "Overwrite"
	elseif normalized == "keepnew" then
		return "KeepNew"
	elseif normalized == "manual" then
		return "Manual"
	elseif normalized == "continuousoverwrite" then
		return "ContinuousOverwrite"
	end

	return value
end

local function NormalizeLegacyConfig(legacyConfig: any): any
	if typeof(legacyConfig) ~= "table" then
		return nil
	end

	local advanced = if typeof(legacyConfig.advanced) == "table" then legacyConfig.advanced else {}
	local advancedMarker = if typeof(advanced.marker) == "table" then advanced.marker else {}
	local advancedPayload = if typeof(advanced.payload) == "table" then advanced.payload else {}
	local advancedManual = if typeof(advanced.manual) == "table" then advanced.manual else {}
	local advancedFetch = if typeof(advanced.fetch) == "table" then advanced.fetch else {}

	local normalized = {}

	normalized.Enabled = GetConfigValue(legacyConfig, "enabled", false)
	normalized.DataStoreName = GetConfigValue(legacyConfig, "store", "")
	normalized.Scope = GetConfigValue(legacyConfig, "scope", nil)
	normalized.MergeStrategy = NormalizeStrategy(GetConfigValue(legacyConfig, "strategy", "Auto"))
	normalized.KeyCandidates = GetConfigValue(legacyConfig, "keys", {"{UserId}"})
	normalized.ProfileStorePayload = false
	normalized.Transform = nil
	normalized.MigrationMarkerPath = "_Migration.LegacyDataStoreV1"
	normalized.WriteMarkerOnNoLegacyData = true
	normalized.FetchLegacyData = nil

	local payloadMode = if typeof(advancedPayload.mode) == "string" then string.lower(advancedPayload.mode) else nil
	if payloadMode == "profilestore" then
		normalized.ProfileStorePayload = true
	elseif payloadMode == "auto" then
		normalized.ProfileStorePayload = false
	end

	if advancedMarker.path ~= nil then
		normalized.MigrationMarkerPath = advancedMarker.path
	end
	if advancedMarker.writeOnNoData ~= nil then
		normalized.WriteMarkerOnNoLegacyData = advancedMarker.writeOnNoData
	end
	if advancedManual.transform ~= nil then
		normalized.Transform = advancedManual.transform
	end
	if advancedFetch.custom ~= nil then
		normalized.FetchLegacyData = advancedFetch.custom
	end

	return normalized
end

local function ResolveCandidateKeys(userId: number, candidates: {string}?): {string}
	local resolved = {}
	local seen: {[string]: boolean} = {}
	local userIdString = tostring(userId)

	local patterns: {string}
	if typeof(candidates) == "table" and #candidates > 0 then
		patterns = candidates
	else
		patterns = {"{UserId}"}
	end

	for _, candidate in patterns do
		if typeof(candidate) ~= "string" or candidate == "" then
			continue
		end

		local key = candidate:gsub("{UserId}", userIdString)
		if key ~= "" and not seen[key] then
			table.insert(resolved, key)
			seen[key] = true
		end
	end

	return resolved
end

local function ExtractLegacyPayload(payload: any, treatAsProfileStore: boolean): any
	if typeof(payload) ~= "table" then
		return nil
	end

	if treatAsProfileStore then
		if typeof(payload.Data) == "table" then
			return payload.Data
		end
		return nil
	end

	if typeof(payload.Data) == "table" then
		return payload.Data
	end

	return payload
end

local function MergeOverwrite(currentData: any, legacyData: any): any
	if typeof(legacyData) ~= "table" then
		return currentData
	end

	for key, legacyValue in legacyData do
		if typeof(legacyValue) == "table" and typeof(currentData[key]) == "table" then
			MergeOverwrite(currentData[key], legacyValue)
		else
			currentData[key] = DeepClone(legacyValue)
		end
	end

	return currentData
end

local function MergeKeepNew(currentData: any, _legacyData: any): any
	return currentData
end

local function MergeAuto(currentData: any, templateData: any, legacyData: any): any
	if typeof(legacyData) ~= "table" then
		return currentData
	end

	for key, legacyValue in legacyData do
		local currentValue = currentData[key]
		local templateValue = if typeof(templateData) == "table" then templateData[key] else nil

		if typeof(legacyValue) == "table" and typeof(currentValue) == "table" then
			MergeAuto(currentValue, templateValue, legacyValue)
		elseif currentValue == nil or DeepEqual(currentValue, templateValue) then
			currentData[key] = DeepClone(legacyValue)
		end
	end

	return currentData
end

local function GetLegacyStore(legacyConfig: any): any?
	local storeName = legacyConfig and legacyConfig.DataStoreName
	if typeof(storeName) ~= "string" or storeName == "" then
		return nil
	end

	local scope = legacyConfig.Scope
	if scope ~= nil and typeof(scope) ~= "string" then
		scope = nil
	end

	local success, store = pcall(function()
		if scope ~= nil and scope ~= "" then
			return DataStoreService:GetDataStore(storeName, scope)
		end

		return DataStoreService:GetDataStore(storeName)
	end)

	if not success then
		warn(`[PlayerState] Failed to initialize legacy DataStore "{storeName}"`)

		return nil
	end

	return store
end

local function FetchLegacyData(store: any, keyCandidates: {string}): (any?, string?)
	for _, key in keyCandidates do
		local success, data = pcall(function()
			return store:GetAsync(key)
		end)

		if success and data ~= nil then
			return data, key
		end
	end

	return nil, nil
end

function LegacyMigration.TryMigrateProfile(
	profileData: any,
	template: any,
	userId: number,
	player: Player?,
	legacyConfig: any,
	runtimeRoots: {string}
): MigrationResult
	local result: MigrationResult = {
		applied = false,
		legacyFound = false,
		markerWritten = false,
		keyUsed = nil,
		strategy = "Auto",
		markerPath = "_Migration.LegacyDataStoreV1",
		continuous = false,
		reason = "disabled",
	}

	if typeof(profileData) ~= "table" or typeof(template) ~= "table" then
		return result
	end

	local normalizedConfig = NormalizeLegacyConfig(legacyConfig)
	if typeof(normalizedConfig) ~= "table" or not normalizedConfig.Enabled then
		return result
	end

	local rawStrategy = NormalizeStrategy(normalizedConfig.MergeStrategy)

	local continuousOverwrite = rawStrategy == "ContinuousOverwrite"
	result.continuous = continuousOverwrite

	local markerPath = normalizedConfig.MigrationMarkerPath
	if typeof(markerPath) ~= "string" or markerPath == "" then
		markerPath = "_Migration.LegacyDataStoreV1"
	end
	result.markerPath = markerPath
	result.strategy = if continuousOverwrite then "Overwrite" else rawStrategy

	if not continuousOverwrite and GetPath(profileData, markerPath) == true then
		result.reason = "already_marked"
		return result
	end

	local keyCandidates = ResolveCandidateKeys(userId, normalizedConfig.KeyCandidates)
	local customFetcher = normalizedConfig.FetchLegacyData
	local legacyPayload
	local keyUsed

	if typeof(customFetcher) == "function" then
		local ok, payloadResult, keyResult = pcall(customFetcher, keyCandidates, userId, player)
		if ok then
			legacyPayload = payloadResult
			keyUsed = keyResult
		else
			warn("[PlayerState] LegacyDataStore.FetchLegacyData failed")
			result.reason = "fetch_failed"
		end
	else
		local store = GetLegacyStore(normalizedConfig)
		if not store then
			result.reason = "store_unavailable"
			return result
		end
		legacyPayload, keyUsed = FetchLegacyData(store, keyCandidates)
	end

	result.keyUsed = keyUsed
	result.legacyFound = legacyPayload ~= nil

	local writeMarkerOnNoData = normalizedConfig.WriteMarkerOnNoLegacyData
	if typeof(writeMarkerOnNoData) ~= "boolean" then
		writeMarkerOnNoData = true
	end

	if legacyPayload == nil then
		if writeMarkerOnNoData and not continuousOverwrite then
			SetPath(profileData, markerPath, true)
			result.markerWritten = true
		end
		if result.reason ~= "fetch_failed" then
			result.reason = "no_legacy_data"
		end
		return result
	end

	local treatAsProfileStore = normalizedConfig.ProfileStorePayload == true
	local legacyData = ExtractLegacyPayload(legacyPayload, treatAsProfileStore)
	if typeof(legacyData) ~= "table" then
		if writeMarkerOnNoData and not continuousOverwrite then
			SetPath(profileData, markerPath, true)
			result.markerWritten = true
		end
		result.reason = "invalid_payload"
		return result
	end

	legacyData = DeepClone(legacyData)
	DataUtils.RemoveRuntimeRoots(legacyData, runtimeRoots)

	local strategy = if continuousOverwrite then "Overwrite" else rawStrategy

	if strategy == "Overwrite" then
		MergeOverwrite(profileData, legacyData)
		result.reason = "applied"
	elseif strategy == "KeepNew" then
		MergeKeepNew(profileData, legacyData)
		result.reason = "applied"
	elseif strategy == "Manual" then
		local transform = normalizedConfig.Transform
		if typeof(transform) ~= "function" then
			warn("[PlayerState] LegacyDataStore.MergeStrategy is Manual, but Transform is missing")
			result.reason = "manual_transform_missing"
		else
			local success, transformed = pcall(transform, legacyData, template, player)
			if success and typeof(transformed) == "table" then
				DataUtils.RemoveRuntimeRoots(transformed, runtimeRoots)
				MergeOverwrite(profileData, transformed)
				result.reason = "applied"
			elseif not success then
				warn("[PlayerState] Legacy data transform failed")
				result.reason = "manual_transform_failed"
			end
		end
	else
		MergeAuto(profileData, template, legacyData)
		result.reason = "applied"
	end

	if not continuousOverwrite then
		SetPath(profileData, markerPath, true)
		result.markerWritten = true
	end

	result.applied = true
	if result.reason == "disabled" or result.reason == "no_legacy_data" or result.reason == "already_marked" then
		result.reason = "applied"
	end

	return result
end

return LegacyMigration


--!strict

local Players = game:GetService("Players")
local DataUtils = require(script.Parent.DataUtils)

local OfflineData = {}

function OfflineData.GetOfflineData(
	userId: number,
	config: any,
	profileStore: any,
	getAllForPlayer: (player: Player) -> any?
): any?
	if not userId or userId == 0 then
		warn("[PlayerState] Invalid userId for GetOfflineData")
		return nil
	end

	for _, player in (Players:GetPlayers()) do
		if player.UserId == userId then
			return getAllForPlayer(player)
		end
	end

	local profileKey = config.Profile.Key .. "_" .. tostring(userId)
	local offlineProfile = profileStore:GetAsync(profileKey)

	if offlineProfile then
		return offlineProfile.Data
	end

	return nil
end

function OfflineData.SetOfflineData(
	userId: number,
	path: string,
	value: any,
	config: any,
	profileStore: any,
	setPathForPlayer: (player: Player, path: string, value: any) -> boolean,
	getPathKeys: (path: string) -> {string},
	cleanData: (data: any, template: any) -> ()
): boolean
	if not userId or userId == 0 then
		warn("[PlayerState] Invalid userId for SetOfflineData")
		return false
	end

	for _, player in Players:GetPlayers() do
		if player.UserId == userId then
			return setPathForPlayer(player, path, value)
		end
	end

	local profileKey = config.Profile.Key .. "_" .. tostring(userId)

	local offlineProfile = profileStore:GetAsync(profileKey)

	if offlineProfile and offlineProfile.Session then
		local success = profileStore:MessageAsync(profileKey, {
			Type = "SetData",
			Path = path,
			Value = value,
		})

		if success then
			print(`[PlayerState] Sent data change message to active profile for user {userId}`)
			return true
		else
			warn(`[PlayerState] Failed to send message to active profile for user {userId}`)
			return false
		end
	end

	local success, profile = pcall(function()
		return profileStore:StartSessionAsync(profileKey)
	end)

	if not success or not profile then
		warn(`[PlayerState] Failed to start profile session for offline user {userId}`)
		return false
	end

	profile:AddUserId(userId)
	profile:Reconcile()

	cleanData(profile.Data, config.Profile.Template)
	local runtimeRoots = DataUtils.ResolveRuntimeNonPersistentRoots(config.RuntimeNonPersistentRoots)
	DataUtils.RemoveRuntimeRoots(profile.Data, runtimeRoots)

	local setSuccess = pcall(function()
		if not string.find(path, ".", 1, true) then
			profile.Data[path] = value
		else
			local pathKeys = getPathKeys(path)
			local current = profile.Data

			for i = 1, #pathKeys - 1 do
				local key = pathKeys[i]

				if not current[key] then
					current[key] = {}
				end

				current = current[key]
			end

			current[pathKeys[#pathKeys]] = value
		end
	end)

	if not setSuccess then
		warn(`[PlayerState] Failed to set offline data for user {userId} at path {path}`)
		profile:EndSession()
		return false
	end

	local capturedRuntimeData = DataUtils.CaptureAndRemoveRuntimeRoots(profile.Data, runtimeRoots)
	profile:Save()
	DataUtils.RestoreRuntimeRoots(profile.Data, capturedRuntimeData)
	profile:EndSession()

	print(`[PlayerState] Successfully set offline data for user {userId}: {path} = {value}`)
	return true
end

return OfflineData


--!strict

local Players = game:GetService("Players")

local Validation = {}

function Validation.ValidatePlayer(player: Player): boolean
	return player and player.Parent == Players
end

function Validation.ValidateReplica(replica: any?): boolean
	return replica ~= nil and replica:IsActive()
end

function Validation.ValidateProfile(profile: any?): boolean
	return profile ~= nil and profile:IsActive()
end

return Validation


--!strict

export type PathCacheModule = {
	GetPathKeys: (path: string) -> {string},
	ClearCache: () -> (),
}

local function CreatePathCache(maxCacheSize: number): PathCacheModule
	local pathCache: {[string]: {string}} = {}
	local pathCacheSize: number = 0

	local function GetPathKeys(path: string): {string}
		local cached = pathCache[path]
		if cached then
			return cached
		end

		local keys = string.split(path, ".")
		pathCache[path] = keys
		pathCacheSize += 1

		if pathCacheSize > maxCacheSize then
			local clearCount = math.floor(maxCacheSize * 0.2)
			local cleared = 0

			for cachedPath in pathCache do
				pathCache[cachedPath] = nil
				cleared += 1
				if cleared >= clearCount then
					break
				end
			end

			pathCacheSize -= cleared
		end

		return keys
	end

	local function ClearCache(): ()
		table.clear(pathCache)
		pathCacheSize = 0
	end

	return {
		GetPathKeys = GetPathKeys,
		ClearCache = ClearCache,
	}
end

return CreatePathCache


--!strict

local Utils = {}

function Utils.DeepClone(value: any): any
	local copy = {}

	for key, entry in value do
		copy[key] = if typeof(entry) == "table" then Utils.DeepClone(entry) else entry
	end

	return copy
end

return Utils


--!strict

--[[
	PlayerStateClient - Client-side player data access

	Documentation: https://playerstate.netlify.app/api/client.html
--]]

local RunService = game:GetService("RunService")

local dataReadyEvent = Instance.new("BindableEvent")

local IS_CLIENT = RunService:IsClient()
if not IS_CLIENT then
	error("[PlayerStateClient] This module can only be used on the client")
end

local DefaultData = require(script.Parent.DefaultData)
local ReplicaClient = require(script.Parent.Replica.ReplicaClient)
local Config = require(script.Parent.PlayerStateConfig)

local Utils = require(script.Parent.Internal.Shared.Utils)
local CreatePathCache = require(script.Parent.Internal.Client.PathCache)
local CreateCacheModule = require(script.Parent.Internal.Client.Cache)
local Validation = require(script.Parent.Internal.Client.Validation)
local Leaderboard = require(script.Parent.Internal.Client.Leaderboard)
local CreateConnectionModule = require(script.Parent.Internal.Client.Connection)

export type PlayerData = typeof(DefaultData)
export type ReplicaInstance = ReplicaClient.Replica
export type ValuePath = string
export type LeaderboardInfo = {
	rank: number?,
	score: number?,
	statName: string,
}
export type LeaderboardEntry = {
	userId: number,
	score: number,
	rank: number,
}
export type ChangeInfo = {
	action: "Set" | "SetValues" | "TableInsert" | "TableRemove",
	path: {string},
	index: number?,
}

export type PlayerStateClient = {
	Get: (key: string) -> any,
	GetPath: (path: ValuePath) -> any,
	GetFromDict: (dictPath: ValuePath, key: string | number) -> any,
	Clone: (value: any, deep: boolean?) -> any,
	OnChanged: (pathOrKey: string, callback: (newValue: any, oldValue: any, info: ChangeInfo | {string}?) -> ()) -> ReplicaClient.Connection?,
	GetReplica: () -> ReplicaInstance?,
	GetAll: () -> PlayerData?,
	IsReady: () -> boolean,
	ClearCache: () -> (),
	GetLeaderboardInfo: (statName: string) -> LeaderboardInfo?,
	GetLeaderboard: (statName: string, limit: number?) -> {LeaderboardEntry},
}

local localReplica: ReplicaInstance?
local dataReady: boolean = false

local PathCacheModule = CreatePathCache(Config.Client.MaxPathCacheSize)
local CacheModule = CreateCacheModule(Config.Client.CacheDuration, Config.Client.CacheCleanupInterval)
local ConnectionModule = CreateConnectionModule()
local AUTO_CLONE_TABLES = Config.Client.AutoCloneTables

ReplicaClient.OnNew("PlayerData", function(replica)
	localReplica = replica
	dataReady = true

	CacheModule.ClearAllCaches()

	dataReadyEvent:Fire()

	print("[PlayerState] Client data is ready")
end)
ReplicaClient.RequestData()

local function GetNestedValue(data: any, path: string): any
	local cached = CacheModule.GetFromNestedCache(path)
	if cached ~= nil then
		return cached
	end

	local keys = PathCacheModule.GetPathKeys(path)
	local current = data

	for _, key in keys do
		if typeof(current) ~= "table" or current[key] == nil then
			CacheModule.SetInNestedCache(path, nil)
			return nil
		end
		current = current[key]
	end

	CacheModule.SetInNestedCache(path, current)

	return current
end

local function WaitForData(): boolean
	return Validation.WaitForData(
		function()
			return dataReady
		end,
		Validation.ValidateReplica,
		function()
			return localReplica
		end,
		dataReadyEvent
	)
end

local PlayerState: PlayerStateClient = {} :: PlayerStateClient

function PlayerState.Get(key: string): any
	if not WaitForData() then
		return nil
	end

	if not Validation.ValidateReplica(localReplica) then
		return nil
	end

	if typeof(key) == "Instance" and key:IsA("Player") then
		warn(`[PlayerState] Player object detected as first parameter in Get(). Client-side Get() only takes a key string.`)
		return nil
	end
	if string.find(key, "%.") then
		warn(`[PlayerState] Detected path syntax in Get() call. Use GetPath() instead for nested paths like "{key}"`)
		return false
	end

	local cachedValue = CacheModule.GetFromValueCache(key)
	if cachedValue ~= nil then
		if AUTO_CLONE_TABLES and typeof(cachedValue) == "table" then
			return Utils.DeepClone(cachedValue)
		end
		return cachedValue
	end

	local value = (localReplica :: ReplicaInstance).Data[key]

	if AUTO_CLONE_TABLES and typeof(value) == "table" then
		value = Utils.DeepClone(value)
	end

	CacheModule.SetInValueCache(key, value)

	CacheModule.CleanupCache()

	return value
end

function PlayerState.Clone(value: any, deep: boolean?): any
	if typeof(value) ~= "table" then
		return value
	end

	if deep then
		return Utils.DeepClone(value)
	end

	return table.clone(value)
end

function PlayerState.GetPath(path: ValuePath): any
	if not WaitForData() then
		return nil
	end

	if not Validation.ValidateReplica(localReplica) then
		return nil
	end

	if typeof(path) == "Instance" and path:IsA("Player") then
		warn(`[PlayerState] Player object detected as first parameter in GetPath(). Client-side GetPath() only takes a path string. Use server-side PlayerState.GetPath(player, path) instead.`)
		return nil
	end

	local replicaData = (localReplica :: ReplicaInstance).Data

	local result
	if not string.find(path, ".", 1, true) then
		result = replicaData[path]
	else
		result = GetNestedValue(replicaData, path)
	end

	CacheModule.CleanupCache()

	if AUTO_CLONE_TABLES and typeof(result) == "table" then
		return Utils.DeepClone(result)
	end

	return result
end

function PlayerState.GetFromDict(dictPath: ValuePath, key: string | number): any
	if not WaitForData() then
		return nil
	end

	if not Validation.ValidateReplica(localReplica) then
		return nil
	end

	if typeof(dictPath) == "Instance" and dictPath:IsA("Player") then
		warn(`[PlayerState] Player object detected as first parameter in GetFromDict(). Client-side GetFromDict() only takes a dictPath string. Use server-side PlayerState.GetFromDict(player, dictPath, key) instead.`)
		return nil
	end

	local stringKey = if typeof(key) == "number" then tostring(key) else key

	local replicaData = (localReplica :: ReplicaInstance).Data
	local dictKeys = PathCacheModule.GetPathKeys(dictPath)
	local current: any = replicaData

	for _, segment in dictKeys do
		if typeof(current) ~= "table" then
			CacheModule.CleanupCache()
			return nil
		end
		current = current[segment]
		if current == nil then
			CacheModule.CleanupCache()
			return nil
		end
	end

	if typeof(current) ~= "table" then
		CacheModule.CleanupCache()
		return nil
	end

	local result = current[stringKey]

	CacheModule.CleanupCache()

	if AUTO_CLONE_TABLES and typeof(result) == "table" then
		return Utils.DeepClone(result)
	end

	return result
end

function PlayerState.OnChanged(pathOrKey: string, callback: (newValue: any, oldValue: any, fullPath: {string}?) -> ()): ReplicaClient.Connection?
	local function createConnection(replica: ReplicaInstance): ReplicaClient.Connection?
		if not Validation.ValidateReplica(replica) then
			return nil
		end

		local connection: ReplicaClient.Connection?

		if pathOrKey == "." then
			connection = replica:OnChange(function(action, path, param1, param2)
				CacheModule.ClearAllCaches()

				if action == "Set" then
					local newValue, oldValue = param1, param2
					callback(newValue, oldValue, {action = action, path = path})
				elseif action == "SetValues" then
					local values = param1
					callback(values, nil, {action = action, path = path})
				elseif action == "TableInsert" then
					local value, index = param1, param2
					callback(value, nil, {action = action, path = path, index = index})
				elseif action == "TableRemove" then
					local removedValue, index = param1, param2
					callback(nil, removedValue, {action = action, path = path, index = index})
				end
			end)
		else
			local pathKeys = PathCacheModule.GetPathKeys(pathOrKey)

			connection = replica:OnChange(function(action, path, param1, param2)
				local isExactMatch = #path == #pathKeys
				local isSubPath = #path > #pathKeys
				local pathMatches = false

				if isExactMatch or isSubPath then
					pathMatches = true

					for i = 1, #pathKeys do
						if path[i] ~= pathKeys[i] then
							pathMatches = false
							break
						end
					end
				end

				if pathMatches then
					CacheModule.InvalidateForMutationPath(path)

					if action == "Set" then
						if isExactMatch then
							local newValue, oldValue = param1, param2

							callback(newValue, oldValue, pathKeys)
						else
							local currentValue = PlayerState.GetPath(pathOrKey)

							callback(currentValue, nil, {action = action, path = path})
						end
					elseif action == "SetValues" then
						if isExactMatch then
							local values = param1

							callback(values, nil, {action = action, path = path})
						else
							local currentValue = PlayerState.GetPath(pathOrKey)

							callback(currentValue, nil, {action = action, path = path})
						end
					elseif action == "TableInsert" then
						local value, index = param1, param2

						callback(value, nil, {action = action, path = path, index = index})
					elseif action == "TableRemove" then
						local removedValue, index = param1, param2

						callback(nil, removedValue, {action = action, path = path, index = index})
					end
				end
			end)
		end

		if connection then
			ConnectionModule.AddConnection(connection)
		end

		return connection
	end

	if localReplica then
		return createConnection(localReplica)
	else
		local connection: ReplicaClient.Connection?

		local newReplicaConnection = ReplicaClient.OnNew("PlayerData", function(replica)
			connection = createConnection(replica)
		end)

		ConnectionModule.AddConnection(newReplicaConnection)

		return connection
	end
end

function PlayerState.GetReplica(): ReplicaInstance?
	if not Validation.ValidateReplica(localReplica) then
		return nil
	end

	return localReplica
end

function PlayerState.GetAll(): PlayerData?
	if not WaitForData() then
		return nil
	end

	if not Validation.ValidateReplica(localReplica) then
		return nil
	end

	return (localReplica :: ReplicaInstance).Data
end

function PlayerState.IsReady(): boolean
	return dataReady and Validation.ValidateReplica(localReplica)
end

function PlayerState.ClearCache(): ()
	PathCacheModule.ClearCache()
	CacheModule.ClearAllCaches()
end

function PlayerState.GetLeaderboardInfo(statName: string): LeaderboardInfo?
	if not WaitForData() then
		return nil
	end

	if not Validation.ValidateReplica(localReplica) then
		return nil
	end

	return Leaderboard.GetLeaderboardInfo(localReplica, statName)
end

function PlayerState.GetLeaderboard(statName: string, limit: number?): {LeaderboardEntry}
	if not WaitForData() then
		return {}
	end

	if not Validation.ValidateReplica(localReplica) then
		return {}
	end

	return Leaderboard.GetLeaderboard(localReplica, statName, limit)
end

return PlayerState


--[[
	PlayerStateConfig - Configuration settings for PlayerState system

	Documentation: https://playerstate.netlify.app/setup.html#playerstateconfig-module
--]]

-- Libraries
local DefaultData = require(script.Parent.DefaultData)

local Config = {
	Server = {
		Profile = {
			-- Unique key for ProfileStore data identification
			Key = "PlayerData",

			-- Data template/structure (automatically pulled from DefaultData)
			Template = DefaultData,
		},

		DataStore = {
			-- DataStore name (change for different versions/environments)
			Name = "PlayerData_0.01",

			-- Scope: "Production" for live data, "Testing" for mock data that won't save
			Scope = "Production",
		},

		-- Check documentation migration instructions for info
		LegacyDataStore = {
			enabled = false,
			store = "OldStore",
			strategy = "Auto", -- Auto | Overwrite | KeepNew | Manual | ContinuousOverwrite
			keys = {
				"{UserId}",
				"PlayerData_{UserId}",
			},
			advanced = {
				scope = nil,
				marker = {
					path = "_Migration.LegacyDataStoreV1",
					writeOnNoData = true,
				},
				payload = {
					mode = "auto", -- auto | profileStore
				},
				manual = {
					transform = nil, -- function(legacyData, template, player) -> table
				},
				fetch = {
					custom = nil, -- function(keyCandidates, userId, player) -> payload, keyUsed
				},
			},
		},

		-- Allows you to load another user's data profile in Studio for debugging purposes
		ViewedUser = {
			-- Studio-only debug profile override:
			-- 0 = disabled, any other value loads that user's profile key in Studio
			ViewedUserId = 0,

			-- If true, blocks all data mutation APIs while viewing another user's data
			ReadOnly = true,
		},

		-- If it will remove fields not in template during data reconciliation
		CleanExtraFields = false,

		-- If enabled, Get/GetPath/GetFromDict will return deep copies of tables instead of references
		-- This can be resource heavy so I would not recommend enabling it unless you really need it
		AutoCloneTables = false,

		-- How long to wait for player data to load before timing out (in seconds)
		DataWaitTimeout = 10,

		RuntimeNonPersistentRoots = {
			"session",
			"_LeaderboardRanks",
			"_Leaderboards",
			"_Leaderboard",
		},

		-- Batch processing settings
		BatchDelay = 0.03,      -- Seconds to wait before processing batch (lower = faster, higher = more efficient)
		BatchSize = 20,         -- Auto-flush batch when this many operations are queued
		MaxCacheSize = 1000,    -- Maximum number of cached path keys

		Leaderboard = {
			Enabled = true,

			-- DataStore name for leaderboards
			DataStoreName = "PlayerState_Leaderboards",

			-- Which data fields should have leaderboards (auto-updated when values change)
			TrackedStats = {
				"Coins",
				-- Add stat names here to create leaderboards
				-- Examples: "coins", "level", "score", "Inventory.Pills"
			},

			-- How often to sync leaderboard data (in seconds)
			SyncInterval = 60,
			UpdateOnPlayerLeave = true, -- Always update leaderboards when player leaves
		},
	},

	Client = {
		-- How long to cache values (in seconds)
		CacheDuration = 0.1,

		-- How often to clean up expired cache entries (in seconds)
		CacheCleanupInterval = 30,

		-- Maximum number of cached values before cleanup
		MaxCacheSize = 1000,

		-- Maximum number of cached path strings before cleanup
		MaxPathCacheSize = 1000,

		-- If enabled, Get/GetPath/GetFromDict will return deep copies of tables instead of references
		AutoCloneTables = false,
	},
}

return Config