local luneFileSystem = require("@lune/fs")
local gt = require("../luau_packages/greentea")
local vfs = require("./vfs")
local asPathToString = vfs.asPathToString
local types = require("./types")

local AsPathType = types.AsPathType
local booleanType = types.booleanType
local stringType = types.stringType
local ContentsType = types.ContentsType
local arrayOfStringType = types.arrayOfStringType

--[=[
	@class fs

	`require("@lune/fs")` compat module

	### Example
	```lua
	local fs = require("path/to/pathfs").fs

	fs.readFile("path/to/file")
	fs.writeFile("path/to/file", "content")
	```
]=]
local fs = {}

local overwriteOrOptionsType = gt.build(gt.any({ allowNil = true }) :: (luneFileSystem.WriteOptions | boolean)?)
--[=[
	@within fs

	Copies a file or directory recursively to a new path.

	Throws an error if a file or directory already exists at the target path.
	This can be bypassed by passing `true` as the third argument, or a dictionary of options.
	Refer to the documentation for `WriteOptions` for specific option keys and their values.

	An error will be thrown in the following situations:

	* The current process lacks permissions to read at `from` or write at `to`.
	* Some other I/O error occurred.

	@param from -- The path to copy from
	@param to -- The path to copy to
	@param overwriteOrOptions -- Options for the target path, such as if should be overwritten if it already exists
]=]
function fs.copy(
	from: typeof(AsPathType:type()),
	to: typeof(AsPathType:type()),
	overwriteOrOptions: typeof(overwriteOrOptionsType:type())
)
	assert(AsPathType(from))
	assert(AsPathType(to))
	assert(overwriteOrOptionsType(overwriteOrOptions))

	return luneFileSystem.copy(asPathToString(from), asPathToString(to), overwriteOrOptions)
end

--[=[
	@within fs
	@tag must_use

	Checks if a given path is a directory.

	An error will be thrown in the following situations:

	* The current process lacks permissions to read at `path`.
	* Some other I/O error occurred.

	@param dirPath -- The directory path to check
	@return boolean -- If the path is a directory or not
]=]
function fs.isDir(dirPath: typeof(AsPathType:type())): typeof(booleanType:type())
	assert(AsPathType(dirPath))

	return luneFileSystem.isDir(asPathToString(dirPath))
end

--[=[
	@within fs
	@tag must_use

	Checks if a given path is a file.

	An error will be thrown in the following situations:

	* The current process lacks permissions to read at `path`.
	* Some other I/O error occurred.

	@param filePath -- The file path to check
	@return boolean -- If the path is a file or not
]=]
function fs.isFile(filePath: typeof(AsPathType:type())): typeof(booleanType:type())
	assert(AsPathType(filePath))

	return luneFileSystem.isFile(asPathToString(filePath))
end

--[=[
	@within fs
	@tag must_use

	Gets metadata for the given path.

	An error will be thrown in the following situations:

	* The current process lacks permissions to read at `path`.
	* Some other I/O error occurred.

	@param path -- The path to get metadata for
	@return Metadata -- Metadata for the path
]=]
function fs.metadata(path: typeof(AsPathType:type())): luneFileSystem.Metadata
	assert(AsPathType(path))

	return luneFileSystem.metadata(asPathToString(path))
end

--[=[
	@within fs

	Moves a file or directory to a new path.

	Throws an error if a file or directory already exists at the target path.
	This can be bypassed by passing `true` as the third argument, or a dictionary of options.
	Refer to the documentation for `WriteOptions` for specific option keys and their values.

	An error will be thrown in the following situations:

	* The current process lacks permissions to read at `from` or write at `to`.
	* The new path exists on a different mount point.
	* Some other I/O error occurred.

	@param from -- The path to move from
	@param to -- The path to move to
	@param overwriteOrOptions -- Options for the target path, such as if should be overwritten if it already exists
]=]
function fs.move(
	from: typeof(AsPathType:type()),
	to: typeof(AsPathType:type()),
	overwriteOrOptions: typeof(overwriteOrOptionsType:type())
)
	assert(AsPathType(from))
	assert(AsPathType(to))
	assert(overwriteOrOptionsType(overwriteOrOptions))

	return luneFileSystem.move(asPathToString(from), asPathToString(to), overwriteOrOptions)
end

--[=[
	@within fs
	@tag must_use

	Reads entries in a directory at `path`.

	An error will be thrown in the following situations:

	* `path` does not point to an existing directory.
	* The current process lacks permissions to read the contents of the directory.
	* Some other I/O error occurred.

	@param dirPath -- The directory path to search in
	@return {string} -- A list of files & directories found
]=]
function fs.readDir(dirPath: typeof(AsPathType:type())): typeof(arrayOfStringType:type())
	assert(AsPathType(dirPath))

	return luneFileSystem.readDir(asPathToString(dirPath))
end

--[=[
	@within fs
	@tag must_use

	Reads a file at `path`.

	An error will be thrown in the following situations:

	* `path` does not point to an existing file.
	* The current process lacks permissions to read the file.
	* Some other I/O error occurred.

	@param filePath -- The path to the file to read
	@return string -- The contents of the file
]=]
function fs.readFile(filePath: typeof(AsPathType:type())): typeof(stringType:type())
	assert(AsPathType(filePath))

	return luneFileSystem.readFile(asPathToString(filePath))
end

--[=[
	@within fs

	Removes a directory and all of its contents.

	An error will be thrown in the following situations:

	* `path` is not an existing and empty directory.
	* The current process lacks permissions to remove the directory.
	* Some other I/O error occurred.

	@param path -- The directory to remove
]=]
function fs.removeDir(path: typeof(AsPathType:type()))
	assert(AsPathType(path))

	return luneFileSystem.removeDir(asPathToString(path))
end

--[=[
	@within fs

	Removes a file.

	An error will be thrown in the following situations:

	* `path` does not point to an existing file.
	* The current process lacks permissions to remove the file.
	* Some other I/O error occurred.

	@param path -- The file to remove
]=]
function fs.removeFile(path: typeof(AsPathType:type()))
	assert(AsPathType(path))

	return luneFileSystem.removeFile(asPathToString(path))
end

--[=[
	@within fs

	Writes to a file at `path`.

	An error will be thrown in the following situations:

	* The file's parent directory does not exist.
	* The current process lacks permissions to write to the file.
	* Some other I/O error occurred.

	@param path -- The path of the file
	@param contents -- The contents of the file
]=]
function fs.writeFile(path: typeof(AsPathType:type()), contents: typeof(ContentsType:type()))
	assert(AsPathType(path))
	assert(ContentsType(contents))

	return luneFileSystem.writeFile(asPathToString(path), contents)
end

--[=[
	@within fs

	Creates a directory and its parent directories if they are missing.

	An error will be thrown in the following situations:

	* `path` already points to an existing file or directory.
	* The current process lacks permissions to create the directory or its missing parents.
	* Some other I/O error occurred.

	@param path -- The directory to create
]=]
function fs.writeDir(path: typeof(AsPathType:type()))
	assert(AsPathType(path))

	return luneFileSystem.writeDir(asPathToString(path))
end

return fs


local types = require("./types")
local luauPath = require("../luau_packages/luau_path")
local Path = luauPath.Path

type Component = types.Component
type Components = types.Components
type AsPath = luauPath.AsPath
type Path = luauPath.Path

export type PeekableComponents = typeof(setmetatable(
	{} :: {
		iter: Components,
		peeked: Component | false,
	},
	{} :: PeekableComponentsImpl
))

type PeekableComponentsImpl = {
	__index: PeekableComponentsImpl,
	peek: (self: PeekableComponents) -> Component?,
	next: (self: PeekableComponents) -> Component?,
}

local PeekableComponents = {} :: PeekableComponentsImpl
PeekableComponents.__index = PeekableComponents

function PeekableComponents.next(self)
	if self.peeked then
		local peeked = self.peeked
		self.peeked = false
		return peeked
	end
	return self.iter:next()
end

function PeekableComponents.peek(self)
	if not self.peeked then
		self.peeked = self.iter:next() or false
	end
	return self.peeked or nil
end

local function peekable(iter: Components): PeekableComponents
	return setmetatable({
		iter = iter,
		peeked = false :: false,
	}, PeekableComponents)
end

