--[=[
	@class Signal

	Author: dig1t

	A lightweight signal implementation that allows for firing, connecting, and disconnecting.

	Install with wally by adding the following to your `wally.toml`:
	```toml
	Signal = "dig1t/signal@1.0.0"
	```
]=]

--!strict
--!native

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

local Util = require(script.Parent.Util)

local SIZE_LIMIT: number = 0 -- The maximum number of events that can be stored in the buffer before being fired.
local BUFFER_TIME: number = 0 -- The time in seconds before the buffer is automatically fired.
local BUFFER_CHAR_LIMIT = 1000

local Connection = {}
Connection.__index = Connection

-- Contains arguments passed to the signal
type BufferItem = { any }

--[=[
	@interface BufferOptions
	@within Signal
	.sizeLimit number?
	.bufferTime number?
]=]
export type BufferOptions = {
	sizeLimit: number?,
	bufferTime: number?,
}

export type Connection<T...> = RBXScriptConnection & typeof(setmetatable(
	{} :: {
		_connectionId: string,
		_signal: Signal<T...>,
		_callback: Callback<T...>,
	},
	Connection
))

--[=[
	@type Callback (T...) -> ()
	@within Signal
]=]
export type Callback<T...> = (T...) -> ()

function Connection.new<T...>(signal: Signal<T...>, callback: Callback<T...>): Connection<T...>
	return setmetatable({
		_connectionId = Util.randomString(8),
		_signal = signal,
		_callback = callback,

		Connected = true,
	}, Connection) :: Connection<T...>
end

function Connection.Disconnect<T...>(self: Connection<T...>)
	if not self.Connected or not self._signal then
		return
	end

	self.Connected = false
	self._signal:Disconnect(self._connectionId)
end

local Signal = {}
Signal.__index = Signal

export type Signal<T...> = {
	_connections: { [string]: any },
	_buffer: { BufferItem },
	_bufferSize: number,
	_charCount: number,
	_elapsedTime: number,
	_timerConnection: RBXScriptConnection?,

	sizeLimit: number,
	bufferTime: number,

	onDestroy: () -> ()?,

	new: () -> Signal<T...>,
	Fire: (Signal<T...>, T...) -> (),
	Disconnect: (Signal<T...>, connectionId: string) -> (),
	Connect: (Signal<T...>, callback: Callback<T...>) -> RBXScriptConnection,
	Once: (Signal<T...>, callback: Callback<T...>) -> RBXScriptConnection,
	Wait: (Signal<T...>) -> T...,
	Destroy: (Signal<T...>) -> (),

	_flush: (Signal<T...>) -> (),
	_startTimer: (Signal<T...>) -> (),
	_stopTimer: (Signal<T...>) -> (),
}

local function getTableCharacterCount(table: { any }): number
	local json: string = HttpService:JSONEncode(table)

	return #json
end

--[=[
	Starts a new signal

	On creation, an `onDestroy` function can be defined to run when the signal is destroyed.
]=]
function Signal.new<T...>(options: BufferOptions?): Signal<T...>
	local bufferOptions = options or {} :: BufferOptions

	return setmetatable({
		_connections = {},
		_buffer = {},
		_bufferSize = 0,
		_charCount = 0,
		_elapsedTime = 0,
		_timerConnection = nil,

		sizeLimit = bufferOptions.sizeLimit or SIZE_LIMIT,
		bufferTime = bufferOptions.bufferTime or BUFFER_TIME,
	}, Signal) :: any
end

--[=[
	Fires the signal with the given arguments
	All arguments are stored in the buffer until the buffer is full or the buffer time has elapsed

	@method Fire
	@within Signal
	@param ... tuple -- The arguments to pass to the signal
]=]
function Signal.Fire<T...>(self: Signal<T...>, ...)
	table.insert(self._buffer, { ... } :: BufferItem)

	self._bufferSize += 1
	self._charCount += getTableCharacterCount(self._buffer)

	-- Prevent dropped signals by checking the character count
	if self._charCount >= BUFFER_CHAR_LIMIT then
		self:_flush()
		return
	end

	if self._bufferSize >= self.sizeLimit then
		self:_flush()
	elseif not self._timerConnection then
		self:_startTimer()
	end
end

--[=[
	Flushes the buffer and fires all events.

	@private
	@method _flush
	@within Signal
]=]
function Signal._flush<T...>(self: Signal<T...>)
	self:_stopTimer()

	-- Equivalent of running signal:Fire()
	for _, connection in pairs(self._connections) do
		if connection.Connected then
			for _, args in self._buffer do
				task.spawn(connection._callback, unpack(args))
			end
		end
	end

	self._buffer = {}
	self._bufferSize = 0
	self._charCount = 0
	self._elapsedTime = 0
end

function Signal._startTimer<T...>(self: Signal<T...>)
	-- Prevent multiple connections
	if self._timerConnection then
		return
	end

	self._timerConnection = RunService.Stepped:Connect(function(deltaTime: number)
		if self._bufferSize == 0 then
			return
		end

		self._elapsedTime += deltaTime

		if self._elapsedTime > self.bufferTime then
			self:_flush()
			self._elapsedTime = 0
		end
	end)
end

function Signal._stopTimer<T...>(self: Signal<T...>)
	if self._timerConnection then
		self._timerConnection:Disconnect()
		self._timerConnection = nil
	end
end

--[=[
	Disconnects a connection from the signal using the connection's ID.

	For advanced usage, you can store the connection's ID and disconnect it later.

	@private
	@method Disconnect
	@within Signal
]=]
function Signal.Disconnect<T...>(self: Signal<T...>, connectionId: string)
	self._connections[connectionId] = nil
end

--[=[
	Connects to the signal.

	```lua
	local signal = Signal.new()

	signal:Connect(function(message: string)
		print(message) -- "Hello, world!"
	end)

	signal:Fire("Hello, world!")
	```

	@method Connect
	@within Signal
	@param callback Callback<T...> -- The function to call when the signal is fired
	@return RBXScriptConnection -- The connection object that can be used to disconnect
]=]
function Signal.Connect<T...>(self: Signal<T...>, callback: Callback<T...>): RBXScriptConnection
	local connection: Connection<T...> = Connection.new(self, callback)

	self._connections[connection._connectionId] = connection

	-- Forcibly cast to RBXScriptConnection
	return (connection :: any) :: RBXScriptConnection
end

--[=[
	Connects to the signal and disconnects after the first fire.

	```lua
	local signal = Signal.new()

	signal:Once(function(message: string)
		print(message) -- "Hello, world!"
	end)

	signal:Fire("Hello, world!")
	```

	@method Once
	@within Signal
	@param callback Callback<T...> -- The function to call when the signal is fired
]=]
function Signal.Once<T...>(self: Signal<T...>, callback: Callback<T...>): RBXScriptConnection
	local connection: RBXScriptConnection

	connection = self:Connect(function(...)
		if not connection.Connected then
			return
		end

		connection:Disconnect()
		callback(...)
	end)

	return connection
end

--[=[
	Blocks the current thread until the signal is fired.

	```lua
	local signal = Signal.new()

	task.spawn(function()
		task.wait(1)
		signal:Fire("Hello, world!")
	end)

	local message = signal:Wait() -- Blocks until the signal is fired
	print(message) -- "Hello, world!"
	```

	@method Wait
	@within Signal
]=]
function Signal.Wait<T...>(self: Signal<T...>): T...
	local _coroutine = coroutine.running()
	local connection: RBXScriptConnection

	connection = self:Connect(function(...)
		if not connection.Connected then
			return
		end

		connection:Disconnect()
		task.spawn(_coroutine, ...)
	end)

	return coroutine.yield()
end

--[=[
	Disconnects all connections from the signal.

	`Destroy` is not necessary to call as the signal will be garbage collected when it is no longer referenced.

	@method Destroy
	@within Signal
	@tag Cleanup
]=]
function Signal.Destroy<T...>(self: Signal<T...>)
	self:_stopTimer()

	for _, connection in pairs(self._connections) do
		connection:Disconnect()
	end

	self._connections = {}

	if self.onDestroy then
		self.onDestroy()
		self.onDestroy = nil
	end
end

return Signal


--[=[
	@class State

	The State class is used to manage state for anything.
	It can be used to store data for the game, UI, or any other feature.

	Usage example

	```lua
	local state = State.new({
		val = 1,
		time = tick(),
		nest = {
			val = 123123
		}
	})

	local connection: RBXScriptConnection = state.Changed:Connect(function(newState, prevState)
		print(prevState, newState)
	end)

	state:Set("val", 11)

	print(state:Length()) -- expect 3

	state:Set(function(state)
		state.val = 111
	end)

	state:Set({
		val = 1111
	})

	state.val = 11111

	state:Remove("val") -- Removes val from the state

	state:Reset() -- Reset the state to the initial state

	connection:Disconnect()

	state:Destroy()

	local success = pcall(function()
		state:Set("val", 1)
	end)

	print("state destroyed:", not success) -- expect: state destroyed: true
	```

	### Signals

	`State.Changed :: Signal.Signal<newState: T, prevState: T>`
	Signal that is fired when the state changes

	Install with wally by adding the following to your `wally.toml`:
	```toml
	State = "dig1t/state@1.1.2"
	```
]=]

--!strict

local Signal = require(script.Parent.Signal)
local Util = require(script.Parent.Util)

local State = {}
State.__index = State

export type State<T> = typeof(setmetatable(
	{} :: {
		--[=[
			Signal that is fired when the state changes

			Example:
			```lua
			local connection: RBXScriptConnection = state.Changed:Connect(function(newState, prevState)
				print(prevState, newState)
			end)
			```

			@prop Changed Signal.Signal<T, T>
			@within State
			@tag Signal
		]=]
		Changed: Signal.Signal<T, T>,

		_context: T,
		_initialState: T,
	},
	State
))

--[=[
	Creates a new state instance

	@param initialState T -- Initial state values
	@return State<T>
]=]
function State.new<T>(initialState: T): State<T>
	return setmetatable({
		__index = function(_self, key)
			return _self._context[key] or _self[key]
		end,

		Changed = Signal.new(),

		_context = initialState :: T,
		_initialState = initialState,
	}, State) :: State<T>
end

-- Number of first level children

--[=[
	Returns the size of the state if the state is a table.

	Example:
	```lua
	local state = State.new({
		val = 1,
		time = tick(),
		nest = {
			val = 123123
		}
	})

	print(state:Length()) -- expect 3
	```

	@method Length
	@within State
	@return number
]=]
function State.Length<T>(self: State<T>): number
	assert(typeof(self._context) == "table", "State context is not a table")

	return self._context and Util.tableLength(self._context) or 0
end

--[=[
	Returns the value at the given path.
	The path parameter can only be used if the state is a table.

	Example:
	```lua
	local state = State.new({
		val = 1,
		time = tick(),
		nest = {
			val = 123123
		}
	})

	print(state:Get("val")) -- expect 1
	print(state:Get("nest.val")) -- expect 123123

	-- True as the argument returns the entire state
	print(state:Get()) -- expect { val = 1, time = tick(), nest = { val = 123123 } }
	```

	@method Get
	@within State
	@param path string | number? -- The path to the value to get. Leave empty to get the entire state
	@return any
]=]
function State.Get<T>(
	self: State<T>,
	path: string | number?
): any -- Todo: support for nested tables
	if path == nil then
		return self._context
	end

	-- The following requires context to be a table
	assert(typeof(self._context) == "table", "State context is not a table")

	if typeof(path) == "number" then
		return self._context[path] -- Return the index, if exists
	end

	if typeof(path) == "table" then
		return Util.treePath(self._context, path, ".")
	end

	error("Invalid path type")
end

--[=[
	Fires the Changed signal with the new state

	@private
]=]
function State._pushUpdates<T>(self: State<T>, newState: T, prevState: T)
	local _newState = newState or self._context

	self.Changed:Fire(_newState, prevState)
end

--[=[
	Pushes a new value to the state.

	The state must be a table to use this method.

	Example:
	```lua
	local state = State.new({
		val = 1,
		time = tick(),
		nest = {
			val = 123123
		}
	})

	state:Push("val", 2)

	print(state:Get("val")) -- expect 2
	```

	@method Push
	@within State
	@param keyOrValue any -- The key of the pushed value or the value itself if the state is an array
	@param value any? -- The value to push
	@return any -- The key of the pushed value
]=]
function State.Push<T>(self: State<T>, keyOrValue: any, value: any?): any
	assert(typeof(self._context) == "table", "Cannot push to a non-table state")

	local prevState: T = table.clone(self._context) :: any -- Create a 1 level copy -- TODO: change to deep copy
	local key: any = value ~= nil and keyOrValue or #self._context + 1

	self._context[key] = value ~= nil and value or keyOrValue

	self:_pushUpdates(self._context, prevState)

	return key
