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

local RunService = game:GetService('RunService')

local Config = require(script.Parent.Parent.Config)
local Types = require(script.Parent.Parent.Types)
local Geometry = require(script.Parent.Parent.Utils.Geometry)
local LinearBVH = require(script.Parent.Parent.Utils.LinearBVH)
local Log = require(script.Parent.Parent.Utils.Log)
local State = require(script.Parent.State)

local queryPoint = LinearBVH.queryPoint
local isPointInShape = Geometry.isPointInShape

local staticCFrames = State.staticCFrames
local staticHalfSizes = State.staticHalfSizes
local staticTypes = State.staticTypes
local dynamicCFrames = State.dynamicCFrames
local dynamicHalfSizes = State.dynamicHalfSizes
local dynamicTypes = State.dynamicTypes
local zoneIdToZoneObj = State.zoneIdToZoneObj
local zoneAttachedObservers = State.zoneAttachedObservers
local autoSyncZones = State.autoSyncZones

local observerPriorityMap = State.observerPriorityMap
local observerTrackingEntities = State.observerTrackingEntities
local observerEnteredCallbacks = State.observerEnteredCallbacks
local observerExitedCallbacks = State.observerExitedCallbacks
local observerTransitionedCallbacks = State.observerTransitionedCallbacks
local observerEnabled = State.observerEnabled
local observerSafety = State.observerSafety
local observerPrecisionSq = State.observerPrecisionSq
local observerUpdateRate = State.observerUpdateRate
local observerStaticCount = State.observerStaticCount
local observerDynamicCount = State.observerDynamicCount

local entityToReference = State.entityToReference
local entityData = State.entityData
local entityToObservers = State.entityToObservers
local entityToGroups = State.entityToGroups
local groupToObservers = State.groupToObservers
local dirtyProfiles = State.dirtyProfiles
local dirtyTopology = State.dirtyTopology

local staticTree = State.staticTree
local dynamicTree = State.dynamicTree

local ctx_pos: Vector3
local ctx_cCount: number = 0
local ctx_highestWin: number
local ctx_candidatePriority: { [number]: number } = table.create(16)
local ctx_candidateIndex: { [number]: number } = table.create(16)
local ctx_candidateList: { [number]: number } = table.create(16)
local ctx_linkedObservers: { [number]: boolean }
local ctx_currentMemberships: { [number]: number }

local frameBudget = Config.Scheduler.frameBudget
local autoSyncRate = Config.Scheduler.autoSyncRate
local autoSyncTimer = 0
local autoUpdateConnection: RBXScriptConnection?

local bucketList = State.bucketList
local buckets = State.buckets
local nextBucketIndex = 1 -- Round-robin pointer
local bucketLastProcessedIndex = {} :: { [number]: number }
local BUDGET_CHECK = 32

local STRAT_POS = Config.Strategy.POS
local STRAT_PRIM = Config.Strategy.PRIM
local STRAT_WORLD = Config.Strategy.WORLD
local STRAT_CFRAME = Config.Strategy.CFRAME
local STRAT_TRANSFORM = Config.Strategy.TRANSFORM
local STRAT_PIVOT = Config.Strategy.PIVOT

local function processProfileChange(entity: Types.Entity)
	local groups = entityToGroups[entity]
	if not groups then
		return
	end

	-- Track linked observers for quick lookup in hot loop
	local linkedObservers = entityToObservers[entity]
	if linkedObservers then
		table.clear(linkedObservers)
	else
		linkedObservers = {}
	end

	local minPrecSq = math.huge
	local maxRate = 0

	-- Find the highest settings across all subscribed observers
	for groupId, _ in groups do
		local observers = groupToObservers[groupId]
		if not observers then
			continue
		end

		for observerId, _ in observers do
			linkedObservers[observerId] = true

			local prec = observerPrecisionSq[observerId]
			local rate = observerUpdateRate[observerId]

			if prec < minPrecSq then
				minPrecSq = prec
			end
			if rate > maxRate then
				maxRate = rate
			end
		end
	end

	local data = entityData[entity]
	local currentRate = data.updateRate

	-- Swap buckets
	if currentRate ~= maxRate then
		-- Remove from old bucket
		if currentRate and currentRate > 0 and buckets[currentRate] then
			local oldBucket = buckets[currentRate]
			local idx = data.bucketIndex
			local maxEntities = #oldBucket
			local lastEntity = oldBucket[maxEntities]

			-- We swap and then pop to avoid shifting all entities down
			if idx ~= maxEntities then
				oldBucket[idx] = lastEntity
				if entityData[lastEntity] then
					entityData[lastEntity].bucketIndex = idx
				end
			end

			oldBucket[maxEntities] = nil

			-- If the bucket is now empty, remove it and update the bucket list
			if #oldBucket == 0 then
				buckets[currentRate] = nil
				local listIdx = table.find(bucketList, currentRate)
				if listIdx then
					table.remove(bucketList, listIdx)
				end
			end
		end

		-- Add to new bucket
		if maxRate > 0 then
			if not buckets[maxRate] then
				buckets[maxRate] = {}
				table.insert(bucketList, maxRate)
				table.sort(bucketList, function(a, b)
					return a > b
				end)
			end

			local newBucket = buckets[maxRate]
			local newIdx = #newBucket + 1
			newBucket[newIdx] = entity
			data.bucketIndex = newIdx
		else
			data.bucketIndex = 0 -- Entity is not in a bucket, so no processing
		end

		data.updateRate = maxRate
	end

	if maxRate > 0 then
		data.precisionSq = minPrecSq
		entityToObservers[entity] = linkedObservers
	else
		entityToObservers[entity] = nil
	end
end

local function processTopologyChange(entity: Types.Entity)
	local data = entityData[entity]
	if not data then
		return
	end

	local needsStatic = false
	local needsDynamic = false
	local linkedObservers = entityToObservers[entity]

	if linkedObservers then
		for observerId, _ in linkedObservers do
			if (observerStaticCount[observerId] or 0) > 0 then
				needsStatic = true
			end
			if (observerDynamicCount[observerId] or 0) > 0 then
				needsDynamic = true
			end
			if needsStatic and needsDynamic then
				break
			end
		end
	end

	data.needsStatic = needsStatic
	data.needsDynamic = needsDynamic
end

local function processZoneHit(zoneId: number)
	local observers = zoneAttachedObservers[zoneId]
	if not observers then
		return
	end

	for _, observerId in observers do
		if ctx_linkedObservers[observerId] and observerEnabled[observerId] ~= false then
			local currentPriority = ctx_candidatePriority[observerId]
			local priority = observerPriorityMap[observerId] or 0

			if not currentPriority then -- First hit
				ctx_cCount += 1
				ctx_candidateList[ctx_cCount] = observerId
				ctx_candidateIndex[observerId] = zoneId
				ctx_candidatePriority[observerId] = priority

				if priority > ctx_highestWin then
					ctx_highestWin = priority
				end
			elseif priority > currentPriority then -- Override previous hits
				ctx_candidateIndex[observerId] = zoneId
				ctx_candidatePriority[observerId] = priority

				if priority > ctx_highestWin then
					ctx_highestWin = priority
				end
			elseif priority == currentPriority and ctx_currentMemberships[observerId] == zoneId then -- Sticky behavior
				ctx_candidateIndex[observerId] = zoneId
			end
		end
	end
end

local function queryCallbackStatic(zoneId: number)
	if isPointInShape(ctx_pos, staticCFrames[zoneId], staticHalfSizes[zoneId], staticTypes[zoneId]) then
		processZoneHit(zoneId)
	end
end

local function queryCallbackDynamic(zoneId: number)
	if isPointInShape(ctx_pos, dynamicCFrames[zoneId], dynamicHalfSizes[zoneId], dynamicTypes[zoneId]) then
		processZoneHit(zoneId)
	end
end

local function fireCallback(
	callbacks: { (any, Types.Zone, Types.Entity) -> () },
	reference: any,
	zone: Types.Zone,
	entity: any,
	safety: boolean
)
	if safety then
		for _, callback in callbacks do
			task.spawn(callback, reference, zone, entity)
		end
	else
		for _, callback in callbacks do
			callback(reference, zone, entity)
		end
	end
end

local Scheduler = {}

function Scheduler.update(dt: number)
	local start = os.clock()

	-- Route entities that have changed groups or observers since the last update
	for entity, _ in dirtyProfiles do
		dirtyProfiles[entity] = nil
		if entityData[entity] then
			processProfileChange(entity)
		end
	end

	-- Route entities of which the topology changed
	-- Must be after profile change
	for entity, _ in dirtyTopology do
		dirtyTopology[entity] = nil
		if entityData[entity] then
			processTopologyChange(entity)
		end
	end

	-- Auto-sync zone positions if enabled
	if autoSyncRate > 0 then
		local syncInterval = 1 / autoSyncRate
		autoSyncTimer += dt

		if autoSyncTimer >= syncInterval then
			autoSyncTimer = 0
			local needsRebuild = false

			for id, zone in autoSyncZones do
				local reference = zone.reference
				if not reference then
					continue
				end

				local newCF = reference:IsA('Attachment') and reference.WorldCFrame or reference.CFrame
				local oldCF = dynamicCFrames[id]

				if oldCF == newCF then
					continue
				end

				dynamicCFrames[id] = newCF
				needsRebuild = true
			end

			if needsRebuild then
				State.pendingDynamicRebuild = true
			end
		end
	end

	Scheduler.rebuildTrees()

	if (os.clock() - start) > frameBudget then
		return
	end

	local numBuckets = #bucketList
	if numBuckets == 0 then
		return
	end

	-- Pointer may have gotten out of bounds
	if nextBucketIndex > numBuckets then
		nextBucketIndex = 1
	end

	local dynamicVersion = State.dynamicVersion
	local staticVersion = State.staticVersion
	local logicVersion = State.logicVersion

	-- Round-robin scheduling to prevent starvation
	for _ = 1, numBuckets do
		local rate = bucketList[nextBucketIndex]
		nextBucketIndex = (nextBucketIndex % numBuckets) + 1

		local list = buckets[rate]
		local totalEntities = #list
		if totalEntities == 0 then
			continue
		end

		local rawQuota = math.ceil(totalEntities * rate * dt)
		local workQuota = math.clamp(rawQuota, 1, totalEntities)

		local processed = 0
		local idx = bucketLastProcessedIndex[rate] or 1
		if idx > totalEntities then
			idx = 1
		end

		local budgetCheckCountdown = BUDGET_CHECK

		while processed < workQuota do
			local entity = list[idx]
			local data = entityData[entity] :: Types.EntityData

			-- Strategy
			local position
			local strategy = data.strategy
			if strategy == STRAT_POS then
				position = (entity :: BasePart).Position
			elseif strategy == STRAT_WORLD then
				position = (entity :: Attachment).WorldPosition
			elseif strategy == STRAT_CFRAME then
				position = (entity :: BasePart).CFrame.Position
			elseif strategy == STRAT_TRANSFORM then
				position = (entity :: any).Transform.Position
			elseif strategy == STRAT_PRIM then
				local prim = (entity :: Model).PrimaryPart
				position = prim and prim.Position or (entity :: Model):GetPivot().Position
			elseif strategy == STRAT_PIVOT then
				position = (entity :: any):GetPivot().Position
			end

			local needsStatic = data.needsStatic
			local needsDynamic = data.needsDynamic

			local shouldCheck = (data.logicVersion ~= logicVersion)
				or (needsDynamic and data.dynamicVersion ~= dynamicVersion)
				or (needsStatic and data.staticVersion ~= staticVersion)

			if not shouldCheck then
				local lastPosition = data.lastPosition
				local dx = position.X - lastPosition.X
				local dy = position.Y - lastPosition.Y
				local dz = position.Z - lastPosition.Z
				if (dx * dx + dy * dy + dz * dz) >= data.precisionSq then
					shouldCheck = true
				end
			end

			if shouldCheck then
				data.lastPosition = position
				data.dynamicVersion = dynamicVersion
				data.logicVersion = logicVersion
				data.staticVersion = staticVersion

				ctx_linkedObservers = entityToObservers[entity]
				ctx_currentMemberships = data.activeObserverMemberships

				ctx_pos = position
				ctx_cCount = 0
				ctx_highestWin = -math.huge

				if needsDynamic then
					queryPoint(dynamicTree, position, queryCallbackDynamic)
				end

				if needsStatic then
					queryPoint(staticTree, position, queryCallbackStatic)
				end

				local current = data.activeObserverMemberships

				-- Exits
				for observerId, oldZoneId in current do
					local newPri = ctx_candidatePriority[observerId]
					if newPri and newPri >= ctx_highestWin then
						continue
					end

					current[observerId] = nil
					if observerTrackingEntities[observerId] then
						observerTrackingEntities[observerId][entity] = nil
					end

					local cbs = observerExitedCallbacks[observerId]
					if not cbs then
						continue
					end

					local safety = observerSafety[observerId]
					local zone = zoneIdToZoneObj[oldZoneId]
					local reference = entityToReference[entity] or entity
					fireCallback(cbs, reference, zone, entity, safety)
				end

				-- Entries & Transitions
				for i = 1, ctx_cCount do
					local observerId = ctx_candidateList[i]
					local priority = ctx_candidatePriority[observerId]
					local zoneIdx = ctx_candidateIndex[observerId]

					ctx_candidatePriority[observerId] = nil
					ctx_candidateIndex[observerId] = nil
					ctx_candidateList[i] = nil

					if priority < ctx_highestWin then
						continue
					end

					local currentZoneId = current[observerId]
					if currentZoneId == zoneIdx then
						continue
					end

					-- Update the tracker.
					current[observerId] = zoneIdx
					local safety = observerSafety[observerId]

					if not currentZoneId then -- New entry
						observerTrackingEntities[observerId][entity] = true

						local cbs = observerEnteredCallbacks[observerId]
						if not cbs then
							continue
						end

						local zone = zoneIdToZoneObj[zoneIdx]
						local reference = entityToReference[entity] or entity
						fireCallback(cbs, reference, zone, entity, safety)
					else -- Transition
						local cbs = observerTransitionedCallbacks[observerId]
						if not cbs then
							continue
						end

						local zone = zoneIdToZoneObj[zoneIdx]
						local reference = entityToReference[entity] or entity
						fireCallback(cbs, reference, zone, entity, safety)
					end
				end
			end

			idx += 1
			if idx > totalEntities then
				idx = 1
			end
			processed += 1

			-- Global budget check
			budgetCheckCountdown -= 1
			if budgetCheckCountdown == 0 then
				budgetCheckCountdown = BUDGET_CHECK
				if (os.clock() - start) > frameBudget then
					break
				end
			end
		end

		bucketLastProcessedIndex[rate] = idx

		if (os.clock() - start) > frameBudget then
			break
		end
	end
