--!strict

local StudioSettings = settings().Studio
local Oklab = require(script.Oklab)

local Theme = {}
Theme.__index = Theme

Theme.accentHues = {
	red = 0,
	orange = 21,
	yellow = 42,
	yellowGreen = 72,
	green = 115,
	greenBlue = 140,
	seaGreen = 165,
	cyan = 190,
	blue = 220,
	purpleBlue = 235,
	purple = 265,
	pink = 295,
	redPink = 330,
	neutral = 0, -- Special case
}

-- these use oklch hue
Theme.studioAccentHues = {
	red = math.rad(20),
	orange = math.rad(65),
	yellow = math.rad(100),
	yellowGreen = math.rad(122),
	green = math.rad(156),
	greenBlue = math.rad(179),
	seaGreen = math.rad(190),
	cyan = math.rad(230),
	blue = math.rad(266),
	purpleBlue = math.rad(280),
	purple = math.rad(300),
	pink = math.rad(325),
	redPink = math.rad(340),
	neutral = math.rad(0), -- Special case
}

local defaultIcons = {
	export = "rbxassetid://18872446140",
	import = "rbxassetid://18872442909",
	lookup = "rbxassetid://18872448764",
	edit = "rbxassetid://18872450875",
	delete = "rbxassetid://18872454033",
	storage = "rbxassetid://18872458094",
	insert = "rbxassetid://18872509789",
	pin = "rbxassetid://18886260860",
	unpin = "rbxassetid://18886277155",
	copy = "rbxassetid://131613059116054",
	maximize = "rbxassetid://18962414864",
	minimize = "rbxassetid://18962422050",
	fill = "rbxassetid://84916176036418",
	select = "rbxassetid://127081818194911",
	downArrow = "rbxassetid://17650880752",
	rightArrow = "rbxassetid://17650877462",
	upArrow = "rbxassetid://18557565597",
	externalLink = "rbxassetid://107795554941527",
	hex = "rbxassetid://89135486788434",
	script = "rbxassetid://91274876240645",
	tree = "rbxassetid://122537027737334",
	circle = "rbxassetid://134783631811707",
	brackets = "rbxassetid://114949690827797",
	error = "rbxassetid://18829614103",
	warn = "rbxassetid://18829786614",
	success = "rbxassetid://18829818279",
	check = "rbxassetid://17650916717",
	datastore = "rbxassetid://17650834077",
	orderedDatastore = "rbxassetid://18467184552",
	allScopesDatastore = "rbxassetid://18807231111",
	globalDatastore = "rbxassetid://89897336645947",
}

local function areColorsSimilar(a: Color3, b: Color3): boolean
	return math.abs(a.R - b.R) <= 0.01 and math.abs(a.B - b.B) <= 0.01 and math.abs(a.G - b.G) <= 0.01
end

local function getStudioThemeData(): (boolean, boolean)
	local StudioTheme = StudioSettings.Theme :: StudioTheme
	local isDark = Theme.getLuminance(StudioTheme:GetColor(Enum.StudioStyleGuideColor.MainBackground)) < 0.5

	-- if it's the old colors
	local isOld =
		if isDark then areColorsSimilar(StudioTheme:GetColor(Enum.StudioStyleGuideColor.MainButton), Color3.fromRGB(0, 162, 255))
		else areColorsSimilar(StudioTheme:GetColor(Enum.StudioStyleGuideColor.MainButton), Color3.fromRGB(228, 238, 254))

	return isDark, isOld
end

local function applyAccentToStudioColor(color: Color3, accent: string): Color3
	local lab = Oklab.fromColor3(color)
	if accent == "neutral" then
		return Oklab.oklch(lab.x, 0, 0)
	else
		local c = math.sqrt(lab.y^2 + lab.z^2)
		return Oklab.oklch(lab.x, c, math.deg(Theme.studioAccentHues[accent]))
	end
end

-- Reference: https://stackoverflow.com/questions/596216/formula-to-determine-perceived-brightness-of-rgb-color
function Theme.srgbToLinear(colorChannel: number)
	if colorChannel <= 0.03928 then
		return colorChannel / 12.92
	else
		return ((colorChannel + 0.055) / 1.055) ^ 2.4
	end
end

function Theme.getLuminance(color: Color3): number
	return 0.2126 * Theme.srgbToLinear(color.R)
		+ 0.7152 * Theme.srgbToLinear(color.G)
		+ 0.0722 * Theme.srgbToLinear(color.B)
end

function Theme.getContrastRatio(L1: Color3, L2: Color3): number
	local luminance1 = Theme.getLuminance(L1)
	local luminance2 = Theme.getLuminance(L2)
	local lightest = math.max(luminance1, luminance2)
	local darkest = math.min(luminance1, luminance2)
	return (lightest + 0.05) / (darkest + 0.05)
end

function Theme.improveContrast(color: Color3, backgroundColor: Color3)
	local moveTowardsColor = if Theme.getContrastRatio(backgroundColor, Color3.new(0, 0, 0))
			< Theme.getContrastRatio(backgroundColor, Color3.new(1, 1, 1))
		then Color3.new(1, 1, 1)
		else Color3.new(0, 0, 0)
	local ratio = Theme.getContrastRatio(color, backgroundColor)
	local originalColor = color

	if ratio < 6 then
		for i = 1, 10 do
			color = originalColor:Lerp(moveTowardsColor, i / 10)
			if Theme.getContrastRatio(color, backgroundColor) >= 4.5 then
				return color
			end
		end
	end

	return originalColor
end

function Theme.lighten(color: Color3, amount: number): Color3
	local lab = Oklab.fromColor3(color)
	return Oklab.toColor3(vector.create(lab.x + amount, lab.y, lab.z))
end

function Theme.new(): Theme
	local self = setmetatable({
		accent = "red",
		preset = "dark",
	}, Theme) :: Theme
	self.colors = Theme.generatePreset(self.preset, self.accent)
	self.icons = table.clone(defaultIcons)

	self._colorsChanged = Instance.new("BindableEvent")
	self.colorsChanged = self._colorsChanged.Event

	return self
end

-- returns HSV
function Theme.getAccentComponents(preset: string, accent: string): (number, number, number)
	local StudioTheme = StudioSettings.Theme :: StudioTheme
	if preset == "studio" then
		local defaultAccent = StudioTheme:GetColor(Enum.StudioStyleGuideColor.DialogMainButton, Enum.StudioStyleGuideModifier.Default)
		return applyAccentToStudioColor(defaultAccent, accent):ToHSV()
	elseif preset == "light" then
		if accent == "neutral" then
			return 0, 0, 0
		end
		return Theme.accentHues[accent] / 360, 0.8, 0.8
	else --if preset == "dark" then
		if accent == "neutral" then
			return 0, 0, 1
		end
		return Theme.accentHues[accent] / 360, 0.55, 0.9
	end
end

function Theme.getAccentColor(preset: string, accent: string)
	return Color3.fromHSV(Theme.getAccentComponents(preset, accent))
end