--[=[
	@within pathfs
	@function normalize

	Normalizes paths similarly to canonicalize, but without performing I/O.

	This is like Python's `os.path.normpath`.

	*Original implementation/source credit goes to [normalize-path](https://docs.rs/normalize-path/0.2.1/src/normalize_path/lib.rs.html#30).*

	Normalize a path without performing I/O.

	All redundant separator and up-level references are collapsed.

	However, this does not resolve links.

	### Example
	```lua
	local path = pathfs.normalize("./path/to/./file") -- "path/to/file"
	```

	@param path AsPath -- The path to normalize
	@return Path -- The normalized path
]=]
return function(path: AsPath): Path
	-- selene: allow(shadowing)
	local path = Path.from(path)
	local components = peekable(path:components())
	local ret
	local c = components:peek()
	if c and c.type == "prefix" then
		local buf = Path.new(c:toString())
		components:next()
		ret = buf
	else
		ret = Path.new("")
	end

	local component = components:next()
	while component do
		if component.type == "prefix" then
			error("unreachable")
		elseif component.type == "rootDir" or component.type == "normal" then
			ret:push(component:toString())
		elseif component.type == "parentDir" then
			ret:pop()
		else
			error("Unknown component type: " .. tostring(component.type))
		end
		component = components:next()
	end

	return ret
end


local types = require("./types")
local AsPathType = types.AsPathType
local luauPath = require("../luau_packages/luau_path")
local Path = luauPath.Path
local process = require("@lune/process")
local normalizePath = require("./normalizePath")

type Path = luauPath.Path
type AsPath = luauPath.AsPath

--[=[
	@class vfs
	@private
]=]
local vfs = {}

local currentDir: Path?

local cwd = normalizePath(process.cwd)

function vfs.createPath(path: AsPath): Path
	-- selene: allow(shadowing)
	local path = Path.from(path)
	if currentDir and not path:isAbsolute() then
		return currentDir:join(path)
	end
	return path
end

function vfs.asPathToString(asPath: AsPath): string
	if type(asPath) == "string" then
		return asPath
	end
	return vfs.createPath(asPath):toString()
end

--[=[
	@within vfs

	Sets the current directory path virtually.

	An error will be thrown in the following situations:

	* Given path is not a relative path.
]=]
function vfs.setCurrentDir(path: AsPath)
	assert(AsPathType(path))

	local newCurrentDir: Path = Path.from(vfs.asPathToString(path))
	if not newCurrentDir:isRelative() then
		error("Current path must be a relative path")
	end
	currentDir = newCurrentDir
	cwd = normalizePath(Path.from(process.cwd):join(newCurrentDir))
end

--[=[
	@within pathfs

	Returns the current working directory as an absolute path.

	@return Path -- The current working directory
]=]
function vfs.cwd(): Path
	return cwd
end

return vfs


local frktest = require("../luau_packages/frktest")
local test = frktest.test
local check = frktest.assert.check
local pathfs = require("../src/lib")
local fs = pathfs.fs

return function()
	local test1 = "tests/test1.txt"
	local test2 = "tests/test2.txt"
	local test3 = "tests/test3.txt"
	local test_content = "Hello, world!"

	test.suite("writing files", function()
		test.case("from string paths", function()
			fs.writeFile(test1, test_content)
			fs.move(test1, test2)
			fs.copy(test2, test3)
			fs.writeFile(test2, "")
			fs.removeFile(test2)
			fs.removeFile(test3)
		end)

		test.case("from components", function()
			local components_test1 = pathfs.Path.from(test1):components()
			local components_test2 = pathfs.Path.from(test2):components()
			local components_test3 = pathfs.Path.from(test3):components()

			fs.writeFile(components_test1, test_content)
			fs.move(components_test1, components_test2)
			fs.copy(components_test2, components_test3)
			fs.writeFile(components_test2, "")
			fs.removeFile(components_test2)
			fs.removeFile(components_test3)
		end)

		test.case("from paths", function()
			local path_test1 = pathfs.Path.from(test1)
			local path_test2 = pathfs.Path.from(test2)
			local path_test3 = pathfs.Path.from(test3)

			fs.writeFile(path_test1, test_content)
			fs.move(path_test1, path_test2)
			fs.copy(path_test2, path_test3)
			fs.writeFile(path_test2, "")
			fs.removeFile(path_test2)
			fs.removeFile(path_test3)
		end)
	end)

	test.suite("reading files", function()
		test.case("from string paths", function()
			fs.writeFile(test1, test_content)
			check.equal(fs.readFile(test1), test_content)
			fs.move(test1, test2)
			check.equal(fs.readFile(test2), test_content)
			fs.copy(test2, test3)
			check.equal(fs.readFile(test3), test_content)
			fs.removeFile(test2)
			fs.removeFile(test3)
		end)

		test.case("from components", function()
			local components_test1 = pathfs.Path.from(test1):components()
			local components_test2 = pathfs.Path.from(test2):components()
			local components_test3 = pathfs.Path.from(test3):components()

			fs.writeFile(components_test1, test_content)
			check.equal(fs.readFile(components_test1), test_content)
			fs.move(components_test1, components_test2)
			check.equal(fs.readFile(components_test2), test_content)
			fs.copy(components_test2, components_test3)
			check.equal(fs.readFile(components_test3), test_content)
			fs.removeFile(components_test2)
			fs.removeFile(components_test3)
		end)

		test.case("from paths", function()
			local path_test1 = pathfs.Path.from(test1)
			local path_test2 = pathfs.Path.from(test2)
			local path_test3 = pathfs.Path.from(test3)

			fs.writeFile(path_test1, test_content)
			check.equal(fs.readFile(path_test1), test_content)
			fs.move(path_test1, path_test2)
			check.equal(fs.readFile(path_test2), test_content)
			fs.copy(path_test2, path_test3)
			check.equal(fs.readFile(path_test3), test_content)
			fs.removeFile(path_test2)
			fs.removeFile(path_test3)
		end)
	end)

	local test_dir = "tests/testdir"

	test.suite("writing directories", function()
		test.case("from string paths", function()
			fs.writeDir(test_dir)
			fs.removeDir(test_dir)
		end)

		test.case("from components", function()
			local components = pathfs.Path.from(test_dir):components()
			fs.writeDir(components)
			fs.removeDir(components)
		end)

		test.case("from paths", function()
			local path = pathfs.Path.from(test_dir)
			fs.writeDir(path)
			fs.removeDir(path)
		end)
	end)

	test.suite("reading directories", function()
		test.case("from string paths", function()
			fs.writeDir(test_dir)
			check.truthy(fs.readDir(test_dir))
			fs.removeDir(test_dir)
		end)

		test.case("from components", function()
			local components = pathfs.Path.from(test_dir):components()
			fs.writeDir(components)
			check.truthy(fs.readDir(components))
			fs.removeDir(components)
		end)

		test.case("from paths", function()
			local path = pathfs.Path.from(test_dir)
			fs.writeDir(path)
			check.truthy(fs.readDir(path))
			fs.removeDir(path)
		end)
	end)
end


local frktest = require("../luau_packages/frktest")
local test = frktest.test
local check = frktest.assert.check
local pathfs = require("../src/lib")

return function()
	test.suite("joining paths", function()
		local expectedResultPath = pathfs.Path.from("tests/test1.txt")

		test.case("path and string", function()
			local path = pathfs.Path.from("tests")
			check.is_true(path:join("test1.txt"):equal(expectedResultPath))
		end)

		test.case("path and path", function()
			local path = pathfs.Path.from("tests")
			local path2 = pathfs.Path.from("test1.txt")
			check.is_true(path:join(path2):equal(expectedResultPath))
		end)

		test.case("path and components", function()
			local path = pathfs.Path.from("tests")
			local components = pathfs.Path.from("test1.txt"):components()
			check.is_true(path:join(components):equal(expectedResultPath))
		end)
	end)
end


local frktest = require("../luau_packages/frktest")
local test = frktest.test
local check = frktest.assert.check
local pathfs = require("../src/lib")

return function()
	test.suite("pathfs.script", function()
		test.case("should handle script path gracefully", function()
			-- Test that script() function exists and is callable
			check.is_true(type(pathfs.script) == "function")

			-- Try to call it and handle potential errors
			local success, result = pcall(function()
				return pathfs.script()
			end)

			if success then
				check.is_true(pathfs.Path.is(result))
			else
				-- It's okay if it fails in test environment
				check.is_true(type(result) == "string")
			end
		end)
	end)

	test.suite("pathfs.absolute", function()
		test.case("should return absolute path for relative path", function()
			local relativePath = "test.txt"
			local absolutePath = pathfs.absolute(relativePath)
			check.is_true(pathfs.Path.is(absolutePath))
			check.is_true(absolutePath:isAbsolute())
		end)

		test.case("should return same path for already absolute path", function()
			local currentDir = pathfs.cwd()
			local absolutePath = pathfs.absolute(currentDir)
			check.is_true(absolutePath:equal(currentDir))
		end)
	end)

	test.suite("pathfs.withoutCurDir", function()
		test.case("should remove current directory component", function()
			local pathWithCurDir = pathfs.Path.from("./test.txt")
			local pathWithoutCurDir = pathfs.withoutCurDir(pathWithCurDir)
			check.is_true(pathWithoutCurDir:equal(pathfs.Path.from("test.txt")))
		end)

		test.case("should return same path if no current directory component", function()
			local normalPath = pathfs.Path.from("test.txt")
			local result = pathfs.withoutCurDir(normalPath)
			check.is_true(result:equal(normalPath))
		end)
	end)

	test.suite("pathfs.diff", function()
		test.case("should calculate relative path between two paths", function()
			local target = pathfs.Path.from("src/file.txt")
			local base = pathfs.Path.from("tests")
			local diff = pathfs.diff(target, base)
			check.truthy(diff)
			if diff then
				check.is_true(pathfs.Path.is(diff))
			end
		end)

		test.case("should return target if target is absolute and base is relative", function()
			local target = pathfs.absolute("src/file.txt")
			local base = pathfs.Path.from("tests")
			local diff = pathfs.diff(target, base)
			check.is_true(diff ~= nil and diff:equal(target))
		end)

		test.case("should work with custom separator", function()
			local target = pathfs.Path.from("src/file.txt")
			local base = pathfs.Path.from("tests")
			local diff = pathfs.diff(target, base, "/")
			check.truthy(diff)
		end)
	end)

	test.suite("pathfs.canonicalize", function()
		test.case("should canonicalize existing path", function()
			local currentDir = pathfs.cwd()
			local canonical = pathfs.canonicalize(currentDir)
			check.is_true(pathfs.Path.is(canonical))
			check.is_true(canonical:isAbsolute())
		end)

		test.case("should throw error for non-existent path", function()
			local nonExistentPath = pathfs.Path.from("non-existent-file-xyz.txt")
			local success = pcall(function()
				pathfs.canonicalize(nonExistentPath)
			end)
			check.is_false(success)
		end)
	end)

	test.suite("pathfs.fromDir", function()
		test.case("should handle fromDir gracefully", function()
			-- Test that fromDir function exists
			check.is_true(type(pathfs.fromDir) == "function")

			-- Try to call it and handle potential errors
			local success, result = pcall(function()
				return pathfs.fromDir("test.txt")
			end)

			if success then
				check.is_true(pathfs.Path.is(result))
			else
				-- It's okay if it fails when script path is not available
				check.is_true(type(result) == "string")
			end
		end)
	end)

	test.suite("pathfs.getDir", function()
		test.case("should handle getDir gracefully", function()
			-- Test that getDir function exists
			check.is_true(type(pathfs.getDir) == "function")

			-- Try to call it and handle potential errors
			local success, result = pcall(function()
				return pathfs.getDir()
			end)

			if success then
				check.is_true(pathfs.Path.is(result))
			else
				-- It's okay if it fails when script path is not available
				check.is_true(type(result) == "string")
			end
		end)
	end)

	test.suite("pathfs.findFile", function()
		test.case("should return nil for non-existent file", function()
			local file = pathfs.findFile("non-existent-file.txt")
			check.falsy(file)
		end)

		test.case("should return FilePath for existing file", function()
			-- Test with a known existing file (README.md should exist)
			local file = pathfs.findFile("README.md")
			if file then
				check.is_true(pathfs.FilePath.is(file))
			else
				-- Test with lib.luau which should definitely exist
				local libFile = pathfs.findFile("src/lib.luau")
				check.truthy(libFile)
				if libFile then
					check.is_true(pathfs.FilePath.is(libFile))
				end
			end
		end)
	end)

	test.suite("pathfs.findDir", function()
		test.case("should return nil for non-existent directory", function()
			local dir = pathfs.findDir("non-existent-dir")
			check.falsy(dir)
		end)

		test.case("should return DirectoryPath for existing directory", function()
			local currentDir = pathfs.cwd()
			local dir = pathfs.findDir(currentDir)
			check.truthy(dir)
			if dir then
				check.is_true(pathfs.DirectoryPath.is(dir))
			end
		end)
	end)

	test.suite("pathfs.getEntries", function()
		test.case("should get entries from current directory", function()
			local currentDir = pathfs.cwd()
			local entries = pathfs.getEntries(currentDir)
			check.is_true(type(entries) == "table")
			check.is_true(#entries > 0)

			-- Check structure of first entry
			local firstEntry = entries[1]
			check.is_true(type(firstEntry.name) == "string")
			check.is_true(pathfs.Path.is(firstEntry.path))
		end)
	end)

	test.suite("pathfs.getDescendantEntries", function()
		test.case("should get descendant entries from current directory", function()
			local currentDir = pathfs.cwd()
			local entries = pathfs.getDescendantEntries(currentDir)
			check.is_true(type(entries) == "table")
			check.is_true(#entries > 0)
		end)
	end)

	test.suite("pathfs.writeFileAll", function()
		test.case("should write file and create directories", function()
			local testPath = pathfs.Path.from("temp-test-dir/test-file.txt")
			local testContent = "test content"

			-- Clean up first if exists
			pcall(function()
				pathfs.fs.removeFile(testPath)
				local parent = testPath:parent()
				if parent then
					pathfs.fs.removeDir(parent)
				end
			end)

			pathfs.writeFileAll(testPath, testContent)

			-- Verify file was created
			check.is_true(pathfs.fs.isFile(testPath))
			check.equal(pathfs.fs.readFile(testPath), testContent)

			-- Clean up
			pathfs.fs.removeFile(testPath)
			local parent = testPath:parent()
			if parent then
				pathfs.fs.removeDir(parent)
			end
		end)
	end)

	test.suite("pathfs normalize function", function()
		test.case("should normalize simple path correctly", function()
			local normalizedPath = pathfs.normalize("src/file.txt")
			check.is_true(pathfs.Path.is(normalizedPath))
			check.is_true(normalizedPath:toString():find("src") ~= nil)
			check.is_true(normalizedPath:toString():find("file.txt") ~= nil)
		end)

		test.case("should work with Path objects", function()
			local path = pathfs.Path.from("src/file.txt")
			local normalizedPath = pathfs.normalize(path)
			check.is_true(pathfs.Path.is(normalizedPath))
			check.is_true(normalizedPath:equal(path))
		end)
	end)

	test.suite("pathfs serialization functions", function()
		test.case("should serialize and deserialize JSON", function()
			local testData = { name = "test", value = 42, nested = { array = {1, 2, 3} } }
			local testPath = "temp-test.json"

			-- Clean up first
			pcall(function()
				pathfs.fs.removeFile(testPath)
			end)

			-- Test serialization
			pathfs.serializeFile(testData, testPath)
			check.is_true(pathfs.fs.isFile(testPath))

			-- Test deserialization
			local deserializedData = pathfs.deserializeFile(testPath)
			check.equal(deserializedData.name, testData.name)
			check.equal(deserializedData.value, testData.value)
			check.equal(#deserializedData.nested.array, 3)

			-- Clean up
			pathfs.fs.removeFile(testPath)
		end)

		test.case("should serialize and deserialize TOML", function()
			local testData = { title = "Test Config", database = { server = "localhost", port = 5432 } }
			local testPath = "temp-test.toml"

			-- Clean up first
			pcall(function()
				pathfs.fs.removeFile(testPath)
			end)

			-- Test serialization
			pathfs.serializeFile(testData, testPath)
			check.is_true(pathfs.fs.isFile(testPath))

			-- Test deserialization
			local deserializedData = pathfs.deserializeFile(testPath)
			check.equal(deserializedData.title, testData.title)
			check.equal(deserializedData.database.server, testData.database.server)
			check.equal(deserializedData.database.port, testData.database.port)

			-- Clean up
			pathfs.fs.removeFile(testPath)
		end)

		test.case("should serialize and deserialize YAML", function()
			local testData = { name = "yaml-test", items = {"item1", "item2", "item3"} }
			local testPath = "temp-test.yaml"

			-- Clean up first
			pcall(function()
				pathfs.fs.removeFile(testPath)
			end)

			-- Test serialization
			pathfs.serializeFile(testData, testPath)
			check.is_true(pathfs.fs.isFile(testPath))

			-- Test deserialization
			local deserializedData = pathfs.deserializeFile(testPath)
			check.equal(deserializedData.name, testData.name)
			check.equal(#deserializedData.items, 3)
			check.equal(deserializedData.items[1], "item1")

			-- Clean up
			pathfs.fs.removeFile(testPath)
		end)

		test.case("should work with .yml extension", function()
			local testData = { test = "yml extension" }
			local testPath = "temp-test.yml"

			-- Clean up first
			pcall(function()
				pathfs.fs.removeFile(testPath)
			end)

			-- Test serialization
			pathfs.serializeFile(testData, testPath)
			check.is_true(pathfs.fs.isFile(testPath))

			-- Test deserialization
			local deserializedData = pathfs.deserializeFile(testPath)
			check.equal(deserializedData.test, testData.test)

			-- Clean up
			pathfs.fs.removeFile(testPath)
		end)

		test.case("should work with FilePath objects", function()
			local testData = { filePath = "test" }
			local testPath = pathfs.Path.from("temp-test-filepath.json")

			-- Clean up first
			pcall(function()
				pathfs.fs.removeFile(testPath)
			end)

			-- Test serialization with Path object
			pathfs.serializeFile(testData, testPath)
			check.is_true(pathfs.fs.isFile(testPath))

			-- Test deserialization with Path object
			local deserializedData = pathfs.deserializeFile(testPath)
			check.equal(deserializedData.filePath, testData.filePath)

			-- Clean up
			pathfs.fs.removeFile(testPath)
		end)

		test.case("should throw error for unsupported file extension", function()
			local testData = { test = "data" }
			local testPath = "temp-test.unsupported"

			local success = pcall(function()
				pathfs.serializeFile(testData, testPath)
			end)
			check.is_false(success)
		end)

		test.case("should throw error for non-existent file in deserialize", function()
			local success = pcall(function()
				pathfs.deserializeFile("non-existent-file.json")
			end)
			check.is_false(success)
		end)

		test.case("should export serializeFile function", function()
			check.is_true(type(pathfs.serializeFile) == "function")
		end)

		test.case("should export deserializeFile function", function()
			check.is_true(type(pathfs.deserializeFile) == "function")
		end)
	end)
end


type WatchHandler = (newValue: any?) -> any

declare debugger: {
	halt: (conditional: boolean?) -> (),
	watch: (name: string, handler: WatchHandler) -> (),
	watchBatch: (vars: { [string]: WatchHandler }) -> (),
	check: <T>(current: T, newValue: T?) -> T,

	watchStack: { [string]: WatchHandler },
}


declare __COMPOSER: {
    Insert: (text: string) -> string,

    genDate: string,
    build: string,
    cfg: string,
    vers: string
}

declare _P: {[any]: any}


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Outputs the current external time as a state object.
]]
local Types = require("../Types")
local External = require("../External")
-- Graph
local change = require("../Graph/change")
-- Utility
local nicknames = require("../Utility/nicknames")

type ExternalTime = Types.StateObject<number>

type Self = ExternalTime

local class = {}
class.type = "State"
class.kind = "ExternalTime"
class.timeliness = "lazy"
class.dependencySet = table.freeze {}
class._EXTREMELY_DANGEROUS_usedAsValue = External.lastUpdateStep()

local METATABLE = table.freeze {__index = class}

local allTimers: {Self} = {}

local function ExternalTime(
	scope: Types.Scope<unknown>
): ExternalTime
	local createdAt = os.clock()
	local self: Self = setmetatable(
		{
			createdAt = createdAt,
			dependentSet = {},
			lastChange = nil,
			scope = scope,
			validity = "invalid"
		},
		METATABLE
	) :: any
	local destroy = function()
		self.scope = nil
		local index = table.find(allTimers, self)
		if index ~= nil then
			table.remove(allTimers, index)
		end
	end
	self.oldestTask = destroy
	nicknames[self.oldestTask] = "ExternalTime"
	table.insert(scope, destroy)
	table.insert(allTimers, self)
	return self
end

function class._evaluate(
	self: Self
): boolean
	-- While someone else could call `change()` on this object, it wouldn't be
	-- idiomatic. So, since the only idiomatic time this function runs is when
	-- the external update step runs, it's safe enough to assume that the result
	-- has always meaningfully changed. The worst that can happen is unexpected
	-- refreshing for people doing unorthodox shenanigans, which is an OK trade.
	return true
end

External.bindToUpdateStep(function(
	externalNow: number
): ()
	class._EXTREMELY_DANGEROUS_usedAsValue = External.lastUpdateStep()
	for _, timer in allTimers do
		change(timer)
	end
end)

-- Do *not* freeze the class table, because it stores the shared value of all
-- external time objects, and is updated every frame because of that.
-- table.freeze(class)
return ExternalTime


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	State object for measuring time since an event using a reference timer.

	TODO: this should not be exposed to users until it has a proper reactive API
	surface
]]
local Types = require("../Types")
-- Memory
local checkLifetime = require("../Memory/checkLifetime")
-- Graph
local depend = require("../Graph/depend")
local change = require("../Graph/change")
-- State
local peek = require("../State/peek")
-- Utility
local nicknames = require("../Utility/nicknames")

export type Stopwatch = Types.StateObject<number> & {
	zero: (Stopwatch) -> (),
	pause: (Stopwatch) -> (),
	unpause: (Stopwatch) -> ()
}

type Self = Stopwatch & {
	_measureTimeSince: number,
	_playing: boolean,
	_timer: Types.StateObject<number>
}

local class = {}
class.type = "State"
class.kind = "Stopwatch"
class.timeliness = "lazy"

local METATABLE = table.freeze {__index = class}

local function Stopwatch(
	scope: Types.Scope<unknown>,
	timer: Types.StateObject<number>
): Stopwatch
	local createdAt = os.clock()
	local self: Self = setmetatable(
		{
			awake = true,
			createdAt = createdAt,
			dependencySet = {},
			dependentSet = {},
			lastChange = nil,
			scope = scope,
			validity = "invalid",
			_EXTREMELY_DANGEROUS_usedAsValue = 0,
			_measureTimeSince = 0, -- this should be set on unpause
			_playing = false,
			_timer = timer
		},
		METATABLE
	) :: any
	local destroy = function()
		self.scope = nil
	end
	self.oldestTask = destroy
	nicknames[self.oldestTask] = "Stopwatch"
	table.insert(scope, destroy)

	checkLifetime.bOutlivesA(
		scope, self.oldestTask,
		timer.scope, timer.oldestTask,
		checkLifetime.formatters.parameter, "timer"
	)
	depend(self, timer)
	return self
end

function class.zero(
	self: Self
): ()
	local newTimepoint = peek(self._timer)
	if newTimepoint ~= self._measureTimeSince then
		self._measureTimeSince = newTimepoint
		self._EXTREMELY_DANGEROUS_usedAsValue = 0
		change(self)
	end
end

function class.pause(
	self: Self
): ()
	if self._playing == true then
		self._playing = false
		change(self)
	end
end

function class.unpause(
	self: Self
): ()
	if self._playing == false then
		self._playing = true
		self._measureTimeSince = peek(self._timer) - self._EXTREMELY_DANGEROUS_usedAsValue
		change(self)
	end
end

function class._evaluate(
	self: Self
): boolean
	if self._playing then
		depend(self, self._timer)
		local currentTime = peek(self._timer)
		local oldValue = self._EXTREMELY_DANGEROUS_usedAsValue
		local newValue = currentTime - self._measureTimeSince
		self._EXTREMELY_DANGEROUS_usedAsValue = newValue
		return oldValue ~= newValue
	else
		return false
	end

end

table.freeze(class)
return Stopwatch


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	A specialised state object for following a goal state smoothly over time,
	using a TweenInfo to shape the motion.

	https://elttob.uk/Fusion/0.3/api-reference/animation/types/tween/
]]
local Types = require("../Types")
local External = require("../External")
-- Memory
local checkLifetime = require("../Memory/checkLifetime")
-- Graph
local depend = require("../Graph/depend")
local evaluate = require("../Graph/evaluate")
-- State
local castToState = require("../State/castToState")
local peek = require("../State/peek")
-- Animation
local ExternalTime = require("../Animation/ExternalTime")
local Stopwatch = require("../Animation/Stopwatch")
local lerpType = require("../Animation/lerpType")
local getTweenRatio = require("../Animation/getTweenRatio")
local getTweenDuration = require("../Animation/getTweenDuration")
-- Utility
local nicknames = require("../Utility/nicknames")

