local Src = script:FindFirstAncestor("ultimate-list")

local DataSourceMethods = require(Src.DataSources.DataSourceMethods)
local DataSources = require(Src.DataSources)
local Dimensions = require(Src.Dimensions)
local DimensionsMethods = require(Src.Dimensions.DimensionsMethods)
local binarySearchIndexRangeInView = require(script.binarySearchIndexRangeInView)
local createDebugLogger = require(Src.Util.createDebugLogger)
local exhaustiveMatch = require(Src.Util.exhaustiveMatch)

local log = createDebugLogger("createVirtualizedListController")

export type VirtualizedListController<T> = {
	setScrollAxis: (scrollAxis: number) -> (),

	setWindowSize: (windowSize: Vector2) -> (),
	getWindowSize: () -> Vector2,

	setDataSource: (DataSources.DataSource<T>) -> (),
	setDimensions: (Dimensions.Dimensions<T>) -> (),

	-- If the view has not changed, will return the identical last result
	get: () -> { T },
	getCanvasSize: () -> UDim2,
	getRange: () -> Vector3,

	bindToUpdate: (callback: () -> ()) -> () -> (),

	destroy: () -> (),
}

type CurrentView<T> = {
	range: Vector3,
	canvasSize: UDim2,
	windowSize: Vector2,
	lastDimensions: Dimensions.Dimensions<T>,
	cachedLastResult: { T },
	offsetBoundaries: Vector3?,
	dataSource: DataSources.DataSource<T>,
}

local function createVirtualizedListController<T>(
	dataSource: DataSources.DataSource<T>,
	dimensions: Dimensions.Dimensions<T>,
	direction: "x" | "y"
): VirtualizedListController<T>
	local currentView: CurrentView<T> = {
		range = Vector3.new(),
		canvasSize = UDim2.new(),
		windowSize = Vector2.new(),
		lastDimensions = dimensions,
		scrollSizeAxis = 0,
		cachedLastResult = {},
		dataSource = dataSource,
	}

	local scrollAxis = 0

	local windowAxis = 0
	local windowSize = Vector2.new()

	local function getIndexRangeInViewForSize(size: number): Vector3
		local head = 1 + scrollAxis // size
		local tail = 1 + ((scrollAxis + windowAxis) // size)

		return Vector3.new(head, tail)
	end

	local function getIndexRangeInViewForUDim2(udim2: UDim2): Vector3
		-- Amount per row if we're Y direction,
		-- amount per column if we're X.
		local amountPerNonDominant = DimensionsMethods.getAmountPerNonDominantInGrid(udim2, windowSize, direction)

		local dominantSize = if direction == "x"
			then udim2.X.Offset
			elseif direction == "y" then udim2.Y.Offset
			else exhaustiveMatch(direction)

		local head = 1 + ((scrollAxis // dominantSize) * amountPerNonDominant)
		local tail = (1 + ((scrollAxis + windowAxis) // dominantSize)) * amountPerNonDominant

		log(
			"getIndexRangeInView: amountPerNonDominant = %d, scrollAxis = %d, dominantSize = %d. Going from %d to %d",
			amountPerNonDominant,
			scrollAxis,
			dominantSize,
			head,
			tail
		)

		return Vector3.new(head, tail)
	end

	-- consistentUDim2: O(1)
	-- consistentSize: O(1)
	-- getter: O(log n) assuming getter is O(1)
	local function getIndexRangeInView(): Vector3
		if dimensions.type == "consistentUDim2" then
			return getIndexRangeInViewForUDim2(dimensions.udim2)
		elseif dimensions.type == "consistentSize" then
			return getIndexRangeInViewForSize(dimensions.size)
		elseif dimensions.type == "getter" then
			return binarySearchIndexRangeInView(dataSource, dimensions.callback, scrollAxis, windowAxis, direction)
		elseif dimensions.type == "spaced" then
			if dimensions.inner.type == "consistentSize" then
				return getIndexRangeInViewForSize(dimensions.inner.size + dimensions.spacing)
			elseif dimensions.inner.type == "consistentUDim2" then
				return getIndexRangeInViewForUDim2(
					dimensions.inner.udim2
						+ UDim2.fromOffset(
							if direction == "x" then dimensions.spacing else 0,
							if direction == "y" then dimensions.spacing else 0
						)
				)
			elseif dimensions.inner.type == "spaced" or dimensions.inner.type == "getter" then
				error("Unsupported spaced dimensions")
			else
				return exhaustiveMatch(dimensions.inner.type)
			end
		else
			return exhaustiveMatch(dimensions.type)
		end
	end

	local callbacks: { [() -> ()]: true? } = {}

	local function callUpdateCallbacks()
		for callback in callbacks do
			callback()
		end
	end

	local function update()
		-- Only re-check if we actually will see anything new
		if
			currentView.offsetBoundaries ~= nil
			and currentView.offsetBoundaries.X <= scrollAxis
			and currentView.offsetBoundaries.Y >= windowAxis + scrollAxis
			and currentView.windowSize == windowSize
			-- TODO: If append only, just check length, though make sure
			-- that it still works if the window hasn't been tall enough to
			-- scroll yet (I'm not sure if offsetBoundaries will work)
			and currentView.dataSource == dataSource
			and currentView.lastDimensions == dimensions
		then
			return
		end

		local newIndexRange = getIndexRangeInView()
		local items = DataSourceMethods.getByRange(dataSource, newIndexRange)

		if #items == 0 then
			log("View changed, but no items in list")

			currentView = {
				range = newIndexRange,
				canvasSize = UDim2.new(),
				windowSize = windowSize,
				lastDimensions = dimensions,
				cachedLastResult = {},
				offsetBoundaries = Vector3.new(scrollAxis, windowAxis + scrollAxis),
				dataSource = dataSource,
			}

			callUpdateCallbacks()

			return
		end

		local topDimensions =
			DimensionsMethods.getUDimRect(dimensions, items[1], newIndexRange.X, windowSize, direction)
		local bottomDimensions =
			DimensionsMethods.getUDimRect(dimensions, items[#items], newIndexRange.Y, windowSize, direction)

		log(
			"View changed: %d..%d (scroll axis: %d, window axis: %d, offset boundaries: %* - %*)",
			newIndexRange.X,
			newIndexRange.Y,
			scrollAxis,
			windowAxis,
			currentView.offsetBoundaries and currentView.offsetBoundaries.X,
			currentView.offsetBoundaries and currentView.offsetBoundaries.Y
		)

		currentView = {
			range = newIndexRange,
			canvasSize = DimensionsMethods.getCanvasSize(dimensions, dataSource, windowSize, direction),
			windowSize = windowSize,
			lastDimensions = dimensions,
			cachedLastResult = items,
			offsetBoundaries = Vector3.new(
				if direction == "x"
					then topDimensions.position.X.Offset
					elseif direction == "y" then topDimensions.position.Y.Offset
					else exhaustiveMatch(direction),

				if direction == "x"
					then bottomDimensions.position.X.Offset + bottomDimensions.size.X.Offset
					elseif direction == "y" then bottomDimensions.position.Y.Offset + bottomDimensions.size.Y.Offset
					else exhaustiveMatch(direction)
			),
			dataSource = dataSource,
		}

		callUpdateCallbacks()
	end

	local function setScrollAxis(newScrollAxis: number)
		scrollAxis = newScrollAxis
		update()
	end

	local function setDataSource(newDataSource: DataSources.DataSource<T>)
		assert(dataSource.type == newDataSource.type, "Data source type changed, you must keep it the same")

		if DataSourceMethods.equals(dataSource, newDataSource) then
			return
		end

		dataSource = newDataSource
		update()
	end

	local function setDimensions(newDimensions: Dimensions.Dimensions<T>)
		if DimensionsMethods.equals(dimensions, newDimensions) then
			return
		end

		dimensions = newDimensions
		update()
	end

	local function setWindowSize(newWindowSize: Vector2)
		if windowSize == newWindowSize then
			return
		end

		windowSize = newWindowSize
		windowAxis = DimensionsMethods.getDominantAxis(newWindowSize, direction)

		update()
	end

	local function getWindowSize(): Vector2
		return windowSize
	end

	local function get(): { T }
		return currentView.cachedLastResult
	end

	local function getCanvasSize(): UDim2
		return currentView.canvasSize
	end

	local function getRange(): Vector3
		return currentView.range
	end

	local function bindToUpdate(callback: () -> ()): () -> ()
		callbacks[callback] = true
		return function()
			callbacks[callback] = nil
		end
	end

	local disconnectMutableSourceUpdate: (() -> ())? = if dataSource.type == "mutableSource"
		then dataSource.methods.bindToChanged(update)
		else nil

	local function destroy()
		if disconnectMutableSourceUpdate ~= nil then
			disconnectMutableSourceUpdate()
		end
	end

	return {
		setScrollAxis = setScrollAxis,
		setDataSource = setDataSource,
		setDimensions = setDimensions,

		setWindowSize = setWindowSize,
		getWindowSize = getWindowSize,

		get = get,
		getCanvasSize = getCanvasSize,
		getRange = getRange,

		bindToUpdate = bindToUpdate,

		destroy = destroy,
	}
end

return createVirtualizedListController


local Components = require(script.Components)
local DataSources = require(script.DataSources)
local DebugFlags = require(script.DebugFlags)
local Dimensions = require(script.Dimensions)
local Renderers = require(script.Renderers)

export type Dimensions<T> = Dimensions.Dimensions<T>
export type UDimRect = Dimensions.UDimRect

export type DataSource<T> = DataSources.DataSource<T>
export type DataSourceCursor<T> = DataSources.DataSourceCursor<T>
export type MutableDataSourceMethods<T> = DataSources.MutableDataSourceMethods<T>

export type Renderer<T> = Renderers.Renderer<T>
export type StateRendererConfig = Renderers.StateRendererConfig

local UltimateList = {}

UltimateList.Components = Components
UltimateList.DebugFlags = DebugFlags
UltimateList.DataSources = DataSources
UltimateList.Dimensions = Dimensions
UltimateList.Renderers = Renderers

return UltimateList


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

local function Demo()
	local items = {}
	for index = 1, 100_000 do
		table.insert(items, tostring(index))
	end

	local bindingsRef = React.useRef(nil :: ScrollingFrame?)

	local onStateScrollAxisChanged = React.useCallback(function(scrollAxis: number)
		if bindingsRef.current ~= nil then
			bindingsRef.current.CanvasPosition = Vector2.new(0, scrollAxis)
		end
	end, {})

	return e("Frame", {}, {
		State = e("Frame", {
			Size = UDim2.fromOffset(300, 300),
		}, {
			ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
				dataSource = UltimateList.DataSources.array(items),
				dimensions = UltimateList.Dimensions.consistentSize(48),
				renderer = UltimateList.Renderers.byState(function(value)
					return e("TextLabel", {
						BackgroundColor3 = Color3.new(1, 1, 1),
						Font = Enum.Font.BuilderSansBold,
						Text = value,
						TextColor3 = Color3.new(0, 0, 0),
						TextSize = 36,
						Size = UDim2.fromScale(1, 1),
					})
				end),

				direction = "y",

				onScrollAxisChanged = onStateScrollAxisChanged,
			}),
		}),

		Bindings = e("Frame", {
			Size = UDim2.fromOffset(300, 300),
			Position = UDim2.fromOffset(350, 0),
		}, {
			ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
				dataSource = UltimateList.DataSources.array(items),
				dimensions = UltimateList.Dimensions.consistentSize(48),
				renderer = UltimateList.Renderers.byBinding(function(value)
					return e("TextLabel", {
						BackgroundColor3 = Color3.new(1, 1, 1),
						Font = Enum.Font.BuilderSansBold,
						Text = value,
						TextColor3 = Color3.new(0, 0, 0),
						TextSize = 36,
						Size = UDim2.fromScale(1, 1),
					})
				end),

				direction = "y",

				scrollingFrameRef = bindingsRef,
			}),
		}),
	})
end

return function(target)
	local root = ReactRoblox.createRoot(target)

	root:render(e(Demo))

	return function()
		root:unmount()
	end
end


-- An example of an efficient chat
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TextService = game:GetService("TextService")

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

local WORD_BANK = {
	"time",
	"way",
	"year",
	"work",
	"government",
	"day",
	"man",
	"world",
	"life",
	"part",
	"house",
	"course",
	"case",
	"system",
	"place",
	"end",
	"group",
	"company",
	"party",
	"information",
	"school",
	"fact",
	"money",
	"point",
	"example",
	"state",
	"business",
	"night",
	"area",
	"water",
	"thing",
	"family",
	"head",
	"hand",
	"order",
	"john",
	"side",
	"home",
	"development",
	"week",
	"power",
	"country",
	"council",
	"use",
	"service",
	"room",
	"market",
	"problem",
	"court",
	"lot",
	"a",
	"war",
	"police",
	"interest",
	"car",
	"law",
	"road",
	"form",
	"face",
	"education",
	"policy",
	"research",
	"sort",
	"office",
	"body",
	"person",
	"health",
	"mother",
	"question",
	"period",
	"name",
	"book",
	"level",
	"child",
	"control",
	"society",
	"minister",
	"view",
	"door",
	"line",
	"community",
	"south",
	"city",
	"god",
	"father",
	"centre",
	"effect",
	"staff",
	"position",
	"kind",
	"job",
	"woman",
	"action",
	"management",
	"act",
	"process",
	"north",
	"age",
	"evidence",
	"idea",
}

local NAME_BANK = {
	"Alice",
	"Bob",
	"Charlie",
	"David",
	"Eve",
	"Frank",
}

local FONT = Font.fromEnum(Enum.Font.BuilderSans)
local TEXT_SIZE = 14
local WIDTH = 300

type Message = {
	richText: string,

	height: number,
	yOffset: number,
}

local function Demo()
	local messages: { Message }, setMessages = React.useState({} :: { Message })

	React.useEffect(function()
		local thread = task.spawn(function()
			local yOffset = 0

			local function newMessageAsync()
				local words = {}

				for _ = 1, math.random(100) do
					table.insert(words, WORD_BANK[math.random(#WORD_BANK)])
				end

				local richText = `<b>[{NAME_BANK[math.random(#NAME_BANK)]}]:</b> {table.concat(words, ", ")}`

				local textBoundsParams = Instance.new("GetTextBoundsParams")
				textBoundsParams.RichText = true
				textBoundsParams.Text = richText
				textBoundsParams.Font = FONT
				textBoundsParams.Size = TEXT_SIZE
				textBoundsParams.Width = WIDTH - 12 -- Subtract scrolling bar

				local height = TextService:GetTextBoundsAsync(textBoundsParams).Y

				local ourYOffset = yOffset

				setMessages(function(newMessages)
					newMessages = table.clone(newMessages)
					table.insert(newMessages, {
						richText = richText,
						height = height,
						yOffset = ourYOffset,
					})
					return newMessages
				end)

				yOffset += height
			end

			for _ = 1, 30 do
				newMessageAsync()
			end

			while true do
				task.wait(1)
				newMessageAsync()
			end
		end)

		return function()
			task.cancel(thread)
		end
	end, {})

	return e("Frame", {
		Size = UDim2.fromOffset(WIDTH, 300),
	}, {
		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = UltimateList.DataSources.array(messages),

			dimensions = UltimateList.Dimensions.getter(function(message: Message)
				return {
					position = UDim2.fromOffset(0, message.yOffset),
					size = UDim2.new(1, 0, 0, message.height),
				}
			end),

			renderer = UltimateList.Renderers.byBinding(function(messageBinding)
				return e("TextLabel", {
					BackgroundTransparency = 1,
					FontFace = FONT,
					RichText = true,
					Text = messageBinding:map(function(message: Message?)
						return if message then message.richText else ""
					end),
					TextColor3 = Color3.new(1, 1, 1),
					TextStrokeColor3 = Color3.new(0, 0, 0),
					TextStrokeTransparency = 0.7,
					TextSize = TEXT_SIZE,
					TextWrapped = true,
					TextXAlignment = Enum.TextXAlignment.Left,
					TextYAlignment = Enum.TextYAlignment.Top,
					Size = UDim2.fromScale(1, 1),
				})
			end),

			direction = "y",
		}),
	})
end

return function(target)
	local root = ReactRoblox.createRoot(target)

	root:render(e(Demo))

	return function()
		root:unmount()
	end
end


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

type Item =
	{
		type: "category",
		name: string,
	}
	| {
		type: "text",
		text: string,
	}

type ItemWithUDimRect = {
	item: Item,
	udimRect: UltimateList.UDimRect,
}

-- Helper functions
local function category(name: string): Item
	return {
		type = "category",
		name = name,
	}
end

local function text(value: string): Item
	return {
		type = "text",
		text = value,
	}
end

local ITEMS: { Item } = {
	category("North America"),
	text("Canada"),
	text("Mexico"),
	text("United States"),
	text("Panama"),
	text("Jamaica"),

	category("South America"),
	text("Brazil"),
	text("Argentina"),
	text("Bolivia"),
	text("Paraguay"),
	text("Venezuela"),

	category("Europe"),
	text("United Kingdom"),
	text("The Netherlands"),
	text("France"),
	text("Germany"),
	text("Ukraine"),
}

local itemsWithUDimRects: { ItemWithUDimRect } = {}
local nextPosition = UDim2.new()

for _, item in ITEMS do
	local height = if item.type == "category" then 32 else 24

	table.insert(itemsWithUDimRects, {
		item = item,
		udimRect = {
			size = UDim2.new(1, 0, 0, height),
			position = nextPosition,
		},
	})

	nextPosition += UDim2.fromOffset(0, height)
end

return function(target)
	local root = ReactRoblox.createRoot(target)

	root:render(e("Frame", {
		Size = UDim2.fromOffset(300, 300),
	}, {
		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = UltimateList.DataSources.array(itemsWithUDimRects),
			dimensions = UltimateList.Dimensions.getter(function(itemWithUDimRect: ItemWithUDimRect)
				return itemWithUDimRect.udimRect
			end),
			renderer = UltimateList.Renderers.byState(function(itemWithUDimRect: ItemWithUDimRect)
				if itemWithUDimRect.item.type == "category" then
					return e("TextLabel", {
						BackgroundColor3 = Color3.new(1, 1, 1),
						Font = Enum.Font.BuilderSansBold,
						Text = itemWithUDimRect.item.name,
						TextColor3 = Color3.new(0, 0, 0),
						TextSize = 30,
						Size = UDim2.fromScale(1, 1),
					})
				else
					return e("TextLabel", {
						BackgroundColor3 = Color3.new(1, 1, 1),
						Font = Enum.Font.BuilderSans,
						Text = itemWithUDimRect.item.text,
						TextColor3 = Color3.new(0, 0, 0),
						TextSize = 20,
						Size = UDim2.fromScale(1, 1),
					})
				end
			end),

			direction = "y",
		}),
	}))
end


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

return function(target)
	local root = ReactRoblox.createRoot(target)

	local letters = {}
	for offset = 0, 25 do
		table.insert(letters, string.char(string.byte("A") + offset))
	end

	root:render(e("Frame", {
		Size = UDim2.fromOffset(300, 300),
	}, {
		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = UltimateList.DataSources.array(letters),
			dimensions = UltimateList.Dimensions.consistentSize(48),
			renderer = UltimateList.Renderers.byBinding(function(valueBinding)
				return e("TextLabel", {
					-- Visually see efficient ordering
					BackgroundColor3 = Color3.fromHSV(math.random(), 0.5, 0.5),
					Font = Enum.Font.BuilderSansBold,
					Text = valueBinding:map(function(value: string?)
						return value or ""
					end),
					TextColor3 = Color3.new(0, 0, 0),
					TextSize = 36,
					Size = UDim2.fromScale(1, 1),
				})
			end),

			direction = "y",
		}),
	}))

	return function()
		root:unmount()
	end
end


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

return function(target)
	local root = ReactRoblox.createRoot(target)

	local items = {}
	for index = 1, 100_000 do
		table.insert(items, tostring(index))
	end

	root:render(e("Frame", {
		Size = UDim2.fromOffset(300, 300),
	}, {
		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = UltimateList.DataSources.array(items),
			dimensions = UltimateList.Dimensions.consistentSize(48),
			renderer = UltimateList.Renderers.byBinding(function(valueBinding)
				return e("TextLabel", {
					BackgroundColor3 = Color3.new(1, 1, 1),
					Font = Enum.Font.BuilderSansBold,
					Text = valueBinding:map(function(value: string?)
						return value or ""
					end),
					TextColor3 = Color3.new(0, 0, 0),
					TextSize = 36,
					Size = UDim2.fromScale(1, 1),
				})
			end),

			direction = "y",
		}),
	}))

	return function()
		root:unmount()
	end