function Theme.generatePreset(preset: string, accent: string)
	local accentHue, accentSaturation, accentValue = Theme.getAccentComponents(preset, accent)
	local accentColor = Theme.getAccentColor(preset, accent)

	local selectedText = if Theme.getContrastRatio(accentColor, Color3.fromRGB(20, 20, 20))
			>= Theme.getContrastRatio(accentColor, Color3.fromRGB(230, 230, 230))
		then Color3.fromRGB(20, 20, 20)
		else Color3.fromRGB(230, 230, 230)

	if preset == "dark" then
		local colors = {
			button = {
				primary = if accent == "neutral"
					then {
						text = {
							select = selectedText,
							disable = Color3.new(0.25, 0.25, 0.25),
							default = selectedText,
							hover = selectedText,
							press = selectedText,
						},
						body = {
							select = accentColor,
							disable = Color3.new(0.4, 0.4, 0.4),
							default = accentColor,
							hover = Color3.new(0.9, 0.9, 0.9),
							press = Color3.new(0.8, 0.8, 0.8),
						},
						outline = {
							select = accentColor,
							disable = Color3.new(0.4, 0.4, 0.4),
							default = accentColor,
							hover = Color3.new(0.9, 0.9, 0.9),
							press = Color3.new(0.8, 0.8, 0.8),
						},
					}
					else {
						text = {
							select = selectedText,
							disable = Color3.fromHSV(accentHue, 100 / 255, 100 / 255),
							default = selectedText,
							hover = selectedText,
							press = selectedText,
						},
						body = {
							select = accentColor,
							disable = Color3.fromHSV(accentHue, 62 / 255, 62 / 255),
							default = accentColor,
							hover = Color3.fromHSV(accentHue, 0.85 * accentSaturation, 1.1 * accentValue),
							press = Color3.fromHSV(accentHue, 0.7 * accentSaturation, accentValue),
						},
						outline = {
							select = accentColor,
							disable = Color3.fromHSV(accentHue, 62 / 255, 62 / 255),
							default = accentColor,
							hover = Color3.fromHSV(accentHue, 0.85 * accentSaturation, 1.1 * accentValue),
							press = Color3.fromHSV(accentHue, 0.7 * accentSaturation, accentValue),
						},
					},
				secondary = {
					text = {
						select = selectedText,
						disable = Color3.fromRGB(80, 80, 80),
						default = Color3.fromRGB(220, 220, 220),
						hover = Color3.fromRGB(220, 220, 220),
						press = Color3.fromRGB(220, 220, 220),
					},
					body = {
						select = accentColor,
						disable = Color3.fromRGB(52, 52, 52),
						default = Color3.fromRGB(32, 32, 32),
						hover = Color3.fromRGB(32, 32, 32),
						press = Color3.fromRGB(45, 45, 45),
					},
					outline = {
						select = accentColor,
						disable = Color3.fromRGB(63, 63, 63),
						default = Color3.fromRGB(62, 62, 62),
						hover = Color3.fromRGB(135, 135, 135),
						press = Color3.fromRGB(105, 105, 105),
					},
				},
				transparent = {
					baseColor = if accent == "neutral" then accentColor else Color3.fromHSV(accentHue, 0.9, 1),
					selectColor = if accent == "neutral" then accentColor else Color3.fromHSV(accentHue, 0.9, 1),

					select = 0.7,
					disable = 1,
					default = 1,
					hover = 0.8,
					press = 0.6,
				},
				dormant = {
					select = Color3.fromRGB(240, 240, 240),
					disable = Color3.fromRGB(85, 85, 85),
					default = Color3.fromRGB(170, 170, 170),
					hover = Color3.fromRGB(200, 200, 200),
					press = Color3.fromRGB(220, 220, 220),
				},
			},

			message = {
				text = Color3.fromRGB(220, 220, 220),
				background = Color3.fromRGB(16, 16, 16),
				outline = Color3.fromRGB(50, 50, 50),
			},

			floatingAction = {
				background = Color3.fromRGB(40, 40, 40),
				outline = Color3.fromRGB(62, 62, 62),
			},

			error = Color3.fromRGB(209, 13, 13),
			warn = Color3.fromRGB(186, 138, 15),
			success = Color3.fromRGB(20, 182, 66),

			text = Color3.fromRGB(220, 220, 220),
			disabledText = Color3.fromRGB(100, 100, 100),

			headerBackground = Color3.fromRGB(26, 26, 26),

			background = Color3.fromRGB(26, 26, 26), --Color3.fromRGB(40, 40, 40),
			backgroundOutline = Color3.fromRGB(62, 62, 62),

			popupBackground = Color3.fromRGB(36, 36, 36),
			popupBackgroundOutline = Color3.fromRGB(62, 62, 62),

			scrollbar = Color3.fromRGB(150, 150, 150),

			scrimTransparency = 0.75,

			imageBackground = Color3.fromRGB(60, 60, 60),
			imageBackgroundOutline = Color3.fromRGB(70, 70, 70),

			separator = Color3.fromRGB(72, 72, 72),

			mainAccent = accentColor,

			syntaxHighlight = {
				root = Color3.fromRGB(247, 17, 255),
				key = Color3.fromRGB(90, 223, 106),
				key2 = Color3.fromRGB(171, 96, 225),
				key3 = Color3.fromRGB(107, 123, 230),
				index = Color3.fromRGB(171, 96, 225),

				array = Color3.fromRGB(116, 116, 116),
				object = Color3.fromRGB(116, 116, 116),
				boolean = Color3.fromRGB(243, 45, 88),
				number = Color3.fromRGB(241, 190, 36),
				string = Color3.fromRGB(78, 199, 243),
				null = Color3.fromRGB(243, 243, 243),
				buffer = Color3.fromRGB(15, 200, 154),
			},
		}
		colors.button.input = {
			text = table.clone(colors.button.secondary.text),
			body = table.clone(colors.button.secondary.body),
			outline = table.clone(colors.button.secondary.outline),
		}
		colors.button.input.outline.select = accentColor
		colors.button.input.body.select = colors.button.secondary.body.default
		colors.button.input.text.select = colors.button.secondary.text.default
		return colors
	elseif preset == "light" then
		local colors = {
			button = {
				primary = if accent == "neutral"
					then {
						text = {
							select = selectedText,
							disable = Color3.new(0.6, 0.6, 0.6),
							default = selectedText,
							hover = selectedText,
							press = selectedText,
						},
						body = {
							select = accentColor,
							disable = Color3.new(0.4, 0.4, 0.4),
							default = accentColor,
							hover = Color3.new(0.1, 0.1, 0.1),
							press = Color3.new(0.2, 0.2, 0.2),
						},
						outline = {
							select = accentColor,
							disable = Color3.new(0.4, 0.4, 0.4),
							default = accentColor,
							hover = Color3.new(0.1, 0.1, 0.1),
							press = Color3.new(0.2, 0.2, 0.2),
						},
					}
					else {
						text = {
							select = selectedText,
							disable = Color3.fromHSV(accentHue, 0.15, 0.6 * accentValue),
							default = selectedText,
							hover = selectedText,
							press = selectedText,
						},
						body = {
							select = accentColor,
							disable = Color3.fromHSV(accentHue, 0.15, accentValue),
							default = accentColor,
							hover = Color3.fromHSV(accentHue, 0.85 * accentSaturation, 1.1 * accentValue),
							press = Color3.fromHSV(accentHue, 0.7 * accentSaturation, accentValue),
						},
						outline = {
							select = accentColor,
							disable = Color3.fromHSV(accentHue, 0.15, accentValue),
							default = accentColor,
							hover = Color3.fromHSV(accentHue, 0.85 * accentSaturation, 1.1 * accentValue),
							press = Color3.fromHSV(accentHue, 0.7 * accentSaturation, accentValue),
						},
					},
				secondary = {
					text = {
						select = selectedText,
						disable = Color3.fromRGB(150, 150, 150),
						default = Color3.fromRGB(20, 20, 20),
						hover = Color3.fromRGB(20, 20, 20),
						press = Color3.fromRGB(20, 20, 20),
					},
					body = {
						select = accentColor,
						disable = Color3.fromRGB(210, 210, 210),
						default = Color3.fromRGB(236, 236, 236),
						hover = Color3.fromRGB(236, 236, 236),
						press = Color3.fromRGB(204, 204, 204),
					},
					outline = {
						select = accentColor,
						disable = Color3.fromRGB(190, 190, 190),
						default = Color3.fromRGB(200, 200, 200),
						hover = Color3.fromRGB(160, 160, 160),
						press = Color3.fromRGB(130, 130, 130),
					},
				},
				transparent = {
					baseColor = if accent == "neutral" then accentColor else Color3.fromHSV(accentHue, 0.9, 0.7),
					selectColor = if accent == "neutral" then accentColor else Color3.fromHSV(accentHue, 0.9, 0.7),

					select = 0.7,
					disable = 1,
					default = 1,
					hover = 0.8,
					press = 0.6,
				},
				dormant = {
					select = Color3.fromRGB(15, 15, 15),
					disable = Color3.fromRGB(200, 200, 200),
					default = Color3.fromRGB(110, 110, 110),
					hover = Color3.fromRGB(85, 85, 85),
					press = Color3.fromRGB(65, 65, 65),
				},
			},

			message = {
				text = Color3.fromRGB(20, 20, 20),
				background = Color3.fromRGB(240, 240, 240),
				outline = Color3.fromRGB(205, 205, 205),
			},

			floatingAction = {
				background = Color3.fromRGB(238, 238, 238),
				outline = Color3.fromRGB(170, 170, 170),
			},

			error = Color3.fromRGB(209, 13, 13),
			warn = Color3.fromRGB(186, 138, 15),
			success = Color3.fromRGB(20, 182, 66),

			text = Color3.fromRGB(20, 20, 20),
			disabledText = Color3.fromRGB(120, 120, 120),

			headerBackground = Color3.fromRGB(246, 246, 246),

			background = Color3.fromRGB(246, 246, 246),
			backgroundOutline = Color3.fromRGB(170, 170, 170),

			popupBackground = Color3.fromRGB(238, 238, 238),
			popupBackgroundOutline = Color3.fromRGB(170, 170, 170),

			scrollbar = Color3.fromRGB(120, 120, 120),

			scrimTransparency = 0.75,

			imageBackground = Color3.fromRGB(210, 210, 210),
			imageBackgroundOutline = Color3.fromRGB(190, 190, 190),

			separator = Color3.fromRGB(180, 180, 180),

			mainAccent = accentColor,

			syntaxHighlight = {
				root = Color3.fromRGB(152, 11, 159),
				key = Color3.fromRGB(60, 124, 65),
				key2 = Color3.fromRGB(105, 42, 157),
				key3 = Color3.fromRGB(45, 63, 135),
				index = Color3.fromRGB(105, 42, 157),

				array = Color3.fromRGB(95, 95, 95),
				object = Color3.fromRGB(95, 95, 95),
				boolean = Color3.fromRGB(141, 26, 53),
				number = Color3.fromRGB(147, 116, 22),
				string = Color3.fromRGB(44, 113, 138),
				null = Color3.fromRGB(29, 29, 29),
				buffer = Color3.fromRGB(30, 157, 115),
			},
		}
		colors.button.input = {
			text = table.clone(colors.button.secondary.text),
			body = table.clone(colors.button.secondary.body),
			outline = table.clone(colors.button.secondary.outline),
		}
		colors.button.input.outline.select = accentColor
		colors.button.input.body.select = colors.button.secondary.body.default
		colors.button.input.text.select = colors.button.secondary.text.default
		return colors
	elseif preset == "studio" then
		local StudioTheme = StudioSettings.Theme :: StudioTheme
		local isDark, isOld = getStudioThemeData()
		local separatorColor =
			if isDark and (not isOld) then Theme.lighten(StudioTheme:GetColor(Enum.StudioStyleGuideColor.Separator), 0.025)
			else StudioTheme:GetColor(Enum.StudioStyleGuideColor.Separator)

		return {
			button = {
				primary = {
					text = {
						select = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.DialogMainButtonText,
							Enum.StudioStyleGuideModifier.Selected
						),
						disable = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.DialogMainButtonText,
							Enum.StudioStyleGuideModifier.Disabled
						),
						default = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.DialogMainButtonText,
							Enum.StudioStyleGuideModifier.Default
						),
						hover = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.DialogMainButtonText,
							Enum.StudioStyleGuideModifier.Hover
						),
						press = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.DialogMainButtonText,
							Enum.StudioStyleGuideModifier.Pressed
						),
					},
					body = {
						select = applyAccentToStudioColor(StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.DialogMainButton,
							Enum.StudioStyleGuideModifier.Selected
						), accent),
						disable = applyAccentToStudioColor(StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.DialogMainButton,
							Enum.StudioStyleGuideModifier.Disabled
						), accent),
						default = applyAccentToStudioColor(StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.DialogMainButton,
							Enum.StudioStyleGuideModifier.Default
						), accent),
						hover = applyAccentToStudioColor(StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.DialogMainButton,
							Enum.StudioStyleGuideModifier.Hover
						), accent),
						press = applyAccentToStudioColor(StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.DialogMainButton,
							Enum.StudioStyleGuideModifier.Pressed
						), accent),
					},
					outline = {
						select = applyAccentToStudioColor(StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.DialogMainButton,
							Enum.StudioStyleGuideModifier.Selected
						), accent),
						disable = applyAccentToStudioColor(StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.DialogMainButton,
							Enum.StudioStyleGuideModifier.Disabled
						), accent),
						default = applyAccentToStudioColor(StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.DialogMainButton,
							Enum.StudioStyleGuideModifier.Default
						), accent),
						hover = applyAccentToStudioColor(StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.DialogMainButton,
							Enum.StudioStyleGuideModifier.Hover
						), accent),
						press = applyAccentToStudioColor(StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.DialogMainButton,
							Enum.StudioStyleGuideModifier.Pressed
						), accent),
					},
				},
				secondary = {
					text = {
						select = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.ButtonText,
							Enum.StudioStyleGuideModifier.Selected
						),
						disable = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.ButtonText,
							Enum.StudioStyleGuideModifier.Disabled
						),
						default = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.ButtonText,
							Enum.StudioStyleGuideModifier.Default
						),
						hover = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.ButtonText,
							Enum.StudioStyleGuideModifier.Hover
						),
						press = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.ButtonText,
							Enum.StudioStyleGuideModifier.Pressed
						),
					},
					body = {
						select = applyAccentToStudioColor(StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.Button,
							Enum.StudioStyleGuideModifier.Selected
						), accent),
						disable = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.Button,
							Enum.StudioStyleGuideModifier.Disabled
						),
						default = if isDark and isOld
							then Color3.fromRGB(69, 69, 69)
							else StudioTheme:GetColor(
								Enum.StudioStyleGuideColor.Button,
								Enum.StudioStyleGuideModifier.Default
							),
						hover = if isDark and isOld
							then Color3.fromRGB(77, 77, 77)
							elseif isOld then
								StudioTheme:GetColor(
									Enum.StudioStyleGuideColor.Button,
									Enum.StudioStyleGuideModifier.Selected
								)
							else StudioTheme:GetColor(
								Enum.StudioStyleGuideColor.Button,
								Enum.StudioStyleGuideModifier.Hover
							),
						press = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.Button,
							Enum.StudioStyleGuideModifier.Pressed
						),
					},
					outline = {
						select = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.ButtonBorder,
							Enum.StudioStyleGuideModifier.Selected
						),
						disable = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.ButtonBorder,
							Enum.StudioStyleGuideModifier.Disabled
						),
						default = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.ButtonBorder,
							Enum.StudioStyleGuideModifier.Default
						),
						hover = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.ButtonBorder,
							Enum.StudioStyleGuideModifier.Hover
						),
						press = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.ButtonBorder,
							Enum.StudioStyleGuideModifier.Pressed
						),
					},
				},
				input = {
					text = {
						select = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.ButtonText,
							Enum.StudioStyleGuideModifier.Selected
						),
						disable = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.ButtonText,
							Enum.StudioStyleGuideModifier.Disabled
						),
						default = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.ButtonText,
							Enum.StudioStyleGuideModifier.Default
						),
						hover = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.ButtonText,
							Enum.StudioStyleGuideModifier.Hover
						),
						press = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.ButtonText,
							Enum.StudioStyleGuideModifier.Pressed
						),
					},
					body = {
						select = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.InputFieldBackground,
							Enum.StudioStyleGuideModifier.Selected
						),
						disable = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.InputFieldBackground,
							Enum.StudioStyleGuideModifier.Disabled
						),
						default = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.InputFieldBackground,
							Enum.StudioStyleGuideModifier.Default
						),
						hover = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.InputFieldBackground,
							Enum.StudioStyleGuideModifier.Hover
						),
						press = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.InputFieldBackground,
							Enum.StudioStyleGuideModifier.Pressed
						),
					},
					outline = {
						select = applyAccentToStudioColor(StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.InputFieldBorder,
							Enum.StudioStyleGuideModifier.Selected
						), accent),
						disable = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.InputFieldBorder,
							Enum.StudioStyleGuideModifier.Disabled
						),
						default = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.InputFieldBorder,
							Enum.StudioStyleGuideModifier.Default
						),
						hover = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.InputFieldBorder,
							Enum.StudioStyleGuideModifier.Hover
						),
						press = StudioTheme:GetColor(
							Enum.StudioStyleGuideColor.InputFieldBorder,
							Enum.StudioStyleGuideModifier.Pressed
						),
					},
				},
				transparent = {
					baseColor = StudioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
					selectColor = applyAccentToStudioColor(StudioTheme:GetColor(
						Enum.StudioStyleGuideColor.DialogMainButton,
						Enum.StudioStyleGuideModifier.Selected
					), accent),

					select = 0.5,
					disable = 1,
					default = 1,
					hover = 0.95,
					press = 0.85,
				},
				dormant = if isDark
					then {
						select = Color3.new(1, 1, 1),
						disable = Color3.new(0.35, 0.35, 0.35),
						default = Color3.new(0.6, 0.6, 0.6),
						hover = Color3.new(0.7, 0.7, 0.7),
						press = Color3.new(0.8, 0.8, 0.8),
					}
					else {
						select = Color3.new(0, 0, 0),
						disable = Color3.new(0.75, 0.75, 0.75),
						default = Color3.new(0.4, 0.4, 0.4),
						hover = Color3.new(0.3, 0.3, 0.3),
						press = Color3.new(0.2, 0.2, 0.2),
					},
			},

			message = if isOld then {
				text = Color3.fromRGB(250, 250, 250),
				background = Color3.fromRGB(24, 24, 24),
				outline = Color3.fromRGB(24, 24, 24),
			} elseif isDark then {
				text = StudioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
				background = StudioTheme:GetColor(Enum.StudioStyleGuideColor.Tooltip),
				outline = Theme.lighten(StudioTheme:GetColor(Enum.StudioStyleGuideColor.Tooltip), -0.03),
			} else {
				text = StudioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
				background = Theme.lighten(StudioTheme:GetColor(Enum.StudioStyleGuideColor.Tooltip), -0.03),
				outline = Theme.lighten(StudioTheme:GetColor(Enum.StudioStyleGuideColor.Tooltip), -0.08),
			},

			floatingAction = {
				background = StudioTheme:GetColor(
					Enum.StudioStyleGuideColor.Button
				),
				outline = StudioTheme
					:GetColor(Enum.StudioStyleGuideColor.MainBackground)
					:Lerp(Color3.new(0, 0, 0), if isDark then 0 else 0.15),
			},

			error = StudioTheme:GetColor(Enum.StudioStyleGuideColor.ErrorText),
			warn = StudioTheme:GetColor(Enum.StudioStyleGuideColor.WarningText),
			success = StudioTheme:GetColor(Enum.StudioStyleGuideColor.DialogMainButton),

			text = StudioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
			disabledText = StudioTheme:GetColor(
				Enum.StudioStyleGuideColor.DimmedText
			),

			headerBackground = StudioTheme:GetColor(
				Enum.StudioStyleGuideColor.Titlebar
			),

			background = StudioTheme:GetColor(
				Enum.StudioStyleGuideColor.MainBackground
			),
			backgroundOutline = StudioTheme:GetColor(
				Enum.StudioStyleGuideColor.MainBackground
			),

			popupBackground =
				if (not isOld) and isDark then Theme.lighten(StudioTheme:GetColor(Enum.StudioStyleGuideColor.MainBackground), 0.02)
				elseif (not isOld) and (not isDark) then Theme.lighten(StudioTheme:GetColor(Enum.StudioStyleGuideColor.MainBackground), -0.025)
				else StudioTheme:GetColor(
					Enum.StudioStyleGuideColor.MainBackground
				),
			popupBackgroundOutline = separatorColor,

			scrollbar = StudioTheme:GetColor(Enum.StudioStyleGuideColor.DimmedText),

			scrimTransparency = 0.6,

			imageBackground = StudioTheme:GetColor(
				Enum.StudioStyleGuideColor.ViewPortBackground
			),
			imageBackgroundOutline = StudioTheme:GetColor(
				Enum.StudioStyleGuideColor.ViewPortBackground
			),

			separator = separatorColor,

			mainAccent = accentColor,

			syntaxHighlight = if isDark
				then {
					root = Color3.fromRGB(248, 109, 124),
					key = Color3.fromRGB(97, 161, 241),
					key2 = Color3.fromRGB(172, 111, 247),
					key3 = Color3.fromRGB(132, 214, 247),
					index = Color3.fromRGB(132, 214, 247),

					array = Color3.fromRGB(102, 102, 102),
					object = Color3.fromRGB(102, 102, 102),
					boolean = Color3.fromRGB(253, 251, 172),
					number = Color3.fromRGB(255, 198, 0),
					string = Color3.fromRGB(173, 241, 149),
					null = Color3.fromRGB(204, 204, 204),
					buffer = Color3.fromRGB(86, 214, 188),
				}
				else {
					root = Color3.fromRGB(0, 0, 127),
					key = Color3.fromRGB(72, 92, 142),
					key2 = Color3.fromRGB(116, 72, 189),
					key3 = Color3.fromRGB(0, 116, 189),
					index = Color3.fromRGB(0, 116, 189),

					array = Color3.fromRGB(127, 127, 127),
					object = Color3.fromRGB(127, 127, 127),
					boolean = Color3.fromRGB(0, 127, 9),
					number = Color3.fromRGB(0, 127, 127),
					string = Color3.fromRGB(127, 0, 127),
					null = Color3.fromRGB(127, 127, 0),
					buffer = Color3.fromRGB(19, 127, 100),
				},
		}
	else
		error(`{preset} is not valid theme reset`, 2)
	end