end

--[=[
	Resets the state to the initial state

	Example:
	```lua
	local state = State.new({
		val = 1
	})

	state:Set("val", 2)

	state:Reset()

	state:Get() -- expect { val = 1 }
	```

	@method Reset
	@within State
]=]
function State.Reset<T>(self: State<T>)
	self:_pushUpdates(self._initialState, self._context)

	self._context = self._initialState
end

--[=[
	Sets the state with a new value.

	Example:
	```lua
	local state = State.new({
		val = 1,
		time = tick(),
		nest = {
			val = 123123
		}
	})

	state:Set({
		val = 1111
	}) -- Change val to 1111
	```

	@method Set
	@within State
	@param newState T -- The new state value
]=]
function State.Set<T>(self: State<T>, newState: T)
	-- TODO: Create a deep copy if the newState is a table
	local prevState: T = if typeof(self._context) == "table"
		then Util.extend({}, self._context :: any) :: any
		else self._context

	self._context = newState

	self:_pushUpdates(self._context, prevState)
end

--[=[
	Sets the state with a new value.

	The `modifier` function should NOT yield as it can cause issues or data loss.
	This function should be used as a shortcut to modify the state.

	For example
	```
	local value = state:Get()

	value.number += 1

	state:Set(value)
	```

	Turns into:
	```
	state:SetCallback(function(context)
		context.number += 1
	end)
	```

	Example:
	```lua
	type NumberState = {
		val: number
	}

	local state: State.State<NumberState> = State.new({
		val = 1
	})

	state:SetCallback(function(prevState: NumberState)
		prevState.val = 111
	end) -- Change val to 111
	```

	@method SetCallback
	@within State
	@param modifier (T) -> ()
]=]
function State.SetCallback<T>(self: State<T>, modifier: (T) -> ())
	local prevState: T = if typeof(self._context) == "table"
		then Util.extend({}, self._context :: any) :: any
		else self._context

	-- call the "modifier" function so _context can be altered
	-- the "modifier" function should NOT yield
	modifier(self._context)

	self:_pushUpdates(self._context, prevState)
end

--[=[
	Sets the state with a new value

	Example:
	```lua
	local state = State.new({
		val = 1,
		time = tick(),
		nest = {
			val = 123123
		}
	})

	state:SetPath("nest.val", 11111) -- Change nest.val from 123123 to 11111
	```

	@method SetPath
	@within State
	@param pathString string -- The path to set
	@param value any? -- The value to set
]=]
function State.SetPath<T>(self: State<T>, pathString: string, value: any)
	assert(typeof(self._context) == "table", "State context must be a table")

	local prevState: T = table.clone(self._context) :: any -- Create a copy
	local path: { string } = Util.split(pathString, ".", true)
	local res = self._context

	if not res then
		return
	end

	-- Go through nest until the last nest level is reached
	for i: number, childName: string in path do
		local numberIndex: number? = tonumber(childName)
		local resIndex: number | string = numberIndex or childName

		if res[resIndex] and i ~= #path then
			res = res[resIndex]
		elseif i == #path then
			-- Change the value if end of the path was reached
			res[resIndex] = value
		else
			break
		end
	end

	self:_pushUpdates(self._context, prevState)
end

--[=[
	Removes a value from the state

	Example:
	```lua
	local state = State.new({
		val = 1,
		time = tick(),
		nest = {
			val = 123123
		}
	})

	state:Remove("val") -- Removes val from the state

	state:Get() -- expect { time = tick(), nest = { val = 123123 } }
	```

	@method Remove
	@within State
	@param path string | number -- The path to the value to remove
]=]
function State.Remove<T>(self: State<T>, path: string | number)
	assert(typeof(self._context) == "table", "State context must be a table")

	local prevState: T = table.clone(self._context) :: any -- Create a local copy
	local treePath = Util.split(tostring(path), ".", true)
	local res = self._context

	if not res then
		return
	end

	-- Dig through nest until the last nest level is reached
	for i: number, childName: string in treePath do
		local numberIndex: number? = tonumber(childName)
		local resIndex: number | string = numberIndex or childName

		if res[resIndex] and i ~= #treePath then
			res = res[resIndex]
		elseif i == #treePath then
			-- Remove the value if end of the path was reached
			res[resIndex] = nil
		else
			break
		end
	end

	self:_pushUpdates(self._context, prevState)
end

--[=[
	Destroys the state

	Example:
	```lua
	local state = State.new({
		val = 1,
		time = tick(),
		nest = {
			val = 123123
		}
	})

	state:Destroy()

	local success = pcall(function()
		-- This should throw an error since the state object was destroyed
		state:Set("val", 1)
	end)

	print('state destroyed?', not success) -- expect: true
	```

	@method Destroy
	@within State
	@tag Cleanup
]=]
function State.Destroy<T>(self: State<T>)
	self._context = nil :: any

	pcall(setmetatable, self, {})
end

return State


--[=[
	@class Trash

	Trash is a class that allows you to destroy an instance after a certain amount of time.

	If the instance is destroyed before the time is up, the `Trash` object will automatically clear itself

	In addition to instances, this class can be used to destroy any class object that has a `Destroy` method.

	Migrating from Debris:
	`Debris:AddItem(part, 8)` -> `Trash.new(part, 8)`

	Timed class destruction:
	```lua
	local MyClass = require(class)

	local myInstance = MyClass.new()

	Trash.new(myInstance, 10) -- Destroy the instance after 10 seconds
	```

	Use case:
	```lua
	local function partExplosion()
		for i = 1, 100 do
			local part = Instance.new("Part")
			part.Parent = Workspace

			Trash.new(part, 10) -- Destroy the part after 10 seconds

			part.Touched:Connect(function()
				part:Destroy()
				-- `Trash` will now automatically clear the Trash object
			end)
		end
	end
	```

	Install with wally by adding the following to your `wally.toml`:
	```toml
	Trash = "dig1t/trash@1.0.3"
	```
]=]

--!native

local RunService = game:GetService("RunService")

local DEFAULT_DESTRUCTION_TIME = 60

local trash: { Trash } = {}

local Trash = {}
Trash.__index = Trash

export type Trash = typeof(setmetatable(
	{} :: {
		instance: any,
		destroyAt: number,
		callback: () -> (),
	},
	Trash
))

local function destroyTrash(trashIndex: number)
	local debris = trash[trashIndex]

	if not debris then
		return
	end

	pcall(debris.instance.Destroy, debris.instance) -- Safely destroy the instance

	if debris.callback then
		task.spawn(debris.callback)
	end

	table.remove(trash, trashIndex)
end

--- Destroys all instances waiting to be destroyed.
function Trash.empty()
	for trashIndex = #trash, 1, -1 do
		destroyTrash(trashIndex)
	end

	trash = {}
end

--- Stops an instance from being destroyed.
function Trash.removeFromTrash(instance: any)
	-- Search for the instance
	for trashIndex, debris in trash do
		if debris.instance == instance then
			table.remove(trash, trashIndex)
			break
		end
	end
end

--[=[
	Creates a new Trash object

	@param instance Instance -- The instance to destroy
	@param life number? -- The amount of time (seconds) to wait before destroying the instance. Defaults to 60 seconds.
	@param callback function? -- A callback function to call when the instance is destroyed
	@return Trash
]=]
function Trash.new(instance: any, life: number?, callback: () -> ()?): Trash
	assert(instance.Destroy, "Instance must have a Destroy method")

	local self = setmetatable({
		instance = instance,
		destroyAt = os.clock() + (life or DEFAULT_DESTRUCTION_TIME),
		callback = callback,
	}, Trash) :: Trash

	table.insert(trash, self)

	-- Order the trash by destruction time
	table.sort(trash, function(a, b)
		return a.destroyAt > b.destroyAt
	end)

	-- Is this instance has a Destroying signal
	-- Connect to it to remove the instance from the trash list
	-- If the instance is destroyed before the time is up
	if instance.Destroying then
		instance.Destroying:Once(function()
			local trashIndex = table.find(trash, self)

			if trashIndex then
				table.remove(trash, trashIndex)
			end
		end)
	end

	return self
end

--[=[
	Destroys a Trash object

	@method Destroy
	@within Trash
	@tag Cleanup
]=]
function Trash.Destroy(self: Trash)
	local trashIndex = table.find(trash, self)

	if trashIndex then
		destroyTrash(trashIndex)
	end
end

RunService.Stepped:Connect(function()
	local now = os.clock()
	local trashAmount = #trash

	if trashAmount == 0 then
		return
	end

	for trashIndex = trashAmount, 1, -1 do
		local debris = trash[trashIndex]

		if debris.destroyAt <= now then
			destroyTrash(trashIndex)
		end
	end

	trashAmount = #trash -- Update the trash amount

	if trashAmount == 0 then
		return
	end

	for garbageIndex = trashAmount, 1, -1 do
		local debris = trash[garbageIndex]

		if not debris then
			continue
		end

		if now > debris.destroyAt then
			destroyTrash(garbageIndex)
		else
			-- The trash list is ordered by destruction time
			-- If we reach an instance that has not yet expired,
			-- we can safely break the loop
			return
		end
	end
end)

return Trash


local DateUtil = {}

export type DateTimeComponents = {
	Year: number,
	Month: number,
	Day: number,
	Hour: number,
	Minute: number,
	Second: number,
	Millisecond: number,
} | any

local milisecondsInSecond: number = 1000
local milisecondsInMinute: number = milisecondsInSecond * 60
local milisecondsInHour: number = milisecondsInMinute * 60
local milisecondsInDay: number = milisecondsInHour * 24
local milisecondsInYear: number = 31556952000
local milisecondsInMonth: number = 2629746000

local secondsInMinute: number = 60
local secondsInHour: number = secondsInMinute * 60
local secondsInDay: number = secondsInHour * 24
local secondsInMonth: number = 2629746
local secondsInYear: number = 31556952

local timeNames: { { any } } = {
	{ secondsInYear, "year" },
	{ secondsInMonth, "month" },
	{ secondsInDay, "day" },
	{ secondsInHour, "hour" },
	{ secondsInMinute, "minute" },
	{ 1, "second" },
}

--[=[
	Converts a DateTimeComponents table to a timestamp.

	@within Util
	@param components DateTimeComponents
	@return number
]=]
function DateUtil.dateTimeComponentsToTimestamp(components: DateTimeComponents): number
	assert(typeof(components) == "table", "Components must be a table")
	assert(typeof(components.Year) == "number", "Components.Year must be a number")
	assert(typeof(components.Month) == "number", "Components.Month must be a number")
	assert(typeof(components.Day) == "number", "Components.Day must be a number")
	assert(typeof(components.Hour) == "number", "Components.Hour must be a number")
	assert(typeof(components.Minute) == "number", "Components.Minute must be a number")
	assert(typeof(components.Second) == "number", "Components.Second must be a number")
	assert(
		typeof(components.Millisecond) == "number",
		"Components.Millisecond must be a number"
	)

	local timestamp: number = 0

	timestamp += components.Year * milisecondsInYear
	timestamp += components.Month * milisecondsInMonth
	timestamp += components.Day * milisecondsInDay
	timestamp += components.Hour * milisecondsInHour
	timestamp += components.Minute * milisecondsInMinute
	timestamp += components.Second * milisecondsInSecond
	timestamp += components.Millisecond

	return timestamp / 1000
end

--[=[
	Get the current unix timestamp in UTC time.
	This is the number of **seconds** since January 1, 1970.

	@within Util
	@return number
]=]
function DateUtil.unix(): number
	local utcDateComponents: DateTimeComponents = DateTime.now():ToUniversalTime()

	return DateUtil.dateTimeComponentsToTimestamp(utcDateComponents)
end

--[=[
	Gets the time in seconds since now or the provided timestamp.

	@within Util
	@param timestamp number?
	@param endTimestamp number? -- The timestamp to compare to. Defaults to current time.
	@return number
]=]
function DateUtil.timeAgoUnix(timestamp: number, endTimestamp: number?): number
	assert(typeof(timestamp) == "number", "Timestamp must be a number")
	assert(
		typeof(endTimestamp) == "number" or endTimestamp == nil,
		"toTimestamp must be a number or nil"
	)

	return (endTimestamp or DateUtil.unix()) - timestamp
end