end


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

return function(target)
	local root = ReactRoblox.createRoot(target)

	local letters = {}
	for offset = 0, 25 do
		table.insert(letters, string.char(string.byte("A") + offset))
	end

	root:render(e("Frame", {
		Size = UDim2.fromOffset(300, 300),
	}, {
		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = UltimateList.DataSources.array(letters),
			dimensions = UltimateList.Dimensions.consistentSize(48),
			renderer = UltimateList.Renderers.byState(function(value)
				return e("TextLabel", {
					BackgroundColor3 = Color3.new(1, 1, 1),
					Font = Enum.Font.BuilderSansBold,
					Text = value,
					TextColor3 = Color3.new(0, 0, 0),
					TextSize = 36,
					Size = UDim2.fromScale(1, 1),
				})
			end),

			direction = "y",
		}),
	}))

	return function()
		root:unmount()
	end
end


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

return function(target)
	local root = ReactRoblox.createRoot(target)

	local items = {}
	for index = 1, 100_000 do
		table.insert(items, tostring(index))
	end

	root:render(e("Frame", {
		Size = UDim2.fromOffset(300, 300),
	}, {
		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = UltimateList.DataSources.array(items),
			dimensions = UltimateList.Dimensions.consistentSize(48),
			renderer = UltimateList.Renderers.byState(function(value)
				return e("TextLabel", {
					BackgroundColor3 = Color3.new(1, 1, 1),
					Font = Enum.Font.BuilderSansBold,
					Text = value,
					TextColor3 = Color3.new(0, 0, 0),
					TextSize = 36,
					Size = UDim2.fromScale(1, 1),
				})
			end),

			direction = "y",
		}),
	}))

	return function()
		root:unmount()
	end
end


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

local function Demo()
	local numbers: { number }, setNumbers = React.useState({ 1, 2, 3 } :: { number })

	React.useEffect(function()
		local thread = task.spawn(function()
			while true do
				setNumbers(function(newNumbers)
					newNumbers = table.clone(newNumbers)
					table.insert(newNumbers, #newNumbers + 1)
					return newNumbers
				end)

				task.wait(0.5)
			end
		end)

		return function()
			task.cancel(thread)
		end
	end, {})

	return e("Frame", {
		Size = UDim2.fromOffset(300, 300),
	}, {
		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = UltimateList.DataSources.array(numbers),
			dimensions = UltimateList.Dimensions.consistentSize(48),
			renderer = UltimateList.Renderers.byState(function(value)
				return e("TextLabel", {
					BackgroundColor3 = Color3.new(1, 1, 1),
					Font = Enum.Font.BuilderSansBold,
					Text = value,
					TextColor3 = Color3.new(0, 0, 0),
					TextSize = 36,
					Size = UDim2.fromScale(1, 1),
				})
			end),

			direction = "y",
		}),
	})
end

return function(target)
	local root = ReactRoblox.createRoot(target)

	root:render(e(Demo))

	return function()
		root:unmount()
	end
end


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

return function(target)
	local root = ReactRoblox.createRoot(target)

	local letters = {}
	for offset = 0, 25 do
		table.insert(letters, string.char(string.byte("A") + offset))
	end

	root:render(e("Frame", {
		Size = UDim2.fromOffset(300, 300),
	}, {
		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = UltimateList.DataSources.array(letters),
			dimensions = UltimateList.Dimensions.withSpacing(UltimateList.Dimensions.consistentSize(48), 16),
			renderer = UltimateList.Renderers.byState(function(value)
				return e("TextLabel", {
					BackgroundColor3 = Color3.new(1, 1, 1),
					Font = Enum.Font.BuilderSansBold,
					Text = value,
					TextColor3 = Color3.new(0, 0, 0),
					TextSize = 36,
					Size = UDim2.fromScale(1, 1),
				})
			end),

			direction = "y",
		}),
	}))

	return function()
		root:unmount()
	end
end


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

return function(target)
	local root = ReactRoblox.createRoot(target)

	local letters = {}
	for offset = 0, 25 do
		table.insert(letters, string.char(string.byte("A") + offset))
	end

	root:render(e("Frame", {
		Size = UDim2.fromOffset(300, 300),
	}, {
		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = UltimateList.DataSources.array(letters),
			dimensions = UltimateList.Dimensions.consistentUDim2(UDim2.new(0.33, 0, 0, 72)),
			renderer = UltimateList.Renderers.byBinding(function(valueBinding)
				return e("TextLabel", {
					BackgroundColor3 = Color3.new(1, 1, 1),
					Font = Enum.Font.BuilderSansBold,
					Text = valueBinding:map(function(value: string?)
						return value or ""
					end),
					TextColor3 = Color3.new(0, 0, 0),
					TextSize = 36,
					Size = UDim2.fromScale(1, 1),
				})
			end),

			direction = "y",
		}),
	}))

	return function()
		root:unmount()
	end