end

function Theme:_updateColors()
	self.colors = Theme.generatePreset(self.preset, self.accent)

	self._colorsChanged:Fire()
end

-- Interface

function Theme:setAccent(accent: string)
	if self.accent ~= accent then
		self.accent = accent
		self:_updateColors()
	end
end

function Theme:setPreset(presetName: string)
	if self.preset ~= presetName then
		self.preset = presetName
		self:_updateColors()
	end
end

Theme.global = Theme.new()

StudioSettings.ThemeChanged:Connect(function()
	if Theme.global.preset == "studio" then
		Theme.global:_updateColors()
	end
end)

export type Theme = typeof(setmetatable({
	accent = "red",
	preset = "dark",
	colors = Theme.generatePreset("red", "dark"),
	icons = defaultIcons,
	_colorsChanged = Instance.new("BindableEvent"),
	colorsChanged = Instance.new("BindableEvent").Event,
}, Theme))

return Theme


--!strict

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

local PopupHelper = require(script.Parent.PopupHelper)

local Tooltip = {}
Tooltip.__index = Tooltip

local FrameTemplate = script.FrameTemplate
local DropShadowTemplate = script.DropShadowTemplate
local TextLabelTemplate = script.TextLabelTemplate

local APPEAR_DELAY = 0.5
local APPEAR_DURATION = 0.06

local TOOLTIP_DISTANCE = 8
local TOOLTIP_PADDING_X = 6
local TOOLTIP_PADDING_Y = 6
local TOOLTIP_MAX_X = 200

local SHADOW_EXTRA_SIZE_X = 30
local SHADOW_EXTRA_SIZE_Y = 26
local SHADOW_OFFSET_X = -15
local SHADOW_OFFSET_Y = -9

--[[ Main Tooltip Interface ]]

export type TooltipDestructor = () -> ()

-- Bind tooltip to button, placing it right below.
function Tooltip.bindButtonGeneric(theme: any, button: GuiObject, message: string): TooltipDestructor
	return Tooltip.bind(theme, button, message, {
		on = button,
		offsetAnchor = Vector2.new(0.5, 1),
		tooltipAnchor = Vector2.new(0.5, 0),
	})
end

-- Ignores rotation
local function isHovering(object: GuiObject, position: Vector2): boolean
	return (object.AbsolutePosition.X <= position.X)
		and (object.AbsolutePosition.X + object.AbsoluteSize.X >= position.X)
		and (object.AbsolutePosition.Y <= position.Y)
		and (object.AbsolutePosition.Y + object.AbsoluteSize.Y >= position.Y)
end