export type Self<T> = Types.Tween<T> & {
	_activeDuration: number,
	_activeElapsed: number,
	_activeFrom: T,
	_activeTo: T,
	_activeTweenInfo: TweenInfo,
	_goal: Types.UsedAs<T>,
	_stopwatch: Stopwatch.Stopwatch?,
	_tweenInfo: Types.UsedAs<TweenInfo>,
}

local class = {}
class.type = "State"
class.kind = "Tween"
class.timeliness = "eager"

local METATABLE = table.freeze {__index = class}

local function Tween<T>(
	scope: Types.Scope<unknown>,
	goal: Types.UsedAs<T>,
	tweenInfo: Types.UsedAs<TweenInfo>?
): Types.Tween<T>
	local createdAt = os.clock()
	if castToState(scope) then
		External.logError("scopeMissing", nil, "Tweens", "myScope:Tween(goalState, tweenInfo)")
	end

	local goalState = castToState(goal)
	local stopwatch = nil
	if goalState ~= nil then
		stopwatch = Stopwatch(scope, ExternalTime(scope))
	end

	local self: Self<T> = setmetatable(
		{
			createdAt = createdAt,
			dependencySet = {},
			dependentSet = {},
			lastChange = nil,
			scope = scope,
			validity = "invalid",
			_activeDuration = nil,
			_activeElapsed = nil,
			_activeFrom = nil,
			_activeTo = nil,
			_activeTweenInfo = nil,
			_EXTREMELY_DANGEROUS_usedAsValue = peek(goal),
			_goal = goal,
			_stopwatch = stopwatch,
			_tweenInfo = tweenInfo or TweenInfo.new()
		},
		METATABLE
	) :: any
	local destroy = function()
		self.scope = nil
		for dependency in pairs(self.dependencySet) do
			dependency.dependentSet[self] = nil
		end
	end
	self.oldestTask = destroy
	nicknames[self.oldestTask] = "Tween"
	table.insert(scope, destroy)

	if goalState ~= nil then
		checkLifetime.bOutlivesA(
			scope, self.oldestTask,
			goalState.scope, goalState.oldestTask,
			checkLifetime.formatters.animationGoal
		)
	end

	local tweenInfoState = castToState(tweenInfo)
	if tweenInfoState ~= nil then
		checkLifetime.bOutlivesA(
			scope, self.oldestTask,
			tweenInfoState.scope, tweenInfoState.oldestTask,
			checkLifetime.formatters.parameter, "tween info"
		)
	end

	-- Eagerly evaluated objects need to evaluate themselves so that they're
	-- valid at all times.
	evaluate(self, true)

	return self