end


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

return function(target)
	local root = ReactRoblox.createRoot(target)

	local letters = {}
	for offset = 0, 25 do
		table.insert(letters, string.char(string.byte("A") + offset))
	end

	root:render(e("Frame", {
		Size = UDim2.fromOffset(300, 300),
	}, {
		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = UltimateList.DataSources.array(letters),
			dimensions = UltimateList.Dimensions.consistentUDim2(UDim2.new(0.5, 0, 0, 72)),
			renderer = UltimateList.Renderers.byState(function(value)
				return e("TextLabel", {
					BackgroundColor3 = Color3.new(1, 1, 1),
					Font = Enum.Font.BuilderSansBold,
					Text = value,
					TextColor3 = Color3.new(0, 0, 0),
					TextSize = 36,
					Size = UDim2.fromScale(1, 1),
				})
			end),

			direction = "y",
		}),
	}))

	return function()
		root:unmount()
	end
end


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

return function(target)
	local root = ReactRoblox.createRoot(target)

	local letters = {}
	for offset = 0, 25 do
		table.insert(letters, string.char(string.byte("A") + offset))
	end

	root:render(e("Frame", {
		Size = UDim2.fromOffset(300, 300),
	}, {
		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = UltimateList.DataSources.array(letters),
			dimensions = UltimateList.Dimensions.withSpacing(
				UltimateList.Dimensions.consistentUDim2(UDim2.new(0.33, 0, 0, 72)),
				16
			),
			renderer = UltimateList.Renderers.byState(function(value)
				return e("TextLabel", {
					BackgroundColor3 = Color3.new(1, 1, 1),
					Font = Enum.Font.BuilderSansBold,
					Text = value,
					TextColor3 = Color3.new(0, 0, 0),
					TextSize = 36,
					Size = UDim2.fromScale(1, 1),
				})
			end),

			direction = "y",
		}),
	}))

	return function()
		root:unmount()
	end
end


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

return function(target)
	local root = ReactRoblox.createRoot(target)

	local letters = {}
	for offset = 0, 25 do
		table.insert(letters, string.char(string.byte("A") + offset))
	end

	root:render(e("Frame", {
		Size = UDim2.new(0.5, 0, 0, 300),
	}, {
		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = UltimateList.DataSources.array(letters),
			dimensions = UltimateList.Dimensions.consistentUDim2(UDim2.fromOffset(100, 72)),
			renderer = UltimateList.Renderers.byState(function(value)
				return e("TextLabel", {
					BackgroundColor3 = Color3.new(1, 1, 1),
					Font = Enum.Font.BuilderSansBold,
					Text = value,
					TextColor3 = Color3.new(0, 0, 0),
					TextSize = 36,
					Size = UDim2.fromScale(1, 1),
				})
			end),

			direction = "y",
		}),
	}))

	return function()
		root:unmount()
	end
end


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

return function(target)
	local root = ReactRoblox.createRoot(target)

	local letters = {}
	for offset = 0, 25 do
		table.insert(letters, string.char(string.byte("A") + offset))
	end

	root:render(e("Frame", {
		Size = UDim2.new(0.5, 0, 0, 300),
	}, {
		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = UltimateList.DataSources.array(letters),
			dimensions = UltimateList.Dimensions.withSpacing(
				UltimateList.Dimensions.consistentUDim2(UDim2.fromOffset(100, 72)),
				8
			),
			renderer = UltimateList.Renderers.byState(function(value)
				return e("TextLabel", {
					BackgroundColor3 = Color3.new(1, 1, 1),
					Font = Enum.Font.BuilderSansBold,
					Text = value,
					TextColor3 = Color3.new(0, 0, 0),
					TextSize = 36,
					Size = UDim2.fromScale(1, 1),
				})
			end),

			direction = "y",
		}),
	}))

	return function()
		root:unmount()
	end
end


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

return function(target)
	local root = ReactRoblox.createRoot(target)

	local letters = {}
	for offset = 0, 25 do
		table.insert(letters, string.char(string.byte("A") + offset))
	end

	root:render(e("Frame", {
		Size = UDim2.fromOffset(300, 300),
	}, {
		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = UltimateList.DataSources.array(letters),
			dimensions = UltimateList.Dimensions.consistentSize(48),
			renderer = UltimateList.Renderers.byState(function(value)
				return e("TextLabel", {
					BackgroundColor3 = Color3.new(1, 1, 1),
					Font = Enum.Font.BuilderSansBold,
					Text = value,
					TextColor3 = Color3.new(0, 0, 0),
					TextSize = 36,
					Size = UDim2.fromScale(1, 1),
				})
			end),

			direction = "x",
		}),
	}))

	return function()
		root:unmount()
	end
end


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

type Letter = {
	text: string,
	dimensions: UltimateList.UDimRect,
}

return function(target)
	local root = ReactRoblox.createRoot(target)

	local letters: { Letter } = {}
	local positionOffset = 0

	for offset = 0, 25 do
		local row = offset // 2
		if offset % 2 == 0 and row > 0 then
			positionOffset += row * 16
		end

		table.insert(letters, {
			text = string.char(string.byte("A") + offset),
			dimensions = {
				size = UDim2.new(0.5, 0, 0, (row + 1) * 16),
				position = UDim2.new((offset % 2) * 0.5, 0, 0, positionOffset),
			},
		})
	end

	root:render(e("Frame", {
		Size = UDim2.fromOffset(300, 300),
	}, {
		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = UltimateList.DataSources.array(letters),
			dimensions = UltimateList.Dimensions.getter(function(value: Letter, _index: number)
				return value.dimensions
			end),
			renderer = UltimateList.Renderers.byBinding(function(valueBinding: React.Binding<Letter?>)
				return e("TextLabel", {
					BackgroundColor3 = Color3.new(1, 1, 1),
					Font = Enum.Font.BuilderSansBold,
					Text = valueBinding:map(function(value: Letter?)
						return if value then value.text else ""
					end),
					TextColor3 = Color3.new(0, 0, 0),
					TextSize = 12,
					Size = UDim2.fromScale(1, 1),
				})
			end),

			direction = "y",
		}),
	}))

	return function()
		root:unmount()
	end
end


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

type Letter = {
	text: string,
	dimensions: UltimateList.UDimRect,
}

return function(target)
	local root = ReactRoblox.createRoot(target)

	local letters: { Letter } = {}
	local positionOffset = 0

	for offset = 0, 25 do
		local row = offset // 2
		if offset % 2 == 0 and row > 0 then
			positionOffset += row * 16
		end

		table.insert(letters, {
			text = string.char(string.byte("A") + offset),
			dimensions = {
				size = UDim2.new(0.5, 0, 0, (row + 1) * 16),
				position = UDim2.new((offset % 2) * 0.5, 0, 0, positionOffset),
			},
		})
	end

	root:render(e("Frame", {
		Size = UDim2.fromOffset(300, 300),
	}, {
		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = UltimateList.DataSources.array(letters),
			dimensions = UltimateList.Dimensions.getter(function(value: Letter, _index: number)
				return value.dimensions
			end),
			renderer = UltimateList.Renderers.byState(function(value: Letter)
				return e("TextLabel", {
					BackgroundColor3 = Color3.new(1, 1, 1),
					Font = Enum.Font.BuilderSansBold,
					Text = value.text,
					TextColor3 = Color3.new(0, 0, 0),
					TextSize = 12,
					Size = UDim2.fromScale(1, 1),
				})
			end),

			direction = "y",
		}),
	}))

	return function()
		root:unmount()
	end
end


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

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

return function(target)
	local root = ReactRoblox.createRoot(target)

	local strings: { string } = {}
	local changedCallbacks: { [() -> ()]: true? } = {}

	local mutableSource = UltimateList.DataSources.mutableSource({
		get = UltimateList.DataSources.utilities.createGetSimpleCursor(function(index: number): string
			return strings[index]
		end, function()
			return #strings
		end),

		length = function()
			return #strings
		end,

		bindToChanged = function(callback)
			changedCallbacks[callback] = true
			return function()
				changedCallbacks[callback] = nil
			end
		end,
	})

	local thread = task.spawn(function()
		while true do
			table.insert(strings, HttpService:GenerateGUID())

			for callback in changedCallbacks do
				callback()
			end

			task.wait(0.5)
		end
	end)

	root:render(e("Frame", {
		Size = UDim2.fromOffset(300, 300),
	}, {
		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = mutableSource,
			dimensions = UltimateList.Dimensions.consistentSize(48),
			renderer = UltimateList.Renderers.byState(function(value)
				return e("TextLabel", {
					BackgroundColor3 = Color3.new(1, 1, 1),
					Font = Enum.Font.BuilderSansBold,
					Text = value,
					TextColor3 = Color3.new(0, 0, 0),
					TextSize = 36,
					Size = UDim2.fromScale(1, 1),
				})
			end),

			direction = "y",
		}),
	}))

	return function()
		task.cancel(thread)
		root:unmount()
	end
end


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

local React = require(ReplicatedStorage.Packages.React)
local ReactRoblox = require(ReplicatedStorage.DevPackages.ReactRoblox)
local UltimateList = require(ReplicatedStorage.Packages["ultimate-list"])

local e = React.createElement

return function(target)
	local root = ReactRoblox.createRoot(target)

	local letters = {}
	for offset = 0, 25 do
		table.insert(letters, string.char(string.byte("A") + offset))
	end

	local styleSheet = Instance.new("StyleSheet")

	local styleRule = Instance.new("StyleRule")
	styleRule.Selector = ".MyScrollingFrame"
	styleRule:SetProperty("BackgroundColor3", Color3.new(1, 0, 0))
	styleRule.Parent = styleSheet

	styleSheet.Parent = StylingService

	root:render(e("Frame", {
		Size = UDim2.fromOffset(300, 300),
	}, {
		StyleLink = e("StyleLink", {
			StyleSheet = styleSheet,
		}),

		ScrollingFrame = e(UltimateList.Components.ScrollingFrame, {
			dataSource = UltimateList.DataSources.array(letters),
			dimensions = UltimateList.Dimensions.consistentSize(48),
			renderer = UltimateList.Renderers.byState(function(value)
				return e("TextLabel", {
					BackgroundColor3 = Color3.new(1, 1, 1),
					Font = Enum.Font.BuilderSansBold,
					Text = value,
					TextColor3 = Color3.new(0, 0, 0),
					TextSize = 36,
					Size = UDim2.fromScale(1, 1),
				})
			end),

			direction = "y",

			tag = "MyScrollingFrame",

			native = {
				BackgroundTransparency = 0.9,
			},
		}),
	}))

	return function()
		root:unmount()
		styleSheet:Destroy()
	end
end


local remodel = require("./remodel")
local fs = require("@lune/fs")
local serde = require("@lune/serde")

local informationFile = serde.decode("json", fs.readFile("RostarData.json"))
local initialRojoProject
do
	local success, exists = pcall(remodel.isFile, informationFile.rojoProjectPath)
	if success and exists then
		initialRojoProject = serde.decode("json", fs.readFile(informationFile.rojoProjectPath))
	end
end
local shouldOverwriteProjectFile = informationFile.shouldOverwriteProjectFile
local shouldUnpackLua = informationFile.shouldUnpackLua
local shouldUnpackModels = informationFile.shouldUnpackModels
local modelFileExtension = informationFile.modelFormat
local baseAssetsFolder = informationFile.assetsDirectory
local DataModel
if informationFile.placeType == "file" then
	DataModel = remodel.readPlaceFile(informationFile.placeLocation)
elseif informationFile.placeType == "web" then
	DataModel = remodel.readPlaceAsset(informationFile.placeLocation)
end

if not DataModel then
	error("Could not get any DataModel.")
end

local WHITELISTED_SERVICES = {
	["Workspace"] = true,
	["Players"] = true,
	["Lighting"] = true,
	["ReplicatedFirst"] = true,
	["ReplicatedStorage"] = true,
	["ServerScriptService"] = true,
	["ServerStorage"] = true,
	["StarterGui"] = true,
	["StarterPack"] = true,
	["StarterPlayer"] = true,
	["SoundService"] = true,
	["Chat"] = true,
	["LocalizationService"] = true,
	["TestService"] = true,
	["MaterialService"] = true,
}

local function reverseTable(tbl)
	local rev = {}
	local size = #tbl
	for i, value in ipairs(tbl) do
		rev[size - i + 1] = value
	end
	return rev
end

local function shallowCopyArray(tbl)
	local newTbl = {}
	for i, v in ipairs(tbl) do
		newTbl[i] = v
	end
	return newTbl
