local types = require("@self/../types")

type Implementation<T> = { new: (() -> T)?, implementation: T? }

export type RuntimeName = "roblox" | "luau" | "lune" | "lute" | "seal" | "zune"

local supportedRuntimes = table.freeze({
	roblox = require("@self/roblox"),
	luau = require("@self/luau"),
	lune = require("@self/lune"),
	lute = require("@self/lute"),
	seal = require("@self/seal"),
	zune = require("@self/zune"),
})

local currentRuntime: types.Runtime = nil
do
	for _, runtime in supportedRuntimes :: { [string]: types.Runtime } do
		local isRuntime = runtime.is()
		if isRuntime then
			local currentRuntimePriority = currentRuntime and currentRuntime.priority or 0
			local runtimePriority = runtime.priority or 0
			if currentRuntimePriority <= runtimePriority then
				currentRuntime = runtime
			end
		end
	end
	assert(currentRuntime, "No supported runtime")
end

local currentRuntimeName: RuntimeName = currentRuntime.name :: any

local function notImplemented(functionName: string)
	return function()
		error(`'{functionName}' is not implemented by runtime '{currentRuntimeName}'`, 2)
	end
end

local function implementFunction<T>(label: string, implementations: { [types.Runtime]: Implementation<T>? }): T
	local implementation = implementations[currentRuntime]
	if implementation then
		if implementation.new then
			return implementation.new()
		elseif implementation.implementation then
			return implementation.implementation
		end
	end
	return notImplemented(label) :: any
end

return table.freeze({
	supportedRuntimes = supportedRuntimes,
	currentRuntime = currentRuntime,
	currentRuntimeName = currentRuntimeName,
	implementFunction = implementFunction,
})


local types = require("../types")

return types.Runtime({
	name = "luau",
	priority = -1,
	is = function()
		return true
	end,
})


local types = require("../types")

local LUNE_VERSION_FORMAT = "(Lune) (%d+%.%d+%.%d+.*)+(%d+)"

return types.Runtime({
	name = "lune",
	is = function()
		return string.match(_VERSION, LUNE_VERSION_FORMAT) ~= nil
	end,
})


local types = require("../types")

return types.Runtime({
	name = "lute",
	is = function()
		return false
	end,
})


local types = require("../types")

return types.Runtime({
	name = "roblox",
	is = function()
		-- wtf
		return not not (_VERSION == "Luau" and game and workspace)
	end,
})


local types = require("../types")

return types.Runtime({
	name = "seal",
	is = function()
		return false
	end,
})


local types = require("../types")

local ZUNE_VERSION_FORMAT = "(Zune) (%d+%.%d+%.%d+.*)+(%d+%.%d+)"

return types.Runtime({
	name = "zune",
	is = function()
		return string.match(_VERSION, ZUNE_VERSION_FORMAT) ~= nil
	end,
})


local implementations = require("@self/implementations")
local task = require("@self/libs/task")
local threadpool = require("@self/batteries/threadpool")
local warn = require("@self/libs/warn")

export type RuntimeName = implementations.RuntimeName

return table.freeze({
	name = implementations.currentRuntimeName,

	task = task,
	warn = warn,

	threadpool = threadpool,
})


local implementations = require("../implementations")

export type TaskLib = {
	cancel: (thread) -> (),
	defer: <T...>(functionOrThread: thread | (T...) -> (), T...) -> thread,
	delay: <T...>(duration: number, functionOrThread: thread | (T...) -> (), T...) -> thread,
	spawn: <T...>(functionOrThread: thread | (T...) -> (), T...) -> thread,
	wait: (duration: number?) -> number,
}

local supportedRuntimes = implementations.supportedRuntimes
local implementFunction = implementations.implementFunction

local function defaultSpawn<T...>(functionOrThread: thread | (T...) -> (), ...: T...): thread
	if type(functionOrThread) == "thread" then
		coroutine.resume(functionOrThread, ...)
		return functionOrThread
	else
		local thread = coroutine.create(functionOrThread)
		coroutine.resume(thread, ...)
		return thread
	end
end

local function defaultWait(seconds: number?)
	local startTime = os.clock()
	local endTime = startTime + (seconds or 1)
	local clockTime: number
	repeat
		clockTime = os.clock()
	until clockTime >= endTime
	return clockTime - startTime
end

local task: TaskLib = table.freeze({
	cancel = implementFunction("task.wait", {
		[supportedRuntimes.roblox] = {
			new = function()
				return task.cancel
			end,
		},
		[supportedRuntimes.lune] = {
			new = function()
				return require("@lune/task").cancel
			end,
		},
		[supportedRuntimes.zune] = {
			new = function()
				return require("@zcore/task").cancel
			end,
		},
		[supportedRuntimes.luau] = {
			implementation = function(thread)
				if not coroutine.close(thread) then
					error(debug.traceback(thread, "Could not cancel thread"))
				end
			end,
		},
	}),
	defer = implementFunction("task.defer", {
		[supportedRuntimes.roblox] = {
			new = function()
				return task.defer :: any
			end,
		},
		[supportedRuntimes.lune] = {
			new = function()
				return require("@lune/task").defer
			end,
		},
		[supportedRuntimes.zune] = {
			new = function()
				return require("@zcore/task").defer
			end,
		},
		[supportedRuntimes.luau] = {
			implementation = defaultSpawn,
		},
	}),
	delay = implementFunction("task.delay", {
		[supportedRuntimes.roblox] = {
			new = function()
				return task.delay :: any
			end,
		},
		[supportedRuntimes.lune] = {
			new = function()
				return require("@lune/task").delay
			end,
		},
		[supportedRuntimes.zune] = {
			new = function()
				return require("@zcore/task").delay
			end,
		},
		[supportedRuntimes.luau] = {
			implementation = function<T...>(duration: number, functionOrThread: thread | (T...) -> (), ...: T...): thread
				return defaultSpawn(
					if typeof(functionOrThread) == "function"
						then function(duration, functionOrThread, ...)
							defaultWait(duration)
							functionOrThread(...)
						end
						else function(duration, functionOrThread, ...)
							defaultWait(duration)
							coroutine.resume(...)
						end,
					duration,
					functionOrThread,
					...
				)
			end,
		},
	}),
	spawn = implementFunction("task.spawn", {
		[supportedRuntimes.roblox] = {
			new = function()
				return task.spawn :: any
			end,
		},
		[supportedRuntimes.lune] = {
			new = function()
				return require("@lune/task").spawn
			end,
		},
		[supportedRuntimes.zune] = {
			new = function()
				return require("@zcore/task").spawn
			end,
		},
		[supportedRuntimes.luau] = {
			implementation = defaultSpawn,
		},
	}),
	wait = implementFunction("task.wait", {
		[supportedRuntimes.roblox] = {
			new = function()
				return task.wait
			end,
		},
		[supportedRuntimes.lune] = {
			new = function()
				return require("@lune/task").wait
			end,
		},
		[supportedRuntimes.zune] = {
			new = function()
				return require("@zcore/task").wait
			end,
		},
		[supportedRuntimes.luau] = {
			implementation = defaultWait,
		},
	}),
})

return task


local implementations = require("../implementations")

local supportedRuntimes = implementations.supportedRuntimes
local implementFunction = implementations.implementFunction

local function tostringTuple(...: any)
	local stringified = {}
	for index = 1, select("#", ...) do
		table.insert(stringified, tostring(select(index, ...)))
	end
	return table.concat(stringified, " ")
end

local warn: <T...>(T...) -> () = implementFunction("warn", {
	[supportedRuntimes.roblox] = {
		new = function()
			return warn
		end,
	},
	[supportedRuntimes.luau] = {
		implementation = function<T...>(...: T...)
			print(`\27[0;33m{tostringTuple(...)}\27[0m`)
		end,
	},
})

return warn


local configure = require("@lune-lib/configure")

return configure.package({
	name = "runtime",
	description = "Runtime agnostic libraries for Prvd 'M Wrong",

	types = {
		RuntimeName = true,
	},
})


export type Runtime = {
	name: string,
	priority: number?,
	is: () -> boolean,
}

local function Runtime(x: Runtime)
	return table.freeze(x)
end

return table.freeze({
	Runtime = Runtime,
})


local configure = require("@lune-lib/configure")

return configure.package({
	name = "typecheckers",
	description = "Typechecking primitives and compatibility for Prvd 'M Wrong",
})


local fs = require("@lune/fs")
local h = require("./h")
local path = require("@lune-lib/utils/path")
local process = require("@lune/process")
local siteUrls = require("@sitegen/config/site-urls")
local sitemap = require("@sitegen/config/sitemap")

local rootPath = process.args[1]

pcall(fs.removeDir, "site")
fs.copy(path(rootPath, "static"), siteUrls.base)