end

function class.get<T>(
	self: Self<T>
): never
	return External.logError("stateGetWasRemoved")
end

function class._evaluate<T>(
	self: Self<T>
): boolean
	local goal = castToState(self._goal)
	-- Allow non-state goals to pass through transparently.
	if goal == nil then
		self._EXTREMELY_DANGEROUS_usedAsValue = self._goal :: T
		return false
	end
	depend(self, goal)
	local newTweenTo = peek(goal)
	-- Protect against NaN goals.
	if newTweenTo ~= newTweenTo then
		External.logWarn("tweenNanGoal")
		return false
	end
	local stopwatch = self._stopwatch :: Stopwatch.Stopwatch
	local tweenInfo = peek(self._tweenInfo) :: TweenInfo
	-- Restart animation when the goal changes, or if the tween info changes
	-- partway through another animation.
	if
		self._activeTo ~= newTweenTo or
		(self._activeElapsed < self._activeDuration and self._activeTweenInfo ~= tweenInfo)
	then
		self._activeDuration = getTweenDuration(tweenInfo)
		self._activeFrom = self._EXTREMELY_DANGEROUS_usedAsValue
		self._activeTo = newTweenTo
		self._activeTweenInfo = tweenInfo
		stopwatch:zero()
		stopwatch:unpause()
	end
	depend(self, stopwatch)
	self._activeElapsed = peek(stopwatch)
	if
		self._activeFrom == self._activeTo or -- endpoints match
		self._activeElapsed >= self._activeDuration or -- animation is done
		typeof(self._activeTo) ~= typeof(self._activeFrom) -- type difference
	then
		self._activeFrom = self._activeTo
		self._activeElapsed = self._activeDuration
		stopwatch:pause()
	end
	-- Compute actual tweened value.
	local ratio = getTweenRatio(tweenInfo, self._activeElapsed)
	local oldValue = self._EXTREMELY_DANGEROUS_usedAsValue
	local newValue = lerpType(self._activeFrom, self._activeTo, ratio) :: T
	-- Protect against NaN after motion.
	if newValue ~= newValue then
		External.logWarn("tweenNanMotion")
		newValue = self._activeTo
	end
	-- Push update and check for similarity.
	-- Don't need to use the similarity test here because this code doesn't
	-- deal with tables, and NaN is already guarded against, so the similarity
	-- test doesn't actually add any new safety here.
	self._EXTREMELY_DANGEROUS_usedAsValue = newValue
	return oldValue ~= newValue
end

table.freeze(class)
return Tween :: Types.TweenConstructor


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Linearly interpolates the given animatable types by a ratio.
	If the types are different or not animatable, then the first value will be
	returned for ratios below 0.5, and the second value for 0.5 and above.
]]
local Oklab = require("../Colour/Oklab")

local function lerpType(
	from: unknown,
	to: unknown,
	ratio: number
): unknown
	local typeString = typeof(from)

	if typeof(to) == typeString then
		-- both types must match for interpolation to make sense
		if typeString == "number" then
			local to, from = to :: number, from :: number
			return (to - from) * ratio + from

		elseif typeString == "CFrame" then
			local to, from = to :: CFrame, from :: CFrame
			return from:Lerp(to, ratio)

		elseif typeString == "Color3" then
			local to, from = to :: Color3, from :: Color3
			local fromLab = Oklab.fromSRGB(from)
			local toLab = Oklab.fromSRGB(to)
			return Oklab.toSRGB(
				fromLab:Lerp(toLab, ratio),
				false
			)

		elseif typeString == "ColorSequenceKeypoint" then
			local to, from = to :: ColorSequenceKeypoint, from :: ColorSequenceKeypoint
			local fromLab = Oklab.fromSRGB(from.Value)
			local toLab = Oklab.fromSRGB(to.Value)
			return ColorSequenceKeypoint.new(
				(to.Time - from.Time) * ratio + from.Time,
				Oklab.toSRGB(
					fromLab:Lerp(toLab, ratio),
					false
				)
			)

		elseif typeString == "DateTime" then
			local to, from = to :: DateTime, from :: DateTime
			return DateTime.fromUnixTimestampMillis(
				(to.UnixTimestampMillis - from.UnixTimestampMillis) * ratio + from.UnixTimestampMillis
			)

		elseif typeString == "NumberRange" then
			local to, from = to :: NumberRange, from :: NumberRange
			return NumberRange.new(
				(to.Min - from.Min) * ratio + from.Min,
				(to.Max - from.Max) * ratio + from.Max
			)

		elseif typeString == "NumberSequenceKeypoint" then
			local to, from = to :: NumberSequenceKeypoint, from :: NumberSequenceKeypoint
			return NumberSequenceKeypoint.new(
				(to.Time - from.Time) * ratio + from.Time,
				(to.Value - from.Value) * ratio + from.Value,
				(to.Envelope - from.Envelope) * ratio + from.Envelope
			)

		elseif typeString == "PhysicalProperties" then
			local to, from = to :: PhysicalProperties, from :: PhysicalProperties
			return PhysicalProperties.new(
				(to.Density - from.Density) * ratio + from.Density,
				(to.Friction - from.Friction) * ratio + from.Friction,
				(to.Elasticity - from.Elasticity) * ratio + from.Elasticity,
				(to.FrictionWeight - from.FrictionWeight) * ratio + from.FrictionWeight,
				(to.ElasticityWeight - from.ElasticityWeight) * ratio + from.ElasticityWeight
			)

		elseif typeString == "Ray" then
			local to, from = to :: Ray, from :: Ray
			return Ray.new(
				from.Origin:Lerp(to.Origin, ratio),
				from.Direction:Lerp(to.Direction, ratio)
			)

		elseif typeString == "Rect" then
			local to, from = to :: Rect, from :: Rect
			return Rect.new(
				from.Min:Lerp(to.Min, ratio),
				from.Max:Lerp(to.Max, ratio)
			)

		elseif typeString == "Region3" then
			local to, from = to :: Region3, from :: Region3
			-- FUTURE: support rotated Region3s if/when they become constructable
			local position = from.CFrame.Position:Lerp(to.CFrame.Position, ratio)
			local halfSize = from.Size:Lerp(to.Size, ratio) / 2
			return Region3.new(position - halfSize, position + halfSize)

		elseif typeString == "Region3int16" then
			local to, from = to :: Region3int16, from :: Region3int16
			return Region3int16.new(
				Vector3int16.new(
					(to.Min.X - from.Min.X) * ratio + from.Min.X,
					(to.Min.Y - from.Min.Y) * ratio + from.Min.Y,
					(to.Min.Z - from.Min.Z) * ratio + from.Min.Z
				),
				Vector3int16.new(
					(to.Max.X - from.Max.X) * ratio + from.Max.X,
					(to.Max.Y - from.Max.Y) * ratio + from.Max.Y,
					(to.Max.Z - from.Max.Z) * ratio + from.Max.Z
				)
			)

		elseif typeString == "UDim" then
			local to, from = to :: UDim, from :: UDim
			return UDim.new(
				(to.Scale - from.Scale) * ratio + from.Scale,
				(to.Offset - from.Offset) * ratio + from.Offset
			)

		elseif typeString == "UDim2" then
			local to, from = to :: UDim2, from :: UDim2
			return from:Lerp(to, ratio)

		elseif typeString == "Vector2" then
			local to, from = to :: Vector2, from :: Vector2
			return from:Lerp(to, ratio)

		elseif typeString == "Vector2int16" then
			local to, from = to :: Vector2int16, from :: Vector2int16
			return Vector2int16.new(
				(to.X - from.X) * ratio + from.X,
				(to.Y - from.Y) * ratio + from.Y
			)

		elseif typeString == "Vector3" then
			local to, from = to :: Vector3, from :: Vector3
			return from:Lerp(to, ratio)

		elseif typeString == "Vector3int16" then
			local to, from = to :: Vector3int16, from :: Vector3int16
			return Vector3int16.new(
				(to.X - from.X) * ratio + from.X,
				(to.Y - from.Y) * ratio + from.Y,
				(to.Z - from.Z) * ratio + from.Z
			)
		end
	end

	-- fallback case: the types are different or not animatable
	if ratio < 0.5 then
		return from
	else
		return to
	end
end

return lerpType


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Packs an array of numbers into a given animatable data type.
	If the type is not animatable, nil will be returned.
]]
local Types = require("../Types")
local Oklab = require("../Colour/Oklab")

local function packType(
	numbers: {number},
	typeString: string
): Types.Animatable?
	if typeString == "number" then
		return numbers[1]

	elseif typeString == "CFrame" then
		return
			CFrame.new(numbers[1], numbers[2], numbers[3]) *
			CFrame.fromAxisAngle(
				Vector3.new(numbers[4], numbers[5], numbers[6]).Unit,
				numbers[7]
			)

	elseif typeString == "Color3" then
		return Oklab.toSRGB(
			Vector3.new(numbers[1], numbers[2], numbers[3]),
			false
		)

	elseif typeString == "ColorSequenceKeypoint" then
		return ColorSequenceKeypoint.new(
			numbers[4],
			Oklab.toSRGB(
				Vector3.new(numbers[1], numbers[2], numbers[3]),
				false
			)
		)

	elseif typeString == "DateTime" then
		return DateTime.fromUnixTimestampMillis(numbers[1])

	elseif typeString == "NumberRange" then
		return NumberRange.new(numbers[1], numbers[2])

	elseif typeString == "NumberSequenceKeypoint" then
		return NumberSequenceKeypoint.new(numbers[2], numbers[1], numbers[3])

	elseif typeString == "PhysicalProperties" then
		return PhysicalProperties.new(numbers[1], numbers[2], numbers[3], numbers[4], numbers[5])

	elseif typeString == "Ray" then
		return Ray.new(
			Vector3.new(numbers[1], numbers[2], numbers[3]),
			Vector3.new(numbers[4], numbers[5], numbers[6])
		)

	elseif typeString == "Rect" then
		return Rect.new(numbers[1], numbers[2], numbers[3], numbers[4])

	elseif typeString == "Region3" then
		-- FUTURE: support rotated Region3s if/when they become constructable
		local position = Vector3.new(numbers[1], numbers[2], numbers[3])
		local halfSize = Vector3.new(numbers[4] / 2, numbers[5] / 2, numbers[6] / 2)
		return Region3.new(position - halfSize, position + halfSize)

	elseif typeString == "Region3int16" then
		return Region3int16.new(
			Vector3int16.new(numbers[1], numbers[2], numbers[3]),
			Vector3int16.new(numbers[4], numbers[5], numbers[6])
		)

	elseif typeString == "UDim" then
		return UDim.new(numbers[1], numbers[2])

	elseif typeString == "UDim2" then
		return UDim2.new(numbers[1], numbers[2], numbers[3], numbers[4])

	elseif typeString == "Vector2" then
		return Vector2.new(numbers[1], numbers[2])

	elseif typeString == "Vector2int16" then
		return Vector2int16.new(numbers[1], numbers[2])

	elseif typeString == "Vector3" then
		return Vector3.new(numbers[1], numbers[2], numbers[3])

	elseif typeString == "Vector3int16" then
		return Vector3int16.new(numbers[1], numbers[2], numbers[3])
	else
		return nil
	end
end

return packType


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Unpacks an animatable type into an array of numbers.
	If the type is not animatable, an empty array will be returned.
]]
local Oklab = require("../Colour/Oklab")