end

function Scheduler.setEnabled(enabled: boolean)
	if enabled then
		if not autoUpdateConnection then
			autoUpdateConnection = RunService.PostSimulation:Connect(Scheduler.update)
		end
	else
		if autoUpdateConnection then
			autoUpdateConnection:Disconnect()
			autoUpdateConnection = nil
		end
	end
end

function Scheduler.setAutoSyncRate(hz: number)
	if hz <= 0 then
		autoSyncRate = 0
	else
		autoSyncRate = hz
	end
end

function Scheduler.setFrameBudget(n: number)
	if n <= 0 then
		Log.fatal('frameBudget must be greater than 0.', nil)
	end

	frameBudget = n
end

function Scheduler.rebuildTrees()
	if State.pendingDynamicRebuild then
		LinearBVH.build(dynamicTree, dynamicCFrames, dynamicHalfSizes)
		State.pendingDynamicRebuild = false
		State.dynamicVersion += 1
	end

	if State.pendingStaticRebuild then
		LinearBVH.build(staticTree, staticCFrames, staticHalfSizes)
		State.pendingStaticRebuild = false
		State.staticVersion += 1
	end
end

Scheduler.setEnabled(Config.Scheduler.enabled)

return Scheduler


--!strict

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

local State = {
	nextZoneId = 1,
	nextObserverId = 1,
	nextGroupId = 1,

	staticCFrames = {} :: { [number]: CFrame },
	staticHalfSizes = {} :: { [number]: Vector3 },
	staticTypes = {} :: { [number]: number },

	dynamicCFrames = {} :: { [number]: CFrame },
	dynamicHalfSizes = {} :: { [number]: Vector3 },
	dynamicTypes = {} :: { [number]: number },

	staticTree = { nodes = {}, count = 0 } :: Types.Tree,
	dynamicTree = { nodes = {}, count = 0 } :: Types.Tree,
	pendingStaticRebuild = false,
	pendingDynamicRebuild = false,
	staticVersion = 1,
	dynamicVersion = 1,
	logicVersion = 1,

	groups = {} :: { [number]: Types.InternalGroup },
	groupToObservers = {} :: { [number]: { [number]: boolean } },
	groupEntityCleanups = {} :: { [number]: { [any]: RBXScriptConnection } },
	entityToGroups = {} :: { [Types.Entity]: { [number]: boolean } },
	entityData = {} :: { [Types.Entity]: Types.EntityData },
	entityToObservers = {} :: { [Types.Entity]: { [number]: boolean } },
	entityToReference = (setmetatable({}, { __mode = 'kv' }) :: any) :: { [Types.Entity]: any },
	referenceToEntity = (setmetatable({}, { __mode = 'kv' }) :: any) :: { [any]: Types.Entity },

	dirtyProfiles = {} :: { [Types.Entity]: boolean },
	dirtyTopology = {} :: { [Types.Entity]: boolean },
	bucketList = {} :: { number },
	buckets = {} :: { [number]: { Types.Entity } },

	observerTrackingEntities = {} :: { [number]: { [Types.Entity]: boolean } },
	observerPriorityMap = {} :: { [number]: number },
	observerEnteredCallbacks = {} :: { [number]: { (any, Types.Zone, Types.Entity) -> () } },
	observerExitedCallbacks = {} :: { [number]: { (any, Types.Zone, Types.Entity) -> () } },
	observerTransitionedCallbacks = {} :: { [number]: { (any, Types.Zone, Types.Entity) -> () } },
	observerEnabled = {} :: { [number]: boolean },
	observerSafety = {} :: { [number]: boolean },
	observerUpdateRate = {} :: { [number]: number },
	observerPrecisionSq = {} :: { [number]: number },
	observerDynamicCount = {} :: { [number]: number },
	observerStaticCount = {} :: { [number]: number },

	autoSyncZones = {} :: { [number]: Types.InternalZone },
	zoneAttachedObservers = {} :: { [number]: { number } },
	zoneIdToZoneObj = {} :: { [number]: Types.InternalZone },
	observerIdToObserverObj = {} :: { [number]: Types.InternalObserver },
}

return State


--!strict

--[=[
	@type ShapeType "Block" | "Ball" | "Cylinder" | "Wedge" | "CornerWedge"
	@within QuickZone
	The geometric shape of a Zone.
]=]
export type ShapeType = 'Block' | 'Ball' | 'Cylinder' | 'Wedge' | 'CornerWedge'

--[=[
	@interface EntityTable
	@within QuickZone
	.Position Vector3?
	.WorldPosition Vector3?
	.CFrame CFrame?
	.Transform: CFrame?
	.GetPivot ((any) -> CFrame)?
	A custom table that mimics the spatial properties of a Roblox Instance.
]=]
export type EntityTable = {
	Position: Vector3?,
	WorldPosition: Vector3?,
	CFrame: CFrame?,
	Transform: CFrame?,
	GetPivot: ((self: any) -> CFrame)?,
}

--[=[
	@type Entity BasePart | Model | Camera | Attachment | Bone | EntityTable
	@within QuickZone
]=]
export type Entity = BasePart | Model | Camera | Attachment | Bone | EntityTable

export type BoundingVolume = {
	position: Vector3,
	cframe: CFrame,
	halfSize: Vector3,
	type: ShapeType,
	radiusSq: number?,
	start: Vector3?,
	axis: Vector3?,
	axisLength: number?,
}

export type Node = {
	min: Vector3,
	max: Vector3,
	id: number,
	skipIndex: number,
}

export type Tree = {
	nodes: { Node },
	count: number,
}

export type EntityData = {
	activeObserverMemberships: { [number]: number },
	lastPosition: Vector3,
	strategy: number,
	precisionSq: number,
	updateRate: number,
	bucketIndex: number,
	staticVersion: number,
	dynamicVersion: number,
	logicVersion: number,
	needsStatic: boolean,
	needsDynamic: boolean,
}

export type Group<T = any> = {
	add: (self: Group<T>, entity: T) -> Group<T>,
	addBulk: (self: Group<T>, entities: { T }) -> Group<T>,
	remove: (self: Group<T>, entity: T) -> Group<T>,
	removeBulk: (self: Group<T>, entities: { T }) -> Group<T>,
	clear: (self: Group<T>) -> Group<T>,

	setAutoClean: (self: Group<T>, enabled: boolean) -> Group<T>,
	getId: (self: Group<T>) -> number,
	getEntities: (self: Group<T>) -> { T },
	contains: (self: Group<T>, entity: T) -> boolean,
	iterEntities: (self: Group<T>) -> () -> T?,
	onDestroy: (self: Group<T>, callback: () -> ()) -> () -> (),
	destroy: (self: Group<T>) -> (),
}

export type InternalGroup<T = any> = Group<T> & {
	_add: (self: Group<T>, entity: T) -> Group<T>,
	_addBulk: (self: Group<T>, entities: { T }) -> Group<T>,
	_remove: (self: Group<T>, entity: T) -> Group<T>,
	_removeBulk: (self: Group<T>, entities: { T }) -> Group<T>,
	_clear: (self: Group<T>) -> Group<T>,
	id: number,
	entities: { T },
	entityIndices: { [any]: number },
	isManaged: boolean?,
	autoClean: boolean,
	onDestroyCallbacks: { () -> () }?,
}

export type GroupClass = {
	new: <T>(config: {
		entities: { T }?,
		autoClean: boolean?,
	}?) -> Group<T>,
	fromTag: (tag: string) -> Group<Entity>,
	players: () -> Group<Player>,
	localPlayer: () -> Group<Player>,
}

export type Zone = {
	sync: (self: Zone) -> Zone,
	setReference: (self: Zone, reference: (Attachment | BasePart | Bone)?) -> Zone,
	setAutoSync: (self: Zone, autoSync: boolean) -> Zone,
	setDynamic: (self: Zone, dynamic: boolean) -> Zone,
	setCFrame: (self: Zone, cf: CFrame) -> Zone,
	setPosition: (self: Zone, pos: Vector3) -> Zone,
	setSize: (self: Zone, size: Vector3) -> Zone,
	setShape: (self: Zone, shape: ShapeType) -> Zone,
	setMetadata: (self: Zone, metadata: any?) -> Zone,

	getMetadata: (self: Zone) -> any?,
	getId: (self: Zone) -> number,
	getReference: (self: Zone) -> (Attachment | BasePart | Bone)?,
	getPosition: (self: Zone) -> Vector3,
	getCFrame: (self: Zone) -> CFrame,
	getSize: (self: Zone) -> Vector3,
	getShape: (self: Zone) -> ShapeType,

	isDynamic: (self: Zone) -> boolean,
	isPointInside: (self: Zone, point: Vector3) -> boolean,

	onDestroy: (self: Zone, callback: () -> ()) -> () -> (),
	destroy: (self: Zone) -> (),
}

export type InternalZone = Zone & {
	attach: (self: Zone, observer: any) -> Zone,
	detach: (self: Zone, observer: any) -> Zone,

	id: number,
	reference: (Attachment | BasePart | Bone)?,
	dynamic: boolean,
	metadata: any?,
	autoSync: boolean,
	syncConnections: { RBXScriptConnection }?,
	onDestroyCallbacks: { () -> () }?,
}

export type Zones = {
	sync: (self: Zones) -> Zones,
	setAutoSync: (self: Zones, autoSync: boolean) -> Zones,
	setDynamic: (self: Zones, dynamic: boolean) -> Zones,
	getZones: (self: Zones) -> { Zone },
	getMetadata: (self: Zones) -> any?,
	isDynamic: (self: Zones) -> boolean,
	isPointInside: (self: Zones, point: Vector3) -> boolean,
	setMetadata: (self: Zones, metadata: any?) -> Zones,
	contains: (self: Zones, zone: Zone) -> boolean,
	iterZones: (self: Zones) -> () -> Zone?,
	getReferences: (self: Zones) -> { Attachment | BasePart | Bone },
	iterReferences: (self: Zones) -> () -> ((Attachment | BasePart | Bone)?, Zone?),

	onDestroy: (self: Zones, callback: () -> ()) -> () -> (),
	destroy: (self: Zones) -> (),
}

export type InternalZones = Zones & {
	attach: (self: Zones, observer: any) -> Zones,
	detach: (self: Zones, observer: any) -> Zones,

	autoSync: boolean?,
	dynamic: boolean,
	metadata: any?,
	zones: { [any]: boolean },
	observers: { [any]: boolean },
	onDestroyCallbacks: { () -> () }?,
	onDestroyObserverCleanups: { [any]: () -> () },
	onDestroyZoneCleanups: { [any]: () -> () },
	add: (self: InternalZones, zone: Zone) -> (),
	remove: (self: InternalZones, zone: Zone) -> (),
}

export type ZoneClass = {
	new: (
		config: {
			cframe: CFrame,
			size: Vector3,
			shape: ShapeType?,
			reference: (Attachment | BasePart | Bone)?,
			isDynamic: boolean?,
			metadata: any?,
			autoSync: boolean?,
		}
	) -> Zone,
	fromPart: (part: BasePart, config: { isDynamic: boolean?, metadata: any?, autoSync: boolean? }?) -> Zone,
	fromParts: (
		parts: { BasePart },
		config: { isDynamic: boolean?, metadata: any?, autoSync: boolean? }?
	) -> Zones,
	fromTag: (tag: string, config: { isDynamic: boolean?, metadata: any?, autoSync: boolean? }?) -> Zones,
	fromDescendants: (
		parent: Instance,
		config: { isDynamic: boolean?, metadata: any?, autoSync: boolean? }?
	) -> Zones,
	fromChildren: (
		parent: Instance,
		config: { isDynamic: boolean?, metadata: any?, autoSync: boolean? }?
	) -> Zones,
}