--[=[
	Converts a unix timestamp to a `mm:ss` format (e.g. 00:02).
	A format and locale can be provided.

	Use the [DateTime](https://create.roblox.com/docs/reference/engine/datatypes/DateTime) API for formatting options.

	#### Example
	```lua
	local secondsLeft: number = 80

	Util.formatUnix(secondsLeft, "mm:ss", nil, true) -- 01:20
	Util.formatUnix(secondsLeft, "HH:mm:ss", nil, true) -- 00:01:20
	```

	@within Util
	@param timestamp number
	@param format string -- The format to use.
	@param locale string? -- The locale to use. Defaults to `en-us`.
	@param useUTC boolean? -- Whether to use UTC time. Defaults to local time.
	@return string -- The formatted time in local time.
]=]
function DateUtil.formatUnix(
	timestamp: number,
	format: string,
	locale: string?,
	useUTC: boolean
): string
	assert(typeof(timestamp) == "number", "Timestamp must be a number")
	assert(typeof(format) == "string", "format must be a string")
	assert(typeof(locale) == "string" or locale == nil, "locale must be a string or nil")

	local dateTime = DateTime.fromUnixTimestamp(math.floor(timestamp))

	return (dateTime :: any)[useUTC and "FormatUniversalTime" or "FormatLocalTime"](
		dateTime,
		format,
		locale or "en-us"
	)
end

--[=[
	Converts a unix timestamp to a `mm:ss` format (e.g. 00:02).

	#### Example
	```lua
	Util.elapsedTimeClockFormat(80) -- 01:20
	Util.elapsedTimeClockFormat(3661) -- 01:01:01
	```

	@within Util
	@param timestamp number
	@return string -- The formatted time in local time.
]=]
function DateUtil.elapsedTimeClockFormat(timestamp: number): string
	assert(typeof(timestamp) == "number", "Timestamp must be a number")

	local isOverAnHour: boolean = timestamp >= 3600

	return DateUtil.formatUnix(
		timestamp,
		isOverAnHour and "HH:mm:ss" or "mm:ss",
		"en-us",
		true
	)
end
--[=[
	Converts a unix timestamp to a readable time ago format (e.g. 1 minute ago, 1 minute 20 seconds ago).

	#### Example
	```lua
	local secondsLeft: number = 80

	Util.timeAgo(secondsLeft) -- 1 minute ago
	```

	```lua
	local secondsLeft: number = 80

	Util.timeAgo(secondsLeft, 80 + 60) -- just now
	```

	@within Util
	@param timestamp number
	@param endTimestamp number? -- The timestamp to compare to. Defaults to current time.
	@return string
]=]
function DateUtil.timeAgo(timestamp: number, endTimestamp: number?): string
	assert(typeof(timestamp) == "number", "Timestamp must be a number")
	assert(
		typeof(endTimestamp) == "number" or endTimestamp == nil,
		"endTimestamp must be a number or nil"
	)

	local timeAgo: number = DateUtil.timeAgoUnix(timestamp, endTimestamp)
	local result: string = ""

	if timeAgo < 1 then
		return "now"
	end

	local current: number = 1
	local previousUnitSeconds: number = 0

	while current <= #timeNames do
		local _time: {} = timeNames[current]
		local unitThreshold: number = timeNames[current][1]
		local unit: string = timeNames[current][2]

		if (timeAgo >= unitThreshold) and current <= #timeNames then
			local amount: number =
				math.floor((timeAgo - previousUnitSeconds) / unitThreshold)

			previousUnitSeconds += amount * unitThreshold

			if amount > 0 then
				local plural: string = amount > 1 and "s" or ""

				result = `{result}{amount} {unit}{plural} `
			end
		end

		current += 1
	end

	return `{result}ago`
end

--[=[
	Similar to `Util.timeAgo` but returns a more detailed string with the total time instead of the largest unit.
	Convert a unix timestamp to a readable format (1 days, 1 hours, 1 minutes, 1 seconds).

	#### Example
	```lua
	local secondsLeft: number = 80

	Util.totalTime(secondsLeft) -- 1 minute 20 seconds
	```

	@within Util
	@param timestamp number
	@param endTimestamp number? -- The timestamp to compare to. Defaults to current time.
	@param separator string? -- The separator to use between each unit. Defaults to a space.
	@param depth number? -- The maximum number of units to return.
	@return string
]=]
function DateUtil.totalTime(
	timestamp: number,
	endTimestamp: number?,
	separator: string?,
	depth: number?
): string
	assert(typeof(timestamp) == "number", "Timestamp must be a number")
	assert(
		typeof(endTimestamp) == "number" or endTimestamp == nil,
		"endTimestamp must be a number or nil"
	)
	assert(
		typeof(separator) == "string" or separator == nil,
		"separator must be a string or nil"
	)
	assert(typeof(depth) == "number" or depth == nil, "depth must be a number or nil")

	local _separator: string = separator or " "

	local timeAgo: number = DateUtil.timeAgoUnix(timestamp, endTimestamp)
	local result: string = ""

	if timeAgo < 1 then
		return "now"
	end

	local current: number = 1
	local previousUnitSeconds: number = 0
	local iteration: number = 0

	while current <= #timeNames and (depth == nil or iteration < depth) do
		local _time: {} = timeNames[current]
		local unitThreshold: number = timeNames[current][1]
		local unit: string = timeNames[current][2]

		if (timeAgo >= unitThreshold) and current <= #timeNames then
			local amount: number =
				math.floor((timeAgo - previousUnitSeconds) / unitThreshold)

			previousUnitSeconds += amount * unitThreshold

			if amount > 0 then
				local plural: string = amount > 1 and "s" or ""

				result = `{result}{amount} {unit}{plural}{_separator}`
				iteration += 1
			end
		end

		current += 1
	end

	-- Trim last separator and return
	return string.sub(result, 1, string.len(result) - string.len(_separator))
end

return DateUtil


--!native

local MathUtil = {}

--[=[
	Rounds a number to the nearest integer.

	#### Example:
	```lua
	Util.round(1.5) -- 2
	Util.round(1.4) -- 1
	```

	@within Util
	@param _number number
	@return number
]=]
function MathUtil.round(_number: number): number
	assert(typeof(_number) == "number", "Number argument must be a number")

	local decimal: number = _number % 1

	return math[decimal >= 0.5 and "ceil" or "floor"](_number)
end

--[=[
	Formats a number to have commas.

	#### Example:
	```lua
	Util.formatNumber(1000) -- 1,000
	Util.formatNumber(1000.01) -- 1,000.01
	```

	@within Util
	@param number number
	@param decimals number? -- The number of decimal places to round to. Defaults to 2.
	@return string
]=]
function MathUtil.formatNumber(
	number: number,
	decimals: number?
): string -- 1000.01 to 1,000.01
	assert(typeof(number) == "number", "Number argument must be a number")

	local numberString: string = tostring(number)

	if not numberString then
		return ""
	end

	local negative: string?, int: string?, _decimals: string? =
		numberString:match("([-]?)(%d+)([.]?%d*)")

	if not negative or not int or not _decimals then
		return ""
	end

	-- Add decimal places
	if decimals and #_decimals > 0 then
		_decimals = _decimals:sub(1, decimals + 1)

		-- If the only result is ".", remove it
		if #_decimals == 1 then
			_decimals = ""
		end
	end

	int = string.gsub(int:reverse(), "(%d%d%d)", "%1,"):reverse():gsub("^,", "")

	return `{negative}{int}{_decimals}`
end

--[=[
	Returns a random number between two numbers.

	#### Example:
	```lua
	Util.random(1, 10) -- 5
	Util.random(1, 10, true) -- 5.5
	Util.random(10) -- 5 (min defaults to 1)
	Util.random(10, true) -- 5.5 (min defaults to 1)
	```

	@within Util
	@param minValue number
	@param maxValue number?
	@param float boolean? -- Whether to return as a float or not. Defaults to false.
]=]
function MathUtil.random(minValue: number, maxValue: number?, float: boolean?): number
	assert(typeof(minValue) == "number", "Number1 argument must be a number")
	assert(
		typeof(maxValue) == "number" or maxValue == nil,
		"Number2 argument must be a number or nil"
	)
	assert(
		typeof(float) == "boolean" or float == nil,
		"Float argument must be a boolean or nil"
	)

	local min: number = not maxValue and 1 or minValue -- If maxValue is not defined, use 1 as min
	local max: number = maxValue or minValue

	return float == true and Random.new():NextNumber(min, max) -- Return float
		or Random.new():NextInteger(min, max) -- Return integer
end

local charset: string = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
local charsetLength: number = #charset

--[=[
	Returns a random string.

	#### Example:
	```lua
	Util.randomString() -- "aBcDeFgHiJkLmNoPqRsTuVwXyZ"
	Util.randomString(10) -- "aBcDeFgHiJ"
	```

	@within Util
	@param length number? -- Length of the string (defaults to 18)
	@return string
]=]
function MathUtil.randomString(length: number?): string
	assert(
		typeof(length) == "number" or length == nil,
		"Length argument must be a number or nil"
	)

	local res: string = ""

	if length and length < 1 then
		return res
	end

	for _ = 1, (length or 18) do
		local random: number = Random.new():NextInteger(1, charsetLength)
		res = res .. charset:sub(random, random)
	end

	return res
end

--[=[
	Returns a random object from a table or instance.

	#### Example:
	```lua
	Util.randomObj({ "a", "b", "c" }) -- "b"
	Util.randomObj(Workspace.Player) -- Head
	```

	@within Util
	@param obj table | Instance
	@return any
]=]
function MathUtil.randomObj(obj: { [any]: any } | Instance): any
	assert(
		typeof(obj) == "table" or typeof(obj) == "Instance",
		"Object argument must be a table or Instance"
	)

	if typeof(obj) == "Instance" then
		-- Convert to a table
		obj = obj:GetChildren()
	end

	if typeof(obj) == "table" then
		return obj[Random.new():NextInteger(1, #obj)]
	end

	return
end

--[=[
	Returns a random boolean.

	#### Example:
	```lua
	Util.coinFlip() -- true
	Util.coinFlip() -- false
	```

	@within Util
	@return boolean
]=]
function MathUtil.coinFlip(): boolean
	return Random.new():NextNumber() > 0.5
end

local shortNumberSuffixes: { string } =
	{ "K", "M", "B", "T", "Q", "QQ", "S", "SS", "O", "N", "D", "UN", "DD", "TD", "QD" }

--[=[
	Shortens a large number to a readable format.
	Useful for displaying large numbers that don't require exact precision.
	Common use cases are for displaying XP, money in leaderboards, damage etc.

	#### Example:
	```lua
	Util.shortenNumber(1000) -- 1K
	Util.shortenNumber(1000000) -- 1M
	Util.shortenNumber(1000000000) -- 1B
	Util.shortenNumber(1000000000000) -- 1T
	Util.shortenNumber(1000000000000000) -- 1Q
	```

	minimumTiers is useful for when you want to shorten a number but only if it's above a certain tier.
	For example, if you want to shorten a number but only if it's above 1,000,000, you would do:
	```lua
	Util.shortenNumber(1000000, 2) -- 1,000,000
	```

	A tier is the amount of commas in a number. For example, 1,000,000 has 2 tiers and 1,000 has 1 tier.

	Tier list and their abbreviations:
	- 1,000 = K
	- 1,000,000 = M
	- 1,000,000,000 = B
	- 1,000,000,000,000 = T
	- 1,000,000,000,000,000 = Q

	@within Util
	@param number number | string
	@param minimumTiers number? -- The minimum number of tiers to shorten. Defaults to 1.
	@param decimals number? -- The number of decimal places to round to. Defaults to 1.
	@return string
]=]
function MathUtil.shortenNumber(
	number: number | string,
	minimumTiers: number?,
	decimals: number?
): string
	assert(
		typeof(number) == "number" or typeof(number) == "string",
		"Number argument must be a number or string"
	)
	assert(
		typeof(minimumTiers) == "number" or minimumTiers == nil,
		"MinimumTiers argument must be a number or nil"
	)
	assert(
		typeof(decimals) == "number" or decimals == nil,
		"Decimals argument must be a number or nil"
	)

	number = typeof(number) == "string" and tonumber(number) or number

	assert(
		typeof(number) == "number",
		"Number argument be a number or string that can be converted to a number"
	)

	-- Not necessary to shorten
	if number < 1000 then
		return MathUtil.formatNumber(number, decimals or 3)
	end

	number = math.floor(number)

	local negative: number = math.abs(number) == number and 1 or -1
	number = math.abs(number)

	local tier: number = math.log10(number) / 3

	if tier < (minimumTiers or 1) then -- If tier is less than minimumTiers, return the number
		return MathUtil.formatNumber(number * negative, decimals or 3)
	end

	tier = math.floor(tier)

	local tierSuffix: string? = shortNumberSuffixes[tier]
		or shortNumberSuffixes[#shortNumberSuffixes] -- Get tier or use highest tier if # too high

	local _number: number? = tonumber(string.format("%.2f", number / 10 ^ (3 * tier)))

	if not _number or not tierSuffix then
		return ""
	end

	local decimalText: string = ""

	if not decimals then
		decimals = 2
	end

	if decimals then
		local formattedNumber = tostring(_number)
		local decimalIndex = formattedNumber:find("%.")

		if decimalIndex then
			decimalText = "."
				.. formattedNumber:sub(decimalIndex + 1, decimalIndex + decimals)
		end
	end

	return `{MathUtil.formatNumber(_number, 0)}{decimalText}{tierSuffix}`
end

--[=[
	Gets a Vector3 from an object containing a Vector3 or CFrame.

	#### Supports:
	- Vector3
	- CFrame
	- Attachment
	- BasePart
	- CFrameValue
	- Vector3Value
	- Camera

	@within Util
	@param object Vector3 | CFrame | Instance
	@return Vector3?
]=]
function MathUtil.getVector3(
	object: Vector3 | CFrame | Attachment | BasePart | CFrameValue | Vector3Value | Camera
): Vector3?
	return (typeof(object) == "Vector3" and object)
		or (typeof(object) == "CFrame" and object.Position)
		or (typeof(object) == "Instance" and ((object:IsA("Attachment") and object.WorldPosition) or (object:IsA(
			"BasePart"
		) and object.Position) or (object:IsA("CFrameValue") and object.Value and object.Value.Position) or (object:IsA(
			"Vector3Value"
		) and object.Value) or (object:IsA("Camera") and object.CFrame.Position)))
		or nil
end

--[=[
	Gets the distance between two Vector3s.

	@within Util
	@param origin Vector3 | Instance
	@param target Vector3 | Instance
	@return number
]=]
function MathUtil.getDistance(origin: any, target: any)
	local originVector3: Vector3? = MathUtil.getVector3(origin)
	local targetVector3: Vector3? = MathUtil.getVector3(target)

	assert(
		originVector3,
		"Util.getDistance - Parameter 1 is missing a Vector3 value or an Instance with a Vector3 Position"
	)
	assert(
		targetVector3,
		"Util.getDistance - Parameter 2 is missing a Vector3 value or an Instance with a Vector3 Position"
	)

	return (originVector3 - targetVector3).Magnitude
end

--[=[
	Gets the bottom surface of a part.

	@within Util
	@param part BasePart
	@return CFrame
]=]
function MathUtil.getPartBottomSurface(part: BasePart): CFrame
	return part.CFrame * CFrame.new(0, -part.Size.Y / 2, 0)
end

return MathUtil


local Players = game:GetService("Players")

local groupRankCache = {}
local groupRankCacheExpiration = 60 -- 1 minutes

local PlayerUtil = {}

--[=[
	Gets the group rank of a player.

	#### Example:
	```lua
	Util.getGroupRank(game.Players.LocalPlayer, 1) -- 255
	```

	@within Util
	@param player Player
	@param groupId number
	@return number
]=]
function PlayerUtil.getGroupRank(player: Player, groupId: number): number
	local uniqueId: string = `${player.UserId}-${groupId}`

	if not player or not groupId then
		return 0
	end

	if not groupRankCache[uniqueId] then
		local success, res = pcall(function()
			return player:GetRankInGroup(groupId)
		end)

		groupRankCache[uniqueId] = {
			rank = success and res or 0,
			fetchedAt = os.time(),
		}

		return success and res or 0
	end

	if os.clock() - groupRankCache[uniqueId].fetchedAt > groupRankCacheExpiration then
		return PlayerUtil.getGroupRank(player, groupId) -- refetch if expired
	end

	return groupRankCache[uniqueId].rank
end

--[=[
	Checks if a player is the creator of the game.

	#### Example:
	```lua
	Util.isCreator(game.Players.LocalPlayer) -- true
	```

	@within Util
	@param player Player
	@return boolean
]=]
function PlayerUtil.isCreator(player: Player): boolean
	return (
		game.CreatorType == Enum.CreatorType.Group
		and PlayerUtil.getGroupRank(player, game.CreatorId) >= 255
	)
		or (game.CreatorType == Enum.CreatorType.User and player.UserId == game.CreatorId)
end

--[=[
	@within Util
	@prop userLevel table<number, number>
]=]
PlayerUtil.userLevel = {
	normal = 0, -- (default)
	premium = 1, -- (requires premium)
	VIP = 2, -- (if game is owned by group, requires rank 10)
	moderator = 3, -- (if game is owned by group, requires rank 50)
	superuser = 4, -- developer (if game is owned by group, requires rank 254)
	creator = 5, -- (requires group rank 255 or if game is owned by user)
}

--[=[
	Gets the user level of a player.

	#### Example:
	```lua
	Util.getUserLevel(game.Players.LocalPlayer) -- 0
	Util.getUserLevel(game.Players.GameDeveloper) -- 4
	Util.getUserLevel(game.Players.PremiumPlayer) -- 4
	Util.getUserLevel(game.Players.Creator) -- 5
	```

	@within Util
	@param player Player
	@return number
]=]
function PlayerUtil.getUserLevel(player: Player): number
	local isGameOwnedByGroup: boolean = game.CreatorType == Enum.CreatorType.Group

	if PlayerUtil.isCreator(player) then
		return PlayerUtil.userLevel.creator
	elseif isGameOwnedByGroup then
		local playerRank: number = PlayerUtil.getGroupRank(player, game.CreatorId)

		if playerRank >= 253 then
			return PlayerUtil.userLevel.superuser
		elseif playerRank == 99 then
			return PlayerUtil.userLevel.moderator
		elseif playerRank < 10 then
			return PlayerUtil.userLevel.VIP
		end
	end

	return (
		player.MembershipType == Enum.MembershipType.Premium
		and PlayerUtil.userLevel.premium
	) or PlayerUtil.userLevel.normal
end

--[=[
	Gets the humanoid object from a player's character part, character model, player object, or humanoid.

	#### Example:
	```lua
	Util.getHumanoid(game.Players.LocalPlayer) -- Humanoid
	```

	@within Util
	@param object Instance
	@return Humanoid?
]=]
function PlayerUtil.getHumanoid(object: Instance?): Humanoid?
	if typeof(object) ~= "Instance" then
		warn(`Util.getHumanoid - expected Instance, got type "{typeof(object)}"`)
	elseif object:IsA("Humanoid") then
		return object
	elseif object:IsA("Player") then
		return object.Character and object.Character:FindFirstChildOfClass("Humanoid")
	elseif object:IsA("Model") then
		return object:FindFirstChildOfClass("Humanoid")
	end

	return nil
end

--[=[
	Checks if a humanoid is alive.
	If no humanoid is found, it will return nil

	#### Example:
	```lua
	Util.isAlive(game.Players.LocalPlayer) -- true
	Util.isAlive(game.Workspace.Zombie) -- false
	Util.isAlive(game.Workspace.Part) -- nil, but will throw a warning
	```

	@within Util
	@param obj Instance
	@return boolean
]=]
function PlayerUtil.isAlive(obj: Instance): boolean
	local humanoid: Humanoid? = PlayerUtil.getHumanoid(obj)

	return humanoid and humanoid.Health > 0 or false
end

--[=[
	Checks if an object has a humanoid.

	#### Example:
	```lua
	Util.hasHumanoid(game.Players.LocalPlayer) -- true
	Util.hasHumanoid(game.Workspace.Zombie) -- true
	```

	@within Util
	@param obj Instance
	@return boolean
]=]
function PlayerUtil.hasHumanoid(obj: Instance): boolean
	return PlayerUtil.getHumanoid(obj) ~= nil
end

--[=[
	Gets the player object from a BasePart.

	#### Example:
	```lua
	Util.getPlayerFromInstance(game.Workspace.Part) -- Player
	```

	@within Util
	@param instance Instance
	@return Player?
]=]
function PlayerUtil.getPlayerFromInstance(instance: Instance?): Player?
	if typeof(instance) ~= "Instance" or not instance:IsA("BasePart") then
		return nil
	end

	local node: Instance? = instance
	local humanoid: Humanoid?

	while node ~= nil and humanoid == nil and node ~= game do
		humanoid = node and node:FindFirstChildOfClass("Humanoid")
		node = node.Parent :: Instance?
	end

	return humanoid and Players:GetPlayerFromCharacter(humanoid.Parent) or nil
end

-- Alias
PlayerUtil.getPlayerFromPart = PlayerUtil.getPlayerFromInstance

return PlayerUtil


--!native

local SearchUtil = {}

--[=[
	Gets the number of parts in the given instance

	Example:
	```lua
	Util.getPartCount(game.Workspace.NPC) -- 5
	```

	@within Util
	@param instance Instance
	@return number
]=]
function SearchUtil.getPartCount(instance: Instance?): number
	if instance == nil then
		warn("Util.getPartCount() - instance is nil")
		return 0
	end

	assert(
		typeof(instance) == "Instance",
		"Util.getPartCount() - argument must be an Instance"
	)

	local count: number = 0

	for _, part in instance:GetDescendants() do
		if part:IsA("BasePart") then
			count += 1
		end
	end

	return count
end

--[=[
	Gets all parts in the given instance

	Example:
	```lua
	Util.getDescendantParts(game.Workspace.LightPost) -- { [1] = Light, [2] = PostMeshPart }
	```

	@within Util
	@param instance Instance
	@return { BasePart }
]=]
function SearchUtil.getDescendantParts(instance: Instance): { BasePart }
	assert(
		typeof(instance) == "Instance",
		"Util.getDescendantParts() - argument must be an Instance"
	)

	local parts: { BasePart } = {}

	for _, part: Instance | BasePart in instance:GetDescendants() do
		if part:IsA("BasePart") then
			table.insert(parts, part)
		end
	end

	return parts
end

--[=[
	Gets the first descendant of the given instance that matches the given condition.
	This will search all descendants of the given instance, including the instance itself.
	It will stop  if no ancestor is found.

	Example 1:
	```lua
	detector.Touched:Connect(function(part: BasePart)
		local humanoid = Util.getAncestor(part, "Humanoid")

		if humanoid ~= nil then
			print("Humanoid touched the detector")
		end
	end)
	```

	Example 2:
	```lua
	detector.Touched:Connect(function(part: BasePart)
		local bot: Model? = Util.getAncestor(part, function(instance: Instance)
			return instance:HasTag("NPC")
		end)

		if bot ~= nil then
			bot.
			print("NPC bot touched the detector")
		end
	end)
	```

	@within Util
	@param child Instance
	@param condition string | (obj: Instance) -> boolean
	@return any
]=]
function SearchUtil.getAncestor(
	child: Instance,
	condition: string | (obj: Instance) -> boolean
): any
	assert(
		typeof(child) == "Instance",
		"Util.getAncestor() - child argument must be an Instance"
	)
	assert(
		typeof(condition) == "string" or typeof(condition) == "function",
		"Util.getAncestor() - condition argument must be a string name or function"
	)

	if typeof(condition) == "string" then
		local searchString = condition

		condition = function(_child: Instance)
			return _child.Name == searchString
		end
	end

	local _condition = condition :: (_child: Instance) -> boolean

	while child do
		if _condition(child) then
			return child
		elseif child.Parent then
			child = child.Parent :: Instance
		else
			break
		end
	end

	return nil
end

--[=[
	Starts at the given node and searches all children for the first child that matches the given condition.
	This search will continue until `maxDepth` is reached, a match is found, or no more children are found.
	When a match is found, it will return the matching child.
	When no match is found, it will return nil.

	By default `maxDepth` is 1. Only the children of the given node will be searched.

	Function condition example:
	```lua
	local player: Player? = Util.find(game.Players, function(player: Player)
		return player.Name == "Player1"
	end)
	```

	Instance example:
	```lua
	local player: Player? = Util.find(game.Players, "Player1")
	```

	Filtering example:
	```lua
	local NPCs = {
		{ Name = "NPC1" },
		{ Name = "NPC1" },
		{ Name = "NPC1" },
	}

	local player = Util.find(NPCs, function(NPC)
		return NPC.Name == "NPC1"
	end)
	```

	Advanced example:
	```lua
	local NPCs = {
		{ Name = "NPC1", Health = 0 },
		{ Name = "NPC1", Health = 20 },
		{ Name = "NPC1", Health = 0 },
	}

	local firstAliveNPC = Util.find(NPCs, function(NPC)
		return NPC.Health > 0
	end)
	```

	@within Util
	@param node Instance | { [any]: any }
	@param condition string | (obj: Instance) -> boolean
	@param maxDepth number -- Default: 1
	@param _depth number -- Internal use, used to track the current depth
	@return any
]=]
function SearchUtil.find(
	node: Instance | { [any]: any },
	condition: any | (obj: Instance) -> boolean,
	maxDepth: number?,
	_depth: number?
): any
	assert(
		typeof(node) == "Instance" or typeof(node) == "table",
		"Util.find() - node argument must be an Instance or table"
	)
	assert(condition ~= nil, "Util.find() - condition argument must be a function or key")
	assert(
		typeof(maxDepth) == "number" or maxDepth == nil,
		"Util.find() - maxDepth must be a number or nil"
	)
	assert(
		typeof(_depth) == "number" or _depth == nil,
		"Util.find() - _depth must be a number or nil"
	)

	local function test(child: Instance): (boolean, any)
		if typeof(condition) == "function" then
			local runner = condition :: (child: Instance) -> boolean
			return runner(child), child
		end

		if typeof(child) == "Instance" then
			local result = child:FindFirstChild(condition)

			return result ~= nil, result
		end

		return child[condition] ~= nil, child
	end
	local currentDepth: number = _depth or 1
	local children: { [any]: any } = {}

	local nodes: { [any]: any } = {}

	if typeof(node) == "Instance" then
		nodes = node:GetChildren()
	elseif typeof(node) == "table" then
		nodes = node
	end

	for _, child: any in pairs(nodes) do
		local childIsInstance: boolean = typeof(child) == "Instance"
		local success: boolean, match: any = test(child)

		if success then
			return match -- Test passed, return the match
		elseif (childIsInstance and #child:GetChildren() or #child) > 0 then
			table.insert(children, child) -- Add to search the children of this node later
		end
	end

	if currentDepth >= (maxDepth or 1) then
		return nil
	end

	for _, child: any in pairs(children) do
		if not child then
			continue
		end

		local match: any = SearchUtil.find(child, condition, maxDepth, currentDepth + 1)

		if match ~= nil then
			return match
		end
	end

	return nil
end

--[=[
	Checks if the given instance has the children with the given key names.
	A single child name can be passed as a string, or multiple child names can be passed as a table.

	Table of children example:
	```lua
	Util.exists(
		game.Workspace.Player1,
		{ "Head", "Torso", "Left Arm", "Right Arm", "Left Leg", "Right Leg" }
	) -- true
	```

	String child example:
	```lua
	Util.exists(game.Workspace.Player1, "Head") -- true
	```

	Table of children in a table node example:
	```lua
	Util.exists(
		game.Workspace.Player1:GetChildren(), -- table
		{ "Head", "Torso" }
	) -- true
	```

	Table node and string child example:
	```lua
	Util.exists(
		game.Workspace.Player1:GetChildren(), -- table
		"HumanoidRootPart"
	) -- true
	```

	@within Util
	@param node Instance | { [string]: any }
	@param children string | { string }
	@return boolean
]=]
function SearchUtil.exists(
	node: Instance | { [string]: any },
	children: (string | { string })?
): boolean
	assert(
		typeof(node) == "Instance" or typeof(node) == "table",
		"Util.exists() - node argument must be an Instance or table"
	)
	assert(
		typeof(children) == "string" or typeof(children) == "table" or children == nil,
		"Util.exists() - children argument must be a string or table"
	)

	if children == nil then
		return false
	end

	local nodes: { [string]: any } = {}

	if typeof(node) == "Instance" then
		-- Insert all children into a table with their name as the key
		for _, child: Instance in node:GetChildren() do
			nodes[child.Name] = child
		end
	elseif typeof(node) == "table" then
		nodes = node
	end

	if typeof(children) == "string" then
		-- Search a single child name

		return nodes[children] ~= nil
	elseif typeof(children) == "table" then
		-- Search multiple child names

		for _, childName: string in pairs(children) do
			if nodes[childName] == nil then
				return false
			end
		end

		return true
	end

	return false
end

--[=[
	Returns the first part in the given instance

	Example:
	```lua
	Util.getFirstPart(game.Workspace.Player1) -- Head
	```

	@within Util
	@param instance Instance
	@return BasePart?
]=]
function SearchUtil.getFirstPart(instance: Instance?): BasePart?
	if instance == nil then
		return nil
	end

	assert(typeof(instance) == "Instance", "instance argument must be an Instance")

	local children: { BasePart | Instance } = instance:GetChildren()

	if not children or #children == 0 then
		return
	end

	local index: number = 1
	local current: Instance? = nil

	while index < #children do
		current = children[index]

		if current and current:IsA("BasePart") then
			return current
		end

		index += 1
	end

	return nil
end

return SearchUtil


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

local SystemUtil = {}

--[=[
	Generates a UUID/GUID (Universally Unique Identifier) random string using HttpService:GenerateGUID.

	@within Util
	@return string
]=]
function SystemUtil.generateUUID(): string
	return HttpService:GenerateGUID(false)
end

--[=[
	Alternative for `task.delay`.

	Runs a function after `delay` seconds.
	Returns the time elapsed in seconds.

	#### Example:
	```lua
	Util.timeout(1, function(elapsedTime: number)
		print("Hello, world!")
	end)
	```

	@within Util
	@param delay number
	@param callback function
]=]
function SystemUtil.timeout(delay: number, callback: (number) -> ())
	assert(typeof(delay) == "number", "Util.timeout - delay must be a number")
	assert(typeof(callback) == "function", "Util.timeout - callback must be a function")

	task.spawn(function()
		local start = os.clock()
		local connection: RBXScriptConnection
		local elapsedTime: number = 0

		connection = RunService.Stepped:Connect(function(deltaTime: number)
			elapsedTime += deltaTime

			if os.clock() - start >= delay then
				connection:Disconnect()
				callback(elapsedTime)
			end
		end)
	end)
end

--[=[
	Runs a function every `interval` in seconds.
	Return `false` within the callback to stop the interval or call `:Disconnect()` on the returned `RBXScriptConnection`.

	This function returns an `RBXScriptConnection` that can be disconnected to stop the interval.

	#### Example:
	```lua
	Util.interval(1, function(elapsedTime: number, deltaTime: number)
		print(elapsedTime)
	end)
	```

	@within Util
	@param interval number
	@param callback function
	@return RBXScriptConnection
]=]
function SystemUtil.interval(
	interval: number,
	callback: (number, number) -> boolean?
): RBXScriptConnection
	assert(typeof(interval) == "number", "Util.interval - interval must be a number")
	assert(typeof(callback) == "function", "Util.interval - callback must be a function")

	local elapsedTime: number = 0
	local lastEventAt: number = os.clock()

	local connection: RBXScriptConnection

	connection = RunService.Stepped:Connect(function(deltaTime: number)
		elapsedTime += deltaTime

		if os.clock() - lastEventAt >= interval then
			lastEventAt = os.clock()

			task.spawn(function()
				local success = callback(elapsedTime, deltaTime)

				if success == false then
					connection:Disconnect()
				end
			end)
		end
	end)

	return connection
end

--[=[
	Checks if a value is in a table.

	#### Example:
	```lua
	Util.inTable({ 1, 2, 3 }, 2) -- true
	Util.inTable({ 1, 2, 3 }, 4) -- false
	Util.inTable({ Part, 2, 3, Part2 = true }, Part) -- true
	```

	@within Util
	@param searchTable { [any]: any }
	@param target any
	@return boolean
]=]
function SystemUtil.inTable(searchTable: { [any]: any }, target: any): boolean
	assert(typeof(searchTable) == "table", "Util.inTable - searchTable must be a table")

	for _, value: any in pairs(searchTable) do
		if value == target then
			return true
		end
	end

	return false
end

return SystemUtil


--[[
	@name Weapon System Client
	@description A weapon library for quickly building weapons.
	@author dig1t
	@version 1.0.0
]]

--!nocheck

local Debris = game:GetService("Debris")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local UserInputService = game:GetService("UserInputService")
local Workspace = game:GetService("Workspace")

local Animation = require(script.Parent.Parent.Animation)
local Util = require(script.Parent.Parent.Util)
-- local CollectionService = require(script.Parent.Parent.CollectionService)
local CollectionService = {
	watch = function(_) end,
	getAll = function()
		return {}
	end,
} -- TODO: replace when CollectionService util is fixed

local DEFAULT_CONFIG = {
	hitscan = true, -- No projectiles will be rendered
	tracerRound = false,

	damage = 30,
	maxRange = 2048,
	rangeModifier = 0.75,
}

local passthroughObjects = CollectionService.watch("BULLET_PASSTHROUGH")

local mouse

local WeaponClient, methods = {}, {}
methods.__index = methods

function methods:setCursor(id)
	if not mouse then
		return
	end

	self.currentCursor = id and self.equipped and Util.asset .. id or ""
	mouse.Icon = self.currentCursor
end

--[[
Variable Names

string inputName (primary, secondary, fire, reload)
string eventName (InputBegan, InputEnded, TouchTap)
]]

--[[
weapon:unsetInput("primary") -- get rid of this input
weapon:unsetInputCondition("canUse") -- get rid of this condition check
]]

function methods:inCooldown(inputName): boolean
	if
		self._inputData[inputName].cooldownTime
		and self._inputData[inputName].lastEventTime
	then
		return os.clock() - self._inputData[inputName].lastEventTime
			<= self._inputData[inputName].cooldownTime
	end

	return false
end

function methods:_onInputEvent(eventName, ...)
	local inputNameChecksPassed = {}

	for inputName, callback in pairs(self._eventHooks[eventName] or {}) do
		local inCooldown = self:inCooldown(inputName)
		local conditionCheck = not inCooldown and callback(...)

		if conditionCheck then -- Condition check passed (controller buttons pressed, keyboard button pressed, etc.)
			conditionCheck = not self._inputData[inputName].condition and true
				or self._inputData[inputName].condition(...)

			if conditionCheck then
				inputNameChecksPassed[#inputNameChecksPassed + 1] = inputName
			end
		end

		if not conditionCheck and self._inputData[inputName].onError then
			self._inputData[inputName].onError()
		end
	end

	for _, inputName in pairs(inputNameChecksPassed) do
		if self._inputData[inputName] then
			self._inputData[inputName].lastEventTime = os.clock()

			if self._inputData[inputName].callback then
				self._inputData[inputName].callback(...) -- Call the callback for the input, if any are listening
			end
		end
	end
end

function methods:setInput(inputName, data)
	assert(inputName, "Missing input name")
	assert(typeof(data) == "table", "Missing data table")
	assert(Util.tableLength(data) > 0, "No events to watch")
	assert(
		not self._inputData[inputName],
		string.format("Input %s already exists", inputName)
	)

	for eventName, condition in pairs(data) do
		assert(
			UserInputService[eventName],
			string.format("%s is not a valid UserInputType", eventName)
		)

		-- Start listing to eventName (InputBegan, InputEnded, etc.)
		if self.equipped and not self._connections[eventName] then
			self._connections[eventName] = UserInputService[eventName]:Connect(
				function(...)
					self:_onInputEvent(eventName, ...)
				end
			)
		end

		if not self._eventHooks[eventName] then
			self._eventHooks[eventName] = {}
		end

		self._eventHooks[eventName][inputName] = condition
	end

	self._inputData[inputName] = {
		listeners = data,
	}
end

function methods:setInputCondition(inputName, condition)
	assert(self._inputData[inputName], "Not a valid input type")

	self._inputData[inputName].condition = condition
end

function methods:onInputAttempt(inputName, callback)
	assert(self._inputData[inputName], "Not a valid input type")
	assert(typeof(callback) == "function", "Not a valid callback type")

	self._inputData[inputName].onError = callback
end

function methods:unsetInputCondition(inputName)
	assert(self._inputData[inputName], "Not a valid input type")

	self._inputData[inputName].condition = nil
end

function methods:onInput(inputName, callback)
	assert(self._inputData[inputName], "Not a valid input type")
	assert(typeof(callback) == "function", "Not a valid callback type")

	self._inputData[inputName].callback = callback
end

function methods:unwatchInput(inputName)
	assert(self._inputData[inputName], "Not a valid input type")

	self._inputData[inputName].callback = nil
end

function methods:setInputCooldown(inputName, cooldownTime)
	assert(
		self._inputData[inputName],
		string.format("Input %s does not exist", inputName)
	)

	self._inputData[inputName].cooldownTime = cooldownTime
end

function methods:getInputCooldownTime(inputName)
	return self._inputData[inputName] and self._inputData[inputName].cooldownTime or 0
end

function methods:bindActionToInput(actionType, inputName)
	assert(actionType, "Missing action type")
	assert(self._inputData[inputName], "Not a valid input type")

	if not self._inputData[inputName].actionBindings then
		self._inputData[inputName].actionBindings = {}
	end

	self._inputData[inputName].actionBindings[actionType] = true
end

-- Plays a random animation of the given state
-- Unless its a looped animation, it will yield until the animation finishes
function methods:playAnimation(state, loop)
	self:stopAnimation()

	if not self.animations or not self.animations[state] then
		return
	end

	self.currentTrack = Util.tableRandom(self.animations[state])

	if self.currentTrack then
		local equipTime = self.equipped

		self.currentTrackLooper = loop
				and self.currentTrack.Stopped:Connect(function()
					if self.currentTrack and self.equipped == equipTime then -- Make sure the weapon wasn't re-equipped
						self.currentTrack:Play()
					end
				end)
			or nil

		self.currentTrack:Play()

		if not loop then
			self.currentTrack.Stopped:Wait()
		end
	end
end

function methods:stopAnimation()
	if self.currentTrackLooper and self.currentTrackLooper.Connected then
		self.currentTrackLooper:Disconnect()
		self.currentTrackLooper = nil
	end

	if self.currentTrack and self.currentTrack.IsPlaying then
		self.currentTrack:Stop()
		self.currentTrack = nil
	end
end

function methods:getTargetPosition()
	return (mouse and mouse.Hit.Position)
		or (
			self.localHumanoid
			and self.localHumanoid.Parent
			and self.localHumanoid.TargetPoint
		)
end

function methods:shoot()
	local origin = self.localPlayer.Character and self.localPlayer.Character.PrimaryPart

	if not origin then
		return
	end

	local target = self:getTargetPosition()
	local direction = (target - origin.Position).Unit

	origin = origin.CFrame * CFrame.new(0, 1.5, 0)

	self.remote:FireServer({
		type = "WEAPON_SHOOT",
		payload = {
			target = target,
			origin = origin,
		},
	})

	-- Fire local tracer
	if not self.config.projectileReference and not self.config.useModelAsProjectile then
		return
	end

	if self.config.hitscan then
		local hit = self:raycast(origin, direction)

		self:fireTracer({
			origin = origin,
			direction = direction,
			projectile = WeaponClient.getProjectile({
				handle = self.handle,
				projectileReference = self.config.projectileReference,
				useModelAsProjectile = self.config.useModelAsProjectile,
				modelParts = self.modelParts,
			}),
			projectileWeldToHit = self.config.projectileWeldToHit,
			projectileRotation = self.config.projectileRotation,
			distance = (hit.Position and (origin.Position - hit.Position).Magnitude)
				or self.config.maxRange
				or 2048,
			velocity = self.config.projectileVelocity or 16,
			localTracer = true,
		})
	else
		print("render projectile locally")
		-- render local projectile
	end
end

function methods:raycast(origin, direction, maxRange)
	return Workspace:Raycast(
		origin.Position,
		(direction and direction or origin.LookVector) * (maxRange or 2048),
		self.raycastFilter
	) or {}
end

function WeaponClient.getModelParts(tool)
	return Util.map(tool:GetDescendants(), function(obj)
		return obj:IsA("BasePart") and obj.Name ~= "Handle" and obj or nil
	end)
end

function WeaponClient.getProjectile(data)
	assert(
		data and typeof(data) == "table",
		"WeaponClient.getProjectile - Missing data table"
	)
	assert(data.handle, "WeaponClient.getProjectile - Missing weapon handle")

	local modelParts = data.modelParts
		or (data.tool and WeaponClient.getModelParts(data.tool))
		or {}
	local projectile

	if not data.useModelAsProjectile then
		assert(
			data.projectileReference,
			"WeaponClient.fireTracer - Missing projectile reference"
		)

		projectile = data.projectileReference:Clone()
	elseif #modelParts == 0 then
		projectile = data.handle:Clone()
	else -- Model extras exist
		local handleClone = data.handle:Clone()
		projectile = Instance.new("Model")
		handleClone.Parent = projectile
		projectile.PrimaryPart = handleClone
		projectile.Name = "Projectile"

		for _, part in pairs(modelParts) do
			if part.Parent then
				part:Clone().Parent = projectile
			end
		end

		Util.clearWelds(projectile)
	end

	return projectile
end

function methods:fireTracer(data)
	assert(data and typeof(data) == "table", "WeaponClient.fireTracer - Missing data")

	if not self.localPlayer.Character and data.localTracer then
		return
	end

	data.velocity = data.velocity or 16

	if not data.origin then
		warn("WeaponClient:fireTracer - Missing origin")
		return
	end

	local newTracer = data.localTracer and data.projectile
		or WeaponClient.getProjectile({
			handle = data.handle,
			projectileReference = data.projectileReference,
			useModelAsProjectile = data.useModelAsProjectile,
			-- Avoid unnecessary processing by passing self.modelParts
			modelParts = data.localTracer and self.modelParts or data.modelParts,
		})

	local tracer = newTracer

	if newTracer:IsA("Model") then
		if not newTracer.PrimaryPart then
			warn(
				"WeaponClient.fireTracer - Projectile model must have a PrimaryPart defined"
			)
			return
		end

		tracer = newTracer.PrimaryPart

		Animation.prepareAnimatedModel(newTracer)
	end

	newTracer.Parent = Workspace.CurrentCamera

	self.raycastFilter.FilterDescendantsInstances = Util.extend(
		data.localTracer and { self.localPlayer.Character, newTracer } or { newTracer },
		passthroughObjects:getAll()
	)

	local originPosition = CFrame.lookAt(
		data.origin.Position,
		data.origin.Position + data.direction
	) * CFrame.new(0, 0, -tracer.Size.Z)
	local rotation = data.projectileRotation or CFrame.new()
	local start = os.clock()

	task.spawn(function()
		local prevCFrame = originPosition
		local hit

		repeat
			RunService.Heartbeat:Wait()

			prevCFrame = prevCFrame * CFrame.new(0, 0, -data.velocity)
			tracer.CFrame = prevCFrame * rotation

			local raycastTest = self:raycast(prevCFrame, data.direction, data.velocity)

			if raycastTest.Instance then
				hit = raycastTest
			end
		until hit
			or not tracer.Parent
			or not newTracer.Parent
			or (data.origin.Position - prevCFrame.Position - Vector3.new(
					0,
					0,
					-data.velocity
				)).Magnitude
				> data.distance

		if tracer.Parent and newTracer.Parent then
			if data.projectileWeldToHit and hit then
				tracer.CFrame = CFrame.lookAt(hit.Position, hit.Position + data.direction)
					* rotation -- Place tracer at intersection

				Util.weld(tracer, hit.Instance)

				for _, part in
					pairs(
						newTracer:IsA("Model") and newTracer:GetChildren()
							or { newTracer }
					)
				do
					part.Anchored = false
				end

				Debris:AddItem(
					newTracer,
					(data.projectileLife or 10) - (os.clock() - start) + 0.001
				) -- TODO: Replace with a better debris collector
			else
				newTracer:Destroy()
			end
		end
	end)
end

function WeaponClient.watch()
	local self = setmetatable({}, methods)

	while not Players.LocalPlayer.Character or not Players.LocalPlayer.Character.Parent do
		RunService.RenderStepped:Wait()
	end

	self.watching = true

	self.localPlayer = Players.LocalPlayer

	self.raycastFilter = RaycastParams.new()
	self.raycastFilter.FilterType = Enum.RaycastFilterType.Exclude

	self.remote = Util.waitForChild(ReplicatedStorage, "WeaponRemote")

	self.remote.OnClientEvent:Connect(function(action)
		assert(
			action and typeof(action) == "table",
			"WeaponClient.watch - Missing action"
		)
		assert(action.type, "WeaponClient.watch - Missing action type")
		assert(action.payload, "WeaponClient.watch - Missing action payload")

		if action.type == "WEAPON_TRACER" then
			self:fireTracer(action.payload)
		end
	end)
end

function WeaponClient.new(config)
	assert(config.tool, "Missing tool object from config")
	assert(config.tool:WaitForChild("Handle"), "Missing tool handle")

	local self = setmetatable({}, methods)

	self._connections = {}
	self._inputData = {}
	self._eventHooks = {}
	self._actionBindings = {}

	self.config =
		Util.extend(Util.extend({}, config), Util.makeConfigFromValues(config.tool))

	for k, v in pairs(DEFAULT_CONFIG) do
		if self.config[k] == nil then
			self.config[k] = v
		end
	end

	while
		not Players.LocalPlayer.Character
		or not Players.LocalPlayer.Character.Parent
		or not config.tool:FindFirstChild("Ready")
	do
		RunService.RenderStepped:Wait()
	end

	self.localPlayer = Players.LocalPlayer
	self.localCharacter = self.localPlayer.Character
		or self.localPlayer.CharacterAdded:Wait()
	self.localHumanoid = self.localCharacter:WaitForChild("Humanoid")
	self.remote = config.tool:WaitForChild("Remote")
	self.handle = config.tool.Handle
	self.modelParts = WeaponClient.getModelParts(config.tool)

	self.raycastFilter = RaycastParams.new()
	self.raycastFilter.FilterType = Enum.RaycastFilterType.Exclude

	config.tool.Equipped:Connect(function()
		if not Util.isAlive(self.localHumanoid) then
			return
		end

		mouse = Players.LocalPlayer:GetMouse()

		--[[if mouse then
			self._connections[#self._connections + 1] = mouse.Move:Connect(function()
				if not self.equipped then
					return
				end

				if mouse.Target and mouse.Target.Parent and (
					mouse.Target.Parent:FindFirstChild("Humanoid") or mouse.Target.Parent.Parent:FindFirstChild("Humanoid")
				) then
					self:setCursor(config.cursor.target)
					return
				end

				self:setCursor(config.cursor.normal)
			end)
		end]]

		for _inputName, data in pairs(self._inputData) do
			for eventName, _condition in pairs(data.listeners) do
				-- Start listing to eventName (InputBegan, InputEnded, etc.)
				if not self._connections[eventName] then
					self._connections[eventName] = UserInputService[eventName]:Connect(
						function(...)
							self:_onInputEvent(eventName, ...)
						end
					)
				end
			end
		end

		local actionId = Util.randomString(4)

		self.currentActionId = actionId
		self.equipped = os.clock()

		self:setCursor(config.cursor.normal)

		if Util.isAlive(self.localHumanoid) then
			self:playAnimation(config.state.equip)

			if self.currentActionId == actionId and self.equipped then
				self:playAnimation(config.state.hold, true) -- If there is a hold animation, play and loop it
			end
		end
	end)

	config.tool.Unequipped:Connect(function()
		-- Unhook all connections
		for eventName, connection in pairs(self._connections) do
			if connection.Connected then
				connection:Disconnect()
			end

			self._connections[eventName] = nil
		end

		self.equipped = false

		self:stopAnimation()
		-- maid:clean()

		self:setCursor()
	end)

	config.tool:WaitForChild("Animations")

	-- Load animations
	self.animations = Util.map(config.animations, function(idList, state)
		return state,
			Util.map(idList, function(id)
				return self.localHumanoid:LoadAnimation(
					config.tool.Animations:WaitForChild(id)
				)
			end)
	end)

	self.remote:FireServer({
		type = "WEAPON_INIT",
	})

	self.remote.OnClientEvent:Connect(function(action)
		if typeof(action) ~= "table" or not action.type then
			return
		end

		-- An action was dispatched to the weapon server
		-- remotely,
		if action.type == "WEAPON_REMOTE_ACTION" then
			local actionType = action.payload and action.payload.actionType

			if not actionType then
				return
			end

			local actionId = Util.randomString(4)
			self.currentActionId = actionId

			self:playAnimation(action.payload.actionType)

			if self.currentActionId == actionId and self.equipped then
				self:playAnimation("WEAPON_HOLD", true)
			end
		end
	end)

	return self
end

return WeaponClient


--[[
	@name Weapon System Server
	@description A weapon library for quickly building weapons.
	@author dig1t
	@version 1.0.0
]]

--!nocheck

local Debris = game:GetService("Debris")
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local RunService = game:GetService("RunService")
local Workspace = game:GetService("Workspace")

local Util = require(script.Parent.Parent.Util)
local WeaponClient = require(script.Parent.WeaponClient)
-- local CollectionService = require(script.Parent.Parent.CollectionService)
local CollectionService = {
	watch = function(_) end,
	getAll = function()
		return {}
	end,
} -- TODO: replace when CollectionService util is fixed

-- Amount to subtract from the cooldown since players
-- will always be ready before the server by a small amount
local COOLDOWN_OFFSET = 0.2

local DEFAULT_CONFIG = {
	hitscan = true, -- No projectiles will be rendered
	tracerRound = false,

	damage = 30,
	maxRange = 2048,
	rangeModifier = 0.75,
}

local passthroughObjects = CollectionService.watch("BULLET_PASSTHROUGH")

local WeaponServer, methods = {}, {}
methods.__index = methods

-- globalWeaponRemote calls all clients to replicate
-- weapon projectiles and tracers
local globalWeaponRemote = ReplicatedStorage:FindFirstChild("WeaponRemote")

if not globalWeaponRemote then
	globalWeaponRemote = Instance.new("RemoteEvent")
	globalWeaponRemote.Name = "WeaponRemote"
	globalWeaponRemote.Parent = ReplicatedStorage
end

--[[
-- @param type The type of action to identify the aciton by.
-- @desc Binds an action to the remote listener
so it can later be called by client-side code.
]]
function methods:bind(type, fn)
	assert(typeof(type) == "string", "Weapon:bind - No action type given")
	assert(typeof(fn) == "function", "Weapon:bind - No function given")

	self.events[type] = fn
end

--[[
-- @param actionType The action type to check
-- @desc Checks if an action is in cooldown (example: A gun reloading)
]]
function methods:inCooldown(actionType)
	if not self.lastActions[actionType] then
		return
	end

	return os.clock() - self.lastActions[actionType] <= self.config.cooldown[actionType]
end

--[[
-- @param action The action to process
-- @desc Simplified "red" store. Processes an action. The action must include
an action type for it to successfully processed
]]
function methods:processAction(action)
	if not action.type or not self.events[action.type] or not self:ready(action.type) then
		return
	end

	-- Process mobile button triggers
	if action.source == "MOBILE_CONTROL" then
		self.remote:FireClient(self.localPlayer, {
			type = "WEAPON_REMOTE_ACTION",
			payload = {
				actionType = action.type,
			},
		})
	end

	if action.type == "WEAPON_EQUIP" then
		self.equipped = Util.unix()
	elseif action.type == "WEAPON_UNEQUIP" then
		self.equipped = nil
	end

	if self.config.sfx[action.type] then
		self:playSFX(action.type)
	end

	self.events[action.type](action.payload)

	if self.config.cooldown[action.type] then
		self.lastActions[action.type] = os.clock() - COOLDOWN_OFFSET
	end
end

--[[
-- @param actionType The action type to check
-- @desc Makes sure the action is ready to be further processed
by checking if the player is alive and the weapon is not currently
in a cooldown
]]
function methods:ready(actionType)
	return Util.isAlive(self.localPlayer) and not self:inCooldown(actionType)
end

function methods:playSFX(state)
	local id = Util.tableRandom(self.config.sfx[state])
	self.currentSFX = self.handle:FindFirstChild(id)
		or Util.instance("Sound")({
			Name = id,
			Parent = self.handle,
			SoundId = Util.asset .. id,
			Volume = 0.3,
		})

	self.currentSFX:Play()
end

--[[
-- @param player The target player
-- @desc Returns the player's team color, if available
-- @return Color3|nil
]]
function methods:getTeam(player)
	return player
		and player.Character
		and player.Character:FindFirstChild("Team")
		and player.Character.Team.Value
end

--[[
-- @desc Tags the player with a kill tag of the local
player's UserId to later track who they were killed by
]]
function methods:tag(player)
	if not player.Character then
		return
	end

	player.Character:SetAttribute("attr_kill_tag", self.localPlayer.UserId)
end

--[[
-- @param victim The player to damage
-- @param amount Amount to damage player
-- @desc Damages a player. If friendly fire is disabled, it will check
to make sure they are not damaging teammates. If successfully damaged
the function will return true to let the caller know the victim was damaged
-- @return true | nil
]]
function methods:damagePlayer(victim, amount)
	if not victim or victim == self.localPlayer then
		return
	elseif not self.config.friendlyFire then
		local victimTeamColor = self:getTeam(victim)
		local localPlayerTeamColor = self:getTeam(self.localPlayer)

		-- Make sure the local player and the victim are both
		-- in teams before checking to see if they're teammates
		if
			victimTeamColor
			and localPlayerTeamColor
			and victimTeamColor == localPlayerTeamColor
		then
			-- Cannot damage teammates
			return
		end
	end

	local humanoid = Util.getHumanoid(victim)

	if humanoid then
		humanoid:TakeDamage(amount)
		self:tag(victim)

		return true
	end

	return false
end

--[[
-- @param object The projectile reference to save. This object must be a descendant of ReplicatedStorage
-- @desc Saves a reference of the projectile that will be later shot
]]
function methods:setProjectileReference(object)
	assert(object, "WeaponServer:setProjectileReference - Missing reference object")
	if typeof(object) == "Instance" then
		assert(
			object:IsDescendantOf(ReplicatedStorage),
			"WeaponServer:setProjectileReference - Object must be a descendant of ReplicatedStorage"
		)
	end

	if typeof(object) == "function" then
		object = object()
	end

	self.projectileReference = object
end

function methods:createRemoteConfig(name, value)
	Util.set(self.config.tool, name, value)
end

function methods:addMobileControlButton(name)
	if not self.config.tool or not self.config.tool.Parent then
		return
	end

	local controlContainer = self.config.tool:FindFirstChild("MobileControls")

	if not controlContainer then
		controlContainer = Instance.new("Configuration")
		controlContainer.Name = "MobileControls"
		controlContainer.Parent = self.config.tool
	end

	if controlContainer:FindFirstChild(name) then
		-- No need to add another value
		return
	end

	local control = Instance.new("StringValue")
	control.Name = name
	-- control.Value = value
	control.Parent = controlContainer

	return control
end

-- Begin projectile functions
function methods:computeDirection(vec) -- From old roblox rocket launcher
	local invSqrt = 1 / math.sqrt(vec.magnitude * vec.magnitude)
	return Vector3.new(vec.x * invSqrt, vec.y * invSqrt, vec.z * invSqrt)
end

function methods:calcInitialProjectilePosition(target)
	if not self.handle or not self.handle.Parent then
		return CFrame.new()
	end

	target = target or (self.handle.CFrame * CFrame.new(0, 0, -2)).p

	local rootPos = self.handle.Position
	local direction = self:computeDirection(target - rootPos)
	local pos = rootPos + (direction * 2)

	return CFrame.new(pos, pos + direction)
end

function methods:raycast(origin, direction)
	return Workspace:Raycast(
		origin.Position,
		(direction and direction or origin.LookVector) * self.config.maxRange,
		self.raycastFilter
	) or {}
end

function methods:renderProjectile() end

-- ProjectilePassThrough
function methods:shoot(data)
	assert(data and typeof(data) == "table", "WeaponServer:shoot - Missing data table")
	assert(data.callback, "WeaponServer:shoot - Missing callback")

	data.origin = (
		self.localPlayer.Character
		and self.localPlayer.Character.PrimaryPart
		and self.localPlayer.Character.PrimaryPart.CFrame
	)

	if not data.origin then
		return
	end

	local start = os.clock() -- When the projectile first started shooting

	if self.config.hitscan then
		assert(data.target, "WeaponServer:shoot - Missing target")

		self.raycastFilter.FilterDescendantsInstances = Util.extend(
			{ self.localPlayer.Character },
			-- Gather passthrough objects so raycast does not detect them
			passthroughObjects:getAll()
		)

		local direction = data.direction or (data.target - data.origin.Position).Unit

		local hit = self:raycast(data.origin, direction)

		if self.config.tracerRound and self.config.tool:IsDescendantOf(Workspace) then
			for _, player in Players:GetPlayers() do
				if player ~= self.localPlayer then
					globalWeaponRemote:FireClient(player, {
						type = "WEAPON_TRACER",
						payload = {
							origin = data.origin,
							direction = direction,
							handle = self.handle,
							projectileReference = self.projectileReference,
							useModelAsProjectile = self.config.useModelAsProjectile,
							--tool = self.config.tool,
							modelParts = self.modelParts,
							projectileWeldToHit = self.config.projectileWeldToHit,
							projectileRotation = self.config.projectileRotation,
							distance = hit.Position
									and (data.origin.Position - hit.Position).Magnitude
								or self.config.maxRange,
							velocity = self.config.projectileVelocity or 16,
						},
					})
				end
			end
		end

		if hit.Instance then
			data.callback(hit)
		end
	else
		-- Initial variables
		local velocity = self.config.projectileVelocity or 4
		local bulletDrop = self.config.projectileDrop

		-- The slowest velocity the bullet can travel (max 2)
		local minVelocity = velocity > 2 and 2 or velocity

		local projectile = data.projectile or self.projectileReference:Clone()

		if data.projectile:IsA("Model") then
			assert(
				data.projectile.PrimaryPart,
				"Projectile model must have a PrimaryPart defined"
			)

			projectile = data.projectile.PrimaryPart
		end

		projectile.Anchored = true
		projectile.CanCollide = false

		-- If the weapon server controller has not set the parent,
		-- then parent the projectile to Workspace
		if not data.projectile.Parent then
			data.projectile.Parent = Workspace
		end

		Debris:AddItem(data.projectile, self.config.projectileLife or 16)

		local prevCFrame = projectile.CFrame

		-- Loop in a separate thread
		task.spawn(function()
			repeat
				local direction = prevCFrame.LookVector
				local position = prevCFrame.Position
					+ (direction * velocity)
					- Vector3.new(0, bulletDrop, 0)
				local distance = (position - prevCFrame.Position).magnitude

				local hit = self:raycast(
					prevCFrame,
					(position - prevCFrame.Position).unit * velocity
				)

				local nextCFrame = CFrame.new(prevCFrame.Position, position)
					* CFrame.new(0, 0, -distance / 2)

				prevCFrame = nextCFrame
				projectile.CFrame = data.modifier and data.modifier(nextCFrame)
					or nextCFrame

				if hit.Instance then
					data.callback(hit)
					break
				end

				-- Set variables for next frame
				velocity = velocity > minVelocity and velocity - 0.01 or 2
				bulletDrop = self.config.projectileDrop
						and bulletDrop > 0
						and bulletDrop + (self.config.projectileDrop or 0.002)
					or 0

				RunService.Heartbeat:Wait()
			until not data.projectile.Parent
				or not projectile.Parent
				or os.clock() - start > (self.config.projectileLife or 8)

			-- An instance where this would be set could be where the projectile
			-- sticks to a part such as a sticky grenade sticking to a wall
			if not self.config.projectileKeepAlive then
				data.projectile:Destroy()
			end
		end)
	end
end
-- End projectile functions

function methods:handleTouched(fn)
	assert(fn, "Weapon:handleTouched - No callback function provided")

	if self.connections.handleListener then
		self.connection.handleListener:Disconnect()
	end

	self.connections.handleListener = self.handle.Touched:Connect(fn)
end

function methods:destroy()
	for _, connection in pairs(self.connections) do
		connection:Disconnect()
	end

	if self.handle and self.handle.Parent then
		self.handle:Destroy()
	end

	self.localPlayer = nil
	self.sfx = nil
	self.events = nil
	self.connections = nil
	self.lastActions = {}
end

function WeaponServer.new(config)
	assert(
		config.tool and typeof(config.tool) == "Instance" and config.tool:IsA("Tool"),
		"WeaponServer.new - Missing tool object from config"
	)

	local self = setmetatable({}, methods)

	self.config = Util.extend(
		Util.extend({}, config),
		Util.makeConfigFromValues(config.tool) -- Values inside the tool will override variables in the shared config
	)

	for k, v in pairs(DEFAULT_CONFIG) do
		if self.config[k] == nil then
			self.config[k] = v
		end
	end

	if self.config.projectileReference then
		self:setProjectileReference(self.config.projectileReference)
	end

	self.handle = config.tool:FindFirstChild("Handle")
	self.modelParts = WeaponClient.getModelParts(config.tool)

	if not self.handle then
		local toolModel = config.tool:FindFirstChildOfClass("Model")

		if toolModel then
			self.handle = toolModel:FindFirstChild("Handle")
			self.handle.Parent = config.tool

			assert(self.handle, "WeaponServer.new - Tool model must have a handle")

			self.modelParts = Util.map(toolModel:GetDescendants(), function(obj)
				if obj:IsA("BasePart") then
					Util.weld(obj, self.handle) -- Weld the part to the handle

					obj.Anchored = false
					obj.CanCollide = false

					return obj
				end

				return nil
			end)
		end
	end

	assert(self.handle, "WeaponServer.new - Missing tool handle")

	self.handle.Anchored = false
	self.handle.CanCollide = false

	self.sfx = {}
	self.events = {}
	self.connections = {}
	self.lastActions = {}

	self.remote = Instance.new("RemoteEvent")
	self.remote.Name = "Remote"
	self.remote.Parent = config.tool

	self.raycastFilter = RaycastParams.new()
	self.raycastFilter.FilterType = Enum.RaycastFilterType.Exclude

	-- Weld extra parts to the handle
	--[[for _, obj in config.tool:GetDescendants() do
		if obj:IsA("BasePart") and obj ~= self.handle then
			Util.weld(obj, self.handle)
			obj.Parent = self.handle
		end
	end]]

	-- Begin weapon configuration
	if self.config.grip then
		local x, y, z, R00, R01, R02, R10, R11, R12, R20, R21, R22 =
			self.config.grip:GetComponents()

		self.config.tool.GripPos = Vector3.new(x, y, z)
		self.config.tool.GripRight = Vector3.new(R00, R10, R20)
		self.config.tool.GripUp = Vector3.new(R01, R11, R21)
		self.config.tool.GripForward = Vector3.new(R02, R12, R22)
	end
	-- End weapon configuration

	local animations = Instance.new("Folder")
	animations.Name = "Animations"

	Util.map(self.config.animations, function(idList, state)
		return state,
			Util.map(idList, function(id)
				Util.instance("Animation")({
					Name = id,
					AnimationId = Util.asset .. id,
					Parent = animations,
				})
			end)
	end)

	animations.Parent = self.config.tool

	-- Start listening for dispatched actions from the remote
	self.connections.remote = self.remote.OnServerEvent:Connect(function(player, action)
		assert(typeof(action) == "table", "Weapon action must be a table")

		if not self.localPlayer then
			self.localPlayer = player
			self.localHumanoid = Util.getHumanoid(player)
		end

		if self.localPlayer ~= player then
			-- A different player attempted to
			-- take control of the owner's weapon
			return player:Kick()
		elseif not Util.isAlive(self.localPlayer) then
			return
		end

		self:processAction(action)

		return nil
	end)

	task.spawn(function()
		repeat
			RunService.Heartbeat:Wait()
		until self.localPlayer and self.localHumanoid

		local function detectLocation()
			-- Mock action as if it came from the client
			-- This will prevent equip/unequip action spam
			self:processAction({
				type = config.tool:IsDescendantOf(self.localPlayer) and "WEAPON_UNEQUIP"
					or "WEAPON_EQUIP",
			})
		end

		detectLocation()
		self.connections.equipListener =
			config.tool:GetPropertyChangedSignal("Parent"):Connect(detectLocation)
	end)

	Util.set(config.tool, "Ready", true) -- TODO: Replace with config.tool:setAttribute("Ready", true)

	return self
end

return WeaponServer


-- WIP module

local RunService = game:GetService("RunService")

if RunService:IsServer() then
	return require(script.WeaponServer)
else
	return require(script.WeaponClient)
end


declare function afterAll(callback: (context: { [any]: any }) -> ()): ()
declare function afterEach(callback: (context: { [any]: any }) -> ()): ()

declare function beforeAll(callback: (context: { [any]: any }) -> ()): ()
declare function beforeEach(callback: (context: { [any]: any }) -> ()): ()

declare function describe(phrase: string, callback: (context: { [any]: any }) -> ()): ()
declare function describeFOCUS(phrase: string, callback: (context: { [any]: any }) -> ()): ()
declare function fdescribe(phrase: string, callback: (context: { [any]: any }) -> ()): ()
declare function describeSKIP(phrase: string, callback: (context: { [any]: any }) -> ()): ()
declare function xdescribe(phrase: string, callback: (context: { [any]: any }) -> ()): ()

declare function expect(value: any): any

declare function FIXME(optionalMessage: string?): ()
declare function FOCUS(): ()
declare function SKIP(): ()

declare function it(phrase: string, callback: (context: { [any]: any }) -> ()): ()
declare function itFOCUS(phrase: string, callback: (context: { [any]: any }) -> ()): ()
declare function fit(phrase: string, callback: (context: { [any]: any }) -> ()): ()
declare function itSKIP(phrase: string, callback: (context: { [any]: any }) -> ()): ()
declare function xit(phrase: string, callback: (context: { [any]: any }) -> ()): ()
declare function itFIXME(phrase: string, callback: (context: { [any]: any }) -> ()): ()


--!strict
local CollectionService = game:GetService("CollectionService")

local Maid = require(script.Parent.Parent.Maid)
local Signal = require(script.Parent.Parent.Signal)

--[[
	@class CollectionObject
	Wrapper for CollectionService instances found using CollectionWatcher.
	Provides a `Destroyed` signal that fires when the object is removed.
]]
local CollectionObject = {}
CollectionObject.__index = CollectionObject

export type CollectionObject<T> = typeof(setmetatable(
	{} :: {
		context: T,
		Destroyed: Signal.Signal<nil>,
	},
	CollectionObject
))

local function newCollectionObject<T>(instance: T): CollectionObject<T>
	return setmetatable({
		context = instance,
		Destroyed = Signal.new(),
	}, CollectionObject) :: CollectionObject<T>
end

function CollectionObject._destroy<T>(self: CollectionObject<T>)
	self.Destroyed:Fire()
end

--[[
	@class CollectionWatcher
	Watches for instances with a specific CollectionService tag.

	Example:
	```lua
	local killPart = CollectionWatcher.new("KillPart")

	killPart:RegisterHandler(function(object)
		object.context.Touched:Connect(function(part)
			-- Handle touch
		end)

		object.Destroyed:Once(function()
			-- Cleanup
		end)
	end)
	```
]]
local CollectionWatcher = {}
CollectionWatcher.__index = CollectionWatcher

local TAG_PREFIX = "attr_"

export type CollectionWatcher<T> = typeof(setmetatable(
	{} :: {
		_maid: Maid.Maid,
		_instances: { [T]: CollectionObject<T> },
		Added: Signal.Signal<CollectionObject<T>>,
	},
	CollectionWatcher
))

--[[
	Creates a new CollectionWatcher for the given tag.
	@param tag string -- The CollectionService tag to watch
	@return CollectionWatcher
]]
function CollectionWatcher.new<T>(tag: string): CollectionWatcher<T>
	tag = TAG_PREFIX .. tag -- Prepend the tag prefix

	local self = setmetatable({
		_maid = Maid.new(),
		_instances = {},
		Added = Signal.new(),
	}, CollectionWatcher) :: CollectionWatcher<T>

	local function handleNewObject(obj: T)
		local newObject: CollectionObject<T> = newCollectionObject(obj)
		self._instances[obj] = newObject
		self.Added:Fire(newObject)
	end

	for _, obj in CollectionService:GetTagged(tag) do
		handleNewObject(obj)
	end

	self._maid:Add(CollectionService:GetInstanceAddedSignal(tag):Connect(handleNewObject))
	self._maid:Add(
		CollectionService:GetInstanceRemovedSignal(tag):Connect(function(obj: T)
			if self._instances[obj] then
				self._instances[obj]:_destroy()
				self._instances[obj] = nil
			end
		end)
	)

	return self
end

--[[
	Returns all CollectionObjects in the collection.
	@return { CollectionObject }
]]
function CollectionWatcher.GetAll<T>(self: CollectionWatcher<T>): { CollectionObject<T> }
	local result: { CollectionObject<T> } = {}

	for _, object in pairs(self._instances) do
		table.insert(result, object)
	end

	return result
end

--[[
	Fires the callback for all existing objects and connects it to the Added signal.
	@param callback (object: CollectionObject<T>) -> ()
	@return RBXScriptConnection
]]
function CollectionWatcher.RegisterHandler<T>(
	self: CollectionWatcher<T>,
	callback: (object: CollectionObject<T>) -> ()
)
	for _, object in pairs(self._instances) do
		callback(object)
	end

	return self.Added:Connect(callback)
end

--[[
	Disconnects all signals and stops listening for new objects.
]]
function CollectionWatcher.Destroy<T>(self: CollectionWatcher<T>)
	self._maid:Clean()
end

return CollectionWatcher


--!strict
--[[
	@class Types
	Shared type definitions for Attrify watchers.
]]

local CollectionWatcher = require(script.Parent.Modules.CollectionWatcher)

export type CollectionWatcher<T> = CollectionWatcher.CollectionWatcher<T>
export type CollectionObject<T> = CollectionWatcher.CollectionObject<T>

export type WatcherModule = {
	watcher: CollectionWatcher<any>,
	tag: string,
}

-- Checkpoint data for ProfileDB persistence
export type CheckpointData = {
	current: number,
	highest: number,
	firstJoinAt: number,
	lastCheckpointAt: number,
}

-- Checkpoint metadata
export type CheckpointInfo = {
	number: number,
	part: BasePart,
	spawnCFrame: CFrame?,
}

-- Player checkpoint state snapshot
export type PlayerCheckpointState = {
	currentCheckpoint: number,
	highestCheckpoint: number,
	totalCheckpoints: number,
}

return nil


--!strict
--[[
	@class Bobber
	Floats an object up and down continuously.

	Attributes:
	- attr_bob_height: number (default: 2) - Maximum bob distance in studs
	- attr_bob_speed: number (default: 1) - Cycles per second
]]

local RunService = game:GetService("RunService")

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "bobber"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_BOB_HEIGHT = 2
local DEFAULT_BOB_SPEED = 1

local Bobber: CollectionWatcher.CollectionWatcher<Model | BasePart> = CollectionWatcher.new(COLLECTION_TAG)

Bobber:RegisterHandler(function(object: CollectionWatcher.CollectionObject<Model | BasePart>)
	if not IS_CLIENT then
		return
	end

	local bobHeight = object.context:GetAttribute("attr_bob_height") :: number? or DEFAULT_BOB_HEIGHT
	local bobSpeed = object.context:GetAttribute("attr_bob_speed") :: number? or DEFAULT_BOB_SPEED

	local originalPivot = object.context:GetPivot()
	local elapsedTime = 0

	local connection = RunService.Heartbeat:Connect(function(deltaTime)
		if not object.context or not object.context.Parent then
			return
		end

		elapsedTime += deltaTime

		local bobOffset = math.sin(elapsedTime * bobSpeed * math.pi * 2) * bobHeight
		object.context:PivotTo(originalPivot + Vector3.new(0, bobOffset, 0))
	end)

	object.Destroyed:Once(function()
		if connection.Connected then
			connection:Disconnect()
		end
	end)
end)

return Bobber


--!strict
--[[
	@class Elevator
	Multi-floor elevator that moves between stops.

	Requires:
	- Stop1, Stop2, Stop3... (Attachments) - Floor positions

	Attributes:
	- attr_move_time: number (default: 2) - Time to move between floors
	- attr_wait_time: number (default: 3) - Time to wait at each floor
]]

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

local Attrify = script.Parent.Parent.Parent

local CollectionWatcher = require(Attrify.Modules.CollectionWatcher)

local COLLECTION_TAG = "elevator"
local IS_CLIENT = RunService:IsClient()
local DEFAULT_MOVE_TIME = 2
local DEFAULT_WAIT_TIME = 3

local Elevator: CollectionWatcher.CollectionWatcher<Model | BasePart> = CollectionWatcher.new(COLLECTION_TAG)

local function getAlpha(progress: number)
	return TweenService:GetValue(progress, Enum.EasingStyle.Quad, Enum.EasingDirection.InOut)
end

Elevator:RegisterHandler(function(object: CollectionWatcher.CollectionObject<Model | BasePart>)
	if not IS_CLIENT then
		return
	end

	local moveTime = object.context:GetAttribute("attr_move_time") :: number? or DEFAULT_MOVE_TIME
	local waitTime = object.context:GetAttribute("attr_wait_time") :: number? or DEFAULT_WAIT_TIME

	local stops: { CFrame } = {}

	for i = 1, 20 do
		local stopName = `Stop{i}`
		for _, child in object.context:GetChildren() do
			if child:IsA("Attachment") and child.Name == stopName then
				table.insert(stops, child.WorldCFrame)
				break
			end
		end
	end

	if #stops < 2 then
		warn("Elevator requires at least Stop1 and Stop2 attachments")
		return
	end

	object.context:PivotTo(stops[1])

	local currentStopIndex = 1
	local nextStopIndex = 2
	local direction = 1
	local cycleTime = 0
	local isMoving = false
	local totalCycleTime = moveTime + waitTime

	local connection = RunService.Heartbeat:Connect(function(deltaTime)
		if not object.context or not object.context.Parent then
			return
		end

		cycleTime += deltaTime

		if cycleTime >= totalCycleTime then
			cycleTime = 0

			currentStopIndex = nextStopIndex
			nextStopIndex = currentStopIndex + direction

			if nextStopIndex > #stops then
				direction = -1
				nextStopIndex = currentStopIndex + direction
			elseif nextStopIndex < 1 then
				direction = 1
				nextStopIndex = currentStopIndex + direction
			end

			isMoving = true
		end

		if isMoving then
			local progress = cycleTime / moveTime
			if progress >= 1 then
				object.context:PivotTo(stops[nextStopIndex])
				isMoving = false
			else
				local alpha = getAlpha(progress)
				object.context:PivotTo(stops[currentStopIndex]:Lerp(stops[nextStopIndex], alpha))
			end
		end
	end)

	object.Destroyed:Once(function()
		if connection.Connected then
			connection:Disconnect()
		end
	end)
end)

return Elevator