local function unpackType(
	value: unknown,
	typeString: string
): {number}
	if typeString == "number" then
		local value = value :: number
		return {value}

	elseif typeString == "CFrame" then
		local value = value :: CFrame
		-- FUTURE: is there a better way of doing this? doing distance
		-- calculations on `angle` may be incorrect
		local axis, angle = value:ToAxisAngle()
		return {value.X, value.Y, value.Z, axis.X, axis.Y, axis.Z, angle}

	elseif typeString == "Color3" then
		local value = value :: Color3
		local lab = Oklab.fromSRGB(value)
		return {lab.X, lab.Y, lab.Z}

	elseif typeString == "ColorSequenceKeypoint" then
		local value = value :: ColorSequenceKeypoint
		local lab = Oklab.fromSRGB(value.Value)
		return {lab.X, lab.Y, lab.Z, value.Time}

	elseif typeString == "DateTime" then
		local value = value :: DateTime
		return {value.UnixTimestampMillis}

	elseif typeString == "NumberRange" then
		local value = value :: NumberRange
		return {value.Min, value.Max}

	elseif typeString == "NumberSequenceKeypoint" then
		local value = value :: NumberSequenceKeypoint
		return {value.Value, value.Time, value.Envelope}

	elseif typeString == "PhysicalProperties" then
		local value = value :: PhysicalProperties
		return {value.Density, value.Friction, value.Elasticity, value.FrictionWeight, value.ElasticityWeight}

	elseif typeString == "Ray" then
		local value = value :: Ray
		return {value.Origin.X, value.Origin.Y, value.Origin.Z, value.Direction.X, value.Direction.Y, value.Direction.Z}

	elseif typeString == "Rect" then
		local value = value :: Rect
		return {value.Min.X, value.Min.Y, value.Max.X, value.Max.Y}

	elseif typeString == "Region3" then
		local value = value :: Region3
		-- FUTURE: support rotated Region3s if/when they become constructable
		return {
			value.CFrame.X, value.CFrame.Y, value.CFrame.Z,
			value.Size.X, value.Size.Y, value.Size.Z
		}

	elseif typeString == "Region3int16" then
		local value = value :: Region3int16
		return {value.Min.X, value.Min.Y, value.Min.Z, value.Max.X, value.Max.Y, value.Max.Z}

	elseif typeString == "UDim" then
		local value = value :: UDim
		return {value.Scale, value.Offset}

	elseif typeString == "UDim2" then
		local value = value :: UDim2
		return {value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset}

	elseif typeString == "Vector2" then
		local value = value :: Vector2
		return {value.X, value.Y}

	elseif typeString == "Vector2int16" then
		local value = value :: Vector2int16
		return {value.X, value.Y}

	elseif typeString == "Vector3" then
		local value = value :: Vector3
		return {value.X, value.Y, value.Z}

	elseif typeString == "Vector3int16" then
		local value = value :: Vector3int16
		return {value.X, value.Y, value.Z}
	else
		return {}
	end
end

return unpackType


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Abstraction layer between Fusion internals and external environments,
	allowing for flexible integration with schedulers and test mocks.
]]
local formatError = require("./Logging/formatError")
local Types = require("./Types")

local ERROR_INFO_URL = "https://elttob.uk/Fusion/0.3/api-reference/general/errors/#"

local External = {}

-- Indicates that a highly time-critical passage of code is running. During
-- critical periods of a program, Fusion might decide to change some of its
-- internal behaviour to be more performance friendly.
local timeCritical = false

-- Multiplier for running-time safety checks across the Fusion codebase. Used to
-- stricten tests on infinite loop detection during unit testing.
External.safetyTimerMultiplier = 1

local updateStepCallbacks = {}
local currentProvider: Types.ExternalProvider? = nil
local lastUpdateStep = 0

--[[
	Swaps to a new provider for external operations.
	Returns the old provider, so it can be used again later.
]]
function External.setExternalProvider(
	newProvider: Types.ExternalProvider?
): Types.ExternalProvider?
	local oldProvider = currentProvider
	if oldProvider ~= nil then
		oldProvider.stopScheduler()
	end
	currentProvider = newProvider
	if newProvider ~= nil then
		newProvider.startScheduler()
	end
	return oldProvider
end

--[[
	Returns true if a highly time-critical passage of code is running.
]]
function External.isTimeCritical(): boolean
	return timeCritical
end

--[[
   Sends an immediate task to the external provider. Throws if none is set.
]]
function External.doTaskImmediate(
	resume: () -> ()
)
	if currentProvider == nil then
		External.logError("noTaskScheduler")
	else
		currentProvider.doTaskImmediate(resume)
	end
end

--[[
	Sends a deferred task to the external provider. Throws if none is set.
]]
function External.doTaskDeferred(
	resume: () -> ()
)
	if currentProvider == nil then
		External.logError("noTaskScheduler")
	else
		currentProvider.doTaskDeferred(resume)
	end
end

--[[
	Errors in the current thread and halts execution.
]]
function External.logError(
	messageID: string,
	errObj: Types.Error?,
	...: unknown
): never
	error(formatError(currentProvider, messageID, errObj, ...), 0)
end

--[[
	Errors in a different thread to preserve the flow of execution.
]]
function External.logErrorNonFatal(
	messageID: string,
	errObj: Types.Error?,
	...: unknown
): ()
	local errorString = formatError(currentProvider, messageID, errObj, ...)
	if currentProvider ~= nil then
		currentProvider.logErrorNonFatal(errorString)
	else
		print(errorString)
	end
end

--[[
	Shows a warning message in the output.
]]
function External.logWarn(
	messageID: string,
	...: unknown
): ()
	local errorString = formatError(currentProvider, messageID, debug.traceback(nil, 2), ...)
	if currentProvider ~= nil then
		currentProvider.logWarn(errorString)
	else
		print(errorString)
	end
end

--[[
	Registers a callback to the update step of the external provider.
	Returns a function that can be used to disconnect later.

	Callbacks are given the current number of seconds since an arbitrary epoch.

	TODO: This epoch may change between providers. We could investigate ways
	of allowing providers to co-operate to keep the epoch the same, so that
	monotonicity can be better preserved.
]]
function External.bindToUpdateStep(
	callback: (
		now: number
	) -> ()
): () -> ()
	local uniqueIdentifier = {}
	updateStepCallbacks[uniqueIdentifier] = callback
	return function()
		updateStepCallbacks[uniqueIdentifier] = nil
	end
end

--[[
	Steps time-dependent systems with the current number of seconds since an
	arbitrary epoch. This should be called as early as possible in the external
	provider's update cycle.
]]
function External.performUpdateStep(
	now: number
)
	lastUpdateStep = now
	for _, callback in updateStepCallbacks do
		callback(now)
	end
end

--[[
	Returns the timestamp of the last update step.
]]
function External.lastUpdateStep()
	return lastUpdateStep
end

return External


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Abstraction layer between Fusion internals and external debuggers, allowing
	for deep introspection using function hooks.

	Unlike `External`, attaching a debugger is optional, and all debugger
	functions are expected to be infallible and non-blocking.
]]
local Types = require("./Types")

local currentProvider: Types.ExternalDebugger? = nil
local lastUpdateStep = 0

local Debugger = {}

--[[
	Swaps to a new debugger.
	Returns the old debugger, so it can be used again later.
]]
function Debugger.setDebugger(
	newProvider: Types.ExternalDebugger?
): Types.ExternalDebugger?
	local oldProvider = currentProvider
	if oldProvider ~= nil then
		oldProvider.stopDebugging()
	end
	currentProvider = newProvider
	if newProvider ~= nil then
		newProvider.startDebugging()
	end
	return oldProvider
end

--[[
	Called at the earliest moment after a scope is created or removed from the
	scope pool, but not before the scope has finished being prepared by the
	library, so that debuggers can register its existence and track changes
	to the scope over time.
]]
function Debugger.trackScope(
	scope: Types.Scope<unknown>
): ()
	if currentProvider == nil then
		return
	end
	currentProvider.trackScope(scope)
end

--[[
	Called at the final moment before a scope is poisoned or added to the scope
	pool, after all cleanup tasks have completed, so that debuggers can erase
	the scope from internal trackers. Note that, due to scope pooling and user
	code, never assume that this correlates with garbage collection events.
]]
function Debugger.untrackScope(
	scope: Types.Scope<unknown>
): ()
	if currentProvider == nil then
		return
	end
	currentProvider.trackScope(scope)
end

return Debugger


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	A graph object that runs user code when it's updated by the reactive graph.

	http://elttob.uk/Fusion/0.3/api-reference/state/types/observer/
]]
local Types = require("../Types")
local External = require("../External")
-- Memory
local checkLifetime = require("../Memory/checkLifetime")
-- Graph
local castToGraph = require("../Graph/castToGraph")
local depend = require("../Graph/depend")
local evaluate = require("../Graph/evaluate")
-- Utility
local nicknames = require("../Utility/nicknames")

type Self = Types.Observer & {
	_watchingGraph: Types.GraphObject?,
	_changeListeners: {[unknown]: () -> ()}
}

local class = {}
class.type = "Observer"
class.timeliness = "eager"
class.dependentSet = table.freeze {}

local METATABLE = table.freeze {__index = class}

local function Observer(
	scope: Types.Scope<unknown>,
	watching: unknown
): Types.Observer
	local createdAt = os.clock()
	if watching == nil then
		External.logError("scopeMissing", nil, "Observers", "myScope:Observer(watching)")
	end

	local self: Self = setmetatable(
		{
			scope = scope,
			createdAt = createdAt,
			dependencySet = {},
			lastChange = nil,
			validity = "invalid",
			_watchingGraph = castToGraph(watching),
			_changeListeners = {}
		},
		METATABLE
	) :: any
	local destroy = function()
		self.scope = nil
		for dependency in pairs(self.dependencySet) do
			dependency.dependentSet[self] = nil
		end
	end
	self.oldestTask = destroy
	nicknames[self.oldestTask] = "Observer"
	table.insert(scope, destroy)

	if self._watchingGraph ~= nil then
		checkLifetime.bOutlivesA(
			scope, self.oldestTask,
			self._watchingGraph.scope, self._watchingGraph.oldestTask,
			checkLifetime.formatters.observer
		)
	end

	-- Eagerly evaluated objects need to evaluate themselves so that they're
	-- valid at all times.
	evaluate(self, true)

	return self
end

function class.onBind(
	self: Self,
	callback: () -> ()
): () -> ()
	External.doTaskImmediate(callback)
	return self:onChange(callback)
end

function class.onChange(
	self: Self,
	callback: () -> ()
): () -> ()
	local uniqueIdentifier = table.freeze {}
	self._changeListeners[uniqueIdentifier] = callback
	return function()
		self._changeListeners[uniqueIdentifier] = nil
	end
end

function class._evaluate(
	self: Self
): ()
	if self._watchingGraph ~= nil then
		depend(self, self._watchingGraph)
	end
	for _, callback in self._changeListeners do
		External.doTaskImmediate(callback)
	end
	return true
end

table.freeze(class)
return Observer :: Types.ObserverConstructor


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Returns the input *only* if it is a graph object.
]]
local Types = require("../Types")

local function castToGraph(
	target: any
): Types.GraphObject?
	if
		typeof(target) == "table" and
		typeof(target.validity) == "string" and
		typeof(target.timeliness) == "string" and
		typeof(target.dependencySet) == "table" and
		typeof(target.dependentSet) == "table"
	then
		return target
	else
		return nil
	end
end

return castToGraph


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Prompts a graph object to re-evaluate its own value. If it meaningfully
	changes, then dependents will have to re-evaluate their own values in the
	future.

	https://fluff.blog/2024/04/16/monotonic-painting.html
]]
local Types = require("../Types")
local External = require("../External")
local evaluate = require("../Graph/evaluate")

-- How long should this function run before it's considered to be in an infinite
-- cycle and error out?
local TERMINATION_TIME = 1

