--!native
--!optimize 2
-- reference documentation: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent
local Error = require("./Error")
local String = require("./String")
local net = require("@lune/net")

local charCodeAt = String.charCodeAt

local function encodeURIComponent(value: string): string
	local valueLength = utf8.len(value)
	if valueLength == 0 or valueLength == nil then
		return ""
	end

	-- we don't exhaustively test the whole string for invalid characters like ECMA-262 15.1.3 says
	local check = charCodeAt(value, 1)
	if valueLength == 1 then
		if check == 0xD800 then
			error(Error.new("URI malformed"))
		end

		if check == 0xDFFF then
			error(Error.new("URI malformed"))
		end
	end

	if check >= 0xDC00 and check < 0xDFFF then
		error(Error.new("URI malformed"))
	end

	local encoded = net.urlEncode(value)
	-- reverting encoded chars which are not encoded by JS

	local result = string.gsub(
		string.gsub(
			string.gsub(
				string.gsub(
					string.gsub(
						string.gsub(
							string.gsub(string.gsub(string.gsub(encoded, "%%2D", "-"), "%%5F", "_"), "%%2E", "."),
							"%%21",
							"!"
						),
						"%%7E",
						"~"
					),
					"%%2A",
					"*"
				),
				"%%27",
				"'"
			),
			"%%28",
			"("
		),
		"%%29",
		")"
	)
	return result
end

return encodeURIComponent


--!native
--!optimize 2
--!nonstrict
--[[
	deviation: Our constructors currently have no notion of 'super' so any
	such behavior in upstream JS must be implemented manually by setting fields

	A constructor passed to this class would typically look along the lines of:
	function(self, arg, otherArg)
		self.arg = arg
		self.otherArg = otherArg
	end

	But even something like function() end can be passed in as the constructor
	if no fields need to be set. In upstream JS, the equivalent would be
	returning an empty object from the constructor.
]]
return function(parent, childName, constructor)
	local Child = {}
	Child.__index = Child
	Child.__tostring = function(self)
		return getmetatable(parent).__tostring(self)
	end

	local mt = {}

	Child.new = function(...)
		local self = {}
		constructor(self, ...)
		return setmetatable(self, Child)
	end

	if typeof(getmetatable(parent)) == "table" and getmetatable(parent).__call then
		mt.__call = function(_, ...)
			return Child.new(...)
		end
	end

	mt.__index = parent
	mt.__tostring = function(self)
		-- Printing class name
		if self == Child then
			return `{childName}`
		end

		return getmetatable(parent).__tostring(self)
	end

	setmetatable(Child, mt)

	return Child
end


--!native
--!optimize 2
--!strict

local AssertionError = require("./AssertionError")
local Boolean = require("./Boolean")
local Collections = require("./Collections")
local Console = require("./Console")
local Error = require("./Error")
local Math = require("./Math")
local Number = require("./Number")
local PromiseModule = require("./Promise")
local String = require("./String")
local Symbol = require("./Symbol")
local Timers = require("./Timers")
local extends = require("./extends")
local instanceof = require("./InstanceOf")
local types = require("./ES7Types")

export type Array<T> = types.Array<T>
export type AssertionError = AssertionError.AssertionError
export type Error = Error.Error
export type Map<T, V> = types.Map<T, V>
export type Object = types.Object

export type PromiseLike<T> = PromiseModule.PromiseLike<T>
export type Promise<T> = PromiseModule.Promise<T>

export type Set<T> = types.Set<T>
export type Symbol = Symbol.Symbol
export type Timeout = Timers.Timeout
export type Interval = Timers.Interval
export type WeakMap<T, V> = Collections.WeakMap<T, V>

return table.freeze({
	Array = Collections.Array;
	AssertionError = AssertionError;
	Boolean = Boolean;
	console = Console;
	Error = Error;
	extends = extends;
	instanceof = instanceof;
	Math = Math;
	Number = Number;
	Object = Collections.Object;
	Map = Collections.Map;
	coerceToMap = Collections.coerceToMap;
	coerceToTable = Collections.coerceToTable;
	Set = Collections.Set;
	WeakMap = Collections.WeakMap;
	String = String;
	Symbol = Symbol;
	setTimeout = Timers.setTimeout;
	clearTimeout = Timers.clearTimeout;
	setInterval = Timers.setInterval;
	clearInterval = Timers.clearInterval;
	util = {
		inspect = Collections.inspect;
	};
})


--!native
--!optimize 2
--!strict

local Utf8 = {}

function Utf8.Graphemes(value: string, start: number?, finish: number?): () -> (number, number)
	local trueStart = start or 1
	local trueFinish = finish or #value

	local codePoints = {}
	local length = 0
	for point in utf8.codes(value) do
		if point >= trueStart and point <= trueFinish then
			length += 1
			codePoints[length] = point
		end
	end

	codePoints[length + 1] = trueFinish + 1

	local index = 1
	local function iterate(): ()
		if index > length then
			return nil
		end

		local first = codePoints[index]
		local last = codePoints[index + 1] - 1
		index += 1
		return first, last
	end

	return iterate :: never
end

return table.freeze(Utf8)


--!native
--!optimize 2
--!strict

local Utf8 = require("./Utf8")

local LunePolyfill = table.freeze({
	Utf8 = Utf8;
})

return LunePolyfill


--!optimize 2
--!strict

local LuauPath = require("@packages/LuauPath")
local LuauPolyfill = require("@packages/LuauPolyfill")

local fs = require("@lune/fs")
local process = require("@lune/process")

local PathFileSystem = {}

local Path = LuauPath.Path
local console = LuauPolyfill.console

export type Path = LuauPath.Path
export type AsPath = LuauPath.AsPath
export type Metadata = fs.Metadata
export type MetadataKind = fs.MetadataKind
export type MetadataPermissions = fs.MetadataPermissions
export type WriteOptions = fs.WriteOptions
export type Contents = buffer | string

PathFileSystem.Path = Path

local function AsPathToString(asPath: AsPath): string
	if type(asPath) == "string" then
		return asPath
	end

	return Path.from(asPath):toString()
end

function PathFileSystem.Copy(from: AsPath, to: AsPath, overwriteOrOptions: boolean | WriteOptions | nil)
	return fs.copy(AsPathToString(from), AsPathToString(to), overwriteOrOptions)
end
PathFileSystem.copy = PathFileSystem.Copy

function PathFileSystem.IsDirectory(path: AsPath)
	return fs.isDir(AsPathToString(path))
end
PathFileSystem.isDir = PathFileSystem.IsDirectory
function PathFileSystem.IsDirectorySafe(path: AsPath)
	local success, value = pcall(fs.isDir, AsPathToString(path))
	if not success then
		console.warn(`Failed to check if '{path}' is a directory - {value}`)
		return false
	end
	return value
end

function PathFileSystem.IsFile(path: AsPath)
	return fs.isFile(AsPathToString(path))
end
PathFileSystem.isFile = PathFileSystem.IsFile
function PathFileSystem.IsFileSafe(path: AsPath)
	local success, value = pcall(fs.isFile, AsPathToString(path))
	if not success then
		console.warn(`Failed to check if '{path}' is a file - {value}`)
		return false
	end
	return value
end

function PathFileSystem.Metadata(path: AsPath)
	return fs.metadata(AsPathToString(path))
end
PathFileSystem.metadata = PathFileSystem.Metadata

function PathFileSystem.Move(from: AsPath, to: AsPath, overwriteOrOptions: boolean | WriteOptions | nil)
	return fs.move(AsPathToString(from), AsPathToString(to), overwriteOrOptions)
end
PathFileSystem.move = PathFileSystem.Move

function PathFileSystem.ReadDirectory(path: AsPath)
	return fs.readDir(AsPathToString(path))
end
PathFileSystem.readDir = PathFileSystem.ReadDirectory

function PathFileSystem.ReadFile(path: AsPath)
	return fs.readFile(AsPathToString(path))
end
PathFileSystem.readFile = PathFileSystem.ReadFile

function PathFileSystem.RemoveDirectory(path: AsPath)
	return fs.removeDir(AsPathToString(path))
end
PathFileSystem.removeDir = PathFileSystem.RemoveDirectory

function PathFileSystem.RemoveFile(path: AsPath)
	return fs.removeFile(AsPathToString(path))
end
PathFileSystem.removeFile = PathFileSystem.RemoveFile

function PathFileSystem.WriteFile(path: AsPath, contents: Contents)
	return fs.writeFile(AsPathToString(path), contents)
end
PathFileSystem.writeFile = PathFileSystem.WriteFile

function PathFileSystem.WriteDirectory(path: AsPath)
	return fs.writeDir(AsPathToString(path))
end
PathFileSystem.writeDir = PathFileSystem.WriteDirectory

-- function PathFileSystem.Join(...: Path.AsPath) end

function PathFileSystem.GetScriptPath(): Path
	local path = string.match(debug.info(2, "s"), "%[string \"([^\"]*)\"%]")
	if not path then
		error("Failed to get script path")
	end

	local luauPath = path .. ".luau"
	if fs.isFile(luauPath) then
		return Path.new(luauPath)
	end

	local luaPath = path .. ".lua"
	if fs.isFile(luaPath) then
		return Path.new(luaPath)
	end

	if fs.isFile(path) then
		return Path.new(path)
	end

	error("Failed to get script path")
end

local function NormalizePath(path: Path): Path
	return Path.new(path:components():asPathString())
end
PathFileSystem.NormalizePath = NormalizePath

function PathFileSystem.GetAbsolutePathOf(path: Path): Path
	return NormalizePath(PathFileSystem.CWD:join(path))
end

function PathFileSystem.Exists(path: AsPath)
	return fs.isDir(AsPathToString(path)) or fs.isFile(AsPathToString(path))
end

function PathFileSystem.GetNoCurrentDirectoryPathOf(path: Path): Path
	local components = path:components()
	local firstComponent = components:next()
	if not firstComponent then
		return path
	end

	if firstComponent.type == "curDir" then
		return Path.new(components:asPathString())
	end

	return path
end

PathFileSystem.PathSeparator = if process.os == "windows" then "\\" else "/"
-- selene: allow(global_usage)
_G.SYS_PATH_SEPARATOR = PathFileSystem.PathSeparator

PathFileSystem.CWD = NormalizePath(Path.new(process.cwd))

return table.freeze(PathFileSystem)


--!optimize 2
--!strict

local STRING_CASES: {[string]: string} = {}

local function TypeOf(value: any): string
	local typeOf = typeof(value)
	if typeOf == "userdata" or typeOf == "table" then
		local metatable = getmetatable(value)
		if metatable then
			if type(metatable) == "table" then
				local internalType = metatable.__type or metatable.ClassName
				if internalType then
					return internalType
				end
			elseif type(metatable) == "string" then
				return STRING_CASES[metatable] or metatable
			end
		end
	end

	return typeOf
end

return TypeOf


--!native
--!optimize 2
--!strict

local task = require("@lune/task")

local freeThreads: {thread} = table.create(500)
local freeAmount = 0

local function Execute<Arguments...>(callback: (Arguments...) -> (), thread: thread, ...: Arguments...)
	callback(...)
	freeAmount += 1
	freeThreads[freeAmount] = thread
end

local function Yield()
	while true do
		Execute(coroutine.yield())
	end
end

local function FastSpawn<Arguments...>(callback: (Arguments...) -> (), ...: Arguments...)
	local thread: thread = nil
	if freeAmount > 0 then
		thread = freeThreads[freeAmount]
		freeThreads[freeAmount] = nil
		freeAmount -= 1
	else
		thread = coroutine.create(Yield)
		coroutine.resume(thread)
	end

	return task.spawn(thread, callback, thread, ...)
end

return FastSpawn


--!optimize 2
--!strict

local Debug = require("@packages/Debug")
local PathFileSystem = require("@packages/PathFileSystem")

local FileSystemUtilities = {}
local Path = PathFileSystem.Path