export type Observer<T = any> = {
	subscribe: (self: Observer<T>, group: Group<T>) -> Observer<T>,
	unsubscribe: (self: Observer<T>, group: Group<T>) -> Observer<T>,
	attach: (self: Observer<T>, zone: Zone | Zones) -> Observer<T>,
	detach: (self: Observer<T>, zone: Zone | Zones) -> Observer<T>,

	setPriority: (self: Observer<T>, n: number) -> Observer<T>,
	setUpdateRate: (self: Observer<T>, hz: number) -> Observer<T>,
	setPrecision: (self: Observer<T>, n: number) -> Observer<T>,
	setEnabled: (self: Observer<T>, enabled: boolean) -> Observer<T>,
	setSafety: (self: Observer<T>, enabled: boolean) -> Observer<T>,

	isEnabled: (self: Observer<T>) -> boolean,
	isSafe: (self: Observer<T>) -> boolean,
	isPointInside: (self: Observer<T>, position: Vector3) -> boolean,

	getId: (self: Observer<T>) -> number,
	getPriority: (self: Observer<T>) -> number,
	getUpdateRate: (self: Observer<T>) -> number,
	getPrecision: (self: Observer<T>) -> number,
	getZones: (self: Observer<T>) -> { Zone },
	getGroups: (self: Observer<T>) -> { Group<T> },
	getEntitiesInside: (self: Observer<T>) -> { T },
	getPlayersInside: (self: Observer<T>) -> { Player },
	getZoneOfEntity: (self: Observer<T>, entity: T) -> Zone?,
	getZoneOfPlayer: (self: Observer<T>, player: Player) -> Zone?,
	getEntitiesInZone: (self: Observer<T>, zone: Zone) -> { T },
	getPlayersInZone: (self: Observer<T>, zone: Zone) -> { Player },

	iterZones: (self: Observer<T>) -> () -> Zone?,
	iterGroups: (self: Observer<T>) -> () -> Group<T>?,
	iterEntitiesInside: (self: Observer<T>) -> () -> (T?, Zone?),
	iterPlayersInside: (self: Observer<T>) -> () -> (Player?, Zone?),
	iterEntitiesInZone: (self: Observer<T>, zone: Zone) -> () -> T?,
	iterPlayersInZone: (self: Observer<T>, zone: Zone) -> () -> Player?,

	observe: (self: Observer<T>, callback: (T, Zone) -> (() -> ())?) -> () -> (),
	observePlayer: (self: Observer<T>, callback: (Player, Zone) -> (() -> ())?) -> () -> (),
	observeLocalPlayer: (self: Observer<T>, callback: (Zone) -> (() -> ())?) -> () -> (),
	observeGroup: (self: Observer<T>, callback: (Group<T>, Zone) -> (() -> ())?) -> () -> (),
	onEnter: (self: Observer<T>, callback: (T, Zone) -> ()) -> () -> (),
	onExit: (self: Observer<T>, callback: (T, Zone) -> ()) -> () -> (),
	onPlayerEnter: (self: Observer<T>, callback: (Player, Zone) -> ()) -> () -> (),
	onPlayerExit: (self: Observer<T>, callback: (Player, Zone) -> ()) -> () -> (),
	onLocalPlayerEnter: (self: Observer<T>, callback: (Zone) -> ()) -> () -> (),
	onLocalPlayerExit: (self: Observer<T>, callback: (Zone) -> ()) -> () -> (),
	onGroupEnter: (self: Observer<T>, callback: (Group<T>, Zone) -> ()) -> () -> (),
	onGroupExit: (self: Observer<T>, callback: (Group<T>, Zone) -> ()) -> () -> (),
	onTransition: (self: Observer<T>, callback: (T, Zone) -> ()) -> () -> (),
	onPlayerTransition: (self: Observer<T>, callback: (Player, Zone) -> ()) -> () -> (),
	onLocalPlayerTransition: (self: Observer<T>, callback: (Zone) -> ()) -> () -> (),

	onDestroy: (self: Observer<T>, callback: () -> ()) -> () -> (),
	destroy: (self: Observer<T>) -> (),
}

export type ObserverClass = {
	new: <T>(
		config: {
			groups: { Group<T> }?,
			zones: { Zone | Zones }?,
			priority: number?,
			updateRate: number?,
			precision: number?,
			enabled: boolean?,
			safety: boolean?,
		}?
	) -> Observer<T>,
}

export type InternalObserver<T = any> = Observer<T> & {
	id: number,
	onDestroyCallbacks: { () -> () }?,
}

export type QuickZone = {
	Zone: ZoneClass,
	Observer: ObserverClass,
	Group: GroupClass,

	configure: (
		self: QuickZone,
		config: { enabled: boolean?, autoSyncRate: number?, frameBudget: number? }
	) -> QuickZone,
	setEnabled: (self: QuickZone, enabled: boolean) -> QuickZone,
	rebuild: (self: QuickZone) -> QuickZone,
	update: (self: QuickZone, dt: number) -> QuickZone,
	setFrameBudget: (self: QuickZone, ms: number) -> QuickZone,
	setReference: (self: QuickZone, entity: Entity, reference: any?) -> QuickZone,
	visualize: (self: QuickZone, enabled: boolean) -> QuickZone,
	removeEntity: (self: QuickZone, entity: any) -> QuickZone,

	getEntityOfReference: (self: QuickZone, reference: any) -> Entity?,
	getReferenceOfEntity: (self: QuickZone, entity: Entity) -> any,
	getZones: (self: QuickZone) -> { Zone },
	getObservers: (self: QuickZone) -> { Observer<any> },
	getGroups: (self: QuickZone) -> { Group<any> },
	getEntities: (self: QuickZone) -> { any },
	iterZones: (self: QuickZone) -> () -> Zone?,
	iterObservers: (self: QuickZone) -> () -> Observer<any>?,
	iterGroups: (self: QuickZone) -> () -> Group<any>?,
	iterEntities: (self: QuickZone) -> () -> any?,
	getZonesAtPoint: (self: QuickZone, position: Vector3) -> { Zone },
	iterZonesAtPoint: (self: QuickZone, position: Vector3) -> () -> Zone?,
	getZonesOfEntity: (self: QuickZone, entity: any) -> { Zone },
	iterZonesOfEntity: (self: QuickZone, entity: any) -> () -> Zone?,
	getGroupsOfEntity: (self: QuickZone, entity: any) -> { Group<any> },
	iterGroupsOfEntity: (self: QuickZone, entity: any) -> () -> Group<any>?,
}

return nil


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

local BLOCK_SHAPE_VALUE = Enum.PartType.Block.Value
local BALL_SHAPE_VALUE = Enum.PartType.Ball.Value
local CYLINDER_SHAPE_VALUE = Enum.PartType.Cylinder.Value
local WEDGE_SHAPE_VALUE = Enum.PartType.Wedge.Value
local CORNER_WEDGE_SHAPE_VALUE = Enum.PartType.CornerWedge.Value

local MORTON_SHIFT16_MASK = 0xFF0000FF
local MORTON_SHIFT8_MASK = 0xF00F00F
local MORTON_SHIFT4_MASK = 0x30C30C3
local MORTON_SHIFT2_MASK = 0x9249249

local expandLUT = table.create(1024)
for i = 0, 1023 do
	local value = i
	value = bit32.bor(bit32.lshift(value, 16), value)
	value = bit32.band(value, MORTON_SHIFT16_MASK)
	value = bit32.bor(bit32.lshift(value, 8), value)
	value = bit32.band(value, MORTON_SHIFT8_MASK)
	value = bit32.bor(bit32.lshift(value, 4), value)
	value = bit32.band(value, MORTON_SHIFT4_MASK)
	value = bit32.bor(bit32.lshift(value, 2), value)
	value = bit32.band(value, MORTON_SHIFT2_MASK)
	expandLUT[i + 1] = value
end

local function expandBits(value: number): number
	return expandLUT[bit32.band(value, 0x3FF) + 1]
end

local Geometry = {}

function Geometry.unionBounds(minA: Vector3, maxA: Vector3, minB: Vector3, maxB: Vector3): (Vector3, Vector3)
	return vector.min(minA, minB), vector.max(maxA, maxB)
end

function Geometry.getObjectBounds(cframe: CFrame, halfSize: Vector3): (Vector3, Vector3)
	local pos = cframe.Position
	local right = cframe.RightVector
	local up = cframe.UpVector
	local look = cframe.LookVector

	-- Calculate the projection of the box extents onto the global axes
	local x = math.abs(right.X) * halfSize.X + math.abs(up.X) * halfSize.Y + math.abs(look.X) * halfSize.Z
	local y = math.abs(right.Y) * halfSize.X + math.abs(up.Y) * halfSize.Y + math.abs(look.Y) * halfSize.Z
	local z = math.abs(right.Z) * halfSize.X + math.abs(up.Z) * halfSize.Y + math.abs(look.Z) * halfSize.Z

	local extent = Vector3.new(x, y, z)
	return pos - extent, pos + extent
end

function Geometry.getMortonScale(sceneMin: Vector3, sceneMax: Vector3): (number, number, number)
	local size = sceneMax - sceneMin
	return 1023 / size.X, 1023 / size.Y, 1023 / size.Z
end

function Geometry.positionToMortonCode(
	position: Vector3,
	sceneMin: Vector3,
	scaleX: number,
	scaleY: number,
	scaleZ: number
): number
	local x = math.floor(math.clamp((position.X - sceneMin.X) * scaleX, 0, 1023))
	local y = math.floor(math.clamp((position.Y - sceneMin.Y) * scaleY, 0, 1023))
	local z = math.floor(math.clamp((position.Z - sceneMin.Z) * scaleZ, 0, 1023))

	return bit32.bor(expandBits(x), bit32.lshift(expandBits(y), 1), bit32.lshift(expandBits(z), 2))
end

function Geometry.isPointInShape(point: Vector3, cframe: CFrame, halfSize: Vector3, shapeType: number): boolean
	if shapeType == BLOCK_SHAPE_VALUE then
		local localPoint = vector.abs(cframe:PointToObjectSpace(point))
		return vector.min(localPoint, halfSize) == localPoint
	elseif shapeType == CYLINDER_SHAPE_VALUE then
		local position = cframe.Position
		local halfHeight = halfSize.X
		local start = position - cframe.RightVector * halfHeight
		local toPoint = point - start
		local axis = cframe.RightVector
		local projection = vector.dot(toPoint, axis)
		if projection >= 0 and projection <= halfHeight * 2 :: number then
			local closestPointOnAxis = start :: Vector3 + axis :: Vector3 * projection
			local distanceSq = vector.dot(point - closestPointOnAxis, point - closestPointOnAxis)
			local radiusSq = halfSize.Y * halfSize.Y
			return distanceSq <= radiusSq
		end
		return false
	elseif shapeType == BALL_SHAPE_VALUE then
		local radiusSq = halfSize.X * halfSize.X
		local position = cframe.Position
		return vector.dot(position - point, position - point) <= radiusSq
	elseif shapeType == WEDGE_SHAPE_VALUE then
		local localPoint = cframe:PointToObjectSpace(point)
		local absolutePoint = vector.abs(localPoint)
		if vector.min(absolutePoint, halfSize) == absolutePoint then
			local size = halfSize * 2
			return (localPoint.Y / size.Y - localPoint.Z / size.Z) <= 0
		end
		return false
	elseif shapeType == CORNER_WEDGE_SHAPE_VALUE then
		local size = halfSize * 2
		local localPoint = cframe:PointToObjectSpace(point)
		local plane1_normal = vector.normalize(Vector3.new(0, size.Z, size.Y))
		if vector.dot(localPoint, plane1_normal) > 0 then
			return false
		end
		local plane2_normal = vector.normalize(Vector3.new(-size.Y, size.X, 0))
		if vector.dot(localPoint, plane2_normal) > 0 then
			return false
		end
		local absolute_point = vector.abs(localPoint)
		return vector.min(absolute_point, halfSize) == absolute_point
	end

	return false
end

return Geometry


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

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

local HUGE_VECTOR = Vector3.one * math.huge
local NEGATIVE_HUGE_VECTOR = -HUGE_VECTOR

local unionBounds = Geometry.unionBounds
local getObjectBounds = Geometry.getObjectBounds
local getMortonScale = Geometry.getMortonScale
local positionToMortonCode = Geometry.positionToMortonCode

type ListEntity = {
	id: number,
	mortonCode: number,
}

local LinearBVH = {}

local listCache = {} :: { ListEntity }
local entryPool = {} :: { ListEntity }
local objMinCache = {} :: { [number]: Vector3 }
local objMaxCache = {} :: { [number]: Vector3 }

local function getSplitPos(list: { ListEntity }, beginIndex: number, endIndex: number): number
	if endIndex == beginIndex + 1 then
		return beginIndex + 1
	end

	local first = list[beginIndex].mortonCode
	local last = list[endIndex].mortonCode

	if first == last then
		return (beginIndex + endIndex) // 2
	end

	local xor_val = bit32.bxor(first, last)
	local highest_bit = 31 - bit32.countlz(xor_val)
	local mask = bit32.lshift(1, highest_bit)
	local first_mask = bit32.band(first, mask)

	local left, right = beginIndex, endIndex
	while right - left > 1 do
		local m = (left + right) // 2
		if bit32.band(list[m].mortonCode, mask) == first_mask then
			left = m
		else
			right = m
		end
	end
	return left + 1
end