local function change(
	target: Types.GraphObject
): ()
	if target.validity == "busy" then
		return External.logError("infiniteLoop")
	end

	local meaningfullyChanged = evaluate(target, true)
	if not meaningfullyChanged then
		return
	end

	local searchInNow: {Types.GraphObject} = {}
	local searchInNext: {Types.GraphObject} = {}
	local invalidateList: {Types.GraphObject} = {}

	searchInNow[1] = target
	local terminateBy = os.clock() + TERMINATION_TIME * External.safetyTimerMultiplier
	repeat
		if os.clock() > terminateBy then
			return External.logError("infiniteLoop")
		end
		local done = true
		for _, searchTarget in searchInNow do
			for dependent in searchTarget.dependentSet do
				if dependent.validity == "valid" then
					done = false
					table.insert(invalidateList, dependent)
					table.insert(searchInNext, dependent)
				elseif dependent.validity == "busy" then
					return External.logError("infiniteLoop")
				end
			end
		end
		searchInNow, searchInNext = searchInNext, searchInNow
		table.clear(searchInNext)
	until done

	local eagerList: {Types.GraphObject} = {}

	for _, invalidateTarget in invalidateList do
		invalidateTarget.validity = "invalid"
		if invalidateTarget.timeliness == "eager" then
			table.insert(eagerList, invalidateTarget)
		end
	end
	-- If objects are not executed in order of creations, then dynamic graphs
	-- may experience 'glitches' where nested graph objects see intermediate
	-- values before being destroyed.
	-- https://fluff.blog/2024/07/14/glitches-in-dynamic-reactive-graphs.html
	table.sort(eagerList, function(a, b)
		return a.createdAt < b.createdAt
	end)
	for _, eagerTarget in eagerList do
		evaluate(eagerTarget, false)
	end
end

return change


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Forms a dependency on a graph object.
]]
local Types = require("../Types")
local External = require("../External")
local evaluate = require("../Graph/evaluate")
local nameOf = require("../Utility/nameOf")

local function depend<T>(
	dependent: Types.GraphObject,
	dependency: Types.GraphObject
): ()
	-- Ensure dependencies are evaluated and up-to-date
	-- when they are depended on. Also, newly created objects
	-- might not have any transitive dependencies captured yet,
	-- so ensure that they're present.
	evaluate(dependency, false)

	if table.isfrozen(dependent.dependencySet) or table.isfrozen(dependency.dependentSet) then
		External.logError("cannotDepend", nil, nameOf(dependent, "Dependent"), nameOf(dependency, "dependency"))
	end
	dependency.dependentSet[dependent] = true
	dependent.dependencySet[dependency] = true
end

return depend


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Evaluates the graph object if necessary, so that it is up to date.
	Returns true if it meaningfully changed.

	https://fluff.blog/2024/04/16/monotonic-painting.html
]]
local Types = require("../Types")
local External = require("../External")

local function evaluate(
	target: Types.GraphObject,
	forceComputation: boolean
): boolean
	if target.validity == "busy" then
		return External.logError("infiniteLoop")
	end
	local firstEvaluation = target.lastChange == nil
	local isInvalid = target.validity == "invalid"
	if firstEvaluation or isInvalid or forceComputation then
		local needsComputation = firstEvaluation or forceComputation
		if not needsComputation then
			for dependency in target.dependencySet do
				evaluate(dependency, false)
				if dependency.lastChange > target.lastChange then
					needsComputation = true
					break
				end
			end
		end
		local targetMeaningfullyChanged = false
		if needsComputation then
			for dependency in target.dependencySet do
				dependency.dependentSet[target] = nil
				target.dependencySet[dependency] = nil
			end
			target.validity = "busy"
			targetMeaningfullyChanged = target:_evaluate() or firstEvaluation
		end
		if targetMeaningfullyChanged then
			target.lastChange = os.clock()
		end
		target.validity = "valid"
		return targetMeaningfullyChanged
	else
		return false
	end
end

return evaluate


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	A special key for property tables, which allows users to apply custom
	attributes to instances
]]
local Types = require("../Types")
-- Memory
local checkLifetime = require("../Memory/checkLifetime")
-- Graph
local Observer = require("../Graph/Observer")
-- State
local castToState = require("../State/castToState")
local peek = require("../State/peek")

local keyCache: {[string]: Types.SpecialKey} = {}

local function Attribute(
	attributeName: string
): Types.SpecialKey
	local key = keyCache[attributeName]
	if key == nil then
		key = {
			type = "SpecialKey",
			kind = "Attribute",
			stage = "self",
			apply = function(
				self: Types.SpecialKey,
				scope: Types.Scope<unknown>,
				value: unknown,
				applyTo: Instance
			)
				if castToState(value) then
					local value = value :: Types.StateObject<unknown>
					checkLifetime.bOutlivesA(
						scope, applyTo,
						value.scope, value.oldestTask,
						checkLifetime.formatters.boundAttribute, attributeName
					)
					Observer(scope, value :: any):onBind(function()
						applyTo:SetAttribute(attributeName, peek(value))
					end)
				else
					applyTo:SetAttribute(attributeName, value)
				end
			end
		}
		keyCache[attributeName] = key
	end
	return key
end

return Attribute


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	A special key for property tables, which allows users to connect to
	an attribute change on an instance.
]]
local Types = require("../Types")
local External = require("../External")

local keyCache: {[string]: Types.SpecialKey} = {}

local function AttributeChange(
	attributeName: string
): Types.SpecialKey
	local key = keyCache[attributeName]
	if key == nil then
		key = {
			type = "SpecialKey",
			kind = "AttributeChange",
			stage = "observer",
			apply = function(
				self: Types.SpecialKey,
				scope: Types.Scope<unknown>,
				value: unknown,
				applyTo: Instance
			)
				if typeof(value) ~= "function" then
					External.logError("invalidAttributeChangeHandler", nil, attributeName)
				end
				local value = value :: (...unknown) -> (...unknown)
				local event = applyTo:GetAttributeChangedSignal(attributeName)
				table.insert(scope, event:Connect(function()
					value((applyTo :: any):GetAttribute(attributeName))
				end))
			end
		}
		keyCache[attributeName] = key
	end
	return key
end

return AttributeChange


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	A special key for property tables, which allows users to save instance attributes
	into state objects
]]
local Types = require("../Types")
local External = require("../External")
-- Memory
local checkLifetime = require("../Memory/checkLifetime")
-- State
local castToState = require("../State/castToState")

local keyCache: {[string]: Types.SpecialKey} = {}

local function AttributeOut(
	attributeName: string
): Types.SpecialKey
	local key = keyCache[attributeName]
	if key == nil then
		key = {
			type = "SpecialKey",
			kind = "AttributeOut",
			stage = "observer",
			apply = function(
				self: Types.SpecialKey,
				scope: Types.Scope<unknown>,
				value: unknown,
				applyTo: Instance
			)
				local event = applyTo:GetAttributeChangedSignal(attributeName)

				if not castToState(value) then
					External.logError("invalidAttributeOutType")
				end
				local value = value :: Types.StateObject<unknown>
				if value.kind ~= "Value" then
					External.logError("invalidAttributeOutType")
				end
				local value = value :: Types.Value<unknown>
				checkLifetime.bOutlivesA(
					scope, applyTo,
					value.scope, value.oldestTask,
					checkLifetime.formatters.attributeOutputsTo, attributeName
				)

				value:set((applyTo :: any):GetAttribute(attributeName))
				table.insert(scope, event:Connect(function()
					value:set((applyTo :: any):GetAttribute(attributeName))
				end))
			end
		}
		keyCache[attributeName] = key
	end
	return key
end

return AttributeOut


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Helper function for type checking purposes. Casts the input to a `Child`
	type, while constraining the input to be an array of `Child` - this prevents
	Luau from erroneously inferring a different array type for the input.
]]
local Types = require("../Types")

local function Child(
	x: {Types.Child}
): Types.Child
	return x
end

return Child


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	A special key for property tables, which parents any given descendants into
	an instance.
]]
local Types = require("../Types")
local External = require("../External")
local Observer = require("../Graph/Observer")
local peek = require("../State/peek")
local castToState = require("../State/castToState")
local doCleanup = require("../Memory/doCleanup")

type Set<T> = {[T]: unknown}

-- Experimental flag: name children based on the key used in the [Children] table
local EXPERIMENTAL_AUTO_NAMING = false

return {
	type = "SpecialKey",
	kind = "Children",
	stage = "descendants",
	apply = function(
		self: Types.SpecialKey,
		scope: Types.Scope<unknown>,
		value: unknown,
		applyTo: Instance
	)
		local newParented: Set<Instance> = {}
		local oldParented: Set<Instance> = {}

		-- save scopes for state object observers
		local newScopes: {[Types.StateObject<unknown>]: Types.Scope<unknown>} = {}
		local oldScopes: {[Types.StateObject<unknown>]: Types.Scope<unknown>} = {}

		-- Rescans this key's value to find new instances to parent and state objects
		-- to observe for changes; then unparents instances no longer found and
		-- disconnects observers for state objects no longer present.
		local function updateChildren()
			oldParented, newParented = newParented, oldParented
			oldScopes, newScopes = newScopes, oldScopes

			local function processChild(
				child: unknown,
				autoName: string?
			)
				local childType = typeof(child)

				if childType == "Instance" then
					-- case 1; single instance
					local child = child :: Instance

					newParented[child] = true
					if oldParented[child] == nil then
						-- wasn't previously present

						-- TODO: check for ancestry conflicts here
						child.Parent = applyTo
					else
						-- previously here; we want to reuse, so remove from old
						-- set so we don't encounter it during unparenting
						oldParented[child] = nil
					end

					if EXPERIMENTAL_AUTO_NAMING and autoName ~= nil then
						child.Name = autoName
					end

				elseif castToState(child) then
					-- case 2; state object
					local child = child :: Types.StateObject<unknown>

					local value = peek(child)
					-- allow nil to represent the absence of a child
					if value ~= nil then
						processChild(value, autoName)
					end

					local childScope = oldScopes[child]
					if childScope == nil then
						-- wasn't previously present
						childScope = {}
						Observer(childScope, child):onChange(updateChildren)
					else
						-- previously here; we want to reuse, so remove from old
						-- set so we don't encounter it during unparenting
						oldScopes[child] = nil
					end

					newScopes[child] = childScope

				elseif childType == "table" then
					-- case 3; table of objects
					local child = child :: {[unknown]: unknown}

					for key, subChild in pairs(child) do
						local keyType = typeof(key)
						local subAutoName: string? = nil

						if keyType == "string" then
							local key = key :: string
							subAutoName = key
						elseif keyType == "number" and autoName ~= nil then
							local key = key :: number
							subAutoName = autoName .. "_" .. key
						end

						processChild(subChild, subAutoName)
					end

				else
					External.logWarn("unrecognisedChildType", childType)
				end
			end

			if value ~= nil then
				-- `propValue` is set to nil on cleanup, so we don't process children
				-- in that case
				processChild(value)
			end

			-- unparent any children that are no longer present
			for oldInstance in pairs(oldParented) do
				oldInstance.Parent = nil
			end
			table.clear(oldParented)

			-- disconnect observers which weren't reused
			for oldState, childScope in pairs(oldScopes) do
				doCleanup(childScope)
			end
			table.clear(oldScopes)
		end

		table.insert(scope, function()
			value = nil
			updateChildren()
		end)

		-- perform initial child parenting
		updateChildren()
	end
} :: Types.SpecialKey


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Processes and returns an existing instance, with options for setting
	properties, event handlers and other attributes on the instance.
]]
local Types = require("../Types")
local External = require("../External")
local applyInstanceProps = require("../Instances/applyInstanceProps")

local function Hydrate(
	scope: Types.Scope<unknown>,
	target: Instance
)
	if target :: any == nil then
		External.logError("scopeMissing", nil, "instances using Hydrate", "myScope:Hydrate (instance) { ... }")
	end
	return function(
		props: Types.PropertyTable
	): Instance

		table.insert(scope, target)
		applyInstanceProps(scope, props, target)
		return target
	end
end