local function AsPathToString(asPath: PathFileSystem.AsPath): string
	if type(asPath) == "string" then
		return asPath
	end

	return Path.from(asPath):toString()
end
local function _AsStringToPath(asString: PathFileSystem.AsPath): PathFileSystem.Path
	if type(asString) == "string" then
		return Path.new(asString)
	end

	if Path.is(asString) then
		return asString :: PathFileSystem.Path
	end

	return Path.from(asString)
end

local function GetFileDescendants(directoryPath: PathFileSystem.AsPath)
	local contents = PathFileSystem.ReadDirectory(directoryPath)
	if not contents then
		return {}
	end

	local descendants = {}
	local length = 0
	for _, filePath in contents do
		local childPath = AsPathToString(directoryPath) .. "/" .. filePath
		if PathFileSystem.IsDirectory(childPath) then
			for _, descendant in GetFileDescendants(childPath) do
				length += 1
				descendants[length] = descendant
			end
		else
			length += 1
			descendants[length] = childPath
		end
	end

	return descendants
end
FileSystemUtilities.GetFileDescendants = GetFileDescendants

function FileSystemUtilities.FindFirstFileOfExtension(directoryPath: PathFileSystem.AsPath, extension: string): string?
	if not PathFileSystem.IsDirectory(directoryPath) then
		return nil
	end

	if string.byte(extension, 1) ~= 46 then
		extension = "." .. extension
	end

	extension = `{Debug.EscapeString(extension)}$`
	for _, filePath in PathFileSystem.ReadDirectory(directoryPath) do
		if not PathFileSystem.IsFile(filePath) then
			continue
		end

		if string.match(filePath, extension) then
			return filePath
		end
	end

	return nil
end
function FileSystemUtilities.FindFirstFileOfExtensionPath(
	directoryPath: PathFileSystem.AsPath,
	extension: string
): PathFileSystem.Path?
	if not PathFileSystem.IsDirectory(directoryPath) then
		return nil
	end

	if string.byte(extension, 1) ~= 46 then
		extension = "." .. extension
	end

	extension = `{Debug.EscapeString(extension)}$`
	for _, filePath in PathFileSystem.ReadDirectory(directoryPath) do
		if not PathFileSystem.IsFile(filePath) then
			continue
		end

		if string.match(filePath, extension) then
			return Path.new(filePath)
		end
	end

	return nil
end

function FileSystemUtilities.JustFileName(path: PathFileSystem.AsPath)
	local castPath = AsPathToString(path)
	return string.match(string.match(castPath, "^(.+)%..+$") or castPath, "([^\\/]+)$") or castPath
end
function FileSystemUtilities.JustFileNameWithExtension(path: PathFileSystem.AsPath)
	local castPath = AsPathToString(path)
	return string.match(castPath, "([^\\/]+)$") or castPath
end

local function WithoutExtension(path: PathFileSystem.AsPath): string
	local castPath = AsPathToString(path)
	return string.match(castPath, "^(.+)%..+$") or castPath
end
FileSystemUtilities.WithoutExtension = WithoutExtension

local function GetExtension(path: PathFileSystem.AsPath): string?
	return string.match(AsPathToString(path), "%.([^%.]+)$")
end
FileSystemUtilities.GetExtension = GetExtension

type CreatorFunction = (path: PathFileSystem.AsPath, contents: string?) -> ()
type SafeCreate =
	((name: PathFileSystem.AsPath, fileType: "File", contents: string) -> string)
	& ((name: PathFileSystem.AsPath, fileType: "Directory") -> string)

local function GetNewFilePath(withoutExtension: string, index: number, extension: string?): string
	return if extension then `{withoutExtension} ({index}).{extension}` else `{withoutExtension} ({index})`
end

local function SafeCreate(name: PathFileSystem.AsPath, fileType: "File" | "Directory", contents: string?)
	local checkerFunction: typeof(PathFileSystem.IsDirectory) = if fileType == "File"
		then PathFileSystem.IsFile
		else PathFileSystem.IsDirectory

	local creatorFunction = (
		if fileType == "File" then PathFileSystem.WriteFile else PathFileSystem.WriteDirectory
	) :: CreatorFunction

	if not checkerFunction(name) then
		creatorFunction(name, contents)
		return name
	end

	local withoutExtension = WithoutExtension(name)
	local extension = GetExtension(name)
	local index = 1

	local newFilePath = GetNewFilePath(withoutExtension, index, extension)
	while checkerFunction(newFilePath) do
		index += 1
		newFilePath = GetNewFilePath(withoutExtension, index, extension)
	end

	creatorFunction(newFilePath, contents)
	return newFilePath
end
FileSystemUtilities.SafeCreate = SafeCreate

local function GetOrCreateDirectory(name: PathFileSystem.AsPath)
	if not PathFileSystem.IsDirectory(name) then
		SafeCreate(name, "Directory")
	end
end
FileSystemUtilities.GetOrCreateDirectory = GetOrCreateDirectory

function FileSystemUtilities.RecursivelyCreateDirectory(path: PathFileSystem.AsPath)
	for _, component in string.split(AsPathToString(path), PathFileSystem.PathSeparator) do
		PathFileSystem.WriteDirectory(component)
	end
end

return table.freeze(FileSystemUtilities)


--!optimize 2
--!strict

local process = require("@lune/process")

local PathUtilities = {}
local SEPARATOR = if process.os == "windows" then "\\" else "/"
PathUtilities.Separator = SEPARATOR

--- Fixes the path so that it's properly formatted to the local operating system
--- @param path The path to format
--- @return A fixed path matching the OS format
function PathUtilities.OsPath(path: string)
	local newPath = ""

	if process.os == "windows" then
		local index = 1
		while index <= #path do
			local character = string.sub(path, index, index)
			if character == "/" then
				character = "\\"
			end

			newPath ..= character
			index += 1
		end

		newPath = string.gsub(newPath, "\\+", "\\")
	else
		local index = 1
		while index <= #path do
			local character = string.sub(path, index, index)
			if character == "\\" then
				character = "/"
			end

			newPath ..= character
			index += 1
		end

		newPath = string.gsub(newPath, "/+", "/")
	end

	return newPath
end

--- Join multiple path parts into a combined path
--- @return A joined path
function PathUtilities.Join(...: string)
	return PathUtilities.OsPath(table.concat({...}, SEPARATOR))
end

--- Gets the extension of the given path (if applicable) - otherwise returns nil
--- @param path The path to get the extension of
--- @return The path extension or nil
function PathUtilities.ExtensionName(path: string): string?
	return string.match(path, ".(%.[A-z0-9_-]+)$")
end

--- Gets the full directory path of the given path
--- @param value The path
--- @return The full directory path without the end item
function PathUtilities.DirectoryName(value: string): string
	local parts = string.split(PathUtilities.OsPath(value), SEPARATOR)
	table.remove(parts)
	return table.concat(parts, SEPARATOR)
end

function PathUtilities.FileName(value: string)
	return string.match(value, `[^{PathUtilities.Separator}]+$`)
end
function PathUtilities.FileNameNoExtension(value: string)
	local extension = PathUtilities.ExtensionName(value)
	local fileName = string.match(value, `[^{PathUtilities.Separator}]+$`)
	if extension then
		return if fileName then string.gsub(fileName, `{extension}$`, "") else nil
	end

	return fileName
end

return table.freeze(PathUtilities)


--!optimize 2
--!strict

local GreenTea = require("@packages/GreenTea")
local PathFileSystem = require("@packages/PathFileSystem")
local serde = require("@lune/serde")

export type Result<T, E> = {
	Success: true,
	Value: T,
} | {
	Success: false,
	Error: E,
}

local isWallyPackage = GreenTea.build({
	dependencies = GreenTea.optional({[GreenTea.string()] = GreenTea.string()});
	["dev-dependencies"] = GreenTea.optional({[GreenTea.string()] = GreenTea.string()});
	["server-dependencies"] = GreenTea.optional({[GreenTea.string()] = GreenTea.string()});
	package = {
		authors = GreenTea.optional(GreenTea.array(GreenTea.string()));
		description = GreenTea.optional(GreenTea.string());
		exclude = GreenTea.optional(GreenTea.array(GreenTea.string()));
		include = GreenTea.optional(GreenTea.array(GreenTea.string()));
		license = GreenTea.optional(GreenTea.union(GreenTea.literal("MIT"), GreenTea.literal("Apache-2.0"))) :: nil | "MIT" | "Apache-2.0";
		name = GreenTea.string();
		private = GreenTea.optional(GreenTea.boolean());
		realm = GreenTea.union(GreenTea.literal("shared"), GreenTea.literal("server")) :: "shared" | "server";
		registry = GreenTea.string();
		version = GreenTea.string();
	};
})
type WallyPackage = typeof(isWallyPackage:type())

local WALLY_TOML_PATH = PathFileSystem.Path.new("wally.toml")

local function ReadWallyToml(): Result<WallyPackage, string>
	if not PathFileSystem.IsFileSafe(WALLY_TOML_PATH) then
		return {
			Success = false;
			Error = `Failed to read {WALLY_TOML_PATH} (not a file)`;
		}
	end

	local readSuccess, fileSource = pcall(PathFileSystem.ReadFile, WALLY_TOML_PATH)
	if not readSuccess then
		return {
			Success = false;
			Error = `Failed to read {WALLY_TOML_PATH} - {fileSource}`;
		}
	end

	local decodeSuccess, toml: WallyPackage = pcall(serde.decode, "toml" :: "toml", fileSource)
	if not decodeSuccess then
		return {
			Success = false;
			Error = `Failed to decode {WALLY_TOML_PATH} - {toml}`;
		}
	end

	local typeSuccess, typeError = isWallyPackage:matches(toml)
	if not typeSuccess then
		return {
			Success = false;
			Error = `Failed to type check {WALLY_TOML_PATH} - {typeError}`;
		}
	end

	return {
		Success = true;
		Value = toml;
	}
end

return ReadWallyToml


--!native
--!optimize 2
--!strict

--[[
MIT License

Copyright (c) 2024 BusyCityGuy

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
]]

--[[
	Lune instances are tables containing a reference to the instance, but Lune creates
	different tables even for the same instance. Jest's caching behavior depends
	on the instance always being the same to look it up by key, so this function serves to
	always return the same "wrapped" instance when given the same instance. It has to also
	override the FindFirstChild, WaitForChild, and Instance property getter to ensure they
	also return a "wrapped" instance.
--]]

local roblox = require("@lune/roblox")

local ReducedInstance = {}
local cache = {}

local function Once(instance: roblox.Instance): roblox.Instance
	assert(instance, "Instance is nil")

	local debugId = instance:GetDebugId()
	if cache[debugId] then
		return cache[debugId]
	end

	local self = setmetatable({
		instance = instance;
	}, {
		-- selene: allow(shadowing)
		__index = function(self, key): any?
			local value = self.instance[key]
			if type(value) == "function" then
				if key == "FindFirstChild" then
					return function(self, ...): roblox.Instance?
						local child = self.instance:FindFirstChild(...)
						if child then
							return Once(child :: roblox.Instance)
						end
						return
					end
				elseif key == "WaitForChild" then
					return function(self, ...)
						return Once(self.instance:WaitForChild(...) :: roblox.Instance)
					end
				else
					return function(self, ...)
						return value(self.instance, ...)
					end
				end
			elseif typeof(value) == "Instance" then
				return Once((value :: never) :: roblox.Instance)
			end

			return value
		end;
		-- selene: allow(shadowing)
		__newindex = function(self, key, value)
			self.instance[key] = value
		end;
		__metatable = false;
	})

	cache[debugId] = self

	return (self :: any) :: roblox.Instance
end
ReducedInstance.Once = Once

return table.freeze(ReducedInstance)


--!native
--!optimize 2
--!strict

--[[
MIT License

Copyright (c) 2024 BusyCityGuy

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
]]

--[[
	Provides a connection to a loop that runs every frame. This is used
	in the custom Heartbeat implementation in lune/test.luau
--]]