local function buildFlatTree(
	list: { ListEntity },
	boundsMin: { Vector3 },
	boundsMax: { Vector3 },
	beginIndex: number,
	endIndex: number,
	tree: Types.Tree,
	currentIndex: number
): (number, Vector3, Vector3)
	local node = tree.nodes[currentIndex]
	if not node then
		node = {} :: Types.Node
		tree.nodes[currentIndex] = node
	end

	if beginIndex == endIndex then -- Leaf node
		local id = list[beginIndex].id
		local min, max = boundsMin[id], boundsMax[id]

		node.min = min
		node.max = max
		node.id = id
		node.skipIndex = currentIndex + 1

		return currentIndex + 1, min, max
	else -- Branch node
		local mid = getSplitPos(list, beginIndex, endIndex)
		node.id = -1

		local rightIndex, lMin, lMax =
			buildFlatTree(list, boundsMin, boundsMax, beginIndex, mid - 1, tree, currentIndex + 1)
		local nextIndex, rMin, rMax = buildFlatTree(list, boundsMin, boundsMax, mid, endIndex, tree, rightIndex)

		local min, max = unionBounds(lMin, lMax, rMin, rMax)
		node.min = min
		node.max = max
		node.skipIndex = nextIndex -- If the point is not in this branch, we skip both subtrees

		return nextIndex, min, max
	end
end

function LinearBVH.build(tree: Types.Tree, cframes: { [number]: CFrame }, halfSizes: { [number]: Vector3 }): ()
	table.clear(objMinCache)
	table.clear(objMaxCache)

	local min = HUGE_VECTOR
	local max = NEGATIVE_HUGE_VECTOR
	local count = 0

	-- Calculate bounds and update listCache
	for id, cframe in cframes do
		local halfSize = halfSizes[id]

		local bMin, bMax = getObjectBounds(cframe, halfSize)

		objMinCache[id] = bMin
		objMaxCache[id] = bMax

		min = vector.min(min, bMin)
		max = vector.max(max, bMax)

		count += 1

		local entry = listCache[count]
		if not entry then
			local poolSize = #entryPool
			if poolSize > 0 then
				entry = entryPool[poolSize]
				entryPool[poolSize] = nil
			else
				entry = { id = 0, mortonCode = 0 }
			end
			listCache[count] = entry
		end

		entry.id = id
	end

	if count == 0 then
		tree.count = 0
		return
	end

	-- Push unused entries from the tail into the pool
	local cacheSize = #listCache
	if cacheSize > count then
		local poolSize = #entryPool
		for i = count + 1, cacheSize do
			poolSize += 1
			entryPool[poolSize] = listCache[i]
			listCache[i] = nil
		end
	end

	-- Add padding to avoid edge cases with flat scenes
	local padding = vector.max(vector.one, (max - min) * 0.01)
	local sceneMin, sceneMax = min - padding, max + padding
	local scaleX, scaleY, scaleZ = getMortonScale(sceneMin, sceneMax)

	for i = 1, count do
		local entry = listCache[i]
		local pos = cframes[entry.id].Position -- Fast property access
		entry.mortonCode = positionToMortonCode(pos, sceneMin, scaleX, scaleY, scaleZ)
	end

	table.sort(listCache, function(a, b)
		return a.mortonCode < b.mortonCode
	end)

	local nextAvailableIndex = buildFlatTree(listCache, objMinCache, objMaxCache, 1, count, tree, 1)

	tree.count = nextAvailableIndex - 1
end

function LinearBVH.queryPoint(tree: Types.Tree, point: Vector3, callback: (id: number) -> ())
	local nodes = tree.nodes
	local count = tree.count
	local px, py, pz = point.X, point.Y, point.Z

	local index = 1
	while index <= count do
		local node = nodes[index]
		local min, max = node.min, node.max
		if px >= min.X and px <= max.X and py >= min.Y and py <= max.Y and pz >= min.Z and pz <= max.Z then
			index += 1
			local nodeId = node.id
			if nodeId > 0 then
				callback(nodeId)
			end
		else
			index = node.skipIndex -- Skip branch
		end
	end
end

return LinearBVH


--!strict

local Log = {}

local function formatLog(messageTemplate: string, trace: string?, ...: any): string
	local messageText = string.format(messageTemplate, ...)

	messageText = `[QuickZone] {messageText}`

	local finalTrace = trace or debug.traceback('', 3)

	if finalTrace ~= '' then
		messageText ..= ` \n---- Stack trace ----\n{finalTrace}`
	end

	return messageText:gsub('\n', '\n    ')
end

function Log.info(message: string, trace: string?, ...: any)
	print(formatLog(message, trace, ...))
end

function Log.warn(message: string, trace: string?, ...: any)
	warn(formatLog(message, trace, ...))
end

function Log.fatal(message: string, trace: string?, ...: any)
	error(formatLog(message, trace, ...), 0)
end

function Log.nonFatal(message: string, trace: string?, ...: any)
	local formattedString = formatLog(message, trace, ...)
	task.spawn(error, formattedString, 0)
end

return Log


--!strict
--v1.3.187 • LDGerrits
--[=[
	@class QuickZone

	A high-performance, physics-free spatial query library for Roblox. Maintain 60 FPS with 1M+ zones.
]=]

local RunService = game:GetService('RunService')

local Types = require(script.Types)
local Config = require(script.Config)
local Scheduler = require(script.Core.Scheduler)
local State = require(script.Core.State)
local Geometry = require(script.Utils.Geometry)
local LinearBVH = require(script.Utils.LinearBVH)

local queryPoint = LinearBVH.queryPoint
local isPointInShape = Geometry.isPointInShape

local zoneAttachedObservers = State.zoneAttachedObservers
local zoneIdToZoneObj = State.zoneIdToZoneObj
local entityData = State.entityData
local observerIdToObserverObj = State.observerIdToObserverObj
local groups = State.groups
local entityToGroups = State.entityToGroups
local entityToReference = State.entityToReference
local referenceToEntity = State.referenceToEntity

local staticCFrames = State.staticCFrames
local staticHalfSizes = State.staticHalfSizes
local staticTypes = State.staticTypes
local dynamicCFrames = State.dynamicCFrames
local dynamicHalfSizes = State.dynamicHalfSizes
local dynamicTypes = State.dynamicTypes
local dynamicTree = State.dynamicTree
local staticTree = State.staticTree

local debugConnection: RBXScriptConnection?
local debugFolder: Folder?
local debugVisuals = {} :: { [number]: BoxHandleAdornment }

export type QuickZone = Types.QuickZone
export type Zone = Types.Zone
export type Zones = Types.Zones
export type Observer<T> = Types.Observer<T>
export type Group<T> = Types.Group<T>
export type Entity = Types.Entity

local QuickZone = {
	Zone = require(script.Classes.Zone) :: Types.ZoneClass,
	Observer = require(script.Classes.Observer) :: Types.ObserverClass,
	Group = require(script.Classes.Group) :: Types.GroupClass,
}

--[=[
	Configure the QuickZone settings.

	```lua
	QuickZone:configure({
		enabled = true,
		autoSyncRate = 10,
		frameBudget = 0.5,
	})
	```

	@method configure
	@within QuickZone
	@param config { enabled: boolean?, autoSyncRate: number?, frameBudget: number? }
	@return QuickZone
]=]
function QuickZone:configure(config: { enabled: boolean?, autoSyncRate: number?, frameBudget: number? }): QuickZone
	if config.enabled ~= nil then
		self:setEnabled(config.enabled)
	end

	if config.autoSyncRate ~= nil then
		self:setAutoSyncRate(config.autoSyncRate)
	end

	if config.frameBudget ~= nil then
		self:setFrameBudget(config.frameBudget)
	end

	return self
end

--[=[
	Enables or disables automatic updating. If false, you must call `QuickZone:update(dt)` manually.

	@method setEnabled
	@within QuickZone
	@param enabled boolean
	@return QuickZone
]=]
function QuickZone:setEnabled(enabled: boolean): QuickZone
	Scheduler.setEnabled(enabled)
	return self
end

--[=[
	Manually processes the spatial queries and trigger callbacks.
	This is only necessary if you disabled automatic updating.

	@method update
	@within QuickZone
	@param dt number -- Delta time
	@return QuickZone
]=]
function QuickZone:update(dt: number): QuickZone
	Scheduler.update(dt)
	return self
end

--[=[
	Sets the automatic sync rate for zones with references.
	This controls how often QuickZone checks the reference's position and updates the zone accordingly.

	@method setAutoSyncRate
	@within QuickZone
	@param hz number -- Sync frequency in Hz. Set to 0 to disable auto-syncing.
	@return QuickZone
]=]
function QuickZone:setAutoSyncRate(hz: number): QuickZone
	Scheduler.setAutoSyncRate(hz)
	return self
end

--[=[
	Instantly forces the internal trees to rebuild if any zones have been modified.

	This is only necessary if you have made manual changes to zones or references
	and want to ensure the changes take effect immediately.
	Otherwise, the Scheduler will automatically rebuild the trees on the next update step.

	@method rebuild
	@within QuickZone
	@return QuickZone
]=]
function QuickZone:rebuild(): QuickZone
	Scheduler.rebuildTrees()
	return self
end

--[=[
	Set a reference to an entity. If set, the callbacks will now pass the reference instead of the entity.

	```lua
	QuickZone:setReference(transformComponent, numberId)

	local entityGroup = QuickZone.Group.new({ Entities = { entityId } })
	local observer = QuickZone.Observer.new({ Groups = {entityGroup} })

	observer:onEnter(function(numberId, zone)
		print(tostring(numberId) .. " entered!")
	end)
	```

	@method setReference
	@within QuickZone
	@param entity Entity
	@param reference any?
	@return QuickZone
]=]
function QuickZone:setReference(entity: Entity, reference: any?): QuickZone
	if reference == nil then
		local oldReference = entityToReference[entity]
		if oldReference then
			referenceToEntity[oldReference] = nil
		end
		entityToReference[entity] = nil
		return self
	end

	entityToReference[entity] = reference
	referenceToEntity[reference] = entity

	return self
end

--[=[
	Sets the global execution budget for the Scheduler per frame in milliseconds.

	The Scheduler will 'dispatch' entity steps until this threshold is reached,
	at which point it yields until the next frame to prevent render stutter.

	Defaults to 1ms.

	```lua
	-- Allow QuickZone to use up to 0.5ms of frame time
	QuickZone:setFrameBudget(0.5)
	```

	@method setFrameBudget
	@within QuickZone
	@param ms number -- Time in milliseconds
	@return QuickZone
]=]
function QuickZone:setFrameBudget(ms: number): QuickZone
	local s = ms / 1000
	Scheduler.setFrameBudget(s)
	return self
end

--[=[
	Instantly and safely removes the entity from QuickZone.
	It will be removed from all Groups, resolving any active Observers
	and firing their `onExit` events.

	@method removeEntity
	@within QuickZone
	@param entity any
	@return QuickZone
]=]
function QuickZone:removeEntity(entity: any): QuickZone
	entity = referenceToEntity[entity] or entity

	local groupIds = entityToGroups[entity]
	if not groupIds then
		return self
	end

	local idsToRemove = {}
	for groupId in groupIds do
		table.insert(idsToRemove, groupId)
	end

	for _, groupId in idsToRemove do
		local group = groups[groupId]
		if group then
			group:remove(entity)
		end
	end

	return self
end

--[=[
	Returns the physical Entity associated with the reference.

	@method getEntityOfReference
	@within QuickZone
	@param reference any
	@return Entity?
]=]
function QuickZone:getEntityOfReference(reference: any): Types.Entity?
	local entity = referenceToEntity[reference]
	if entity then
		return entity
	end

	entity = reference
	if typeof(entity) == 'Instance' then
		if
			entity:IsA('BasePart')
			or entity:IsA('Attachment')
			or entity:IsA('Bone')
			or entity:IsA('Camera')
			or entity:IsA('Model')
		then
			return entity
		end
	elseif typeof(entity) == 'table' then
		if entity.Position or entity.CFrame or entity.WorldPosition or entity.GetPivot then
			return entity
		end
	end

	return nil
end

--[=[
	Returns the reference associated with the Entity.

	@method getReferenceOfEntity
	@within QuickZone
	@param reference any
	@return any
]=]
function QuickZone:getReferenceOfEntity(entity: any): any
	return entityToReference[entity] or entity
end

--[=[
	Returns an array of all Observers currently existing.

	@method getObservers
	@within QuickZone
	@return { Observer }
]=]
function QuickZone:getObservers(): { Types.Observer<any> }
	local result = {}
	for _, observer in observerIdToObserverObj do
		table.insert(result, observer)
	end
	return (result :: any) :: { Types.Observer<any> }
end

--[=[
	Returns an array of all Groups (including PlayerGroups) currently
	registered.

	@method getGroups
	@within QuickZone
	@return { Group }
]=]
function QuickZone:getGroups(): { Types.Group }
	local result = {}
	for _, group in groups do
		table.insert(result, group)
	end
	return result
end

--[=[
	Returns an array containing every Zone currently registered.
	This includes both static and dynamic zones.

	@method getZones
	@within QuickZone
	@return { Zone }
]=]
function QuickZone:getZones(): { Types.Zone }
	local result = {}
	for _, zone in zoneIdToZoneObj do
		table.insert(result, zone)
	end
	return result
end

--[=[
	Returns a flattened array of every entity currently
	being tracked across all Groups.

	@method getEntities
	@within QuickZone
	@return { any }
]=]
function QuickZone:getEntities(): { any }
	local result = {}
	local count = 0

	for entity in entityData do
		count += 1
		result[count] = entityToReference[entity] or entity
	end

	return result