return Hydrate


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Constructs and returns a new instance, with options for setting properties,
	event handlers and other attributes on the instance right away.
]]
local Types = require("../Types")
local External = require("../External")
local defaultProps = require("../Instances/defaultProps")
local applyInstanceProps = require("../Instances/applyInstanceProps")

type Component = (Types.PropertyTable) -> Instance

local function New(
	scope: Types.Scope<unknown>,
	className: string
)
	if (className :: any) == nil then
		local scope = (scope :: any) :: string
		External.logError("scopeMissing", nil, "instances using New", "myScope:New \"" .. scope .. "\" { ... }")
	end

	-- This might look appealing to try and cache. But please don't. The scope
	-- upvalue is shared between the two curried function calls, so this will
	-- open incredible cross-codebase wormholes like you've never seen before.
	return function(
		props: Types.PropertyTable
	): Instance
		local ok, instance = pcall(Instance.new, className)
		if not ok then
			External.logError("cannotCreateClass", nil, className)
		end

		local classDefaults = defaultProps[className]
		if classDefaults ~= nil then
			for defaultProp, defaultValue in pairs(classDefaults) do
				(instance :: any)[defaultProp] = defaultValue
			end
		end

		table.insert(scope, instance)
		applyInstanceProps(scope, props, instance)

		return instance
	end
end

return New


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Constructs special keys for property tables which connect property change
	listeners to an instance.
]]
local Types = require("../Types")
local External = require("../External")

local keyCache: {[string]: Types.SpecialKey} = {}

local function OnChange(
	propertyName: string
): Types.SpecialKey
	local key = keyCache[propertyName]
	if key == nil then
		key = {
			type = "SpecialKey",
			kind = "OnChange",
			stage = "observer",
			apply = function(
				self: Types.SpecialKey,
				scope: Types.Scope<unknown>,
				callback: unknown,
				applyTo: Instance
			)
				local ok, event = pcall(applyTo.GetPropertyChangedSignal, applyTo, propertyName)
				if not ok then
					External.logError("cannotConnectChange", nil, applyTo.ClassName, propertyName)
				elseif typeof(callback) ~= "function" then
					External.logError("invalidChangeHandler", nil, propertyName)
				else
					local callback = callback :: (...unknown) -> (...unknown)
					table.insert(scope, event:Connect(function()
						callback((applyTo :: any)[propertyName])
					end))
				end
			end
		}
		keyCache[propertyName] = key
	end
	return key
end

return OnChange


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Constructs special keys for property tables which connect event listeners to
	an instance.
]]
local Types = require("../Types")
local External = require("../External")

local keyCache: {[string]: Types.SpecialKey} = {}

local function getProperty_unsafe(
	instance: Instance,
	property: string
)
	return (instance :: any)[property]
end

local function OnEvent(
	eventName: string
): Types.SpecialKey
	local key = keyCache[eventName]
	if key == nil then
		key = {
			type = "SpecialKey",
			kind = "OnEvent",
			stage = "observer",
			apply = function(
				self: Types.SpecialKey,
				scope: Types.Scope<unknown>,
				callback: unknown,
				applyTo: Instance
			)
				local ok, event = pcall(getProperty_unsafe, applyTo, eventName)
				if not ok or typeof(event) ~= "RBXScriptSignal" then
					External.logError("cannotConnectEvent", nil, applyTo.ClassName, eventName)
				elseif typeof(callback) ~= "function" then
					External.logError("invalidEventHandler", nil, eventName)
				else
					table.insert(scope, event:Connect(callback :: any))
				end
			end
		}
		keyCache[eventName] = key
	end
	return key
end

return OnEvent


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	A special key for property tables, which allows users to extract values from
	an instance into an automatically-updated Value object.
]]
local Types = require("../Types")
local External = require("../External")
-- Memory
local checkLifetime = require("../Memory/checkLifetime")
-- State
local castToState = require("../State/castToState")

local keyCache: {[string]: Types.SpecialKey} = {}

local function Out(
	propertyName: string
): Types.SpecialKey
	local key = keyCache[propertyName]
	if key == nil then
		key = {
			type = "SpecialKey",
			kind = "Out",
			stage = "observer",
			apply = function(
				self: Types.SpecialKey,
				scope: Types.Scope<unknown>,
				value: unknown,
				applyTo: Instance
			)
				local ok, event = pcall(applyTo.GetPropertyChangedSignal, applyTo, propertyName)
				if not ok then
					External.logError("invalidOutProperty", nil, applyTo.ClassName, propertyName)
				end

				if not castToState(value) then
					External.logError("invalidOutType")
				end
				local value = value :: Types.StateObject<unknown>
				if value.kind ~= "Value" then
					External.logError("invalidOutType")
				end
				local value = value :: Types.Value<unknown>
				checkLifetime.bOutlivesA(
					scope, applyTo,
					value.scope, value.oldestTask,
					checkLifetime.formatters.propertyOutputsTo, propertyName
				)

				value:set((applyTo :: any)[propertyName])
				table.insert(
					scope,
					event:Connect(function()
						value:set((applyTo :: any)[propertyName])
					end)
				)
			end
		}
		keyCache[propertyName] = key
	end
	return key
end

return Out


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Applies a table of properties to an instance, including binding to any
	given state objects and applying any special keys.

	No strong reference is kept by default - special keys should take care not
	to accidentally hold strong references to instances forever.

	If a key is used twice, an error will be thrown. This is done to avoid
	double assignments or double bindings. However, some special keys may want
	to enable such assignments - in which case unique keys should be used for
	each occurence.
]]
local Types = require("../Types")
local External = require("../External")
-- Logging
local parseError = require("../Logging/parseError")
-- Memory
local checkLifetime = require("../Memory/checkLifetime")
-- Graph
local Observer = require("../Graph/Observer")
-- State
local castToState = require("../State/castToState")
local peek = require("../State/peek")
-- Utility
local xtypeof = require("../Utility/xtypeof")

local function setProperty_unsafe(
	instance: Instance,
	property: string,
	value: unknown
)
	(instance :: any)[property] = value
end

local function testPropertyAssignable(
	instance: Instance,
	property: string
)
	(instance :: any)[property] = (instance :: any)[property]
end

local function setProperty(
	instance: Instance,
	property: string,
	value: unknown
)
	local success, err = xpcall(setProperty_unsafe :: any, parseError, instance, property, value)

	if not success then
		if not pcall(testPropertyAssignable, instance, property) then
			External.logErrorNonFatal("cannotAssignProperty", nil, instance.ClassName, property)
		else
			-- property is assignable, but this specific assignment failed
			-- this typically implies the wrong type was received
			local givenType = typeof(value)
			local expectedType = typeof((instance :: any)[property])

			if givenType == expectedType then
				External.logErrorNonFatal("propertySetError", err)
			else
				External.logErrorNonFatal("invalidPropertyType", nil, instance.ClassName, property, expectedType, givenType)
			end
		end
	end
end

local function bindProperty(
	scope: Types.Scope<unknown>,
	instance: Instance,
	property: string,
	value: Types.UsedAs<unknown>
)
	if castToState(value) then
		local value = value :: Types.StateObject<unknown>
		checkLifetime.bOutlivesA(
			scope, instance,
			value.scope, value.oldestTask,
			checkLifetime.formatters.boundProperty, property
		)
		-- value is a state object - bind to changes
		Observer(scope, value :: any):onBind(function()
			setProperty(instance, property, peek(value))
		end)
	else
		-- value is a constant - assign once only
		setProperty(instance, property, value)
	end
end

local function applyInstanceProps(
	scope: Types.Scope<unknown>,
	props: Types.PropertyTable,
	applyTo: Instance
)
	local specialKeys = {
		self = {} :: {[Types.SpecialKey]: unknown},
		descendants = {} :: {[Types.SpecialKey]: unknown},
		ancestor = {} :: {[Types.SpecialKey]: unknown},
		observer = {} :: {[Types.SpecialKey]: unknown}
	}

	for key, value in pairs(props) do
		local keyType = xtypeof(key)

		if keyType == "string" then
			if key ~= "Parent" then
				bindProperty(scope, applyTo, key :: string, value)
			end
		elseif keyType == "SpecialKey" then
			local stage = (key :: Types.SpecialKey).stage
			local keys = specialKeys[stage]
			if keys == nil then
				External.logError("unrecognisedPropertyStage", nil, stage)
			else
				keys[key] = value
			end
		else
			-- we don't recognise what this key is supposed to be
			External.logError("unrecognisedPropertyKey", nil, keyType)
		end
	end

	for key, value in pairs(specialKeys.self) do
		key:apply(scope, value, applyTo)
	end
	for key, value in pairs(specialKeys.descendants) do
		key:apply(scope, value, applyTo)
	end

	if props.Parent ~= nil then
		bindProperty(scope, applyTo, "Parent", props.Parent)
	end

	for key, value in pairs(specialKeys.ancestor) do
		key:apply(scope, value, applyTo)
	end
	for key, value in pairs(specialKeys.observer) do
		key:apply(scope, value, applyTo)
	end
end

return applyInstanceProps


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Formats a Fusion-specific error message.
]]
local Types = require("../Types")
local messages = require("../Logging/messages")

local ERROR_INFO_URL = "https://elttob.uk/Fusion/0.3/api-reference/general/errors/#"

local function formatError(
	externalProvider: Types.ExternalProvider?,
	messageID: string,
	errorOrTrace: Types.Error | string | nil,
	...: unknown
): string
	local originalMessageID = messageID
	local error: Types.Error? = if typeof(errorOrTrace) == "table" then errorOrTrace else nil
	local trace: string? = if typeof(errorOrTrace) == "table" then errorOrTrace.trace else errorOrTrace
	local messageText = messages[messageID]
	if messageText == nil then
		messageID = "unknownMessage"
		messageText = messages[messageID]
	end
	messageText = messageText:format(...)
	if error ~= nil then
		messageText = messageText:gsub("ERROR_MESSAGE", error.message)
		if error.context ~= nil then
			messageText ..= ` ({error.context})`
		end
	else
		messageText = messageText:gsub("ERROR_MESSAGE", originalMessageID)
	end
	messageText = `[Fusion] {messageText} \nID: {messageID}`
	if externalProvider ~= nil and externalProvider.policies.allowWebLinks then
		messageText ..= `\nLearn more: {ERROR_INFO_URL}{messageID:lower()}`
	end
	if trace ~= nil then
		messageText ..= ` \n---- Stack trace ----\n{trace}`
	end
	return messageText:gsub("\n", "\n    ")
end

return formatError


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	An xpcall() error handler to collect and parse useful information about
	errors, such as clean messages and stack traces.
]]
local Types = require("../Types")

local function parseError(
	err: string
): Types.Error
	return {
		type = "Error",
		raw = err,
		message = err:gsub("^.+:%d+:%s*", ""),
		trace = debug.traceback(nil, 2)
	}
end

return parseError


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Procedures for checking lifetimes and printing helpful warnings about them.
]]
local Types = require("../Types")
local External = require("../External")
local whichLivesLonger = require("../Memory/whichLivesLonger")
local nameOf = require("../Utility/nameOf")

local checkLifetime = {}

checkLifetime.formatters = {}

function checkLifetime.formatters.useFunction(
	self: unknown,
	used: unknown
): (string, string)
	local selfName = nameOf(self, "object")
	local usedName = nameOf(used, "object")
	return `The use()-d {usedName}`, `the {selfName}`
end

function checkLifetime.formatters.boundProperty(
	instance: Instance,
	bound: unknown,
	property: string
): (string, string)
	local selfName = instance.Name
	local boundName = nameOf(bound, "value")
	return `The {boundName} (bound to the {property} property)`, `the {selfName} instance`
end

function checkLifetime.formatters.boundAttribute(
	instance: Instance,
	bound: unknown,
	attribute: string
): (string, string)
	local selfName = instance.Name
	local boundName = nameOf(bound, "value")
	return `The {boundName} (bound to the {attribute} attribute)`, `the {selfName} instance`
end