local FastSpawn = require("@utilities/FastSpawn")
local task = require("@lune/task")

type Subscription = {
	Connected: boolean,
	Function: (deltaTime: number) -> (),
}
export type Connection = {
	Connected: boolean,
	Disconnect: (self: Connection) -> (),
}

local Runtime = {}

local isRunning = false
local subscriptions: {Subscription} = {}

local function Loop()
	if isRunning then
		return
	end

	isRunning = true
	while #subscriptions > 0 do
		local deltaTime = task.wait()
		for _, subscription in subscriptions do
			if subscription.Connected then
				subscription.Function(deltaTime)
			end
		end

		for index = #subscriptions, 1, -1 do
			if not subscriptions[index].Connected then
				table.remove(subscriptions, index)
			end
		end
	end
	isRunning = false
end

function Runtime.Connect(_: unknown, callback: (deltaTime: number) -> ()): Connection
	local subscription: Subscription = {
		Connected = true;
		Function = callback;
	}
	table.insert(subscriptions, subscription)
	FastSpawn(Loop)

	return {
		Connected = true;
		Disconnect = function(self)
			self.Connected = false
			subscription.Connected = false
		end;
	}
end

return table.freeze(Runtime)


--!optimize 2
--!strict

local StringUtilities = {}

function StringUtilities.ToPascalCase(value: string)
	return (
		string.gsub(
			string.gsub(string.gsub(string.lower(value), "[ _-](%a)", string.upper), "^%a", string.upper),
			"%p",
			""
		)
	)
end

return table.freeze(StringUtilities)


--!optimize 2
--!strict
--[[
	Wraps `wally install` with type exports in link files.

	Roughly equivalent to wally-package-types, but with support for generics
	with defaults and some support for WaitForChild require paths.
--]]

local Option = require("@classes/Option")

local LuauLexer = require("@packages/LuauLexer")
local LuauPolyfill = require("@packages/LuauPolyfill")
local PathFileSystem = require("@packages/PathFileSystem")

local luau = require("@lune/luau")
local process = require("@lune/process")
local serde = require("@lune/serde")

local Path = PathFileSystem.Path
local console = LuauPolyfill.console

type Path = PathFileSystem.Path
type Option<T> = Option.Option<T>

local GENERIC_EQUAL_TYPES = {
	"^(%b()%s*,)";
	"^(%b{}%s*,)";
	"^(%S+%b<>%s*,)";
	"^(%S+%s*,)";
}

local function ParseGenerics(generics: string): {string}
	generics = `{generics},`
	local results = {}
	local length = 0
	local cursor = 1

	while true do
		local preTrim = string.match(generics, "^%s*", cursor)
		assert(preTrim, "Failed to pre-trim?")
		cursor += #preTrim

		if #generics - cursor <= 0 then
			break
		end

		local nameWithEquals, equals = string.match(generics, "^(%S+)(%s*=%s*)", cursor)
		if nameWithEquals and equals then
			length += 1
			results[length] = nameWithEquals
			cursor += #nameWithEquals + #equals

			local matched = false
			for _, genericEquals in GENERIC_EQUAL_TYPES do
				local match = string.match(generics, genericEquals, cursor)
				if match then
					cursor += #match
					matched = true
					-- print("matched", generics, "with", match)
					break
				end
			end

			if matched then
				continue
			end
		end

		local name, comma = string.match(generics, "^(%S+)(%s*,)", cursor)
		if name and comma then
			length += 1
			results[length] = name
			cursor += #name + #comma
			continue
		end

		console.warn("Cannot match generics for the following generics:")
		console.warn(`  {generics}`)
		console.warn("Value at cursor:")
		console.warn(`  {string.sub(generics, cursor)}`)
		process.exit(1)
	end

	return results
end

local function RemoveComments(contents: string)
	local newContents = {}
	local length = 0

	for tokenType: LuauLexer.TokenType, substring in LuauLexer.Scan(contents) do
		if tokenType == "comment" then
			continue
		end
		length += 1
		newContents[length] = substring
	end

	return table.concat(newContents)
end

local function CollectTypes(path: Path): {string}
	local result = {}
	local length = 0

	for match in string.gmatch(RemoveComments(PathFileSystem.ReadFile(path)), "export%stype%s([^\n]*)") do
		local name, generics = string.match(match, "^(%S+)(%b<>)")
		if name and generics then
			local genericsInner = string.match(generics, "<(.*)>")
			assert(genericsInner, "Failed to parse generics")

			length += 1
			result[length] = `{name}<{table.concat(ParseGenerics(genericsInner), ", ")}>`
		else
			length += 1
			result[length] = string.match(match, "^(%S+)") :: string
		end
	end

	return result
end

type PathResolver = typeof(setmetatable(
	{} :: {
		FindFirstChild: (self: PathResolver, childName: string, recursive: boolean?) -> PathResolver,
		WaitForChild: (self: PathResolver, childName: string) -> PathResolver,
		[string]: PathResolver,
	},
	{} :: {__call: (self: PathResolver) -> string}
))

local PARENT_PATH_VALUE = utf8.char(128331, 9832)

local PathResolverMetatable = {}
function PathResolverMetatable:__call()
	local components = table.clone(self.__components)
	local root = table.remove(components, 1) :: string
	if not root then
		error("Cannot get root of empty path?")
	end

	local path = Path.new(root)
	for _, component in components do
		if component == PARENT_PATH_VALUE then
			local parentPath = path:parent()
			if not parentPath then
				error(`Couldn't get parent of {path}?`)
			end

			path = parentPath
		else
			path = path:join(component)
		end
	end

	return path:toString()
end
function PathResolverMetatable:__index(path: string): any
	if path == "Parent" then
		table.insert(self.__components, PARENT_PATH_VALUE)
	elseif path == "WaitForChild" then
		return function(_, childName)
			table.insert(self.__components, childName)
			return self
		end
	elseif path == "FindFirstChild" then
		return function(_, childName, recursive)
			if recursive then
				error("recursive not supported yet!")
			end

			table.insert(self.__components, childName)
			return self
		end
	else
		table.insert(self.__components, path)
	end

	return self
end

local function NewPathResolver(base: string): PathResolver
	local pathResolver = setmetatable({__components = {base}}, PathResolverMetatable) :: never
	return pathResolver
end

local function Pathify(value: string): Option<Path>
	return Option.Some(Path.new(value))
end

local function ResolveLinkTargetPath(linkPath: Path): Option<Path>
	local contents = PathFileSystem.ReadFile(linkPath)
	return Option.Wrap(string.match(contents, "return require%((.+)%)")):AndThen(function(requireInner)
		local resolveFunction = luau.load(`local script = ...; return {requireInner}()`, {
			debugName = `resolve {linkPath}`;
		})
		return Option.Wrap(resolveFunction(NewPathResolver(linkPath:toString()))):AndThen(Pathify)
	end)
end

local POTENTIAL_LUAU_INITS = {
	"init.luau";
	"init.server.luau";
	"init.client.luau";
	"init.lua";
	"init.server.lua";
	"init.client.lua";
}

local function GetMainLuauPath(path: Path): Option<Path>
	if PathFileSystem.IsFile(path) then
		return Option.Some(path)
	end

	local projectFile = path:join("default.project.json")
	if PathFileSystem.IsFile(projectFile) then
		local project = serde.decode("json", PathFileSystem.ReadFile(projectFile))
		local tree = project.tree
		if tree then
			local treePath = tree["$path"]
			if treePath then
				return GetMainLuauPath(path:join(treePath))
			end
		end
	end

	for _, luaSourceContainer in POTENTIAL_LUAU_INITS do
		local luauPath = path:join(luaSourceContainer)
		if PathFileSystem.IsFile(luauPath) then
			return Option.Some(luauPath)
		end
	end

	return Option.None
end

local function RewriteLink(path: Path)
	local contents = PathFileSystem.ReadFile(path)
	if string.find(contents, "-- fixed types", 1, true) then
		return
	end

	ResolveLinkTargetPath(path):FastMatchVoid(function(linkTargetPath)
		GetMainLuauPath(linkTargetPath):FastMatchVoid(function(mainLuauPath)
			local types = CollectTypes(mainLuauPath)
			local requireExpression = string.match(contents, "return%s+(require[^\n]+)")

			local newContentsBuilder = {
				"-- fixed types";
				`local LINK = {requireExpression}`;
			}
			local length = 2

			for _, item in types do
				length += 1
				newContentsBuilder[length] = `export type {item} = LINK.{item}`
			end
			newContentsBuilder[length + 1] = "return LINK"
			PathFileSystem.WriteFile(path, table.concat(newContentsBuilder, "\n"))
		end, function()
			console.warn(`No main luau file found for {path}'s link target {linkTargetPath}`)
		end)
	end, function()
		console.warn(`No link target found for {path}`)
	end)
end

local VALID_EXTENSIONS = {
	luau = true;
	lua = true;
}

local function WallyPackageTypes()
	console.log("Fixing package types...")

	local packages = Path.new("Packages")
	for _, name in PathFileSystem.ReadDirectory(packages) do
		local path = packages:join(name)
		if VALID_EXTENSIONS[path:extension()] and PathFileSystem.IsFile(path) then
			RewriteLink(path)
		end
	end

	local index = packages:join("_Index")
	for _, nameOuter in PathFileSystem.ReadDirectory(index) do
		local outerPath = index:join(nameOuter)
		for _, nameInner in PathFileSystem.ReadDirectory(outerPath) do
			local path = outerPath:join(nameInner)
			if VALID_EXTENSIONS[path:extension()] and PathFileSystem.IsFile(path) then
				RewriteLink(path)
			end
		end
	end

	console.log("  fixed package types")
end

return WallyPackageTypes


--!optimize 2
--!strict
--[=[ lunar
	about = "Creates a built release of the project."
	args = "[-s --skip-confirmation] [-i --include-packages] [-b --bad-package-types] [package-name]"
]=]

local FileInstance = require("@classes/FileInstance")
local LuauPolyfill = require("@packages/LuauPolyfill")
local PathFileSystem = require("@packages/PathFileSystem")
local PathUtilities = require("@utilities/PathUtilities")
local ReadWallyToml = require("@utilities/ReadWallyToml")
local StringUtilities = require("@utilities/StringUtilities")
local WallyPackageTypes = require("@utilities/WallyPackageTypes")

local process = require("@lune/process")
local serde = require("@lune/serde")
local stdio = require("@lune/stdio")

local console = LuauPolyfill.console

local SKIP_CONFIRMATION = table.find(process.args, "-s") or table.find(process.args, "--skip-confirmation")
local INCLUDE_PACKAGES = table.find(process.args, "-i") or table.find(process.args, "--include-packages")
local USE_BAD_WALLY_PACKAGE_TYPES = table.find(process.args, "-b") or table.find(process.args, "--bad-package-types")

local PROJECT_NAME = (function()
	for _, argument in process.args do
		if string.byte(argument) == 45 then
			continue
		end

		return argument
	end

	local result = ReadWallyToml()
	if result.Success then
		local packageName = string.match(result.Value.package.name, "^.*/(.*)$")
		if not packageName then
			console.warn("Failed to get package name from wally.toml")
			process.exit(1)
			return "FAILURE"
		end

		return StringUtilities.ToPascalCase(packageName)
	end

	console.warn(result.Error)
	process.exit(1)
	return "FAILURE"
end)()

local ROJO_PROJECT_PATH = "built-release.project.json"
local SOURCEMAP_NAME = "built-sourcemap.json"

local BUILT_PROJECT = "built-project"
local PACKAGES = "Packages"

type Cleanup = () -> ()

