--!strict

--[[
	PlayerStateClient - Server-side player data access

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

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

local IS_SERVER = RunService:IsServer()
if not IS_SERVER then
	error("[PlayerStateServer] This module can only be used on the server")
end

local DefaultData = require(script.Parent.DefaultData)
local ProfileService = require(script.Parent.ProfileStore)
local ReplicaServer = require(script.Parent.Replica.ReplicaServer)
local Signal = require(script.Parent.Replica.Signal)
local Config = require(script.Parent.PlayerStateConfig)

local CreatePathCache = require(script.Parent.Internal.Shared.PathCache)
local Utils = require(script.Parent.Internal.Shared.Utils)
local Validation = require(script.Parent.Internal.Server.Validation)
local DataUtils = require(script.Parent.Internal.Server.DataUtils)
local ArrayOps = require(script.Parent.Internal.Server.Array)
local DictOps = require(script.Parent.Internal.Server.Dict)
local CreateBatchModule = require(script.Parent.Internal.Server.Batch)
local Leaderstats = require(script.Parent.Internal.Server.Leaderstats)
local CreateLeaderboardModule = require(script.Parent.Internal.Server.Leaderboard)
local OfflineData = require(script.Parent.Internal.Server.OfflineData)
local DataWipe = require(script.Parent.Internal.Server.DataWipe)
local LegacyMigration = require(script.Parent.Internal.Server.LegacyMigration)

export type PlayerData = typeof(DefaultData)

export type ProfileStoreProfile = {
	Data: PlayerData,
	OnSessionEnd: RBXScriptSignal,
	AddUserId: (self: ProfileStoreProfile, userId: number) -> (),
	Reconcile: (self: ProfileStoreProfile) -> (),
	EndSession: (self: ProfileStoreProfile) -> (),
	IsActive: (self: ProfileStoreProfile) -> boolean,
	MessageHandler: (self: ProfileStoreProfile, fn: (message: any, processed: () -> ()) -> ()) -> (),
}

export type ProfileStoreInstance = any

export type ReplicaInstance = ReplicaServer.Replica

export type ArrayPath = string
export type DictPath = string
export type ValuePath = string
export type ChangeInfo = {
	action: "Set" | "SetValues" | "TableInsert" | "TableRemove",
	path: {any},
	index: number?,
}
export type ChangeCallback = (newValue: any, oldValue: any, info: ChangeInfo | {any}?) -> ()

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

export type BatchUpdateOperation = {
	path: ValuePath,
	operation: "Increment" | "Decrement",
	amount: number?,
}

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

export type MigrationResultInfo = LegacyMigration.MigrationResult

export type PlayerStateServer = {
	SaveData: (player: Player) -> boolean,
	Init: (player: Player, existingData: PlayerData?) -> boolean,
	Set: (player: Player, key: string, value: any) -> boolean,
	Get: (player: Player, key: string) -> any,
	SetPath: (player: Player, path: ValuePath, value: any) -> boolean,
	GetPath: (player: Player, path: ValuePath) -> any,
	OnChanged: (player: Player, pathOrKey: string, callback: ChangeCallback) -> Connection?,
	SetValues: (player: Player, values: {[string]: any}) -> boolean,
	BatchSetValues: (player: Player, operations: {BatchOperation}) -> boolean,
	BatchUpdateValues: (player: Player, operations: {BatchUpdateOperation}) -> boolean,
	Increment: (player: Player, path: ValuePath, amount: number?) -> boolean,
	Decrement: (player: Player, path: ValuePath, amount: number?) -> boolean,
	FlushBatch: (player: Player) -> boolean,
	AddToArray: (player: Player, arrayPath: ArrayPath, item: any) -> boolean,
	RemoveFromArray: (player: Player, arrayPath: ArrayPath, index: number) -> boolean,
	UpdateArrayItem: (player: Player, arrayPath: ArrayPath, index: number, newItem: any) -> boolean,
	SetInDict: (player: Player, dictPath: DictPath, key: string | number, value: any) -> boolean,
	GetFromDict: (player: Player, dictPath: DictPath, key: string | number) -> any,
	RemoveFromDict: (player: Player, dictPath: DictPath, key: string | number) -> boolean,
	Clone: (value: any, deep: boolean?) -> any,
	GetReplica: (player: Player) -> ReplicaInstance?,
	GetProfile: (player: Player) -> ProfileStoreProfile?,
	GetAll: (player: Player) -> PlayerData?,
	GetOfflineData: (userId: number) -> PlayerData?,
	IsPlayerDataReady: (player: Player) -> boolean,
	ClearPathCache: () -> (),
	UpdateLeaderboard: (player: Player, statName: string, score: number) -> boolean,
	GetLeaderboard: (statName: string, topCount: number) -> {LeaderboardEntry},
	GetPlayerRank: (player: Player, statName: string) -> number?,
	SetOfflineData: (userId: number, path: string, value: any) -> boolean,
	WipePlayerData: (player: Player) -> boolean,
	WipeOfflinePlayerData: (userId: number) -> boolean,

	BeforeSave: BeforeSaveSignal,
	ProfileLoaded: ProfileLoadedSignal,
	ProfileUnloaded: ProfileUnloadedSignal,
	MigrationResult: MigrationResultSignal,
}

export type Connection = {
	Disconnect: (self: Connection) -> ()
}
export type Signal = {
	Connect: (self: Signal, listener: (...any) -> ()) -> (Connection),
	Fire: (self: Signal, ...any) -> (),
	GetListenerCount: (self: Signal) -> (number),
}
export type BeforeSaveSignal = {
	Connect: (self: BeforeSaveSignal, listener: (player: Player, data: PlayerData) -> ()) -> (Connection),
	Fire: (self: BeforeSaveSignal, player: Player, data: PlayerData) -> (),
	GetListenerCount: (self: BeforeSaveSignal) -> (number),
}
export type ProfileLoadedSignal = {
	Connect: (self: ProfileLoadedSignal, listener: (player: Player, data: PlayerData) -> ()) -> (Connection),
	Fire: (self: ProfileLoadedSignal, player: Player, data: PlayerData) -> (),
	GetListenerCount: (self: ProfileLoadedSignal) -> (number),
}
export type ProfileUnloadedSignal = {
	Connect: (self: ProfileUnloadedSignal, listener: (player: Player, data: PlayerData) -> ()) -> (Connection),
	Fire: (self: ProfileUnloadedSignal, player: Player, data: PlayerData) -> (),
	GetListenerCount: (self: ProfileUnloadedSignal) -> (number),
}
export type MigrationResultSignal = {
	Connect: (self: MigrationResultSignal, listener: (player: Player, result: MigrationResultInfo, data: PlayerData) -> ()) -> (Connection),
	Fire: (self: MigrationResultSignal, player: Player, result: MigrationResultInfo, data: PlayerData) -> (),
	GetListenerCount: (self: MigrationResultSignal) -> (number),
}

local CONFIG = Config.Server
local AUTO_CLONE_TABLES = CONFIG.AutoCloneTables
local RUNTIME_NON_PERSISTENT_ROOTS = DataUtils.ResolveRuntimeNonPersistentRoots(CONFIG.RuntimeNonPersistentRoots)

if CONFIG.Leaderboard and CONFIG.Leaderboard.SyncInterval and CONFIG.Leaderboard.SyncInterval < 60 then
	warn("[PlayerState] SyncInterval should be >= 60 seconds for optimal performance. Setting to 60.")

	CONFIG.Leaderboard.SyncInterval = 60
end

if CONFIG.Leaderboard then
	local lb = CONFIG.Leaderboard :: any
	if lb.FullLeaderboardWriteInterval == nil and type(lb.ReconcileInterval) == "number" then
		lb.FullLeaderboardWriteInterval = lb.ReconcileInterval
	end
	if type(lb.FullLeaderboardWriteInterval) == "number" and lb.FullLeaderboardWriteInterval > 0 and lb.FullLeaderboardWriteInterval < 60 then
		warn("[PlayerState] FullLeaderboardWriteInterval should be >= 60 seconds when enabled. Setting to 60.")
		lb.FullLeaderboardWriteInterval = 60
	end
end

local replicas: { [Player]: ReplicaInstance } = {}
local profiles: { [Player]: ProfileStoreProfile } = {}
local loadedProfileUserIds: {[Player]: number} = {}
local mutationBlockedByPlayer: {[Player]: boolean} = {}
local initInFlight: {[number]: boolean} = {}
local profileStore: ProfileStoreInstance
local leaderboardPeriodicStarted = false

local BeforeSave = Signal.New() :: BeforeSaveSignal
local ProfileLoaded = Signal.New() :: ProfileLoadedSignal
local ProfileUnloaded = Signal.New() :: ProfileUnloadedSignal
local MigrationResult = Signal.New() :: MigrationResultSignal

local PathCacheModule = CreatePathCache(CONFIG.MaxCacheSize)
local GetPathKeys = PathCacheModule.GetPathKeys

local function GetNestedValue(data: any, path: string): any
	local keys = GetPathKeys(path)
	return DataUtils.GetNestedValue(data, keys)
end

local LeaderboardModule = CreateLeaderboardModule(
	CONFIG,
	Validation.ValidatePlayer,
	Validation.ValidateReplica,
	function()
		return replicas
	end,
	GetNestedValue
)

local BatchModule = CreateBatchModule(
	CONFIG,
	GetPathKeys,
	Validation.ValidateReplica,
	function(player: Player)
		return replicas[player]
	end,
	function(player: Player, operations: {BatchOperation})
		if not CONFIG.Leaderboard or not CONFIG.Leaderboard.Enabled then
			return
		end

		local replica = replicas[player]
		if not replica then
			return
		end

		for _, op in operations do
			if LeaderboardModule.IsStatTracked(op.path) and typeof(op.value) == "number" then
				LeaderboardModule.UpdateReplicaLeaderboardScore(replica, op.path, op.value :: number)
			end
		end
	end
)

local PlayerDataToken = ReplicaServer.Token("PlayerData")

profileStore = ProfileService.New(
	CONFIG.DataStore.Name,
	CONFIG.Profile.Template
)

if CONFIG.DataStore.Scope == "Testing" then
	profileStore = profileStore.Mock
	print("[PlayerState] Testing Scope: Using ProfileStore with mock data")
end

LeaderboardModule.InitializeLeaderboards()

local PlayerState: PlayerStateServer = {} :: PlayerStateServer

local function _SetupRuntimeSaveProtection(profile: ProfileStoreProfile): ()
	DataUtils.AttachRuntimeSaveProtection(profile, RUNTIME_NON_PERSISTENT_ROOTS)
end

local function _WaitForPlayerData(player: Player, maxWaitTime: number?): boolean
	local waitTime: number = maxWaitTime or CONFIG.DataWaitTimeout or 10
	local startTime = tick()

	while not PlayerState.IsPlayerDataReady(player) do
		if tick() - startTime > waitTime then
			warn(`[PlayerState] Timeout waiting for {player.Name}'s data to load`)
			return false
		end
		task.wait(0.1)
	end

	return true
end

local function _GetPlayerProfile(player: Player): ProfileStoreProfile?
	if not Validation.ValidatePlayer(player) then
		return nil
	end

	local profile = profiles[player]
	if not Validation.ValidateProfile(profile) then
		warn(`[PlayerState] No valid profile found for {player.Name}`)
		return nil
	end

	return profile
end

local function _GetPlayerReplica(player: Player): ReplicaInstance?
	if not Validation.ValidatePlayer(player) then
		local profile = profiles[player]
		if profile and Validation.ValidateProfile(profile) then
			local replica = replicas[player]

			if Validation.ValidateReplica(replica) then
				return replica
			end
		end

		return nil
	end

	if not _WaitForPlayerData(player) then
		return nil
	end

	local replica = replicas[player]
	if not Validation.ValidateReplica(replica) then
		warn(`[PlayerState] No valid replica found for {player.Name}`)
		return nil
	end

	return replica
end

local function _ResolveViewedUserSettings(): (number, boolean)
	local viewedConfig = CONFIG.ViewedUser
	if typeof(viewedConfig) ~= "table" then
		return 0, true
	end

	local viewedUserId = viewedConfig.ViewedUserId
	if typeof(viewedUserId) ~= "number" then
		if viewedUserId ~= nil then
			warn("[PlayerState] CONFIG.ViewedUser.ViewedUserId must be a number. Falling back to 0.")
		end

		viewedUserId = 0
	end

	viewedUserId = math.floor(viewedUserId)
	if viewedUserId < 0 then
		warn("[PlayerState] CONFIG.ViewedUser.ViewedUserId cannot be negative. Falling back to 0.")

		viewedUserId = 0
	end

	local readOnly = viewedConfig.ReadOnly
	if typeof(readOnly) ~= "boolean" then
		if readOnly ~= nil then
			warn("[PlayerState] CONFIG.ViewedUser.ReadOnly must be a boolean. Falling back to true.")
		end

		readOnly = true
	end

	return viewedUserId, readOnly
end

local function _ResolveEffectiveProfileUserId(player: Player): number
	local viewedUserId = _ResolveViewedUserSettings()

	if viewedUserId == 0 then
		return player.UserId
	end

	if not RunService:IsStudio() then
		warn("[PlayerState] CONFIG.ViewedUser.ViewedUserId is Studio-only. Falling back to player's own profile.")
		return player.UserId
	end

	return viewedUserId
end

local function _CanMutatePlayerData(player: Player): boolean
	if mutationBlockedByPlayer[player] ~= true then
		return true
	end

	warn(`[PlayerState] Blocked write for {player.Name} while viewing user {loadedProfileUserIds[player]} (ReadOnly mode).`)
	return false
end

local function _GetWritableReplica(player: Player): ReplicaInstance?
	if not _CanMutatePlayerData(player) then
		return nil
	end

	return _GetPlayerReplica(player)
end

function PlayerState.Init(player: Player, existingData: PlayerData?): boolean
	if not Validation.ValidatePlayer(player) then return false end

	if profiles[player] or replicas[player] then
		warn(`[PlayerState] {player.Name} already initialized`)
		return false
	end

	if existingData then
		error("[PlayerState] existingData is not supported")
	end

	local initInFlight: {[number]: boolean} = initInFlight or {}
	if initInFlight[player.UserId] then
		warn(`[PlayerState] Init already running for {player.Name}`)
		return false
	end

	initInFlight[player.UserId] = true

	local effectiveProfileUserId = _ResolveEffectiveProfileUserId(player)
	local profileKey = CONFIG.Profile.Key .. "_" .. effectiveProfileUserId
	local _, viewedReadOnly = _ResolveViewedUserSettings()
	local isViewingAnotherUser = effectiveProfileUserId ~= player.UserId
	local isMutationBlocked = isViewingAnotherUser and viewedReadOnly

	if isViewingAnotherUser then
		local viewedModeLabel = if viewedReadOnly then "read-only" else "writable"
		local viewedUserDisplay = tostring(effectiveProfileUserId)
		local ok, viewedUsername = pcall(Players.GetNameFromUserIdAsync, Players, effectiveProfileUserId)
		if ok and type(viewedUsername) == "string" then
			viewedUserDisplay = `{viewedUsername} ({effectiveProfileUserId})`
		end

		print(`[PlayerState] {player.Name} is loading viewed profile {viewedUserDisplay} ({viewedModeLabel}).`)
	end

	local function attach(profile: ProfileStoreProfile)
		profiles[player] = profile
		loadedProfileUserIds[player] = effectiveProfileUserId
		mutationBlockedByPlayer[player] = isMutationBlocked

		_SetupRuntimeSaveProtection(profile)

		if replicas[player] then
			pcall(replicas[player].Destroy, replicas[player])
		end
		local replica = ReplicaServer.New({
			Token = PlayerDataToken,
			Data = profile.Data,
			Tags = { UserId = player.UserId, PlayerName = player.Name },
		})
		replicas[player] = replica

		if ReplicaServer.ReadyPlayers[player] then
			replica:Subscribe(player)
		else
			local conn
			conn = ReplicaServer.NewReadyPlayer:Connect(function(p: Player)
				if p == player then
					replica:Subscribe(player)
					conn:Disconnect()
				end
			end)
		end

		local ls = player:FindFirstChild("leaderstats")
		if ls then
			pcall(function()
				ls:Destroy()
			end)
		end

		Leaderstats.SetupLeaderstatsSync(
			player,
			replica,
			function(p: Player)
				return profiles[p]
			end,
			Validation.ValidateProfile,
			GetNestedValue
		)

		if CONFIG.Leaderboard.Enabled then
			if not leaderboardPeriodicStarted then
				leaderboardPeriodicStarted = true
				LeaderboardModule.StartPeriodicUpdates()
			end
			LeaderboardModule.EnqueueTrackedStatsFromReplica(replica)
		end

		ProfileLoaded:Fire(player, profile.Data)
	end

	local function openSession(): ProfileStoreProfile?
		local ok, profOrErr = pcall(function()
			return profileStore:StartSessionAsync(profileKey)
		end)

		if not ok or not profOrErr then
			warn(`[PlayerState] StartSession failed for {player.Name}: {profOrErr}`)
			return nil
		end

		local prof: ProfileStoreProfile = profOrErr
		prof:AddUserId(player.UserId)
		prof:Reconcile()

		if CONFIG.CleanExtraFields then
			DataUtils.CleanData(prof.Data, CONFIG.Profile.Template)
		end

		local migrationResult = LegacyMigration.TryMigrateProfile(
			prof.Data,
			CONFIG.Profile.Template,
			effectiveProfileUserId,
			player,
			CONFIG.LegacyDataStore,
			RUNTIME_NON_PERSISTENT_ROOTS
		)

		if migrationResult.applied and CONFIG.CleanExtraFields then
			DataUtils.CleanData(prof.Data, CONFIG.Profile.Template)
		end

		DataUtils.RemoveRuntimeRoots(prof.Data, RUNTIME_NON_PERSISTENT_ROOTS)
		DataUtils.ApplyRuntimeRootDefaults(prof.Data, CONFIG.Profile.Template, RUNTIME_NON_PERSISTENT_ROOTS)
		MigrationResult:Fire(player, migrationResult, prof.Data)

		return prof
	end

	local profile = openSession()
	if not profile then
		initInFlight[player.UserId] = nil
		player:Kick("Failed to load data - please rejoin")

		return false
	end

	profile.OnSessionEnd:Connect(function()
		ProfileUnloaded:Fire(player, profile.Data)

		task.delay(2, function()
			if not player:IsDescendantOf(Players) then return end
			local newProfile = openSession()

			if newProfile then
				attach(newProfile)
			elseif player:IsDescendantOf(Players) then
				player:Kick("Profile session ended - rejoin to continue playing")
			end
		end)
	end)

	profile:MessageHandler(function(message, processed)
		if message.Type == "SetData" then
			PlayerState.SetPath(player, message.Path, message.Value)
			processed()
		elseif message.Type == "WipeData" then
			for key, value in CONFIG.Profile.Template do
				PlayerState.Set(player, key :: string, value)
			end
			processed()
		end
	end)

	if player:IsDescendantOf(Players) then
		attach(profile)
	else
		profile:EndSession()
		initInFlight[player.UserId] = nil

		return false
	end

	initInFlight[player.UserId] = nil
	return true
end

function PlayerState.Set(player: Player, key: string, value: any): boolean
	local replica = _GetWritableReplica(player)

	if not replica then
		return false
	end

	if string.find(key, "%.") then
		warn(`[PlayerState] Detected path syntax in Set() call. Use SetPath() instead for nested paths like "{key}"`)
		return false
	end

	if key == "leaderstats" or string.sub(key, 1, 12) == "leaderstats." then
		warn(`[PlayerState] Cannot directly modify leaderstats. Change the actual data source that the leaderstat references instead.`)
		return false
	end

	local success = pcall(replica.Set, replica, {key}, value)
	if not success then
		warn(`[PlayerState] Set operation failed for {player.Name}`)
		return false
	end

	if LeaderboardModule.IsStatTracked(key) and typeof(value) == "number" then
		LeaderboardModule.UpdateReplicaLeaderboardScore(replica, key, value :: number)
	end

	return true
end

function PlayerState.Get(player: Player, key: string): any
	if not player or player.Parent ~= Players then
		return nil
	end

	local replica = _GetPlayerReplica(player)
	if not replica then
		return nil
	end

	local value = (replica.Data :: any)[key]

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

	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.SetPath(player: Player, path: ValuePath, value: any): boolean
	local replica = _GetWritableReplica(player)

	if not replica then
		return false
	end

	if path == "leaderstats" or string.sub(path, 1, 12) == "leaderstats." then
		warn(`[PlayerState] Cannot directly modify leaderstats path "{path}". Change the actual data source that the leaderstat references instead.`)
		return false
	end

	local shouldSyncLeaderboard = LeaderboardModule.IsStatTracked(path) and typeof(value) == "number"

	if not string.find(path, ".", 1, true) then
		local success = pcall(replica.Set, replica, {path}, value)
		if not success then
			warn(`[PlayerState] SetPath operation failed for {player.Name}`)
			return false
		end

		if shouldSyncLeaderboard then
			LeaderboardModule.UpdateReplicaLeaderboardScore(replica, path, value :: number)
		end

		return true
	end

	local pathKeys = GetPathKeys(path)
	local success = pcall(replica.Set, replica, pathKeys, value)
	if not success then
		warn(`[PlayerState] SetPath operation failed for {player.Name}`)
		return false
	end

	if shouldSyncLeaderboard then
		LeaderboardModule.UpdateReplicaLeaderboardScore(replica, path, value :: number)
	end

	return true
end

function PlayerState.GetPath(player: Player, path: ValuePath): any
	local replica = _GetPlayerReplica(player)
	if not replica then
		return nil
	end

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

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

	return result
end

function PlayerState.OnChanged(player: Player, pathOrKey: string, callback: ChangeCallback): Connection?
	if not Validation.ValidatePlayer(player) then
		return nil
	end

	if type(pathOrKey) ~= "string" then
		warn("[PlayerState] OnChanged pathOrKey must be a string")
		return nil
	end

	if type(callback) ~= "function" then
		warn("[PlayerState] OnChanged callback must be a function")
		return nil
	end

	local replica = _GetPlayerReplica(player)
	if not replica then
		return nil
	end

	if pathOrKey == "." then
		return replica:OnChange(function(action, path, param1, param2)
			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)
	end

	local pathKeys = GetPathKeys(pathOrKey)
	return replica:OnPath(pathKeys, true, function(action, path, param1, param2)
		local isExactMatch = #path == #pathKeys

		if action == "Set" then
			if isExactMatch then
				local newValue, oldValue = param1, param2
				callback(newValue, oldValue, pathKeys)
			else
				callback(PlayerState.GetPath(player, pathOrKey), nil, {action = action, path = path})
			end
		elseif action == "SetValues" then
			if isExactMatch then
				local values = param1
				callback(values, nil, {action = action, path = path})
			else
				callback(PlayerState.GetPath(player, pathOrKey), 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

function PlayerState.SetValues(player: Player, values: {[string]: any}): boolean
	local replica = _GetWritableReplica(player)

	if not replica then
		return false
	end

	for key, _ in values do
		if key == "leaderstats" or string.sub(key, 1, 12) == "leaderstats." then
			warn(`[PlayerState] Cannot directly modify leaderstats key "{key}" in SetValues. Change the actual data source that the leaderstat references instead.`)
			return false
		end
	end

	local success = pcall(replica.SetValues, replica, {}, values)
	if not success then
		warn(`[PlayerState] SetValues operation failed for {player.Name}`)
		return false
	end

	for key, value in values do
		if LeaderboardModule.IsStatTracked(key) and typeof(value) == "number" then
			LeaderboardModule.UpdateReplicaLeaderboardScore(replica, key, value :: number)
		end
	end

	return true
end

function PlayerState.BatchSetValues(player: Player, operations: {BatchOperation}): boolean
	if not Validation.ValidatePlayer(player) then
		return false
	end

	if not _GetWritableReplica(player) then
		return false
	end

	for _, operation in operations do
		if operation.path == "leaderstats" or string.sub(operation.path, 1, 12) == "leaderstats." then
			warn(`[PlayerState] Cannot directly modify leaderstats path "{operation.path}" in BatchSetValues. Change the actual data source that the leaderstat references instead.`)
			return false
		end

		BatchModule.ScheduleBatchOperation(player, operation)
	end

	return true
end

function PlayerState.BatchUpdateValues(player: Player, operations: {BatchUpdateOperation}): boolean
	if not Validation.ValidatePlayer(player) then
		return false
	end

	local replica = _GetWritableReplica(player)
	if not replica then
		return false
	end

	local batchOperations: {BatchOperation} = {}

	for _, op in operations do
		local opKeys = GetPathKeys(op.path)
		local currentValue = DataUtils.GetNestedValue(replica.Data, opKeys)

		if typeof(currentValue) ~= "number" then
			warn(`[PlayerState] Cannot {op.operation} non-numeric value at path: {op.path}`)
			return false
		end

		local amount = if op.amount ~= nil then op.amount else 1
		local newValue: number

		if op.operation == "Increment" then
			newValue = currentValue + amount
		elseif op.operation == "Decrement" then
			newValue = currentValue - amount
		else
			warn(`[PlayerState] Invalid operation type: {op.operation}`)
			return false
		end

		table.insert(batchOperations, {
			path = op.path,
			value = newValue,
		})
	end

	local success = PlayerState.BatchSetValues(player, batchOperations)

	BatchModule.FlushBatch(player)

	return success
end

function PlayerState.FlushBatch(player: Player): boolean
	if not Validation.ValidatePlayer(player) then
		return false
	end

	if not _GetWritableReplica(player) then
		return false
	end

	BatchModule.FlushBatch(player)
	return true
end

function PlayerState.AddToArray(player: Player, arrayPath: ArrayPath, item: any): boolean
	local replica = _GetWritableReplica(player)

	if not replica then
		return false
	end

	local pathKeys = GetPathKeys(arrayPath)
	local success, result = ArrayOps.AddToArray(replica, pathKeys, item)
	if not success then
		warn(`[PlayerState] AddToArray operation failed for {player.Name}: {result}`)
		return false
	end

	return success
end

function PlayerState.RemoveFromArray(player: Player, arrayPath: ArrayPath, index: number): boolean
	local replica = _GetWritableReplica(player)

	if not replica then
		return false
	end

	local pathKeys = GetPathKeys(arrayPath)
	local success, result = ArrayOps.RemoveFromArray(replica, pathKeys, index)
	if not success then
		warn(`[PlayerState] RemoveFromArray operation failed for {player.Name}: {result}`)
		return false
	end

	return success
end

function PlayerState.UpdateArrayItem(player: Player, arrayPath: ArrayPath, index: number, newItem: any): boolean
	local replica = _GetWritableReplica(player)

	if not replica then
		return false
	end

	local pathKeys = GetPathKeys(arrayPath)
	local success = ArrayOps.UpdateArrayItem(replica, pathKeys, index, newItem)
	if not success then
		warn(`[PlayerState] UpdateArrayItem operation failed for {player.Name}`)
		return false
	end

	return true
end

function PlayerState.SetInDict(player: Player, dictPath: DictPath, key: string | number, value: any): boolean
	local replica = _GetWritableReplica(player)

	if not replica then
		return false
	end

	local stringKey = if typeof(key) == "number" then tostring(key) else key
	local pathKeys = GetPathKeys(dictPath)
	local prior = DataUtils.GetNestedValue(replica.Data, pathKeys)

	if prior ~= nil and typeof(prior) ~= "table" then
		warn(`[PlayerState] SetInDict path "{dictPath}" is not a dictionary for {player.Name}`)
		return false
	end

	local success = DictOps.SetInDict(replica, pathKeys, stringKey, value)

	if not success then
		warn(`[PlayerState] SetInDict operation failed for {player.Name}`)
		return false
	end

	if prior == nil then
		warn(`[PlayerState] Created new dictionary at path "{dictPath}" for {player.Name}`)
	end

	return true
end

function PlayerState.GetFromDict(player: Player, dictPath: DictPath, key: string | number): any
	local replica = _GetPlayerReplica(player)

	if not replica then
		return nil
	end

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

	local currentDict = PlayerState.GetPath(player, dictPath) or {}

	local result = currentDict[stringKey]

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

	return result
end

function PlayerState.RemoveFromDict(player: Player, dictPath: DictPath, key: string | number): boolean
	local replica = _GetWritableReplica(player)

	if not replica then
		return false
	end

	local stringKey = if typeof(key) == "number" then tostring(key) else key
	local pathKeys = GetPathKeys(dictPath)
	local success = DictOps.RemoveFromDict(replica, pathKeys, stringKey)

	if not success then
		warn(`[PlayerState] RemoveFromDict failed - path is not a dictionary for {player.Name}`)
		return false
	end

	return true
end

function PlayerState.GetReplica(player: Player): ReplicaInstance?
	return _GetPlayerReplica(player)
end

function PlayerState.GetProfile(player: Player): ProfileStoreProfile?
	return _GetPlayerProfile(player)
end

function PlayerState.GetAll(player: Player): PlayerData?
	if not Validation.ValidatePlayer(player) then
		local profile = profiles[player]
		if profile and Validation.ValidateProfile(profile) then
			return profile.Data
		end
		return nil
	end

	local replica = _GetPlayerReplica(player)
	if not replica then
		return nil
	end

	return replica.Data
end

function PlayerState.IsPlayerDataReady(player: Player): boolean
	return Validation.ValidatePlayer(player) and Validation.ValidateProfile(profiles[player]) and Validation.ValidateReplica(replicas[player])
end

function PlayerState.ClearPathCache(): ()
	PathCacheModule.ClearCache()
end

function PlayerState.GetOfflineData(userId: number): PlayerData?
	return OfflineData.GetOfflineData(
		userId,
		CONFIG,
		profileStore,
		function(player: Player)
			return PlayerState.GetAll(player)
		end
	)
end

function PlayerState.SetOfflineData(userId: number, path: string, value: any): boolean
	if DataUtils.IsPathInRuntimeRoots(path, RUNTIME_NON_PERSISTENT_ROOTS) then
		warn(`[PlayerState] Cannot set offline value for runtime-only path "{path}"`)
		return false
	end

	return OfflineData.SetOfflineData(
		userId,
		path,
		value,
		CONFIG,
		profileStore,
		function(player: Player, p: string, v: any)
			return PlayerState.SetPath(player, p, v)
		end,
		GetPathKeys,
		function(data: any, template: any)
			if CONFIG.CleanExtraFields then
				DataUtils.CleanData(data, template)
			end
		end
	)
end

function PlayerState.WipePlayerData(player: Player): boolean
	if not _GetWritableReplica(player) then
		return false
	end

	return DataWipe.WipePlayerData(
		player,
		CONFIG,
		function(p: Player)
			return profiles[p]
		end,
		function(p: Player)
			return replicas[p]
		end,
		Validation.ValidatePlayer,
		Validation.ValidateProfile,
		Validation.ValidateReplica
	)
end

function PlayerState.WipeOfflinePlayerData(userId: number): boolean
	return DataWipe.WipeOfflinePlayerData(
		userId,
		CONFIG,
		profileStore,
		PlayerState.WipePlayerData,
		function(data: any, template: any)
			if CONFIG.CleanExtraFields then
				DataUtils.CleanData(data, template)
			end
		end
	)
end

function PlayerState.SaveData(player: Player): boolean
	local profile = _GetPlayerProfile(player)
	if not profile then
		return false
	end

	BeforeSave:Fire(player, profile.Data)

	return profile:IsActive()
end

function PlayerState.Increment(player: Player, path: ValuePath, amount: number?): boolean
	if not player then
		print("[PlayerState] Invalid player for Increment")
		return false
	end

	local replica = _GetWritableReplica(player)
	if not replica then
		return false
	end

	local pathKeys = GetPathKeys(path)
	local currentValue = DataUtils.GetNestedValue(replica.Data, pathKeys)
	if typeof(currentValue) ~= "number" then
		warn(`[PlayerState] Cannot increment non-numeric value at path: {path}`)
		return false
	end

	local incrementAmount = if amount ~= nil then amount else 1
	local newValue = currentValue + incrementAmount

	return PlayerState.SetPath(player, path, newValue)
end

function PlayerState.Decrement(player: Player, path: ValuePath, amount: number?): boolean
	local decrementAmount = if amount ~= nil then amount else 1

	return PlayerState.Increment(player, path, -decrementAmount)
end

function PlayerState.UpdateLeaderboard(player: Player, statName: string, score: number): boolean
	if not CONFIG.Leaderboard or not CONFIG.Leaderboard.Enabled then
		warn("[PlayerState] Leaderboard functionality is disabled")
		return false
	end

	if not LeaderboardModule.IsStatTracked(statName) then
		warn(`[PlayerState] Stat "{statName}" is not configured as a tracked leaderboard stat. Add it to CONFIG.Leaderboard.TrackedStats`)
		return false
	end

	return PlayerState.SetPath(player, statName, score)
end

function PlayerState.GetLeaderboard(statName: string, limit: number): {LeaderboardEntry}
	if not CONFIG.Leaderboard or not CONFIG.Leaderboard.Enabled then
		return {}
	end

	if not LeaderboardModule.IsStatTracked(statName) then
		warn(`[PlayerState] Stat "{statName}" is not configured as a tracked leaderboard stat. Add it to CONFIG.Leaderboard.TrackedStats`)
		return {}
	end

	return LeaderboardModule.GetLeaderboardInternal(statName, limit)
end

function PlayerState.GetPlayerRank(player: Player, statName: string): number?
	if not CONFIG.Leaderboard or not CONFIG.Leaderboard.Enabled then
		return nil
	end

	if not LeaderboardModule.IsStatTracked(statName) then
		warn(`[PlayerState] Stat "{statName}" is not configured as a tracked leaderboard stat. Add it to CONFIG.Leaderboard.TrackedStats`)
		return nil
	end

	if not Validation.ValidatePlayer(player) then
		return nil
	end

	local leaderboard = PlayerState.GetLeaderboard(statName, 100)

	for _, entry in (leaderboard) do
		if entry.userId == player.UserId then
			return entry.rank
		end
	end

	return nil
end

Players.PlayerRemoving:Connect(function(player)
	if profiles[player] then
		BeforeSave:Fire(player, profiles[player].Data)
	end

	BatchModule.ClearPlayerBatch(player)

	LeaderboardModule.UpdatePlayerLeaderboard(player)

	if profiles[player] then
		profiles[player]:EndSession()
		profiles[player] = nil
	end
	loadedProfileUserIds[player] = nil
	mutationBlockedByPlayer[player] = nil

	if replicas[player] then
		replicas[player]:Destroy()
		replicas[player] = nil
	end
end)

game:BindToClose(function()
	for player, profile in profiles do
		if profile then
			BeforeSave:Fire(player, profile.Data)
		end
	end

	for player in profiles do
		BatchModule.ProcessBatchOperations(player)
	end

	LeaderboardModule.CancelPeriodicUpdates()

	LeaderboardModule.UpdateAllLeaderboards()

	for _, profile in profiles do
		if profile then
			profile:EndSession()
		end
	end
end)

PlayerState.BeforeSave = BeforeSave
PlayerState.ProfileLoaded = ProfileLoaded
PlayerState.ProfileUnloaded = ProfileUnloaded
PlayerState.MigrationResult = MigrationResult

return PlayerState


--!strict
--[[
MAD STUDIO

-[RateLimit]---------------------------------------
	Can be used to prevent RemoteEvent spamming; Player references are automatically removed as they leave

	Functions:

		RateLimit.New(rate, is_full_wait) --> [RateLimit]
			rate           [number] -- Events per second allowed; Excessive events are dropped
			is_full_wait   [bool] -- (Optional) If set to true, allows the next event to happen after the full rate period has passed

	Methods [RateLimit]:

		RateLimit:CheckRate(source) --> is_to_be_processed [bool] -- Whether event should be processed
			source   [any]

		RateLimit:CleanSource(source) -- Forgets about the source - must be called for any object that
			has been passed to RateLimit:CheckRate() after that object is no longer going to be used;
			Does not have to be called for Player instances!

		RateLimit:Cleanup() -- Forgets all sources

		RateLimit:Destroy() -- Make the RateLimit module forget about this RateLimit object

--]]

----- Private -----

local Players = game:GetService("Players")

local PlayerReference = {} -- {player = true}
local RateLimits = {} -- {rate_limit = true, ...}

----- Public -----

local RateLimit = {}
RateLimit.__index = RateLimit

function RateLimit.New(rate : number, is_full_wait : boolean?) --> [RateLimit]
	if rate <= 0 then
		error("[RateLimit]: Invalid rate")
	end

	local self = {
		sources = {},
		rate_period = 1 / rate,
		is_full_wait = is_full_wait == true,
	}
	setmetatable(self, RateLimit)

	RateLimits[self] = true

	return self
end

function RateLimit:CheckRate(source) : boolean --> is_to_be_processed [bool] -- Whether event should be processed

	local sources = self.sources
	local os_clock = os.clock()

	if source == nil then
		source = "nil"
	end

	local rate_time: number = sources[source]

	if rate_time ~= nil then
		if self.is_full_wait ~= true then
			rate_time = math.max(os_clock, rate_time + self.rate_period)
			if rate_time - os_clock < 1 then
				sources[source] = rate_time
				return true
			else
				return false
			end
		else
			if rate_time <= os_clock then
				sources[source] = os_clock + self.rate_period
				return true
			else
				return false
			end
		end
	else
		-- Preventing from remembering players that already left:
		if typeof(source) == "Instance" and source:IsA("Player")
			and PlayerReference[source] == nil then
			return false
		end
		sources[source] = os_clock + self.rate_period
		return true
	end

end

function RateLimit:CleanSource(source) -- Forgets about the source - must be called for any object that
	self.sources[source] = nil
end

function RateLimit:Cleanup() -- Forgets all sources
	self.sources = {}
end

function RateLimit:Destroy() -- Make the RateLimit module forget about this RateLimit object
	RateLimits[self] = nil
end

----- Init -----

for _, player in (Players:GetPlayers()) do
	PlayerReference[player] = true
end

Players.PlayerAdded:Connect(function(player)
	PlayerReference[player] = true
end)

Players.PlayerRemoving:Connect(function(player)
	PlayerReference[player] = nil
	for rate_limit in (RateLimits) do
		rate_limit.sources[player] = nil
	end
end)

return RateLimit


--[[
MAD STUDIO

-[Remote]---------------------------------------
	Remote event setup utility; Prevents duplicate RemoteEvent names server-side

	Functions:

		Remote.New(name [string], is_unreliable [bool]) --> [RemoteEvent]
			-- Calling this with the same name server-side twice will throw an error

--]]

----- Private -----

local CONTAINER_NAME = "RemoteEvents"
local REMOTE_MISSING_TIME = 20

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

local IsStudio = RunService:IsStudio()
local IsServer = RunService:IsServer()
local DefinedRemotes = {} -- [name] = true, ...

local Container, OnContainerReady do

	if IsServer == true then

		Container = ReplicatedStorage:FindFirstChild(CONTAINER_NAME)

		if Container ~= nil then
			if IsStudio == true then
				warn(`[{script.Name}]: ReplicatedStorage "{CONTAINER_NAME}" container was already defined`)
			end
		else
			Container = Instance.new("Folder")
			Container.Name = CONTAINER_NAME
			Container.Parent = ReplicatedStorage
		end

	else

		Container = ReplicatedStorage:FindFirstChild(CONTAINER_NAME)

		if Container == nil then

			OnContainerReady = Instance.new("BindableEvent")

			task.spawn(function()
				while task.wait() do
					Container = ReplicatedStorage:FindFirstChild(CONTAINER_NAME)
					if Container ~= nil then
						OnContainerReady:Fire()
						break
					end
				end
			end)

		end

	end

end

----- Public -----

local Connection = {}
Connection.__index = Connection

function Connection.New(fn)
	return setmetatable({
		fn = fn,
		is_disconnected = false,
		real_connection = nil,
	}, Connection)
end

function Connection:Disconnect()
	self.is_disconnected = true
	if self.real_connection ~= nil then
		self.real_connection:Disconnect()
	end
end

local Remote = {}
Remote.__index = Remote

function Remote.New(name: string, is_unreliable: boolean): RemoteEvent

	if type(name) ~= "string" then
		error(`[{script.Name}]: name must be a string`)
	end

	if IsServer == true then

		if DefinedRemotes[name] ~= nil then
			error(`[{script.Name}]: RemoteEvent {name} was already defined`)
		end

		DefinedRemotes[name] = true

		local remote = Instance.new(if is_unreliable == true then "UnreliableRemoteEvent" else "RemoteEvent")
		remote.Name = name
		remote.Parent = Container

		return remote

	else

		local remote: RemoteEvent = Container and Container:FindFirstChild(name)

		if remote ~= nil then
			return remote
		end

		local connections = {}

		local self = setmetatable({
			OnClientEvent = {
				Connect = function(_, fn)

					if remote ~= nil then
						return remote.OnClientEvent:Connect(fn)
					else
						local connection = Connection.New(fn)
						table.insert(connections, connection)
						return connection
					end

				end,
			},
			OnServerEvent = {
				Connect = function()
					error(`[{script.Name}]: Can't connect to "OnServerEvent" client-side`)
				end,
			},
			RemoteEvent = nil,
		}, Remote)

		local function on_container_ready()

			local missing_start = os.clock()

			while true do
				remote = Container:FindFirstChild(name)
				if remote == nil then
					if missing_start ~= nil and os.clock() - missing_start > REMOTE_MISSING_TIME then
						missing_start = nil
						warn(`[{script.Name}]: RemoteEvent "{name}" hasn't been defined server-side`)
					end
					task.wait()
				else
					break
				end
			end

			for _, connection in ipairs(connections) do
				if connection.is_disconnected == false then
					connection.real_connection = remote.OnClientEvent:Connect(connection.fn)
				end
			end

			self.RemoteEvent = remote
			connections = nil

		end

		if Container ~= nil then
			task.spawn(on_container_ready)
		else
			local wait_connection
			wait_connection = OnContainerReady.Event:Connect(function()
				wait_connection:Disconnect()
				on_container_ready()
			end)
		end

		return self

	end

end

function Remote:FireServer(...)
	if self.RemoteEvent ~= nil then
		self.RemoteEvent:FireServer(...)
	end
end

function Remote:FireClient()
	error(`[{script.Name}]: Can't use "FireClient" client-side`)
end

function Remote:FireAllClients()
	error(`[{script.Name}]: Can't use "FireAllClients" client-side`)
end

return Remote


local CommentRegion = {}
CommentRegion.__index = CommentRegion

export type CommentRegion = typeof(setmetatable({} :: { label: string }, CommentRegion))

function CommentRegion.new(label: string): CommentRegion
	return setmetatable({ label = label }, CommentRegion)
end

function CommentRegion.pattern(self: CommentRegion)
	return `(\s*)%-%- boba: start {self.label}\n(.*)\n(\s*)%-%- boba: end {self.label}`
end

function CommentRegion.wrap(self: CommentRegion, str: string)
	return `-- boba: start {self.label}\n{str}\n-- boba: end {self.label}`
end

table.freeze(CommentRegion)
return CommentRegion


local fs = require("@lune/fs")
local roblox = require("@lune/roblox")
local CommentRegion = require("@lib/comment-region")

local PATH = "./packages/boba-roblox/src/init.luau"

local REGION_DATA_TYPES = CommentRegion.new("roblox data types codegen")
local REGION_INSTANCES = CommentRegion.new("roblox instances typegen")

local DATA_TYPES = {
	"Axes",
	"BrickColor",
	"CatalogSearchParams",
	"CFrame",
	"Color3",
	"ColorSequence",
	"ColorSequenceKeypoint",
	"Content",
	"DateTime",
	"DockWidgetPluginGuiInfo",
	"Enum",
	"EnumItem",
	"Enums",
	"Faces",
	"FloatCurveKey",
	"Font",
	"Instance",
	"NumberRange",
	"NumberSequence",
	"NumberSequenceKeypoint",
	"OverlapParams",
	"Path2DControlPoint",
	"PathWaypoint",
	"PhysicalProperties",
	"Random",
	"Ray",
	"RaycastParams",
	"RaycastResult",
	"RBXScriptConnection",
	"RBXScriptSignal",
	"Rect",
	"Region3",
	"Region3int16",
	"RotationCurveKey",
	"Secret",
	"SharedTable",
	"TweenInfo",
	"UDim",
	"UDim2",
	"ValueCurveKey",
	"Vector2",
	"Vector2int16",
	"Vector3",
	"Vector3int16",
}

local EXCLUDED_CLASSES = {
	AuroraService = true,
	AuroraScript = true,
	AuroraScriptService = true,
	ExampleService = true,
	InternalSyncItem = true,
	InternalSyncService = true,
}

local function renderRegionDatatypes()
	local lines = {}
	for _, datatype in DATA_TYPES do
		table.insert(lines, `ext.{datatype} = Boba.Typeof("{datatype}") :: Boba<{datatype}>`)
	end
	return REGION_DATA_TYPES:wrap(table.concat(lines, "\n"))
end

local function renderRegionInstances()
	local db = roblox.getReflectionDatabase()
	local classes = db:GetClassNames()

	table.sort(classes)

	local lines = { "type Instances = {" }
	for _, className in classes do
		if not EXCLUDED_CLASSES[className] then
			table.insert(lines, `\t{className}: Boba<{className}>,`)
		end
	end
	table.insert(lines, "}")

	return REGION_INSTANCES:wrap(table.concat(lines, "\n"))
end

local function raw(str: string)
	return function()
		return str
	end
end

local contents = fs.readFile(PATH)
contents = string.gsub(contents, REGION_DATA_TYPES:pattern(), raw(renderRegionDatatypes()))
contents = string.gsub(contents, REGION_INSTANCES:pattern(), raw(renderRegionInstances()))
fs.writeFile(PATH, contents)


--!strict
--!optimize 2

--[[

    ▄ █ ▀█▀ █ ▄  Welcome To Hell @ wthrblx.com
     █▀█ █ █▀█   (c) Team Fireworks 2024-2026.

    This software is provided 'as-is', without any express or implied
    warranty. In no event will the authors be held liable for any damages
    arising from the use of this software.

    Permission is granted to anyone to use this software for any purpose,
    including commercial applications, and to alter it and redistribute it
    freely, subject to the following restrictions:

    1. The origin of this software must not be misrepresented; you must not
       claim that you wrote the original software. If you use this software
       in a product, an acknowledgment in the product documentation would be
       appreciated but is not required.
    2. Altered source versions must be plainly marked as such, and must not be
       misrepresented as being the original software.
    3. This notice may not be removed or altered from any source distribution.

]]

local INDENT = "  "

local function quote(x: any)
	local xType = typeof(x)
	if xType == "string" then
		x = string.format("%q", x)
	end
	return `{x} ({xType})`
end

local function quoteGot(x: any)
	return `got {quote(x)}`
end

local Result = {}
Result.__index = Result

export type ResultOk = typeof(setmetatable({} :: { ok: true }, Result))
export type ResultFail = typeof(setmetatable(
	{} :: { ok: false, expectedType: string, message: string, children: { Result } },
	Result
))

export type Result = ResultOk | ResultFail

Result.ok = table.freeze(setmetatable({ ok = true :: true }, Result)) :: Result

function Result.fail(expectedType: string, message: string, children: { Result }?): Result
	return setmetatable({
		ok = false :: false,
		expectedType = expectedType,
		message = message,
		children = children or {},
	}, Result)
end

function Result.clone(self: Result)
	return if self.ok then self else Result.fail(self.expectedType, self.message, self.children)
end

function Result.because(self: Result, sub: Result)
	return if self.ok
		then sub:clone()
		elseif sub.ok then self:clone()
		else Result.fail(self.expectedType, self.message, table.move(self.children, 1, #self.children, 2, { sub }))
end

function Result.format(self: Result, maybeIndent: string?): string
	if self.ok then
		return ""
	end

	local indent = maybeIndent or ""
	local lines = {}

	table.insert(lines, indent .. `expected {self.expectedType}`)
	table.insert(lines, indent .. "> " .. self.message)
	indent ..= INDENT

	for _, sub in self.children do
		if not sub.ok then
			table.insert(lines, sub:format(indent))
		end
	end

	return table.concat(lines, "\n")
end

function Result.__tostring(self: Result)
	return self:format()
end

---

local Boba = {}
Boba.__index = Boba
Boba.Result = Result
Boba.quote = quote
Boba.quoteGot = quoteGot

export type BobaInner<T> = {
	expectedType: string,
	match: (self: Boba<T>, x: unknown) -> Result,
	inner: T,
}

export type Boba<T> = typeof(setmetatable({} :: BobaInner<T>, Boba))

function Boba.new<T>(expectedType: string, match: (self: Boba<T>, x: any) -> Result): Boba<T>
	local self = setmetatable({
		expectedType = expectedType,
		match = match,
	}, Boba)
	self.inner = self :: any
	return self
end

-- FIXME: Luau types are bad </3
-- function boba.__call<T>(self: Type<T>, x: any)
function Boba.__call(self: any, x: any): Result
	return self:match(x)
end

function Boba.__tostring(self: Boba<any>)
	return `Boba<{self.expectedType}>`
end

function Boba.isMatch<T>(self: Boba<T>, x: any): boolean
	return self:match(x).ok
end

function Boba.cast<T>(self: Boba<T>, x: any): T?
	return if self:match(x).ok then x else nil
end

function Boba.assert<T>(self: Boba<T>, x: any): T
	local result = self:match(x)
	return if result.ok then x else error(result:format())
end

function Boba.fail(self: Boba<any>, message: string)
	return Result.fail(self.expectedType, message)
end

function Boba.Nickname<T>(self: Boba<T>, name: string): Boba<T>
	return Boba.new(name, self.match)
end

function Boba.Retype<T>(self: Boba<any>): Boba<T>
	return self
end

function Boba.Untype<T>(self: Boba<T>): Boba<any>
	return self
end

function Boba.And<L, R>(left: Boba<L>, right: Boba<R>): Boba<L & R>
	return Boba.new(`({left.expectedType}) & ({right.expectedType})`, function(self, x)
		local leftResult = left:match(x)
		if leftResult.ok then
			local rightResult = right:match(x)
			return if rightResult.ok
				then Result.ok
				else self:fail("it didn't match the right type"):because(rightResult)
		end
		return self:fail("it didn't match the left type"):because(leftResult)
	end)
end

function Boba.Or<L, R>(left: Boba<L>, right: Boba<R>): Boba<L | R>
	return Boba.new(`{left.expectedType} | {right.expectedType}`, function(self, x)
		local leftResult = left:match(x)
		local rightResult = right:match(x)
		if leftResult.ok or rightResult.ok then
			return Result.ok
		end
		return self:fail(`did not match any of: {left.expectedType}, {right.expectedType}`)
			:because(leftResult)
			:because(rightResult)
	end)
end

function Boba.Optional<T>(inner: Boba<T>): Boba<T?>
	return Boba.new(`({inner.expectedType})?`, function(self, x)
		if x == nil then
			return Result.ok
		end
		return inner:match(x)
	end)
end

function Boba.Predicate<T>(inner: Boba<T>, predicate: (value: T) -> (boolean, string?)): Boba<T>
	return Boba.new(`Predicate<{inner.expectedType}>`, function(self, x)
		local innerResult = inner:match(x)

		if innerResult.ok then
			local predicateOk, predicateError = predicate(x)
			if predicateOk then
				return Result.ok
			end

			return self:fail(predicateError or "value did not satisfy the predicate")
		end

		return innerResult
	end)
end

function Boba.Literal<T>(literal: T, nickname: string?): Boba<T>
	return Boba.new(nickname or tostring(literal), function(self, x)
		return if rawequal(x, literal) then Result.ok else self:fail(`only {literal} is accepted, but {quoteGot(x)}`)
	end)
end

function Boba.Typeof<T>(expected: string)
	return Boba.new(expected, function(self, x)
		return if typeof(x) == expected then Result.ok else self:fail(quoteGot(x))
	end)
end

function Boba.Array<V>(values: Boba<V>): Boba<{ V }>
	return Boba.new(`\{ {values.expectedType} \}`, function(self, x)
		if typeof(x) ~= "table" then
			return self:fail(`expected a table, but {quoteGot(x)}`)
		end

		local expected = #x
		local encountered = 0

		for key, value in pairs(x) do
			encountered += 1

			if encountered > expected or typeof(key) ~= "number" then
				return self:fail(`because there's non-numeric indexes, such as key {quote(key)}`)
			end

			local valueResult = values:match(value)
			if not valueResult.ok then
				return self:fail(`value at index #{key} doesn't match`):because(valueResult)
			end
		end

		return Result.ok
	end)
end

function Boba.Map<K, V>(keys: Boba<K>, values: Boba<V>): Boba<{ [K]: V }>
	return Boba.new(`\{ [{keys.expectedType}]: {values.expectedType} \}`, function(self, x)
		if typeof(x) ~= "table" then
			return self:fail(`expected a table, but {quoteGot(x)}`)
		end

		for key, value in pairs(x) do
			local keyResult = keys:match(key)
			if not keyResult.ok then
				return self:fail(`quoteGot unexpected key {quote(key)}`):because(keyResult)
			end

			local valueResult = values:match(value)
			if not valueResult.ok then
				return self:fail(`at key {quote(key)}`):because(valueResult)
			end
		end

		return Result.ok
	end)
end

function Boba.Set<K>(keys: Boba<K>): Boba<{ [K]: true }>
	return Boba.new(`\{ [{keys.expectedType}]: true \}`, function(self, x)
		if typeof(x) ~= "table" then
			return self:fail(`expected a table, but {quoteGot(x)}`)
		end

		for key, value in pairs(x) do
			local keyResult = keys:match(key)
			if not keyResult.ok then
				return self:fail(`quoteGot unexpected key {quote(key)}`):because(keyResult)
			end

			if value ~= true then
				return self:fail(`expected literal \`true\`, but at key {quote(key)} quoteGot {quote(value)}`)
			end
		end

		return Result.ok
	end)
end

function Boba.Struct<T>(struct: T & { [string]: Boba<any> }): Boba<T>
	local expectedPairs = { "[any]: any" }
	for key, value in struct :: { [string]: Boba<any> } do
		-- TODO: key could be better formatted here
		table.insert(expectedPairs, `{key}: {value.expectedType}`)
	end

	return Boba.new(`\{ {table.concat(expectedPairs, ", ")} \}`, function(self, x)
		if typeof(x) ~= "table" then
			return self:fail(`expected a table, but {quoteGot(x)}`)
		end

		for key, valueType in pairs(struct) do
			local value = x[key]
			local valueResult = valueType:match(value)
			if not valueResult.ok then
				return self:fail(`value at key {quote(key)}`):because(valueResult)
			end
		end

		return Result.ok
	end)
end

function Boba.ExhaustiveStruct<T>(struct: T): Boba<T>
	local expectedPairs = {}
	for key, value in (struct :: any) :: { [string]: Boba<any> } do
		-- TODO: key could be better formatted here
		table.insert(expectedPairs, `{key}: {value.expectedType}`)
	end

	return Boba.new(`\{ {table.concat(expectedPairs, ", ")} \}`, function(self, x)
		if typeof(x) ~= "table" then
			return self:fail(`expected a table, but {quoteGot(x)}`)
		end

		for key, valueType: Boba<any> in pairs(struct :: any) do
			local value = x[key]
			local valueResult = valueType:match(value)
			if not valueResult.ok then
				return self:fail(`value at key {quote(key)}`):because(valueResult)
			end
		end

		for key in pairs(x) do
			if (struct :: any)[key] == nil then
				return self:fail(`{quote(key)} isn't included in exhaustive struct`)
			end
		end

		return Result.ok
	end)
end

Boba.Any = Boba.new("any", function()
	return Result.ok
end) :: Boba<any>

Boba.Unknown = Boba.new("unknown", function()
	return Result.ok
end) :: Boba<unknown>

Boba.Never = Boba.new("never", function(self)
	return self:fail("there are no valid values")
end) :: Boba<never>

Boba.Boolean = Boba.Typeof("boolean") :: Boba<boolean>
Boba.Buffer = Boba.Typeof("buffer") :: Boba<buffer>
Boba.Function = Boba.Typeof("function") :: Boba<(...any) -> ...any>
Boba.Number = Boba.Typeof("number") :: Boba<number>
Boba.String = Boba.Typeof("string") :: Boba<string>
Boba.Table = Boba.Typeof("table") :: Boba<{ [any]: any }>
Boba.Thread = Boba.Typeof("thread") :: Boba<thread>
Boba.Vector = Boba.Typeof("vector") :: Boba<vector>

Boba.True = Boba.Literal(true):Untype() :: Boba<true>
Boba.False = Boba.Literal(false):Untype() :: Boba<false>
Boba.Nil = Boba.Literal(nil) :: Boba<nil>

-- support for named imports in rbxts
(Boba :: any).Boba = Boba

table.freeze(Boba)
return Boba


local Packager = require("@pkg/Packager")

local Serializer = require("./Serializer")
local Types = require("./Types")

local formatCase = require("./Lib/formatCase")
local sanitizeName = require("./Lib/sanitizeName")
local trimString = require("./Lib/trimString")

local IGNORED_PROPERTIES = require("./IgnoredProperties")

local SanitizeNameReservedWords = {
	luau = require("./Lib/sanitizeName/ReservedWords/Luau"),
}

local _packagerHandle
local function getPackagerHandle()
	if _packagerHandle then
		return _packagerHandle
	end

	local packager = Packager.fetchFromServer()
	_packagerHandle = packager

	return packager
end

local function setPackagerHandle(packager)
	if typeof(packager) ~= "table" then
		error(`"{packager}" ({typeof(packager)}) is not a valid Packager instance.`)
	end

	if not packager.CreatePackageFlat or not packager.ConvertToPackage then
		error('Packager is missing the "CreatePackageFlat" and "ConvertToPackage" methods.')
	end

	_packagerHandle = packager
end

local function isIgnoredProperty(className: string, propertyName: string)
	if table.find(IGNORED_PROPERTIES.global, propertyName) then
		return true
	end

	if IGNORED_PROPERTIES.classes[className] then
		if table.find(IGNORED_PROPERTIES[className], propertyName) then
			return true
		end
	end

	local packager = getPackagerHandle()
	local ancestry = packager.Dump:GetAncestry(className)

	for _, ancestor in ancestry do
		if IGNORED_PROPERTIES.classes[ancestor] then
			if table.find(IGNORED_PROPERTIES.classes[ancestor], propertyName) then
				return true
			end
		end
	end

	return false
end

local function codify(
	root: Instance,
	config: {
		generator: {
			language: Serializer.Language,
			generate: (package: Types.SerializedPackage) -> string,
		},
		userFormats: Serializer.UserFormats?,
		caseFormat: formatCase.CaseFormat?,
	}
)
	local packager = getPackagerHandle()
	local flatpack = packager:CreatePackageFlat(root)

	for _, node in flatpack.Tree do
		local newNode: Types.SeralizedFlatTreeNode = node :: never
		local name = trimString(node.Name)

		newNode.VariableName = formatCase(
			sanitizeName(name, {
				reservedWords = SanitizeNameReservedWords[config.generator.language],
			}),
			config.caseFormat
		)

		for propertyName, property in node.Properties do
			if property.Type == "REF" then
				continue
			end

			if isIgnoredProperty(node.ClassName, propertyName) then
				continue
			end

			local newProperty: Types.SerializedProperty = property :: never
			local value = property.Value

			if property.Type == "Enum" then
				value = (Enum :: any)[value.Type][value.Value]
			end

			local serializedValue = Serializer.format(value, {
				language = config.generator.language,
				userFormats = config.userFormats,
			})

			if serializedValue == nil then
				continue
			end

			newProperty.OriginalType = property.Type
			newProperty.OriginalValue = value

			newProperty.Type = "serialized"
			newProperty.Value = serializedValue
		end
	end

	local package = Packager:ConvertToPackage(flatpack)
	return config.generator.generate(package :: any)
end

return {
	codify = codify,
	setPackager = setPackagerHandle,
}


local Types = require("../Types")

local Serializer = require("../Serializer")

local DEFAULT_INDENTATION = (" "):rep(4)
local DEFAULT_CREATE_METHOD = "scope:New"
local DEFAULT_CHILDREN_KEY = "Children"

return function(config: {
	indentation: string?,
	createMethod: string?,
	childrenKey: string?,
	names: ("all" | "changed" | "none")?,
}?)
	local createMethod = config and config.createMethod or DEFAULT_CREATE_METHOD
	local childrenKey = config and config.childrenKey or DEFAULT_CHILDREN_KEY

	local function tab(n: number)
		local indent = config and config.indentation or DEFAULT_INDENTATION
		return indent:rep(n)
	end

	local function generate(package: Types.SerializedPackage)
		local document = {}
		local depth = 1

		local function renderNode(
			node: Types.SerializedTreeNode,
			prevNode: Types.SerializedTreeNode?
		)
			local nameKey = Serializer.formatString(node.ClassName, "luau")
			table.insert(document, `{tab(prevNode and depth or 0)}{createMethod} {nameKey} \{`)

			if prevNode then
				depth += 1
			end

			local applyNameProperty = true

			if config and config.names == "none" then
				applyNameProperty = false
			elseif config and config.names == "changed" then
				applyNameProperty = node.Name ~= node.ClassName
			end

			if applyNameProperty then
				local nameString = Serializer.formatString(node.Name, "luau")
				table.insert(document, `{tab(depth)}Name = {nameString},`)
			end

			for propertyName, property in node.Properties do
				if property.Type ~= "serialized" then
					continue
				end

				local propertyValue = property.Value:gsub("\t", tab(1))
				table.insert(document, `{tab(depth)}{propertyName} = {propertyValue},`)
			end

			if node.Children then
				table.insert(document, "")
				table.insert(document, `{tab(depth)}[{childrenKey}] = \{`)

				depth += 1

				for _, child: any in node.Children do
					renderNode(child, node)
					table.insert(document, "")
				end

				table.remove(document)
				depth -= 1

				table.insert(document, `{tab(depth)}}`)
			end

			if prevNode then
				depth -= 1
			end

			table.insert(document, `{tab(prevNode and depth or 0)}\}{prevNode and "," or ""}`)
		end

		renderNode(package.Tree)

		local renderedDocument = table.concat(document, "\n")
		return renderedDocument
	end

	return {
		language = "luau",
		generate = generate,
	}
end


local Types = require("../Types")

local Serializer = require("../Serializer")

local createUniqueKey = require("../Lib/createUniqueKey")

local DEFAULT_INDENTATION = (" "):rep(4)

return function(config: {
	indentation: string?,
	names: ("all" | "changed" | "none")?,
	lowercaseKeyProp: boolean?,
}?)
	local keyProp = config and config.lowercaseKeyProp and "key" or "Key"

	local function tab(n: number, condition: boolean?)
		if condition then
			return ""
		end

		local indent = config and config.indentation or DEFAULT_INDENTATION
		return indent:rep(n)
	end

	local function generate(package: Types.SerializedPackage)
		local document = {}
		local depth = 1

		local function renderNode(
			node: Types.SerializedTreeNode,
			prevNode: Types.SerializedTreeNode?
		)
			local className = node.ClassName:lower()

			table.insert(document, `{tab(depth, not prevNode)}<{className}`)
			local initLineIndex = #document

			local nodeNameProp = Serializer.formatString(node.Name, "jsx")
			local applyNameProperty = true

			if config and config.names == "changed" then
				applyNameProperty = node.Name ~= node.ClassName
			elseif config and config.names == "none" then
				applyNameProperty = false
			end

			if prevNode then
				depth += 1
			end

			local hasProps = false
			local keyPropInsertPosition = #document + 1

			for propertyName, property: any in node.Properties do
				if property.Type ~= "serialized" then
					continue
				end

				local propertyValue = property.Value:gsub("\t", tab(1))

				if property.OriginalType ~= "string" then
					propertyValue = `\{{propertyValue}}`
				end

				table.insert(document, `{tab(depth)}{propertyName}={propertyValue}`)

				hasProps = true
			end

			if applyNameProperty then
				if not hasProps then
					document[initLineIndex] ..= ` {keyProp}={nodeNameProp}`
				else
					table.insert(
						document,
						keyPropInsertPosition,
						`{tab(depth)}{keyProp}={nodeNameProp}`
					)
				end
			end

			if node.Children then
				local uniqueKey = createUniqueKey()

				if hasProps then
					table.insert(document, ">")
				else
					document[initLineIndex] ..= ">"
				end

				for _, child: any in node.Children do
					child.Name = uniqueKey(child.Name)

					renderNode(child, node)
					table.insert(document, "")
				end

				table.remove(document)
			end

			if prevNode then
				depth -= 1
			end

			if hasProps or node.Children then
				table.insert(document, `{tab(depth, not prevNode)}/>`)
			else
				document[initLineIndex] ..= `{applyNameProperty and " " or ""}/>`
			end
		end

		renderNode(package.Tree)

		local renderedDocument = table.concat(document, "\n")
		return renderedDocument
	end

	return {
		language = "jsx",
		generate = generate,
	}
end


local Types = require("../Types")

local Serializer = require("../Serializer")

local createUniqueKey = require("../Lib/createUniqueKey")

local DEFAULT_INDENTATION = (" "):rep(4)
local DEFAULT_CREATE_METHOD = "React.createElement"

return function(config: {
	indentation: string?,
	createMethod: string?,
	names: ("all" | "changed" | "none")?,
}?)
	local createMethod = config and config.createMethod or DEFAULT_CREATE_METHOD

	local function tab(n: number, condition: boolean?)
		if condition then
			return ""
		end

		local indent = config and config.indentation or DEFAULT_INDENTATION
		return indent:rep(n)
	end

	local function generate(package: Types.SerializedPackage)
		local document = {}
		local depth = 1

		local function renderNode(
			node: Types.SerializedTreeNode,
			prevNode: Types.SerializedTreeNode?
		)
			local nameKey = Serializer.formatString(node.ClassName, "luau")
			local initPrefix = ""

			if prevNode then
				local childKey = tostring(table.find((prevNode :: any).Children, node))

				if config then
					if
						config.names == "all"
						or (config.names == "changed" and node.Name ~= node.ClassName)
					then
						childKey = node.VariableName
					end
				end

				if not config or (config and config.names ~= "none") then
					initPrefix = `{childKey} = `
				end
			end

			table.insert(document, `{tab(depth, not prevNode)}{initPrefix}{createMethod}({nameKey}`)
			local initLineIndex = #document

			if prevNode then
				depth += 1
			end

			local hasProps = false

			for propertyName, property in node.Properties do
				if property.Type ~= "serialized" then
					continue
				end

				local propertyValue = property.Value:gsub("\t", tab(1))
				table.insert(document, `{tab(depth)}{propertyName} = {propertyValue},`)

				hasProps = true
			end

			if node.Children then
				depth -= 1

				if hasProps then
					table.insert(document, `{tab(depth)}}, \{`)
				end

				depth += 1

				local uniqueKey = createUniqueKey()

				for _, child: any in node.Children do
					child.VariableName = uniqueKey(child.VariableName)

					renderNode(child, node)
					table.insert(document, "")
				end

				table.remove(document)
			end

			if prevNode then
				depth -= 1
			end

			if hasProps or node.Children then
				table.insert(document, `{tab(depth, not prevNode)}}){prevNode and "," or ""}`)
			end

			if hasProps then
				document[initLineIndex] ..= ", {"
			elseif node.Children then
				document[initLineIndex] ..= ", nil, {"
			else
				document[initLineIndex] ..= prevNode and ")," or ")"
			end
		end

		renderNode(package.Tree)

		local renderedDocument = table.concat(document, "\n")
		return renderedDocument
	end

	return {
		language = "luau",
		generate = generate,
	}
end


local Types = require("../Types")

local Serializer = require("../Serializer")

local createUniqueKey = require("../Lib/createUniqueKey")

local DEFAULT_INDENTATION = (" "):rep(4)

return function(config: {
	indentation: string?,
	names: ("all" | "changed" | "none")?,
}?)
	local function tab(n: number)
		local indent = config and config.indentation or DEFAULT_INDENTATION
		return indent:rep(n)
	end

	local function generate(package: Types.SerializedPackage)
		local uniqueKey = createUniqueKey()
		local document = {}

		local function renderNode(
			node: Types.SerializedTreeNode,
			prevNode: Types.SerializedTreeNode?
		)
			local variableName = uniqueKey(node.VariableName)
			node.VariableName = variableName

			if prevNode then
				table.insert(document, "")
			end

			table.insert(document, `local {variableName} = Instance.new("{node.ClassName}")`)

			local applyNameProperty = true

			if config and config.names == "changed" then
				applyNameProperty = node.Name ~= node.ClassName
			elseif config and config.names == "none" then
				applyNameProperty = false
			end

			if applyNameProperty then
				local nameString = Serializer.formatString(node.Name, "luau")
				table.insert(document, `{variableName}.Name = {nameString}`)
			end

			for propertyName, property in node.Properties do
				if property.Type ~= "serialized" then
					continue
				end

				local propertyValue = property.Value:gsub("\t", tab(1))
				table.insert(document, `{variableName}.{propertyName} = {propertyValue}`)
			end

			if prevNode then
				table.insert(document, `{variableName}.Parent = {prevNode.VariableName}`)
			end

			if node.Children then
				for _, childNode: any in node.Children do
					renderNode(childNode, node)
				end
			end
		end

		renderNode(package.Tree)

		local renderedDocument = table.concat(document, "\n")
		return renderedDocument
	end

	return {
		language = "luau",
		generate = generate,
	}
end


local Fusion = require("./Fusion")
local Jsx = require("./Jsx")
local React = require("./React")
local Roblox = require("./Roblox")

return {
	Fusion = Fusion,
	Jsx = Jsx,
	React = React,
	Roblox = Roblox,
}


return {
	global = {
		"Parent",
	},
	classes = {
		BasePart = {
			"BrickColor",
		},
	},
}


local function createUniqueKey()
	local names = {}

	return function(name)
		if names[name] == nil then
			names[name] = 1
			return name
		end

		while true do
			names[name] += 1
			local finalName = `{name}_{names[name]}`

			if names[finalName] == nil then
				return finalName
			end
		end
	end
end

return createUniqueKey


export type CaseFormat = "camel" | "lower" | "pascal" | "upper"

local function formatCase(str: string, caseFormat: CaseFormat?)
	if caseFormat == "pascal" then
		local formatted = str:gsub("^%l", string.upper)
		return formatted
	elseif caseFormat == "lower" then
		return str:lower()
	elseif caseFormat == "camel" then
		local formatted = str:gsub("^%l", string.upper):gsub("^%u", string.lower)
		return formatted
	elseif caseFormat == "upper" then
		return str:upper()
	end

	return str
end

return formatCase


local trimString = require("../trimString")

local function sanitizeName(
	name: string,
	config: {
		reservedWords: { string }?,
	}?
)
	local reservedWords = config and config.reservedWords

	-- Replace all non-alphanumeric characters with underscores
	name = trimString(name):gsub("[^%w_]+", "_")

	-- If the name is a reserved word, prefix it with `var_`
	if reservedWords and table.find(reservedWords, name) then
		return `var_{name}`
	end

	-- If the name starts with a number, prefix it with `var_`
	if name:match("^%d") then
		return `var_{name}`
	end

	-- If the name is empty, return `var_undefined`
	if name == "" then
		return "var_undefined"
	end

	return name
end

return sanitizeName


local function trimString(str: string)
	local trimmed = str:gsub("^%s*(.-)%s*$", "%1")
	return trimmed
end

return trimString


export type UserFormats = {
	color3: Color3Format,
	udim2: UDim2Format,
	numberRange: NumberRangeFormat,
	enum: EnumFormat,
	normalId: NormalIdFormat,
	brickColor: BrickColorFormat,
	physicalProperties: PhysicalPropertiesFormat,
	udim: UDimFormat,
	cframe: CFrameFormat,
	vector2: Vector2Format,
	vector3: Vector3Format,
	font: FontFormat,
	colorSequence: ColorSequenceFormat,
	numberSequence: NumberSequenceFormat,
}

export type Language = "luau" | "jsx"

local DEFAULT_LANGUAGE: Language = "luau"
local DEFAULT_USER_FORMATS: UserFormats = {
	color3 = "full",
	udim2 = "full",
	numberRange = "full",
	enum = "full",
	normalId = "full",
	brickColor = "full",
	physicalProperties = "full",
	udim = "full",
	cframe = "full",
	vector2 = "full",
	vector3 = "full",
	font = "full",
	colorSequence = "full",
	numberSequence = "full",
}

local function formatNumber(value: number, precision: number?)
	if value == math.huge then
		return "math.huge"
	elseif value == -math.huge then
		return "-math.huge"
	end

	return string.format(`%.{precision or 3}g`, value)
end

local function formatString(value: string, lang: Language?)
	local isMultiLine = value:find("\n")

	if isMultiLine then
		if lang == "jsx" then
			return `\`{value:gsub("`", "\\`")}\``
		end

		return `[[{value:gsub("]]", "]\\]")}]`
	end

	return `"{value:gsub('"', '\\"')}"`
end

export type Color3Format = "full" | "hex" | "hsv" | "rgb"
local function formatColor3(format: Color3Format, _userFormats: UserFormats)
	return function(value: Color3, lang: Language?)
		if format == "hex" then
			return `Color3.fromHex("#{value:ToHex()}")`
		elseif format == "hsv" then
			local h, s, v = value:ToHSV()

			local hue = formatNumber(math.clamp(h, 0, 1))
			local sat = formatNumber(math.clamp(s, 0, 1))
			local val = formatNumber(math.clamp(v, 0, 1))

			return `Color3.fromHSV({hue}, {sat}, {val})`
		elseif format == "rgb" then
			local r = formatNumber(math.clamp(value.R, 0, 1))
			local g = formatNumber(math.clamp(value.G, 0, 1))
			local b = formatNumber(math.clamp(value.B, 0, 1))

			return `Color3.fromRGB({r}, {g}, {b})`
		end

		if value.R == 0 and value.G == 0 and value.B == 0 then
			if lang == "jsx" then
				return "new Color()"
			end

			return "Color3.new()"
		end

		local r = formatNumber(math.clamp(value.R, 0, 1))
		local g = formatNumber(math.clamp(value.G, 0, 1))
		local b = formatNumber(math.clamp(value.B, 0, 1))

		if lang == "jsx" then
			return `new Color({r}, {g}, {b})`
		end

		return `Color3.new({r}, {g}, {b})`
	end
end

export type UDim2Format = "full"
local function formatUDim2(_format: UDim2Format, _userFormats: UserFormats)
	return function(value: UDim2, lang: Language?)
		local xs = value.X.Scale
		local xo = value.X.Offset
		local ys = value.Y.Scale
		local yo = value.Y.Offset

		if xs == 0 and xo == 0 and ys == 0 and yo == 0 then
			if lang == "jsx" then
				return "new UDim2()"
			end

			return "UDim2.new()"
		end

		local xss = formatNumber(xs)
		local xos = formatNumber(xo)
		local yss = formatNumber(ys)
		local yos = formatNumber(yo)

		if lang == "jsx" then
			return `new UDim2({xss}, {xos}, {yss}, {yos})`
		end

		return `UDim2.new({xss}, {xos}, {yss}, {yos})`
	end
end

export type NumberRangeFormat = "full"
local function formatNumberRange(_format: NumberRangeFormat, _userFormats: UserFormats)
	return function(value: NumberRange, lang: Language?)
		local min = formatNumber(value.Min)
		local max = formatNumber(value.Max)

		if lang == "jsx" then
			return `new NumberRange({min}, {max})`
		end

		return `NumberRange.new({min}, {max})`
	end
end

export type EnumFormat = "full" | "number" | "string"
local function formatEnum(format: EnumFormat, _userFormats: UserFormats)
	return function(value: EnumItem, _lang: Language?)
		if format == "number" then
			return tostring(value.Value)
		elseif format == "string" then
			return `"{value.Name}"`
		end

		return tostring(value)
	end
end

export type NormalIdFormat = "full"
local function formatNormalIdDataType<T>(className: string, __datatype: T)
	return function(_format: NormalIdFormat, userFormats: UserFormats)
		return function(value: T, lang: Language?)
			local axes = {}

			for _, normalId in Enum.NormalId:GetEnumItems() do
				if (value :: any)[normalId.Name] then
					table.insert(axes, formatEnum("full", userFormats)(normalId, lang))
				end
			end

			local values = table.concat(axes, ", ")

			if lang == "jsx" then
				return `new {className}({values})`
			end

			return `{className}.new({values})`
		end
	end
end

local FORMAT_AXES_TYPE_INSTANCE = (nil :: never) :: Axes
local formatAxes = formatNormalIdDataType("Axes", FORMAT_AXES_TYPE_INSTANCE)

local FORMAT_FACES_TYPE_INSTANCE = (nil :: never) :: Faces
local formatFaces = formatNormalIdDataType("Faces", FORMAT_FACES_TYPE_INSTANCE)

export type BrickColorFormat = "full" | "rgb" | "number"
local function formatBrickColor(format: BrickColorFormat, _userFormats: UserFormats)
	return function(value: BrickColor, lang: Language?)
		if format == "rgb" then
			local r = formatNumber(math.clamp(value.r, 0, 1))
			local g = formatNumber(math.clamp(value.g, 0, 1))
			local b = formatNumber(math.clamp(value.b, 0, 1))

			if lang == "jsx" then
				return `new BrickColor({r}, {g}, {b})`
			end

			return `BrickColor.new({r}, {g}, {b})`
		elseif format == "number" then
			if lang == "jsx" then
				return `new BrickColor({value.Number})`
			end

			return `BrickColor.new({value.Number})`
		end

		if lang == "jsx" then
			return `new BrickColor("{value.Name}")`
		end

		return `BrickColor.new("{value.Name}")`
	end
end

export type PhysicalPropertiesFormat = "full"
local function formatPhysicalProperties(
	_format: PhysicalPropertiesFormat,
	_userFormats: UserFormats
)
	return function(value: PhysicalProperties, lang: Language?)
		local values = {
			formatNumber(value.Density),
			formatNumber(value.Friction),
			formatNumber(value.Elasticity),
			formatNumber(value.FrictionWeight),
			formatNumber(value.ElasticityWeight),
		}

		if lang == "jsx" then
			return `new PhysicalProperties({table.concat(values, ", ")})`
		end

		return `PhysicalProperties.new({table.concat(values, ", ")})`
	end
end

export type UDimFormat = "full"
local function formatUDim(_format: UDimFormat, _userFormats: UserFormats)
	return function(value: UDim, lang: Language?)
		local scale = formatNumber(value.Scale)
		local offset = formatNumber(value.Offset)

		if lang == "jsx" then
			return `new UDim({scale}, {offset})`
		end

		return `UDim.new({scale}, {offset})`
	end
end

export type CFrameFormat = "full"
local function formatCFrame(_format: CFrameFormat, _userFormats: UserFormats)
	return function(value: CFrame, lang: Language?)
		local components = {}

		for _, component in { value:GetComponents() } do
			table.insert(components, formatNumber(component))
		end

		if lang == "jsx" then
			return `new CFrame({table.concat(components, ", ")})`
		end

		return `CFrame.new({table.concat(components, ", ")})`
	end
end

export type Vector2Format = "full"
local function formatVector2(_format: Vector2Format, _userFormats: UserFormats)
	return function(value: Vector2 | Vector2int16, lang: Language?)
		local dataType = typeof(value)

		local x = formatNumber(value.X)
		local y = formatNumber(value.Y)

		if lang == "jsx" then
			return `new {dataType}({x}, {y})`
		end

		return `{dataType}.new({x}, {y})`
	end
end

export type Vector3Format = "full"
local function formatVector3(_format: Vector3Format, _userFormats: UserFormats)
	return function(value: Vector3 | Vector3int16, lang: Language?)
		local dataType = typeof(value)

		local x = formatNumber(value.X)
		local y = formatNumber(value.Y)
		local z = formatNumber(value.Z)

		if lang == "jsx" then
			return `new {dataType}({x}, {y}, {z})`
		end

		return `{dataType}.new({x}, {y}, {z})`
	end
end

export type FontFormat = "full"
local function formatFont(_format: FontFormat, userFormats: UserFormats)
	return function(value: Font, lang: Language?)
		if value.Style == Enum.FontStyle.Normal and value.Weight == Enum.FontWeight.Regular then
			if lang == "jsx" then
				return `new Font("{value.Family}")`
			end

			return `Font.new("{value.Family}")`
		end

		local style = formatEnum("full", userFormats)(value.Style)
		local weight = formatEnum("full", userFormats)(value.Weight)

		if lang == "jsx" then
			return `new Font(\n\t"{value.Family}",\n\t{style},\n\t{weight}\n)`
		end

		return `Font.new(\n\t"{value.Family}",\n\t{style},\n\t{weight}\n)`
	end
end

export type ColorSequenceFormat = "full"
local function formatColorSequence(_format: ColorSequenceFormat, userFormats: UserFormats)
	return function(value: ColorSequence, lang: Language?)
		local keypoints = {}

		for _, keypoint in value.Keypoints do
			local keypointValue =
				formatColor3(userFormats.color3, userFormats)(keypoint.Value, lang)
			local timeValue = formatNumber(keypoint.Time)

			if lang == "jsx" then
				table.insert(keypoints, `\tnew ColorSequenceKeypoint({timeValue}, {keypointValue})`)
				continue
			end

			table.insert(keypoints, `\tColorSequenceKeypoint.new({timeValue}, {keypointValue})`)
		end

		local keypointsString = table.concat(keypoints, ",\n")

		if lang == "jsx" then
			return `new ColorSequence([\n{keypointsString}\n])`
		end

		return `ColorSequence.new(\{\n{keypointsString}\n})`
	end
end

export type NumberSequenceFormat = "full"
local function formatNumberSequence(_format: NumberSequenceFormat, _userFormats: UserFormats)
	return function(value: NumberSequence, lang: Language?)
		local keypoints = {}

		for _, keypoint in value.Keypoints do
			local keypointValue = formatNumber(keypoint.Value)
			local timeValue = formatNumber(keypoint.Time)
			local envelopeValue = formatNumber(keypoint.Envelope)

			if keypoint.Envelope == 0 then
				if lang == "jsx" then
					table.insert(
						keypoints,
						`\tnew NumberSequenceKeypoint({timeValue}, {keypointValue})`
					)
					continue
				end

				table.insert(
					keypoints,
					`\tNumberSequenceKeypoint.new({timeValue}, {keypointValue})`
				)
				continue
			end

			if lang == "jsx" then
				table.insert(
					keypoints,
					`\tnew NumberSequenceKeypoint({timeValue}, {keypointValue}, {envelopeValue})`
				)
				continue
			end

			table.insert(
				keypoints,
				`\tNumberSequenceKeypoint.new({timeValue}, {keypointValue}, {envelopeValue})`
			)
		end

		local keypointsString = table.concat(keypoints, ",\n")

		if lang == "jsx" then
			return `new NumberSequence([\n{keypointsString}\n])`
		end

		return `NumberSequence.new(\{\n{keypointsString}\n})`
	end
end

local function formatValue(
	value: any,
	config: {
		userFormats: UserFormats?,
		language: Language?,
	}?
): string?
	local lang: Language = config and config.language or DEFAULT_LANGUAGE
	local userFormats = config and config.userFormats or DEFAULT_USER_FORMATS

	local dataType = typeof(value)

	if dataType == "Color3" then
		local format: Color3Format = (
			userFormats.color3 or DEFAULT_USER_FORMATS.color3
		) :: Color3Format

		return formatColor3(format, userFormats)(value, lang)
	elseif dataType == "UDim2" then
		local format: UDim2Format = (userFormats.udim2 or DEFAULT_USER_FORMATS.udim2) :: UDim2Format

		return formatUDim2(format, userFormats)(value, lang)
	elseif dataType == "NumberRange" then
		local format: NumberRangeFormat = (
			userFormats.numberRange or DEFAULT_USER_FORMATS.numberRange
		) :: NumberRangeFormat

		return formatNumberRange(format, userFormats)(value, lang)
	elseif dataType == "EnumItem" then
		local format: EnumFormat = (userFormats.enum or DEFAULT_USER_FORMATS.enum) :: EnumFormat

		return formatEnum(format, userFormats)(value, lang)
	elseif dataType == "Axes" then
		local format: NormalIdFormat = (
			userFormats.normalId or DEFAULT_USER_FORMATS.normalId
		) :: NormalIdFormat

		return formatAxes(format, userFormats)(value, lang)
	elseif dataType == "Faces" then
		local format: NormalIdFormat = (
			userFormats.normalId or DEFAULT_USER_FORMATS.normalId
		) :: NormalIdFormat

		return formatFaces(format, userFormats)(value, lang)
	elseif dataType == "BrickColor" then
		local format: BrickColorFormat = (
			userFormats.brickColor or DEFAULT_USER_FORMATS.brickColor
		) :: BrickColorFormat

		return formatBrickColor(format, userFormats)(value, lang)
	elseif dataType == "PhysicalProperties" then
		local format: PhysicalPropertiesFormat = (
			userFormats.physicalProperties or DEFAULT_USER_FORMATS.physicalProperties
		) :: PhysicalPropertiesFormat

		return formatPhysicalProperties(format, userFormats)(value, lang)
	elseif dataType == "UDim" then
		local format: UDimFormat = (userFormats.udim or DEFAULT_USER_FORMATS.udim) :: UDimFormat

		return formatUDim(format, userFormats)(value, lang)
	elseif dataType == "CFrame" then
		local format: CFrameFormat = (
			userFormats.cframe or DEFAULT_USER_FORMATS.cframe
		) :: CFrameFormat

		return formatCFrame(format, userFormats)(value, lang)
	elseif dataType == "Vector2" or dataType == "Vector2int16" then
		local format: Vector2Format = (
			userFormats.vector2 or DEFAULT_USER_FORMATS.vector2
		) :: Vector2Format

		return formatVector2(format, userFormats)(value, lang)
	elseif dataType == "Vector3" or dataType == "Vector3int16" then
		local format: Vector3Format = (
			userFormats.vector3 or DEFAULT_USER_FORMATS.vector3
		) :: Vector3Format

		return formatVector3(format, userFormats)(value, lang)
	elseif dataType == "Font" then
		local format: FontFormat = (userFormats.font or DEFAULT_USER_FORMATS.font) :: FontFormat

		return formatFont(format, userFormats)(value, lang)
	elseif dataType == "ColorSequence" then
		local format: ColorSequenceFormat = (
			userFormats.colorSequence or DEFAULT_USER_FORMATS.colorSequence
		) :: ColorSequenceFormat

		return formatColorSequence(format, userFormats)(value, lang)
	elseif dataType == "NumberSequence" then
		local format: NumberSequenceFormat = (
			userFormats.numberSequence or DEFAULT_USER_FORMATS.numberSequence
		) :: NumberSequenceFormat

		return formatNumberSequence(format, userFormats)(value, lang)
	end

	if dataType == "Instance" then
		return
	elseif dataType == "number" then
		return formatNumber(value)
	elseif dataType == "string" then
		return formatString(value, lang)
	end

	if type(value) == "userdata" then
		if lang == "jsx" then
			return `new {dataType}({value})`
		end

		return `{dataType}.new({value})`
	end

	return tostring(value)
end

return {
	DEFAULT_USER_FORMATS = DEFAULT_USER_FORMATS,
	DEFAULT_LANGUAGE = DEFAULT_LANGUAGE,

	formatAxes = formatAxes,
	formatBrickColor = formatBrickColor,
	formatColor3 = formatColor3,
	formatColorSequence = formatColorSequence,
	formatCFrame = formatCFrame,
	formatEnum = formatEnum,
	formatFont = formatFont,
	formatNumber = formatNumber,
	formatNumberRange = formatNumberRange,
	formatNumberSequence = formatNumberSequence,
	formatPhysicalProperties = formatPhysicalProperties,
	formatString = formatString,
	formatUDim = formatUDim,
	formatUDim2 = formatUDim2,
	formatVector2 = formatVector2,
	formatVector3 = formatVector3,

	format = formatValue,
}


local Packager = require("@pkg/Packager")

export type SerializedProperty = {
	Type: "serialized",
	Value: string,
	OriginalType: string,
	OriginalValue: any,
}

type SerializedNodeAdditions = {
	VariableName: string,
}

export type SeralizedFlatTreeNode = Packager.FlatTreeNode & SerializedNodeAdditions
export type SerializedTreeNode = Packager.TreeNode & SerializedNodeAdditions

export type SerializedPackage = Packager.Package & {
	Tree: SerializedTreeNode,
}

return {}


local Generators = require("./Generators")
local Serializer = require("./Serializer")

local codify = require("./Codify")

return {
	Generators = Generators,
	Serializer = Serializer,

	codify = codify.codify,
	setPackager = codify.setPackager,
}