end

local function prependCopy(tbl, ...)
	local cp = shallowCopyArray(tbl)
	for _, element in ipairs({ ... }) do
		table.insert(cp, 1, element)
	end
	return cp
end

local function appendCopy(tbl, ...)
	local cp = shallowCopyArray(tbl)
	for _, element in ipairs({ ... }) do
		table.insert(cp, element)
	end
	return cp
end

local function splitString(str, sep)
	local tbl = {}
	for str in string.gmatch(str, "([^" .. sep .. "]+)") do
		table.insert(tbl, str)
	end
	return tbl
end

local function isLuaSourceContainer(className)
	return className == "Script" or className == "LocalScript" or className == "ModuleScript"
end

local function formatScriptFile(baseName, className)
	local suffix = (className == "Script" and ".server") or (className == "LocalScript" and ".client") or ""
	return baseName .. suffix .. ".lua"
end

local function formatModelFile(baseName)
	return baseName .. "." .. modelFileExtension
end

local function joinPath(pathSegments)
	return table.concat(pathSegments, "/")
end

local function makeFileParentDirectory(segments)
	local size = #segments
	local newSegments = {}
	for i, segment in ipairs(segments) do
		if i ~= size then
			newSegments[i] = segment
		end
	end
	remodel.createDirAll(joinPath(newSegments))
end

local function addEntryToProjectNode(node, entrySegments, value)
	for i, segment in ipairs(entrySegments) do
		node[segment] = node[segment] or {}
		node = node[segment]
	end
	node["$path"] = value
end

local function writeModelFile(instance, pathSegments)
	makeFileParentDirectory(pathSegments)
	remodel.writeModelFile(joinPath(pathSegments), instance)
end

local function writeFile(pathSegments, content)
	makeFileParentDirectory(pathSegments)
	remodel.writeFile(joinPath(pathSegments), content)
end

local function isService(instance)
	return instance.Parent == DataModel
end

local function isInstancePure(instance)
	return isLuaSourceContainer(instance.ClassName) or instance.ClassName == "Folder"
end

local function isCodeTree(instance)
	local toCheck = instance:GetDescendants()
	table.insert(toCheck, instance)
	for _, descendant in ipairs(toCheck) do
		if not isInstancePure(descendant) then
			return false
		end
	end
	return true
end

local function _mergingExtraChecks(instance)
	return instance.ClassName == "Model" or instance.ClassName == "ScreenGui"
end

local instancesWarnedAbout = {}
local function shouldInstanceGetMergedInParentModel(instance)
	local isCode = isCodeTree(instance)
	local fullName = instance:GetFullName()

	if
		isService(instance)
		or instance.ClassName == "StarterPlayerScripts"
		or instance.ClassName == "StarterCharacterScripts"
	then
		return false
	end

	if _mergingExtraChecks(instance.Parent) then
		return true
	end

	local basicCheck = not isInstancePure(instance)
		and not _mergingExtraChecks(instance)
		and instance.ClassName ~= "Actor"

	if basicCheck then
		return true
	end

	-- Detect name conflicts
	local clonedParent = instance.Parent:Clone()
	local foundChild = clonedParent:FindFirstChild(instance.Name)
	foundChild:Destroy()
	if clonedParent:FindFirstChild(instance.Name) then
		if isCode and shouldUnpackLua and not instancesWarnedAbout[fullName] then
			print(
				"Warning: Duplicate script objects have been detected. The scripts at "
					.. fullName
					.. " will not be unpacked in .lua files."
			)
			instancesWarnedAbout[fullName] = true
		end
		return true
	end
	return false
end

local function getInstancePath(instance)
	local parents = {}
	local currentInstance = instance
	while currentInstance.Parent ~= DataModel do
		table.insert(parents, currentInstance.Parent)
		currentInstance = currentInstance.Parent
	end
	parents = reverseTable(parents)
	return parents
end

local function getAssetParent(instance)
	local path = {}
	local instancePath = getInstancePath(instance)
	for _, segment in ipairs(instancePath) do
		table.insert(path, segment.Name)
	end
	return path
end

local function findInstanceFromPath(pathSegments)
	local currentInstance = DataModel
	while currentInstance and #pathSegments > 0 do
		local pathSegment = table.remove(pathSegments, 1)
		currentInstance = currentInstance:FindFirstChild(pathSegment)
	end
	return currentInstance
end

local function canNodeForInstanceBeExpanded(instance)
	return instance.ClassName == "Folder"
		or isService(instance)
		or instance.ClassName == "StarterPlayerScripts"
		or instance.ClassName == "StarterCharacterScripts"
		or instance.ClassName == "Actor"
end

local function shouldNodeForInstanceBeExpanded(instance)
	if not canNodeForInstanceBeExpanded(instance) then
		return false
	end
	if instance.ClassName == "Folder" then
		for _, child in ipairs(instance:GetChildren()) do
			if shouldInstanceGetMergedInParentModel(child) then
				return true
			end
		end
		return false
	end
	return true
end