local function CreateBuildProject(): Cleanup
	if PathFileSystem.IsDirectory(BUILT_PROJECT) then
		PathFileSystem.RemoveDirectory(BUILT_PROJECT)
	end

	PathFileSystem.Copy("src", BUILT_PROJECT, true)
	local builtProject = FileInstance.Mark(BUILT_PROJECT)
	if PathFileSystem.IsDirectory(PACKAGES) then
		PathFileSystem.RemoveDirectory(PACKAGES)
	end

	local installResult = process.spawn("wally", {"install"})
	if not installResult.ok then
		console.warn(`wally install failed: {installResult.stderr} ({installResult.code})`)
		process.exit(installResult.code)
		return function()
			builtProject:Destroy()
		end
	end

	if not PathFileSystem.IsDirectory(PACKAGES) then
		PathFileSystem.WriteDirectory(PACKAGES)
	end

	local packages = FileInstance.Mark(PACKAGES)

	local tests = builtProject:FindFirstDirectory("__tests__")
	if tests then
		tests:Destroy()
	end

	local stories = builtProject:FindFirstDirectory("Stories")
	if stories then
		stories:Destroy()
	end

	local configuration = builtProject:FindFirstFile("jest.config.luau")
	if configuration then
		configuration:Destroy()
	end

	local descendants = builtProject:GetDescendants()
	for _, child in builtProject:GetChildren() do
		if child.IsDirectory and #child:GetChildren() == 0 then
			child:Destroy()
		end
	end

	if not PathFileSystem.IsFile(ROJO_PROJECT_PATH) then
		local json = serde.encode("json", {
			globIgnorePaths = {
				"**/*.spec.luau";
				"**/*.spec.lua";
				"**/*.story.luau";
				"**/*.story.lua";
				"**/__tests__";
				"**/*.test.luau";
				"**/*.test.lua";
			};
			name = PROJECT_NAME;
			tree = {
				["$path"] = "built-project";
				Packages = if INCLUDE_PACKAGES then {["$path"] = PACKAGES} else nil;
			};
		}, false)
		PathFileSystem.WriteFile(ROJO_PROJECT_PATH, json)
	end

	local sourcemapResult = process.spawn("rojo", {"sourcemap", "--output", SOURCEMAP_NAME, ROJO_PROJECT_PATH})
	if not sourcemapResult.ok then
		console.warn(`rojo sourcemap failed: {sourcemapResult.stderr} ({sourcemapResult.code})`)
		process.exit(sourcemapResult.code)
		return function()
			if PathFileSystem.IsFile(ROJO_PROJECT_PATH) then
				PathFileSystem.RemoveFile(ROJO_PROJECT_PATH)
			end

			builtProject:Destroy()
		end
	end

	if USE_BAD_WALLY_PACKAGE_TYPES then
		local packageTypesResult = process.spawn("wally-package-types", {"--sourcemap", SOURCEMAP_NAME, "Packages/"})
		if not packageTypesResult.ok then
			console.warn(`wally-package-types failed: {packageTypesResult.stderr} ({packageTypesResult.code})`)
			process.exit(packageTypesResult.code)

			return function()
				if PathFileSystem.IsFile(ROJO_PROJECT_PATH) then
					PathFileSystem.RemoveFile(ROJO_PROJECT_PATH)
				end
				if PathFileSystem.IsFile(SOURCEMAP_NAME) then
					PathFileSystem.RemoveFile(SOURCEMAP_NAME)
				end

				builtProject:Destroy()
			end
		end
	else
		local success, value = pcall(function()
			WallyPackageTypes()
		end)

		if not success then
			console.warn(`wally-package-types failed: {value}`)
			process.exit(1)

			return function()
				if PathFileSystem.IsFile(ROJO_PROJECT_PATH) then
					PathFileSystem.RemoveFile(ROJO_PROJECT_PATH)
				end
				if PathFileSystem.IsFile(SOURCEMAP_NAME) then
					PathFileSystem.RemoveFile(SOURCEMAP_NAME)
				end

				builtProject:Destroy()
			end
		end
	end

	-- here is some of the worst code of all time
	if INCLUDE_PACKAGES then
		local packageNames: {[string]: string} = {}
		for _, child in packages:GetChildren() do
			if child.IsDirectory then
				continue
			end

			local packageName = PathUtilities.FileNameNoExtension(child.Name)
			if not packageName or packageNames[packageName] then
				continue
			end

			packageNames[packageName] = `local {packageName} = require%(script.(.*).{packageName}%)`
			console.log(`Adding {packageName}`)
		end

		for _, descendant in descendants do
			if descendant.IsDirectory or descendant.Name == "README.luau" then
				continue
			end

			local extension = PathUtilities.ExtensionName(descendant.Name)
			if not extension then
				console.warn(`No extension found for {descendant:GetFullName()}`)
				continue
			end

			if extension ~= ".lua" and extension ~= ".luau" then
				continue
			end

			local fileSource = descendant:Read()
			if not fileSource then
				console.warn(`Failed to read {descendant:GetFullName()}`)
				continue
			end

			local splitByNewline = string.split(fileSource, "\n")
			local wasModified = false
			if descendant.Name == "init.luau" then
				for index, line in splitByNewline do
					for packageName, pattern in packageNames do
						local parentPath = string.match(line, pattern)
						if not parentPath then
							continue
						end

						if parentPath == "Parent" then
							splitByNewline[index] = `local {packageName} = require(script.Packages.{packageName})`
							wasModified = true
						end
					end
				end
			else
				for index, line in splitByNewline do
					for packageName, pattern in packageNames do
						local parentPath = string.match(line, pattern)
						if not parentPath then
							continue
						end

						local requirePath = {"script"}
						local length = 1

						local wasFound = false

						local object = descendant
						while true do
							local parent = object.Parent

							length += 1
							requirePath[length] = "Parent"

							if parent == builtProject then
								requirePath[length + 1] = "Packages"
								requirePath[length + 2] = packageName
								wasFound = true
								break
							end

							if not parent then
								console.warn(`Failed to find parent for {object:GetFullName()}`)
								break
							end

							object = parent
						end

						if wasFound then
							local newLine = `local {packageName} = require({table.concat(requirePath, ".")})`
							splitByNewline[index] = newLine
							wasModified = true
						end
					end
				end
			end

			if wasModified then
				descendant:Write(table.concat(splitByNewline, "\n"))
			end
		end
	end

	return function()
		if PathFileSystem.IsFile(ROJO_PROJECT_PATH) then
			PathFileSystem.RemoveFile(ROJO_PROJECT_PATH)
		end
		if PathFileSystem.IsFile(SOURCEMAP_NAME) then
			PathFileSystem.RemoveFile(SOURCEMAP_NAME)
		end

		builtProject:Destroy()
	end
end

local function Install(lastCleanup: Cleanup?): Cleanup
	local rbxmResult = process.spawn("rojo", {"build", "--output", `{PROJECT_NAME}.rbxm`, ROJO_PROJECT_PATH})
	if not rbxmResult.ok then
		console.warn(`rojo build rbxm failed: {rbxmResult.stderr} ({rbxmResult.code})`)
		process.exit(rbxmResult.code)

		return function()
			if lastCleanup then
				lastCleanup()
			end
		end
	end

	local rbxmxResult = process.spawn("rojo", {"build", "--output", `{PROJECT_NAME}.rbxmx`, ROJO_PROJECT_PATH})
	if not rbxmxResult.ok then
		console.warn(`rojo build rbxmx failed: {rbxmxResult.stderr} ({rbxmxResult.code})`)
		process.exit(rbxmxResult.code)

		return function()
			if lastCleanup then
				lastCleanup()
			end
		end
	end

	return function()
		if lastCleanup then
			lastCleanup()
		end
	end
end

local cleanup = Install(CreateBuildProject())
if not SKIP_CONFIRMATION then
	stdio.prompt("confirm", "Press enter to continue and cleanup", true)
end
cleanup()

if not PathFileSystem.IsDirectory(PACKAGES) then
	PathFileSystem.WriteDirectory(PACKAGES)
end
if not PathFileSystem.IsDirectory("DevPackages") then
	PathFileSystem.WriteDirectory("DevPackages")
end

-- local sourcemapResult = process.spawn("rojo", {"sourcemap", "--output", "sourcemap.json", "place.project.json"})
-- if not sourcemapResult.ok then
-- 	console.warn(`rojo sourcemap failed: {sourcemapResult.stderr} ({sourcemapResult.code})`)
-- 	process.exit(1)
-- end

-- local packageTypesResult = process.spawn("wally-package-types", {"--sourcemap", "sourcemap.json", "Packages/"})
-- if not packageTypesResult.ok then
-- 	console.warn(`wally-package-types failed: {packageTypesResult.stderr} ({packageTypesResult.code})`)
-- 	process.exit(1)
-- end

-- local devPackageTypesResult = process.spawn("wally-package-types", {"--sourcemap", "sourcemap.json", "DevPackages/"})
-- if not devPackageTypesResult.ok then
-- 	console.warn(`wally-package-types failed: {devPackageTypesResult.stderr} ({devPackageTypesResult.code})`)
-- 	process.exit(1)
-- end


--!optimize 2
--!strict
--[=[ lunar
	about = "Enables FFlagEnableLoadModule in ClientAppSettings.json for Jest tests on Windows."
]=]

local LuauPolyfill = require("@packages/LuauPolyfill")
local PathFileSystem = require("@packages/PathFileSystem")
local process = require("@lune/process")
local serde = require("@lune/serde")

local console = LuauPolyfill.console

if process.env.windir ~= nil then
	local appDataPath = process.env.LOCALAPPDATA
	local robloxVersionsPath = PathFileSystem.Path.from(appDataPath):join("Roblox"):join("Versions")

	if not PathFileSystem.IsDirectory(robloxVersionsPath) then
		error(`Roblox versions folder not found. Please install Roblox. Checked here: {robloxVersionsPath}`)
	end

	local mostRecentStudioVersionPath
	for _, versionDirectoryName in PathFileSystem.ReadDirectory(robloxVersionsPath) do
		local versionDirectoryPath = robloxVersionsPath:join(versionDirectoryName)
		if not PathFileSystem.IsDirectory(versionDirectoryPath) or not PathFileSystem.IsFile(versionDirectoryPath) then
			continue
		end

		mostRecentStudioVersionPath = versionDirectoryPath
		break
	end

	if mostRecentStudioVersionPath == nil then
		error(`Roblox Studio not found. Please install Roblox. Checked here: {robloxVersionsPath}`)
	end

	local clientSettingsDirectory = mostRecentStudioVersionPath:join("ClientSettings")
	if not PathFileSystem.IsDirectory(clientSettingsDirectory) then
		PathFileSystem.WriteDirectory(clientSettingsDirectory)
	end

	local clientAppSettingsPath = clientSettingsDirectory:join("ClientAppSettings.json")
	local clientAppSettings

	if PathFileSystem.IsFile(clientAppSettingsPath) then
		clientAppSettings = serde.decode("json", PathFileSystem.ReadFile(clientAppSettingsPath))

		local metadata = PathFileSystem.Metadata(clientAppSettingsPath)
		if metadata.permissions and metadata.permissions.readOnly then
			local result = process.spawn("attrib", {"-r", clientAppSettingsPath:toString()})
			assert(result.ok, `Failed to remove read-only attribute:\n{result.stderr}`)
		end
	else
		clientAppSettings = {}
	end

	clientAppSettings.FFlagEnableLoadModule = true
	PathFileSystem.WriteFile(clientAppSettingsPath, serde.encode("json", clientAppSettings))

	local result = process.spawn("attrib", {"+r", clientAppSettingsPath:toString()})
	assert(result.ok, `Failed to add read-only attribute:\n{result.stderr}`)

	console.log(`Wrote FFlagEnableLoadModule to ClientAppSettings.json at {clientAppSettingsPath}`)
	process.exit(0)
end

-- TODO: Get the old iMac and figure out Mac
console.warn(`No support for {process.os} yet. ):`, 2)
process.exit(1)


--!optimize 2
--!strict
--[=[ lunar
	about = "Builds and runs the unit tests place file."
	args = "[-d, --debug] [rojo-path]"
]=]