-- Bind tooltip to button with extra options
function Tooltip.bind(
	theme: any,
	interactObject: GuiObject,
	message: string,
	showOptions: ShowOptions
): TooltipDestructor
	local tooltip = Tooltip.new(theme, message)
	local hover = nil

	-- If the interactObject moves (like when scrolling), mouse events won't fire, so have to track if the mouse is
	-- still on the object manually
	local positionConnection: RBXScriptConnection?

	local inputBeganConnection = interactObject.InputBegan:Connect(function(input)
		if input.UserInputType == Enum.UserInputType.MouseMovement then
			local myHover = newproxy()
			hover = myHover
			task.wait(APPEAR_DELAY)

			if (not tooltip.object) and (hover == myHover) then
				if positionConnection then
					positionConnection:Disconnect()
				end

				positionConnection = interactObject:GetPropertyChangedSignal("AbsolutePosition"):Connect(function()
					if not isHovering(interactObject, input.Position :: any) then
						hover = nil
						tooltip:clear()

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

				tooltip:show(showOptions)
			end
		elseif
			input.UserInputType == Enum.UserInputType.MouseButton1
			or input.UserInputType == Enum.UserInputType.Touch
		then
			hover = nil
			tooltip:clear()

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

	local inputEndedConnection = interactObject.InputEnded:Connect(function(input)
		if input.UserInputType == Enum.UserInputType.MouseMovement then
			hover = nil
			tooltip:clear()

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

	return function()
		inputBeganConnection:Disconnect()
		inputEndedConnection:Disconnect()
		tooltip:kill()
	end
end

--[[ Internal Tooltip Stuff ]]

function Tooltip.new(theme: any, message: string)
	local self = setmetatable({
		dead = false,

		theme = theme,
		message = message,

		object = nil :: (Frame | CanvasGroup)?,
	}, Tooltip)

	return self
end

export type ShowOptions = {
	on: GuiObject,
	offsetAnchor: Vector2,
	tooltipAnchor: Vector2,

	offsetOnX: boolean?,
}
function Tooltip:show(options: ShowOptions)
	if self.dead then
		return
	end

	local params = Instance.new("GetTextBoundsParams")
	params.Text = self.message
	params.Font = TextLabelTemplate.FontFace
	params.Size = TextLabelTemplate.TextSize
	params.Width = TOOLTIP_MAX_X
	params.RichText = true

	local textBounds = TextService:GetTextBoundsAsync(params)

	if self.object then
		self:clear()
	end

	local frameBounds = textBounds + Vector2.new(2 * TOOLTIP_PADDING_X, 2 * TOOLTIP_PADDING_Y)
	local objectBounds = frameBounds + Vector2.new(SHADOW_EXTRA_SIZE_X, SHADOW_EXTRA_SIZE_Y)

	local object: any = Instance.new("CanvasGroup")
	object.BackgroundTransparency = 1
	object.Size = UDim2.fromOffset(objectBounds.X, objectBounds.Y)
	object.GroupTransparency = 1
	object.ZIndex = 50

	local shadow = DropShadowTemplate:Clone()
	shadow.Size = UDim2.fromOffset(objectBounds.X, objectBounds.Y)
	shadow.Parent = object

	local frame = FrameTemplate:Clone()
	frame.BackgroundColor3 = self.theme.colors.message.background
	frame.UIStroke.Color = self.theme.colors.message.outline
	frame.Position = UDim2.fromOffset(-SHADOW_OFFSET_X, -SHADOW_OFFSET_Y)
	frame.Size = UDim2.fromOffset(frameBounds.X, frameBounds.Y)
	frame.Parent = object

	local textLabel = TextLabelTemplate:Clone()
	textLabel.TextColor3 = self.theme.colors.message.text
	textLabel.Position = UDim2.fromOffset(TOOLTIP_PADDING_X, TOOLTIP_PADDING_Y)
	textLabel.Size = UDim2.fromOffset(textBounds.X + 1, textBounds.Y + 1)
	textLabel.Text = self.message
	textLabel.Parent = frame

	local container = options.on:FindFirstAncestorWhichIsA("LayerCollector") :: LayerCollector
	local function updatePosition(alpha: number)
		local offsetX = 0
		local offsetY = 0
		if options.offsetOnX then
			offsetX = TOOLTIP_DISTANCE * options.offsetAnchor.X
		else
			offsetY = TOOLTIP_DISTANCE * options.offsetAnchor.Y
		end

		object.Position = UDim2.fromOffset(
			options.on.AbsolutePosition.X
				+ SHADOW_OFFSET_X
				+ options.offsetAnchor.X * options.on.AbsoluteSize.X
				+ alpha * offsetX
				- options.tooltipAnchor.X * frameBounds.X,
			options.on.AbsolutePosition.Y
				+ SHADOW_OFFSET_Y
				+ options.offsetAnchor.Y * options.on.AbsoluteSize.Y
				+ alpha * offsetY
				- options.tooltipAnchor.Y * frameBounds.Y
		)

		PopupHelper.clamp(object, container, {
			boundaryDistance = 5 + SHADOW_OFFSET_X,
		})
	end

	updatePosition(0)

	object.Parent = container
	self.object = object

	task.spawn(function()
		local start = os.clock()
		while object.Parent and self.object == object do
			local delta = math.min(os.clock() - start, APPEAR_DURATION)
			local a = TweenService:GetValue(delta / APPEAR_DURATION, Enum.EasingStyle.Cubic, Enum.EasingDirection.Out)

			updatePosition(a)
			object.GroupTransparency = 1 - a

			if delta >= APPEAR_DURATION then
				break
			end
			task.wait()
		end

		if object.Parent and self.object == object then
			local newObject = Instance.new("Frame")
			newObject.Size = object.Size
			newObject.BackgroundTransparency = 1
			newObject.ZIndex = 50

			shadow.Parent = newObject
			frame.Parent = newObject

			object:Destroy()

			newObject.Parent = container
			self.object = newObject
			updatePosition(1)
		end
	end)
end

function Tooltip:clear()
	if self.object then
		self.object:Destroy()
		self.object = nil :: any
	end
end

function Tooltip:kill()
	self.dead = true
	self:clear()
end

return Tooltip


-- Utility to send alerts/toasts
-- Layout manually calculated (no AutomaticSize).

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

local StyleState = script.Parent.StyleState
local PopupHelper = require(script.Parent.PopupHelper)

local StyleStateWrapper = require(StyleState.StyleStateWrapper)
local BackgroundStyleState = require(StyleState.BackgroundStyleState)
local ButtonStyleState = require(StyleState.ButtonStyleState)
local LabelStyleState = require(StyleState.LabelStyleState)

local ToastTemplate = script.ToastTemplate
local TextLabelTemplate = script.TextLabelTemplate
local IconTemplate = script.IconTemplate
local CloseButtonTemplate = script.CloseButtonTemplate

local UIMessages = {}
UIMessages.__index = UIMessages

-- Toasts

local MIN_TOAST_X = 100
local MIN_TOAST_Y = 0
local TOAST_PADDING_X = 8
local TOAST_PADDING_Y = 8
local TOAST_PADDING_INBETWEEN = 8
local WINDOW_PADDING_X = 12
local WINDOW_PADDING_Y = 12

function UIMessages.new(theme)
	local gui = script.MessagesContainer:Clone()
	local self = setmetatable({
		theme = theme,

		parent = nil,
		gui = gui,
		zIndex = gui.ZIndex,

		_toastQueue = {} :: { ToastQueueElement },
	}, UIMessages)

	return self
end

function UIMessages:setParent(parent: Instance)
	self.gui.Parent = parent
	self.parent = parent
end

function UIMessages:_getMaxToastWidth(): number
	return math.clamp(math.floor((self.gui :: any).AbsoluteSize.X * 0.4), 200, 500)
end

type ToastQueueElement = {
	button: typeof(ToastTemplate),
	position: UDim2,
}

local appearTweenInfo = TweenInfo.new(0.1, Enum.EasingStyle.Quart)
local disappearTweenInfo = TweenInfo.new(0.15, Enum.EasingStyle.Sine)

function UIMessages:_pushToastElements(amount: number, tweenInfo: TweenInfo, after: number?)
	after = after or 0
	for i, element: ToastQueueElement in self._toastQueue do
		if i <= after then
			continue
		end

		element.position -= UDim2.fromOffset(0, amount)
		local tween = TweenService:Create(element.button, tweenInfo, {
			Position = element.position,
		})
		tween:Play()
	end
end

function UIMessages:_removeToastElementAt(index: number)
	local element: ToastQueueElement = self._toastQueue[index]
	local disappearTween = TweenService:Create(element.button, disappearTweenInfo, {
		Position = element.position + UDim2.fromOffset(element.button.AbsoluteSize.X + WINDOW_PADDING_X + 4, 0),
	})
	disappearTween:Play()

	self:_pushToastElements(-element.button.AbsoluteSize.Y - TOAST_PADDING_INBETWEEN, disappearTweenInfo, index)
	table.remove(self._toastQueue, index)

	disappearTween.Completed:Wait()
	element.button:Destroy()
end

export type ToastOptions = {
	text: string,
	icon: string?,
	iconColor: Color3?,
	duration: number?,
}
function UIMessages:toast(options: ToastOptions?)
	options = options or { text = "" }
	assert(options)

	local toast = ToastTemplate:Clone()
	toast.BackgroundColor3 = self.theme.colors.message.background
	toast.UIStroke.Color = self.theme.colors.message.outline

	local boundsX, boundsY = MIN_TOAST_X, MIN_TOAST_Y

	local function updateBounds(newX: number, newY: number)
		boundsX, boundsY = math.max(boundsX, newX), math.max(boundsY, newY)
	end

	local icon: ImageLabel?
	if options.icon then
		icon = IconTemplate:Clone()
		assert(icon)

		icon.Image = options.icon
		icon.ImageColor3 = options.iconColor or self.theme.colors.message.text

		updateBounds(icon.AbsoluteSize.X, icon.AbsoluteSize.Y)
	end

	local getTextBoundParams = Instance.new("GetTextBoundsParams")
	getTextBoundParams.Text = options.text
	getTextBoundParams.Font = TextLabelTemplate.FontFace
	getTextBoundParams.Size = TextLabelTemplate.TextSize
	getTextBoundParams.Width = self:_getMaxToastWidth()
	getTextBoundParams.RichText = true

	local textBounds = TextService:GetTextBoundsAsync(getTextBoundParams)

	updateBounds(textBounds.X, textBounds.Y)

	local textLabel = TextLabelTemplate:Clone()
	textLabel.TextColor3 = self.theme.colors.message.text
	textLabel.Size = UDim2.fromOffset(math.floor(textBounds.X + 1), math.floor(textBounds.Y + 1))
	textLabel.Text = options.text

	local closeButton = CloseButtonTemplate:Clone()
	closeButton.ImageLabel.ImageColor3 = self.theme.colors.message.text
	closeButton.Visible = false

	-- Placing

	local placeX = TOAST_PADDING_X
	if icon then
		icon.Position = UDim2.fromOffset(placeX, math.floor(boundsY / 2 - icon.AbsoluteSize.Y / 2) + TOAST_PADDING_Y)
		icon.Parent = toast
		placeX += 10 + icon.AbsoluteSize.X
		boundsX += 10 + icon.AbsoluteSize.X
	end

	textLabel.Position = UDim2.fromOffset(placeX, math.floor(boundsY / 2 - textBounds.Y / 2) + TOAST_PADDING_Y)
	textLabel.Parent = toast

	closeButton.AnchorPoint = Vector2.new(1, 0.5)
	closeButton.Position = UDim2.new(1, 0, 0.5, 0)
	closeButton.Size += UDim2.fromOffset(TOAST_PADDING_X, 0)
	closeButton.Parent = toast

	toast.Size = UDim2.fromOffset(
		boundsX + 2 * TOAST_PADDING_X + CloseButtonTemplate.AbsoluteSize.X,
		boundsY + 2 * TOAST_PADDING_Y
	)
	toast.Position = UDim2.new(1, -WINDOW_PADDING_X, 1, boundsY + 2 * TOAST_PADDING_Y + 4)
	toast.Parent = self.gui
	PopupHelper.attachDropShadow(toast)

	-- Animate in
	self:_pushToastElements(toast.AbsoluteSize.Y + TOAST_PADDING_INBETWEEN, appearTweenInfo)

	local element: ToastQueueElement = {
		button = toast,
		position = UDim2.new(1, -WINDOW_PADDING_X, 1, -WINDOW_PADDING_Y),
	}

	local appearTween = TweenService:Create(toast, appearTweenInfo, {
		Position = element.position,
	})

	appearTween:Play()
	table.insert(self._toastQueue, 1, element)

	-- Disappearing

	local function disappear()
		local index = table.find(self._toastQueue, element)
		if index then
			self:_removeToastElementAt(index)
		end
	end

	toast.InputBegan:Connect(function(input)
		if input.UserInputType == Enum.UserInputType.MouseMovement then
			closeButton.Visible = true
		end
	end)
	toast.InputEnded:Connect(function(input)
		if input.UserInputType == Enum.UserInputType.MouseMovement then
			closeButton.Visible = false
		end
	end)

	toast.Activated:Connect(disappear)
	closeButton.Activated:Connect(disappear)
	task.delay(options.duration or 5, disappear)
end

function UIMessages:success(message: string)
	self:toast({
		icon = self.theme.icons.success,
		iconColor = self.theme.colors.success,
		text = message,
	})
end

function UIMessages:error(message: string)
	self:toast({
		icon = self.theme.icons.error,
		iconColor = self.theme.colors.error,
		text = message,
	})
end

function UIMessages:warn(message: string)
	self:toast({
		icon = self.theme.icons.warn,
		iconColor = self.theme.colors.warn,
		text = message,
	})
end

-- Error alerts

function UIMessages:errorAlert(message: string)
	local frame = script.ErrorAlert:Clone()
	local wrapper = StyleStateWrapper.new(self.theme, {
		background = BackgroundStyleState.from(self.theme, frame, {
			style = "popup",
		}),
		message = LabelStyleState.from(self.theme, frame.Content.Message),
		ok = ButtonStyleState.from(self.theme, frame.Content.Ok, {
			style = "secondary",
		}),
		icon = LabelStyleState.from(self.theme, frame.Icon, {
			style = "error",
		}),
	})

	wrapper.styleStates.message.label.Text = message

	local modal = PopupHelper.modal(frame, self.parent, {
		zIndex = 100,
	})
	local lockEvent = Instance.new("BindableEvent")

	modal.cancelled:Once(function()
		lockEvent:Fire()
	end)

	wrapper.styleStates.ok.button.Activated:Connect(function()
		lockEvent:Fire()
	end)

	lockEvent.Event:Wait()

	modal:destroy()
	frame:Destroy()
end

-- This will choose to create a toast or alert depending on the severity of the error.
local alertableErrors = {
	["103"] = true,
	["104"] = true,
	["105"] = true,
	["301"] = true,
	["302"] = true,
	["303"] = true,
	["304"] = true,
	["305"] = true,
	["306"] = true,
	["401"] = true,
	["402"] = true,
	["403"] = true,
	["404"] = true,
	["501"] = true,
	["502"] = true,
	["503"] = true,
	["504"] = true,
	["505"] = true,
	["509"] = true,
	["513"] = true,
}
-- TODO: special for 403 API access not enabled
function UIMessages:reportDataStoreError(message: string)
	local code = message:sub(1, 3)
	if alertableErrors[code] then
		self:errorAlert(message)
	else
		self:error(message)
	end
end

function UIMessages:destroy()
	self.gui:Destroy()
end

export type UIMessages = typeof(UIMessages.new({} :: any))
return UIMessages


--!strict

local TimestampHelper = {}

-- Guesses what time of timestamp a number is, or nil if it doesn't look like a timestamp
-- The second parameter is if the guess should be conservative and guess it's not a timestamp
-- more often.
function TimestampHelper.guessTimestampType(n: number, conservative: "conservative"?): "seconds" | "milliseconds" | nil
	local s = tostring(n)

	if #s < 2 then
		return nil
	else
		if conservative then
			local firstTwo = tonumber(s:sub(1, 2)) or 0
			-- This should cover a few years.
			if not (17 <= firstTwo and firstTwo <= 21) then
				return nil
			end
		end

		if #s == 13 or ((not conservative) and #s == 12) then
			return "milliseconds"
		elseif #s == 10 then
			return "seconds"
		else
			return nil
		end
	end
end

-- Converts a number into a DateTime with a best-guess of what kind of timestamp it is
function TimestampHelper.bestGuessIntoDateTime(n: number): DateTime
	local timestampType = TimestampHelper.guessTimestampType(n)
	if timestampType == "milliseconds" then
		return DateTime.fromUnixTimestampMillis(n)
	elseif timestampType == "seconds" then
		return DateTime.fromUnixTimestamp(n)
	else
		return DateTime.fromUnixTimestamp(n)
	end
end

return TimestampHelper


--!strict

local giga = 10^9
local mega = 10^6
local kilo = 10^3

return function(bytes: number)
	return tostring(bytes):reverse():gsub("(%d%d%d)", "%1,"):gsub(",$", ""):reverse()
end


--!strict
return function(object: GuiObject): boolean
	local current: any = object
	while current do
		if (not current) or not current.Visible then
			return false
		end

		current = current.Parent
		if current:IsA("LayerCollector") then
			break
		elseif not current:IsA("GuiObject") then
			return false
		end
	end
	return true
end


--!strict

-- Makes sure a TextBox matches a validator.

return function(textBoxStyleState, validators: { (string) -> (boolean, string?) })
	textBoxStyleState.textBox:GetPropertyChangedSignal("Text"):Connect(function()
		if textBoxStyleState.textBox.Text == "" then
			textBoxStyleState:setError(nil :: string?):update("veryFast")
			return
		end

		local valid, err = true, nil
		for _, validator in validators do
			valid, err = validator(textBoxStyleState.textBox.Text)
			if not valid then
				break
			end
		end

		textBoxStyleState:setError(err):update("veryFast")
	end)
end


--!strict

-- Makes it so when you press tab, it switches between the TextBoxes.

local checkTrulyVisible = require(script.Parent.checkTrulyVisible)

return function(textBoxes: { TextBox })
	local currentSelected = nil
	local currentConnection: RBXScriptConnection? = nil

	for i, textBox in textBoxes do
		-- Don't need to track when it's unfocused since they can't tab at that point anyways
		textBox.Focused:Connect(function()
			currentSelected = i

			if currentConnection then
				currentConnection:Disconnect()
			end

			local lastText = textBox.Text
			-- NOTE:
			--   Will create a neglible memory leak that won't accumulate.
			--   I would say it's an acceptable trade-off for code simplicity.
			currentConnection = textBox:GetPropertyChangedSignal("Text"):Connect(function()
				-- NOTE:
				--   does not completely handle the case where a user pastes something that ends in a tab
				--   and replaces the exact amount of text such that (#lastText + 1 == #textBox.Text) is true
				if
					(textBox.Text:sub(textBox.CursorPosition - 1, textBox.CursorPosition - 1) == "\t")
					and (#lastText + 1 == #textBox.Text)
				then
					local canSwitch = false
					for i = 1, #textBoxes - 1 do
						currentSelected = (currentSelected % #textBoxes) + 1
						if checkTrulyVisible(textBoxes[currentSelected]) then
							canSwitch = true
							break
						end
					end

					if not canSwitch then
						return
					end

					-- Switch!

					textBox.Text = lastText
					lastText = textBox.Text
					textBoxes[currentSelected]:CaptureFocus()
				else
					lastText = textBox.Text
				end
			end)
		end)
	end
end


-- `PopupHelper.modal` creates a frame to block input, but it does not block MouseMovement inputs.
-- To fix that, this utility function will detect when the input blocking frame of `PopupHelper.modal`
-- appears and disable `Interactable` for all the frames you give it. This will prevent MouseMovement
-- inputs from being fired.

-- DRAWBACK: You can only have one layer of MouseMovement input blocking. See right clicking in versions view
--           and how MouseMovement events still fire for the buttons.

-- @param  modalParent  What the modal will be parented too. Used for detecting when it appears.
-- @param  frames  The frames you want to disable MouseMovement events for when a modal appears.
return function(modalParent: Frame, frames: { Frame })
	local blockedInputs = 0

	local function update()
		if blockedInputs == 0 then
			for _, child in frames do
				child.Interactable = true
			end
		else
			for _, child in frames do
				child.Interactable = false
			end
		end
	end

	modalParent.ChildAdded:Connect(function(child)
		if child:IsA("ScrollingFrame") and child.Name == "BlockInput" then
			local clickCapture = child:FindFirstChild("ClickCapture")
			if clickCapture and clickCapture:IsA("TextButton") and clickCapture.Interactable then
				blockedInputs += 1
				update()

				child.Destroying:Connect(function()
					blockedInputs -= 1
					update()
				end)
			end
		end
	end)
end


-- Gets highlight colors for the TreeViewer. Depends on global state from `Settings`
-- to see if it should use the built-in theme or the script editors theme.

export type Colors = {
	plain: Color3,

	background: Color3,
	hoverBackground: Color3,
	selectBackground: Color3,
	scrollbar: Color3,

	mainAccent: Color3,

	root: Color3,
	key: Color3,
	key2: Color3, -- Used when the setting "UseAlternatingKeyColors" is on
	key3: Color3, -- Used when the setting "UseAlternatingKeyColors" is on
	index: Color3,

	array: Color3,
	object: Color3,
	boolean: Color3,
	number: Color3,
	string: Color3,
	null: Color3,
	buffer: Color3,

	disabled: Color3,
	error: Color3,

	selectText: Colors,
}

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

local function invertColor(a: Color3): Color3
	return Color3.new(1 - a.R, 1 - a.G, 1 - a.B)
end

local textColors = {
	"plain",
	"root",
	"key",
	"index",
	"array",
	"object",
	"boolean",
	"number",
	"string",
	"null",
	"buffer",
	"disabled",
	"key2",
	"key3",
}

return function(theme, forceDefault: "forceDefault"?)
	local highlightColors = Settings.get("highlightColors")
	local colors
	if forceDefault or highlightColors == "Default" then
		colors = table.clone(theme.colors.syntaxHighlight)
		colors.plain = theme.colors.text
		colors.background = theme.colors.background
		colors.hoverBackground = theme.colors.button.transparent.baseColor:Lerp(
			theme.colors.background,
			theme.colors.button.transparent.hover
		)
		colors.selectBackground = theme.colors.button.transparent.selectColor:Lerp(
			theme.colors.background,
			theme.colors.button.transparent.select
		)
		colors.mainAccent = theme.colors.mainAccent
		colors.disabled = theme.colors.disabledText
		colors.error = theme.colors.error
		colors.scrollbar = theme.colors.scrollbar
	elseif highlightColors == "Script Editor" then
		local studio = settings().Studio
		colors = {
			plain = studio["Text Color"],

			background = studio["Background Color"],
			hoverBackground = studio["Selection Background Color"]:Lerp(studio["Background Color"], 0.75),
			selectBackground = studio["Selection Background Color"]:Lerp(studio["Background Color"], 0.5),

			mainAccent = if theme.getContrastRatio(theme.colors.mainAccent, studio["Background Color"]) < 4.5
				then invertColor(studio["Background Color"]):Lerp(theme.colors.mainAccent, 0.25)
				else theme.colors.mainAccent,

			root = studio["Keyword Color"],
			key = studio["Property Color"],
			key2 = studio["Built-in Function Color"],
			key3 = studio['"self" Color'],
			index = studio["Built-in Function Color"],

			array = studio["Comment Color"],
			object = studio["Comment Color"],
			boolean = studio["Bool Color"],
			number = studio["Number Color"],
			string = studio["String Color"],
			null = studio['"nil" Color'],
			buffer = studio["Comment Color"],

			disabled = studio["Comment Color"]:Lerp(studio["Background Color"], 0.25),
			error = studio["Error Color"],

			scrollbar = Color3.new(1, 1, 1),
		}

		colors.scrollbar = if theme.getContrastRatio(colors.background, Color3.fromRGB(200, 200, 200))
				> theme.getContrastRatio(colors.background, Color3.fromRGB(100, 100, 100))
			then Color3.fromRGB(200, 200, 200)
			else Color3.fromRGB(100, 100, 100)
	end

	colors.selectText = table.clone(colors)

	-- Adjusted selected colors so contrast is OK
	for _, color in textColors do
		colors.selectText[color] = theme.improveContrast(colors.selectText[color], colors.selectBackground)
	end

	return colors
end


--!strict

-- Sets the position of an object without triggering AutomaticSize resizing.
-- Assumes UIPadding only has offset padding.

-- EXAMPLE: This is used in the `Insert Into/After` menus for the expand button which is
--          positioned at the top right.

return function(
	object: GuiObject,
	container: GuiObject & { UIPadding: UIPadding? },
	anchorRelativeToParent: Vector2,
	extraOffset: UDim2
)
	-- Use a dummy UI padding if one is not found to keep things simple.
	local uiPadding = container:FindFirstChildOfClass("UIPadding") or Instance.new("UIPadding")

	local function updatePosition()
		local x = anchorRelativeToParent.X
			* (container.AbsoluteSize.X - uiPadding.PaddingRight.Offset - uiPadding.PaddingLeft.Offset)
		local y = anchorRelativeToParent.Y
			* (container.AbsoluteSize.Y - uiPadding.PaddingTop.Offset - uiPadding.PaddingBottom.Offset)
		object.Position = UDim2.fromOffset(x, y) + extraOffset
	end

	object.AnchorPoint = anchorRelativeToParent

	updatePosition()
	container:GetPropertyChangedSignal("AbsoluteSize"):Connect(updatePosition)
end


-- Form that has two fields (each one is optional, but you must have one field active)
-- Used in:
--	- Insert Into/After
--	- Insert for OrderedDataStore

local Assets = script.Parent.Parent.Assets
local StyleState = script.Parent.Parent.StyleState

local StyleStateWrapper = require(StyleState.StyleStateWrapper)
local TextBoxStyleState = require(StyleState.Input.TextBoxStyleState)
local JsonTextBoxStyleState = require(StyleState.Input.JsonTextBoxStyleState)
local ButtonStyleState = require(StyleState.ButtonStyleState)
local BackgroundStyleState = require(StyleState.BackgroundStyleState)
local LabelStyleState = require(StyleState.LabelStyleState)

local Validators = require(script.Parent.Parent.Parent.Validators)
local JSONHelper = require(script.Parent.Parent.Parent.JSONHelper)
local PopupHelper = require(script.Parent.Parent.PopupHelper)

local createTabSwitchGroup = require(script.Parent.createTabSwitchGroup)
local lockObjectWithoutResize = require(script.Parent.lockObjectWithoutResize)

export type FormResult = {
	key: string,
	value: JSONHelper.JSONValue,
	type: JSONHelper.JSONType,
}
export type OnExpandResult = {
	operation: "unexpand",
	current: FormResult,
} | {
	operation: "submit",
	result: FormResult?,
} | {
	operation: "cancel",
}
export type FormConstraint = "userId" | "orderedDataStore"
export type Options = {
	position: Vector2?,
	anchorPoint: Vector2?,

	includeKey: boolean,
	includeValue: boolean,

	title: string,
	yesText: string?,
	noText: string?,

	constraint: FormConstraint?,
	extraKeyValidator: Validators.Validator?,

	-- Expand functionality
	-- Expanding should create a completely different form when the button is pressed.
	-- The current form will become invisible until onExpand returns.
	onExpand: ((current: FormResult) -> OnExpandResult)?,
}
return function(theme, gui, options: Options): FormResult?
	local frame = Assets.KeyValueForm:Clone()
	frame.Title.Text = options.title

	local wrapper = StyleStateWrapper.new(theme, {
		title = LabelStyleState.from(theme, frame.Title),
		background = BackgroundStyleState.from(theme, frame, {
			style = "popup",
		}),

		keyLabel = LabelStyleState.from(theme, frame.Form.Key.Title),
		keyTextBox = TextBoxStyleState.from(theme, frame.Form.Key.TextBox),

		valueLabel = LabelStyleState.from(theme, frame.Form.Value.Title),
		valueTextBox = JsonTextBoxStyleState.from(theme, frame.Form.Value.TextBox, {
			useDefaultTheme = true,
		}),

		insert = ButtonStyleState.from(theme, frame.Form.Buttons.Insert, {
			style = "primary",
		}),
		cancel = ButtonStyleState.from(theme, frame.Form.Buttons.Cancel, {
			style = "secondary",
		}),

		expand = ButtonStyleState.from(theme, frame["Escape Layout"].Expand, {
			style = "dormant",
		}),
	})

	local status = nil
	local overrideResult: FormResult? = nil -- Only used if the form was submitted via an expanded form

	local keyTextBoxStyleState = wrapper.styleStates.keyTextBox
	local valueTextBoxStyleState = wrapper.styleStates.valueTextBox

	local function updateDisabled(instant)
		local disabled = (options.includeKey and keyTextBoxStyleState.textBox.Text == "")
			or (options.includeValue and valueTextBoxStyleState.textBox.Text == "")
			or keyTextBoxStyleState.error
			or valueTextBoxStyleState.error

		wrapper.styleStates.insert:setDisabled(disabled):update(if instant then "instant" else nil)
	end

	local function onFocusLost(entered: boolean, input: InputObject?)
		if input == nil then -- was from CaptureFocus, not user pressing enter (entered will still be true, however)
			return
		end

		if entered then
			if wrapper.styleStates.insert.button.Active then
				status = "insert"
			end
		end
	end

	local function getResult(): FormResult
		local value, isArray = valueTextBoxStyleState:getJson()
		return {
			key = keyTextBoxStyleState:getText(),
			value = value,
			type = if isArray
				then "array"
				elseif typeof(value) == "table" then "object"
				elseif value == nil then "null"
				else typeof(value),
		}
	end

	local function autoFocus()
		if options.includeKey then
			keyTextBoxStyleState.textBox:CaptureFocus()
		else
			valueTextBoxStyleState.textBox:CaptureFocus()
		end
	end

	-- Events

	keyTextBoxStyleState.textBox:GetPropertyChangedSignal("Text"):Connect(function()
		if keyTextBoxStyleState.textBox.Text == "" then
			keyTextBoxStyleState:setError(nil):update("veryFast")
			updateDisabled()
			return
		end

		local validator = if options.constraint == "orderedDataStore" then Validators.dataStoreKey else Validators.empty
		local valid, err = validator(keyTextBoxStyleState.textBox.Text)
		if valid and options.extraKeyValidator then
			valid, err = options.extraKeyValidator(keyTextBoxStyleState.textBox.Text)
		end

		if valid then
			keyTextBoxStyleState:setError(nil):update("veryFast")
		else
			keyTextBoxStyleState:setError(err):update("veryFast")
		end

		updateDisabled()
	end)
	valueTextBoxStyleState.textBox:GetPropertyChangedSignal("Text"):Connect(function()
		-- Validate
		if valueTextBoxStyleState.textBox.Text == "" then
			valueTextBoxStyleState:setError(nil):update("veryFast")
			updateDisabled()
			return
		end

		local validator = if options.constraint == "orderedDataStore"
			then Validators.orderedDataStoreValue
			elseif options.constraint == "userId" then Validators.userId
			else Validators.empty

		local valid, err = validator(valueTextBoxStyleState.textBox.Text)
		if valid then
			valueTextBoxStyleState:setError(nil):update("veryFast")
		else
			valueTextBoxStyleState:setError(err):update("veryFast")
		end

		updateDisabled()
	end)

	-- Set up
	local modal -- Forward declare

	wrapper.styleStates.insert.button.TextLabel.Text = options.yesText or "Insert"
	wrapper.styleStates.cancel.button.TextLabel.Text = options.noText or "Cancel"

	if not options.includeKey then
		frame.Form.Key.Visible = false
	end
	if not options.includeValue then
		frame.Form.Value.Visible = false
	end

	if options.onExpand then
		wrapper.styleStates.expand.button.Visible = true
		lockObjectWithoutResize(wrapper.styleStates.expand.button, frame, Vector2.new(1, 0), UDim2.fromOffset(-3, 0))

		wrapper.styleStates.expand.button.Activated:Connect(function()
			modal:hide()
			local onExpandResult = options.onExpand(getResult())
			if onExpandResult.operation == "unexpand" then
				keyTextBoxStyleState.textBox.Text = onExpandResult.current.key
				valueTextBoxStyleState.textBox.Text = if onExpandResult.current.type == "array"
						or onExpandResult.current.type == "object"
					then JSONHelper.encode(onExpandResult.current.value)
					else JSONHelper.toInputString(onExpandResult.current.value, onExpandResult.current.type)
				modal:reveal()
				autoFocus()
			elseif onExpandResult.operation == "submit" then
				if not onExpandResult.result then
					status = "cancel"
				else
					overrideResult = onExpandResult.result
					status = "insert"
				end
			elseif onExpandResult.operation == "cancel" then
				status = "cancel"
			end
		end)
	end

	local position = options.position or gui:GetRelativeMousePosition()
	frame.Position = UDim2.fromOffset(position.X, position.Y)
	frame.AnchorPoint = options.anchorPoint or frame.AnchorPoint
	frame.Parent = gui

	if options.includeKey and options.includeValue then
		createTabSwitchGroup({
			keyTextBoxStyleState.textBox,
			valueTextBoxStyleState.textBox,
		})
	end

	autoFocus()

	-- Placing it in
	PopupHelper.clamp(frame, gui)
	modal = PopupHelper.modal(frame, gui)

	modal.cancelled:Connect(function()
		status = "cancel"
	end)
	wrapper.styleStates.cancel.button.Activated:Connect(function()
		status = "cancel"
	end)

	keyTextBoxStyleState.textBox.FocusLost:Connect(onFocusLost)
	valueTextBoxStyleState.textBox.FocusLost:Connect(onFocusLost)
	wrapper.styleStates.insert.button.Activated:Connect(function()
		status = "insert"
	end)

	updateDisabled(true)

	while status == nil do
		task.wait()
	end

	modal:destroy()
	wrapper:destroy()
	frame:Destroy()

	if status == "insert" then
		if overrideResult then
			return overrideResult
		end

		return getResult()
	else
		return nil
	end
end


return function(scrollingFrame: ScrollingFrame, shadow: Frame)
	return scrollingFrame:GetPropertyChangedSignal("CanvasPosition"):Connect(function()
		shadow.Visible = scrollingFrame.CanvasPosition.Y > 10
	end)
end


--!strict

local TextService = game:GetService("TextService")
local Types = require(script.Parent.Types)

export type NewOptions = {
	theme: Types.Theme,
	doLex: (source: string) -> {Token},
}

export type Token = {
	kind: string,
	start: number,
	length: number,
}

type CodeEditorObject = Types.TextEditorObject & {
	_textChanged: BindableEvent,
	_activeErrorsChanged: BindableEvent,

	_frame: ScrollingFrame,
	_theme: Types.Theme,
	_textBox: TextBox,
	_textBoxPadding: UIPadding,
	_overlay: TextLabel,
	_gutter: TextLabel,
	_gutterPadding: UIPadding,
	_errorFrameContainer: Folder,
	_errorFrames: {Frame},
	_singleCharacterBounds: Vector2,
	_lastLineCount: number,
	_errors: {Types.Error},
	_errorLookups: {[number]: {Types.Error}},
	_disabled: boolean,
	_connections: {RBXScriptConnection},
	_doLex: (source: string) -> {Token},
}

local function getSingleCharacterBounds(theme: Types.Theme)
	local params = Instance.new("GetTextBoundsParams")
	params.Font = theme.font
	params.Text = "a"
	params.Size = theme.textSize

	local bounds = TextService:GetTextBoundsAsync(params)
	return bounds
end

local function escapeRichText(text: string): string
	return text:gsub("&", "&"):gsub("<", "<"):gsub(">", ">"):gsub("\"", """):gsub("'", "'")
end

local function updateHighlights(self: CodeEditorObject)
	local overlayText = table.create(16)
	local source = self._textBox.Text
	local tokens = self._doLex(source)

	for _, t in tokens do
		local color = self._theme.tokenColors[t.kind]
		if color then
			table.insert(overlayText,
				""
					.. escapeRichText(source:sub(t.start, t.start + t.length - 1))
					.. ""
			)
		else
			table.insert(overlayText,
				""
					.. escapeRichText(source:sub(t.start, t.start + t.length - 1))
					.. ""
			)
		end
	end

	self._overlay.Text = table.concat(overlayText)
end

local function updateTheme(self: CodeEditorObject)
	self._singleCharacterBounds = getSingleCharacterBounds(self._theme)

	self._frame.BackgroundColor3 = self._theme.backgroundColor
	self._frame.ScrollBarThickness = self._theme.scrollbarThickness
	self._frame.ScrollBarImageColor3 = self._theme.scrollbarColor
	self._frame.ScrollBarThickness = self._theme.scrollbarThickness

	self._textBox.FontFace = self._theme.font
	self._textBox.TextSize = self._theme.textSize
	self._textBox.TextColor3 = self._theme.textColor
	if self._disabled then
		self._textBox.TextColor3 = self._theme.disabledTextColor
	end

	self._overlay.FontFace = self._theme.font
	self._overlay.TextSize = self._theme.textSize
	self._overlay.Visible = not self._disabled

	self._textBoxPadding.PaddingTop = UDim.new(0, self._theme.padding.top)
	self._textBoxPadding.PaddingBottom = UDim.new(0, self._theme.padding.bottom)
	self._textBoxPadding.PaddingLeft = UDim.new(0, self._theme.padding.left)
	self._textBoxPadding.PaddingRight = UDim.new(0, self._theme.padding.right)

	self._gutter.BackgroundColor3 = self._theme.gutter.backgroundColor
	self._gutter.TextColor3 = self._theme.gutter.textColor
	self._gutter.FontFace = self._theme.font
	self._gutter.TextSize = self._theme.textSize

	self._gutterPadding.PaddingTop = UDim.new(0, self._theme.padding.top)
	self._gutterPadding.PaddingBottom = UDim.new(0, self._theme.padding.bottom)
	self._gutterPadding.PaddingLeft = UDim.new(0, self._theme.gutter.padding.left)
	self._gutterPadding.PaddingRight = UDim.new(0, self._theme.gutter.padding.right)

	updateHighlights(self)
	self:setErrors(self._errors)
end

local function countLines(text: string, upto: number?): number
	if upto then
		local _, replacements = text:sub(1, upto - 1):gsub("\n", "\n")
		return replacements + 1
	else
		local _, replacements = text:gsub("\n", "\n")
		return replacements + 1
	end
end

local function getTopAndBottomLinesInView(self: CodeEditorObject)
	local bottomLine = math.ceil(self._frame.CanvasPosition.Y / self._singleCharacterBounds.Y)
	local topLine = math.floor((self._frame.CanvasPosition.Y + self._frame.AbsoluteSize.Y) / self._singleCharacterBounds.Y) - 1
	return bottomLine, topLine
end

local function getCurrentLine(self: CodeEditorObject): number?
	if self._textBox.CursorPosition <= 0 then
		return
	end

	return countLines(self._textBox.Text, self._textBox.CursorPosition)
end

local function updateGutter(self: CodeEditorObject)
	local lines = table.create(self._lastLineCount)
	for i = 1, math.min(10, self._lastLineCount) do
		lines[i] = "  " .. i
	end
	for i = 10, self._lastLineCount do
		lines[i] = tostring(i)
	end
	self._gutter.Text = table.concat(lines, "\n")
end

local function updateGutterIfNecessary(self: CodeEditorObject)
	local count = countLines(self._textBox.Text)
	if self._lastLineCount ~= count then
		self._lastLineCount = count
		updateGutter(self)
	else
		self._lastLineCount = count
	end
end

local function updateActiveErrors(self: CodeEditorObject, currentLine: number)
	local errors = self._errorLookups[currentLine]
	if errors then
		self.activeErrors = table.clone(errors)

		-- insert all errors with no location
		for _, v in self._errors do
			if not v.line then
				table.insert(self.activeErrors, v)
			end
		end

		self._activeErrorsChanged:Fire(errors)
	else
		self.activeErrors = {}
		self._activeErrorsChanged:Fire({})
	end
end

local CodeEditorMethods: Types.TextEditorMethods<CodeEditorObject> = {} :: Types.TextEditorMethods<CodeEditorObject>
local mt = {__index = CodeEditorMethods :: any}

function CodeEditorMethods.setParent(self: CodeEditorObject, parent: Instance)
	self._frame.Parent = parent
end

function CodeEditorMethods.getText(self: CodeEditorObject): string
	return self._textBox.Text
end

function CodeEditorMethods.setText(self: CodeEditorObject, text: string)
	self._textBox.Text = text
end

function CodeEditorMethods.setErrors(self: CodeEditorObject, errors: {Types.Error})
	self._errors = errors
	self._errorLookups = {}

	local unusedErrorFrames = self._errorFrames
	local usedErrorFrames = {}

	for _, err in errors do
		if not err.line then
			continue
		end

		if self._errorLookups[err.line] then
			table.insert(self._errorLookups[err.line], err)
		else
			self._errorLookups[err.line] = {err}
		end
	end

	for line, errors in self._errorLookups do
		local errorFrame = table.remove(unusedErrorFrames) or Instance.new("Frame")
		errorFrame.Size = UDim2.new(1, 0, 0, self._singleCharacterBounds.Y)
		errorFrame.Position = UDim2.fromOffset(0, ((errors[1].line :: number) - 1) * self._singleCharacterBounds.Y + self._theme.padding.top)
		errorFrame.BorderSizePixel = 0
		errorFrame.BackgroundTransparency = 0.6
		errorFrame.BackgroundColor3 = Color3.fromHex(self._theme.tokenColors.error)
		errorFrame.ZIndex = -1
		errorFrame.Visible = true
		errorFrame.Parent = self._errorFrameContainer

		table.insert(usedErrorFrames, errorFrame)
	end

	self._errorFrames = usedErrorFrames
	for _, v in unusedErrorFrames do
		v:Destroy()
	end

	local currentLine = getCurrentLine(self)
	if currentLine then
		updateActiveErrors(self, currentLine)
	end
end

function CodeEditorMethods.setTheme(self: CodeEditorObject, theme: Types.Theme)
	self._theme = theme
	updateTheme(self)
end

function CodeEditorMethods.setDisabled(self: CodeEditorObject, disabled: boolean)
	self._textBox.TextEditable = not disabled
	self._disabled = disabled
	updateTheme(self)
end

function CodeEditorMethods:destroy()
	for _, c in self._connections do
		c:Disconnect()
	end

	self._textChanged:Destroy()
	self._activeErrorsChanged:Destroy()

	self._frame:Destroy()
end

return function(options: NewOptions): Types.TextEditorObject
	local self: CodeEditorObject = setmetatable({}, mt) :: CodeEditorObject

	self._frame = Instance.new("ScrollingFrame")
	self._frame.Size = UDim2.fromScale(1, 1)
	self._frame.VerticalScrollBarInset = Enum.ScrollBarInset.Always
	self._frame.HorizontalScrollBarInset = Enum.ScrollBarInset.None
	self._frame.BorderSizePixel = 0
	self._frame.CanvasSize = UDim2.fromOffset(0, 0)
	self._frame.AutomaticCanvasSize = Enum.AutomaticSize.XY

	local listLayout = Instance.new("UIListLayout")
	listLayout.FillDirection = Enum.FillDirection.Horizontal
	listLayout.SortOrder = Enum.SortOrder.LayoutOrder
	listLayout.Parent = self._frame

	self._textBox = Instance.new("TextBox")
	self._textBox.BackgroundTransparency = 1
	self._textBox.TextXAlignment = Enum.TextXAlignment.Left
	self._textBox.TextYAlignment = Enum.TextYAlignment.Top
	self._textBox.ClearTextOnFocus = false
	self._textBox.MultiLine = true
	self._textBox.Size = UDim2.fromScale(0, 1)
	self._textBox.AutomaticSize = Enum.AutomaticSize.XY
	self._textBox.LayoutOrder = 1
	self._textBox.Text = ""
	self._textBox.Parent = self._frame

	self._overlay = Instance.new("TextLabel")
	self._overlay.Size = UDim2.fromScale(1, 1)
	self._overlay.BackgroundTransparency = 1
	self._overlay.RichText = true
	self._overlay.TextXAlignment = Enum.TextXAlignment.Left
	self._overlay.TextYAlignment = Enum.TextYAlignment.Top
	self._overlay.Text = ""
	self._overlay.Parent = self._textBox

	local flex = Instance.new("UIFlexItem")
	flex.FlexMode = Enum.UIFlexMode.Grow
	flex.Parent = self._textBox

	self._textBoxPadding = Instance.new("UIPadding")
	self._textBoxPadding.Parent = self._textBox

	self._gutter = Instance.new("TextLabel")
	self._gutter.RichText = true
	self._gutter.AutomaticSize = Enum.AutomaticSize.X
	self._gutter.Size = UDim2.fromScale(0, 1)
	self._gutter.BorderSizePixel = 0
	self._gutter.LayoutOrder = 0
	self._gutter.TextXAlignment = Enum.TextXAlignment.Right
	self._gutter.TextYAlignment = Enum.TextYAlignment.Top
	self._gutter.Parent = self._frame

	self._gutterPadding = Instance.new("UIPadding")
	self._gutterPadding.Parent = self._gutter

	self._errorFrameContainer = Instance.new("Folder")
	self._errorFrameContainer.Parent = self._frame

	self._theme = options.theme
	self._singleCharacterBounds = Vector2.new(0, 0)
	self._lastLineCount = -1

	self.activeErrors = {}
	self._activeErrorsChanged = Instance.new("BindableEvent")
	self.activeErrorsChanged = self._activeErrorsChanged.Event :: RBXScriptSignal
	self._errors = {}
	self._errorLookups = {}
	self._errorFrames = {} :: {Frame}
	self._disabled = false

	self._doLex = options.doLex

	self._textChanged = Instance.new("BindableEvent")
	self.textChanged = self._textChanged.Event :: RBXScriptSignal

	local lastTextSize = 0
	self._connections = {
		self._textBox:GetPropertyChangedSignal("Text"):Connect(function()
			-- hide any errors since they might no longer be valid
			for _, v in self._errorFrames do
				v.Visible = false
			end

			-- auto-indent if necessary
			if self._textBox.CursorPosition > 0 and #self._textBox.Text > lastTextSize then
				local addedCharacter = self._textBox.Text:sub(self._textBox.CursorPosition - 1, self._textBox.CursorPosition - 1)
				if addedCharacter == "\n" then
					local currentLine = getCurrentLine(self)
					if currentLine then
						local lastLine = self._textBox.Text:split("\n")[currentLine - 1]
						if lastLine then
							local indent = lastLine:match("^[\t ]*") or ""
							if #indent > 0 then
								self._textBox.Text =
									self._textBox.Text:sub(1, self._textBox.CursorPosition - 2)
									.. "\n"
									.. indent
									.. self._textBox.Text:sub(self._textBox.CursorPosition)
								self._textBox.CursorPosition += #indent
								lastTextSize = #self._textBox.Text
								return
							end
						end
					end
				end
			end

			lastTextSize = #self._textBox.Text

			updateGutterIfNecessary(self)
			updateHighlights(self)

			self._textChanged:Fire(self._textBox.Text)
		end),
		self._textBox:GetPropertyChangedSignal("CursorPosition"):Connect(function()
			local currentLine = getCurrentLine(self)
			if currentLine then
				-- scroll the cursor into view
				local bottomLine, topLine = getTopAndBottomLinesInView(self)
				if currentLine < bottomLine then
					self._frame.CanvasPosition = Vector2.new(self._frame.CanvasPosition.X, (currentLine - 1) * self._singleCharacterBounds.Y)
				elseif currentLine > topLine then
					local heightLines = math.floor(self._frame.AbsoluteSize.Y / self._singleCharacterBounds.Y)
					self._frame.CanvasPosition = Vector2.new(self._frame.CanvasPosition.X, (currentLine - heightLines + 1) * self._singleCharacterBounds.Y)
				end

				-- update active errors
				updateActiveErrors(self, currentLine)
			end
		end),
		self._frame:GetPropertyChangedSignal("AbsoluteCanvasSize"):Connect(function()
			-- UDim2.fromScale(1, 0) doesn't work for whatever reason
			self._gutter.Size = UDim2.fromOffset(0, self._frame.AbsoluteCanvasSize.Y)
		end)
	}

	updateTheme(self)
	updateGutterIfNecessary(self)

	return self
end


--!strict

-- Editor with missing no highlighting or error capabilties.

local Types = require(script.Parent.Types)

export type NewOptions = {
	theme: Types.Theme,
}

export type DumbEditorObject = Types.TextEditorObject & {
	_textChanged: BindableEvent,
	_activeErrorsChanged: BindableEvent,

	_frame: ScrollingFrame,
	_theme: Types.Theme,
	_textBox: TextBox,
	_textBoxPadding: UIPadding,

	_connections: {RBXScriptConnection},

	_disabled: boolean,
}

local DumbEditorMethods: Types.TextEditorMethods<DumbEditorObject> = {} :: Types.TextEditorMethods<DumbEditorObject>
local mt = {__index = DumbEditorMethods :: any}

local function updateTheme(self: DumbEditorObject)
	self._frame.BackgroundColor3 = self._theme.backgroundColor
	self._frame.ScrollBarThickness = self._theme.scrollbarThickness
	self._frame.ScrollBarImageColor3 = self._theme.scrollbarColor
	self._frame.ScrollBarThickness = self._theme.scrollbarThickness

	self._textBox.FontFace = self._theme.font
	self._textBox.TextSize = self._theme.textSize
	self._textBox.TextColor3 = self._theme.textColor
	if self._disabled then
		self._textBox.TextColor3 = self._theme.disabledTextColor
	end

	self._textBoxPadding.PaddingTop = UDim.new(0, self._theme.padding.top)
	self._textBoxPadding.PaddingBottom = UDim.new(0, self._theme.padding.bottom)
	self._textBoxPadding.PaddingLeft = UDim.new(0, self._theme.padding.left)
	self._textBoxPadding.PaddingRight = UDim.new(0, self._theme.padding.right)
end

function DumbEditorMethods:setParent(parent: Instance)
	self._frame.Parent = parent
end

function DumbEditorMethods:getText(): string
	return self._textBox.Text
end

function DumbEditorMethods:setText(text: string)
	self._textBox.Text = text
end

function DumbEditorMethods:setDisabled(disabled: boolean)
	self._textBox.TextEditable = not disabled
	self._disabled = disabled
	updateTheme(self)
end

function DumbEditorMethods:setErrors(errors)
	-- stub, don't do anything, dumb editor doesn't support this
end

function DumbEditorMethods:destroy()
	for _, c in self._connections do
		c:Disconnect()
	end

	self._textChanged:Destroy()
	self._activeErrorsChanged:Destroy()
	self._frame:Destroy()
end

return function(options: NewOptions): Types.TextEditorObject
	local self: DumbEditorObject = setmetatable({}, mt) :: DumbEditorObject

	self._theme = options.theme

	self._frame = Instance.new("ScrollingFrame")
	self._frame.Size = UDim2.fromScale(1, 1)
	self._frame.VerticalScrollBarInset = Enum.ScrollBarInset.Always
	self._frame.HorizontalScrollBarInset = Enum.ScrollBarInset.None
	self._frame.BorderSizePixel = 0
	self._frame.CanvasSize = UDim2.fromOffset(0, 0)
	self._frame.AutomaticCanvasSize = Enum.AutomaticSize.XY

	self._textBox = Instance.new("TextBox")
	self._textBox.BackgroundTransparency = 1
	self._textBox.TextXAlignment = Enum.TextXAlignment.Left
	self._textBox.TextYAlignment = Enum.TextYAlignment.Top
	self._textBox.ClearTextOnFocus = false
	self._textBox.MultiLine = true
	self._textBox.Size = UDim2.fromScale(0, 1)
	self._textBox.AutomaticSize = Enum.AutomaticSize.XY
	self._textBox.LayoutOrder = 1
	self._textBox.Text = ""
	self._textBox.Parent = self._frame

	self._textBoxPadding = Instance.new("UIPadding")
	self._textBoxPadding.Parent = self._textBox

	-- stub, don't actually use this
	self.activeErrors = {}
	self._activeErrorsChanged = Instance.new("BindableEvent")
	self.activeErrorsChanged = self._activeErrorsChanged.Event :: RBXScriptSignal

	self._textChanged = Instance.new("BindableEvent")
	self.textChanged = self._textChanged.Event :: RBXScriptSignal

	self._disabled = false

	self._connections = {
		self._textBox:GetPropertyChangedSignal("Text"):Connect(function()
			self._textChanged:Fire(self._textBox.Text)
		end),
	}

	updateTheme(self)

	return self
end


--!strict

export type Theme = {
	padding: {top: number, left: number, bottom: number, right: number},
	font: Font, -- should be monospace
	textSize: number,
	backgroundColor: Color3,
	textColor: Color3,
	disabledTextColor: Color3,
	scrollbarThickness: number,
	scrollbarColor: Color3,

	tokenColors: {[string]: string}, -- should be a hex color

	gutter: {
		backgroundColor: Color3,
		textColor: Color3,
		padding: {left: number, right: number},
	},
}

export type Error = {
	line: number?,
	column: number?,
	message: string,
}

export type TextEditorMethods<Object> = {
	setParent: (Object, parent: Instance) -> (),
	getText: (Object) -> string,
	setText: (Object, text: string) -> (),
	setErrors: (Object, {Error}) -> (),
	setTheme: (Object, Theme) -> (),
	setDisabled: (Object, disabled: boolean) -> (),
	destroy: (Object) -> (),
}

export type TextEditorObject = typeof(setmetatable({} :: {
	-- params:
	--   text: string
	textChanged: RBXScriptSignal,

	-- params:
	--   errors: {Error}
	activeErrorsChanged: RBXScriptSignal,
	activeErrors: {Error},
}, {} :: {__index: TextEditorMethods<unknown>}))

return {}


--!strict

local Settings = require(script.Parent.Parent.Parent.Settings)
local Theme = require(script.Parent.Parent.Theme)
local UIMessages = require(script.Parent.Parent.UIMessages)

local json5 = require(script.Parent.Parent.Parent.json5)
local JSONHelper = require(script.Parent.Parent.Parent.JSONHelper)

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

local CodeEditor = require(script.CodeEditor)
local DumbEditor = require(script.DumbEditor)

local getHighlightColors = require(script.Parent.Parent.Utilities.getHighlightColors)

-- after this many characters, switch from the CodeEditor to the DumbEditor
local CODE_EDITOR_CUTOFF = 20_000
local MAX_TEXTBOX_CHARACTERS = 200_000

export type CodeViewerFields = {
	_idLookedUp: BindableEvent,
	idLookedUp: RBXScriptSignal,

	_container: typeof(script.Container),
	_editor: EditorTypes.TextEditorObject?,
	_theme: Theme.Theme,
	_connections: {RBXScriptConnection},
	_cachedData: unknown,
}

export type CodeViewerImplementation = {
	__index: CodeViewerImplementation,
	new: (Theme.Theme, UIMessages.UIMessages, GuiObject, Types.NewOptions) -> CodeViewer,

	getDirtyCapability: (CodeViewer) -> Types.DirtyCapability?,
	getHistoryCapability: (CodeViewer) -> Types.HistoryCapability?,
	getKeyInfoCapability: (CodeViewer) -> Types.KeyInfoCapability?,
	getSearchCapability: (CodeViewer) -> Types.SearchCapability?,

	getValue: (CodeViewer) -> Types.GetValueResult,
	setDisabled: (CodeViewer, disabled: boolean) -> (),

	_updateTheme: (CodeViewer) -> (),
	_updateTextEditorFrom: (CodeViewer, data: unknown) -> (),

	destroy: (CodeViewer) -> (),
}

type CodeViewer = typeof(setmetatable({} :: CodeViewerFields, {} :: CodeViewerImplementation))

local CodeViewer: CodeViewerImplementation = {} :: CodeViewerImplementation
CodeViewer.__index = CodeViewer

local function getCodeEditorTheme(theme: Theme.Theme): EditorTypes.Theme
	local highlightColors = getHighlightColors(theme)
	local tokenColors = {}

	for token, group in JSONHelper.highlightGroups do
		if group ~= "plain" then
			tokenColors[token] = "#" .. highlightColors[group]:ToHex()
		end
	end

	return {
		padding = {top=2, left=2, bottom=2, right=2},
		textColor = highlightColors.plain,
		disabledTextColor = highlightColors.disabled,
		backgroundColor = highlightColors.background,
		font = Font.fromEnum(Enum.Font.Code),
		textSize = 15,
		scrollbarThickness = 6,
		scrollbarColor = highlightColors.scrollbar,
		gutter = {
			padding = {left=2, right=8},
			backgroundColor = highlightColors.background,
			textColor = highlightColors.disabled,
		},
		tokenColors = tokenColors,
	}
end

local function enhanceErrorMessage(message: string): string
	return message
		:gsub("doubleQuotedString", [["]])
		:gsub("leftCurlyBracket", "'{'")
		:gsub("leftSquareBracket", "'['")
		:gsub("rightCurlyBracket", "'}'")
		:gsub("rightSquareBracket", "']'")
		:gsub("singleQuotedString", [[']])
		:gsub("colon", "':'")
		:gsub("equals", "'='")
		:gsub("comma", "','")
end

local function collectDecodeErrors(result: json5.DecodeResult): {EditorTypes.Error}
	local errors = {}
	if result.kind == "failure" then
		for _, err in result.errors do
			if err.token then
				table.insert(errors, {
					line = err.token.startLine,
					column = err.token.startColumn,
					message = err.message or (err.token :: json5.ErrorToken).message,
				})
			else
				table.insert(errors, {
					message = err.message,
				} :: any)
			end
		end
	end
	return errors
end

local function formatError(err: EditorTypes.Error): string
	if err.line then
		return `[line {err.line} col {err.column}] {err.message}`
	else
		return err.message
	end
end

function CodeViewer.new(theme: Theme.Theme, uiMessages: UIMessages.UIMessages, container: GuiObject, options: Types.NewOptions)
	local self = setmetatable({}, CodeViewer)
	self._theme = theme

	self._container = script.Container:Clone()
	self._container.Parent = container

	-- events
	self._idLookedUp = Instance.new("BindableEvent")
	self.idLookedUp = self._idLookedUp.Event :: any

	self._connections = {
		theme.colorsChanged:Connect(function()
			self:_updateTheme()
		end),
		Settings.changed:Connect(function(name)
			if name == "highlightColors" then
				self:_updateTheme()
			end
		end)
	}

	self:_updateTheme()

	self:_updateTextEditorFrom(options.data)

	return self
end

function CodeViewer:getDirtyCapability()
	return nil
end

function CodeViewer:getHistoryCapability()
	return nil
end

function CodeViewer:getKeyInfoCapability()
	return nil
end

function CodeViewer:getSearchCapability()
	return nil
end

function CodeViewer:getValue()
	if not self._editor then
		return { kind = "success", value = self._cachedData, hasNil = false }
	end

	local text = self._editor:getText()
	local result = json5.decode(text, {
		mode = "permissive",
		doesRecover = false,
	})

	if result.kind == "failure" then
		local errors = collectDecodeErrors(result)
		if #result.errors == 1 then
			return {
				kind = "failure",
				message = enhanceErrorMessage(formatError(errors[1])),
			}
		else
			local text = {}
			for _, err in errors do
				table.insert(text, formatError(err))
			end

			return {
				kind = "failure",
				message = enhanceErrorMessage(table.concat(text, "\n")),
			}
		end
	else
		return {
			kind = "success",
			value = result.json,
			hasNil = false, -- TODO: detect if the user put any nils
		}
	end
end

function CodeViewer:setDisabled(disabled: boolean)
	if self._editor then
		self._editor:setDisabled(disabled)
	end
end

function CodeViewer:_updateTheme()
	if self._editor then
		self._editor:setTheme(getCodeEditorTheme(self._theme))
	end

	self._container.Error.BackgroundColor3 = self._theme.colors.message.background
	self._container.Error.UIStroke.Color = self._theme.colors.message.outline
	self._container.Error.TextLabel.TextColor3 = self._theme.colors.message.text
	self._container.Error.ImageLabel.ImageColor3 = self._theme.colors.error
	self._container.Error.ImageLabel.Image = self._theme.icons.error
end

function CodeViewer:_updateTextEditorFrom(data: unknown)
	if self._editor then
		self._editor:destroy()
	end

	local editorTheme = getCodeEditorTheme(self._theme)
	local text: string
	local success, err = pcall(function()
		self._cachedData = data
		text = json5.encode(data :: any, {
			mode = "readable",
			tab = "\t",
		})
	end)

	self._container.Error.Visible = false
	self._container.Error.Size = UDim2.fromScale(1, 0)
	self._container.Error.AutomaticSize = Enum.AutomaticSize.Y
	self._container.Error.UIListLayout.HorizontalAlignment = Enum.HorizontalAlignment.Left
	self._container.Error.TextLabel.UIFlexItem.FlexMode = Enum.UIFlexMode.Fill
	self._container.EditorContainer.Visible = true

	local isTooLarge = text and #text > MAX_TEXTBOX_CHARACTERS
	if (not success) or isTooLarge then
		self._container.Error.Visible = true
		self._container.Error.Size = UDim2.fromScale(1, 1)
		self._container.Error.AutomaticSize = Enum.AutomaticSize.None
		self._container.Error.UIListLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center
		self._container.Error.TextLabel.UIFlexItem.FlexMode = Enum.UIFlexMode.None
		self._container.EditorContainer.Visible = false

		self._container.Error.TextLabel.Text =
			if isTooLarge then "Data is too large. Use the tree view mode."
			elseif typeof(err) == "string" and err:match("unexpected type buffer") then "Buffers are not supported. Use the tree view mode."
			else `There was an error. Try using the tree view mode. Error: {err}`

		self._editor = nil
	elseif #text > CODE_EDITOR_CUTOFF then
		local editor = DumbEditor({
			theme = editorTheme,
		})

		editor:setText(text)

		self._container.Error.Visible = true
		self._container.Error.TextLabel.Text = "Editor features disabled due to large size of data."

		self._editor = editor
	else
		local editor = CodeEditor({
			doLex = function(source: string)
				return json5.lex(source, {
					mode = "strict",
				}) :: any
			end,
			theme = editorTheme,
		})

		local lastTextChange = 0
		local analysisThread: thread?
		editor.textChanged:Connect(function()
			lastTextChange = os.clock()
			if not analysisThread then
				analysisThread = task.spawn(function()
					while os.clock() - lastTextChange <= 1 do
						task.wait()
					end

					local result = json5.decode(editor:getText(), {
						mode = "permissive",
						doesRecover = false,
					})

					local errors = collectDecodeErrors(result)

					editor:setErrors(errors)
					analysisThread = nil
				end)
			end
		end)

		editor.activeErrorsChanged:Connect(function(errors: {EditorTypes.Error})
			if #errors == 0 then
				self._container.Error.Visible = false
			else
				local text = {}
				for _, err in errors do
					table.insert(text, formatError(err))
				end

				self._container.Error.Visible = true
				self._container.Error.TextLabel.Text = enhanceErrorMessage(table.concat(text, "\n"))
			end
		end)

		editor:setText(text)

		self._editor = editor
	end

	if self._editor then
		self._editor:setParent(self._container.EditorContainer)
	end
end

function CodeViewer:destroy()
	for _, connection in self._connections do
		connection:Disconnect()
	end

	if self._editor then
		self._editor:destroy()
		self._editor = nil
	end

	self._container:Destroy()

	self._idLookedUp:Destroy()
end

return CodeViewer


--!strict

local JSONHelper = require(script.Parent.Parent.Parent.JSONHelper)
local Theme = require(script.Parent.Parent.Theme)
local UIMessages = require(script.Parent.Parent.UIMessages)
local ModalState = require(script.Parent.Parent.ModalState)

export type ViewerMode = "Tree" | "Code"

export type KeyInfo = {
	CreatedTime: number,
	UpdatedTime: number,
	Version: string,
	UserIds: { number },
	Metadata: any,
}

export type NewOptions = {
	data: JSONHelper.JSONValue,
	keyInfo: KeyInfo?,
	dirty: boolean?, -- If it's dirty by default

	modalState: ModalState.ModalState,

	readOnly: boolean, -- default: false

	-- default: false
	-- If all the branches start out open
	allOpen: boolean?,

	-- default: true. Difference between this and `keyInfo = nil` is that `keyInfo = nil` will still
	-- show the the Key Info area, but disabling this will completely disable the Key Info area.
	showKeyInfo: boolean?,

	isTesting: boolean?, -- default: false

	inputReceiver: Frame | UserInputService | nil,
}

export type ViewerClass = {
	from: (Theme.Theme,	UIMessages.UIMessages, GuiObject, NewOptions) -> Viewer,
}

export type GetValueResult =
	| { kind: "success", value: unknown, hasNil: boolean }
	| { kind: "failure", message: string }

export type Viewer = {
	idLookedUp: RBXScriptSignal,

	setDisabled: (Viewer, disabled: boolean) -> (),

	getValue: (Viewer) -> GetValueResult,

	getDirtyCapability: (Viewer) -> DirtyCapability?,
	getHistoryCapability: (Viewer) -> HistoryCapability?,
	getKeyInfoCapability: (Viewer) -> KeyInfoCapability?,
	getSearchCapability: (Viewer) -> SearchCapability?,

	destroy: (Viewer) -> (),
}

export type DirtyCapability = {
	dirtyChanged: RBXScriptSignal,
	isDirty: boolean,
	undirty: (DirtyCapability) -> (),
}

export type HistoryCapability = {
	historyChanged: RBXScriptSignal,

	canUndo: (HistoryCapability) -> (),
	undo: (HistoryCapability) -> (),

	canRedo: (HistoryCapability) -> (),
	redo: (HistoryCapability) -> (),
}

export type KeyInfoCapability = {
	getKeyInfo: (KeyInfoCapability) -> (--[[userIds:]]{ number }, --[[metadata]]{ [string]: any }),
}

export type SearchResult = {
	kind: "success",
	matches: number,
} | {
	kind: "none"
}

export type SearchCapability = {
	setSearch: (SearchCapability, term: string) -> (),
	endSearch: (SearchCapability) -> (),
	jumpNextMatch: (SearchCapability) -> (),
	jumpPrevMatch: (SearchCapability) -> (),
	searchResultsChanged: RBXScriptSignal,
}

return {}


--!strict

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

local Assets = script.Parent.Parent.Assets

local StyleState = script.Parent.Parent.StyleState
local StyleStateHelper = require(StyleState.StyleStateHelper)
local ButtonStyleState = require(StyleState.ButtonStyleState)
local LabelStyleState = require(StyleState.LabelStyleState)

local ContextMenuStyleState = require(StyleState.Utility.ContextMenuStyleState)

export type HistoryFrame = typeof(Assets.Widgets.MainApp.ConnectDataStore.Content.History)
type ConnectionRow = typeof(Assets.ConnectionRow)

local function getStyleStates(theme: Theme.Theme, frame: HistoryFrame)
	return {
		labels = LabelStyleState.fromDescendants(theme, frame),
	}
end

type HistoryFieldImplementation = {
	__index: HistoryFieldImplementation,

	from: (HistoryFrame) -> HistoryView,

	update: (HistoryView, StyleStateHelper.TransitionSpeed) -> (),
	destroy: (HistoryView, completely: boolean) -> (),

	_updateConnectionsHistory: (HistoryView) -> (),
}

type HistoryViewFields = {
	frame: HistoryFrame,
	_widget: PluginGui,
	_history: History.History,
	_theme: Theme.Theme,
	_styleStates: typeof(getStyleStates({} :: any, {} :: any)),
	_connections: { RBXScriptConnection },

	_connectionRecordStyleStates: { StyleStateHelper.StyleState },

	_connectionRecordSelected: BindableEvent,
	_connectionRecordDeleted: BindableEvent,
	_connectionRecordPinChanged: BindableEvent,
	_connectionRecordLoaded: BindableEvent,

	connectionRecordSelected: RBXScriptSignal,
	connectionRecordDeleted: RBXScriptSignal,
	connectionRecordPinChanged: RBXScriptSignal,
	connectionRecordLoaded: RBXScriptSignal,
}

export type HistoryView = typeof(setmetatable({} :: HistoryViewFields, {} :: HistoryFieldImplementation))

local HistoryView = {} :: HistoryFieldImplementation
HistoryView.__index = HistoryView

function HistoryView.from(theme: Theme.Theme, history: History.History, frame: HistoryFrame)
	local self = setmetatable({}, HistoryView)
	self.frame = frame
	self._history = history
	self._widget = frame:FindFirstAncestorWhichIsA("PluginGui")

	self._connectionRecordSelected = Instance.new("BindableEvent")
	self._connectionRecordDeleted = Instance.new("BindableEvent")
	self._connectionRecordPinChanged = Instance.new("BindableEvent")
	self._connectionRecordLoaded = Instance.new("BindableEvent")

	self.connectionRecordSelected = self._connectionRecordSelected.Event :: RBXScriptSignal
	self.connectionRecordDeleted = self._connectionRecordDeleted.Event :: RBXScriptSignal
	self.connectionRecordPinChanged = self._connectionRecordPinChanged.Event :: RBXScriptSignal
	self.connectionRecordLoaded = self._connectionRecordLoaded.Event :: RBXScriptSignal

	self._theme = theme
	self._styleStates = getStyleStates(theme, frame)

	self._connectionRecordStyleStates = {}
	self._connections = {
		theme.colorsChanged:Connect(function()
			task.wait()
			self:update("slow")
		end),
		history.connectionsChanged:Connect(function()
			self:_updateConnectionsHistory()
		end),
	}

	self:_updateConnectionsHistory()
	return self
end

local function updateConnectionRowPinVisual(row: ConnectionRow, theme: Theme.Theme, pinned: boolean?)
	if pinned then
		if not row:FindFirstChild("ConnectionRowPinIcon") then
			local icon = Assets.ConnectionRowPinIcon:Clone()
			icon.Parent = row
			icon.ImageColor3 = theme.colors.text
		end
		row.DataStore.Position = UDim2.new(0, 20, 0, 0)
		row.DataStore.Size = UDim2.new(0.5, -20, 1, 0)
	else
		local icon = row:FindFirstChild("ConnectionRowPinIcon")
		if icon then
			icon:Destroy()
		end
		row.DataStore.Position = UDim2.new(0, 0, 0, 0)
		row.DataStore.Size = UDim2.new(0.5, 0, 1, 0)
	end
end

function HistoryView:_updateConnectionsHistory()
	local records = self._history.connectionRecords
	local isVisible = (#records > 0)

	for _, v in self._connectionRecordStyleStates do
		v:destroy(true)
	end
	self._connectionRecordStyleStates = {}

	for _, record in records do
		local row = Assets.ConnectionRow:Clone()
		if record.connection.scope and record.connection.scope ~= "" then
			row.DataStore.Text = `{record.connection.scope}/{record.connection.name}`
		else
			row.DataStore.Text = record.connection.name
		end
		row.ConnectionType.Text = record.connection.type:gsub("^.", string.upper)
		row.LastAccess.Text = DateTime.fromUnixTimestamp(record.lastAccess):FormatUniversalTime("ll LT", "en-us")
		row.LayoutOrder = if record.pinned then 0 else 1
		updateConnectionRowPinVisual(row, self._theme, record.pinned)
		row.Parent = self.frame.Table

		local styleState = ButtonStyleState.from(self._theme, row, {
			style = "transparent",
		})

		table.insert(self._connectionRecordStyleStates, styleState)

		row.Activated:Connect(function()
			self._connectionRecordSelected:Fire(record)
		end)
		row.MouseButton2Click:Connect(function()
			styleState:setSelecting(true):update()
			local option =
				ContextMenuStyleState.prompt(self._theme, self._widget, self._widget:GetRelativeMousePosition(), {
					options = {
						if record.pinned
							then { icon = self._theme.icons.unpin, text = "Unpin" }
							else { icon = self._theme.icons.pin, text = "Pin" },
						{ icon = self._theme.icons.import, text = "Load" },
						{ icon = self._theme.icons.delete, text = "Delete" },
					},
				})
			styleState:setSelecting(false):update()

			if option == "Delete" then
				self._connectionRecordDeleted:Fire(record)
			elseif option == "Load" then
				self._connectionRecordLoaded:Fire(record)
			elseif option == "Pin" then
				self._connectionRecordPinChanged:Fire(record, true)
			elseif option == "Unpin" then
				self._connectionRecordPinChanged:Fire(record, false)
			end
		end)
	end
end

function HistoryView:update(speed: StyleStateHelper.TransitionSpeed)
	StyleStateHelper.update(self._styleStates, speed)
end

function HistoryView:destroy(completely)
	for _, connection in self._connections do
		connection:Disconnect()
	end

	for _, styleState in self._connectionRecordStyleStates do
		styleState:destroy(true)
	end

	for _, v in self :: any do
		if typeof(v) == "Instance" and v:IsA("BindableEvent") then
			v:Destroy()
		end
	end

	table.clear(self :: any)
end

return HistoryView


local OrderedDataStoreKeyStyleState = {}
OrderedDataStoreKeyStyleState.__index = OrderedDataStoreKeyStyleState

function OrderedDataStoreKeyStyleState.from(theme, highlightColors, frame)
	local self = setmetatable({
		theme = theme,
		frame = frame,

		_highlightColors = highlightColors,

		selecting = false,
		_disabled = false,
		_hovering = false,

		_connections = {},
	}, OrderedDataStoreKeyStyleState)

	table.insert(
		self._connections,
		frame.InputBegan:Connect(function(input)
			if input.UserInputType == Enum.UserInputType.MouseMovement then
				self._hovering = true
				self:update()
			end
		end)
	)
	table.insert(
		self._connections,
		frame.InputEnded:Connect(function(input)
			if input.UserInputType == Enum.UserInputType.MouseMovement then
				self._hovering = false
				self:update()
			end
		end)
	)

	self:update("instant")
	return self
end

function OrderedDataStoreKeyStyleState:setSelecting(selecting: boolean)
	self.selecting = selecting
	return self
end

function OrderedDataStoreKeyStyleState:setDisabled(disabled: boolean)
	self._disabled = disabled
	self.frame.Key.Active = not disabled
	self.frame.Value.Active = not disabled
	return self
end

function OrderedDataStoreKeyStyleState:setHighlightColors(colors)
	self._highlightColors = colors
	return self
end

function OrderedDataStoreKeyStyleState:update(_)
	-- Since there can be 100s at at time, no tween

	if self._disabled then
		self.frame.BackgroundColor3 = self._highlightColors.background
		self.frame.Key.TextColor3 = self.theme.colors.disabledText
		self.frame.Value.TextColor3 = self.theme.colors.disabledText
	else
		self.frame.BackgroundColor3 = if self.selecting
			then self._highlightColors.selectBackground
			elseif self._hovering then self._highlightColors.hoverBackground
			else self._highlightColors.background
		self.frame.Key.TextColor3 = self._highlightColors.key
		self.frame.Value.TextColor3 = self._highlightColors.number
	end
end

function OrderedDataStoreKeyStyleState:destroy(completely: boolean)
	if completely then
		self.frame:Destroy()
	end
	for _, connection in self._connections do
		connection:Disconnect()
	end
end

return OrderedDataStoreKeyStyleState