for _, location in sitemap do
	local locationPath = location.path:gsub("^/", ""):gsub("/$", ""):split("/")
	local parentPath = table.clone(locationPath)
	parentPath[#parentPath] = nil

	local dirPath = path(siteUrls.base, table.unpack(locationPath))
	local filePath = path(dirPath, "index.html")

	print("Rendering page", filePath)
	local rendered = h.flatten(location.render(location.props))

	pcall(fs.writeDir, dirPath)
	fs.writeFile(filePath, rendered)
end


local Document = require("./document")
local h = require("@sitegen/h")
local siteBaseUrl = require("@sitegen/utils/site-base-url")

export type ArticleSection = {
	title: string,
	content: { h.Child },
	sections: { ArticleSection }?,
}

local headings = {
	h.h1,
	h.h2,
	h.h3,
	h.h4,
	h.h5,
	h.h6,
}

local defaultHeading = h.h6

local function Article(props: ArticleSection)
	local toc = {}

	local content = {
		h.h1(props.title) :: any,
		props.content,
	}

	local function processSections(sections: { ArticleSection }, depth: number)
		for _, section in sections do
			table.insert(content, {
				(headings[depth] or defaultHeading)(section.title),
				section.content :: any,
			})

			if section.sections then
				processSections(section.sections, depth + 1)
			end
		end
	end

	if props.sections then
		processSections(props.sections, 1)
	end

	return Document({
		title = props.title,
		extraHead = {
			h.link({ rel = "stylesheet", href = siteBaseUrl("stylesheets/article.css") }),
		},
		main = {
			h.section({
				class = "article-container",

				h.nav({
					class = "article-nav",
				}),

				h.article({
					class = "article-content",
					content,
				}),

				h.div({
					class = "article-toc",
					toc,
				}),
			}),
		},
	})
end

return Article


local h = require("@sitegen/h")

local function Button(props: {
	href: string,
	[number]: h.Child,
})
	return h.a({
		href = props.href,
		class = "button",

		table.unpack(props),
	})
end

return Button


local h = require("@sitegen/h")

local function CodeSnippet(props: {
	language: "lua" | "shell" | "typescript",

	[number]: h.Child,
})
	return h.pre({
		class = `code-snippet`,

		h.code({
			class = `language-{props.language}`,

			table.unpack(props),
		}),
	})
end

return CodeSnippet


local Navbar = require("@sitegen/components/navbar")
local h = require("@sitegen/h")
local siteBaseUrl = require("@sitegen/utils/site-base-url")

local function Document(props: {
	title: string?,
	main: h.Child,
	header: h.Child?,
	extraHead: h.Child?,
})
	return {
		"<!DOCTYPE html>",
		h.html({
			lang = "en",
			h.head({
				h.meta({ charset = "UTF-8" }),
				h.meta({ name = "viewport", content = "width=device-width, initial-scale=1.0" }),

				h.title((if props.title then `{props.title} - ` else "") .. "Prvd 'M Wrong"),

				h.link({ rel = "stylesheet", href = siteBaseUrl("stylesheets/reset.css") }),
				h.link({ rel = "stylesheet", href = siteBaseUrl("stylesheets/fonts.css") }),
				h.link({ rel = "stylesheet", href = siteBaseUrl("stylesheets/common.css") }),

				h.link({
					rel = "stylesheet",
					href = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/styles/default.min.css",
				}),
				h.script({ src = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/highlight.min.js" }),
				h.script({ src = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/lua.min.js" }),
				h.script({ src = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/ts.min.js" }),
				h.script({ src = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1/languages/shell.min.js" }),

				props.extraHead :: any,
			}),
			h.body({
				Navbar(),
				props.header and h.header(props.header) :: any,
				h.main(props.main),

				h.script("hljs.highlightAll();"),
			}),
		}),
	}
end

return Document


local forEach = require("@sitegen/utils/for-each")
local h = require("@sitegen/h")
local navPages = require("@sitegen/config/nav-pages")
local siteBaseUrl = require("@sitegen/utils/site-base-url")

local function Navbar()
	return h.nav({
		class = "navbar",

		h.div({
			class = "navbar-inner",

			h.a({
				href = siteBaseUrl(""),
				"Prvd 'M Wrong",
			}),

			forEach(navPages, function(index, page)
				return index, h.a({
					href = page.href,
					page.title,
				})
			end) :: any,
		}),
	})
end

return Navbar


local siteBaseUrl = require("@sitegen/utils/site-base-url")

export type NavPage = {
	id: string,
	href: string,
	title: string,
}

return {
	{
		id = "learn",
		href = siteBaseUrl("learn"),
		title = "Learn",
	},
	{
		id = "api",
		href = siteBaseUrl("api"),
		title = "API",
	},
	{
		id = "releases",
		href = siteBaseUrl("releases"),
		title = "Releases",
	},
	{
		id = "blog",
		href = siteBaseUrl("blog"),
		title = "Blog",
	},
} :: { NavPage }


local path = require("@lune-lib/utils/path")
local process = require("@lune/process")

return {
	base = if process.env.IS_CI then "https://prvdmwrong.luau.page/" else path(process.cwd, "site"),
}


local h = require("@sitegen/h")

export type SitemapEntry<Props = nil> = {
	path: string,
	render: (Props) -> h.Child,
	props: Props,
}

local sitemap: { SitemapEntry<any> } = {
	{ path = "", render = require("@sitegen/pages/index") },
	{ path = "learn", render = require("@sitegen/pages/learn/index") },
	{ path = "learn/installation", render = require("@sitegen/pages/learn/installation") },
}

return sitemap


export type Child = string | { Child }

local function flatten(child: Child): string
	if typeof(child) == "string" then
		return child
	else
		local flat = ""
		for _, sub_child in child do
			flat ..= flatten(sub_child)
		end
		return flat
	end
end

local function escape(attribute: string): string
	attribute = string.gsub(attribute, "&", "&")
	attribute = string.gsub(attribute, "<", "<")
	attribute = string.gsub(attribute, ">", ">")
	attribute = string.gsub(attribute, "'", "'")
	attribute = string.gsub(attribute, '"', """)
	return attribute
end

local function createElement(tag: string)
	local function element(props: string | { [unknown]: unknown })
		if typeof(props) == "table" then
			local attributes = ""
			for key, value in props do
				if typeof(key) == "string" then
					assert(typeof(value) == "string", "HTML attribute value must be string")
					attributes ..= ` {key}="{escape(value)}"`
				end
			end
			return `<{tag}{attributes}>{flatten({ table.unpack(props :: any) })}</{tag}>`
		end

		return `<{tag}>{props}</{tag}>`
	end

	return element
end

return table.freeze({
	escape = escape,
	flatten = flatten,

	div = createElement("div"),
	span = createElement("span"),
	h1 = createElement("h1"),
	h2 = createElement("h2"),
	h3 = createElement("h3"),
	h4 = createElement("h4"),
	h5 = createElement("h5"),
	h6 = createElement("h6"),
	p = createElement("p"),
	a = createElement("a"),
	img = createElement("img"),
	button = createElement("button"),
	input = createElement("input"),
	label = createElement("label"),
	textarea = createElement("textarea"),
	select = createElement("select"),
	option = createElement("option"),
	ul = createElement("ul"),
	ol = createElement("ol"),
	li = createElement("li"),
	table = createElement("table"),
	tr = createElement("tr"),
	td = createElement("td"),
	th = createElement("th"),
	thead = createElement("thead"),
	tbody = createElement("tbody"),
	tfoot = createElement("tfoot"),
	form = createElement("form"),
	br = createElement("br"),
	hr = createElement("hr"),
	strong = createElement("strong"),
	b = createElement("b"),
	em = createElement("em"),
	i = createElement("i"),
	u = createElement("u"),
	s = createElement("s"),
	sup = createElement("sup"),
	sub = createElement("sub"),
	small = createElement("small"),
	code = createElement("code"),
	pre = createElement("pre"),
	blockquote = createElement("blockquote"),
	nav = createElement("nav"),
	header = createElement("header"),
	footer = createElement("footer"),
	section = createElement("section"),
	article = createElement("article"),
	aside = createElement("aside"),
	main = createElement("main"),
	details = createElement("details"),
	summary = createElement("summary"),
	dialog = createElement("dialog"),
	time = createElement("time"),
	address = createElement("address"),
	mark = createElement("mark"),
	progress = createElement("progress"),
	meter = createElement("meter"),
	caption = createElement("caption"),
	figure = createElement("figure"),
	figcaption = createElement("figcaption"),
	legend = createElement("legend"),
	fieldset = createElement("fieldset"),
	dfn = createElement("dfn"),
	kbd = createElement("kbd"),
	var = createElement("var"),
	cite = createElement("cite"),
	q = createElement("q"),

	html = createElement("html"),
	head = createElement("head"),
	title = createElement("title"),
	meta = createElement("meta"),
	link = createElement("link"),
	style = createElement("style"),
	body = createElement("body"),

	script = createElement("script"),
	noscript = createElement("noscript"),

	audio = createElement("audio"),
	video = createElement("video"),
	source = createElement("source"),
	track = createElement("track"),
	iframe = createElement("iframe"),
	canvas = createElement("canvas"),
	svg = createElement("svg"),
})


local Article = require("@sitegen/components/article")
local commonUrls = require("@sitegen/config/common-urls")
local h = require("@sitegen/h")

return function()
	return Article({
		title = "Learn Prvd 'M Wrong",
		content = {
			h.p("Congratulations on choosing Prvd 'M Wrong, you’re finally making good decisions!"),
			h.p("You will learn how to build great projects with Prvd 'M Wrong, even if you never used \
			it before, with advice from maintainers who know Prvd 'M Wrong best."),
		},
		sections = {
			{
				title = "Expectations",
				content = {
					h.p("Prvd 'M Wrong expects:"),
					h.ul({
						h.li("That you're comfortable with the Luau scripting language."),
						h.li("That — if you will use Roblox-specific packages — you're familiar with Roblox"),
					}),
					h.p("Some articles might challenge you more than others. Remember, Prvd 'M Wrong is \
				built with you in mind, but it may still take a bit of time to absorb some \
				concepts. Take your time and explore at your own pace."),
				},
			},
			{
				title = "Support",
				content = {
					h.p("Prvd 'M Wrong is built with you in mind and our documentation aims to be as \
				    useful and comprehensive as possible. However, you might need specific advice \
					on an issue, perhaps you may want to learn Prvd 'M Wrong through other means, \
					or you caught a bug."),
					h.p({
						"Whatever you're looking for, feel free to swing by our ",
						h.a({
							href = commonUrls.rossThread,
							"dedicated thread over the Roblox OSS Discord server.",
						}),
						" Maintainers drop in frequently alongside eager \
						Prvd 'M Wrong users.",
					}),
				},
			},
			{
				title = "Using the Documentation",
				content = {
					h.p("The Prvd 'M Wrong documentation aims to be as useful and comprehensive as \
				        possible. You can open the documentation settings by clicking the gear \
						icon in the top right corner."),
					h.p("These customization settings will persist between sessions:"),
					h.ul({
						h.li("Dark, light and sepia color schemes"),
						h.li("Preferred monospace fonts for code snippets"),
						h.li("Relevant documentation for Luau and Roblox TypeScript"),
					}),
				},
			},
		},
	})
end


local Article = require("@sitegen/components/article")
local commonUrls = require("@sitegen/config/common-urls")
local h = require("@sitegen/h")

return function()
	return Article({
		title = "Installation",
		content = {
			h.p("Prvd 'M Wrong is distributed as several packages that needs to be installed into \
			    your game."),
		},
		sections = {
			{
				title = "Templates",
				content = {
					h.p("TBA"),
				},
			},
			{
				title = "Installation",
				content = {
					h.p("TBA"),
				},
			},
			{
				title = "Build from Source",
				content = {
					h.p("TBA"),
				},
			},
		},
	})
end


local function forEach<KI, KO, VI, VO>(input: { [KI]: VI }, func: (KI, VI) -> (KO, VO)): { [KO]: VO }
	local output = {}
	for keyIn, valueIn in input do
		local keyOut, valueOut = func(keyIn, valueIn)
		if keyOut == nil or valueOut == nil then
			continue
		end
		assert(output[keyOut] == nil, "Key collision")
		output[keyOut] = valueOut
	end
	return output
end

return forEach


local siteUrls = require("@sitegen/config/site-urls")

local function siteBaseUrl(suffix: string)
	return siteUrls.base .. "/" .. suffix
end

return siteBaseUrl


declare _G: {
    PRVDMWRONG_SUPPRESS_MULTIPLE_SAME_TAG: unknown,
    PRVDMWRONG_DISALLOW_MULTIPLE_LISTENERS: unknown,
    PRVDMWRONG_PROFILE_LIFECYCLES: unknown
}


local DEV = false
local VERSION = "v1.5b"

local RunService = game:GetService("RunService")
local Players = game:GetService("Players")
local MarketplaceService = game:GetService("MarketplaceService")

--- Version comparison method inspired by @ForeverHD's TopbarPlus!
--- @return boolean -- True if module is up to date, false otherwise.
local function UpToDate()
	local asset = 85110005799468 -- Official Roblox model

	local name = ""
	repeat
		local success, info = pcall(function()
			return MarketplaceService:GetProductInfoAsync(asset)
		end)
		if success and info then
			name = info.Name
			break
		end
		task.wait(1)
	until name ~= ""

	local latestVersion = VERSION
	latestVersion = string.match(name, "^Facial Unification (.*)$")
	return latestVersion ~= nil and latestVersion == VERSION
end

if DEV or not RunService:IsStudio() then
	local creator = Players:GetNameFromUserIdAsync(1371995) -- Dogutsune
	print(`🙂 Running Facial Unification ({VERSION}) by @{creator}`)
end

if not UpToDate() then
	warn("⚠ Facial Unification is not the same version as the model! You can check the official DevForum post at: https://devforum.roblox.com/t/4312162")
end

return {}


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

local QuickZone = require(ReplicatedStorage.QuickZone)
local SharedGroups = require(ReplicatedStorage.Common.Groups)
local SharedZones = require(ReplicatedStorage.Common.Zones)

local localPlayer = game:GetService('Players').LocalPlayer

local antiGravityObserver = QuickZone.Observer.new({
	updateRate = 20,
	groups = { SharedGroups.LocalPlayer },
	zones = { SharedZones.AntiGravity },
})

RunService.PreSimulation:Connect(function(dt)
	local zone = antiGravityObserver:getZoneOfPlayer(localPlayer)
	if zone then
		local character = localPlayer.Character
		local hrp = character and character:FindFirstChild('HumanoidRootPart')
		if not hrp then
			return
		end

		local reference = zone:getReference()
		local gravityFactor = reference and reference:GetAttribute('Gravity')
		if not gravityFactor then
			return
		end

		local totalMass = hrp.AssemblyMass
		local upwardForce = totalMass * workspace.Gravity * (1 - gravityFactor)
		local upwardImpulse = Vector3.new(0, upwardForce * dt, 0)

		hrp:ApplyImpulse(upwardImpulse)
	end
end)

return nil


local ReplicatedStorage = game:GetService('ReplicatedStorage')
local Lighting = game:GetService('Lighting')

local QuickZone = require(ReplicatedStorage.QuickZone)
local SharedZones = require(ReplicatedStorage.Common.Zones)

local DEFAULTS = {
	Brightness = Lighting.Brightness,
	Ambient = Lighting.Ambient,
	FogEnd = Lighting.FogEnd,
	FogColor = Lighting.FogColor,
}

local PRESETS = {
	Lava = {
		Brightness = 1.5,
		Ambient = Color3.fromRGB(255, 100, 0),
		FogEnd = 80,
		FogColor = Color3.fromRGB(100, 20, 0),
	},
	ToxicGas = {
		Brightness = 0.7,
		Ambient = Color3.fromRGB(50, 200, 50),
		FogEnd = 150,
		FogColor = Color3.fromRGB(20, 60, 20),
	},
}

local function applyProps(props)
	for prop, value in props do
		Lighting[prop] = value
	end
end

local camera = workspace.CurrentCamera
local cameraGroup = QuickZone.Group.new({ entities = { camera } })

local cameraEffectsObserver = QuickZone.Observer.new({
	groups = { cameraGroup },
	zones = { SharedZones.Lava, SharedZones.ToxicGas },
	updateRate = 60,
})

cameraEffectsObserver:observe(function(entity, initialZone)
	local name = initialZone:getMetadata().Name
	applyProps(PRESETS[name] or DEFAULTS)

	local disconnectTransition = cameraEffectsObserver:onTransition(function(transitioningEntity, newZone)
		if transitioningEntity ~= entity then
			return
		end

		local newName = newZone:getMetadata().Name
		applyProps(PRESETS[newName] or DEFAULTS)
	end)

	return function()
		disconnectTransition()
		applyProps(DEFAULTS)
	end
end)

return nil


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

local QuickZone = require(ReplicatedStorage.QuickZone)
local SharedGroups = require(ReplicatedStorage.Common.Groups)

local discoPart = workspace:WaitForChild('DiscoPart')
local defaultColor = discoPart.Color

local discoZone = QuickZone.Zone.fromPart(discoPart)

local discoObserver = QuickZone.Observer.new({
	groups = { SharedGroups.Players },
	zones = { discoZone },
	updateRate = 5,
})

discoObserver:observeGroup(function(_, zone)
	local part = zone:getReference()
	if not (part and part:IsA('BasePart')) then
		return
	end

	print('The party has started!')

	local hue = 0
	local colorSpeed = 0.5
	local heartbeatConn = RunService.Heartbeat:Connect(function(dt)
		hue = (hue + (dt * colorSpeed)) % 1
		part.Color = Color3.fromHSV(hue, 0.8, 1)
	end)

	return function()
		print('Last player left. Party is over!')

		if heartbeatConn then
			heartbeatConn:Disconnect()
			heartbeatConn = nil
		end

		part.Color = defaultColor
	end
end)

return nil


local ReplicatedStorage = game:GetService('ReplicatedStorage')
local TweenService = game:GetService('TweenService')

local QuickZone = require(ReplicatedStorage.QuickZone)
local SharedGroups = require(ReplicatedStorage.Common.Groups)
local SharedZones = require(ReplicatedStorage.Common.Zones)

local camera = workspace.CurrentCamera

local speedBoostObserver = QuickZone.Observer.new({
	groups = { SharedGroups.LocalPlayer },
	zones = { SharedZones.SpeedBoost },
	updateRate = 20,
})

local DEFAULT_SPEED = 16
local DEFAULT_FOV = 70

local function getBoostData(zone)
	local part = zone:getReference()
	if part then
		return {
			WalkSpeed = part:GetAttribute('WalkSpeed') or 32,
			FOV = part:GetAttribute('FOV') or 90,
		}
	end
	return { WalkSpeed = 32, FOV = 90 }
end

speedBoostObserver:observe(function(localPlayer, initialZone)
	local character = localPlayer.Character
	local humanoid = character and character:FindFirstChild('Humanoid')
	if not humanoid then
		return
	end

	local function applyBoost(data)
		humanoid.WalkSpeed = data.WalkSpeed
		TweenService:Create(camera, TweenInfo.new(0.5), { FieldOfView = data.FOV }):Play()
	end

	applyBoost(getBoostData(initialZone))

	local disconnectTransition = speedBoostObserver:onLocalPlayerTransition(function(newZone)
		applyBoost(getBoostData(newZone))
	end)

	return function()
		disconnectTransition()

		if humanoid then
			humanoid.WalkSpeed = DEFAULT_SPEED
		end
		TweenService:Create(camera, TweenInfo.new(0.5), { FieldOfView = DEFAULT_FOV }):Play()
	end
end)

return nil


while not game:IsLoaded() do
	game.Loaded:Wait()
end

local ReplicatedStorage = game:GetService('ReplicatedStorage')

local QuickZone = require(ReplicatedStorage.QuickZone)
QuickZone:configure({
	enabled = true,
	autoSyncRate = 30,
	frameBudget = 1,
})

for _, instance in script.Parent.Observers:GetChildren() do
	if instance:IsA('ModuleScript') then
		require(instance)
	end
end


local RunService = game:GetService('RunService')

local QuickZone = require(game.ReplicatedStorage.QuickZone)
local Group = QuickZone.Group

--[=[
	Put groups here only if they need to be accessed by multiple scripts.
	(e.g., The "Players" group is used by Hazards, SafeZones, and Disco).
]=]
local Groups = {
	Players = Group.players(),
}

if RunService:IsClient() then
	Groups.LocalPlayer = Group.localPlayer()
end

return Groups


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

local QuickZone = require(ReplicatedStorage.QuickZone)
local SharedGroups = require(ReplicatedStorage.Common.Groups)
local SharedZones = require(ReplicatedStorage.Common.Zones)

local hazardObserver = QuickZone.Observer.new({
	groups = { SharedGroups.Players },
	zones = { SharedZones.Lava, SharedZones.ToxicGas },
	updateRate = 20,
	priority = 0,
})

RunService.Heartbeat:Connect(function(dt)
	for player, zone in hazardObserver:iterPlayersInside() do
		local character = player.Character
		local humanoid = character and character:FindFirstChild('Humanoid')

		if not humanoid or humanoid.Health <= 0 then
			continue
		end

		local metadata = zone:getMetadata() or {}
		local dps = metadata.Damage
		if not dps then
			continue
		end

		humanoid:TakeDamage(dps * dt)

		if humanoid.Health <= 0 then
			local hazardName = metadata.Name or 'Unknown Hazard'
			print(player.Name .. ' died in the ' .. hazardName .. '!')
		end
	end
end)

return nil


local ReplicatedStorage = game:GetService('ReplicatedStorage')
local QuickZone = require(ReplicatedStorage.QuickZone)
local SharedGroups = require(ReplicatedStorage.Common.Groups)

local safeZonePart = workspace:WaitForChild('SafeZone')

local safeZone = QuickZone.Zone.fromPart(safeZonePart)

local safeObserver = QuickZone.Observer.new({
	groups = { SharedGroups.Players },
	zones = { safeZone },
	updateRate = 10,
	priority = 100, -- Higher priority than the HazardsObserver, so a player is always safe here from hazards!
})

safeObserver:observe(function(player)
	local character = player.Character
	local humanoid = character and character:FindFirstChild('Humanoid')

	local forceField = Instance.new('ForceField')
	forceField.Parent = character

	if humanoid and humanoid.Health < 100 then
		humanoid.Health = 100
		print('Health restored to 100')
	end

	return function()
		if forceField and forceField.Parent then
			forceField:Destroy()
		end
	end
end)

return nil


local ReplicatedStorage = game:GetService('ReplicatedStorage')

local QuickZone = require(ReplicatedStorage.QuickZone)
QuickZone:configure({
	enabled = true,
	autoSyncRate = 30,
	frameBudget = 1,
})

for _, instance in script.Parent.Observers:GetChildren() do
	if instance:IsA('ModuleScript') then
		require(instance)
	end
end


--!strict
--[=[
	@class Group

	Groups represent a collection of entities that should be tracked by the system.

	:::info Observer
	Every entity must be in a group to be observed.
	:::
]=]

local CollectionService = game:GetService('CollectionService')
local Players = game:GetService('Players')
local RunService = game:GetService('RunService')

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

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 DEFAULT_PRECISION_SQ = Config.Observer.precision ^ 2

local IS_CLIENT = RunService:IsClient()

local function trackPlayer(group: Types.InternalGroup, player: Player, trackerStore: { [Player]: () -> () })
	if trackerStore[player] then
		return
	end

	local charConn: RBXScriptConnection?
	local hrpConn: RBXScriptConnection?
	local currentCharacter: Model?
	local currentHrp: BasePart?

	local function clearHrp(targetHrp: BasePart?)
		if targetHrp and targetHrp ~= currentHrp then
			return
		end

		if hrpConn then
			hrpConn:Disconnect()
			hrpConn = nil
		end

		if currentHrp then
			group:_remove(currentHrp)
			currentHrp = nil
		end
	end

	local function clearCharacter()
		clearHrp()
		if charConn then
			charConn:Disconnect()
			charConn = nil
		end
		currentCharacter = nil
	end

	local function checkHrp(character: Model)
		if character ~= currentCharacter then
			return
		end

		local hrp = character:FindFirstChild('HumanoidRootPart')

		if hrp and hrp:IsA('BasePart') and hrp ~= currentHrp then
			clearHrp()
			currentHrp = hrp

			State.entityToReference[hrp] = player
			State.referenceToEntity[player] = hrp
			group:_add(currentHrp)

			-- Listen for the HRP streaming out or getting destroyed
			hrpConn = hrp.AncestryChanged:Connect(function(_, parent)
				if not parent then
					clearHrp(hrp)
				end
			end)
		end
	end

	local function onCharacterAdded(character: Model)
		clearCharacter()
		currentCharacter = character

		checkHrp(character)

		-- Listen for the HRP streaming in dynamically
		charConn = character.ChildAdded:Connect(function(child)
			if child.Name == 'HumanoidRootPart' then
				checkHrp(character)
			end
		end)
	end

	local addedConn = player.CharacterAdded:Connect(onCharacterAdded)

	local removingConn = player.CharacterRemoving:Connect(function(character)
		if currentCharacter == character then
			clearCharacter()
		end
	end)

	trackerStore[player] = function()
		addedConn:Disconnect()
		removingConn:Disconnect()
		clearCharacter()
	end

	if player.Character then
		onCharacterAdded(player.Character)
	end
end

local function untrackPlayer(player: Player, trackerStore: { [Player]: () -> () })
	local cleanup = trackerStore[player]
	if cleanup then
		cleanup()
		trackerStore[player] = nil
	end
end

local Group = {}
Group.__index = Group

--[=[
	Creates a generic Group.
	Groups manage collections of Entities (any instance with a position in the world like BaseParts, Models, Attachments, etc.) that Observers should track.

	```lua
	local myGroup = Group.new({
		entities = { NPC1, NPC2, NPC3 }, -- Add entities
		autoClean = true, -- Entities in group are cleaned up when entities are destroyed
	})
	```

	@tag Constructor
	@param config { entities: { T }?, autoClean: boolean? }?
	@return Group
]=]
function Group.new<T>(config: {
	entities: { T }?,
	autoClean: boolean?,
}?): Types.Group<T>
	local id = State.nextGroupId
	State.nextGroupId += 1

	local autoClean = Config.Group.autoClean
	if config and config.autoClean ~= nil then
		autoClean = config.autoClean
	end

	local self: Types.InternalGroup<T> = setmetatable({
		id = id,
		entities = {},
		entityIndices = {},
		autoClean = autoClean,
	}, Group) :: any

	State.groups[id] = self
	State.groupToObservers[id] = {}
	State.groupEntityCleanups[id] = {}

	if config and config.entities then
		self:_addBulk(config.entities)
	end

	return self
end

--[=[
	Creates a dynamic Group that automatically tracks all instances with a specific tag.
	Instances are only tracked while they are descendants of the Workspace.

	@tag Constructor
	@param tag string
	@return Group
]=]
function Group.fromTag(tag: string): Types.Group<Types.Entity>
	local group = Group.new({ autoClean = false }) :: Types.InternalGroup<Types.Entity>
	group.isManaged = true

	local watchingInstances = {} :: { [Instance]: RBXScriptConnection }

	local function checkAncestry(instance: BasePart | Model | Attachment | Bone | Camera)
		if instance:IsDescendantOf(workspace) then
			group:_add(instance)
		else
			group:_remove(instance)
		end
	end

	local function startWatching(instance: Instance)
		if watchingInstances[instance] then
			return
		end

		if
			not (
				instance:IsA('BasePart')
				or instance:IsA('Model')
				or instance:IsA('Attachment')
				or instance:IsA('Bone')
				or instance:IsA('Camera')
			)
		then
			return
		end

		watchingInstances[instance] = instance.AncestryChanged:Connect(function()
			checkAncestry(instance)
		end)

		checkAncestry(instance)
	end

	local function stopWatching(instance: Instance)
		local conn = watchingInstances[instance]
		if conn then
			conn:Disconnect()
			watchingInstances[instance] = nil
		end
		group:_remove(instance :: any)
	end

	local addedConn = CollectionService:GetInstanceAddedSignal(tag):Connect(startWatching)
	local removedConn = CollectionService:GetInstanceRemovedSignal(tag):Connect(stopWatching)

	for _, instance in CollectionService:GetTagged(tag) do
		startWatching(instance)
	end

	group:onDestroy(function()
		addedConn:Disconnect()
		removedConn:Disconnect()
		for instance, _ in watchingInstances do
			stopWatching(instance)
		end
	end)

	return group
end

--[=[
	Returns a Group that automatically tracks all Players in the server.

	:::info Behavior
	* Automatically tracks the `HumanoidRootPart` of every player.
	* Handles `PlayerAdded` and `CharacterAdded` internally.
	:::

	@tag Constructor
	@return Group
]=]
function Group.players(): Types.Group<Player>
	local group = Group.new({ autoClean = false }) :: Types.InternalGroup<Player>
	group.isManaged = true

	local trackerStore = {} :: { [Player]: () -> () }
	local mainConnections = {} :: { RBXScriptConnection }

	table.insert(
		mainConnections,
		Players.PlayerAdded:Connect(function(player)
			trackPlayer(group, player, trackerStore)
		end)
	)

	table.insert(
		mainConnections,
		Players.PlayerRemoving:Connect(function(player)
			untrackPlayer(player, trackerStore)
		end)
	)

	for _, player in Players:GetPlayers() do
		trackPlayer(group, player, trackerStore)
	end

	group:onDestroy(function()
		for _, conn in mainConnections do
			conn:Disconnect()
		end
		for _, cleanup in trackerStore do
			cleanup()
		end

		table.clear(mainConnections)
		table.clear(trackerStore)
	end)

	return group
end

--[=[
	Returns a Group that tracks only the LocalPlayer.

	:::info Behavior
	* Automatically tracks the `HumanoidRootPart` of the local player.
	* Handles `CharacterAdded` internally.
	:::

	@client
	@tag Constructor
	@return Group
]=]
function Group.localPlayer(): Types.Group<Player>
	if not IS_CLIENT then
		Log.fatal('Group.localPlayer() can only be called on the Client.', nil)
	end

	local group = Group.new({ autoClean = false }) :: Types.InternalGroup<Player>
	group.isManaged = true

	local lp = Players.LocalPlayer
	local trackers = {}

	trackPlayer(group, lp, trackers)

	group:onDestroy(function()
		untrackPlayer(lp, trackers)
	end)

	return group
end

--[=[
	Enables or disables automatic cleanup for Instances in this group.
	When enabled, if an Instance is Destroyed() or parented to nil, it will be
	automatically removed from this group.

	@method setAutoClean
	@within Group
	@param enabled boolean
	@return Group
]=]
function Group.setAutoClean(self: Types.InternalGroup, enabled: boolean): Types.Group
	if self.isManaged then
		Log.warn('Cannot set autoClean on a Managed Group. Ignoring request.', nil)
		return self
	end

	if self.autoClean == enabled then
		return self
	end

	self.autoClean = enabled
	local cleanups = State.groupEntityCleanups[self.id]

	if enabled then
		for _, entity in self.entities do
			if typeof(entity) == 'Instance' and not cleanups[entity] then
				cleanups[entity] = (entity :: Instance).AncestryChanged:Connect(function(_, parent)
					if not parent then
						self:_remove(entity)
					end
				end)
			end
		end
	else
		for entity, conn in cleanups do
			conn:Disconnect()
			cleanups[entity] = nil
		end
	end

	return self
end

--[=[
	Adds an entity to the group. If the entity is already in another group,
	it is automatically removed from the old one first.

	#### Example: Adding Different Entity Types
	```lua
	-- Adding a BasePart and a Model
	myGroup:add(workspace.Part):add(workspace.NPCModel)

	-- Adding a custom table
	local spell = {
		CFrame = CFrame.new(0, 10, 0),
		Name = 'Fireball'
	}
	myGroup:add(spell)
	```

	:::info Strategy Detection
	QuickZone automatically determines how to track the entity's position:
	* **BasePart:** Uses `Position`
	* **Model:** Uses `PrimaryPart.Position` or `GetPivot()`
	* **Attachment/Bone:** Uses `WorldPosition`
	* **Camera:** Uses `CFrame`
	* **Table:** Looks for `.Position`, `.CFrame`, `.WorldPosition`, or `:GetPivot()`.
	:::

	@method add
	@within Group
	@param entity any -- The object to track.
	@return Group
]=]
function Group.add(self: Types.InternalGroup, entity: any): Types.Group
	if self.isManaged then
		Log.warn('Cannot manually add to a Managed Group. Ignoring request.', nil)
		return self
	end
	return self:_add(entity)
end

--[=[
	Adds multiple entities to the group.

	```lua
	-- Add an entire folder of NPCs at once
	myGroup:addBulk(workspace.Enemies:GetChildren())
	```

	@method addBulk
	@within Group
	@param entities { any } -- Array of entities to add.
	@return Group
]=]
function Group.addBulk(self: Types.InternalGroup, entities: { any }): Types.Group
	if self.isManaged then
		Log.warn('Cannot manually addBulk to a Managed Group. Ignoring request.', nil)
		return self
	end
	return self:_addBulk(entities)
end

--[=[
	Removes the entity from the group.

	@method remove
	@within Group
	@param entity any
	@return Group
]=]
function Group.remove(self: Types.InternalGroup, entity: any): Types.Group
	if self.isManaged then
		Log.warn('Cannot manually remove from a Managed Group. Ignoring request.', nil)
		return self
	end
	return self:_remove(entity)
end

--[=[
	Removes multiple entities from the group.

	@method removeBulk
	@within Group
	@param entities { any }
	@return Group
]=]
function Group.removeBulk(self: Types.InternalGroup, entities: { any }): Types.Group
	if self.isManaged then
		Log.warn('Cannot manually removeBulk from a Managed Group. Ignoring request.', nil)
		return self
	end
	return self:_removeBulk(entities)
end

--[=[
	Removes all entities from the group.
	This fires 'onExit' events for every entity currently inside a zone.

	@method clear
	@within Group
	@return Group
]=]
function Group.clear(self: Types.InternalGroup): Types.Group
	if self.isManaged then
		Log.warn('Cannot clear a Managed Group. Ignoring request.', nil)
		return self
	end
	return self:_clear()
end

--[=[
	Checks if a specific entity is currently a member of this group.

	@method contains
	@within Group
	@param entity any
	@return boolean
]=]
function Group.contains(self: Types.InternalGroup, entity: any): boolean
	entity = State.referenceToEntity[entity] or entity
	local groups = State.entityToGroups[entity]
	return groups and groups[self.id] == true or false
end

--[=[
	Returns the unique internal ID of the group.

	@method getId
	@within Group
	@return number
]=]
function Group.getId(self: Types.InternalGroup): number
	return self.id
end

--[=[
	Returns a list of all entities currently belonging to the group.

	@method getEntities
	@within Group
	@return { any }
]=]
function Group.getEntities(self: Types.InternalGroup): { any }
	local result = table.create(#self.entities)
	for i, entity in ipairs(self.entities) do
		result[i] = State.entityToReference[entity] or entity
	end
	return result
end

--[=[
	Iterates over all entities currently belonging to the group.
	Provides a zero-allocation way to loop through entities, ideal for high-frequency
	checks.

	```lua
	for entity in myGroup:iterEntities() do
		print(entity.Name .. " is in the group!")
	end
	```

	@method iterEntities
	@within Group
	@return () -> any?
]=]
function Group.iterEntities(self: Types.InternalGroup): () -> any?
	local entity = nil

	return function()
		entity = next(self.entityIndices, entity)
		if entity then
			return State.entityToReference[entity] or entity
		end
		return entity
	end
end

--[=[
	Fires when `destroy()` is called on the group.

	```lua
	local disconnect = myGroup:onDestroy(function()
		print('Group was destroyed!')
	end)
	```

	@tag Event
	@method onDestroy
	@within Group
	@param callback () -> ()
	@return () -> () -- Disconnect function
]=]
function Group.onDestroy(self: Types.InternalGroup, callback: () -> ()): () -> ()
	if not self.onDestroyCallbacks then
		self.onDestroyCallbacks = {}
	end

	table.insert(self.onDestroyCallbacks :: any, callback)

	return function()
		local idx = table.find(self.onDestroyCallbacks :: any, callback)
		if idx then
			table.remove(self.onDestroyCallbacks :: any, idx)
		end
	end
end

--[=[
	Cleans up the group, removes all tracked entities, and detaches any
	associated observers.

	@tag Destructor
	@method destroy
	@within Group
]=]
function Group.destroy(self: Types.InternalGroup): ()
	if self.onDestroyCallbacks then
		for _, callback in self.onDestroyCallbacks do
			task.spawn(callback)
		end
		table.clear(self.onDestroyCallbacks)
	end

	for idx = #self.entities, 1, -1 do
		self:_remove(self.entities[idx])
	end

	State.groupToObservers[self.id] = nil
	State.groups[self.id] = nil
	State.groupEntityCleanups[self.id] = nil

	setmetatable(self :: any, nil)
end

function Group._add(self: Types.InternalGroup, reference: any): Types.Group
	local entity: Types.Entity = State.referenceToEntity[reference] or reference

	if not State.entityToGroups[entity] then
		State.entityToGroups[entity] = {}
	end

	if State.entityToGroups[entity][self.id] then
		return self
	end

	State.entityToGroups[entity][self.id] = true

	if not State.entityData[entity] then
		local strat
		if typeof(entity) == 'Instance' then
			if entity:IsA('BasePart') then
				strat = STRAT_POS
			elseif entity:IsA('Attachment') or entity:IsA('Bone') then
				strat = STRAT_WORLD
			elseif entity:IsA('Camera') then
				strat = STRAT_CFRAME
			elseif entity:IsA('Model') then
				strat = entity.PrimaryPart and STRAT_PRIM or STRAT_PIVOT
			end
		elseif typeof(entity) == 'table' then
			if entity.Position then
				strat = STRAT_POS
			elseif entity.CFrame then
				strat = STRAT_CFRAME
			elseif entity.Transform then
				strat = STRAT_TRANSFORM
			elseif entity.WorldPosition then
				strat = STRAT_WORLD
			elseif entity.GetPivot then
				strat = STRAT_PIVOT
			end
		end

		if not strat then
			Log.nonFatal(
				'Invalid entity (%s). Expected BasePart, Model, Bone, Attachment, Camera, or a valid EntityTable.',
				nil,
				tostring(entity)
			)
			State.entityToGroups[entity][self.id] = nil
			return self
		end

		State.entityData[entity] = {
			strategy = strat,
			lastPosition = Vector3.zero,
			activeObserverMemberships = {},
			precisionSq = DEFAULT_PRECISION_SQ,
			updateRate = -1,
			bucketIndex = 0,
			dynamicVersion = 0,
			staticVersion = 0,
			logicVersion = 0,
			needsStatic = false,
			needsDynamic = false,
		}
	end

	local idx = #self.entities + 1
	self.entities[idx] = entity
	self.entityIndices[entity] = idx

	State.dirtyProfiles[entity] = true
	State.dirtyTopology[entity] = true

	if self.autoClean and typeof(entity) == 'Instance' and not State.groupEntityCleanups[self.id][entity] then
		State.groupEntityCleanups[self.id][entity] = (entity :: Instance).AncestryChanged:Connect(function(_, parent)
			if not parent then
				self:_remove(entity)
			end
		end)
	end

	return self
end

function Group._addBulk(self: Types.InternalGroup, references: { any }): Types.Group
	for _, reference in references do
		self:_add(reference)
	end
	return self
end

function Group._remove(self: Types.InternalGroup, reference: any): Types.Group
	local entity: Types.Entity = State.referenceToEntity[reference] or reference

	if not State.entityToGroups[entity] or not State.entityToGroups[entity][self.id] then
		return self
	end

	local cleanupConn = State.groupEntityCleanups[self.id][entity]
	if cleanupConn then
		cleanupConn:Disconnect()
		State.groupEntityCleanups[self.id][entity] = nil
	end

	State.entityToGroups[entity][self.id] = nil
	local data = State.entityData[entity]

	local idx = self.entityIndices[entity]
	local lastIdx = #self.entities
	if idx == lastIdx then
		self.entities[lastIdx] = nil
		self.entityIndices[entity] = nil
	else
		local lastEntity = self.entities[lastIdx]
		self.entities[idx] = lastEntity
		self.entityIndices[lastEntity] = idx
		self.entities[lastIdx] = nil
		self.entityIndices[entity] = nil
	end

	State.dirtyProfiles[entity] = true
	State.dirtyTopology[entity] = true

	for observerId, oldZoneId in data.activeObserverMemberships do
		local stillTracked = false
		for remainingGroupId in State.entityToGroups[entity] do
			if State.groupToObservers[remainingGroupId] and State.groupToObservers[remainingGroupId][observerId] then
				stillTracked = true
				break
			end
		end

		if stillTracked then
			continue
		end

		data.activeObserverMemberships[observerId] = nil

		if State.observerTrackingEntities[observerId] then
			State.observerTrackingEntities[observerId][entity] = nil
		end

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

		local safety = State.observerSafety[observerId]
		local z = State.zoneIdToZoneObj[oldZoneId]
		local ref = State.entityToReference[entity] or entity
		for _, fn in cbs do
			if safety then
				task.spawn(fn, ref, z, entity)
			else
				fn(ref, z, entity)
			end
		end
	end

	if next(State.entityToGroups[entity]) == nil then
		-- Remove from bucket
		local currentRate = data.updateRate
		if currentRate and currentRate > 0 and State.buckets[currentRate] then
			local bucket = State.buckets[currentRate]
			local bucketIdx = data.bucketIndex
			local maxEntities = #bucket
			local lastEntity = bucket[maxEntities]

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

			bucket[maxEntities] = nil
		end

		State.entityData[entity] = nil
		State.entityToGroups[entity] = nil
		State.entityToObservers[entity] = nil
		State.dirtyProfiles[entity] = nil
		State.dirtyTopology[entity] = nil

		local ref = State.entityToReference[entity]
		if ref then
			State.entityToReference[entity] = nil
			-- Ensure we only clear the reverse map if it still points to this exact entity
			if State.referenceToEntity[ref] == entity then
				State.referenceToEntity[ref] = nil
			end
		end
	end

	return self
end

function Group._removeBulk(self: Types.InternalGroup, entities: { any }): Types.Group
	for _, entity in entities do
		self:_remove(entity)
	end
	return self
end

function Group._clear(self: Types.InternalGroup): Types.Group
	-- Iterate backwards for safe removal
	local entities = self.entities
	for idx = #entities, 1, -1 do
		self:_remove(entities[idx])
	end
	return self
end

return Group


--!strict
--[=[
	@class Observer

	Observers are the logical bridge between Groups and Zones.
	They monitor specific groups and trigger events when entities within those
	groups enter or exit any zones associated with the observer.
]=]

local Players = game:GetService('Players')
local RunService = game:GetService('RunService')

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

local isPointInShape = Geometry.isPointInShape

local hasAttachedMap = {} :: { [number]: boolean }
local hasSubscribedMap = {} :: { [number]: boolean }

local dirtyProfiles = State.dirtyProfiles
local dirtyTopology = State.dirtyTopology
local zoneAttachedObservers = State.zoneAttachedObservers

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 IS_CLIENT = RunService:IsClient()

local function updateAllEntitiesForObserver(observerId: number)
	for groupId, observers in State.groupToObservers do
		if not observers[observerId] then
			continue
		end

		local group = State.groups[groupId]
		if not group then
			continue
		end

		for _, entity in group.entities do
			dirtyProfiles[entity] = true
			dirtyTopology[entity] = true
		end
	end
end

local function disconnectObserverFromGroup(observerId: number, group: Types.InternalGroup)
	local safety = State.observerSafety[observerId]

	for _, entity in group.entities do
		local data = State.entityData[entity]
		if not data then
			continue
		end

		dirtyProfiles[entity] = true
		dirtyTopology[entity] = true

		local oldZoneId = data.activeObserverMemberships[observerId]
		if not oldZoneId then
			continue
		end

		local stillTracked = false
		for groupId in State.entityToGroups[entity] do
			if State.groupToObservers[groupId] and State.groupToObservers[groupId][observerId] then
				stillTracked = true
				break
			end
		end

		if stillTracked then
			continue
		end

		data.activeObserverMemberships[observerId] = nil
		if State.observerTrackingEntities[observerId] then
			State.observerTrackingEntities[observerId][entity] = nil
		end

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

		local z = State.zoneIdToZoneObj[oldZoneId]
		local ref = State.entityToReference[entity] or entity
		for _, fn in cbs do
			if safety then
				task.spawn(fn, ref, z, entity)
			else
				fn(ref, z, entity)
			end
		end
	end
end

local Observer = {}
Observer.__index = Observer

--[=[
	Creates an Observer. Observers listen for entities entering or exiting assigned Zones.

	```lua
	local observer = QuickZone.Observer.new({
		groups = { enemyGroup, playerGroup } -- Immediately subscribe to these groups
		zones = { damageZone } -- Immediately subscribe to these zones
		priority = 20, -- The priority value is used to resolve overlaps
		updateRate = 20, -- Check at 20Hz
		precision = 0.5, -- Ignore movement smaller than 0.5 studs
		enabled = false, -- Observer will not start processing spatial checks
		safety = false, -- Do not wrap callbacks in task.spawn
	})
	```

	:::info Resolution Priority
	When an entity is inside multiple zones watched by different observers,
	higher priority observers take complete control.
	:::

	:::warning Safety
	If set to unsafe, you are not allowed to yield in the callbacks anymore. If you do, QuickZone will throw errors.
	:::

	:::note Dynamic Instantiation
	QuickZone will warn you if an Observer is created without groups or zones. To suppress this warning when building observers dynamically, explicitly pass an empty table (`zones = {}`) or instantiate the observer as disabled (`enabled = false`).
	:::

	@tag Constructor
	@param config { groups: { Types.Group }?, zones: { Types.Zone | Types.Zones }?, priority: number?, updateRate: number?, precision: number?, enabled: boolean?, safety: boolean? }?
	@return Observer
]=]
function Observer.new<T>(config: {
	groups: { Types.Group<T> }?,
	zones: { Types.Zone | Types.Zones },
	priority: number?,
	updateRate: number?,
	precision: number?,
	enabled: boolean?,
	safety: boolean?,
}?): Types.Observer
	local id = State.nextObserverId
	State.nextObserverId += 1

	local self: Types.InternalObserver<T> = setmetatable({
		id = id,
	}, Observer) :: any

	State.observerPriorityMap[id] = config and config.priority or 0
	State.observerUpdateRate[id] = config and config.updateRate or Config.Observer.updateRate
	State.observerPrecisionSq[id] = (config and config.precision or Config.Observer.precision) ^ 2

	State.observerEnteredCallbacks[id] = {}
	State.observerExitedCallbacks[id] = {}
	State.observerTransitionedCallbacks[id] = {}
	State.observerTrackingEntities[id] = {}
	State.observerIdToObserverObj[id] = self
	State.observerEnabled[id] = if config and config.enabled ~= nil then config.enabled else true
	State.observerSafety[id] = if config and config.safety ~= nil then config.safety else Config.Observer.safety
	State.observerStaticCount[id] = 0
	State.observerDynamicCount[id] = 0

	hasAttachedMap[id] = false
	hasSubscribedMap[id] = false

	if config and config.zones then
		for _, zone in config.zones do
			self:attach(zone)
		end
	end

	if config and config.groups then
		for _, group in config.groups do
			self:subscribe(group)
		end
	end

	-- If a user passes a table, the warning is suppressed
	local explicitlyPassedZones = config and config.zones ~= nil
	local explicitlyPassedGroups = config and config.groups ~= nil

	local missingZones = not explicitlyPassedZones and not hasAttachedMap[id]
	local missingGroups = not explicitlyPassedGroups and not hasSubscribedMap[id]

	if missingZones or missingGroups then
		local creationTrace = debug.traceback('', 2)

		task.defer(function()
			-- Ensure the observer wasn't destroyed or disabled
			if not State.observerIdToObserverObj[id] or not self:isEnabled() then
				return
			end

			-- Check if :attach() or :subscribe() was used instead
			if missingZones and not hasAttachedMap[id] then
				Log.warn('Observer %d has no attached zones. It will not detect spatial queries.', creationTrace, id)
			end
			if missingGroups and not hasSubscribedMap[id] then
				Log.warn('Observer %d has no subscribed groups. It will not track any entities.', creationTrace, id)
			end
		end)
	end

	return self
end

--[=[
	Links this observer to the specified group. The observer will only
	track entities that belong to subscribed groups.

	```lua
	observer:subscribe(playerGroup):subscribe(enemyGroup)
	```

	@method subscribe
	@within Observer
	@param group Group -- The group to monitor.
	@return Observer
]=]
function Observer.subscribe(self: Types.InternalObserver, group: Types.InternalGroup): Types.Observer
	hasSubscribedMap[self.id] = true

	local groupId = group.id
	if not State.groupToObservers[groupId] then
		State.groupToObservers[groupId] = {}
	end
	State.groupToObservers[groupId][self.id] = true

	for _, entity in group.entities do
		dirtyProfiles[entity] = true
		dirtyTopology[entity] = true
	end

	return self
end

--[=[
	Stops the observer from monitoring the specified group.

	@method unsubscribe
	@within Observer
	@param group Group -- The group to stop monitoring.
	@return Observer
]=]
function Observer.unsubscribe(self: Types.InternalObserver, group: Types.InternalGroup): Types.Observer
	local groupId = group.id

	if State.groupToObservers[groupId] then
		State.groupToObservers[groupId][self.id] = nil
	end

	disconnectObserverFromGroup(self.id, group)

	return self
end

--[=[
	Attaches the Observer to a zone. The observer will begin
	monitoring this area for entity overlaps.

	```lua
	-- You are also able to chain calls
	observer:attach(zone1)
		:attach(zone2)
		:attach(zone3)
	```

	@method attach
	@within Observer
	@param zone Zone -- The zone to link.
	@return Observer
]=]
function Observer.attach(self: Types.InternalObserver, zone: any)
	hasAttachedMap[self.id] = true
	zone:attach(self)
	return self
end

--[=[
	Detaches the Observer from the zone. The observer will begin
	monitoring this area for entity overlaps.

	@method detach
	@within Observer
	@param zone Zone -- The zone to unlink.
	@return Observer
]=]
function Observer.detach(self: Types.InternalObserver, zone: any)
	zone:detach(self)
	return self
end

--[=[
	Observes entities entering and exiting the zone.
	The callback executes on entry and expects a function to be returned,
	which is executed on exit.

	```lua
	observer:observe(function(entity, zone)
		print("Entered", entity)
		local highlight = Instance.new("Highlight", entity)

		-- Return cleanup function
		return function()
			print("Exited", entity)
			highlight:Destroy()
		end
	end)
	```

	@method observe
	@within Observer
	@param callback (entity: any, zone: Zone) -> (() -> ())?
	@return () -> () -- Disconnect function
]=]
function Observer.observe(
	self: Types.InternalObserver,
	callback: (any, Types.Zone, Types.Entity) -> (() -> ())?
): () -> ()
	local cleanups = {} :: { [Types.Entity]: () -> () }
	local activeIds = {} :: { [Types.Entity]: number }
	local nextId = 0

	local cleanupEnter = self:onEnter(function(reference, zone, entity: any)
		nextId += 1
		local observerId = nextId
		activeIds[entity] = observerId

		local cleanup = callback(reference, zone, entity)

		if activeIds[entity] ~= observerId then
			if type(cleanup) == 'function' then
				cleanup()
			end
			return
		end

		if type(cleanup) == 'function' then
			cleanups[entity] = cleanup
		end
	end)

	local cleanupExit = self:onExit(function(_, _, entity: any)
		activeIds[entity] = nil

		local cleanup = cleanups[entity]
		if cleanup then
			cleanups[entity] = nil
			cleanup()
		end
	end)

	local function disconnect()
		cleanupEnter()
		cleanupExit()

		table.clear(activeIds)

		for entity, cleanup in cleanups do
			cleanups[entity] = nil
			task.spawn(cleanup)
		end
	end

	local disconnectDestroy = self:onDestroy(disconnect)

	return function()
		disconnectDestroy()
		disconnect()
	end
end

--[=[
	Fires when any entity from a subscribed group enters a zone attached to this observer.

	```lua
	local disconnect = observer:onEnter(function(entity, zone)
		print(entity.Name .. ' entered ' .. zone.id)
	end)
	```

	@tag Event
	@method onEnter
	@within Observer
	@param callback (entity: any, zone: Zone) -> ()
	@return () -> () -- Disconnect function
]=]
function Observer.onEnter(self: Types.InternalObserver, callback: (any, Types.Zone, Types.Entity) -> ()): () -> ()
	table.insert(State.observerEnteredCallbacks[self.id], callback)
	return function()
		local idx = table.find(State.observerEnteredCallbacks[self.id], callback)
		if idx then
			table.remove(State.observerEnteredCallbacks[self.id], idx)
		end
	end
end

--[=[
	Fires when an entity exits all zones attached to this observer.

	@tag Event
	@method onExit
	@within Observer
	@param callback (entity: any, zone: Zone) -> ()
	@return () -> () -- Disconnect function
]=]
function Observer.onExit(self: Types.InternalObserver, callback: (any, Types.Zone, Types.Entity) -> ()): () -> ()
	table.insert(State.observerExitedCallbacks[self.id], callback)
	return function()
		local idx = table.find(State.observerExitedCallbacks[self.id], callback)
		if idx then
			table.remove(State.observerExitedCallbacks[self.id], idx)
		end
	end
end

--[=[
	Observes players entering and exiting the zone.

	```lua
	observer:observePlayer(function(player, zone)
		print(player.Name .. " entered!")

		-- Return cleanup (optional)
		return function()
			print(player.Name .. " left!")
		end
	end)
	```

	@method observePlayer
	@within Observer
	@param callback (player: Player, zone: Zone) -> (() -> ())?
	@return () -> () -- Disconnect function
]=]
function Observer.observePlayer(
	self: Types.InternalObserver,
	callback: (Player, Types.Zone, Types.Entity) -> (() -> ())?
): () -> ()
	local cleanups = {} :: { [Types.Entity]: () -> () }
	local activeIds = {} :: { [Types.Entity]: number }
	local nextId = 0

	local cleanupEnter = self:onEnter(function(reference, zone, entity: any)
		if not (typeof(reference) == 'Instance' and reference:IsA('Player')) then
			return
		end

		nextId += 1
		local observerId = nextId
		activeIds[entity] = observerId

		local cleanup = callback(reference, zone, entity)

		if activeIds[entity] ~= observerId then
			if type(cleanup) == 'function' then
				cleanup()
			end
			return
		end

		if type(cleanup) == 'function' then
			cleanups[entity] = cleanup
		end
	end)

	local cleanupExit = self:onExit(function(_, _, entity: any)
		if not activeIds[entity] then
			return
		end

		activeIds[entity] = nil

		local cleanup = cleanups[entity]
		if cleanup then
			cleanups[entity] = nil
			cleanup()
		end
	end)

	local function disconnect()
		cleanupEnter()
		cleanupExit()

		table.clear(activeIds)

		for entity, cleanup in cleanups do
			cleanups[entity] = nil
			task.spawn(cleanup)
		end
	end

	local disconnectDestroy = self:onDestroy(disconnect)

	return function()
		disconnectDestroy()
		disconnect()
	end
end

--[=[
	Specialized event for Player entities.

	```lua
	observer:onPlayerEnter(function(player, zone)
		print(player.Name .. " entered safe zone " .. zone:getId())

		-- Play a sound
		local sound = workspace.Sounds.SafeZoneEnter:Clone()
		sound.Parent = player.Character.PrimaryPart
		sound:Play()
		game.Debris:AddItem(sound, 2)
	end)
	```

	@tag Event
	@method onPlayerEnter
	@within Observer
	@param callback (player: Player, zone: Zone) -> ()
	@return () -> () -- Disconnect function
]=]
function Observer.onPlayerEnter(
	self: Types.InternalObserver,
	callback: (Player, Types.Zone, Types.Entity) -> ()
): () -> ()
	return self:onEnter(function(reference, zone, entity: any)
		if typeof(reference) == 'Instance' and reference:IsA('Player') then
			callback(reference, zone, entity)
		end
	end)
end

--[=[
	A specialized event for Player entities. Fires when a player's character
	exits all zones attached to this observer.

	@tag Event
	@method onPlayerExit
	@within Observer
	@param callback (player: Player, zone: Zone) -> ()
	@return () -> () -- Disconnect function
]=]
function Observer.onPlayerExit(
	self: Types.InternalObserver,
	callback: (Player, Types.Zone, Types.Entity) -> ()
): () -> ()
	return self:onExit(function(reference, zone, entity: any)
		if typeof(reference) == 'Instance' and reference:IsA('Player') then
			callback(reference, zone, entity)
		end
	end)
end

--[=[
	Observes the LocalPlayer.

	```lua
	observer:observeLocalPlayer(function(zone)
		local blur = Instance.new("BlurEffect", game.Lighting)
		return function()
			blur:Destroy()
		end
	end)
	```

	@client
	@method observeLocalPlayer
	@within Observer
	@param callback (zone: Zone) -> (() -> ())?
	@return () -> () -- Disconnect function
]=]
function Observer.observeLocalPlayer(
	self: Types.InternalObserver,
	callback: (Types.Zone, Types.Entity) -> (() -> ())?
): () -> ()
	if not IS_CLIENT then
		Log.fatal('Observer:observeLocalPlayer can only be called on the Client.', nil)
	end

	local cleanups = {} :: { [Types.Entity]: () -> () }
	local activeIds = {} :: { [Types.Entity]: number }
	local nextId = 0

	local cleanupEnter = self:onEnter(function(reference, zone, entity: any)
		if reference ~= Players.LocalPlayer then
			return
		end

		nextId += 1
		local observerId = nextId
		activeIds[entity] = observerId

		local cleanup = callback(zone, entity)

		if activeIds[entity] ~= observerId then
			if type(cleanup) == 'function' then
				cleanup()
			end
			return
		end

		if type(cleanup) == 'function' then
			cleanups[entity] = cleanup
		end
	end)

	local cleanupExit = self:onExit(function(_, _, entity: any)
		if not activeIds[entity] then
			return
		end

		activeIds[entity] = nil

		local cleanup = cleanups[entity]
		if cleanup then
			cleanups[entity] = nil
			cleanup()
		end
	end)

	local function disconnect()
		cleanupEnter()
		cleanupExit()

		table.clear(activeIds)

		for entity, cleanup in cleanups do
			cleanups[entity] = nil
			task.spawn(cleanup)
		end
	end

	local disconnectDestroy = self:onDestroy(disconnect)

	return function()
		disconnectDestroy()
		disconnect()
	end
end

--[=[
	Specialized event for the LocalPlayer.

	```lua
	observer:onLocalPlayerEnter(function(zone)
		print("You entered zone " .. zone:getId())

		local char = player.Character
		if char and char:FindFirstChild("Humanoid") then
			char.Humanoid.WalkSpeed = 24
		end
	end)
	```

	@client
	@tag Event
	@method onLocalPlayerEnter
	@within Observer
	@param callback (zone: Zone) -> ()
	@return () -> () -- Disconnect function
]=]
function Observer.onLocalPlayerEnter(self: Types.InternalObserver, callback: (Types.Zone, Types.Entity) -> ()): () -> ()
	if not IS_CLIENT then
		Log.fatal('Observer:onLocalPlayerEnter can only be called on the Client.', nil)
	end

	return self:onEnter(function(reference, zone, entity: any)
		if reference == Players.LocalPlayer then
			callback(zone, entity)
		end
	end)
end

--[=[
	A specialized event for the LocalPlayer. Fires when the local player's
	character exits all zones attached to this observer.

	@client
	@tag Event
	@method onLocalPlayerExit
	@within Observer
	@param callback (zone: Zone) -> ()
	@return () -> () -- Disconnect function
]=]
function Observer.onLocalPlayerExit(self: Types.InternalObserver, callback: (Types.Zone, Types.Entity) -> ()): () -> ()
	if not IS_CLIENT then
		Log.fatal('Observer:onLocalPlayerExit can only be called on the Client.', nil)
	end

	return self:onExit(function(reference, zone, entity: any)
		if reference == Players.LocalPlayer then
			callback(zone, entity)
		end
	end)
end

--[=[
	Observes a Group's presence within the observer's zones.
	The callback fires when the first entity of a group enters, and the
	returned cleanup function fires when the last entity of the group leaves.

	```lua
	observer:observeGroup(function(group, zone)
		print("Group " .. group:getId() .. " has arrived!")
		return function()
			print("Group " .. group:getId() .. " has left entirely.")
		end
	end)
	```

	@method observeGroup
	@within Observer
	@param callback (group: Group, zone: Zone) -> (() -> ())?
	@return () -> () -- Disconnect function
]=]
function Observer.observeGroup(
	self: Types.InternalObserver,
	callback: (Types.Group, Types.Zone, Types.Entity) -> (() -> ())?
): () -> ()
	local activeMembers = {} :: { [number]: { [Types.Entity]: boolean } }
	local cleanups = {} :: { [number]: () -> () }

	return self:observe(function(_, zone, entity: any)
		local groups = State.entityToGroups[entity]
		if not groups then
			return
		end

		for groupId, _ in groups do
			local group = State.groups[groupId]
			if not group or not State.groupToObservers[groupId][self.id] then
				continue
			end

			if not activeMembers[groupId] then
				activeMembers[groupId] = {}
			end

			if next(activeMembers[groupId]) == nil then
				activeMembers[groupId][entity] = true
				local groupCleanup = callback(group, zone, entity)
				if typeof(groupCleanup) == 'function' then
					cleanups[groupId] = groupCleanup
				end
			else
				activeMembers[groupId][entity] = true
			end
		end

		return function()
			for groupId, members in activeMembers do
				if not members[entity] then
					continue
				end

				members[entity] = nil
				if next(members) ~= nil then
					continue
				end

				activeMembers[groupId] = nil
				local groupCleanup = cleanups[groupId]
				if groupCleanup then
					groupCleanup()
					cleanups[groupId] = nil
				end
			end
		end
	end)
end

--[=[
	Fires when the first member of a Group enters any zone attached to this observer.
	Subsequent entries by other members of the same group will not trigger this event
	until the group has completely exited and re-entered.

	@tag Event
	@method onGroupEnter
	@within Observer
	@param callback (group: Group, zone: Zone) -> ()
	@return () -> () -- Disconnect function
]=]
function Observer.onGroupEnter(
	self: Types.InternalObserver,
	callback: (Types.Group, Types.Zone, Types.Entity) -> ()
): () -> ()
	local activeMembers = {} :: { [number]: { [Types.Entity]: boolean } }

	return self:observe(function(_, zone, entity: any)
		local groups = State.entityToGroups[entity]
		if not groups then
			return
		end

		for groupId, _ in groups do
			if not State.groupToObservers[groupId] or not State.groupToObservers[groupId][self.id] then
				continue
			end

			if not activeMembers[groupId] then
				activeMembers[groupId] = {}
			end

			local isFirst = next(activeMembers[groupId]) == nil
			activeMembers[groupId][entity] = true

			if isFirst then
				local group = State.groups[groupId]
				if group then
					callback(group, zone, entity)
				end
			end
		end

		return function()
			for groupId, members in activeMembers do
				if not members[entity] then
					continue
				end

				members[entity] = nil
				if next(members) == nil then
					activeMembers[groupId] = nil
				end
			end
		end
	end)
end

--[=[
	Fires when the last remaining member of a Group exits all zones attached to
	this observer. This is useful for "cleared" states or stopping group-wide effects.

	@tag Event
	@method onGroupExit
	@within Observer
	@param callback (group: Group, zone: Zone) -> ()
	@return () -> () -- Disconnect function
]=]
function Observer.onGroupExit(
	self: Types.InternalObserver,
	callback: (Types.Group, Types.Zone, Types.Entity) -> ()
): () -> ()
	local activeMembers = {} :: { [number]: { [Types.Entity]: boolean } }

	return self:observe(function(_, zone, entity: any)
		local groups = State.entityToGroups[entity]
		if not groups then
			return
		end

		for groupId, _ in groups do
			if not State.groupToObservers[groupId] or not State.groupToObservers[groupId][self.id] then
				continue
			end

			if not activeMembers[groupId] then
				activeMembers[groupId] = {}
			end
			activeMembers[groupId][entity] = true
		end

		return function()
			for groupId, members in activeMembers do
				if not members[entity] then
					continue
				end

				members[entity] = nil
				if next(members) == nil then
					activeMembers[groupId] = nil
					local group = State.groups[groupId]
					if group then
						callback(group, zone, entity)
					end
				end
			end
		end
	end)
end

--[=[
	Fires when an entity seamlessly transitions from one attached zone to another
	overlapping attached zone.

	@tag Event
	@method onTransition
	@within Observer
	@param callback (entity: any, newZone: Zone) -> ()
	@return () -> () -- Disconnect function
]=]
function Observer.onTransition(self: Types.InternalObserver, callback: (any, Types.Zone, Types.Entity) -> ()): () -> ()
	table.insert(State.observerTransitionedCallbacks[self.id], callback)

	return function()
		local idx = table.find(State.observerTransitionedCallbacks[self.id], callback)
		if idx then
			table.remove(State.observerTransitionedCallbacks[self.id], idx)
		end
	end
end

--[=[
	Specialized event for Player entities transitioning between overlapping zones.
	If `targetPlayer` is provided, the event will only fire for that specific player.

	@tag Event
	@method onPlayerTransition
	@within Observer
	@param callback (player: Player, newZone: Zone) -> ()
	@return () -> () -- Disconnect function
]=]
function Observer.onPlayerTransition(
	self: Types.InternalObserver,
	callback: (Player, Types.Zone, Types.Entity) -> ()
): () -> ()
	return self:onTransition(function(reference, newZone, entity: any)
		local player = State.entityToReference[reference] or reference
		if typeof(player) == 'Instance' and player:IsA('Player') then
			callback(player, newZone, entity)
		end
	end)
end

--[=[
	Specialized event for the LocalPlayer transitioning between overlapping zones.

	@client
	@tag Event
	@method onLocalPlayerTransition
	@within Observer
	@param callback (newZone: Zone) -> ()
	@return () -> () -- Disconnect function
]=]
function Observer.onLocalPlayerTransition(
	self: Types.InternalObserver,
	callback: (Types.Zone, Types.Entity) -> ()
): () -> ()
	if not IS_CLIENT then
		Log.fatal('Observer:onLocalPlayerTransition can only be called on the Client.', nil)
	end

	return self:onTransition(function(reference, newZone, entity: any)
		local player = State.entityToReference[reference] or reference
		if player == Players.LocalPlayer then
			callback(newZone, entity)
		end
	end)
end

--[=[
	Enables or disables the observer. When disabled, it will no longer process
	spatial checks or fire events.

	@method setEnabled
	@within Observer
	@param enabled boolean
	@return Observer
]=]
function Observer.setEnabled(self: Types.InternalObserver, enabled: boolean): Types.Observer
	local wasEnabled = self:isEnabled()

	if wasEnabled == enabled then
		return self
	end

	local id = self.id
	State.observerEnabled[id] = enabled
	State.logicVersion += 1

	if not enabled then
		for groupId, observersMap in State.groupToObservers do
			if not observersMap[id] then
				continue
			end

			local group = State.groups[groupId]
			if not group then
				continue
			end

			disconnectObserverFromGroup(id, group)
		end
	end

	updateAllEntitiesForObserver(self.id)

	return self
end

--[=[
	Whether to wrap callbacks in task.spawn (safe) or not (unsafe).

	:::warning Safety
	If set to unsafe, you are not allowed to yield in the callbacks anymore. If you do, QuickZone will throw errors.
	:::

	@method setSafety
	@within Observer
	@param enabled boolean
	@return Observer
]=]
function Observer.setSafety(self: Types.InternalObserver, enabled: boolean): Types.Observer
	State.observerSafety[self.id] = enabled
	return self
end

--[=[
	Updates the resolution priority of the observer.

	@method setPriority
	@within Observer
	@param p number
	@return Observer
]=]
function Observer.setPriority(self: Types.InternalObserver, p: number): Types.Observer
	State.observerPriorityMap[self.id] = p
	State.logicVersion += 1
	return self
end

--[=[
	Updates the update frequency for this observer.

	@method setUpdateRate
	@within Observer
	@param hz number
	@return Observer
]=]
function Observer.setUpdateRate(self: Types.InternalObserver, hz: number): Types.Observer
	if hz < 0 then
		Log.fatal('updateRate must be non-negative.', nil)
	end

	State.observerUpdateRate[self.id] = hz
	updateAllEntitiesForObserver(self.id)
	return self
end

--[=[
	Updates the precision in studs for this observer.

	@method setPrecision
	@within Observer
	@param n number
	@return Observer
]=]
function Observer.setPrecision(self: Types.InternalObserver, n: number): Types.Observer
	if n < 0 then
		Log.fatal('precision must be non-negative.', nil)
	end

	State.observerPrecisionSq[self.id] = n ^ 2
	updateAllEntitiesForObserver(self.id)
	return self
end

--[=[
	Checks if the observer is currently enabled.

	@method isEnabled
	@within Observer
	@return boolean
]=]
function Observer.isEnabled(self: Types.InternalObserver): boolean
	return State.observerEnabled[self.id] ~= false
end

--[=[
	Checks if a specific point in world space is inside any zone attached to this observer.

	@method isPointInside
	@within Observer
	@param position Vector3
	@return boolean
]=]
function Observer.isPointInside(self: Types.InternalObserver, position: Vector3): boolean
	local px, py, pz = position.X, position.Y, position.Z
	local observerId = self.id

	local nodes = dynamicTree.nodes
	local index = 1
	local count = dynamicTree.count
	while index <= count do
		local node = nodes[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

		local hitId = node.id
		index += 1

		if hitId == 0 then
			continue
		end

		local observers = zoneAttachedObservers[hitId]
		if not observers or not table.find(observers, observerId) then
			continue
		end

		if isPointInShape(position, dynamicCFrames[hitId], dynamicHalfSizes[hitId], dynamicTypes[hitId]) then
			return true
		end
	end

	nodes = staticTree.nodes
	index = 1
	count = staticTree.count
	while index <= count do
		local node = nodes[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

		local hitId = node.id
		index += 1

		if hitId == 0 then
			continue
		end

		local observers = zoneAttachedObservers[hitId]
		if not observers or not table.find(observers, observerId) then
			continue
		end

		if isPointInShape(position, staticCFrames[hitId], staticHalfSizes[hitId], staticTypes[hitId]) then
			return true
		end
	end

	return false
end

--[=[
	Checks if the observer wraps callbacks in task.spawn (safe) or not (unsafe).

	@method isSafe
	@within Observer
	@return boolean
]=]
function Observer.isSafe(self: Types.InternalObserver): boolean
	return State.observerSafety[self.id]
end

--[=[
	Returns the unique internal ID of the observer.

	@method getId
	@within Observer
	@return number
]=]
function Observer.getId(self: Types.InternalObserver): number
	return self.id
end

--[=[
	Returns the current resolution priority.

	@method getPriority
	@within Observer
	@return number
]=]
function Observer.getPriority(self: Types.InternalObserver): number
	return State.observerPriorityMap[self.id]
end

--[=[
	Returns the current update frequency for this observer.

	@method getUpdateRate
	@within Observer
	@return number
]=]
function Observer.getUpdateRate(self: Types.InternalObserver): number
	return State.observerUpdateRate[self.id]
end

--[=[
	Updates the precision for this observer.

	@method getPrecision
	@within Observer
	@return number
]=]
function Observer.getPrecision(self: Types.InternalObserver): number
	return math.sqrt(State.observerPrecisionSq[self.id])
end

--[=[
	Returns a list of all tracked entities currently inside zones attached to this observer.

	@method getEntitiesInside
	@within Observer
	@return { any }
]=]
function Observer.getEntitiesInside(self: Types.InternalObserver): { any }
	local entities = State.observerTrackingEntities[self.id]
	if not entities then
		return {}
	end
	local results = {}
	for entity, _ in entities do
		local ref = State.entityToReference[entity] or entity
		table.insert(results, ref)
	end
	return results
end

--[=[
	Returns a list of all players currently inside zones attached to this observer.

	@method getPlayersInside
	@within Observer
	@return { Player }
]=]
function Observer.getPlayersInside(self: Types.InternalObserver): { Player }
	local result = {}
	local entities = State.observerTrackingEntities[self.id]

	for entity in entities do
		local ref = State.entityToReference[entity] or entity
		if typeof(ref) == 'Instance' and ref:IsA('Player') then
			if not table.find(result, ref) then
				table.insert(result, ref)
			end
		end
	end
	return result
end

--[=[
	Returns the list of Zones currently attached to this observer.

	@method getZones
	@within Observer
	@return { Zone }
]=]
function Observer.getZones(self: Types.InternalObserver): { Types.Zone }
	local results = {}
	for zoneId, attachedList in State.zoneAttachedObservers do
		if not table.find(attachedList, self.id) then
			continue
		end

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

--[=[
	Returns the list of Groups currently monitored by this observer.

	@method getGroups
	@within Observer
	@return { Group }
]=]
function Observer.getGroups(self: Types.InternalObserver): { Types.Group }
	local result = {}

	for groupId, observersMap in State.groupToObservers do
		if not observersMap[self.id] then
			continue
		end

		local group = State.groups[groupId]
		if group then
			table.insert(result, group)
		end
	end

	return result
end

--[=[
	Returns the specific Zone this entity is currently tracked under for this Observer.

	@method getEntityInZone
	@within Observer
	@param entity Entity -- The entity to get the zone for
	@return Zone?
]=]
function Observer.getZoneOfEntity(self: Types.InternalObserver, entity: any): Types.Zone?
	entity = State.referenceToEntity[entity] or entity

	local data = State.entityData[entity]
	if not data then
		return nil
	end

	local zoneId = data.activeObserverMemberships[self.id]
	if zoneId then
		return State.zoneIdToZoneObj[zoneId]
	end
	return nil
end

--[=[
	Returns the specific Zone a player is currently tracked under for this Observer.

	@method getPlayerInZone
	@within Observer
	@param player Player -- The player to get the zone for
	@return Zone?
]=]
function Observer.getZoneOfPlayer(self: Types.InternalObserver, player: Player): Types.Zone?
	return self:getZoneOfEntity(player)
end

--[=[
	Returns an array of all entities currently inside a specific zone attached to this observer.

	@method getEntitiesInZone
	@within Observer
	@param zone Zone
	@return { any }
]=]
function Observer.getEntitiesInZone(self: Types.InternalObserver, zone: Types.Zone): { any }
	local results = {}

	for entity in self:iterEntitiesInZone(zone) do
		table.insert(results, entity)
	end

	return results
end

--[=[
	Returns an array of all players currently inside a specific zone attached to this observer.

	@method getPlayersInZone
	@within Observer
	@param zone Zone
	@return { Player }
]=]
function Observer.getPlayersInZone(self: Types.InternalObserver, zone: Types.Zone): { Player }
	local results = {}

	for player in self:iterPlayersInZone(zone) do
		table.insert(results, player)
	end

	return results
end

--[=[
	Returns a zero-allocation iterator for all Zones currently attached to this observer.

	@method iterZones
	@within Observer
	@return () -> Zone?
]=]
function Observer.iterZones(self: Types.InternalObserver): () -> Types.Zone?
	local zoneId = nil
	local attachedList = nil
	local observerId = self.id

	return function(): Types.Zone?
		while true do
			zoneId, attachedList = next(zoneAttachedObservers, zoneId)

			if not zoneId then
				return nil
			end

			if not table.find(attachedList, observerId) then
				continue
			end

			local zone = State.zoneIdToZoneObj[zoneId]
			if zone then
				return zone
			end
		end
	end
end

--[=[
	Returns a zero-allocation iterator for all Groups currently monitored by this observer.

	@method iterGroups
	@within Observer
	@return () -> Group?
]=]
function Observer.iterGroups(self: Types.InternalObserver): () -> Types.Group?
	local groupId = nil
	local observersMap = nil
	local observerId = self.id

	return function(): Types.Group?
		while true do
			groupId, observersMap = next(State.groupToObservers, groupId)

			if not groupId then
				return nil
			end

			if not observersMap[observerId] then
				continue
			end

			local group = State.groups[groupId]
			if group then
				return group
			end
		end
	end
end

--[=[
	Iterates over all entities currently inside zones attached to this observer.
	Yields both the Entity and the specific Zone they are in.

	@method iterEntitiesInside
	@within Observer
	@return () -> (Entity?, Zone?)
]=]
function Observer.iterEntitiesInside(self: Types.InternalObserver): () -> (any?, Types.Zone?)
	local entities = State.observerTrackingEntities[self.id]
	local entity = nil
	local observerId = self.id

	return function()
		while true do
			entity = next(entities, entity)
			if not entity then
				return nil
			end

			local data = State.entityData[entity]
			if not data then
				continue
			end

			local zoneId = data.activeObserverMemberships[observerId]
			if not zoneId then
				continue
			end

			local ref = State.entityToReference[entity] or entity
			return ref, State.zoneIdToZoneObj[zoneId]
		end
	end
end

--[=[
	Iterates over all players currently inside zones attached to this observer.
	Yields both the Player and the specific Zone they are in.

	@method iterPlayersInside
	@within Observer
	@return () -> (Player?, Zone?)
]=]
function Observer.iterPlayersInside(self: Types.InternalObserver): () -> (Player?, Types.Zone?)
	local entities = State.observerTrackingEntities[self.id]
	local entity = nil
	local observerId = self.id

	return function()
		while true do
			entity = next(entities, entity)
			if not entity then
				return nil
			end

			local data = State.entityData[entity]
			if not data then
				continue
			end

			local zoneId = data.activeObserverMemberships[observerId]
			if not zoneId then
				continue
			end

			local ref = State.entityToReference[entity] or entity
			if typeof(ref) == 'Instance' and ref:IsA('Player') then
				return ref, State.zoneIdToZoneObj[zoneId]
			end
		end
	end
end

--[=[
	Returns a zero-allocation iterator for all entities currently inside a specific zone
	attached to this observer.

	@method iterEntitiesInZone
	@within Observer
	@param zone Zone
	@return () -> Entity?
]=]
function Observer.iterEntitiesInZone(self: Types.InternalObserver, zone: Types.Zone): () -> Types.Entity?
	local entities = State.observerTrackingEntities[self.id]
	if not entities then
		return function()
			return nil
		end
	end

	local entity = nil
	local targetZoneId = zone:getId()
	local observerId = self.id

	return function(): Types.Entity?
		while true do
			entity = next(entities, entity)
			if not entity then
				return nil
			end

			local data = State.entityData[entity]

			if not data or data.activeObserverMemberships[observerId] ~= targetZoneId then
				continue
			end

			return State.entityToReference[entity] or entity
		end
	end
end

--[=[
	Returns a zero-allocation iterator for all players currently inside a specific zone
	attached to this observer.

	@method iterPlayersInZone
	@within Observer
	@param zone Zone
	@return () -> Player?
]=]
function Observer.iterPlayersInZone(self: Types.InternalObserver, zone: Types.Zone): () -> Player?
	local entities = State.observerTrackingEntities[self.id]
	if not entities then
		return function()
			return nil
		end
	end

	local entity = nil
	local targetZoneId = zone:getId()
	local observerId = self.id

	return function(): Player?
		while true do
			entity = next(entities, entity)
			if not entity then
				return nil
			end

			local data = State.entityData[entity]

			if not data or data.activeObserverMemberships[observerId] ~= targetZoneId then
				continue
			end

			local ref = State.entityToReference[entity] or entity
			if typeof(ref) == 'Instance' and ref:IsA('Player') then
				return ref
			end
		end
	end
end

--[=[
	Fires when `destroy()` is called on the observer

	```lua
	local disconnect = myObserver:onDestroy(function()
		print('Observer was destroyed!')
	end)
	```

	@tag Event
	@method onDestroy
	@within Observer
	@param callback () -> ()
	@return () -> () -- Disconnect function
]=]
function Observer.onDestroy(self: Types.InternalObserver, callback: () -> ()): () -> ()
	if not self.onDestroyCallbacks then
		self.onDestroyCallbacks = {}
	end

	table.insert(self.onDestroyCallbacks :: any, callback)

	return function()
		local idx = table.find(self.onDestroyCallbacks :: any, callback)
		if idx then
			table.remove(self.onDestroyCallbacks :: any, idx)
		end
	end
end

--[=[
	Cleans up the observer, disables tracking, and unsubscribes it from all groups and zones.

	@tag Destructor
	@method destroy
	@within Observer
]=]
function Observer.destroy(self: Types.InternalObserver): ()
	if self.onDestroyCallbacks then
		for _, callback in self.onDestroyCallbacks do
			task.spawn(callback)
		end
		table.clear(self.onDestroyCallbacks)
	end

	self:setEnabled(false)

	local id = self.id
	for _, observers in State.groupToObservers do
		observers[id] = nil
	end

	for _, attachedList in State.zoneAttachedObservers do
		local idx = table.find(attachedList, id)
		if idx then
			local lastIdx = #attachedList
			if idx ~= lastIdx then
				attachedList[idx] = attachedList[lastIdx]
			end
			attachedList[lastIdx] = nil
		end
	end

	State.observerPriorityMap[id] = nil
	State.observerEnteredCallbacks[id] = nil
	State.observerExitedCallbacks[id] = nil
	State.observerTransitionedCallbacks[id] = nil
	State.observerTrackingEntities[id] = nil
	State.observerIdToObserverObj[id] = nil
	State.observerEnabled[id] = nil
	State.observerSafety[id] = nil
	State.observerUpdateRate[id] = nil
	State.observerPrecisionSq[id] = nil
	State.observerStaticCount[id] = nil
	State.observerDynamicCount[id] = nil

	hasAttachedMap[id] = nil
	hasSubscribedMap[id] = nil

	State.logicVersion += 1
	setmetatable(self :: any, nil)
end

return Observer


--!strict

local Config = {}

-- Static enums used to determine how to read an entity's position
Config.Strategy = {
	POS = 1,
	PRIM = 2,
	WORLD = 3,
	CFRAME = 4,
	TRANSFORM = 5,
	PIVOT = 6,
}

Config.Group = {
	-- Entities are automatically cleaned up for that group if destroyed
	autoClean = true,
}

Config.Scheduler = {
	-- If true, the scheduler will automatically run every frame.
	-- If false, you must call QuickZone:update manually.
	enabled = true,

	-- Maximum execution time (in seconds) per frame
	-- 0.001 = 1ms (approx 6% of a frame at 60 FPS)
	frameBudget = 1 / 1000,

	-- How often (in Hz) to automatically sync zone positions to their references.
	-- Only applies to zones with autoSync enabled.
	autoSyncRate = 30,
}

Config.Observer = {
	-- Update frequency (Hz).
	updateRate = 30,

	-- Minimum positional change (in studs) required to trigger a spatial query
	precision = 0.1,

	-- If true, callbacks are fired via task.spawn
	-- If false, callbacks fire immediately
	safety = true,
}

Config.Debug = {
	-- Static zones
	staticActive = Color3.fromRGB(0, 204, 255),
	staticInactive = Color3.fromRGB(40, 114, 133),

	-- Dynamic zones
	dynamicActive = Color3.fromRGB(94, 255, 0),
	dynamicInactive = Color3.fromRGB(80, 141, 46),

	-- General
	transparency = 0.6,
}

return Config