local LuauPolyfill = require("@packages/LuauPolyfill")
local PathFileSystem = require("@packages/PathFileSystem")
local ReducedInstance = require("@utilities/ReducedInstance")
local Runtime = require("@utilities/Runtime")

local luau = require("@lune/luau")
local process = require("@lune/process")
local roblox = require("@lune/roblox")
local serde = require("@lune/serde")
local task = require("@lune/task")

local DateTime = require("@contexts/DateTime")
local Debug = require("@contexts/Debug")
local Random = require("@contexts/Random")
local Utf8 = require("@contexts/Utf8")

local Path = PathFileSystem.Path
local console = LuauPolyfill.console

type Path = PathFileSystem.Path
type RojoProject = {
	name: string,
	tree: any,
}

local DEFAULT_PROJECT_FILE_PATH = Path.new("test.project.json")

local function ReadRojoProject(filePath: PathFileSystem.Path): RojoProject
	assert(
		PathFileSystem.IsFile(filePath),
		`Rojo project file {filePath} not found. Please create it or specify a different file as an argument.`
	)

	local success, value = pcall(serde.decode, "json" :: "json", PathFileSystem.ReadFile(filePath))
	assert(success, `Failed to read Rojo project file {filePath}: {value}`)
	assert(value.name, "Rojo project name is required in the project file")
	return value
end
local function BuildProject(rojoProjectFilePath: PathFileSystem.Path)
	local rojoProject = ReadRojoProject(rojoProjectFilePath)
	local builtProjectFilePath = `{rojoProject.name}.rbxl`
	local root = process.env.HOME or process.env.USERPROFILE

	local rojoPath = Path.new(root):join(".rokit"):join("bin"):join("rojo")

	console.log(`Building project {rojoProjectFilePath} into {builtProjectFilePath} with Rojo...\n`)
	local spawnResult =
		process.spawn(rojoPath:toString(), {"build", rojoProjectFilePath:toString(), "--output", builtProjectFilePath})
	assert(spawnResult.ok, `Failed to build project [{builtProjectFilePath}]: {spawnResult.stderr}`)

	console.log(`Deserializing {builtProjectFilePath}...`)
	local success, result = pcall(roblox.deserializePlace, PathFileSystem.ReadFile(builtProjectFilePath))
	assert(success, `Failed to deserialize built project [{builtProjectFilePath}]: {result}`)

	return result
end

local function ImplementRobloxMethods()
	roblox.implementMethod("Instance", "WaitForChild", function(self, ...)
		local child = self:FindFirstChild(...)
		assert(
			child,
			`WaitForChild is not implemented in Lune, so FindFirstChild was substituted but {self:GetFullName()} does not contain child {(...)} at the time of calling.`
		)
		return child
	end)

	roblox.implementMethod("Instance", "isA", function(self, className: string)
		return self:IsA(className)
	end)

	roblox.implementProperty("RunService", "Heartbeat", function()
		return {
			Connect = Runtime.Connect;
			Wait = function()
				local thread = coroutine.running()
				local connection: Runtime.Connection = nil
				connection = Runtime:Connect(function(deltaTime)
					connection:Disconnect()
					coroutine.resume(thread, deltaTime)
				end)
				return coroutine.yield()
			end;
		}
	end)
end

local arguments = table.clone(process.args)

local forceDebugMode = (function()
	local index = table.find(arguments, "-d") or table.find(arguments, "--debug")
	if index then
		table.remove(arguments, index)
	end
	return index ~= nil
end)()

local rojoProjectFilePath = (function()
	local firstIndex: number? = nil

	for index, argument in arguments do
		if string.byte(argument) ~= 45 and string.sub(argument, -13) == ".project.json" then
			firstIndex = index
			break
		end
	end

	if firstIndex == nil then
		return DEFAULT_PROJECT_FILE_PATH
	end

	local success, pathObject = pcall(function()
		return Path.from(arguments[firstIndex])
	end)

	if not success then
		return DEFAULT_PROJECT_FILE_PATH
	end

	return if PathFileSystem.IsFileSafe(pathObject) then pathObject else DEFAULT_PROJECT_FILE_PATH
end)()

if forceDebugMode then
	_G.__DEV__ = true
end

local game = BuildProject(rojoProjectFilePath)
ImplementRobloxMethods()

-- Declaring because it's needed by `loadScript`, but `loadScript` is also needed in `requireModule`. Defined later.
local RequireModule

-- DEPENDENTS: [TestService/Source/run.server.luau]
-- ProcessService isn't implemented in Lune, so this creates a workable implementation of it by mapping it to process.exit
local gameWithContext = setmetatable({
	GetService = function(_self, serviceName: string)
		if serviceName == "ProcessService" then
			return {
				ExitAsync = function(_, code: number)
					process.exit(code)
				end;
			} :: any
		end

		return game:GetService(serviceName)
	end;
}, {__index = game})

local function LoadScript(script: roblox.Instance): (((...any) -> ...any)?, string?)
	script = ReducedInstance.Once(script)
	if not script:IsA("LuaSourceContainer") then
		return nil, "Attempt to load a non LuaSourceContainer"
	end

	local bytecodeSuccess, bytecode = pcall(luau.compile, (script :: any).Source)
	if not bytecodeSuccess then
		return nil, bytecode
	end

	local callableFunction = luau.load(bytecode, {
		debugName = script:GetFullName();
		environment = setmetatable({
			DateTime = DateTime;
			debug = Debug;
			game = gameWithContext;
			Random = Random;
			require = RequireModule;
			script = script;
			task = task;
			tick = os.clock;
			utf8 = Utf8;
		}, {__index = roblox}) :: any;
	})

	return callableFunction
end

-- Override the unimplemented _loader function with the above implementation
Debug._loader = LoadScript

local MODULE_REGISTRY = {}

function RequireModule(moduleScript: roblox.Instance)
	if not moduleScript then
		error("Attempt to require an undefined ModuleScript!")
	end
	if not moduleScript:IsA("ModuleScript") then
		error(`Attempt to require a non-ModuleScript {moduleScript}!`)
	end

	local cached = MODULE_REGISTRY[moduleScript]
	if cached then
		return cached
	end

	local callback, exception = LoadScript(moduleScript)
	assert(callback, exception)

	local result = callback()
	MODULE_REGISTRY[moduleScript] = result
	return result
end

console.log("Starting main...\n")
local TestService = game:GetService("TestService")

local unitTesting = TestService:FindFirstChild("UnitTesting")
assert(unitTesting, "game.TestService.UnitTesting not found")

local execute = unitTesting:FindFirstChild("Execute")
assert(execute, "game.TestService.UnitTesting.Execute not found")

local callback, exception = LoadScript(ReducedInstance.Once(execute))
assert(callback, exception)

console.log("Running tests from game.TestService.UnitTesting.Execute...\n")
callback()


--!optimize 2
--!strict
--[=[ lunar
	about = "Builds and runs the unit tests place file."
	args = ""
]=]

error("This is not to be used. Just use the local version.")

local Execute = require("@utilities/Execute")
local PathFileSystem = require("@packages/PathFileSystem")

if not PathFileSystem.IsFile("test-place.rbxl") then
	Execute({"rojo", "build", "--output", "test-place.rbxl", "place.project.json"}):Assert()
end

Execute({"run-in-roblox", "--place", "test-place.rbxl", "--script", "luau/run-package-tests.lua"}, {
	stdio = "inherit";
}):Assert()

if PathFileSystem.IsFile("test-place.rbxl") then
	PathFileSystem.RemoveFile("test-place.rbxl")
end


--!optimize 2
--!strict
--[=[ lunar
	about = "Updates the versions in the Wally configuration as well as the documentation."
	args = "[next-version]"
]=]

local LuauPolyfill = require("@packages/LuauPolyfill")
local PathFileSystem = require("@packages/PathFileSystem")
local SemanticVersion = require("@packages/SemanticVersion")

local process = require("@lune/process")
local serde = require("@lune/serde")

local console = LuauPolyfill.console

local wally = serde.decode("toml", PathFileSystem.ReadFile(PathFileSystem.CWD:join("wally.toml")))
local lastVersion = wally.package.version

local lastVersionSemantic = SemanticVersion.new(lastVersion)

local nextVersionArgument = process.args[1] or tostring(lastVersionSemantic:NextMinor())

local success, nextVersionSemantic = pcall(SemanticVersion.new, nextVersionArgument)
if not success then
	console.warn(`Invalid version argument "{nextVersionArgument}" - {nextVersionSemantic}`)
	process.exit(1)
end

local function SanitizePattern(value: string)
	return (string.gsub(value, "([%.%-%*%+%?%%])", "%%%1"))
end

local function ReplaceVersion(file: string, from: string, to: string)
	local filePath = PathFileSystem.CWD:join(file)
	PathFileSystem.WriteFile(filePath, (string.gsub(PathFileSystem.ReadFile(filePath), SanitizePattern(from), to)))
end

do
	ReplaceVersion("docs/installation.md", `@{lastVersionSemantic}`, `@{nextVersionSemantic}`)
	ReplaceVersion("docs/intro.md", `@{lastVersionSemantic}`, `@{nextVersionSemantic}`)
	ReplaceVersion("README.md", `@{lastVersionSemantic}`, `@{nextVersionSemantic}`)
end
do
	ReplaceVersion("docs/installation.md", `@^{lastVersionSemantic}`, `@^{nextVersionSemantic}`)
	ReplaceVersion("docs/intro.md", `@^{lastVersionSemantic}`, `@^{nextVersionSemantic}`)
	ReplaceVersion("README.md", `@^{lastVersionSemantic}`, `@^{nextVersionSemantic}`)
end
ReplaceVersion("wally.toml", `version = "{lastVersionSemantic}"`, `version = "{nextVersionSemantic}"`)


--!native
--!optimize 2
--!strict

local Error = require(script.Parent.Parent.Parent.Error)
local GreenTea = require(script.Parent.Parent.Parent.GreenTea)
local GreenTeaUtilities = require(script.Parent.Parent.Parent.GreenTeaUtilities)

local Types = require(script.Parent.Parent.Types)

type ComparisonFunction<T> = Types.ComparisonFunction<T>

export type SortedArray<T> = typeof(setmetatable(
	{} :: {
		Array: {T},
		Comparison: ComparisonFunction<T>?,

		-- add when luau sucks less
		-- Map: <U>(self: SortedArray<T>, callback: (value: T, index: number, array: {T}) -> U, comparison: ComparisonFunction<T>?) -> SortedArray<U>,
		-- MapFiltered: <U>(self: SortedArray<T>, callback: (value: T, index: number, array: {T}) -> U, comparison: ComparisonFunction<T>?) -> SortedArray<U>, -- NonNullable<U>

		Insert: (self: SortedArray<T>, value: T) -> number,

		Find: (
			self: SortedArray<T>,
			value: T,
			equalityCheck: ComparisonFunction<T>?,
			lessThan: ComparisonFunction<T>?,
			low: number?,
			high: number?
		) -> number?,
		IndexOf: (
			self: SortedArray<T>,
			value: T,
			equalityCheck: ComparisonFunction<T>?,
			lessThan: ComparisonFunction<T>?,
			low: number?,
			high: number?
		) -> number?,

		Copy: (self: SortedArray<T>) -> {T},
		CopyDeep: (self: SortedArray<T>) -> {T},

		Clone: (self: SortedArray<T>) -> SortedArray<T>,
		CloneDeep: (self: SortedArray<T>) -> SortedArray<T>,

		RemoveElement: (
			self: SortedArray<T>,
			signature: T,
			equalityCheck: ComparisonFunction<T>?,
			lessThan: ComparisonFunction<T>?
		) -> T?,

		RemoveIndex: (self: SortedArray<T>, index: number) -> T?,
		Pop: (self: SortedArray<T>) -> T?,
		Shift: (self: SortedArray<T>) -> T?,

		Sort: (self: SortedArray<T>) -> SortedArray<T>,
		SortIndex: (self: SortedArray<T>, index: number) -> number,
		SortElement: (
			self: SortedArray<T>,
			element: T,
			equalityCheck: ComparisonFunction<T>?,
			lessThan: ComparisonFunction<T>?
		) -> number,

		GetIntersection: (
			self: SortedArray<T>,
			other: SortedArray<T>,
			equalityCheck: ComparisonFunction<T>?,
			lessThan: ComparisonFunction<T>?
		) -> SortedArray<T>,

		IsEmpty: (self: SortedArray<T>) -> boolean,
	},
	{} :: {
		__len: (self: SortedArray<T>) -> number,
		__tostring: (self: SortedArray<T>) -> string,
	}
))