function checkLifetime.formatters.propertyOutputsTo(
	instance: Instance,
	bound: unknown,
	property: string
): (string, string)
	local selfName = instance.Name
	local boundName = nameOf(bound, "object")
	return `The {boundName} (which the {property} property outputs to)`, `the {selfName} instance`
end

function checkLifetime.formatters.attributeOutputsTo(
	instance: Instance,
	bound: unknown,
	attribute: string
): (string, string)
	local selfName = instance.Name
	local boundName = nameOf(bound, "object")
	return `The {boundName} (which the {attribute} attribute outputs to)`, `the {selfName} instance`
end

function checkLifetime.formatters.refOutputsTo(
	instance: Instance,
	bound: unknown
): (string, string)
	local selfName = instance.Name
	local boundName = nameOf(bound, "object")
	return `The {boundName} (which the Ref key outputs to)`, `the {selfName} instance`
end

function checkLifetime.formatters.animationGoal(
	self: unknown,
	goal: unknown
): (string, string)
	local selfName = nameOf(self, "object")
	local goalName = nameOf(goal, "object")
	return `The goal {goalName}`, `the {selfName} that is following it`
end

function checkLifetime.formatters.parameter(
	self: unknown,
	used: unknown,
	parameterName: string | false
): (string, string)
	local selfName = nameOf(self, "object")
	local usedName = nameOf(used, "object")
	if parameterName == false then
		return `The {usedName} parameter`, `the {selfName} that it was used for`
	else
		return `The {usedName} representing the {parameterName} parameter`, `the {selfName} that it was used for`
	end
end

function checkLifetime.formatters.observer(
	self: unknown,
	watched: unknown
): (string, string)
	local selfName = nameOf(self, "object")
	local watchedName = nameOf(watched, "object")
	return `The watched {watchedName}`, `the {selfName} that's observing it for changes`
end

function checkLifetime.bOutlivesA<A, B, Args...>(
	scopeA: Types.Scope<unknown>,
	a: A,
	scopeB: Types.Scope<unknown>?,
	b: B,
	formatter: (a: A, b: B, Args...) -> (string, string),
	...: Args...
)
	if scopeB == nil then
		External.logError("useAfterDestroy", nil, formatter(a, b, ...))
	elseif whichLivesLonger(scopeA, a, scopeB, b) == "definitely-a" then
		local aName, bName = formatter(a, b, ...)
		External.logWarn(
			"possiblyOutlives",
			aName, bName,
			if scopeA == scopeB then
				"they're in the same scope, but the latter is destroyed too quickly"
			else
				"the latter is in a different scope that gets destroyed too quickly"
		)
	end
end
return checkLifetime


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Creates an empty scope with the same metatables as the original scope. Used
	for preserving access to constructors when creating inner scopes.

	This is the public version of the function, which implements external
	debugging hooks.
]]
local Types = require("../Types")
local ExternalDebug = require("../ExternalDebug")
local deriveScopeImpl = require("../Memory/deriveScopeImpl")

local function deriveScope(...)
	local scope = deriveScopeImpl(...)
	ExternalDebug.trackScope(scope)
	return scope
end

return deriveScope :: Types.DeriveScopeConstructor


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Creates an empty scope with the same metatables as the original scope. Used
	for preserving access to constructors when creating inner scopes.

	This is the internal version of the function, which does not implement
	external debugging hooks.
]]
local Types = require("../Types")
local merge = require("../Utility/merge")
local scopePool = require("../Memory/scopePool")

-- This return type is technically a lie, but it's required for useful type
-- checking behaviour.
local function deriveScopeImpl<T>(
	existing: Types.Scope<T>,
	methods: {[unknown]: unknown}?,
	...: {[unknown]: unknown}
): any
	local metatable = getmetatable(existing)
	if methods ~= nil then
		metatable = table.clone(metatable)
		metatable.__index = merge(
			true, {},
			metatable.__index,
			merge(
				false, {},
				methods,
				...
			)
		)
	end
	local scope = setmetatable(
		scopePool.reuseAny() :: any or {},
		metatable
	)
	return scope
end

return (deriveScopeImpl :: any) :: Types.DeriveScopeConstructor


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Cleans up the tasks passed in as the arguments.
	A task can be any of the following:

	- an Instance - will be destroyed
	- an RBXScriptConnection - will be disconnected
	- a function - will be run
	- a table with a `Destroy` or `destroy` function - will be called
	- an array - `cleanup` will be called on each item
]]
local Types = require("../Types")
local External = require("../External")
local scopePool = require("../Memory/scopePool")
local poisonScope = require("../Memory/poisonScope")

local alreadyDestroying: {[Types.Task]: true} = {}

local function doCleanup(
	task: Types.Task
): ()
	if alreadyDestroying[task] then
		return External.logError("destroyedTwice")
	end
	alreadyDestroying[task] = true

	-- case 1: Instance
	if typeof(task) == "Instance" then
		task:Destroy()

	-- case 2: RBXScriptConnection
	elseif typeof(task) == "RBXScriptConnection" then
		task:Disconnect()

	-- case 3: callback
	elseif typeof(task) == "function" then
		task()

	elseif typeof(task) == "table" then
		local task = (task :: any) :: {Destroy: (...unknown) -> (...unknown)?, destroy: (...unknown) -> (...unknown)?}

		-- case 4: destroy() function
		if typeof(task.destroy) == "function" then
			local task = (task :: any) :: {destroy: (...unknown) -> (...unknown)}
			task:destroy()

		-- case 5: Destroy() function
		elseif typeof(task.Destroy) == "function" then
			local task = (task :: any) :: {Destroy: (...unknown) -> (...unknown)}
			task:Destroy()

		-- case 6: table of tasks with an array part
		elseif task[1] ~= nil then
			local task = task :: {Types.Task}

			-- It is important to iterate backwards through the table, since
			-- objects are added in order of construction.
			for index = #task, 1, -1 do
				doCleanup(task[index])
				task[index] = nil
			end

			if External.isTimeCritical() then
				scopePool.giveIfEmpty(task)
			else
				poisonScope(task, "`doCleanup()` was previously called on this scope. Ensure you are not reusing scopes after cleanup.")
			end
		end
	end

	alreadyDestroying[task] = nil
end

return doCleanup


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Derives a new scope that's destroyed exactly once, whether by the user or by
	the scope that it's inside of.
]]
local Types = require("../Types")
local ExternalDebug = require("../ExternalDebug")
local deriveScopeImpl = require("../Memory/deriveScopeImpl")

local function innerScope<T>(
	existing: Types.Scope<T>,
	...: {[unknown]: unknown}
): any
	local new = deriveScopeImpl(existing, ...)
	table.insert(existing, new)
	table.insert(
		new,
		function()
			local index = table.find(existing, new)
			if index ~= nil then
				table.remove(existing, index)
			end
		end
	)
	ExternalDebug.trackScope(new)
	return new
end

return (innerScope :: any) :: Types.DeriveScopeConstructor


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler
local Types = require("../Types")
local External = require("../External")
local doCleanup = require("../Memory/doCleanup")

local function legacyCleanup(
	value: Types.Task
)
	External.logWarn("cleanupWasRenamed")
	return doCleanup(value)
end

return legacyCleanup


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	'Poisons' the given scope; if the scope is used again, then it will cause
	the program to crash.
]]
local Types = require("../Types")
local External = require("../External")

local function poisonScope(
	scope: Types.Scope,
	context: string
): ()
	local mt = getmetatable(scope)
	if typeof(mt) == "table" and mt._FUSION_POISONED then
		return
	end
	table.clear(scope)
	setmetatable(scope :: any, {
		_FUSION_POISONED = true,
		__index = function()
			External.logError("poisonedScope", nil, context)
		end,
		__newindex = function()
			External.logError("poisonedScope", nil, context)
		end
	})
end

return poisonScope


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler
local Types = require("../Types")
local poisonScope = require("../Memory/poisonScope")
local ExternalDebug = require("../ExternalDebug")

local ENABLE_POOLING = false
local MAX_POOL_SIZE = 16 -- TODO: need to test what an ideal number for this is

local pool = {}
local poolSize = 0

return {
	giveIfEmpty = function<S>(
		scope: Types.Scope<S>
	): Types.Scope<S>?
		if next(scope) == nil then
			ExternalDebug.untrackScope(scope)
			if ENABLE_POOLING and poolSize < MAX_POOL_SIZE then
				poolSize += 1
				pool[poolSize] = scope
			else
				poisonScope(scope, "previously passed to the internal scope pool, which indicates a Fusion bug.")
			end
			return nil
		else
			return scope
		end
	end,
	clearAndGive = function(
		scope: Types.Scope<unknown>
	)
		ExternalDebug.untrackScope(scope)
		table.clear(scope)
		if ENABLE_POOLING and poolSize < MAX_POOL_SIZE then
			poolSize += 1
			pool[poolSize] = scope :: any
		else
			poisonScope(scope, "previously passed to the internal scope pool, which indicates a Fusion bug.")
		end
	end,
	reuseAny = function(): Types.Scope<unknown>
		if poolSize == 0 then
			return nil :: any
		else
			local scope = pool[poolSize]
			poolSize -= 1
			return scope
		end
	end
}


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Creates cleanup tables with access to constructors as methods.
]]
local Types = require("../Types")
local ExternalDebug = require("../ExternalDebug")
local merge = require("../Utility/merge")
local scopePool = require("../Memory/scopePool")

local function scoped(
	...: {[unknown]: unknown}
): any
	local scope = setmetatable(
		scopePool.reuseAny() :: any or {},
		{__index = merge(false, {}, ...)}
	) :: any
	ExternalDebug.trackScope(scope)
	return scope
end

return (scoped :: any) :: Types.ScopedConstructor


--!strict
--!nolint LocalUnused
--!nolint LocalShadow
local task = nil -- Disable usage of Roblox's task scheduler

--[[
	Calculates how the lifetimes of the two values relate. Specifically, it
	calculates which value will be destroyed earlier or later, if it is possible
	to infer this from their scopes.
]]
local Types = require("../Types")
local External = require("../External")

local function whichScopeLivesLonger(
	scopeA: Types.Scope<unknown>,
	scopeB: Types.Scope<unknown>
): "definitely-a" | "definitely-b" | "unsure"
	-- If we can prove one scope is inside of the other scope, then the outer
	-- scope must live longer than the inner scope (assuming idiomatic scopes).
	-- So, we will search the scopes recursively until we find one of them, at
	-- which point we know they must have been found inside the other scope.
	local openSet: {Types.Scope<unknown>} = {scopeA, scopeB}
	local nextOpenSet: {Types.Scope<unknown>} = {}
	local openSetSize, nextOpenSetSize = 2, 0
	local closedSet = {}
	while openSetSize > 0 do
		for _, scope in openSet do
			closedSet[scope] = true
			for _, inScope in ipairs(scope) do
				if inScope == scopeA then
					return "definitely-b"
				elseif inScope == scopeB then
					return "definitely-a"
				elseif typeof(inScope) == "table" then
					local inScope = inScope :: {unknown}
					if inScope[1] ~= nil and closedSet[scope] == nil then
						nextOpenSetSize += 1
						nextOpenSet[nextOpenSetSize] = inScope :: Types.Scope<unknown>
					end
				end
			end
		end
		table.clear(openSet)
		openSet, nextOpenSet = nextOpenSet, openSet
		openSetSize, nextOpenSetSize = nextOpenSetSize, 0
	end
	return "unsure"
end

local function whichLivesLonger(
	scopeA: Types.Scope<unknown>,
	a: unknown,
	scopeB: Types.Scope<unknown>,
	b: unknown
): "definitely-a" | "definitely-b" | "unsure"
	if External.isTimeCritical() then
		return "unsure"
	elseif scopeA == scopeB then
		local scopeA: {unknown} = scopeA
		for index = #scopeA, 1, -1 do
			local value = scopeA[index]
			if value == a then
				return "definitely-b"
			elseif value == b then
				return "definitely-a"
			end
		end
		return "unsure"
	else
		return whichScopeLivesLonger(scopeA, scopeB)
	end
end

return whichLivesLonger