end

--[=[
	Performs a spatial query to find all Zones containing the given point.

	@method getZonesAtPoint
	@within QuickZone
	@param position Vector3 -- The world position to check.
	@return { Zone }
]=]
function QuickZone:getZonesAtPoint(position: Vector3): { Zone }
	local results = {}
	local count = 0

	queryPoint(dynamicTree, position, function(zoneId)
		if isPointInShape(position, dynamicCFrames[zoneId], dynamicHalfSizes[zoneId], dynamicTypes[zoneId]) then
			local zone = zoneIdToZoneObj[zoneId]
			if zone then
				count += 1
				results[count] = zone
			end
		end
	end)

	queryPoint(staticTree, position, function(zoneId)
		if isPointInShape(position, staticCFrames[zoneId], staticHalfSizes[zoneId], staticTypes[zoneId]) then
			local zone = zoneIdToZoneObj[zoneId]
			if zone then
				count += 1
				results[count] = zone
			end
		end
	end)

	return results
end

--[=[
	Returns an array of all Zones the given entity is actively inside,
	based on its observer memberships.

	@method getZonesOfEntity
	@within QuickZone
	@param entity any
	@return { Zone }
]=]
function QuickZone:getZonesOfEntity(entity: any): { Types.Zone }
	entity = referenceToEntity[entity] or entity
	local data = entityData[entity]
	if not data or not data.activeObserverMemberships then
		return {}
	end

	local results = {}
	local seenZones = {} -- In case multiple observers track the entity in the same zone

	for _, zoneId in data.activeObserverMemberships do
		if seenZones[zoneId] then
			continue
		end
		seenZones[zoneId] = true

		local zone = zoneIdToZoneObj[zoneId]
		if zone then
			table.insert(results, zone)
		end
	end

	return results
end

--[=[
	Returns an array of all Groups that the specified entity is currently in.

	@method getGroupsOfEntity
	@within QuickZone
	@param entity any
	@return { Group }
]=]
function QuickZone:getGroupsOfEntity(entity: any): { Types.Group }
	entity = referenceToEntity[entity] or entity
	local groupIds = entityToGroups[entity]
	if not groupIds then
		return {}
	end

	local results = {}
	local count = 0

	for groupId in groupIds do
		local group = groups[groupId]
		if group then
			count += 1
			results[count] = group
		end
	end

	return results
end

--[=[
	Returns a zero-allocation iterator for all Groups.

	@method iterGroups
	@within QuickZone
	@return () -> Group?
]=]
function QuickZone:iterGroups(): () -> Types.Group?
	local groupId = nil
	return function(): Types.Group?
		groupId = next(groups, groupId)
		return groupId and groups[groupId] or nil
	end
end

--[=[
	Returns a zero-allocation iterator for all Zones.

	@method iterZones
	@within QuickZone
	@return () -> Zone?
]=]
function QuickZone:iterZones(): () -> Types.Zone?
	local zoneId = nil

	return function(): Types.Zone?
		zoneId = next(zoneIdToZoneObj, zoneId)
		return zoneId and zoneIdToZoneObj[zoneId] or nil
	end
end

--[=[
	Returns a zero-allocation iterator for all entities.

	@method iterEntities
	@within QuickZone
	@return () -> any?
]=]
function QuickZone:iterEntities(): () -> any?
	local entity = nil

	return function(): any?
		entity = next(entityData, entity)
		if entity then
			return entityToReference[entity] or entity
		end
		return entity
	end
end

--[=[
	Returns a zero-allocation iterator for all Observers.

	@method iterObservers
	@within QuickZone
	@return () -> Observer?
]=]
function QuickZone:iterObservers(): () -> Types.Observer<any>?
	local observerId = nil

	return function(): Types.Observer<any>?
		observerId = next(observerIdToObserverObj, observerId)
		return observerId and observerIdToObserverObj[observerId] or nil
	end
end

--[=[
	Returns a zero-allocation iterator for all Groups that the specified entity is currently in.

	@method iterGroupsOfEntity
	@within QuickZone
	@param entity any
	@return () -> Group?
]=]
function QuickZone:iterGroupsOfEntity(entity: any): () -> Types.Group?
	entity = referenceToEntity[entity] or entity

	local groupIds = entityToGroups[entity]
	if not groupIds then
		return function()
			return nil
		end
	end

	local groupId = nil
	return function(): Types.Group?
		groupId = next(groupIds, groupId)
		return groupId and groups[groupId] or nil
	end
end

--[=[
	Returns an iterator for all unique Zones the given entity is actively inside.

	@method iterZonesOfEntity
	@within QuickZone
	@param entity any
	@return () -> Zone?
]=]
function QuickZone:iterZonesOfEntity(entity: any): () -> Types.Zone?
	entity = referenceToEntity[entity] or entity

	local data = entityData[entity]
	if not data or not data.activeObserverMemberships then
		return function()
			return nil
		end
	end

	local memberships = data.activeObserverMemberships
	local seenZones = {}
	local observerId = nil

	return function(): Types.Zone?
		while true do
			observerId = next(memberships, observerId)
			if not observerId then
				return nil
			end

			local zoneId = memberships[observerId]
			if seenZones[zoneId] then
				continue
			end

			seenZones[zoneId] = true
			local zone = zoneIdToZoneObj[zoneId]

			if zone then
				return zone
			end
		end
	end
end

--[=[
	Returns an iterator that returns every Zone intersecting the given point.
	Ideal for zero-allocation ECS queries. Safe to `break` out of early.

	@method iterZonesAtPoint
	@within QuickZone
	@param point Vector3
	@return () -> Zone?
]=]
function QuickZone:iterZonesAtPoint(point: Vector3): () -> Types.Zone?
	local dynamicNodes = dynamicTree.nodes
	local dynamicCount = dynamicTree.count or 0

	local staticNodes = staticTree.nodes
	local staticCount = staticTree.count or 0

	local px, py, pz = point.X, point.Y, point.Z

	local index = 1
	local checkingDynamic = true

	return function(): Types.Zone?
		if checkingDynamic then
			while index <= dynamicCount do
				local node = dynamicNodes[index]
				local min, max = node.min, node.max

				if px < min.X or px > max.X or py < min.Y or py > max.Y or pz < min.Z or pz > max.Z then
					index = node.skipIndex
					continue
				end

				index += 1
				local hitId = node.id

				if
					hitId > 0
					and isPointInShape(point, dynamicCFrames[hitId], dynamicHalfSizes[hitId], dynamicTypes[hitId])
				then
					return zoneIdToZoneObj[hitId]
				end
			end

			checkingDynamic = false
			index = 1
		end

		if not checkingDynamic then
			while index <= staticCount do
				local node = staticNodes[index]
				local min, max = node.min, node.max

				if px < min.X or px > max.X or py < min.Y or py > max.Y or pz < min.Z or pz > max.Z then
					index = node.skipIndex
					continue
				end

				index += 1
				local hitId = node.id

				if
					hitId > 0
					and isPointInShape(point, staticCFrames[hitId], staticHalfSizes[hitId], staticTypes[hitId])
				then
					return zoneIdToZoneObj[hitId]
				end
			end
		end

		return nil
	end
end

--[=[
	Enables or disables visual rendering of all registered Zones.
	Static and Dynamic zones are colored differently based on their active status.

	@tag Debug
	@method visualize
	@within QuickZone
	@param enabled boolean -- Whether to show the debug visuals.
	@return QuickZone
]=]
function QuickZone:visualize(enabled: boolean): QuickZone
	if debugConnection then
		debugConnection:Disconnect()
		debugConnection = nil
	end
	if debugFolder then
		debugFolder:Destroy()
		debugFolder = nil
	end
	table.clear(debugVisuals)

	if not enabled then
		return self
	end

	local newDebugFolder = Instance.new('Folder')
	newDebugFolder.Name = 'QuickZone_Debug_Visuals'
	newDebugFolder.Parent = workspace
	debugFolder = newDebugFolder

	local function getOrCreateVisual(id: number): BoxHandleAdornment
		local box = debugVisuals[id]
		if not box then
			local newbox = Instance.new('BoxHandleAdornment')
			newbox.Name = 'Zone_' .. id
			newbox.Adornee = workspace.Terrain
			newbox.Transparency = Config.Debug.transparency
			newbox.AlwaysOnTop = true
			newbox.ZIndex = 1
			newbox.Parent = debugFolder
			debugVisuals[id] = newbox
			box = newbox
		end
		return box
	end

	local loopEvent: RBXScriptSignal<number> = RunService:IsClient() and RunService.PreRender or RunService.Heartbeat

	debugConnection = loopEvent:Connect(function()
		if not debugFolder then
			return
		end

		-- Draw static zones
		for id, cframe in staticCFrames do
			local box = getOrCreateVisual(id)
			local halfSize = staticHalfSizes[id]

			local observers = zoneAttachedObservers[id]
			local isActive = observers and next(observers) ~= nil
			local color = isActive and Config.Debug.staticActive or Config.Debug.staticInactive

			box.Size = halfSize * 2
			box.CFrame = cframe
			box.Color3 = color
		end

		-- Draw dynamic zones
		for id, cframe in dynamicCFrames do
			local box = getOrCreateVisual(id)
			local halfSize = dynamicHalfSizes[id]

			local observers = zoneAttachedObservers[id]
			local isActive = observers and next(observers) ~= nil
			local color = isActive and Config.Debug.dynamicActive or Config.Debug.dynamicInactive

			box.Size = halfSize * 2
			box.CFrame = cframe
			box.Color3 = color
		end

		-- Destroy removed zones
		for id, box in debugVisuals do
			if not staticCFrames[id] and not dynamicCFrames[id] then
				box:Destroy()
				debugVisuals[id] = nil
			end
		end
	end)

	return self
end

return QuickZone


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

local README_PATH = "README.md"