type Static = {
	ClassName: "SortedArray",
	new: <T>(baseArray: {T}?, comparison: ComparisonFunction<T>?) -> SortedArray<T>,
	Is: (value: any) -> boolean,
	instanceof: (value: any) -> boolean,
}
type PrivateStatic = Static & {
	__len: (self: SortedArray<any>) -> number,
	__tostring: (self: SortedArray<any>) -> string,
}

--[=[
	A class to create automatically sorted arrays. Must contain objects
	comparable to one another (that can use the `<` and `==` operators).
	Numbers and strings support these operators by default.

	@tag Optimization
	@tag Supports Debug Mode
	@tag Supports __len
	@tag Supports __tostring
	@class SortedArray
]=]
local SortedArray = {} :: SortedArray<any> & Static
local Private = SortedArray :: SortedArray<any> & PrivateStatic
SortedArray.ClassName = "SortedArray";
(SortedArray :: any).__index = SortedArray

local cached: boolean = nil
local function IsUltraStrictMode(): boolean
	if cached ~= nil then
		return cached
	end

	local isUltraStrictMode = not not _G.__DEV__
	cached = isUltraStrictMode
	return isUltraStrictMode
end
local function Sort<T>(array: {T}, comparison: ComparisonFunction<T>?): {T}
	table.sort(array, comparison)
	return array
end

local IsIntegerRaw = GreenTea.number({integer = true})

local IsInteger = GreenTea.build(IsIntegerRaw)
local IsMaybeArray = GreenTea.build(GreenTea.optional(GreenTea.array(GreenTea.unknown())))
local IsMaybeFunction = GreenTea.build(GreenTea.optional(GreenTea.anyfn()))
local IsMaybeInteger = GreenTea.build(GreenTea.optional(IsIntegerRaw))
local IsSortedArray = GreenTea.build(GreenTeaUtilities.IsInstanceOfLuauClassFromTable(SortedArray, "SortedArray"))
local IsUnknown = GreenTea.build(GreenTea.unknown())

--[=[
	The actual array of data. You can iterate over this or index it.

	:::warning Readonly
	_PLEASE_ treat this array as readonly. Do not mutate it directly.
	:::

	@readonly
	@prop Array ReadonlyArray<T>
	@within SortedArray
]=]

--[=[
	The comparison function of the `SortedArray`.

	@readonly
	@prop Comparison ComparisonFunction<T>?
	@within SortedArray
]=]

local function FindClosest<T>(
	array: {T},
	value: T,
	low: number,
	high: number,
	equalityCheck: ComparisonFunction<T>?,
	lessThan: ComparisonFunction<T>?
): number?
	local middle = math.floor((low + high) / 2)
	if middle == 0 then
		return nil
	end

	local value2 = array[middle]
	while middle ~= high do
		if equalityCheck then
			if equalityCheck(value, value2) then
				return middle
			end
		elseif value == value2 then
			return middle
		end

		local boolean = if lessThan then lessThan(value, value2) else (value :: never) < value2
		if boolean then
			high = middle - 1
		else
			low = middle + 1
		end

		middle = math.floor((low + high) / 2)
		value2 = array[middle]
	end

	return middle
end

--[=[
	Instantiates and returns a new SortedArray, with optional parameters.

	@error DebugTypeError -- Thrown when you are using debug mode and you pass an incorrect type.

	@param baseArray? {T} -- An array of data which will be sorted upon instantiation. If this is omitted, an empty array is used.
	@param comparison? ComparisonFunction<T> -- An optional comparison function which is used to customize the element sorting, which will be given two elements `a` and `b` from the array as parameters. The function should return a boolean value specifying whether the first argument should be before the second argument in the sequence. If no comparison function is passed, the Luau-default `a < b` sorting is used.
	@return SortedArray<T>
]=]
function SortedArray.new<T>(baseArray: {T}?, comparison: ComparisonFunction<T>?): SortedArray<T>
	if IsUltraStrictMode() then
		local typeSuccess0, typeError0 = IsMaybeArray:matches(baseArray)
		if not typeSuccess0 then
			local exception = Error.new(tostring(typeError0))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.new)
			error(exception)
		end
		local typeSuccess1, typeError1 = IsMaybeFunction:matches(comparison)
		if not typeSuccess1 then
			local exception = Error.new(tostring(typeError1))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.new)
			error(exception)
		end
	end

	return setmetatable({
		Array = if baseArray then Sort(table.clone(baseArray), comparison) else {};
		Comparison = comparison;
	}, SortedArray) :: never
end

--[=[
	Checks if `value` is literally a `SortedArray`.

	```lua
	SortedArray.Is(SortedArray.new()) -- true
	SortedArray.Is(SortedArray) -- false
	SortedArray.Is({}) -- false
	SortedArray.Is(nil) -- false
	SortedArray.Is(1) -- false
	```

	@param value unknown
	@return boolean
]=]
function SortedArray.Is(value)
	return type(value) == "table" and getmetatable(value) == SortedArray
end
--[=[
	An alias for [SortedArray.Is].

	@function instanceof
	@within SortedArray

	@param value unknown
	@return boolean
]=]
SortedArray.instanceof = SortedArray.Is

-- function SortedArray:Map(callback, comparison)
-- 	if IsUltraStrictMode() then
-- 		local typeSuccess0, typeError0 = IsFunction:matches(callback)
-- 		if not typeSuccess0 then
-- 			local exception = Error.new(tostring(typeError0))
-- 			exception.name = "DebugTypeError"
-- 			Error.captureStackTrace(exception, SortedArray.Map)
-- 			error(exception)
-- 		end
-- 		local typeSuccess1, typeError1 = IsMaybeFunction:matches(comparison)
-- 		if not typeSuccess1 then
-- 			local exception = Error.new(tostring(typeError1))
-- 			exception.name = "DebugTypeError"
-- 			Error.captureStackTrace(exception, SortedArray.Map)
-- 			error(exception)
-- 		end
-- 	end

-- 	local array = self.Array
-- 	local newArray = table.create(#array)
-- 	for index, value in array do
-- 		newArray[index] = callback(value, index, array)
-- 	end
-- 	return SortedArray.new(newArray, comparison):Sort()
-- end
-- function SortedArray:MapFiltered(callback, comparison)
-- 	if IsUltraStrictMode() then
-- 		local typeSuccess0, typeError0 = IsFunction:matches(callback)
-- 		if not typeSuccess0 then
-- 			local exception = Error.new(tostring(typeError0))
-- 			exception.name = "DebugTypeError"
-- 			Error.captureStackTrace(exception, SortedArray.MapFiltered)
-- 			error(exception)
-- 		end
-- 		local typeSuccess1, typeError1 = IsMaybeFunction:matches(comparison)
-- 		if not typeSuccess1 then
-- 			local exception = Error.new(tostring(typeError1))
-- 			exception.name = "DebugTypeError"
-- 			Error.captureStackTrace(exception, SortedArray.MapFiltered)
-- 			error(exception)
-- 		end
-- 	end

-- 	local array = self.Array
-- 	local newArray = table.create(#array)
-- 	for index, value in array do
-- 		local newValue = callback(value, index, array)
-- 		if newValue ~= nil then
-- 			newArray[index] = newValue
-- 		end
-- 	end
-- 	return SortedArray.new(newArray, comparison):Sort()
-- end

local function Insert<T>(self: SortedArray<T>, value: T): number
	if IsUltraStrictMode() then
		local typeSuccess, typeError = IsUnknown:matches(value)
		if not typeSuccess then
			local exception = Error.new(tostring(typeError))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.Insert)
			error(exception)
		end
	end

	local array = self.Array
	local position = FindClosest(array, value, 1, #array)

	local insertAt = 1
	local value2 = array[position :: never]

	if value2 ~= nil then
		local comparison = self.Comparison
		local boolean = if comparison then comparison(value, value2) else (value :: never) < value2
		insertAt = if boolean then position :: number else (position :: number) + 1
	end

	table.insert(array, insertAt, value)
	return insertAt
end

--[=[
	Inserts an element in the proper place which would preserve the array's
	sorted order. Returns the index the element was inserted.

	@error DebugTypeError -- Thrown when you are using debug mode and you pass an incorrect type.

	@method Insert
	@within SortedArray

	@param value T -- The value you want to insert.
	@return integer -- The index at which it was inserted.
]=]
SortedArray.Insert = Insert

local function Find<T>(
	self: SortedArray<T>,
	value: T,
	equalityCheck: ComparisonFunction<T>?,
	lessThan: ComparisonFunction<T>?,
	low: number?,
	high: number?
): number?
	if IsUltraStrictMode() then
		local typeSuccess0, typeError0 = IsUnknown:matches(value)
		if not typeSuccess0 then
			local exception = Error.new(tostring(typeError0))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.Find)
			error(exception)
		end
		local typeSuccess1, typeError1 = IsMaybeFunction:matches(equalityCheck)
		if not typeSuccess1 then
			local exception = Error.new(tostring(typeError1))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.Find)
			error(exception)
		end
		local typeSuccess2, typeError2 = IsMaybeFunction:matches(lessThan)
		if not typeSuccess2 then
			local exception = Error.new(tostring(typeError2))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.Find)
			error(exception)
		end
		local typeSuccess3, typeError3 = IsMaybeInteger:matches(low)
		if not typeSuccess3 then
			local exception = Error.new(tostring(typeError3))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.Find)
			error(exception)
		end
		local typeSuccess4, typeError4 = IsMaybeInteger:matches(high)
		if not typeSuccess4 then
			local exception = Error.new(tostring(typeError4))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.Find)
			error(exception)
		end
	end

	local array = self.Array
	local position = FindClosest(array, value, low or 1, high or #array, equalityCheck, lessThan or self.Comparison)
	local boolean = false

	if position then
		boolean = if equalityCheck then equalityCheck(value, array[position]) else value == array[position]
	end

	return if boolean then position else nil
end

--[=[
	Finds an element in a `SortedArray` and returns its position (or `nil` if
	non-existent).

	@method Find
	@within SortedArray

	@error DebugTypeError -- Thrown when you are using debug mode and you pass an incorrect type.

	@param value T -- The value you want to find.
	@param equalityCheck? ComparisonFunction<T> -- An optional function which checks for equality between the passed-in element and the other elements in the SortedArray.
	@param lessThan? ComparisonFunction<T> -- An optional less-than comparison function, which falls back on the comparison passed in from [SortedArray.new].
	@param low? integer -- An optional lower bound for the search. Defaults to `1`.
	@param high? integer -- An optional upper bound for the search. Defaults to the length of the SortedArray.
	@return integer? -- The index of the element, or `nil` if it doesn't exist.
]=]
SortedArray.Find = Find
--[=[
	An alias for [SortedArray.Find].

	@method IndexOf
	@within SortedArray

	@error DebugTypeError -- Thrown when you are using debug mode and you pass an incorrect type.

	@param value T -- The value you want to find.
	@param equalityCheck? ComparisonFunction<T> -- An optional function which checks for equality between the passed-in element and the other elements in the SortedArray.
	@param lessThan? ComparisonFunction<T> -- An optional less-than comparison function, which falls back on the comparison passed in from [SortedArray.new].
	@param low? integer -- An optional lower bound for the search. Defaults to `1`.
	@param high? integer -- An optional upper bound for the search. Defaults to the length of the SortedArray.
	@return integer? -- The index of the element, or `nil` if it doesn't exist.
]=]
SortedArray.IndexOf = Find

--[=[
	Makes a shallow copy of the SortedArray's array.
	@return {T} -- A shallow copy of the data.
]=]
function SortedArray:Copy()
	return table.clone(self.Array)
end

local function CopyDeep<T>(object: T): T
	local new = table.clone(object :: never)
	for index, value in next, object :: never do
		if type(value) == "table" then
			new[index] = CopyDeep(value)
		end
	end
	return new :: never
end

--[=[
	Makes a deep copy of the SortedArray's array.
	@return {T} -- A deep copy of the data.
]=]
function SortedArray:CopyDeep()
	return CopyDeep(self.Array)
end

--[=[
	Makes a shallow copy of the SortedArray and returns a new SortedArray.
	Probably would recommend [SortedArray.CloneDeep] instead because of
	funny cache misses.

	@return SortedArray<T> -- A shallow copy of the SortedArray.
]=]
function SortedArray:Clone()
	return SortedArray.new(table.clone(self.Array), self.Comparison):Sort()
end

--[=[
	Makes a deep copy of the SortedArray and returns a new SortedArray.
	@return SortedArray<T> -- A deep copy of the SortedArray.
]=]
function SortedArray:CloneDeep()
	return SortedArray.new(CopyDeep(self.Array), self.Comparison):Sort()
end

local function RemoveElement<T>(
	self: SortedArray<T>,
	signature: T,
	equalityCheck: ComparisonFunction<T>?,
	lessThan: ComparisonFunction<T>?
): T?
	if IsUltraStrictMode() then
		local typeSuccess0, typeError0 = IsUnknown:matches(signature)
		if not typeSuccess0 then
			local exception = Error.new(tostring(typeError0))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.RemoveElement)
			error(exception)
		end
		local typeSuccess1, typeError1 = IsMaybeFunction:matches(equalityCheck)
		if not typeSuccess1 then
			local exception = Error.new(tostring(typeError1))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.RemoveElement)
			error(exception)
		end
		local typeSuccess2, typeError2 = IsMaybeFunction:matches(lessThan)
		if not typeSuccess2 then
			local exception = Error.new(tostring(typeError2))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.RemoveElement)
			error(exception)
		end
	end

	local position = Find(self, signature, equalityCheck, lessThan)
	return if position == nil then nil else table.remove(self.Array, position)