local function mountDataModelCodeTreeToPath(rootInstance, rootFsPathSegments)
	local stack = { { rootInstance, rootFsPathSegments } }

	local function iterate()
		local stackValue = table.remove(stack)

		local instance = stackValue[1]
		local fsPathSegments = stackValue[2]

		for _, child in ipairs(instance:GetChildren()) do
			local segmentsForChild = shallowCopyArray(fsPathSegments)
			table.insert(segmentsForChild, child.Name)
			table.insert(stack, { child, segmentsForChild })
		end

		if isLuaSourceContainer(instance.ClassName) then
			local shouldBeInExpandedForm = #instance:GetChildren() > 0 or instance == rootInstance
			if shouldBeInExpandedForm then
				table.insert(fsPathSegments, formatScriptFile("init", instance.ClassName))
			else
				fsPathSegments[#fsPathSegments] = formatScriptFile(fsPathSegments[#fsPathSegments], instance.ClassName)
			end
			writeFile(fsPathSegments, instance.Source)
		end
	end

	while #stack > 0 do
		iterate()
	end
end

do
	do
		local camera = DataModel:GetService("Workspace"):FindFirstChild("Camera")
		if camera then
			camera:Destroy()
		end
	end

	local newRojoProject = {
		name = "Rostar Project",
		tree = {
			["$className"] = "DataModel",
		},
	}
	if initialRojoProject then
		for property, value in pairs(initialRojoProject) do
			if property ~= "tree" then
				newRojoProject[property] = value
			end
		end
	end

	do
		local stack = {}
		for _, child in ipairs(DataModel:GetChildren()) do
			if WHITELISTED_SERVICES[child.ClassName] then
				table.insert(stack, child)
			end
		end

		local function iterate()
			local instance = table.remove(stack)

			if shouldInstanceGetMergedInParentModel(instance) then
				return
			end

			if isCodeTree(instance) then
				if instance.ClassName == "Folder" and #instance:GetChildren() > 0 or instance.ClassName ~= "Folder" then
					local projectPath = getAssetParent(instance)
					table.insert(projectPath, instance.Name)
					local filePath = prependCopy(projectPath, "DataModel")
					addEntryToProjectNode(newRojoProject.tree, projectPath, joinPath(filePath))
					return
				end
			end
			local parentPath = getAssetParent(instance)

			if canNodeForInstanceBeExpanded(instance) then
				local shouldBeExpanded = shouldNodeForInstanceBeExpanded(instance)
				local folderPath = shallowCopyArray(parentPath)
				table.insert(folderPath, instance.Name)
				local modelPath = shallowCopyArray(parentPath)
				table.insert(modelPath, formatModelFile(instance.Name))
				local asset_modelPath = prependCopy(modelPath, baseAssetsFolder)

				if shouldBeExpanded then
					-- Instance is a Folder with an extra Folder.rbxm set to its $path

					addEntryToProjectNode(newRojoProject.tree, folderPath, joinPath(asset_modelPath))
					local indexInstance = instance:Clone()
					for _, child in ipairs(indexInstance:GetChildren()) do
						if not shouldInstanceGetMergedInParentModel(child) then
							child:Destroy()
						end
					end
					if shouldUnpackModels then
						writeModelFile(indexInstance, asset_modelPath)
					end
					parentPath[#parentPath] = indexInstance.Name
				else
					-- Instance is a simple Folder
				end
			else
				-- Instance is something like a Model

				local instancePath = appendCopy(parentPath, instance.Name)
				local assetPath = prependCopy(parentPath, baseAssetsFolder)
				table.insert(assetPath, formatModelFile(instance.Name))
				addEntryToProjectNode(newRojoProject.tree, instancePath, joinPath(assetPath))

				if shouldUnpackModels then
					writeModelFile(instance, assetPath)
				end
			end
			for _, child in ipairs(instance:GetChildren()) do
				table.insert(stack, child)
			end
		end

		while #stack > 0 do
			iterate()
		end
	end

	do
		local stack = { newRojoProject.tree }

		local function iterate()
			local node = table.remove(stack)

			if node["$className"] == nil and node["$path"] == nil then
				node["$className"] = "Folder"
			end

			for key, value in pairs(node) do
				if type(value) == "table" and key ~= "$properties" then
					table.insert(stack, value)
				end
			end
		end

		-- TODO: Add a pass for optimizing non-expanded folders
		--[[
			FolderA
				$path -> FolderA/FolderB
				$path -> FolderA/FolderC
				FolderB
				FolderC

			...

			FolderA ($path -> FolderA)
				FolderB
				FolderC
		]]

		while #stack > 0 do
			iterate()
		end
	end

	if shouldUnpackLua then
		local currentProjectFile = shouldOverwriteProjectFile and newRojoProject or initialRojoProject
		local stack = { { currentProjectFile.tree, {} } }

		local function iterate()
			local stackValue = table.remove(stack)

			local node = stackValue[1]
			local dataModelPath = stackValue[2]

			local nodePathProperty = node["$path"]

			if nodePathProperty then
				local dataModelInstance = findInstanceFromPath(shallowCopyArray(dataModelPath))
				if dataModelInstance and isCodeTree(dataModelInstance) and not nodePathProperty:find("%.rbxmx?$") then
					mountDataModelCodeTreeToPath(dataModelInstance, splitString(nodePathProperty, "/"))
				end
			end
			for key, value in pairs(node) do
				if type(value) == "table" and key ~= "$properties" then
					local newDataModelPath = shallowCopyArray(dataModelPath)
					table.insert(newDataModelPath, key)
					table.insert(stack, { value, newDataModelPath })
				end
			end
		end

		while #stack > 0 do
			iterate()
		end
	end

	if shouldOverwriteProjectFile then
		print("Writing to project file...")
		fs.writeFile(informationFile.rojoProjectPath, serde.encode("json", newRojoProject, true))
	end
end


local function exampleSystem(_, state)
	local actions = state.actions

	-- Here we use the helper methods we added: justPressed and justReleased.
	if actions:justPressed("jump") then
		print("Jumped!")
	end

	if actions:justReleased("attack") then
		print("Attack released!")
	end

	local move = actions:clampedAxis2d("move")

	if move.Magnitude > 0 then
		print("Moving", move)
	end
end

return exampleSystem


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

local Matter = require(ReplicatedStorage.Packages.Matter)
local Spark = require(ReplicatedStorage.Packages.Spark)
local exampleSystem = require(script.exampleSystem)
local updateInput = require(script.updateInput)

local InputState = Spark.InputState
local Actions = Spark.Actions
local VirtualAxis2d = Spark.VirtualAxis2d

local actions = Actions.new({ "jump", "attack", "move" })
actions:setRebuildBindings(function(bindings)
	bindings:bind("jump", Enum.KeyCode.Space, Enum.KeyCode.ButtonA)
	bindings:bind("attack", Enum.UserInputType.MouseButton1, Enum.KeyCode.ButtonR1)
	bindings:bind("move", VirtualAxis2d.wasd(), Enum.KeyCode.Thumbstick1)
end)

local state = {
	inputState = InputState.new(),
	actions = actions,
}

local world = Matter.World.new()
local loop = Matter.Loop.new(world, state)

-- Here we add two helper methods to the Actions object.
function Actions:justPressed(action: string): boolean
	local signal = self:justPressedSignal(action)
	local iterator = Matter.useEvent(action, signal)

	return iterator() ~= nil
end

function Actions:justReleased(action: string): boolean
	local signal = self:justReleasedSignal(action)
	local iterator = Matter.useEvent(action, signal)

	return iterator() ~= nil
end

loop:scheduleSystems({ exampleSystem, updateInput })
loop:begin({
	default = RunService.Heartbeat,
	RenderStepped = RunService.RenderStepped,
})


local function updateInput(_, state)
	state.actions:update(state.inputState)
	state.inputState:clear()
end

return {
	system = updateInput,
	event = "RenderStepped",
	priority = -math.huge, -- You want to update input before all other systems run.
}


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

local Spark = require(ReplicatedStorage.Packages.Spark)

local Actions = Spark.Actions
local InputState = Spark.InputState
local VirtualAxis2d = Spark.VirtualAxis2d

local inputState = InputState.new()

local actions = (Actions.new({ "jump", "attack", "move" }) :: any) :: Spark.Actions<"jump" | "attack" | "move">
actions:setRebuildBindings(function(bindings)
	bindings:bind("jump", Enum.KeyCode.Space, Enum.KeyCode.ButtonA)
	bindings:bind("attack", Enum.UserInputType.MouseButton1, Enum.KeyCode.ButtonR1)
	bindings:bind("move", VirtualAxis2d.wasd(), Enum.KeyCode.Thumbstick1)
end)

RunService:BindToRenderStep("Spark", Enum.RenderPriority.Input.Value, function()
	actions:update(inputState)
	inputState:clear()
end)

actions:justPressedSignal("jump"):connect(function()
	print("Jumped!")
end)

actions:justPressedSignal("attack"):connect(function()
	print("Attacked!")
end)

RunService.Heartbeat:Connect(function()
	local move = actions:clampedAxis2d("move")

	if move.Magnitude > 0 then
		print("Moving", move)
	end
end)


local Players = game:GetService("Players")

local UserInterface = {}

function UserInterface.createRebindingMenu()
	local screenGui = Instance.new("ScreenGui")
	screenGui.Parent = Players.LocalPlayer.PlayerGui

	local background = Instance.new("Frame")
	background.AnchorPoint = Vector2.new(0.5, 0.5)
	background.Position = UDim2.fromScale(0.5, 0.5)
	background.Size = UDim2.fromOffset(600, 300)
	background.BorderSizePixel = 0
	background.BackgroundColor3 = Color3.fromRGB(61, 61, 61)
	background.Parent = screenGui

	local container = Instance.new("Frame")
	container.AnchorPoint = Vector2.new(0.5, 0.5)
	container.Position = UDim2.fromScale(0.5, 0.5)
	container.Size = UDim2.new(1, -15, 1, -15)
	container.BackgroundTransparency = 1
	container.Parent = background

	local listLayout = Instance.new("UIListLayout")
	listLayout.Padding = UDim.new(0, 5)
	listLayout.Parent = container

	return container
end

function UserInterface.createBind(name, container)
	local frame = Instance.new("Frame")
	frame.Size = UDim2.new(1, 0, 0, 20)
	frame.BackgroundTransparency = 1
	frame.Parent = container

	local label = Instance.new("TextLabel")
	label.AnchorPoint = Vector2.new(0, 0.5)
	label.Position = UDim2.new(0, 5, 0.5, 0)
	label.Size = UDim2.fromScale(0.5, 0.8)
	label.BackgroundTransparency = 1
	label.TextColor3 = Color3.new(1, 1, 1)
	label.TextXAlignment = Enum.TextXAlignment.Left
	label.Text = name
	label.Parent = frame

	local button = Instance.new("TextButton")
	button.AnchorPoint = Vector2.new(1, 0.5)
	button.Position = UDim2.new(1, -80, 0.5, 0)
	button.Size = UDim2.new(0.7, -80, 0.8, 0)
	button.Parent = frame

	local resetButton = Instance.new("TextButton")
	resetButton.AnchorPoint = Vector2.new(1, 0.5)
	resetButton.Position = UDim2.fromScale(1, 0.5)
	resetButton.Size = UDim2.fromScale(0.125, 0.8)
	resetButton.Text = "Reset"
	resetButton.Visible = false
	resetButton.Parent = frame

	return button, resetButton
end

return UserInterface


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

local Spark = require(ReplicatedStorage.Packages.Spark)
local UserInterface = require(script.UserInterface)
local KeyboardBindings = require(ReplicatedStorage.Shared.KeyboardBindings)

local Actions = Spark.Actions
local Rebind = Spark.Rebind

local serializedCustomizedBindings = ReplicatedStorage.Remotes.ReplicateBindings.OnClientEvent:Wait()

local function enumItemFromValue(enum, value)
	for _, enumItem in enum:GetEnumItems() do
		if enumItem.Value == value then
			return enumItem
		end
	end

	error("Invalid enum value")
end

local customizedBindings = {}
for slot, binding in serializedCustomizedBindings do
	-- The server sends a serialized version of the customized bindings. We need to convert them back to EnumItems.
	if binding.kind == "KeyCode" then
		customizedBindings[slot] = enumItemFromValue(Enum.KeyCode, binding.value)
	elseif binding.kind == "UserInputType" then
		customizedBindings[slot] = enumItemFromValue(Enum.UserInputType, binding.value)
	end
end

local actions = Actions.new({ "jump", "move" }):setRebuildBindings(function(bindings)
	bindings:bind("jump", customizedBindings.jump or Enum.KeyCode.Space, Enum.KeyCode.ButtonA)
	bindings:bind(
		"move",
		Spark.VirtualAxis2d.new({
			up = customizedBindings.moveUp or Enum.KeyCode.W,
			down = customizedBindings.moveDown or Enum.KeyCode.S,
			left = customizedBindings.moveLeft or Enum.KeyCode.A,
			right = customizedBindings.moveRight or Enum.KeyCode.D,
		}),
		Enum.KeyCode.Thumbstick1
	)
end)

local allButtons = {}

local function getKeyboardMouseInput(action)
	-- We use getInputsByDevices to return the first input that belongs to either the Keyboard or Mouse.
	return actions:getInputsByDevices(action, { "Keyboard", "Mouse" })[1]
end

-- This returns a name that represents a KeyCode or UserInputType. This can be implemented how you want. You could even use images instead of text.
local function getInputDisplayName(input)
	if input:IsA("UserInputType") then
		return input.Name
	elseif input:IsA("KeyCode") then
		local name = UserInputService:GetStringForKeyCode(input)

		-- Some KeyCodes return an empty string and Enum.KeyCode.Space returns a space character.
		if #name == 0 or name == " " then
			return input.Name
		else
			return name
		end
	else
		error("Invalid input")
	end
end

-- This toggle all buttons so we can disable buttons while the user is rebinding an action.
local function toggleAllButtons(on)
	for _, button in allButtons do
		button.Active = on
		button.AutoButtonColor = on
	end
end

local function createBinding(slot, binding, container)
	local bindButton, resetButton = UserInterface.createBind(binding.name, container)

	table.insert(allButtons, bindButton)
	table.insert(allButtons, resetButton)

	local function updateBindText()
		local input = getKeyboardMouseInput(binding.action)

		if binding.direction == nil then
			bindButton.Text = getInputDisplayName(input)
		else
			-- If our binding has a direciton, we are expecting a VirtualAxis2d. We need to select the specific input
			-- based on the direction.
			bindButton.Text = getInputDisplayName(input[binding.direction])
		end
	end

	local function isDefault()
		local input = getKeyboardMouseInput(binding.action)
		local defaultInput = binding.default

		-- The inputs will be a VirtualAxis2d when our binding has a direction. If so, we need to convert to the input
		-- based on the direction.
		if binding.direction then
			input = input[binding.direction]
		end

		return input == defaultInput
	end

	updateBindText()
	resetButton.Visible = not isDefault()

	bindButton.Activated:Connect(function()
		toggleAllButtons(false)

		bindButton.Text = "Press any key (BACKSPACE to cancel)"

		-- Rebind when calling the start method returns a Promise that resolves with a button the user presses that
		-- meets the requirements. In this case we only want a Keyboard or Mouse button. We also exclude Escape so
		-- it doesn't conflict with the Roblox Escape menu.
		local newInput =
			Rebind.new():withDevices({ "Keyboard", "Mouse" }):withoutInputs({ Enum.KeyCode.Escape }):start():expect()

		-- If they choose Backspace, we skip the rebinding.
		if newInput ~= Enum.KeyCode.Backspace then
			customizedBindings[slot] = newInput
			actions:rebuildBindings() -- Call rebuildBindings to apply the changes to Actions.

			ReplicatedStorage.Remotes.Rebind:FireServer(slot, newInput)
		end

		updateBindText()
		resetButton.Visible = not isDefault()

		toggleAllButtons(true)
	end)

	resetButton.Activated:Connect(function()
		local defaultInput = binding.default

		customizedBindings[slot] = defaultInput
		actions:rebuildBindings() -- Call rebuildBindings to apply the changes to Actions.

		ReplicatedStorage.Remotes.Rebind:FireServer(slot, defaultInput)

		updateBindText()
		resetButton.Visible = not isDefault()
	end)
end

local container = UserInterface.createRebindingMenu()

for slot, binding in KeyboardBindings do
	createBinding(slot, binding, container)
end


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

local KeyboardBindings = require(ReplicatedStorage.Shared.KeyboardBindings)

local allCustomizedBindings = {}

local function onPlayerAdded(player)
	-- Here you would load the player's custom bindings using DataStores. For the sake of the example, it uses the default bindings.
	local customizedBindings = {}

	allCustomizedBindings[player] = customizedBindings

	ReplicatedStorage.Remotes.ReplicateBindings:FireClient(player, customizedBindings)
end

local function onPlayerRemoving(player)
	allCustomizedBindings[player] = nil
end

local function onRebind(player, slot, input)
	local binding = KeyboardBindings[slot]

	if
		binding == nil
		or typeof(input) ~= "EnumItem"
		or (not input:IsA("KeyCode") and not input:IsA("UserInputType"))
	then
		-- Don't continue because the player sent invalid arguments.
		return
	end

	local customizedBindings = allCustomizedBindings[player]

	-- EnumItems can't be saved in DataStores, so we convert them to their value.
	if input:IsA("KeyCode") then
		customizedBindings[slot] = { kind = "KeyCode", value = input.Value }
	elseif input:IsA("UserInputType") then
		customizedBindings[slot] = { kind = "UserInputType", value = input.Value }
	end

	-- Here you can save the bindings to a DataStore.
end

Players.PlayerAdded:Connect(onPlayerAdded)
Players.PlayerRemoving:Connect(onPlayerRemoving)
ReplicatedStorage.Remotes.Rebind.OnServerEvent:Connect(onRebind)

for _, player in Players:GetPlayers() do
	onPlayerAdded(player)
end


return {
	jump = { name = "Jump", action = "jump", default = Enum.KeyCode.Space },
	moveUp = { name = "Move Up", action = "move", direction = "up", default = Enum.KeyCode.W },
	moveDown = { name = "Move Down", action = "move", direction = "down", default = Enum.KeyCode.S },
	moveLeft = { name = "Move Left", action = "move", direction = "left", default = Enum.KeyCode.A },
	moveRight = { name = "Move Right", action = "move", direction = "right", default = Enum.KeyCode.D },
}


local UserInputService = game:GetService("UserInputService")

local Bindings = require(script.Parent.Binding.Bindings)
local Signal = require(script.Parent.Signal)
local getDeviceFromInput = require(script.Parent.getDeviceFromInput)

local function isInputInDevices(input, devices)
	if typeof(input) == "table" then
		if input.kind == "VirtualAxis" then
			for _, direction in { "positive", "negative" } do
				if table.find(devices, getDeviceFromInput(input[direction])) then
					return true
				end
			end
		elseif input.kind == "VirtualAxis2d" then
			for _, direction in { "up", "down", "left", "right" } do
				if table.find(devices, getDeviceFromInput(input[direction])) then
					return true
				end
			end
		end
	end

	return table.find(devices, getDeviceFromInput(input)) ~= nil
end

--[=[
	`Actions` allows you to bind inputs to actions and read their state.

	```lua
	local actions = Actions.new({ "move", "jump" }):setRebuildBindings(function(bindings)
		bindings:bind("move", VirtualAxis2d.new({
			up = Enum.KeyCode.W,
			down = Enum.KeyCode.S,
			left = Enum.KeyCode.A,
			right = Enum.KeyCode.D
		}))
		bindings:bind("jump", Enum.KeyCode.Space)
	end)

	local disconnect = actions:justPressedSignal("jump"):connect(function()
		print("Jumped!")
	end)

	local moveVector = actions:axis2d("move")
	```

	To get auto-completion and type checking for the action names, cast Actions like this:
	```lua
	local actions = (Actions.new({ "move", "jump" }) :: any) :: Spark.Actions<"move" | "jump">
	```

	:::note
	[Actions:update] should be called every frame before calling [InputState:clear].
	:::

	@class Actions
]=]
local Actions = {}
Actions.__index = Actions

--[=[
	Creates a new `Actions`.

	@param actions { string } -- List of action names
	@return Actions
]=]
function Actions.new(actions)
	local states = {}
	local justPressedSignals = {}
	local justReleasedSignals = {}
	for _, action in actions do
		states[action] = {
			manualPresses = 0,
			manualMoveAxis = 0,
			manualMoveAxis2d = Vector2.zero,
			pressed = false,
			axis = 0,
			axis2d = Vector2.zero,
		}
		justPressedSignals[action] = Signal.new()
		justReleasedSignals[action] = Signal.new()
	end

	return setmetatable({
		states = states,
		justPressedSignals = justPressedSignals,
		justReleasedSignals = justReleasedSignals,

		rebuildBindingsCallback = function() end,

		actionToBinds = {},
		actionToModifiers = {},

		associatedGamepad = nil,
	}, Actions)
end

--[=[
	Sets the gamepad that will be used to check for gamepad inputs.

	```lua
	local actions = Actions.new({ "move", "jump" })
	actions:setAssociatedGamepad(Enum.UserInputType.Gamepad1)
	```

	@param gamepad Enum.UserInputType
]=]
function Actions:setAssociatedGamepad(gamepad)
	self.associatedGamepad = gamepad
end

--[=[
	Removes the associated gamepad and instead uses the first connected gamepad.

]=]
function Actions:removeAssociatedGamepad()
	self.associatedGamepad = nil
end

--[=[
	Sets the callback that binds inputs to actions.

	The callback will be called in this function and whenever [Actions:rebuildBindings] is called.

	```lua
	local actions = Actions.new({ "move", "jump" }):setRebuildBindings(function(bindings)
		bindings:bind("jump", Enum.KeyCode.Space)
	end)
	```

	@param rebuildBindings (bindings: Bindings) -> ()
	@return Actions -- Returns self
]=]
function Actions:setRebuildBindings(rebuildBindings)
	self.rebuildBindingsCallback = rebuildBindings

	self:rebuildBindings()

	return self
end

--[=[
	Updates action bindings by calling the callback set by [Actions:setRebuildBindings].
]=]
function Actions:rebuildBindings()
	local bindings = Bindings.new()

	self.rebuildBindingsCallback(bindings)

	table.clear(self.actionToBinds)
	table.clear(self.actionToModifiers)

	for action in self.states do
		self.actionToBinds[action] = bindings.actionToBinds[action] or {}
		self.actionToModifiers[action] = bindings.actionToModifiers[action] or {}
	end
end

--[=[
	Updates action states.

	This should be called once every frame before calling [InputState:clear].

	@param inputState InputState
]=]
function Actions:update(inputState)
	local gamepad = self.associatedGamepad
	if gamepad == nil then
		for _, candidateGamepad in UserInputService:GetConnectedGamepads() do
			if gamepad == nil or candidateGamepad.Value < gamepad.Value then
				gamepad = candidateGamepad
			end
		end
	end

	for action, state in self.states do
		local pressed = state.manualPresses > 0
			or state.manualMoveAxis2d.Magnitude > 0
			or math.abs(state.manualMoveAxis) > 0
		local axis = state.manualMoveAxis2d.Magnitude + state.manualPresses + state.manualMoveAxis

		local axis2d = state.manualMoveAxis2d
		for _, bind in self.actionToBinds[action] do
			for _, input in bind.inputs do
				local inputPressed = inputState:pressed(input, gamepad)
				local inputAxis = inputState:axis(input, gamepad)
				local inputAxis2d = inputState:axis2d(input, gamepad) or Vector2.zero

				for _, modifier in bind.modifiers do
					inputPressed, inputAxis, inputAxis2d = modifier(inputPressed, inputAxis, inputAxis2d)
				end

				pressed = pressed or inputPressed
				axis += inputAxis
				axis2d += inputAxis2d
			end
		end

		for _, modifier in self.actionToModifiers[action] do
			pressed, axis, axis2d = modifier(pressed, axis, axis2d)
		end

		local wasPressed = state.pressed

		state.pressed = pressed
		state.axis = axis
		state.axis2d = axis2d

		state.manualPresses = 0
		state.manualMoveAxis = 0
		state.manualMoveAxis2d = Vector2.zero

		if pressed and not wasPressed then
			self.justPressedSignals[action]:fire()
		elseif not pressed and wasPressed then
			self.justReleasedSignals[action]:fire()
		end
	end
end

--[=[
	Returns whether `action` is currently pressed.

	@param action string
	@return boolean
]=]
function Actions:pressed(action)
	return self.states[action].pressed
end

--[=[
	Returns whether `action` is currently released.

	@param action string
	@return boolean
]=]
function Actions:released(action)
	return not self.states[action].pressed
end

--[=[
	Returns a [Signal] that is fired when `action` is pressed.

	```lua
	local disconnect = actions:justPressedSignal("jump"):connect(function()
		print("Jump pressed!")
	end)

	disconnect()
	```

	@param action string
	@return Signal
]=]
function Actions:justPressedSignal(action)
	return self.justPressedSignals[action]
end

--[=[
	Returns a [Signal] that is fired when `action` is released.

	```lua
	local disconnect = actions:justReleasedSignal("jump"):connect(function()
		print("Jump released!")
	end)

	disconnect()
	```

	@param action string
	@return Signal
]=]
function Actions:justReleasedSignal(action)
	return self.justReleasedSignals[action]
end

--[=[
	Returns the sum of the axis values of each input bound to `action`.

	The axis value of an input depends on its kind:
	- Buttons have a value of `0` when released and `1` when pressed.
	- [Enum.UserInputType.MouseWheel] returns the `Z` value of [InputObject.Position].
	- [VirtualAxis] returns the value of the positive input minus the value of the negative input.
	- 2D axis values like [Enum.UserInputType.MouseMovement], [Enum.KeyCode.Thumbstick1], or [VirutalAxis2d] will return their magnitude.

	:::warning
	The return value is not clamped to any range.
	:::

	@param action string
	@return number
]=]
function Actions:axis(action)
	return self.states[action].axis
end

--[=[
	Returns the sum of axis 2D values of each input bound to `action`.

	The axis 2D value of an input depends on its kind:
	- Buttons and 1D axis values like [Enum.UserInputType.MouseWheel] or [VirtualAxis] will always return [Vector2.zero].
	- [Enum.KeyCode.Thumbstick1] and [Enum.KeyCode.Thumbstick2] returns the `(X, Y)` value of [InputObject.Position].
	- [Enum.UserInputType.MouseMovement] returns the `(X, -Y)` value of [InputObject.Delta].
	- [VirtualAxis2d] returns `Vector2.new(rightValue - leftValue, upValue - downValue)`.

	:::warning
	The returned Vector2 is not clamped to any range. Consider using [Actions:normalizedAxis2d] or [Actions:clampedAxis2d].
	:::

	@param action string
	@return Vector2
]=]
function Actions:axis2d(action)
	return self.states[action].axis2d
end

--[=[
	Returns the [Actions:axis2d] value but normalized. If the value is `Vector2.zero`, `Vector2.zero` will be returned.

	@param action string
	@return Vector2
]=]
function Actions:normalizedAxis2d(action)
	local state = self.states[action].axis2d

	if state.Magnitude > 0 then
		return state.Unit
	else
		return state
	end
end

--[=[
	Returns the [Actions:axis2d] value but with the length clamped to `1`. This allows for `Vector2s` with a length less than `1` which is useful for gamepad and mobile thumbsticks.

	@param action string
	@return Vector2
]=]
function Actions:clampedAxis2d(action)
	local state = self.states[action].axis2d

	if state.Magnitude > 1 then
		return state.Unit
	else
		return state
	end
end

--[=[
	Manually presses `action` until [Actions:update] is called.

	See [Mobile](../docs/Mobile) for an example of usage with UI buttons.

	@param action string
]=]
function Actions:press(action)
	self.states[action].manualPresses += 1
end

--[=[
	Increases the axis value of `action` by `value`.

	This value resets after [Actions:update] is called.

	@param action string
	@param value number
]=]
function Actions:moveAxis(action, value)
	self.states[action].manualMoveAxis += value
end

--[=[
	Increases the axis 2D value of `action` by `value`.

	This value resets after [Actions:update] is called.

	@param action string
	@param value Vector2
]=]
function Actions:moveAxis2d(action, value)
	self.states[action].manualMoveAxis2d += value
end

--[=[
	Returns the list of inputs bound to `action` that belong to the given `devices`.

	If a [VirtualAxis] or [VirtualAxis2d] has a [Button] that belongs to `deviecs` it will be included.

	```lua
	local actions = Actions.new({ "action" }):setRebuildBindings(function(bindings)
		bindings:bind("action", Enum.KeyCode.Space, Enum.UserInputType.MouseButton1)
	end)

	print(actions:getInputsByDevices("action", { "Keyboard" })) -- { Enum.KeyCode.Space }
	print(actions:getInputsByDevices("action", { "Mouse" })) -- { Enum.UserInputType.MouseButton1 }
	```

	@param action string
	@param devices { Device }
	@return { Input }
]=]
function Actions:getInputsByDevices(action, devices)
	local inputs = {}
	for _, bind in self.actionToBinds[action] do
		for _, input in bind.inputs do
			if isInputInDevices(input, devices) then
				table.insert(inputs, input)
			end
		end
	end

	return table.freeze(inputs)
end

return Actions


local ReplicatedStorage = game:GetService("ReplicatedStorage")

local Spark = require(script.Parent)
local VirtualAxis = require(script.Parent.VirtualAxis)
local VirtualAxis2d = require(script.Parent.VirtualAxis2d)
local MutableInputState = require(ReplicatedStorage.MutableInputState)

local Actions = Spark.Actions

return function(x)
	local assertEqual = x.assertEqual

	x.test("button presses", function()
		local actions = Actions.new({ "jump" }):setRebuildBindings(function(bindings)
			bindings:bind("jump", Enum.KeyCode.Space)
		end)

		local inputState = MutableInputState.new()

		local counter = 0
		local disconnect = actions:justPressedSignal("jump"):connect(function()
			counter += 1
		end)

		actions:update(inputState)
		assertEqual(counter, 0)
		assertEqual(actions:pressed("jump"), false)

		inputState:press(Enum.KeyCode.Space)
		actions:update(inputState)
		assertEqual(counter, 1)
		assertEqual(actions:pressed("jump"), true)

		actions:update(inputState)
		assertEqual(counter, 1)
		assertEqual(actions:pressed("jump"), true)

		inputState:release(Enum.KeyCode.Space)
		actions:update(inputState)
		assertEqual(actions:pressed("jump"), false)

		disconnect()

		inputState:press(Enum.KeyCode.Space)
		actions:update(inputState)
		assertEqual(counter, 1)
		assertEqual(actions:pressed("jump"), true)
	end)

	x.test("button releases", function()
		local actions = Actions.new({ "jump" }):setRebuildBindings(function(bindings)
			bindings:bind("jump", Enum.KeyCode.Space)
		end)

		local inputState = MutableInputState.new()

		local counter = 0
		local disconnect = actions:justReleasedSignal("jump"):connect(function()
			counter += 1
		end)

		actions:update(inputState)
		assertEqual(counter, 0)
		assertEqual(actions:released("jump"), true)

		inputState:press(Enum.KeyCode.Space)
		actions:update(inputState)
		assertEqual(counter, 0)
		assertEqual(actions:released("jump"), false)

		inputState:release(Enum.KeyCode.Space)
		actions:update(inputState)
		assertEqual(counter, 1)
		assertEqual(actions:released("jump"), true)

		actions:update(inputState)
		assertEqual(counter, 1)
		assertEqual(actions:released("jump"), true)

		disconnect()

		inputState:press(Enum.KeyCode.Space)
		actions:update(inputState)
		assertEqual(actions:released("jump"), false)

		inputState:release(Enum.KeyCode.Space)
		actions:update(inputState)
		assertEqual(counter, 1)
		assertEqual(actions:released("jump"), true)
	end)

	x.test("handles mouse wheel", function()
		local actions = Actions.new({ "scroll" }):setRebuildBindings(function(bindings)
			bindings:bind("scroll", Enum.UserInputType.MouseWheel)
		end)

		local inputState = MutableInputState.new()

		actions:update(inputState)
		assertEqual(actions:axis("scroll"), 0)

		inputState:scroll(4)

		actions:update(inputState)
		assertEqual(actions:axis("scroll"), 4)

		-- Updating InputState should reset the mouse wheel.
		inputState:clear()
		actions:update(inputState)
		assertEqual(actions:axis("scroll"), 0)
	end)

	x.test("handles axis2d values", function()
		local actions = Actions.new({ "mouse" }):setRebuildBindings(function(bindings)
			bindings:bind("mouse", Enum.UserInputType.MouseMovement)
		end)

		local inputState = MutableInputState.new()

		actions:update(inputState)
		assertEqual(actions:axis2d("mouse"), Vector2.zero)
		assertEqual(actions:normalizedAxis2d("mouse"), Vector2.zero)

		inputState:moveMouse(Vector2.new(4, 3))

		actions:update(inputState)
		assertEqual(actions:axis2d("mouse"), Vector2.new(4, 3))
		assertEqual(actions:normalizedAxis2d("mouse"), Vector2.new(4, 3).Unit)

		-- Updating InputState should reset mouse movement.
		inputState:clear()
		actions:update(inputState)
		assertEqual(actions:axis2d("mouse"), Vector2.zero)
		assertEqual(actions:normalizedAxis2d("mouse"), Vector2.zero)
	end)

	x.test("handles VirtualAxis", function()
		local removeNegative = false
		local actions = Actions.new({ "action" }):setRebuildBindings(function(bindings)
			local input = VirtualAxis.horizontalArrowKeys()
			if removeNegative then
				input.negative = nil
			end

			bindings:bind("action", input)
		end)

		local inputState = MutableInputState.new()

		actions:update(inputState)
		assertEqual(actions:axis("action"), 0)

		inputState:press(Enum.KeyCode.Left)
		actions:update(inputState)
		assertEqual(actions:axis("action"), -1)

		inputState:press(Enum.KeyCode.Right)
		actions:update(inputState)
		assertEqual(actions:axis("action"), 0)

		inputState:release(Enum.KeyCode.Left)
		actions:update(inputState)
		assertEqual(actions:axis("action"), 1)

		-- nil inputs should work
		removeNegative = true
		actions:rebuildBindings()

		actions:update(inputState)
		assertEqual(actions:axis("action"), 1)
	end)

	x.test("handles VirtualAxis2d", function()
		local removeDown = false
		local actions = Actions.new({ "action" }):setRebuildBindings(function(bindings)
			local input = VirtualAxis2d.wasd()
			if removeDown then
				input.down = nil
			end

			bindings:bind("action", input)
		end)

		local inputState = MutableInputState.new()

		actions:update(inputState)
		assertEqual(actions:axis2d("action"), Vector2.zero)

		inputState:press(Enum.KeyCode.W)
		actions:update(inputState)
		assertEqual(actions:axis2d("action"), Vector2.yAxis)

		inputState:press(Enum.KeyCode.D)
		actions:update(inputState)
		assertEqual(actions:axis2d("action"), Vector2.one)

		inputState:press(Enum.KeyCode.S)
		actions:update(inputState)
		assertEqual(actions:axis2d("action"), Vector2.xAxis)

		-- nil inputs should work
		removeDown = true
		actions:rebuildBindings()

		actions:update(inputState)
		assertEqual(actions:axis2d("action"), Vector2.one)
	end)

	x.test("clampedAxis2d", function()
		local actions = Actions.new({ "mouse" }):setRebuildBindings(function(bindings)
			bindings:bind("mouse", Enum.UserInputType.MouseMovement)
		end)

		local inputState = MutableInputState.new()

		inputState:moveMouse(Vector2.new(0.25, 0.5))
		actions:update(inputState)
		assertEqual(actions:clampedAxis2d("mouse"), Vector2.new(0.25, 0.5))

		inputState:clear()
		inputState:moveMouse(Vector2.new(0.9, 0.8))
		actions:update(inputState)
		assertEqual(actions:clampedAxis2d("mouse"), Vector2.new(0.9, 0.8).Unit)
	end)

	x.test("gamepad buttons", function()
		local actions = Actions.new({ "jump" }):setRebuildBindings(function(bindings)
			bindings:bind("jump", Enum.KeyCode.ButtonA)
		end)

		local inputState = MutableInputState.new()

		actions:setAssociatedGamepad(Enum.UserInputType.Gamepad2)

		local counter = 0
		actions:justPressedSignal("jump"):connect(function()
			counter += 1
		end)

		inputState:pressGamepad(Enum.KeyCode.ButtonA, Enum.UserInputType.Gamepad1)
		actions:update(inputState)
		assertEqual(counter, 0)

		inputState:releaseGamepad(Enum.KeyCode.ButtonA, Enum.UserInputType.Gamepad1)

		actions:setAssociatedGamepad(Enum.UserInputType.Gamepad1)

		inputState:pressGamepad(Enum.KeyCode.ButtonA, Enum.UserInputType.Gamepad1)
		actions:update(inputState)
		assertEqual(counter, 1)
	end)

	x.test("handles manual press", function()
		local actions = Actions.new({ "jump" }):setRebuildBindings(function(bindings)
			bindings:bind("jump", Enum.KeyCode.Space)
		end)

		local inputState = MutableInputState.new()

		local pressedCount = 0
		actions:justPressedSignal("jump"):connect(function()
			pressedCount += 1
		end)

		local releasedCount = 0
		actions:justReleasedSignal("jump"):connect(function()
			releasedCount += 1
		end)

		actions:press("jump")
		assertEqual(pressedCount, 0)

		actions:update(inputState)
		assertEqual(pressedCount, 1)
		assertEqual(actions:axis("jump"), 1)

		-- The action should not be released by an input if being held manually.
		actions:press("jump")
		inputState:press(Enum.KeyCode.Space)
		actions:update(inputState)
		inputState:release(Enum.KeyCode.Space)
		assertEqual(releasedCount, 0)

		actions:update(inputState)
		assertEqual(releasedCount, 1)
	end)

	x.test("handles manual moveAxis", function()
		local actions = Actions.new({ "zoom" }):setRebuildBindings(function(bindings)
			bindings:bind("zoom", VirtualAxis.verticalArrowKeys())
		end)

		local inputState = MutableInputState.new()

		actions:moveAxis("zoom", 1)
		assertEqual(actions:axis("zoom"), 0)

		actions:update(inputState)
		assertEqual(actions:axis2d("zoom"), Vector2.zero)
		assertEqual(actions:pressed("zoom"), true)
		assertEqual(actions:axis("zoom"), 1)

		-- Manual moves should reset the next update
		actions:update(inputState)
		assertEqual(actions:axis("zoom"), 0)

		-- Manual moves should stack
		actions:moveAxis("zoom", 1)
		actions:moveAxis("zoom", 2)
		inputState:press(Enum.KeyCode.Up)
		actions:update(inputState)
		assertEqual(actions:axis("zoom"), 4)

		-- Should handle negative values properly
		inputState:release(Enum.KeyCode.Up)
		actions:moveAxis("zoom", -1)
		actions:update(inputState)
		assertEqual(actions:axis("zoom"), -1)
		assertEqual(actions:pressed("zoom"), true)
	end)

	x.test("handles manual moveAxis2d", function()
		local actions = Actions.new({ "move" }):setRebuildBindings(function(bindings)
			bindings:bind("move", Enum.UserInputType.MouseMovement)
		end)

		local inputState = MutableInputState.new()

		actions:moveAxis2d("move", Vector2.new(1, 0))
		assertEqual(actions:axis2d("move"), Vector2.zero)

		actions:update(inputState)
		assertEqual(actions:axis2d("move"), Vector2.new(1, 0))
		assertEqual(actions:pressed("move"), true)
		assertEqual(actions:axis("move"), 1)

		-- Manual moves should reset the next update
		actions:update(inputState)
		assertEqual(actions:axis2d("move"), Vector2.zero)

		-- Manual moves should stack
		actions:moveAxis2d("move", Vector2.new(1, 0))
		actions:moveAxis2d("move", Vector2.new(2, 0))
		inputState:moveMouse(Vector2.new(0, 1))
		actions:update(inputState)
		assertEqual(actions:axis2d("move"), Vector2.new(3, 1))
	end)

	x.test("justPressedSignal and justReleasedSignal should fire after values have updated", function()
		local actions = Actions.new({ "action" }):setRebuildBindings(function(bindings)
			bindings:bind("action", Enum.UserInputType.MouseMovement)
		end)

		local inputState = MutableInputState.new()

		actions:moveAxis2d("action", Vector2.xAxis)

		local pressedFired = false
		actions:justPressedSignal("action"):connect(function()
			pressedFired = true
			assertEqual(actions:pressed("action"), true)
			assertEqual(actions:axis("action"), 1)
			assertEqual(actions:axis2d("action"), Vector2.xAxis)
		end)

		actions:update(inputState)

		assertEqual(pressedFired, true)

		local releasedFired = false
		actions:justReleasedSignal("action"):connect(function()
			releasedFired = true
			assertEqual(actions:pressed("action"), false)
			assertEqual(actions:axis("action"), 0)
			assertEqual(actions:axis2d("action"), Vector2.zero)
		end)

		actions:update(inputState)

		assertEqual(releasedFired, true)
	end)

	x.test("getInputsByDevices", function()
		local actions = Actions.new({ "action" }):setRebuildBindings(function(bindings)
			bindings:bind("action", Enum.KeyCode.Space, Enum.UserInputType.MouseButton1, Enum.KeyCode.ButtonA)
		end)

		assertEqual(#actions:getInputsByDevices("action", {}), 0)

		assertEqual(#actions:getInputsByDevices("action", { "Keyboard" }), 1)
		assertEqual(actions:getInputsByDevices("action", { "Keyboard" })[1], Enum.KeyCode.Space)

		assertEqual(#actions:getInputsByDevices("action", { "Mouse" }), 1)
		assertEqual(actions:getInputsByDevices("action", { "Mouse" })[1], Enum.UserInputType.MouseButton1)

		assertEqual(#actions:getInputsByDevices("action", { "Gamepad" }), 1)
		assertEqual(actions:getInputsByDevices("action", { "Gamepad" })[1], Enum.KeyCode.ButtonA)

		assertEqual(#actions:getInputsByDevices("action", { "Keyboard", "Mouse" }), 2)
		assert(table.find(actions:getInputsByDevices("action", { "Keyboard", "Mouse" }), Enum.KeyCode.Space) ~= nil, "")
		assert(
			table.find(actions:getInputsByDevices("action", { "Keyboard", "Mouse" }), Enum.UserInputType.MouseButton1)
				~= nil,
			""
		)
	end)

	x.test("getInputsByDevices + virtual axes", function()
		local useKeyboard = false
		local actions = Actions.new({ "action" }):setRebuildBindings(function(bindings)
			if useKeyboard then
				bindings:bind("action", VirtualAxis.horizontalArrowKeys())
				bindings:bind("action", VirtualAxis2d.arrowKeys())
			else
				bindings:bind(
					"action",
					VirtualAxis.new({ positive = Enum.KeyCode.ButtonA, negative = Enum.KeyCode.ButtonB })
				)
				bindings:bind(
					"action",
					VirtualAxis.new({
						up = Enum.KeyCode.ButtonY,
						down = Enum.KeyCode.ButtonA,
						left = Enum.KeyCode.ButtonX,
						right = Enum.KeyCode.ButtonB,
					})
				)
			end
		end)

		assertEqual(#actions:getInputsByDevices("action", { "Keyboard" }), 0)

		useKeyboard = true
		actions:rebuildBindings()

		assertEqual(#actions:getInputsByDevices("action", { "Keyboard" }), 2)
	end)

	x.test("scale modifier", function()
		local actions = Actions.new({ "inputLevel", "actionLevel" }):setRebuildBindings(function(bindings)
			local modifier = Spark.Modifiers.scale(0.5, Vector2.new(0.75, 0.5))

			bindings:bind(
				"inputLevel",
				Enum.UserInputType.MouseWheel,
				Spark.Bind.new(Enum.UserInputType.MouseMovement):addModifiers(modifier)
			)

			bindings
				:bind("actionLevel", Enum.UserInputType.MouseWheel, Enum.UserInputType.MouseMovement)
				:addModifiers(modifier)
		end)

		local inputState = MutableInputState.new()

		inputState:scroll(4)
		inputState:moveMouse(Vector2.new(4, 4))

		actions:update(inputState)

		assertEqual(actions:axis("inputLevel"), Vector2.new(4, 4).Magnitude * 0.5 + 4)
		assertEqual(actions:axis("actionLevel"), (Vector2.new(4, 4).Magnitude + 4) * 0.5)

		assertEqual(actions:axis2d("inputLevel"), Vector2.new(4 * 0.75, 4 * 0.5))
		assertEqual(actions:axis2d("actionLevel"), Vector2.new(4 * 0.75, 4 * 0.5))

		inputState:clear()

		-- Action level modifier should apply to manual moves
		actions:moveAxis("actionLevel", 1)
		actions:moveAxis2d("actionLevel", Vector2.new(1, 1))
		actions:update(inputState)

		assertEqual(actions:axis("actionLevel"), (Vector2.new(1, 1).Magnitude + 1) * 0.5)
		assertEqual(actions:axis2d("actionLevel"), Vector2.new(0.75, 0.5))
	end)

	x.test("deadzone modifier", function()
		local actions = Actions.new({ "action" }):setRebuildBindings(function(bindings)
			bindings:bind(
				"action",
				Spark.Bind.new(Enum.UserInputType.MouseMovement):addModifiers(Spark.Modifiers.deadzone("Radial", 0.2))
			)
		end)

		local inputState = MutableInputState.new()

		inputState:moveMouse(Vector2.new(0.1, 0.1))

		actions:update(inputState)

		assertEqual(actions:axis("action"), 0)
		assertEqual(actions:axis2d("action"), Vector2.zero)

		inputState:clear()
		inputState:moveMouse(Vector2.new(0.2, 0.2))

		actions:update(inputState)

		assertEqual(actions:axis("action") > 0 and actions:axis("action") < 0.2, true)
		assertEqual(actions:axis2d("action").Magnitude > 0 and actions:axis2d("action").Magnitude < 0.2, true)
	end)
end


--[=[
	`ActionConfig` allows you to configure an action with modifiers.

	```lua
	local actions = Actions.new({ "look" }):setRebuildBindings(function(bindings)
		bindings:bind("look")
			-- ActionConfig is returned by Bindings:bind.
			:addModifiers(scale(0.5, Vector2.new(0.5, 0.5)))
	end)
	```

	@class ActionConfig
]=]
local ActionConfig = {}
ActionConfig.__index = ActionConfig

function ActionConfig.new(action, bindings)
	return setmetatable({ action = action, bindings = bindings }, ActionConfig)
end

--[=[
	Adds modifiers to the action.

	@param ... Modifier
	@return ActionConfig -- Returns self
]=]
function ActionConfig:addModifiers(...)
	if self.bindings.actionToModifiers[self.action] == nil then
		self.bindings.actionToModifiers[self.action] = {}
	end

	for _, modifier in { ... } do
		table.insert(self.bindings.actionToModifiers[self.action], modifier)
	end

	return self
end

return ActionConfig


--[=[
	Allows you to add modifiers to a set of inputs.

	```lua
	local actions = Actions.new({ "look" }):setRebuildBindings(function(bindings)
		bindings:bind(
			"look",
			Bind.new(Enum.KeyCode.Thumbstick2):addModifiers(scale(0.5, Vector2.new(0.5, 0.5)))
		)
	end)
	```

	@class Bind
]=]
local Bind = {}
Bind.__index = Bind

function Bind.new(...)
	local inputs = { ... }

	if #inputs == 0 then
		error("Expected at least one input")
	end

	return setmetatable({
		kind = "Bind",
		inputs = inputs,
		modifiers = {},
	}, Bind)
end

--[=[
	Adds modifiers to the Bind's inputs.

	@param ... Modifier
	@return Bind -- Returns self
]=]
function Bind:addModifiers(...)
	for _, modifier in { ... } do
		table.insert(self.modifiers, modifier)
	end

	return self
end

return Bind


local ActionConfig = require(script.Parent.ActionConfig)
local Bind = require(script.Parent.Bind)

--[=[
	Bind inputs to actions.

	```lua
	local actions = Actions.new({ "move", "jump" }):setRebuildBindings(function(bindings)
		bindings:bind("move", VirtualAxis2d.new({
			up = Enum.KeyCode.W,
			down = Enum.KeyCode.S,
			left = Enum.KeyCode.A,
			right = Enum.KeyCode.D
		}))
		bindings:bind("jump", Enum.KeyCode.Space)
	end)
	```

	@class Bindings
]=]
local Bindings = {}
Bindings.__index = Bindings

function Bindings.new()
	return setmetatable({
		actionToBinds = {},
		actionToModifiers = {},
	}, Bindings)
end

--[=[
	@param action string
	@param ... Bind | Input -- Inputs to bind to the action
]=]
function Bindings:bind(action, ...)
	if self.actionToBinds[action] == nil then
		self.actionToBinds[action] = {}
	end

	for _, bind in { ... } do
		if typeof(bind) == "table" and bind.kind == "Bind" then
			table.insert(self.actionToBinds[action], bind)
		else
			table.insert(self.actionToBinds[action], Bind.new(bind))
		end
	end

	return ActionConfig.new(action, self)
end

return Bindings


local UserInputService = game:GetService("UserInputService")

local Inputs = require(script.Parent.Inputs)

--[=[
	Stores input state derived from [UserInputService] and is used to update [Actions].

	:::note
	You should only ever create one `InputState`.
	:::

	@class InputState
]=]
local InputState = {}
InputState.__index = InputState

--[=[
	Creates a new `InputState`.

	@return InputState
]=]
function InputState.new()
	local gamepadButtons = {}
	for keyCode in Inputs.GAMEPAD_BUTTONS do
		gamepadButtons[keyCode] = {}
	end

	local gamepadTriggers = {}
	for keyCode in Inputs.GAMEPAD_TRIGGERS do
		gamepadTriggers[keyCode] = {}
	end

	local state = {
		keycodes = {},
		mouseButtons = {},
		mouseWheel = 0,
		mouseDelta = Vector2.zero,
		gamepadButtons = gamepadButtons,
		gamepadTriggers = gamepadTriggers,
		gamepadThumbsticks = {
			[Enum.KeyCode.Thumbstick1] = {},
			[Enum.KeyCode.Thumbstick2] = {},
		},
	}

	local function onInputBeganOrEnded(inputObject)
		local keyCode = inputObject.KeyCode

		if Inputs.MOUSE_BUTTONS[inputObject.UserInputType] then
			state.mouseButtons[inputObject.UserInputType] = inputObject.UserInputState == Enum.UserInputState.Begin
		elseif Inputs.GAMEPAD_BUTTONS[keyCode] then
			local gamepad = inputObject.UserInputType

			gamepadButtons[keyCode][gamepad] = inputObject.UserInputState == Enum.UserInputState.Begin
		elseif Inputs.GAMEPAD_TRIGGERS[keyCode] then
			local gamepad = inputObject.UserInputType

			gamepadTriggers[keyCode][gamepad] = inputObject.Position.Z
		elseif keyCode ~= Enum.KeyCode.Unknown then
			state.keycodes[keyCode] = inputObject.UserInputState == Enum.UserInputState.Begin
		end
	end

	local function onInputChanged(inputObject)
		if inputObject.UserInputType == Enum.UserInputType.MouseMovement then
			state.mouseDelta = Vector2.new(inputObject.Delta.X, -inputObject.Delta.Y)
		elseif inputObject.UserInputType == Enum.UserInputType.MouseWheel then
			state.mouseWheel = inputObject.Position.Z
		elseif Inputs.GAMEPAD_TRIGGERS[inputObject.KeyCode] then
			local gamepad = inputObject.UserInputType

			gamepadTriggers[inputObject.KeyCode][gamepad] = inputObject.Position.Z
		elseif inputObject.KeyCode == Enum.KeyCode.Thumbstick1 or inputObject.KeyCode == Enum.KeyCode.Thumbstick2 then
			local gamepad = inputObject.UserInputType

			state.gamepadThumbsticks[inputObject.KeyCode][gamepad] =
				Vector2.new(inputObject.Position.X, inputObject.Position.Y)
		end
	end

	UserInputService.InputBegan:Connect(function(inputObject, sunk)
		if not sunk then
			onInputBeganOrEnded(inputObject)
		end
	end)
	UserInputService.InputEnded:Connect(onInputBeganOrEnded)
	UserInputService.InputChanged:Connect(onInputChanged)

	return setmetatable({
		state = state,
	}, InputState)
end

--[=[
	This clears mouse wheel and mouse delta data so it doesn't persist across frames. This should be called once every frame after [Actions:update] is called.
]=]
function InputState:clear()
	self.state.mouseWheel = 0
	self.state.mouseDelta = Vector2.zero
end

function InputState:pressed(input, gamepad)
	if typeof(input) == "EnumItem" then
		if input:IsA("KeyCode") then
			if input == Enum.KeyCode.Thumbstick1 or input == Enum.KeyCode.Thumbstick2 then
				if gamepad == nil then
					return false
				end

				local value = self.state.gamepadThumbsticks[input][gamepad]

				return not not value
			elseif Inputs.GAMEPAD_BUTTONS[input] then
				if gamepad == nil then
					return false
				end

				return self.state.gamepadButtons[input][gamepad] == true
			elseif Inputs.GAMEPAD_TRIGGERS[input] then
				if gamepad == nil or self.state.gamepadTriggers[input][gamepad] == nil then
					return false
				end

				return self.state.gamepadTriggers[input][gamepad] > 0
			else
				return self.state.keycodes[input] == true
			end
		elseif Inputs.MOUSE_BUTTONS[input] then
			return self.state.mouseButtons[input] == true
		elseif input == Enum.UserInputType.MouseMovement then
			return self.state.mouseDelta.Magnitude > 0
		elseif input == Enum.UserInputType.MouseWheel then
			return self.state.mouseWheel ~= 0
		end
	elseif input.kind == "VirtualAxis" then
		return self:axis(input, gamepad) ~= 0
	elseif input.kind == "VirtualAxis2d" then
		local value = self:axis2d(input, gamepad)

		return if value == nil then false else value.Magnitude > 0
	end

	error("Invalid input")
end

function InputState:axis(input, gamepad)
	if typeof(input) == "EnumItem" then
		if input:IsA("KeyCode") then
			if input == Enum.KeyCode.Thumbstick1 or input == Enum.KeyCode.Thumbstick2 then
				if gamepad == nil then
					return 0
				end

				local value = self.state.gamepadThumbsticks[input][gamepad]

				if value == nil then
					return 0
				else
					return value.Magnitude
				end
			elseif Inputs.GAMEPAD_BUTTONS[input] then
				if gamepad == nil then
					return 0
				end

				return if self.state.gamepadButtons[input][gamepad] then 1 else 0
			elseif Inputs.GAMEPAD_TRIGGERS[input] then
				if gamepad == nil then
					return 0
				end

				return self.state.gamepadTriggers[input][gamepad] or 0
			else
				return if self.state.keycodes[input] then 1 else 0
			end
		elseif Inputs.MOUSE_BUTTONS[input] then
			return if self.state.mouseButtons[input] then 1 else 0
		elseif input == Enum.UserInputType.MouseMovement then
			return self.state.mouseDelta.Magnitude
		elseif input == Enum.UserInputType.MouseWheel then
			return self.state.mouseWheel
		end
	elseif input.kind == "VirtualAxis" then
		local positive = if input.positive then self:axis(input.positive, gamepad) else 0
		local negative = if input.negative then self:axis(input.negative, gamepad) else 0

		return positive - negative
	elseif input.kind == "VirtualAxis2d" then
		local value = self:axis2d(input, gamepad)

		return if value == nil then 0 else value.Magnitude
	end

	error("Invalid input")
end

function InputState:axis2d(input, gamepad)
	if input == Enum.UserInputType.MouseMovement then
		return self.state.mouseDelta
	elseif input == Enum.KeyCode.Thumbstick1 or input == Enum.KeyCode.Thumbstick2 then
		return self.state.gamepadThumbsticks[input][gamepad]
	elseif typeof(input) == "table" and input.kind == "VirtualAxis2d" then
		local right = if input.right then self:axis(input.right, gamepad) else 0
		local left = if input.left then self:axis(input.left, gamepad) else 0
		local up = if input.up then self:axis(input.up, gamepad) else 0
		local down = if input.down then self:axis(input.down, gamepad) else 0

		return Vector2.new(right - left, up - down)
	end

	return nil
end

return InputState


return {
	GAMEPAD_TRIGGERS = {
		[Enum.KeyCode.ButtonL2] = true,
		[Enum.KeyCode.ButtonR2] = true,
	},
	GAMEPAD_BUTTONS = {
		[Enum.KeyCode.ButtonA] = true,
		[Enum.KeyCode.ButtonB] = true,
		[Enum.KeyCode.ButtonX] = true,
		[Enum.KeyCode.ButtonY] = true,
		[Enum.KeyCode.ButtonL1] = true,
		[Enum.KeyCode.ButtonL3] = true,
		[Enum.KeyCode.ButtonR1] = true,
		[Enum.KeyCode.ButtonR3] = true,
		[Enum.KeyCode.DPadUp] = true,
		[Enum.KeyCode.DPadDown] = true,
		[Enum.KeyCode.DPadLeft] = true,
		[Enum.KeyCode.DPadRight] = true,
		[Enum.KeyCode.ButtonStart] = true,
		[Enum.KeyCode.ButtonSelect] = true,
	},
	MOUSE_BUTTONS = {
		[Enum.UserInputType.MouseButton1] = true,
		[Enum.UserInputType.MouseButton2] = true,
		[Enum.UserInputType.MouseButton3] = true,
	},
}