-- Header for the README.md file
local README_HEADER = [[
# Awesome Roblox-TS

A list of all packages for [roblox-ts](https://roblox-ts.com/).

## Packages

]]

-- Function to strip markdown and HTML from descriptions
local function stripMarkdownHtml(s)
	-- Remove HTML tags
	s = s:gsub("<[^>]*>", "")
	-- Remove images and links: ![alt](url) and [text](url)
	s = s:gsub("!%b[]%b()", "")
	s = s:gsub("%b[]%b()", "")
	-- Remove emphasis markers: **bold**, *italic*, __underline__, _italic_
	s = s:gsub("%*%*([^*]-)%*%*", "%1")
	s = s:gsub("%*([^*]-)%*", "%1")
	s = s:gsub("__([^_]-)__", "%1")
	s = s:gsub("_([^_]-)_", "%1")
	-- Remove inline code: `code`
	s = s:gsub("`([^`]-)`", "%1")
	-- Remove code fences: ```...```
	s = s:gsub("```.-```", "")
	-- Remove headers (# H1, ## H2, etc.) anywhere in the string
	s = s:gsub("#+%s*[^\n]*", "")
	-- Remove blockquotes >
	s = s:gsub(">%s?[^\n]*", "")
	-- Remove lists markers (-, *, +) at line-start
	s = s:gsub("^[%-%*%+]%s+", "")
	s = s:gsub("\n[%-%*%+]%s+", " ")
	-- Remove newlines and normalize spaces
	s = s:gsub("[\r\n]+", " ")
	s = s:gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "")
	return s
end

-- Function to run a command and get its output, ignoring errors
local function runCommandSilent(command, args)
	local result = process.exec(command, args)
	-- Always return stdout, even if there was an error (like the original || true)
	return result.stdout or "", nil
end

-- Function to fetch all @rbxts package names from npm
local function getPackageNames()
	print("Fetching package list from npm...")
	local result = process.exec("npm", { "access", "list", "packages", "@rbxts" })
	if not result.ok then
		print(string.format("Error fetching package list: %s", result.stderr))
		process.exit(1)
	end

	local names = {}
	for line in result.stdout:gmatch("[^\r\n]+") do
		local name = line:match("^(%S+):")
		if name then
			table.insert(names, name)
		end
	end
	print(`Found {#names} packages.`)
	return names
end

-- Function to get details for a single package (equivalent to the xargs part)
local function getPackageDetails(name)
	-- Equivalent to: npm view 'package' name description --json 2>/dev/null || true
	local stdout, _ = runCommandSilent("npm", { "view", name, "name", "description", "--json" })

	if not stdout or stdout == "" then
		return nil
	end

	local ok, result = pcall(function()
		return serde.decode("json", stdout)
	end)

	-- Equivalent to jq's: select(type == "object" and .name)
	if ok and type(result) == "table" and result.name then
		return result
	end

	return nil
end

-- Function to process a chunk of packages
local function processPackageChunk(packageNames, startIndex, endIndex)
	local results = {}
	for i = startIndex, endIndex do
		if packageNames[i] then
			local pkg = getPackageDetails(packageNames[i])
			if pkg then
				table.insert(results, pkg)
			end
		end
	end
	return results
end

-- Main function to orchestrate the update
local function main()
	local packageNames = getPackageNames()
	local allPackageDetails = {}

	print("Fetching package details with 10 parallel processes...")

	-- Shared state for parallel processing
	local completedWorkers = 0
	local workerResults = {}

	-- Process packages in parallel chunks
	local chunkSize = math.ceil(#packageNames / 10) -- Divide into 10 roughly equal chunks

	-- Create 10 parallel tasks
	for i = 1, 10 do
		local startIndex = (i - 1) * chunkSize + 1
		local endIndex = math.min(i * chunkSize, #packageNames)

		if startIndex <= #packageNames then
			print(`Starting worker {i}: processing packages {startIndex} to {endIndex}`)
			task.spawn(function()
				local results = processPackageChunk(packageNames, startIndex, endIndex)
				workerResults[i] = results
				completedWorkers = completedWorkers + 1
				print(`Worker {i} completed with {#results} packages`)
			end)
		else
			completedWorkers = completedWorkers + 1
		end
	end

	-- Wait for all workers to complete
	print("Waiting for all workers to complete...")
	while completedWorkers < 10 do
		task.wait(0.1) -- Small delay to avoid busy waiting
	end

	-- Collect all results
	for i = 1, 10 do
		if workerResults[i] then
			for _, pkg in ipairs(workerResults[i]) do
				table.insert(allPackageDetails, pkg)
			end
		end
	end

	-- Sort packages by name (equivalent to jq's sort_by(.name))
	table.sort(allPackageDetails, function(a, b)
		return a.name < b.name
	end)

	print(`Successfully fetched details for {#allPackageDetails} packages.`)
	print("Generating README.md...")
	local readmeContent = { README_HEADER }

	for _, pkg in ipairs(allPackageDetails) do
		local desc = pkg.description or ""

		-- Clean up the description using the comprehensive function
		desc = stripMarkdownHtml(desc)

		if #desc > 0 then
			table.insert(
				readmeContent,
				string.format("- [%s](https://www.npmjs.com/package/%s) - %s", pkg.name, pkg.name, desc)
			)
		else
			table.insert(readmeContent, string.format("- [%s](https://www.npmjs.com/package/%s)", pkg.name, pkg.name))
		end
	end

	local readmeFinal = table.concat(readmeContent, "\n")
	fs.writeFile(README_PATH, readmeFinal)

	print("README.md has been updated successfully.")
end

main()


local AVLTree = {}
AVLTree.__index = AVLTree

export type Node<T = any> = {
    Value: T,
    Left: Node<T>?,
    Right: Node<T>?,
    Height: number,
}

export type Class<T = any> = typeof(setmetatable({} :: {
    Root: Node<T>?,
    Compare: (T, T) -> number,

    _lookup: {
        [T]: Node<T>,
    },

    _size: number,
}, AVLTree))

local function inOrder(node: Node)
    if node.Left then
        inOrder(node.Left)
    end

    coroutine.yield(node.Value)

    if node.Right then
        inOrder(node.Right)
    end
end

local function preOrder(node: Node)
    coroutine.yield(node.Value)

    if node.Left then
        preOrder(node.Left)
    end

    if node.Right then
        preOrder(node.Right)
    end
end

local function postOrder(node: Node)
    if node.Left then
        postOrder(node.Left)
    end

    if node.Right then
        postOrder(node.Right)
    end

    coroutine.yield(node.Value)
end

local function getHeight(node: Node?): number
    return node and node.Height or 0
end

local function getBalance(node: Node?): number
    if node then
        return getHeight(node.Left) - getHeight(node.Right)
    end

    return 0
end

local function updateHeight(node: Node)
    node.Height = 1 + math.max(getHeight(node.Left), getHeight(node.Right))
end

local function rotateRight(y: Node): Node
    local x = assert(y.Left)

    y.Left = x.Right :: Node
    x.Right = y

    updateHeight(y)
    updateHeight(x)

    return x
end

local function rotateLeft(x: Node): Node
    local y = assert(x.Right)

    if y.Left then
        x.Right = y.Left
        y.Left = x
    end

    updateHeight(x)
    updateHeight(y)

    return y
end

local function insertNode<T>(self: Class<T>, node: Node<T>?, value: T): Node<T>
    if not node then
        local newNode: Node<T> = {
            Value = value,
            Left = nil,
            Right = nil,
            Height = 1,
        }

        self._lookup[value] = newNode
        return newNode
    end

    local comp = self.Compare(value, node.Value)

    if comp < 0 then
        node.Left = insertNode(self, node.Left, value)
    elseif comp > 0 then
        node.Right = insertNode(self, node.Right, value)
    else
        return node
    end

    updateHeight(node)

    local balance = getBalance(node)

    -- Left Left Case
    if balance > 1 and node.Left and self.Compare(value, node.Left.Value) < 0 then
        return rotateRight(assert(node))
    end

    -- Right Right Case
    if balance < -1 and node.Right and self.Compare(value, node.Right.Value) > 0 then
        return rotateLeft(node)
    end

    -- Left Right Case
    if balance > 1 and node.Left and self.Compare(value, node.Left.Value) > 0 then
        node.Left = rotateLeft(node.Left)
        return rotateRight(node)
    end

    -- Right Left Case
    if balance < -1 and node.Right and self.Compare(value, node.Right.Value) < 0 then
        node.Right = rotateRight(node.Right)
        return rotateLeft(node)
    end

    return node
end

local function removeNode(self: Class, node: Node?, value: any): Node?
    if not node then
        return nil
    end

    local comp = self.Compare(value, node.Value)

    if comp < 0 then
        node.Left = removeNode(self, node.Left, value)
    elseif comp > 0 then
        node.Right = removeNode(self, node.Right, value)
    else
        if not node.Left or not node.Right then
            local temp = node.Left or node.Right

            if not temp then
                node = nil
            else
                node = temp
            end
        else
            local temp = node.Right

            while temp.Left do
                temp = temp.Left
            end

            local tempVal = temp.Value
            node.Value = tempVal

            if node then
                self._lookup[tempVal] = node :: Node
            end

            if node.Right then
                node.Right = removeNode(self, node.Right, tempVal) :: Node
            end
        end
    end

    if node then
        updateHeight(node)
    else
        return nil
    end

    local balance = getBalance(node)

    -- Left Left Case
    if balance > 1 and getBalance(node.Left) >= 0 then
        return rotateRight(node)
    end

    -- Left Right Case
    if balance > 1 and node.Left and getBalance(node.Left) < 0 then
        node.Left = rotateLeft(node.Left)
        return rotateRight(node)
    end

    -- Right Right Case
    if balance < -1 and getBalance(node.Right) <= 0 then
        return rotateLeft(node)
    end

    -- Right Left Case
    if balance < -1 and node.Right and getBalance(node.Right) > 0 then
        node.Right = rotateRight(node.Right)
        return rotateLeft(node)
    end

    return node
end

function AVLTree.new<T>(compare: (T, T) -> number): Class<T>
    return setmetatable({
        Root = nil :: Node<T>?,
        Compare = compare,

        _lookup = {},
        _size = 0,
    }, AVLTree)
end

function AVLTree.Insert<T>(self: Class<T>, value: T)
    self.Root = insertNode(self, self.Root, value)
end

function AVLTree.Remove<T>(self: Class<T>, value: T)
    self.Root = removeNode(self, self.Root, value)
    self._lookup[value] = nil
end

function AVLTree.Find<T>(self: Class<T>, value: T): T?
    local node = self.Root

    while node do
        local comp = self.Compare(value, node.Value)

        if comp == 0 then
            return node.Value
        elseif comp < 0 then
            node = node.Left
        else
            node = node.Right
        end
    end

    return nil
end

function AVLTree.Contains(self: Class, value: any): boolean
    local node = self:Find(value)
    return node ~= nil
end

function AVLTree.GetHeight(self: Class): number
    return getHeight(self.Root)
end

function AVLTree.InOrder<T>(self: Class<T>): () -> T?
    return coroutine.wrap(function()
        if self.Root then
            inOrder(self.Root)
        end
    end) :: () -> T?
end

function AVLTree.PreOrder<T>(self: Class<T>): () -> T?
    return coroutine.wrap(function()
        if self.Root then
            preOrder(self.Root)
        end
    end) :: () -> T?
end

function AVLTree.PostOrder<T>(self: Class<T>): () -> T?
    return coroutine.wrap(function()
        if self.Root then
            postOrder(self.Root)
        end
    end) :: () -> T?
end

function AVLTree.First<T>(self: Class<T>): T?
    local node = self.Root

    while node and node.Left do
        node = node.Left
    end

    return node and node.Value
end

function AVLTree.Last<T>(self: Class<T>): T?
    local node = self.Root

    while node and node.Right do
        node = node.Right
    end

    return node and node.Value
end

function AVLTree.IsEmpty(self: Class): boolean
    return self.Root == nil
end

function AVLTree.Clear(self: Class)
    self.Root = nil
    self._lookup = {}
end

function AVLTree.__iter<T>(self: Class)
    return AVLTree.InOrder(self)
end

return AVLTree


local Shared = script.Parent
local Squash = require(Shared.Squash)

local VLQ = Squash.vlq()
local F32 = Squash.f32()
local I32 = Squash.i32()
local U32 = Squash.u32()
local U8 = Squash.u8()

type Cursor = Squash.Cursor
type SerDes<T...> = Squash.SerDes<T...>

--------------------------------------------
-- SerDes (Interleaved)
--------------------------------------------

local function createSerInterleaved(sizeof: number, raw: boolean, ser: (cursor: Cursor, values: {number}, i: number) -> ())
    return function (cursor: Cursor, values: { number })
        local count = #values
        local interleaves = {}

        for i = 1, sizeof do
            interleaves[i] = {}
        end

        for i, value in values do
            ser(cursor, values, i)

            for j = 1, sizeof do
                interleaves[j][i] = U8.des(cursor)
            end
        end

        for j = sizeof, 1, -1 do
            local interleave = interleaves[j]

            for i = count, 1, -1 do
                U8.ser(cursor, interleave[i])
            end
        end

        if raw then
            return
        end

        VLQ.ser(cursor, count)
    end
end

local function createDesInterleaved(sizeof: number, des: (cursor: Cursor, values: {number}, i: number) -> number)
    return function (cursor: Cursor): { number }
        local interleaves = {}
        local count = VLQ.des(cursor)

        for i = 1, sizeof do
            interleaves[i] = {}
        end

        for i = 1, sizeof do
            local interleave = interleaves[i]

            for j = 1, count do
                interleave[j] = U8.des(cursor)
            end
        end

        local values = {}

        for i = 1, count do
            for j = sizeof, 1, -1 do
                local interleave = interleaves[j]
                U8.ser(cursor, interleave[i])
            end

            values[i] = des(cursor, values, i)
        end

        return values
    end
end

--------------------------------------------
-- F32
--------------------------------------------

local function compressF32(cursor: Cursor, values: { number }, i: number)
    -- Read the value from the array.
    local value = values[i]

    -- Write the float onto the stack.
    F32.ser(cursor, value)

    -- Read the float back as u32 for bit manipulation.
    local bits = U32.des(cursor)

    -- Shift the sign bit to the front for better compression.
    bits = bit32.bor(bit32.lshift(bits, 1), bit32.rshift(bits, 31))

    -- Push it back onto the stack.
    U32.ser(cursor, bits)
end

local function decompressF32(cursor: Cursor, values: { number }, i: number): number
    -- Read the bits from the stack.
    local bits = U32.des(cursor)

    -- Shift the sign bit back to its original position.
    bits = bit32.bor(bit32.rshift(bits, 1), bit32.lshift(bit32.band(bits, 1), 31))

    -- Push the bits onto the stack.
    U32.ser(cursor, bits)

    -- Read it as a float.
    return F32.des(cursor)
end

local desInterleaved_F32 = createDesInterleaved(4, decompressF32)
local serInterleaved_F32 = createSerInterleaved(4, false, compressF32)
local serInterleavedRaw_F32 = createSerInterleaved(4, true, compressF32)

--------------------------------------------
-- I32
--------------------------------------------

local function compressI32(cursor: Cursor, values: { number }, i: number)
    local value = values[i]

    I32.ser(cursor, value)
    value = I32.des(cursor)

    local sign = value < 0 and 1 or 0
    value = math.abs(value)
    value = bit32.lshift(value, 1)
    value += sign

    U32.ser(cursor, value)
end

local function decompressI32(cursor: Cursor, values: { number }, i: number): number
    local value = U32.des(cursor)
    local sign = value % 2 > 0 and -1 or 1

    value = bit32.rshift(value, 1)
    value *= sign

    return value
end

local desInterleaved_I32 = createDesInterleaved(4, decompressI32)
local serInterleaved_I32 = createSerInterleaved(4, false, compressI32)
local serInterleavedRaw_I32 = createSerInterleaved(4, true, compressI32)

--------------------------------------------
-- Vector2
--------------------------------------------

local function serInterleavedRaw_Vector2(cursor: Cursor, values: { Vector2 }, raw: boolean?)
    local xs, ys = {}, {}

    for i, value in values do
        xs[i] = value.X
        ys[i] = value.Y
    end

    serInterleavedRaw_F32(cursor, ys)
    serInterleavedRaw_F32(cursor, xs)

    if not raw then
        VLQ.ser(cursor, #values)
    end
end

local function serInterleaved_Vector2(cursor: Cursor, values: { Vector2 })
    serInterleavedRaw_Vector2(cursor, values, false)
end

local function desInterleaved_Vector2(cursor: Cursor): { Vector2 }
    local count = VLQ.des(cursor)
    local xy = {}

    for i = 1, 2 do
        VLQ.ser(cursor, count)
        xy[i] = desInterleaved_F32(cursor)
    end

    local xs, ys = unpack(xy)
    local values = {}

    for i = 1, count do
        values[i] = Vector2.new(xs[i], ys[i])
    end

    return values
end

--------------------------------------------
-- Vector3
--------------------------------------------

local function serInterleaved_Vector3(cursor: Cursor, values: { Vector3 })
    local xs, ys, zs = {}, {}, {}

    for i, value in values do
        xs[i] = value.X
        ys[i] = value.Y
        zs[i] = value.Z
    end

    serInterleavedRaw_F32(cursor, zs)
    serInterleavedRaw_F32(cursor, ys)
    serInterleavedRaw_F32(cursor, xs)

    VLQ.ser(cursor, #values)
end

local desInterleaved_Vector3 = function (cursor: Cursor): { Vector3 }
    local count = VLQ.des(cursor)
    local xyz = {}

    for i = 1, 3 do
        VLQ.ser(cursor, count)
        xyz[i] = desInterleaved_F32(cursor)
    end

    local xs, ys, zs = unpack(xyz)
    local values = {}

    for i = 1, count do
        values[i] = Vector3.new(xs[i], ys[i], zs[i])
    end

    return values
end

--------------------------------------------
-- Color3
--------------------------------------------

local serInterleaved_Color3 = function (cursor: Cursor, values: { Color3 })
    local rs, gs, bs = {}, {}, {}

    for i, value in values do
        rs[i] = value.R
        gs[i] = value.G
        bs[i] = value.B
    end

    serInterleavedRaw_F32(cursor, bs)
    serInterleavedRaw_F32(cursor, gs)
    serInterleavedRaw_F32(cursor, rs)

    VLQ.ser(cursor, #values)
end

local desInterleaved_Color3 = function (cursor: Cursor): { Color3 }
    -- Read count.
    local count = VLQ.des(cursor)
    local rgb = {}

    for i = 1, 3 do
        -- Push count for each channel.
        VLQ.ser(cursor, count)

        -- Read channel.
        rgb[i] = desInterleaved_F32(cursor)
    end

    -- Unpack channels.
    local r, g, b = unpack(rgb)

    -- Build values.
    local values = {}

    for i = 1, count do
        values[i] = Color3.new(r[i], g[i], b[i])
    end

    return values
end

--------------------------------------------
-- BrickColor
--------------------------------------------

local serInterleaved_BrickColor = function (cursor: Cursor, values: { BrickColor })
    local ids = {}

    for i, value in values do
        ids[i] = value.Number
    end

    serInterleaved_I32(cursor, ids)
end

local desInterleaved_BrickColor = function (cursor: Cursor): { BrickColor }
    local ids = desInterleaved_I32(cursor)
    local values = {}

    for i, id in ids do
        values[i] = BrickColor.new(id)
    end

    return values
end

--------------------------------------------
-- UDim
--------------------------------------------

local function serInterleavedRaw_UDim(cursor: Cursor, values: { UDim }, raw: boolean)
    local scales, offsets = {}, {}

    for i, value in values do
        scales[i] = value.Scale
        offsets[i] = value.Offset
    end

    serInterleavedRaw_I32(cursor, offsets)
    serInterleavedRaw_F32(cursor, scales)

    if not raw then
        VLQ.ser(cursor, #values)
    end
end

local serInterleaved_UDim = function (cursor: Cursor, values: { UDim })
    serInterleavedRaw_UDim(cursor, values, false)
end

local desInterleaved_UDim = function (cursor: Cursor): { UDim }
    -- Read count.
    local count = VLQ.des(cursor)

    -- Push count for scales.
    VLQ.ser(cursor, count)

    -- Read scales.
    local scales = desInterleaved_F32(cursor)

    -- Push count for offsets.
    VLQ.ser(cursor, count)

    -- Read offsets.
    local offsets = desInterleaved_I32(cursor)

    -- Build values.
    local values = {}

    for i = 1, count do
        values[i] = UDim.new(scales[i], offsets[i])
    end

    return values
end

--------------------------------------------
-- UDim2
--------------------------------------------

local function serInterleaved_UDim2(cursor: Cursor, values: { UDim2 })
    local x, y = {}, {}

    for i, value in values do
        x[i] = value.X
        y[i] = value.Y
    end

    serInterleavedRaw_UDim(cursor, y, true)
    serInterleavedRaw_UDim(cursor, x, true)
    VLQ.ser(cursor, #values)
end

local function desInterleaved_UDim2(cursor: Cursor): { UDim2 }
    local count = VLQ.des(cursor)
    local dims = {}

    for i = 1, 2 do
        VLQ.ser(cursor, count)
        dims[i] = desInterleaved_UDim(cursor)
    end

    local x, y = unpack(dims)
    local values = {}

    for i = 1, count do
        values[i] = UDim2.new(x[i], y[i])
    end

    return values
end

--------------------------------------------
-- Rect
--------------------------------------------

local function serInterleaved_Rect(cursor: Cursor, values: { Rect })
    local mins, maxs = {}, {}

    for i, value in values do
        mins[i] = value.Min
        maxs[i] = value.Max
    end

    serInterleavedRaw_Vector2(cursor, maxs, true)
    serInterleavedRaw_Vector2(cursor, mins, true)
    VLQ.ser(cursor, #values)
end

local function desInterleaved_Rect(cursor: Cursor): { Rect }
    local count = VLQ.des(cursor)
    local aabbs = {}

    for i = 1, 2 do
        VLQ.ser(cursor, count)
        aabbs[i] = desInterleaved_Vector2(cursor)
    end

    local mins, maxs = unpack(aabbs)
    local values = {}

    for i = 1, count do
        values[i] = Rect.new(mins[i], maxs[i])
    end

    return values
end

--------------------------------------------
-- SerDes (Accumulated)
--------------------------------------------

local function createSerAccumulated(ser: (cursor: Cursor, values: {number}) -> ())
    return function (cursor: Cursor, values: { number })
        local prev = 0
        local accumulated = {}

        for i, value in values do
            local delta = value - prev
            accumulated[i] = delta
            prev = value
        end

        ser(cursor, accumulated)
    end
end

local function createDesAccumulated(des: (cursor: Cursor) -> { number })
    return function (cursor: Cursor): { number }
        local accumulated = des(cursor)
        local values = {}
        local prev = 0

        for i, delta in accumulated do
            local value = prev + delta
            values[i] = value
            prev = value
        end

        return values
    end
end

--------------------------------------------
-- F32 (Accumulated)
--------------------------------------------

local serAccumulated_F32 = createSerAccumulated(serInterleaved_F32)
local desAccumulated_F32 = createDesAccumulated(desInterleaved_F32)

--------------------------------------------
-- I32 (Accumulated)
--------------------------------------------

local serAccumulated_I32 = createSerAccumulated(serInterleaved_I32)
local desAccumulated_I32 = createDesAccumulated(desInterleaved_I32)

--------------------------------------------

return table.freeze({
    Interleaved = table.freeze({
        F32 = table.freeze({
            ser = serInterleaved_F32,
            des = desInterleaved_F32,
        }),

        I32 = table.freeze({
            ser = serInterleaved_I32,
            des = desInterleaved_I32,
        }),

        BrickColor = table.freeze({
            ser = serInterleaved_BrickColor,
            des = desInterleaved_BrickColor,
        }),

        Color3 = table.freeze({
            ser = serInterleaved_Color3,
            des = desInterleaved_Color3,
        }),

        Rect = table.freeze({
            ser = serInterleaved_Rect,
            des = desInterleaved_Rect,
        }),

        Vector2 = table.freeze({
            ser = serInterleaved_Vector2,
            des = desInterleaved_Vector2,
        }),

        Vector3 = table.freeze({
            ser = serInterleaved_Vector3,
            des = desInterleaved_Vector3,
        }),

        UDim = table.freeze({
            ser = serInterleaved_UDim,
            des = desInterleaved_UDim,
        }),

        UDim2 = table.freeze({
            ser = serInterleaved_UDim2,
            des = desInterleaved_UDim2,
        }),
    }),

    Accumulated = table.freeze({
        F32 = table.freeze({
            ser = serAccumulated_F32,
            des = desAccumulated_F32,
        }),

        I32 = table.freeze({
            ser = serAccumulated_I32,
            des = desAccumulated_I32,
        }),
    }),
})


--!native
--!strict

--- Reads a 16-bit floating point number (half-precision) from a buffer at the given offset.
local function readf16(buf: buffer, offset: number): number
    local b0 = buffer.readu8(buf, offset)
    local b1 = buffer.readu8(buf, offset + 1)

    local sign = bit32.btest(b0, 128) and -1 or 1
    local exponent = bit32.rshift(bit32.band(b0, 127), 2)
    local mantissa = bit32.lshift(bit32.band(b0, 3), 8) + b1

    if exponent == 31 then --2^5-1
        if mantissa ~= 0 then
            return (0 / 0)
        else
            return math.huge * sign
        end
    elseif exponent == 0 then
        if mantissa == 0 then
            return 0
        else
            return math.ldexp(mantissa / 1024, -14) * sign
        end
    end

    return math.ldexp((mantissa / 1024) + 1, exponent - 15) * sign
end

--- Writes a 16-bit floating point number (half-precision) to a buffer at the given offset.
local function writef16(buf: buffer, offset: number, value: number)
    local mantissa, exponent
    local sign = value < 0

    value = math.abs(value)
    mantissa, exponent = math.frexp(value)

    if value == math.huge then
        if sign then
            buffer.writeu8(buf, offset, 0b_11111100)
        else
            buffer.writeu8(buf, offset, 0b_01111100)
        end

        buffer.writeu8(buf, offset + 1, 0b_00000000)
    elseif value ~= value or value == 0 then
        buffer.writeu16(buf, offset, 0)
    elseif exponent + 15 <= 1 then -- Bias for halfs is 15
        mantissa = math.floor(mantissa * 1024 + 0.5)

        if sign then
            buffer.writeu8(buf, offset, (128 + bit32.rshift(mantissa, 8))) -- Sign bit, 5 empty bits, 2 from mantissa
        else
            buffer.writeu8(buf, offset, (bit32.rshift(mantissa, 8)))
        end

        buffer.writeu8(buf, offset + 1, bit32.band(mantissa, 255)) -- Get last 8 bits from mantissa
    else
        mantissa = ((mantissa - 0.5) * 2048 + 0.5) // 1

        -- The bias for halfs is 15, 15-1 is 14
        if sign then
            buffer.writeu8(buf, offset, (128 + bit32.lshift(exponent + 14, 2) + bit32.rshift(mantissa, 8)))
        else
            buffer.writeu8(buf, offset, (bit32.lshift(exponent + 14, 2) + bit32.rshift(mantissa, 8)))
        end

        buffer.writeu8(buf, offset + 1, bit32.band(mantissa, 255))
    end
end

--- Reads a signed 53-bit integer from a buffer at the given offset.
--- * Note: Lua numbers are double-precision floats, so they can accurately represent integers up to 53 bits.
local function readi53(buf: buffer, offset: number): number
    local lo = buffer.readu32(buf, offset)
    local hi = buffer.readi32(buf, offset + 4)
    return (hi * 0x7FFFFFFF) + lo
end

--- Writes a signed 53-bit integer to a buffer at the given offset.
--- * Note: Lua numbers are double-precision floats, so they can accurately represent integers up to 53 bits.
local function writei53(buf: buffer, offset: number, value: number)
    local lo = value % 0x7FFFFFFF
    local hi = value // 0x7FFFFFFF

    buffer.writeu32(buf, offset, lo)
    buffer.writei32(buf, offset + 4, hi)
end

--- Reads an unsigned 53-bit integer from a buffer at the given offset.
--- * Note: Lua numbers are double-precision floats, so they can accurately represent integers up to 53 bits.
local function readu53(buf: buffer, offset: number): number
    local lo = buffer.readu32(buf, offset)
    local hi = buffer.readu32(buf, offset + 4)
    return (hi * 0x100000000) + lo
end

--- Writes an unsigned 53-bit integer to a buffer at the given offset.
--- * Note: Lua numbers are double-precision floats, so they can accurately represent integers up to 53 bits.
local function writeu53(buf: buffer, offset: number, value: number)
    local lo = value % 0x100000000
    local hi = value // 0x100000000

    buffer.writeu32(buf, offset, lo)
    buffer.writeu32(buf, offset + 4, hi)
end

--- Reads a variable-length integer from a buffer at the given offset.
--- Returns the integer and the new offset.
local function readVarInt(buf: buffer, offset: number): (number, number)
    local result = 0
    local shift = 0
    local byte

    repeat
        byte = buffer.readu8(buf, offset)
        result += bit32.lshift(bit32.band(byte, 127), shift)

        offset += 1
        shift += 7
    until bit32.btest(byte, 128)

    return result, offset
end

--- Writes a variable-length integer to a buffer at the given offset.
--- Returns the new offset.
local function writeVarInt(buf: buffer, offset: number, value: number): number
    repeat
        local byte = bit32.band(value, 127)
        value = bit32.rshift(value, 7)

        if value ~= 0 then
            byte = bit32.bor(byte, 128)
        end

        buffer.writeu8(buf, offset, byte)
        offset += 1
    until value == 0

    return offset
end

return table.freeze({
    ReadInt53 = readi53,
    WriteInt53 = writei53,

    ReadUInt53 = readu53,
    WriteUInt53 = writeu53,

    ReadFloat16 = readf16,
    WriteFloat16 = writef16,

    ReadVarInt = readVarInt,
    WriteVarInt = writeVarInt,
})


-- CrunchTable lets you define compression schemes for simple tables to be sent by roblox
-- If a field in a table is not defined in the layout, it will be ignored and stay in the table
-- If a field in a table is not present, but is defined in the layout, it'll default to 0 (or equiv)

--!native
--!strict

local CrunchTable = {}
local Shared = script.Parent

local Guid = require(Shared.Guid)
local BufferExtras = require(Shared.BufferExtras)

export type LazyTable = {
    [any]: any
}

export type BinaryTable = {
    _b: buffer,
    [string]: any,
}

CrunchTable.Enum = table.freeze({
    IGNORE = 0,

    BOOL = 1,
    BOOLEAN = 1,

    UINT8 = 2,
    BYTE = 2,

    INT8 = 3,
    SBYTE = 3,

    UINT16 = 4,
    USHORT = 4,

    INT16 = 5,
    SHORT = 5,

    UINT32 = 6,
    UINT = 6,

    INT32 = 7,
    INT = 7,

    FLOAT16 = 8,
    HALF = 8,

    FLOAT32 = 9,
    SINGLE = 9,
    FLOAT = 9,

    FLOAT64 = 10,
    DOUBLE = 10,

    INT53 = 11,
    UINT53 = 12,

    Vector2 = 13,
    Vector3 = 14,
    NumberRange = 15,

    STRING8 = 16,
    STRING16 = 17,
    STRING24 = 18,
    STRING32 = 19,
    STRING64 = 20,

    GUID = 21,
})

local building = {} :: {
    [Layout]: boolean
}

local meta = {} :: {
    [number]: {
        Size: number,
        Default: any,
        Reader: (buffer, number) -> any,
        Writer: (buffer, number, any) -> (),
    }
}

local function stringIO(size: number)
    return {
        Size = size,
        Default = "",

        Reader = function(buf: buffer, offset: number): string
            local str = buffer.readstring(buf, offset, size)
            return str:gsub("\0", "")
        end,

        Writer = function(buf: buffer, offset: number, value: string)
            for i = 1, math.min(#value, size) do
                local char = value:sub(i, i)
                buffer.writeu8(buf, offset + i - 1, char:byte())
            end
        end,
    }
end

meta[CrunchTable.Enum.IGNORE] = table.freeze({
    Size = 0,
    Default = nil,
    Reader = function() end,
    Writer = function() end,
})

meta[CrunchTable.Enum.BOOL] = table.freeze({
    Size = 1,
    Default = false,

    Reader = function(buf: buffer, offset: number): boolean
        return buffer.readu8(buf, offset) > 0
    end,

    Writer = function(buf: buffer, offset: number, value: boolean)
        buffer.writeu8(buf, offset, value and 1 or 0)
    end,
})

meta[CrunchTable.Enum.UINT8] = table.freeze({
    Size = 1,
    Default = 0,
    Reader = buffer.readu8,
    Writer = buffer.writeu8,
})

meta[CrunchTable.Enum.INT8] = table.freeze({
    Size = 1,
    Default = 0,
    Reader = buffer.readi8,
    Writer = buffer.writei8,
})

meta[CrunchTable.Enum.UINT16] = table.freeze({
    Size = 2,
    Default = 0,
    Reader = buffer.readu16,
    Writer = buffer.writeu16,
})

meta[CrunchTable.Enum.INT16] = table.freeze({
    Size = 2,
    Default = 0,
    Reader = buffer.readi16,
    Writer = buffer.writei16,
})

meta[CrunchTable.Enum.UINT32] = table.freeze({
    Size = 4,
    Default = 0,
    Reader = buffer.readu32,
    Writer = buffer.writeu32,
})

meta[CrunchTable.Enum.INT32] = {
    Size = 4,
    Default = 0,
    Reader = buffer.readi32,
    Writer = buffer.writei32,
}

meta[CrunchTable.Enum.FLOAT16] = table.freeze({
    Size = 2,
    Default = 0,

    Reader = BufferExtras.ReadFloat16,
    Writer = BufferExtras.WriteFloat16,
})

meta[CrunchTable.Enum.FLOAT32] = table.freeze({
    Size = 4,
    Default = 0,
    Reader = buffer.readf32,
    Writer = buffer.writef32,
})

meta[CrunchTable.Enum.FLOAT64] = table.freeze({
    Size = 8,
    Default = 0,
    Reader = buffer.readf64,
    Writer = buffer.writef64,
})

meta[CrunchTable.Enum.INT53] = table.freeze({
    Size = 8,
    Default = 0,

    Reader = BufferExtras.ReadInt53,
    Writer = BufferExtras.WriteInt53,
})

meta[CrunchTable.Enum.UINT53] = table.freeze({
    Size = 8,
    Default = 0,

    Reader = BufferExtras.ReadUInt53,
    Writer = BufferExtras.WriteUInt53,
})

meta[CrunchTable.Enum.Vector2] = {
    Size = 8,
    Default = Vector2.zero,

    Reader = function(buf: buffer, offset: number): Vector2
        local x = buffer.readf32(buf, offset)
        local y = buffer.readf32(buf, offset + 4)
        return Vector2.new(x, y)
    end,

    Writer = function(buf: buffer, offset: number, value: Vector2)
        buffer.writef32(buf, offset, value.X)
        buffer.writef32(buf, offset + 4, value.Y)
    end,
}

meta[CrunchTable.Enum.Vector3] = {
    Size = 12,
    Default = Vector3.zero,

    Reader = function(buf: buffer, offset: number): Vector3
        local x = buffer.readf32(buf, offset)
        local y = buffer.readf32(buf, offset + 4)
        local z = buffer.readf32(buf, offset + 8)

        return Vector3.new(x, y, z)
    end,

    Writer = function(buf: buffer, offset: number, value: Vector3)
        buffer.writef32(buf, offset, value.X)
        buffer.writef32(buf, offset + 4, value.Y)
        buffer.writef32(buf, offset + 8, value.Z)
    end,
}

meta[CrunchTable.Enum.NumberRange] = {
    Size = 8,
    Default = NumberRange.new(0),

    Reader = function(buf: buffer, offset: number): NumberRange
        local min = buffer.readf32(buf, offset)
        local max = buffer.readf32(buf, offset + 4)
        return NumberRange.new(min, max)
    end,

    Writer = function(buf: buffer, offset: number, value: NumberRange)
        buffer.writef32(buf, offset, value.Min)
        buffer.writef32(buf, offset + 4, value.Max)
    end,
}

meta[CrunchTable.Enum.GUID] = {
    Size = 16,
    Default = "00000000-0000-0000-0000-000000000000",

    Reader = Guid.Decompress,
    Writer = Guid.Compress,
}

meta[CrunchTable.Enum.STRING8] = stringIO(8)
meta[CrunchTable.Enum.STRING16] = stringIO(16)
meta[CrunchTable.Enum.STRING24] = stringIO(24)
meta[CrunchTable.Enum.STRING32] = stringIO(32)
meta[CrunchTable.Enum.STRING64] = stringIO(64)

local Layout = {}
Layout.__index = Layout

type FieldInfo = {
    Required: boolean,
    Default: any,
    Name: string,
    Enum: number,
    Size: number,

    Read: (buffer, number) -> any,
    Write: (buffer, number, any) -> (),
}

-- stylua: ignore
export type Layout = typeof(setmetatable({} :: {
    _building: boolean?,

    FieldsByName: {
        [string]: FieldInfo,
    },

    Fields: {FieldInfo},
    FieldBytes: number,
    TotalBytes: number,

    ContentBytes: number,
    ContentReader: (buffer, number) -> number,
    ContentWriter: (buffer, number, number) -> (),
}, Layout))

local function calcSize(self: Layout)
    if building[self] then
        return
    end

    local fields = self.Fields
    local numFields = #fields

    local contentBytes
    local contentReader
    local contentWriter

    if numFields <= 8 then
        contentBytes = 1
        contentReader = buffer.readu8
        contentWriter = buffer.writeu8
    elseif numFields <= 16 then
        contentBytes = 2
        contentReader = buffer.readu16
        contentWriter = buffer.writeu16
    elseif numFields <= 32 then
        contentBytes = 4
        contentReader = buffer.readu32
        contentWriter = buffer.writeu32
    else
        error("!! FATAL: Exceeded limit of 32 fields in CrunchTable layout!")
    end

    local fieldBytes = 0
    self.ContentBytes = contentBytes
    self.ContentReader = contentReader
    self.ContentWriter = contentWriter

    for index, rec in self.Fields do
        fieldBytes += rec.Size
    end

    self.FieldBytes = fieldBytes
    self.TotalBytes = contentBytes + fieldBytes
end

--[=[
    Call this to optimize redundant calculations when adding fields to the layout.

    **IMPORTANT:** Make sure to call End() after you're done adding fields

    @param fieldsAreRequiredByDefault If `true`, all fields added will be marked as **required** unless marked otherwise. If `false`, all fields added will be marked as **optional** unless marked otherwise. Default: `true`.
    @return The `Layout` object.
]=]
function Layout.Begin(self: Layout, fieldsAreRequiredByDefault: boolean?): Layout
    if fieldsAreRequiredByDefault == nil then
        building[self] = true
    else
        building[self] = fieldsAreRequiredByDefault
    end

    return self
end

--[=[
    Finishes the layout and calculates its size.
]=]
function Layout.End(self: Layout)
    if building[self] ~= nil then
        building[self] = nil
        calcSize(self)
    end
end

function Layout.Add(self: Layout, field: string, enum: number, required: boolean?): Layout
    local fieldMeta = meta[enum]

    local isRequired = if required == nil
        then building[self]
        else required

    local fieldInfo = table.freeze({
        Required = isRequired,
        Default = fieldMeta.Default,
        Write = fieldMeta.Writer,
        Read = fieldMeta.Reader,
        Size = fieldMeta.Size,
        Name = field,
        Enum = enum,
    })

    self.FieldsByName[field] = fieldInfo
    table.insert(self.Fields, fieldInfo)

    calcSize(self)
    return self
end

function Layout.GetFieldInfo(self: Layout, field: string): FieldInfo?
    return self.FieldsByName[field]
end

function Layout.WriteToBuffer(self: Layout, srcData: LazyTable, buf: buffer, start: number?, onWrite: (key: string) -> ()?): number
    local write = self.ContentWriter
    local offset = (start or 0) + self.ContentBytes

    local contents = 0
    local bit = 1

    for index, rec in self.Fields do
        local key = rec.Name
        local srcValue = srcData[key]

        if srcValue ~= nil then
            rec.Write(buf, offset, srcValue)
            offset += rec.Size
            contents += bit

            if onWrite then
                onWrite(key)
            end
        end

        bit += bit
    end

    write(buf, start or 0, contents)
    return offset
end

function Layout.Compress(self: Layout, srcData: LazyTable): BinaryTable
    local packet = table.clone(srcData)

    -- Create buffer with max possible size.
    local work = buffer.create(self.TotalBytes)

    local size = self:WriteToBuffer(srcData, work, 0, function(key: string)
        packet[key] = nil
    end)

    -- Copy the buffer to a new one with the exact size.
    local b = buffer.create(size)
    buffer.copy(b, 0, work, 0, size)
    packet._b = b

    return packet
end

function Layout.ReadFromBuffer<V>(self: Layout, buf: buffer, start: number, callback: (key: string, value: V) -> ()): number
    local reader = self.ContentReader
    local offset = start or 0

    local contentBits
    local bit = 1

    contentBits = reader(buf, offset)
    offset += self.ContentBytes

    for index, rec in self.Fields do
        if bit32.btest(contentBits, bit) then
            local value = rec.Read(buf, offset)
            callback(rec.Name, value)
            offset += rec.Size
        elseif rec.Required then
            callback(rec.Name, rec.Default)
        end

        bit += bit
    end

    return offset
end

function Layout.Decompress(self: Layout, packet: BinaryTable): LazyTable
    local buf = packet._b

    local data: LazyTable = table.clone(packet)
    data._b = nil

    self:ReadFromBuffer(buf, 0, function(key: string, value: any)
        data[key] = value
    end)

    return data
end

function Layout.Clone(self: Layout): Layout
    local newLayout = setmetatable({
        FieldsByName = table.clone(self.FieldsByName),
        Fields = table.clone(self.Fields),

        TotalBytes = self.TotalBytes,
        FieldBytes = self.FieldBytes,
        ContentBytes = self.ContentBytes,

        ContentReader = self.ContentReader,
        ContentWriter = self.ContentWriter,
    }, Layout)

    return newLayout
end

function CrunchTable.new(lazyFields: { [string]: number }?): Layout
    local layout = setmetatable({
        FieldsByName = {},
        Fields = {},

        TotalBytes = 0,
        FieldBytes = 0,
        ContentBytes = 0,

        ContentReader = buffer.readu8,
        ContentWriter = buffer.writeu8,
    }, Layout)

    if lazyFields then
        -- Order needs to be deterministic so its constant across all clients.
        -- If the order shouldn't be alphabetical, use the Begin/End API directly.
        local keys = {}

        for key in lazyFields do
            table.insert(keys, key)
        end

        layout:Begin(false)
        table.sort(keys)

        for i, key in keys do
            local enum = lazyFields[key]
            layout:Add(key, enum)
        end

        layout:End()
    end

    return layout
end

return CrunchTable