end

--[=[
	Searches the array via [SortedArray.Find]. If found, it removes the value
	and returns the value, otherwise returns undefined. Only removes a single
	occurrence.

	@method RemoveElement
	@within SortedArray

	@error DebugTypeError -- Thrown when you are using debug mode and you pass an incorrect type.

	@param signature T -- The value you want to remove.
	@param equalityCheck? ComparisonFunction<T> -- An optional function which checks for equality between the passed-in element and the other elements in the SortedArray.
	@param lessThan? ComparisonFunction<T> -- An optional less-than comparison function, which falls back on the comparison passed in from [SortedArray.new].
	@return T? -- The removed value.
]=]
SortedArray.RemoveElement = RemoveElement

local function RemoveIndex<T>(self: SortedArray<T>, index: number): T?
	if IsUltraStrictMode() then
		local typeSuccess, typeError = IsInteger:matches(index)
		if not typeSuccess then
			local exception = Error.new(tostring(typeError))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.RemoveIndex)
			error(exception)
		end
	end
	return table.remove(self.Array, index)
end

--[=[
	Removes an element from the SortedArray by index. Returns the removed
	element. This is literally just `table.remove`.

	@method RemoveIndex
	@within SortedArray

	@error DebugTypeError -- Thrown when you are using debug mode and you pass an incorrect type.

	@param index integer -- The index of the element to remove.
	@return T? -- The removed element.
]=]
SortedArray.RemoveIndex = RemoveIndex

--[=[
	Pops an element from the SortedArray. This just removes the last element.
	@return T? -- The removed element.
]=]
function SortedArray:Pop()
	local array = self.Array
	local length = #array

	local value = array[length]
	array[length] = nil
	return value
end

--[=[
	Shifts element from the SortedArray. This just removes the first element.
	@return T? -- The removed element.
]=]
function SortedArray:Shift()
	return table.remove(self.Array, 1)
end

--[=[
	Does `table.sort` on the SortedArray's array. Pretty useless.
	@return SortedArray<T>
]=]
function SortedArray:Sort()
	table.sort(self.Array, self.Comparison)
	return self
end

--[=[
	Removes the value at `index` and re-inserts it. This is useful for when a
	value may have updated in a way that could change it's position in a
	`SortedArray`. Returns the new position of the element.

	@error DebugTypeError -- Thrown when you are using debug mode and you pass an incorrect type.
	@error ElementNotFoundError -- Thrown when the element you are trying to sort is not found.

	@param index integer -- The index of the element to sort.
	@return integer -- The new position of the element.
]=]
function SortedArray:SortIndex(index)
	if IsUltraStrictMode() then
		local typeSuccess, typeError = IsInteger:matches(index)
		if not typeSuccess then
			local exception = Error.new(tostring(typeError))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.SortIndex)
			error(exception)
		end
	end

	local removedValue = RemoveIndex(self, index)
	if removedValue == nil then
		local exception = Error.new(`Could not find element at index {index}?`)
		exception.name = "ElementNotFoundError"
		Error.captureStackTrace(exception, SortedArray.SortIndex)
		error(exception)
	end

	return Insert(self, removedValue)
end

--[=[
	Calls [SortedArray.RemoveElement]`(signature, equalityCheck, lessThan)` and
	re-inserts the value. This is useful for when a value may have updated in a
	way that could change it's position in a `SortedArray`. Returns the new
	position of the element.

	@error DebugTypeError -- Thrown when you are using debug mode and you pass an incorrect type.
	@error ElementNotFoundError -- Thrown when the element you are trying to sort is not found.

	@param signature T -- The value you want to remove.
	@param equalityCheck? ComparisonFunction<T> -- An optional function which checks for equality between the passed-in element and the other elements in the SortedArray.
	@param lessThan? ComparisonFunction<T> -- An optional less-than comparison function, which falls back on the comparison passed in from [SortedArray.new].
	@return integer -- The new position of the element.
]=]
function SortedArray:SortElement(signature, equalityCheck, lessThan)
	if IsUltraStrictMode() then
		local typeSuccess0, typeError0 = IsUnknown:matches(signature)
		if not typeSuccess0 then
			local exception = Error.new(tostring(typeError0))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.SortElement)
			error(exception)
		end
		local typeSuccess1, typeError1 = IsMaybeFunction:matches(equalityCheck)
		if not typeSuccess1 then
			local exception = Error.new(tostring(typeError1))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.SortElement)
			error(exception)
		end
		local typeSuccess2, typeError2 = IsMaybeFunction:matches(lessThan)
		if not typeSuccess2 then
			local exception = Error.new(tostring(typeError2))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.SortElement)
			error(exception)
		end
	end

	local removedValue = RemoveElement(self, signature, equalityCheck, lessThan)
	if removedValue == nil then
		local exception = Error.new(`Could not find element {signature}?`)
		exception.name = "ElementNotFoundError"
		Error.captureStackTrace(exception, SortedArray.SortElement)
		error(exception)
	end

	return Insert(self, removedValue)
end

--[=[
	Gets an intersection between two SortedArrays.

	@error DebugTypeError -- Thrown when you are using debug mode and you pass an incorrect type.

	@param other SortedArray<T> -- The other SortedArray to get the intersection with.
	@param equalityCheck? ComparisonFunction<T> -- An optional function which checks for equality between the passed-in element and the other elements in the SortedArray.
	@param lessThan? ComparisonFunction<T> -- An optional less-than comparison function, which falls back on the comparison passed in from [SortedArray.new].
	@return SortedArray<T> -- The intersection of the two SortedArrays. This is a new SortedArray.
]=]
function SortedArray:GetIntersection<T>(other, equalityCheck, lessThan)
	if IsUltraStrictMode() then
		local typeSuccess0, typeError0 = IsSortedArray:matches(other)
		if not typeSuccess0 then
			local exception = Error.new(tostring(typeError0))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.GetIntersection)
			error(exception)
		end
		local typeSuccess1, typeError1 = IsMaybeFunction:matches(equalityCheck)
		if not typeSuccess1 then
			local exception = Error.new(tostring(typeError1))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.GetIntersection)
			error(exception)
		end
		local typeSuccess2, typeError2 = IsMaybeFunction:matches(lessThan)
		if not typeSuccess2 then
			local exception = Error.new(tostring(typeError2))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, SortedArray.GetIntersection)
			error(exception)
		end
	end

	local commonalities: SortedArray<T> = SortedArray.new({}, self.Comparison)
	local count = 0
	local position = 0

	local selfArray = self.Array
	local selfLength = #selfArray

	local otherArray = other.Array
	local otherLength = #otherArray

	if selfLength > otherLength then
		selfLength, otherLength = otherLength, selfLength
		selfArray, otherArray = otherArray, selfArray
		self, other = other, self
	end

	for index = 1, otherLength do
		local current = selfArray[index]
		local currentPosition = other:Find(current, equalityCheck, lessThan, position, otherLength)
		if currentPosition then
			position = currentPosition
			count += 1
			commonalities.Array[count] = current
		end
	end

	return commonalities
end

--[=[
	Checks if the SortedArray is empty.
	@return boolean
]=]
function SortedArray:IsEmpty()
	return #self.Array == 0
end

function Private:__len()
	return #self.Array
end

function Private:__tostring()
	local array = self.Array
	local stringBuilder = table.create(#array)
	for index, value in array do
		stringBuilder[index] = tostring(value)
	end
	return `SortedArray<[{table.concat(stringBuilder, ", ")}]>`
end

return table.freeze(SortedArray :: Static)


--!native
--!optimize 2
--!strict

local Error = require(script.Parent.Parent.Parent.Error)
local GreenTea = require(script.Parent.Parent.Parent.GreenTea)

type Node<K, V> = {
	Key: K,
	Next: Node<K, V>?,
	Previous: Node<K, V>?,
	Value: V,
}

export type LRUCache<K, V> = typeof(setmetatable(
	{} :: {
		Capacity: number,
		Size: number,

		Clear: (self: LRUCache<K, V>) -> (),
		Get: (self: LRUCache<K, V>, key: K) -> V?,
		Set: (self: LRUCache<K, V>, key: K, value: V) -> (),
	},
	{} :: {
		__len: (self: LRUCache<K, V>) -> number,
		__tostring: (self: LRUCache<K, V>) -> string,
	}
))
type Private<K, V> = typeof(setmetatable(
	{} :: {
		Capacity: number,
		Size: number,

		Clear: (self: Private<K, V>) -> (),
		Get: (self: Private<K, V>, key: K) -> V?,
		Set: (self: Private<K, V>, key: K, value: V) -> (),

		Head: Node<K, V>,
		NodesMap: {[K]: Node<K, V>},
		Tail: Node<K, V>,
	},
	{} :: {
		__len: (self: Private<K, V>) -> number,
		__tostring: (self: Private<K, V>) -> string,
	}
))

type Static = {
	ClassName: "LRUCache",
	new: <K, V>(capacity: number) -> LRUCache<K, V>,
	Is: (value: any) -> boolean,
	instanceof: (value: any) -> boolean,
}
type PrivateStatic = Static & {
	__len: (self: Private<any, any>) -> number,
	__tostring: (self: Private<any, any>) -> string,
}

--[=[
	The `LRUCache` (Least Recently Used Cache) class implements a fixed-size
	cache that stores key-value pairs. It uses the Least Recently Used eviction
	policy to manage the cache content when the capacity is exceeded. This
	means that when the cache reaches its capacity, it will remove the least
	recently accessed item to make room for new entries.

	### Features:

	- **Fast Retrieval:** Quickly access cached items using the `Get` method.
	- **Automatic Eviction:** Automatically evicts the least recently used item
	  when adding new entries beyond capacity.
	- **Mutable Size:** Check the current number of items with `Size`.
	- **Capacity Limit:** Set a maximum capacity to control memory usage.
	- **Supports Debug Mode:** You can set `_G.__DEV__` to `true` to enable
	  debug mode. This has to be done BEFORE requiring the module.
	- **Supports `__len`:** Use the length operator `#` to get the cache size.
	- **Supports `__tostring`:** You can call `tostring` on the cache.

	### Example Usage:

	```lua
	_G.__DEV__ = RunService:IsStudio()
	local DataStructures = require("DataStructures")

	-- Create a new cache with a capacity of 2
	local lruCache = DataStructures.LRUCache.new(2)

	-- Add some key-value pairs
	lruCache:Set("apple", "red")
	lruCache:Set("banana", "yellow")

	print(lruCache:Get("apple")) -- Output: "red"
	print(lruCache:Get("banana")) -- Output: "yellow"

	-- Add another item, exceeding the capacity
	lruCache:Set("cherry", "red")

	-- Since "apple" was least recently used, it gets evicted
	print(lruCache:Get("apple")) -- Output: nil
	print(lruCache:Get("cherry")) -- Output: "red"

	-- Check the size of the cache
	print(#lruCache, lruCache.Size) -- Output: 2, 2

	-- Clear the cache
	lruCache:Clear()
	print(#lruCache, lruCache.Size) -- Output: 0, 0
	```

	### Methods:

	- **`LRUCache.new(capacity: number): LRUCache`**
		- Creates a new `LRUCache` instance with the specified capacity.
		- **Parameters:**
			- `capacity` (number): The maximum number of items the cache can
			  hold.
		- **Returns:** A new `LRUCache` object.

	- **`LRUCache:Get(key: K): V?`**
		- Retrieves the value associated with the given key.
		- Marks the key as recently used.
		- **Parameters:**
			- `key` (K): The key to look up.
		- **Returns:** The value associated with the key, or `nil` if not found.

	- **`LRUCache:Set(key: K, value: V): void`**
		- Adds or updates a key-value pair in the cache.
		- If the key already exists, its value is updated, and it is marked as
		  recently used.
		- If adding the new key exceeds the capacity, the least recently used
		  item is evicted.
		- **Parameters:**
			- `key` (K): The key to insert or update.
			- `value` (V): The value to associate with the key.

	- **`LRUCache:Clear(): void`**
		- Removes all items from the cache.

	### Properties:

	- **`LRUCache.Capacity: number`** (read-only)
		- The maximum number of items the cache can hold.

	- **`LRUCache.Size: number`**
		- The current number of items in the cache.

	### Tags:

	- `Supports Debug Mode`: You can set `_G.__DEV__` to `true` to enable
	  debug mode. This has to be done BEFORE requiring the module.
	- `Supports __len`: You can use the `#` operator to get the current size
	  of the cache.
	- `Supports __tostring`: You can call `tostring` on the cache.

	@tag Supports Debug Mode
	@tag Supports __len
	@tag Supports __tostring
	@class LRUCache
]=]
local LRUCache = {} :: LRUCache<any, any> & Static
local Private = LRUCache :: Private<any, any> & PrivateStatic
LRUCache.ClassName = "LRUCache";
(LRUCache :: any).__index = LRUCache

--[=[
	The maximum number of items the cache can hold.
	@prop Capacity integer
	@readonly
	@within LRUCache
]=]
--[=[
	The current number of items in the cache.
	@prop Size integer
	@readonly
	@within LRUCache
]=]

local cached: boolean = nil
local function IsUltraStrictMode(): boolean
	if cached ~= nil then
		return cached
	end

	local isUltraStrictMode = not not _G.__DEV__
	cached = isUltraStrictMode
	return isUltraStrictMode
end
local IsValidCapacity = GreenTea.build(GreenTea.number({integer = true, range = "(0, ]"}))
local IsUnknown = GreenTea.build(GreenTea.unknown())

--[=[
	Creates a new LRU cache.

	@error DebugTypeError -- Thrown when you are using debug mode and you pass an incorrect type.

	@param capacity number -- The maximum number of items the cache can hold.
	@return LRUCache<K, V>
]=]
function LRUCache.new<K, V>(capacity: number): LRUCache<K, V>
	if IsUltraStrictMode() then
		local typeSuccess, typeError = IsValidCapacity:matches(capacity)
		if not typeSuccess then
			local exception = Error.new(tostring(typeError))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, LRUCache.new)
			error(exception)
		end
	end

	local self: Private<K, V> = setmetatable({}, LRUCache) :: never
	self.Capacity = capacity
	self.Size = 0

	self.Head = {} :: Node<K, V>
	self.NodesMap = {}
	self.Tail = {} :: Node<K, V>

	return self
end

--[=[
	Checks if `value` is literally an LRUCache.

	```lua
	LRUCache.Is(LRUCache.new(10)) -- true
	LRUCache.Is(LRUCache) -- false
	LRUCache.Is({}) -- false
	LRUCache.Is(nil) -- false
	LRUCache.Is(1) -- false
	```

	@param value unknown
	@return boolean
]=]
function LRUCache.Is(value: any)
	return type(value) == "table" and getmetatable(value) == LRUCache
end
--[=[
	An alias for [LRUCache.Is].

	@function instanceof
	@within LRUCache

	@param value unknown
	@return boolean
]=]
LRUCache.instanceof = LRUCache.Is

local function Evict<K, V>(self: Private<K, V>, node: Node<K, V>)
	local nodesMap = self.NodesMap
	local key = node.Key
	if nodesMap[key] == nil then
		return
	end

	nodesMap[key] = nil
	self.Size -= 1

	local previousNode = node.Previous :: Node<K, V>
	local nextNode = node.Next :: Node<K, V>

	local head = self.Head
	local tail = self.Tail

	if previousNode == head and nextNode == tail then
		head.Next = nil
		tail.Previous = nil
		self.Size = 0
		return
	end

	if previousNode == head then
		nextNode.Previous = head
		head.Next = nextNode
		return
	end

	if nextNode == tail then
		previousNode.Next = tail
		tail.Previous = previousNode
		return
	end

	previousNode.Next = nextNode
	nextNode.Previous = previousNode
end
local function Append<K, V>(self: Private<K, V>, node: Node<K, V>)
	self.NodesMap[node.Key] = node

	local head = self.Head
	local tail = self.Tail

	if head.Next then
		local tailPrevious = tail.Previous :: Node<K, V>
		tailPrevious.Next = node
		node.Previous = tailPrevious
		node.Next = tail
		tail.Previous = node
	else
		head.Next = node
		tail.Previous = node
		node.Previous = head
		node.Next = tail
	end

	local size = self.Size + 1
	self.Size = size
	if size > self.Capacity then
		Evict(self, head.Next :: Node<K, V>)
	end
end

--[=[
	Gets the value associated with the given key.

	@error DebugTypeError -- Thrown when you are using debug mode and you pass an incorrect type.

	@method Get
	@within LRUCache

	@param key K -- The key to get the value for.
	@return V?
]=]
function Private:Get(key)
	if IsUltraStrictMode() then
		local typeSuccess, typeError = IsUnknown:matches(key)
		if not typeSuccess then
			local exception = Error.new(tostring(typeError))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, LRUCache.Get)
			error(exception)
		end
	end

	local node = self.NodesMap[key]
	if not node then
		return nil
	end

	Evict(self, node)
	Append(self, node)
	return node.Value
end

--[=[
	Sets the value to the given key.

	@error DebugTypeError -- Thrown when you are using debug mode and you pass an incorrect type.

	@method Set
	@within LRUCache

	@param key K -- The key to set the value to.
	@param value V -- The value to set.
]=]
function Private:Set(key, value)
	if IsUltraStrictMode() then
		local typeSuccess0, typeError0 = IsUnknown:matches(key)
		if not typeSuccess0 then
			local exception = Error.new(tostring(typeError0))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, LRUCache.Set)
			error(exception)
		end
		local typeSuccess1, typeError1 = IsUnknown:matches(value)
		if not typeSuccess1 then
			local exception = Error.new(tostring(typeError1))
			exception.name = "DebugTypeError"
			Error.captureStackTrace(exception, LRUCache.Set)
			error(exception)
		end
	end

	local node = self.NodesMap[key]
	if node then
		node.Value = value
		Evict(self, node)
		Append(self, node)
	else
		Append(self, {Key = key, Value = value})
	end
end

--[=[
	Clears the cache.

	@method Clear
	@within LRUCache
]=]
function Private:Clear()
	table.clear(self.NodesMap)
	table.clear(self.Head)
	table.clear(self.Tail)
	self.Size = 0
end

function Private:__len()
	return self.Size
end
function Private:__tostring()
	return "LRUCache"
end

return table.freeze(LRUCache :: Static)


--!optimize 2
--!strict

local TestService = game:GetService("TestService")
local JestGlobals = require(TestService.UnitTesting.DevPackages.JestGlobals)

local LRUCache = require(script.Parent.Parent.LRUCache)

local describe = JestGlobals.describe
local expect = JestGlobals.expect
local it = JestGlobals.it

type LRUCache<K, V> = LRUCache.LRUCache<K, V>

-- _G.__DEV__ = true
local itDebug = (if _G.__DEV__ then it else it.skip) :: typeof(it)

describe("LRUCache", function()
	it("should store and retrieve values correctly", function()
		local lruCache: LRUCache<number, string> = LRUCache.new(2)
		lruCache:Set(1, "one")
		lruCache:Set(2, "two")

		expect(lruCache:Get(1)).toBe("one")
		expect(lruCache:Get(2)).toBe("two")
		expect(lruCache.Size).toBe(2)
		expect(#lruCache).toBe(2)
	end)

	it("should evict the least recently used item when capacity is exceeded", function()
		local lruCache: LRUCache<number, string> = LRUCache.new(2)
		lruCache:Set(1, "one")
		lruCache:Set(2, "two")
		lruCache:Set(3, "three")

		expect(lruCache:Get(1)).toBeUndefined()
		expect(lruCache:Get(2)).toBe("two")
		expect(lruCache:Get(3)).toBe("three")
		expect(lruCache.Size).toBe(2)
		expect(#lruCache).toBe(2)
	end)

	it("should update recently used item on Get", function()
		local lruCache: LRUCache<number, string> = LRUCache.new(2)
		lruCache:Set(1, "one")
		lruCache:Set(2, "two")
		lruCache:Get(1)
		lruCache:Set(3, "three")

		expect(lruCache:Get(1)).toBe("one")
		expect(lruCache:Get(2)).toBeUndefined()
		expect(lruCache:Get(3)).toBe("three")
		expect(lruCache.Size).toBe(2)
		expect(#lruCache).toBe(2)
	end)

	it("should overwrite value if key already exists", function()
		local lruCache: LRUCache<number, string> = LRUCache.new(2)
		lruCache:Set(1, "one")
		lruCache:Set(1, "uno")

		expect(lruCache:Get(1)).toBe("uno")
		expect(lruCache.Size).toBe(1)
		expect(#lruCache).toBe(1)
	end)

	it("should handle Clear correctly", function()
		local lruCache: LRUCache<number, string> = LRUCache.new(2)
		lruCache:Set(1, "one")
		lruCache:Set(2, "two")
		lruCache:Clear()

		expect(lruCache:Get(1)).toBeUndefined()
		expect(lruCache:Get(2)).toBeUndefined()
		expect(lruCache.Size).toBe(0)
		expect(#lruCache).toBe(0)
	end)

	itDebug("should throw with invalid data", function()
		expect(function()
			LRUCache.new(0)
		end).toThrow()

		local lruCache: LRUCache<number, string> = LRUCache.new(2)
		expect(function()
			lruCache:Set(nil :: never, "nil")
		end).toThrow()

		expect(function()
			lruCache:Set(1, nil :: never)
		end).toThrow()
	end)
end)

return false