--!strict

local DESCENDING_ICON = "rbxassetid://17650920474"
local ASCENDING_ICON = "rbxassetid://17650919434"

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

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

local Settings = require(script.Parent.Parent.Parent.Settings)
local Theme = require(script.Parent.Parent.Theme)
local UIMessages = require(script.Parent.Parent.UIMessages)
local PopupHelper = require(script.Parent.Parent.PopupHelper)
local Validators = require(script.Parent.Parent.Parent.Validators)
local Tooltip = require(script.Parent.Parent.Tooltip)

local StyleStateHelper = require(StyleState.StyleStateHelper)
local TextBoxStyleState = require(StyleState.Input.TextBoxStyleState)
local ShortcutsTextBoxStyleState = require(StyleState.Input.ShortcutsTextBoxStyleState)
local ButtonStyleState = require(StyleState.ButtonStyleState)
local LabelStyleState = require(StyleState.LabelStyleState)
local LoadMoreListStyleState = require(StyleState.LoadMoreListStyleState)
local SeparatorStyleState = require(StyleState.SeparatorStyleState)
local ContextMenuStyleState = require(StyleState.Utility.ContextMenuStyleState)
local ConfirmDialogStyleState = require(StyleState.Utility.ConfirmDialogStyleState)

local OrderedDataStoreKeyStyleState = require(script.OrderedDataStoreKeyStyleState)

local constrainTextBoxStyleState = require(script.Parent.Parent.Utilities.constrainTextBoxStyleState)
local createTabSwitchGroup = require(script.Parent.Parent.Utilities.createTabSwitchGroup)
local getHighlightColors = require(script.Parent.Parent.Utilities.getHighlightColors)
local promptKeyValueForm = require(script.Parent.Parent.Utilities.promptKeyValueForm)
local showShadowOnScroll = require(script.Parent.Parent.Utilities.showShadowOnScroll)

local OrderedDataStoreView = {}
OrderedDataStoreView.__index = OrderedDataStoreView

function OrderedDataStoreView.from(
	theme: Theme.Theme,
	uiMessages: UIMessages.UIMessages,
	frame: typeof(Assets.Widget.EditOrderedDataStore)
)
	local self = setmetatable({}, OrderedDataStoreView)
	self.theme = theme
	self.frame = frame
	self.uiMessages = uiMessages
	self.connections = {}
	self.locked = false

	self.activeKey = nil :: string?

	self.order = "descending"
	self._orderChanged = Instance.new("BindableEvent")
	self.orderChanged = self._orderChanged.Event

	self.minValue = nil :: number?
	self.maxValue = nil :: number?

	self._highlightColors = getHighlightColors(self.theme)

	-- Events

	self._listed = Instance.new("BindableEvent")
	self.listed = self._listed.Event

	self._keyQueried = Instance.new("BindableEvent")
	self.keyQueried = self._keyQueried.Event

	self._keyEdited = Instance.new("BindableEvent")
	self.keyEdited = self._keyEdited.Event

	self._keyDeleted = Instance.new("BindableEvent")
	self.keyDeleted = self._keyDeleted.Event

	self._keyAdded = Instance.new("BindableEvent")
	self.keyAdded = self._keyAdded.Event

	self.styleStates = {
		getKey = ShortcutsTextBoxStyleState.from(theme, frame.Key),
		order = ButtonStyleState.from(theme, frame.Order),
		minValue = TextBoxStyleState.from(theme, frame.MinValue),
		maxValue = TextBoxStyleState.from(theme, frame.MaxValue),
		list = ButtonStyleState.from(theme, frame.List, {
			style = "primary",
		}),

		rangeDash = LabelStyleState.from(theme, frame.RangeDash),

		separators = SeparatorStyleState.fromDescendants(theme, frame),

		loadMore = LoadMoreListStyleState.from(theme, frame.Keys.Table, {
			createItemStyleState = function(loadMoreListStyleState, info)
				local key = script.OrderedDataStoreKey:Clone()
				local styleState = OrderedDataStoreKeyStyleState.from(theme, self._highlightColors, key)

				key.Name = info.key
				key.Key.Text = info.key
				key.Value.Text = info.value

				local function onRightClick()
					if not key.Key.Active then
						return
					end -- It's disabled

					local gui = frame:FindFirstAncestorWhichIsA("PluginGui")
					local mousePosition = gui:GetRelativeMousePosition()

					local resetSelecting = loadMoreListStyleState:setSelectingStyleState(styleState)
					local option = ContextMenuStyleState.prompt(theme, gui, mousePosition, {
						options = {
							{ icon = self.theme.icons.insert, text = "Insert" },
							{ icon = self.theme.icons.delete, text = "Delete" },
						},
					})

					if option == "Delete" then
						if
							ConfirmDialogStyleState.confirm(theme, gui, mousePosition, {
								title = "Delete key?",
								message = "Are you sure you want to do this?",
							})
						then
							if
								ConfirmDialogStyleState.confirm(theme, gui, mousePosition, {
									title = "Confirm again",
									message = `You are going to delete the key <b>{info.key}</b>. This CANNOT be undone.`,
								})
							then
								self._keyDeleted:Fire(info.key)
							end
						end
					elseif option == "Insert" then
						self:_promptInsert(mousePosition)
					end

					resetSelecting()
				end

				local function toggleSelect()
					if loadMoreListStyleState.selectingStyleState == styleState then
						loadMoreListStyleState:setSelectingStyleState(nil)
					else
						loadMoreListStyleState:setSelectingStyleState(styleState)
					end
				end

				key.Key.Activated:Connect(function(_, count: number)
					count = count % 2
					if count == 0 then
						toggleSelect()
					elseif count == 1 then
						self:_promptModalTextBox(key, {
							frame = key,
							side = "key",
							defaultText = key.Key.Text,
							locked = true,
						})
					end
				end)
				key.Value.Activated:Connect(function(_, count: number)
					count = count % 2
					if count == 0 then
						toggleSelect()
					elseif count == 1 then
						local newValue = self:_promptModalTextBox(key, {
							frame = key,
							side = "value",
							defaultText = key.Value.Text,
							locked = false,
						})

						if newValue then
							local valid, _ = Validators.integer(newValue)
							if valid then
								self._keyEdited:Fire(info.key, tonumber(newValue))
							else
								self.uiMessages:error("Value must be an integer!")
							end
						end
					end
				end)

				key.Key.MouseButton2Click:Connect(onRightClick)
				key.Value.MouseButton2Click:Connect(onRightClick)

				key.Parent = loadMoreListStyleState.frame
				return styleState
			end,
		}),

		tableLabels = LabelStyleState.fromDescendants(theme, frame.Keys),
	}

	self:_updateHighlightColors({ tween = false })
	self.styleStates.tableLabels.Empty:update("instant")
	self.styleStates.tableLabels.Instructions:update("instant")
	showShadowOnScroll(frame.Keys, frame.ScrollShadow)

	-- Tooltip
	Tooltip.bindButtonGeneric(
		self.theme,
		self.styleStates.order.button,
		"Order\nToggle between highest-to-lowest or lowest-to-highest."
	)
	Tooltip.bindButtonGeneric(
		self.theme,
		self.styleStates.list.button,
		`List Keys\nList all keys in order. Equivalent to a call to {rfmt.code("GetSortedAsync")}.`
	)

	-- Tab switch groups

	createTabSwitchGroup({
		self.styleStates.getKey.textBox,
		self.styleStates.minValue.textBox,
		self.styleStates.maxValue.textBox,
	})

	-- Events

	table.insert(
		self.connections,
		theme.colorsChanged:Connect(function()
			task.wait()

			self:_updateHighlightColors({ tween = false })

			StyleStateHelper.update(self.styleStates, "slow")
		end)
	)

	table.insert(
		self.connections,
		Settings.changed:Connect(function(settingName)
			if settingName == "highlightColors" then
				self:_updateHighlightColors({ tween = true })
			end
		end)
	)

	-- UI Events
	-- Click Background
	self.frame.ClickBackground.MouseButton2Click:Connect(function()
		self.styleStates.loadMore:setSelectingStyleState(nil)

		local gui = self.frame:FindFirstAncestorWhichIsA("LayerCollector")
		local mousePosition = gui:GetRelativeMousePosition()
		local option = ContextMenuStyleState.prompt(self.theme, gui, mousePosition, {
			options = {
				{ icon = self.theme.icons.insert, text = "Insert" },
			},
		})

		if option == "Insert" then
			self:_promptInsert(mousePosition)
		end
	end)

	-- Listing events
	self.styleStates.minValue.textBox.FocusLost:Connect(function(entered: boolean, input: InputObject?)
		if input == nil then
			return
		end

		local number = tonumber(self.styleStates.minValue.textBox.Text)
		if number then
			self.minValue = number
		elseif self.styleStates.minValue.textBox.Text:gsub("%s", "") ~= "" then
			uiMessages:error("Min value must be a number.")
		else
			self.minValue = nil
		end
		self.styleStates.minValue.textBox.Text = if self.minValue then tostring(self.minValue) else ""

		if entered then
			self._listed:Fire()
		end
	end)

	constrainTextBoxStyleState(self.styleStates.minValue, { Validators.integer })

	self.styleStates.maxValue.textBox.FocusLost:Connect(function(entered: boolean, input: InputObject?)
		if input == nil then
			return
		end

		local number = tonumber(self.styleStates.maxValue.textBox.Text)
		if number then
			self.maxValue = number
		elseif self.styleStates.maxValue.textBox.Text:gsub("%s", "") ~= "" then
			uiMessages:error("Max value must be a number.")
		else
			self.maxValue = nil
		end
		self.styleStates.maxValue.textBox.Text = if self.maxValue then tostring(self.maxValue) else ""

		if entered then
			self._listed:Fire()
		end
	end)

	constrainTextBoxStyleState(self.styleStates.maxValue, { Validators.integer })

	self.styleStates.order.button.Activated:Connect(function()
		if self.order == "ascending" then
			self.order = "descending"
			self.styleStates.order.button.ImageLabel.Image = DESCENDING_ICON
			self._orderChanged:Fire()
		elseif self.order == "descending" then
			self.order = "ascending"
			self.styleStates.order.button.ImageLabel.Image = ASCENDING_ICON
			self._orderChanged:Fire()
		end
	end)

	self.styleStates.list.button.Activated:Connect(function()
		self._listed:Fire()
	end)

	self.styleStates.getKey.textBox.FocusLost:Connect(function(entered, input)
		if input == nil then
			return
		end

		if entered then
			local function errorHandler(err: string)
				self.uiMessages:error(err)
			end
			self._keyQueried:Fire(self.styleStates.getKey:getText(errorHandler))
		end
	end)

	constrainTextBoxStyleState(self.styleStates.getKey, { Validators.dataStoreKey })

	return self
end

function OrderedDataStoreView:_updateHighlightColors(options: { tween: boolean })
	self._highlightColors = getHighlightColors(self.theme)
	self.frame.ClickBackground.BackgroundColor3 = self._highlightColors.background
	self.frame.Keys.ScrollBarImageColor3 = self._highlightColors.scrollbar

	if options.tween then
		self.styleStates.tableLabels.Empty:overrideColor(self._highlightColors.plain):update()
		self.styleStates.tableLabels.Instructions:overrideColor(self._highlightColors.plain):update()
	else
		self.styleStates.tableLabels.Empty:overrideColor(self._highlightColors.plain)
		self.styleStates.tableLabels.Instructions:overrideColor(self._highlightColors.plain)
	end

	for _, styleState in self.styleStates.loadMore.itemStyleStates do
		styleState:setHighlightColors(self._highlightColors)
		if options.tween then
			styleState:update()
		end
	end
end

function OrderedDataStoreView:setLocked(locked: boolean)
	self.locked = locked

	self.styleStates.order:setDisabled(locked):update()
	self.styleStates.getKey:setDisabled(locked):update()
	self.styleStates.minValue:setDisabled(locked):update()
	self.styleStates.maxValue:setDisabled(locked):update()
	self.styleStates.list:setDisabled(locked):update()

	for _, styleState in self.styleStates.loadMore.itemStyleStates do
		styleState:setDisabled(locked):update()
	end
end

-- "empty" used when GetSortedAsync returns empty page
-- "notEmpty" when there's stuff in the page
-- "notFound" when queried key not found
function OrderedDataStoreView:setKeysState(state: "empty" | "notEmpty" | "notFound")
	local empty = (state == "empty") or (state == "notFound")
	self.frame.Keys.Table.Visible = not empty
	self.frame.Keys.Empty.Visible = empty
	self.frame.Keys.Instructions.Visible = false

	if state == "notFound" then
		self.frame.Keys.Empty.Text = "Key not found."
	else
		self.frame.Keys.Empty.Text = "No keys found."
	end
end

function OrderedDataStoreView:addKey(key: string, value: number)
	self:setKeysState("notEmpty")

	-- When the user inserts when they haven't listed or the list is empty, the load more button will appear. Make sure it doesn't
	if self.styleStates.loadMore:isEmpty() then
		self.styleStates.loadMore:setLoadMoreVisible(false)
	end

	local styleState = self.styleStates.loadMore:add({ key = key, value = value })
	self.styleStates.loadMore:setSelectingStyleState(styleState)

	-- Scroll all the way down to see the key
	self.frame.Keys.CanvasPosition = Vector2.new(0, self.frame.Keys.AbsoluteCanvasSize.Y)
end

function OrderedDataStoreView:updateKey(key: string, value: number)
	local frame = self.styleStates.loadMore.frame:FindFirstChild(key)
	if frame then
		frame.Value.Text = value
	end
end

function OrderedDataStoreView:deleteKey(key: string)
	local frame = self.styleStates.loadMore.frame:FindFirstChild(key)
	if frame then
		frame:Destroy()

		for i, v in self.styleStates.loadMore.itemStyleStates do
			if v.frame == frame then
				v:destroy()
				table.remove(self.styleStates.loadMore.itemStyleStates, i)
				break
			end
		end
	end
end

-- When setting activeKey to nil, the caller should also list keys.
-- activeKey is the key that they got using the `Get Key` input
function OrderedDataStoreView:setActiveKey(key: string?, value: number?)
	if key then
		self.styleStates.loadMore:clear()
		self.styleStates.loadMore:setLoadMoreVisible(false)
		if value then
			self:setKeysState("notEmpty")
			self.styleStates.loadMore:add({ key = key, value = value })
		else
			self:setKeysState("notFound")
		end
	end
	self.activeKey = key
end

type PromptModalTextBoxOptions = {
	frame: typeof(Assets.OrderedDataStoreKey),
	side: "key" | "value",
	defaultText: string,
	locked: boolean,
}
function OrderedDataStoreView:_promptModalTextBox(key: string, options: PromptModalTextBoxOptions): string
	local gui = self.frame:FindFirstAncestorWhichIsA("LayerCollector")
	local textBox = Assets.ModalTextBox:Clone()
	local styleState = TextBoxStyleState.from(self.theme, textBox)

	styleState
		:overrideTextColor(if options.side == "key" then self._highlightColors.key else self._highlightColors.number)
		:overrideBackgroundColor(self._highlightColors.background)
		:update("instant")

	local attachTo = if options.side == "key" then options.frame.Key else options.frame.Value

	textBox.PlaceholderText = "Value"
	textBox.Text = options.defaultText
	textBox.Size = UDim2.fromOffset(attachTo.AbsoluteSize.X, attachTo.AbsoluteSize.Y)

	if options.locked then
		textBox.TextEditable = false
	end

	local attachment = PopupHelper.attach(textBox, attachTo, {
		anchor = Vector2.new(0, 0),
		offset = Vector2.new(0, 0),
	})
	local modal = PopupHelper.modal(textBox, gui, {
		includeDropShadow = false,
	})

	local done = false
	local result = nil

	local function cleanup()
		modal:destroy()
		attachment:destroy()
		styleState:destroy()
		textBox:Destroy()
		done = true
	end

	modal.cancelled:Once(cleanup)
	textBox.FocusLost:Connect(function(entered)
		if entered then
			result = textBox.Text
		end
		cleanup()
	end)

	-- Weird bug, without this the textbox jitters
	task.delay(0, function()
		textBox:CaptureFocus()
		if options.locked then
			textBox.SelectionStart = 1
			textBox.CursorPosition = #textBox.Text + 1
		end
	end)

	repeat
		task.wait()
	until done

	return result
end

function OrderedDataStoreView:_promptInsert(position: Vector2)
	local result = promptKeyValueForm(self.theme, self.frame:FindFirstAncestorWhichIsA("PluginGui"), {
		title = "Insert Key",
		includeKey = true,
		includeValue = true,
		constraint = "orderedDataStore",
		position = position,
	})
	if result then
		self._keyAdded:Fire(result.key, result.value)
	end
end

function OrderedDataStoreView:reset()
	self.order = "descending"
	self.styleStates.order.button.ImageLabel.Image = DESCENDING_ICON
	self.styleStates.loadMore:clear()

	self.styleStates.getKey.textBox.Text = ""
	self.styleStates.minValue.textBox.Text = ""
	self.styleStates.maxValue.textBox.Text = ""

	self.activeKey = nil
	self.minValue = nil
	self.maxValue = nil

	self.frame.Keys.Instructions.Visible = true
	self.frame.Keys.Table.Visible = false
	self.frame.Keys.Empty.Visible = false
end

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

return OrderedDataStoreView


--!strict

local TweenService = game:GetService("TweenService")

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

local Theme = require(script.Parent.Parent.Theme)
local PopupHelper = require(script.Parent.Parent.PopupHelper)
local ViewerTypes = require(script.Parent.Parent.Viewers.Types)

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

local function getStyleStates(theme: Theme.Theme, frame: typeof(Assets.Views.Search))
	return {
		background = BackgroundStyleState.from(theme, frame, { style = "popup" }),
		next = ButtonStyleState.from(theme, frame.Next, { style = "dormant" }),
		prev = ButtonStyleState.from(theme, frame.Previous, { style = "dormant" }),
		close = ButtonStyleState.from(theme, frame.Close, { style = "dormant" }),
		textBox = TextBoxStyleState.from(theme, frame.TextBox),
		matches = LabelStyleState.from(theme, frame.Matches),
	}
end

export type SearchViewOptions = {
	theme: Theme.Theme,
	parent: Instance,
	position: UDim2,
	anchorPoint: Vector2,

	nextClicked: () -> (),
	prevClicked: () -> (),
	closeClicked: () -> (),
	searchChanged: (searchTerm: string) -> (),
}

type SearchViewImplementation = {
	__index: SearchViewImplementation,

	new: (SearchViewOptions) -> SearchView,
	update: (SearchView, StyleStateHelper.TransitionSpeed) -> (),
	captureFocus: (SearchView) -> (),

	animateIn: (SearchView) -> (),
	animateOut: (SearchView) -> (),

	setResult: (SearchView, result: ViewerTypes.SearchResult) -> (),
	destroy: (SearchView) -> (),
}

type SearchViewFields = {
	_theme: Theme.Theme,
	_styleStates: typeof(getStyleStates({} :: any)),
	_frame: typeof(Assets.Views.Search),
	_dropShadow: ImageLabel,

	_dead: boolean,
	_isAnimating: boolean,
	_lastSearch: string,
}

export type SearchView = typeof(setmetatable({} :: SearchViewFields, {} :: SearchViewImplementation))

local SearchView: SearchViewImplementation = {} :: SearchViewImplementation
SearchView.__index = SearchView

function SearchView.new(options)
	local self = setmetatable({}, SearchView)

	self._theme = options.theme

	self._frame = Assets.Views.Search:Clone()
	self._styleStates = getStyleStates(self._theme, self._frame)

	self._frame.Position = options.position
	self._frame.AnchorPoint = options.anchorPoint
	self._frame.ZIndex = 10
	self._frame.Parent = options.parent

	self._dropShadow = PopupHelper.attachDropShadow(self._frame)

	self._dead = false
	self._isAnimating = false
	self._lastSearch = ""

	self:setResult({ kind = "none" })

	self._styleStates.next.button.Activated:Connect(options.nextClicked)
	self._styleStates.prev.button.Activated:Connect(options.prevClicked)
	self._styleStates.close.button.Activated:Connect(options.closeClicked)
	self._styleStates.textBox.textBox.FocusLost:Connect(function(isReturn, input)
		if input and input.KeyCode == Enum.KeyCode.Escape then
			options.closeClicked()
		elseif isReturn and input and input:IsModifierKeyDown(Enum.ModifierKey.Shift) then
			self._styleStates.textBox.textBox:CaptureFocus()
			options.prevClicked()
		elseif isReturn then
			self._styleStates.textBox.textBox:CaptureFocus()
			options.nextClicked()
		end
	end)
	self._styleStates.textBox.textBox:GetPropertyChangedSignal("Text"):Connect(function()
		-- remove newlines if they pressed enter
		if self._styleStates.textBox.textBox.Text:find("[\n\r]+$") then
			self._styleStates.textBox.textBox.Text = self._styleStates.textBox.textBox.Text:gsub("[\n\r]+$", "")
		elseif self._styleStates.textBox.textBox.Text ~= self._lastSearch then
			self._lastSearch = self._styleStates.textBox.textBox.Text
			options.searchChanged(self._styleStates.textBox.textBox.Text)
		end
	end)

	return self
end

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

local animateTweenInfo = TweenInfo.new(0.1, Enum.EasingStyle.Quad)
function SearchView:animateIn()
	if self._isAnimating then return end

	local canvasGroup = Instance.new("CanvasGroup")
	canvasGroup.Size = UDim2.fromScale(1, 1)
	canvasGroup.Position = UDim2.fromOffset(0, 16)
	canvasGroup.BackgroundTransparency = 1
	canvasGroup.GroupTransparency = 1

	canvasGroup.Parent = self._frame.Parent
	self._frame.Parent = canvasGroup
	self._dropShadow.Parent = canvasGroup

	local originalTransparency = self._dropShadow.ImageTransparency
	self._dropShadow.ImageTransparency = 1
	TweenService:Create(self._dropShadow, animateTweenInfo, {
		ImageTransparency = originalTransparency
	}):Play()

	local tween = TweenService:Create(canvasGroup, animateTweenInfo, {
		GroupTransparency = 0,
		Position = UDim2.fromOffset(0, 0),
	})
	tween:Play()
	tween.Completed:Wait()

	if self._dead then
		canvasGroup:Destroy()
		return
	end

	self._frame.Parent = canvasGroup.Parent
	self._dropShadow.Parent = canvasGroup.Parent
	canvasGroup:Destroy()

	self._isAnimating = false
end

function SearchView:animateOut()
	if self._isAnimating then return end

	local canvasGroup = Instance.new("CanvasGroup")
	canvasGroup.Size = UDim2.fromScale(1, 1)
	canvasGroup.Position = UDim2.fromOffset(0, 16)
	canvasGroup.BackgroundTransparency = 1
	canvasGroup.GroupTransparency = 0

	canvasGroup.Parent = self._frame.Parent
	self._frame.Parent = canvasGroup
	self._dropShadow.Parent = canvasGroup

	TweenService:Create(self._dropShadow, animateTweenInfo, {
		ImageTransparency = 1
	}):Play()

	local tween = TweenService:Create(canvasGroup, animateTweenInfo, {
		GroupTransparency = 1,
		Position = UDim2.fromOffset(0, 8),
	})
	tween:Play()
	tween.Completed:Wait()

	if self._dead then
		canvasGroup:Destroy()
		return
	end

	self._frame.Parent = canvasGroup.Parent
	self._dropShadow.Parent = canvasGroup.Parent
	canvasGroup:Destroy()

	self._isAnimating = false
end

function SearchView:captureFocus()
	self._styleStates.textBox.textBox:CaptureFocus()
end

function SearchView:setResult(result)
	if result.kind == "none" then
		self._styleStates.matches.Visible = false
		self._styleStates.matches.label.Text = ""
	else
		local suffix =
			if result.matches == 1 then "match"
			else "matches"
		self._styleStates.matches.Visible = true
		self._styleStates.matches.label.Text = `{result.matches} {suffix}`
	end
end

function SearchView:destroy()
	self._dead = true
	self._frame:Destroy()
	self._dropShadow:Destroy()
end

return SearchView


local TweenService = game:GetService("TweenService")

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

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

local AccentColorButtonStyleState = setmetatable({}, { __index = ButtonStyleState })
AccentColorButtonStyleState.__index = AccentColorButtonStyleState

function AccentColorButtonStyleState.from(...)
	local self = setmetatable(ButtonStyleState.from(...), AccentColorButtonStyleState)

	self:update("instant")
	return self
end

function AccentColorButtonStyleState:update(speed: StyleStateHelper.TransitionSpeed)
	local tween = StyleStateHelper.getTweenInfoForSpeed(speed)
	local state = if self.disabled
		then "disable"
		elseif self.selecting then "select"
		elseif self._pressing then "press"
		elseif self._hovering then "hover"
		else "default"

	local color = if state == "disable"
		then Color3.new(0.5, 0.5, 0.5)
		else Theme.getAccentColor(self.theme.preset, self.button.Name)
	TweenService:Create(self.button, tween, {
		BackgroundColor3 = color,
	}):Play()
	TweenService:Create(self.button.Outline.UIStroke, tween, {
		Color = color,
		Thickness = if self.disabled
			then 0
			elseif self.selecting then 2
			elseif self._pressing then 2
			elseif self._hovering then 1
			else 0,
	}):Play()
end

return AccentColorButtonStyleState


--!strict

local SelectionService = game:GetService("Selection")
local ServerScriptService = game:GetService("ServerScriptService")
local ChangeHistoryService = game:GetService("ChangeHistoryService")

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

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

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

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

local Tree = require(script.Parent.Parent.Viewers.Tree)

local StyleStateHelper = require(StyleState.StyleStateHelper)
local BackgroundStyleState = require(StyleState.BackgroundStyleState)
local ScrollingFrameStyleState = require(StyleState.ScrollingFrameStyleState)
local ButtonStyleState = require(StyleState.ButtonStyleState)
local LabelStyleState = require(StyleState.LabelStyleState)
local SeparatorStyleState = require(StyleState.SeparatorStyleState)
local SwitchStyleState = require(StyleState.Input.SwitchStyleState)
local SelectStyleState = require(StyleState.Input.SelectStyleState)

local AccentColorButtonStyleState = require(script.AccentColorButtonStyleState)

local SettingsView = {}
SettingsView.__index = SettingsView

local function spinIcon(icon: ImageLabel)
	local start = os.clock()
	local duration = 1
	while true do
		local delta = math.min(duration, os.clock() - start)
		local alpha = delta / duration

		icon.Rotation = 180 * ((alpha - 1) ^ 3 + 1)

		if delta >= duration then
			break
		end

		task.wait()
	end

	icon.Rotation = 0
end

local function rockIcon(icon: ImageLabel)
	local start = os.clock()
	local duration = 1.25
	while true do
		local delta = math.min(duration, os.clock() - start)
		local alpha = delta / duration

		icon.Rotation = 90 * math.sin(4 * math.pi * alpha) * math.pow(10, -alpha)

		if delta >= duration then
			break
		end

		task.wait()
	end

	icon.Rotation = 0
end

local function jumpIcon(icon: ImageLabel)
	local start = os.clock()
	local duration = 0.3
	while true do
		local delta = math.min(duration, os.clock() - start)
		local alpha = delta / duration

		icon.Position = UDim2.new(0.5, 0, 0.5, -5 * (-(2 * alpha - 1) ^ 2 + 1))

		if delta >= duration then
			break
		end

		task.wait()
	end

	icon.Position = UDim2.fromScale(0.5, 0.5)
end

local function bindSettingTooltip(theme: Theme.Theme, setting: any, message: string)
	Tooltip.bind(theme, setting, message, {
		on = setting,
		tooltipAnchor = Vector2.new(0, 0.5),
		offsetAnchor = Vector2.new(1, 0.5),
		offsetOnX = true,
	})
end

function SettingsView.from(theme: Theme.Theme, uiMessages: UIMessages.UIMessages, frame: typeof(Assets.Widget.Settings))
	local accentColorButtonStyleStates = {}
	for _, row in frame.Content.Accent.Buttons:GetChildren() do
		if row:IsA("Frame") then
			for _, button in row:GetChildren() do
				if button:IsA("TextButton") then
					accentColorButtonStyleStates[button.Name] = AccentColorButtonStyleState.from(theme, button)
				end
			end
		end
	end

	local settingLabels = {}
	for _, child in frame.Content:GetChildren() do
		local title = child:FindFirstChild("Title")
		if title then
			settingLabels[child.Name .. "Title"] = LabelStyleState.from(theme, title)
		elseif child:IsA("TextLabel") then
			settingLabels[child.Name] = LabelStyleState.from(theme, child)
		end

		local description = child:FindFirstChild("Description")
		if description then
			settingLabels[child.Name .. "Description"] = LabelStyleState.from(theme, description, { emphasis = "light" })
		end
	end

	local self = setmetatable({
		theme = theme,
		frame = frame,
		styleStates = {
			scrollbar = ScrollingFrameStyleState.from(theme, frame.Content),
			headerBackground = BackgroundStyleState.from(theme, frame.HeaderBackground, { style = "header" }),
			title = LabelStyleState.from(theme, frame.Title),
			presetButtons = {
				dark = ButtonStyleState.from(theme, frame.Content.Preset.Buttons.dark),
				light = ButtonStyleState.from(theme, frame.Content.Preset.Buttons.light),
				studio = ButtonStyleState.from(theme, frame.Content.Preset.Buttons.studio),
			},
			accentColorButtons = accentColorButtonStyleStates,
			highlightColorsSelect = SelectStyleState.from(theme, frame.Content.HighlightColors.Select, {
				default = Settings.get("highlightColors"),
				options = {
					"Default",
					"Script Editor",
				},
			}),
			hideGameNameSwitch = SwitchStyleState.from(theme, frame.Content.HideGameName.Switch, {
				default = Settings.get("hideGameName"),
			}),
			listDeletedKeysSwitch = SwitchStyleState.from(theme, frame.Content.ListDeletedKeys.Switch, {
				default = Settings.get("listDeletedKeys"),
			}),
			automaticallyListSwitch = SwitchStyleState.from(theme, frame.Content.AutomaticallyList.Switch, {
				default = Settings.get("automaticallyList"),
			}),
			clickToOpenSwitch = SwitchStyleState.from(theme, frame.Content.ClickToOpen.Switch, {
				default = Settings.get("clickToOpen"),
			}),
			useAlternatingKeysSwitch = SwitchStyleState.from(theme, frame.Content.UseAlternatingKeyColors.Switch, {
				default = Settings.get("useAlternatingKeyColors"),
			}),
			cloneButton = ButtonStyleState.from(theme, frame.Content.ClonePlugin.Button),

			settingLabels = settingLabels,
			separators = SeparatorStyleState.fromDescendants(theme, frame),
		},

		connections = {},

		_tree = nil,
	}, SettingsView)

	self:loadFrom(Settings.data)

	-- Events

	table.insert(
		self.connections,
		Settings.changed:Connect(function(key, value)
			if key == "themePreset" then
				self:_updatePresetButtons(value)
				self:_updateAccentButtons(Settings.data.themeAccent)
				if value == "dark" then
					rockIcon(self.styleStates.presetButtons.dark.button.ImageHolder.ImageLabel)
				elseif value == "light" then
					spinIcon(self.styleStates.presetButtons.light.button.ImageHolder.ImageLabel)
				elseif value == "studio" then
					jumpIcon(self.styleStates.presetButtons.studio.button.ImageHolder.ImageLabel)
				end
			elseif key == "themeAccent" then
				self:_updateAccentButtons(value)
			end
		end)
	)

	table.insert(
		self.connections,
		theme.colorsChanged:Connect(function()
			task.wait()
			StyleStateHelper.update(self.styleStates :: any, "slow") -- TODO: why is this a warning without :: any
		end)
	)

	-- UI Events
	for _, button: any in self.styleStates.presetButtons do
		button.button.Activated:Connect(function()
			if Settings.data.themePreset ~= button.button.Name then
				Settings.set("themePreset", button.button.Name)
			end
		end)
	end

	for _, button: any in self.styleStates.accentColorButtons do
		button.button.Activated:Connect(function()
			if Settings.data.themeAccent ~= button.button.Name then
				Settings.set("themeAccent", button.button.Name)
			end
		end)
	end

	self.styleStates.highlightColorsSelect.changed:Connect(function(value)
		Settings.set("highlightColors", value)
	end)

	self.styleStates.hideGameNameSwitch.toggled:Connect(function(value)
		Settings.set("hideGameName", value)
	end)

	self.styleStates.useAlternatingKeysSwitch.toggled:Connect(function(value)
		Settings.set("useAlternatingKeyColors", value)
	end)

	self.styleStates.listDeletedKeysSwitch.toggled:Connect(function(value)
		Settings.set("listDeletedKeys", value)
	end)

	self.styleStates.automaticallyListSwitch.toggled:Connect(function(value)
		Settings.set("automaticallyList", value)
	end)

	self.styleStates.clickToOpenSwitch.toggled:Connect(function(value)
		Settings.set("clickToOpen", value)
	end)

	self.styleStates.cloneButton.button.Activated:Connect(function()
		local folder = script
		repeat
			folder = folder.Parent
		until folder == nil or folder.Name:match("DataDelve")

		local recordingId = ChangeHistoryService:TryBeginRecording("Clone DataDelve")
		if not recordingId then
			return
		end

		local clone = folder:Clone()
		clone.Parent = ServerScriptService

		SelectionService:Set({clone})

		ChangeHistoryService:FinishRecording(recordingId, Enum.FinishRecordingOperation.Commit)
	end)

	return self
end

function SettingsView:loadFrom(data: Settings.SettingsData)
	self:_updatePresetButtons(data.themePreset)
	self:_updateAccentButtons(data.themeAccent)
end

function SettingsView:_updatePresetButtons(selecting: string)
	for _, button: any in self.styleStates.presetButtons do
		button:setSelecting(button.button.Name == selecting):update()
	end
end

function SettingsView:_updateAccentButtons(selecting: string)
	for _, button: any in self.styleStates.accentColorButtons do
		button:setSelecting(button.button.Name == selecting):update()
	end
end

function SettingsView:beforeOpen()
	self = self :: any
	if self._tree then
		self._tree:destroy()
	end

	self._tree = Tree.new(self.theme, self.uiMessages, self.frame.Content.TreePreview, {
		data = {
			number = 100,
			string = "preview",
			bool = true,
			buffer = buffer.create(2),
			null = nil,
			object = {
				number = 100,
				string = "preview",
				bool = true,
				null = nil,
				{
					nested = "object",
				},
			},
			array = {
				100 :: any,
				"preview" :: any,
				true :: any,
				nil :: any,
				{
					"nested",
					"array",
				},
			},
		},
		readOnly = true,
		allOpen = true,
		modalState = ModalState.new(),
	})
end

function SettingsView:afterClose()
	self = self :: any

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

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

return SettingsView


--!strict

local DESCENDING_ICON = "rbxassetid://17650920474"
local ASCENDING_ICON = "rbxassetid://17650919434"

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

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

local Session = require(script.Parent.Parent.Parent.Session)
local Transactor = require(script.Parent.Parent.Parent.Transactor)

local Theme = require(script.Parent.Parent.Theme)
local UIMessages = require(script.Parent.Parent.UIMessages)
local Tooltip = require(script.Parent.Parent.Tooltip)
local PopupHelper = require(script.Parent.Parent.PopupHelper)
local StyleStateHelper = require(StyleState.StyleStateHelper)

local BackgroundStyleState = require(StyleState.BackgroundStyleState)
local ButtonStyleState = require(StyleState.ButtonStyleState)
local LabelStyleState = require(StyleState.LabelStyleState)
local LoadMoreListStyleState = require(StyleState.LoadMoreListStyleState)
local SeparatorStyleState = require(StyleState.SeparatorStyleState)

local DatePickerStyleState = require(StyleState.Input.DatePickerStyleState)

local ContextMenuStyleState = require(StyleState.Utility.ContextMenuStyleState)
local ConfirmDialogStyleState = require(StyleState.Utility.ConfirmDialogStyleState)
local LoadingBarStyleState = require(StyleState.Utility.LoadingBarStyleState)

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

local VersionsView = {}
VersionsView.__index = VersionsView

-- Returns true with a callback if the version load should be continued with
-- or false if user cancelled.
export type LoadVersionCallback = (version: string) -> (boolean, (() -> (boolean, string?))?)

export type VersionsViewFromParams = {
	theme: Theme.Theme,
	uiMessages: UIMessages.UIMessages,
	widget: PluginGui,
	container: GuiObject, -- used in drawer mode
	session: Session.Session,
	loadVersion: LoadVersionCallback,
}

function VersionsView.new(params: VersionsViewFromParams)
	local self = setmetatable({}, VersionsView)
	self.frame = Assets.Views.VersionsPopup:Clone()
	self.theme = params.theme
	self.uiMessages = params.uiMessages
	self.widget = params.widget
	self.container = params.container
	self.connections = {}

	-- State

	self._session = params.session
	self._loadVersionCallback = params.loadVersion
	self._transactor = Transactor.new()

	self._loaded = false

	self.order = params.session.versionPagesOrder or Enum.SortDirection.Descending
	self.minDate = nil
	self.maxDate = nil

	-- Events

	self._closed = Instance.new("BindableEvent")
	self.closed = self._closed.Event

	-- UI

	self.styleStates = {
		background = BackgroundStyleState.from(self.theme, self.frame, {
			style = "popup",
		}),
		rightSeparator = SeparatorStyleState.from(self.theme, self.frame["Escape Layout"].RightSeparator),
		title = LabelStyleState.from(self.theme, self.frame.Title),
		instructions = LabelStyleState.from(self.theme, self.frame.Instructions),
		close = ButtonStyleState.from(self.theme, self.frame["Escape Layout"].Close, {
			style = "dormant",
		}),

		order = ButtonStyleState.from(self.theme, self.frame.Actions.Order),
		list = ButtonStyleState.from(self.theme, self.frame.Actions.List, {
			style = "primary",
		}),
		minDate = DatePickerStyleState.from(self.theme, self.frame.Actions.MinDate),
		maxDate = DatePickerStyleState.from(self.theme, self.frame.Actions.MaxDate, {
			upToEndOfDay = true,
		}),
		loadMore = LoadMoreListStyleState.from(self.theme, self.frame.List.Table, {
			createItemStyleState = function(loadMoreListStyleState, info: DataStoreObjectVersionInfo)
				local row = Assets.VersionRow:Clone()
				local styleState = ButtonStyleState.from(self.theme, row, {
					style = "transparent",
				})
				row.Created.Text = DateTime.fromUnixTimestampMillis(info.CreatedTime)
					:FormatUniversalTime("ll LT", "en-us")
				row.Version.Text = info.Version
				if info.Version == self._session.currentVersion then
					row.Version.FontFace = Font.new(row.Version.FontFace.Family, Enum.FontWeight.Bold, row.Version.FontFace.Style)
				end

				row.Name = info.Version
				row.Parent = loadMoreListStyleState.frame

				row.MouseButton2Click:Connect(function()
					if not row.Active then
						return
					end

					local resetSelecting = loadMoreListStyleState:setSelectingStyleState(styleState)
					local option =
						ContextMenuStyleState.prompt(self.theme, self.widget, self.widget:GetRelativeMousePosition(), {
							options = {
								{ icon = self.theme.icons.import, text = "Load" },
								{ icon = self.theme.icons.delete, text = "Delete" },
							},
						})
					resetSelecting()

					if option == "Load" then
						self:_loadVersion(info.Version)
					elseif option == "Delete" then
						self:_deleteVersion(info.Version)
					end
				end)

				row.Activated:Connect(function()
					self:_loadVersion(info.Version)
				end)

				styleState:setDisabled(info.IsDeleted):update("instant")
				if info.IsDeleted then
					-- Inject this secretly into the styleState so when locked state is change
					-- it knows to make sure to disable this
					styleState.SECRET_ACTUALLY_DISABLE = true
				end
				return styleState, info.Version
			end,
		}),

		listLabels = LabelStyleState.fromDescendants(self.theme, self.frame.List),
		listSeparators = SeparatorStyleState.fromDescendants(self.theme, self.frame.List),
	}

	self.mode = "popup"
	self.visible = false
	self._modal = nil
	self._modalConnection = nil
	self:setViewMode(self.mode :: any)

	-- Extra setup

	createTabSwitchGroup({
		self.styleStates.minDate.textBox,
		self.styleStates.maxDate.textBox,
	})

	-- Event connections

	table.insert(
		self.connections,
		self.theme.colorsChanged:Connect(function()
			task.wait()
			StyleStateHelper.update(self.styleStates :: any, "slow")
		end)
	)

	table.insert(
		self.connections,
		self.styleStates.order.button.Activated:Connect(function()
			if self.order == Enum.SortDirection.Ascending then
				self.order = Enum.SortDirection.Descending
				self.styleStates.order.button.ImageLabel.Image = DESCENDING_ICON
			else
				self.order = Enum.SortDirection.Ascending
				self.styleStates.order.button.ImageLabel.Image = ASCENDING_ICON
			end

			self:_reload()
		end)
	)
	table.insert(
		self.connections,
		self.styleStates.list.button.Activated:Connect(function()
			self:_reload()
		end)
	)

	table.insert(
		self.connections,
		self.styleStates.minDate.textBox.FocusLost:Connect(function(entered, input)
			if input == nil then
				return
			end

			local date = self.styleStates.minDate:getValue()
			self.minDate = date

			if entered then
				self:_reload()
			end
		end)
	)

	table.insert(
		self.connections,
		self.styleStates.maxDate.textBox.FocusLost:Connect(function(entered, input)
			if input == nil then
				return
			end

			local date = self.styleStates.maxDate:getValue()
			self.maxDate = date

			if entered then
				self:_reload()
			end
		end)
	)

	table.insert(
		self.connections,
		self.styleStates.loadMore.buttonStyleState.button.Activated:Connect(function()
			if self._session.versionPages then
				if not self._session.versionPages.isFinished then
					self:_runOperation(function()
						local _, err = self._session.versionPages:increment()
						if err then
							self.uiMessages:reportDataStoreError(err)
						end
					end)
				end
			end
		end)
	)

	table.insert(
		self.connections,
		self.styleStates.minDate.errorChanged:Connect(function()
			self:_updateListButtonsLocked()
		end)
	)
	table.insert(
		self.connections,
		self.styleStates.maxDate.errorChanged:Connect(function()
			self:_updateListButtonsLocked()
		end)
	)

	table.insert(
		self.connections,
		self.styleStates.close.button.Activated:Connect(function()
			if self._transactor.locked then
				return
			end
			self:hide()
		end)
	)

	table.insert(
		self.connections,
		self._transactor.lockedChanged:Connect(function(locked)
			self.styleStates.minDate:setDisabled(locked):update()
			self.styleStates.maxDate:setDisabled(locked):update()
			self:_updateListButtonsLocked()

			for _, styleState in self.styleStates.loadMore.itemStyleStates do
				styleState:setDisabled(styleState.SECRET_ACTUALLY_DISABLE or locked):update()
			end
		end)
	)

	-- Tooltips
	self._tooltipDestructors = {
		Tooltip.bindButtonGeneric(
			self.theme,
			self.styleStates.order.button,
			`Order\nToggle between newest-to-oldest or oldest-to-newest.`
		),
		--[[Tooltip.bindButtonGeneric(
			self.theme,
			self.styleStates.list.button,
			`Lists versions. Equivalent to {rfmt.code("ListVersionsAsync")}.`
		),]]
	}

	return self
end

function VersionsView:show()
	self.visible = true
	self:_updateViewMode()

	if not self._loaded then
		task.spawn(function()
			self:_reload()
		end)
	else
		for version, styleState in self.styleStates.loadMore.itemStyleStates do
			local versionText = styleState.button.Version
			if version == self._session.currentVersion then
				versionText.FontFace = Font.new(versionText.FontFace.Family, Enum.FontWeight.Bold, versionText.FontFace.Style)
			else
				versionText.FontFace = Font.new(versionText.FontFace.Family, Enum.FontWeight.Regular, versionText.FontFace.Style)
			end
		end
	end
end

function VersionsView:hide(dontEmitEvent: boolean?)
	self.visible = false
	self:_updateViewMode()
	if not dontEmitEvent then
		self._closed:Fire()
	end
end

function VersionsView:_updateViewMode()
	self:_cleanupModal()

	if not self.visible then
		self.frame.Visible = false
		self.frame.Parent = nil
	elseif self.mode == "drawer" then
		self.frame.Visible = true
		self.frame.AnchorPoint = Vector2.new(0, 0)
		self.frame.Size = UDim2.new(0, 338, 1, 0)
		self.frame.Position = UDim2.fromOffset(0, 0)
		self.frame.UICorner.CornerRadius = UDim.new(0, 0)
		self.styleStates.background.style = "default"
		self.styleStates.background:update("instant")
		self.styleStates.background.background.UIStroke.Enabled = false
		self.styleStates.rightSeparator.separator.Visible = true
		self.frame.Parent = self.container
	else
		self.frame.Visible = true
		self.frame.AnchorPoint = Vector2.new(0.5, 0.5)
		self.frame.Size = UDim2.fromScale(0.9, 0.8)
		self.frame.Position = UDim2.fromScale(0.5, 0.5)
		self.frame.UICorner.CornerRadius = UDim.new(0, 4)
		self.styleStates.background.style = "popup"
		self.styleStates.background:update("instant")
		self.styleStates.background.background.UIStroke.Enabled = true
		self.styleStates.rightSeparator.separator.Visible = false

		local modal = PopupHelper.modal(self.frame, self.widget, {
			scrimTransparency = 0.5,
		})
		self._modal = modal
		self._modalConnection = modal.cancelled:Connect(function()
			if self._transactor.locked then
				return
			end
			self:hide()
		end)
	end
end

function VersionsView:setViewMode(viewMode: "drawer" | "popup")
	self.mode = viewMode
	;(self :: any):_updateViewMode()
end

function VersionsView:_loadVersion(version: string)
	local shouldContinue, continueCallback = self._loadVersionCallback(version)
	if shouldContinue and continueCallback then
		local continueSuccess = false
		local success = self:_runOperation(function()
			local success, result = continueCallback()
			if (not success) or (not result) then
				if typeof(result) == "string" then
					self.uiMessages:reportDataStoreError(result)
				end
				continueSuccess = false
			else
				self.uiMessages:success(`Loaded version {version}.`)
				continueSuccess = true
			end
		end)

		if success and self.visible and self.mode == "popup" then
			self.visible = false
			self:_updateViewMode()
		end
	end
end

function VersionsView:_cleanupModal()
	if self._modalConnection then
		self._modalConnection:Disconnect()
		self._modalConnection = nil
	end
	if self._modal then
		self._modal:destroy()
		self._modal = nil
	end
end

function VersionsView:_deleteVersion(version: string)
	if self._transactor.locked then
		return
	end

	local popupPosition = self.widget:GetRelativeMousePosition()
	if
		not ConfirmDialogStyleState.confirm(self.theme, self.widget, popupPosition, {
			title = "Delete version?",
			message = "This is PERMANENT. You cannot undo this.",
		})
	then
		return
	end

	if self._transactor.locked then
		return
	end

	if
		not ConfirmDialogStyleState.confirm(self.theme, self.widget, popupPosition, {
			title = "Are you sure?",
			message = 'Deleting this version is PERMANENT. You cannot undo this!',
		})
	then
		return
	end

	self:_runOperation(function()
		local success, err = self._session:tryDeleteCurrentKeyVersion(version)
		if not success then
			self.uiMessages:reportDataStoreError(err)
		else
			self:_deleteVersionRow(version)
		end
	end)
end

function VersionsView:_reload()
	self._loaded = true

	self.styleStates.loadMore:clear()
	self.styleStates.loadMore:setLoadMoreVisible(false)

	self:_runOperation(function()
		local success, err = self._session:tryCreateVersionPages(self.order, self.minDate, self.maxDate)

		if not success then
			self.uiMessages:reportDataStoreError(err)
		else
			self.frame.List.Visible = true

			self.frame.List.Table.Visible = not self._session.versionPages:isEmpty()
			self.frame.List.Empty.Visible = self._session.versionPages:isEmpty()

			self._session.versionPages:syncWithLoadMoreListStyleState(self.styleStates.loadMore)
		end
	end)
end

function VersionsView:_runOperation(transaction: Transactor.Transaction): boolean
	if self._transactor.locked then
		return false
	end

	local endLoadingBar = LoadingBarStyleState.show(self.theme, self.frame["Escape Layout"].LoadingBar)
	local success = self._transactor:transact(transaction)
	endLoadingBar()
	return success
end

function VersionsView:_updateListButtonsLocked()
	local locked = self.styleStates.minDate.error or self.styleStates.maxDate.error
	self.styleStates.order:setDisabled(self._transactor.locked or locked):update()
	self.styleStates.list:setDisabled(self._transactor.locked or locked):update()
end

function VersionsView:_deleteVersionRow(version: string)
	for _, styleState in self.styleStates.loadMore.itemStyleStates do
		if styleState.button.Name == version then
			styleState.SECRET_ACTUALLY_DISABLE = true
			styleState:setDisabled(true):update("instant")
		end
	end
end

function VersionsView:destroy()
	local self = self :: any
	if self._dead then
		return
	end

	self._closed:Fire()

	self:_cleanupModal()

	StyleStateHelper.destroy(self.styleStates)

	for _, destructor in self._tooltipDestructors do
		destructor()
	end

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

	self._transactor:destroy()

	self.frame:Destroy()

	self._closed:Destroy()
	table.clear(self)
	self._dead = true
end

export type VersionsView = typeof(VersionsView.new({} :: VersionsViewFromParams))
return VersionsView


--!strict

local plugin = script:FindFirstAncestorOfClass("Plugin")
local StudioService = game:GetService("StudioService")

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

local Types = require(script.Parent.Parent.BufferViewers.Types)
local HexViewer = require(script.Parent.Parent.BufferViewers.Hex)
local DeserializerViewer = require(script.Parent.Parent.BufferViewers.Deserializer)

local Assets = script.Parent.Parent.Assets
local StyleState = script.Parent.Parent.StyleState
local StyleStateWrapper = require(StyleState.StyleStateWrapper)
local BackgroundStyleState = require(StyleState.BackgroundStyleState)
local ButtonStyleState = require(StyleState.ButtonStyleState)
local SelectStyleState = require(StyleState.Input.SelectStyleState)

local WIDGET_INFO = DockWidgetPluginGuiInfo.new(
	Enum.InitialDockState.Float, -- Widget will be initialized in floating panel
	true, -- Widget will be initially enabled
	true, -- Override the previous enabled state
	832, -- Default width of the floating window
	300, -- Default height of the floating window
	400, -- Minimum width of the floating window
	150 -- Minimum height of the floating window
)

local BufferWidget = {}
BufferWidget.__index = BufferWidget

function BufferWidget.new(theme: Theme.Theme, name: string, b: buffer)
	local self = setmetatable({}, BufferWidget)
	self.dead = false
	self.id = tostring(b)

	self.theme = theme
	self.widget = plugin:CreateDockWidgetPluginGuiAsync(self.id, WIDGET_INFO) :: PluginGui & typeof(Assets.HexEditorWidget)
	self.widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
	self.widget.Title = `"{name}" buffer`
	self.widget.Name = "DataDelve HexEditor"

	self.uiMessages = UIMessages.new(self.theme)
	self.uiMessages:setParent(self.widget)

	-- Set up UI

	for _, item in Assets.Widgets.HexEditorWidget:GetChildren() do
		item:Clone().Parent = self.widget
	end
	self.wrapper = StyleStateWrapper.new(theme, {
		background = BackgroundStyleState.from(theme, self.widget.Background),
		mode = SelectStyleState.from(theme, self.widget.Content.LeftTop.Mode, {
			default = Settings.get("bufferViewerMode"),
			options = {
				{ icon = self.theme.icons.hex, name = "Hex" },
				{ icon = self.theme.icons.script, name = "Deserializer" },
			},
			fitButton = false,
		}),
		undo = ButtonStyleState.from(theme, self.widget.Content.LeftTop.History.Undo, { style = "dormant" }),
		redo = ButtonStyleState.from(theme, self.widget.Content.LeftTop.History.Redo, { style = "dormant" }),
		loadFile = ButtonStyleState.from(theme, self.widget.Content.LeftTop.LoadFile, { style = "secondary" }),

		commit = ButtonStyleState.from(theme, self.widget.Content.RightTop.Commit, { style = "primary" }),
		cancel = ButtonStyleState.from(theme, self.widget.Content.RightTop.Cancel, { style = "secondary" }),
	})

	-- Events

	-- Parameters:
	--	submitted: boolean
	--		If the user submitted or cancelled. True is submitted.
	-- NOTE: this is fired with false when the widget is destroyed
	self._interacted = Instance.new("BindableEvent")
	self.interacted = self._interacted.Event

	-- UI Events

	self._viewer = nil :: Types.BufferViewer?
	self._viewerConnections = {} :: {RBXScriptConnection}

	self._mode = self.wrapper.styleStates.mode.selection
	self:_setMode(self._mode, b)
	self.wrapper.styleStates.mode.changed:Connect(function(mode)
		self:_setMode(mode, self:getFreshBuffer())
	end)

	self.wrapper.styleStates.undo.button.Activated:Connect(function()
		assert(self._viewer, "how no viewer?")
		if self._viewer:canUndo() then
			self._viewer:undo()
		end
	end)
	self.wrapper.styleStates.redo.button.Activated:Connect(function()
		assert(self._viewer, "how no viewer?")
		if self._viewer:canRedo() then
			self._viewer:redo()
		end
	end)

	self.wrapper.styleStates.loadFile.button.Activated:Connect(function()
		assert(self._viewer, "how??")

		local file: File = StudioService:PromptImportFile()
		if file then
			self._viewer:setValue(buffer.fromstring(file:GetBinaryContents()))
		else
			self.uiMessages:warn("No file selected.")
		end
	end)

	self.wrapper.styleStates.cancel.button.Activated:Connect(function()
		self._interacted:Fire(false)
	end)
	self.wrapper.styleStates.commit.button.Activated:Connect(function()
		self._interacted:Fire(true)
	end)

	self.widget.Destroying:Connect(function()
		(self :: any):destroy()
	end)
	self.widget:GetPropertyChangedSignal("Enabled"):Connect(function()
		if not self.widget.Enabled then
			(self :: any):destroy()
		end
	end)

	return self
end

function BufferWidget:_updateUndoRedoButtons(speed: string?)
	self.wrapper.styleStates.undo:setDisabled(not self._viewer:canUndo()):update(speed)
	self.wrapper.styleStates.redo:setDisabled(not self._viewer:canRedo()):update(speed)
end

function BufferWidget:_cleanupViewer()
	if self._viewer then
		self._viewer:destroy()
		for _, v in self._viewerConnections do
			v:Disconnect()
		end
	end
end

function BufferWidget:_setMode(mode: Types.BufferViewerMode, buf: buffer)
	self:_cleanupViewer()

	local viewer: Types.BufferViewer
	if mode == "Hex" then
		viewer = (HexViewer :: any) :: Types.BufferViewer
	elseif mode == "Deserializer" then
		viewer = (DeserializerViewer :: any) :: Types.BufferViewer
	end

	Settings.set("bufferViewerMode", mode)

	self._mode = mode
	self._viewer = viewer.from(buf, self.widget.Content.EditorContainer, {
		uiMessages = self.uiMessages,
		theme = self.theme,
		inputReceiver = self.widget.InputReceiver,
		widget = self.widget,
	}) :: Types.BufferViewer

	local selfAny = self :: any
	selfAny:_updateUndoRedoButtons("instant")
	self._viewerConnections = {
		selfAny._viewer.historyChanged:Connect(function()
			selfAny:_updateUndoRedoButtons()
		end)
	}
end

function BufferWidget:getFreshBuffer()
	assert(self._viewer, "how?!?!")
	return self._viewer:getValue()
end

function BufferWidget:destroy()
	if self.dead then
		return
	end
	self.dead = true

	self._interacted:Fire(false)

	self:_cleanupViewer()
	self.wrapper:destroy(true)
	self.widget:Destroy()
end

export type BufferWidget = typeof(BufferWidget.new({} :: any, "", buffer.create(0)))
return BufferWidget


--!strict
-- This is a widget for a single key, used for "Open in New Widget"

local plugin = script:FindFirstAncestorOfClass("Plugin")

local HttpService = game:GetService("HttpService")

local Theme = require(script.Parent.Parent.Theme)
local Session = require(script.Parent.Parent.Parent.Session)
local Transactor = require(script.Parent.Parent.Parent.Transactor)
local UIMessages = require(script.Parent.Parent.UIMessages)

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

local EditKeyView = require(script.Parent.Parent.Views.EditKeyView)
local VersionsView = require(script.Parent.Parent.Views.VersionsView)

local StyleStateHelper = require(StyleState.StyleStateHelper)
local BackgroundStyleState = require(StyleState.BackgroundStyleState)

local WIDGET_INFO = DockWidgetPluginGuiInfo.new(
	Enum.InitialDockState.Float, -- Widget will be initialized in floating panel
	false,
	false,
	500,
	350,
	350,
	230
)

export type SingleKeyParamNewParams = {
	keyName: string,
	theme: Theme.Theme,
	session: Session.Session,
}

export type SingleKeyWidgetImplementation = {
	__index: SingleKeyWidgetImplementation,

	new: (SingleKeyParamNewParams) -> (SingleKeyWidget?, string?),
	destroy: (SingleKeyWidget) -> (),

	_trySwapEditKeyView: (SingleKeyWidget, keyName: string, version: string?, session: Session.Session?) -> (boolean, string?),
}

export type SingleKeyWidgetFields = {
	deleted: RBXScriptSignal,
	_deleted: BindableEvent,

	_theme: Theme.Theme,
	_session: Session.Session,
	_transactor: Transactor.Transactor,
	_uiMessages: UIMessages.UIMessages,

	_id: string,
	_widget: DockWidgetPluginGui,
	_frame: typeof(Assets.Widgets.SingleKeyWidget),
	_styleStates: { StyleStateHelper.StyleState },
	_editKeyView: EditKeyView.EditKeyView,
	_editKeyFrame: EditKeyView.EditFrame,

	_connections: { RBXScriptConnection },
}

export type SingleKeyWidget = typeof(setmetatable({} :: SingleKeyWidgetFields, {} :: SingleKeyWidgetImplementation))

local SingleKeyWidget = {} :: SingleKeyWidgetImplementation
SingleKeyWidget.__index = SingleKeyWidget

function SingleKeyWidget.new(params)
	local self = setmetatable({}, SingleKeyWidget)
	self._session = params.session
	self._transactor = Transactor.new()

	self._theme = params.theme
	self._id = HttpService:GenerateGUID()
	self._widget = plugin:CreateDockWidgetPluginGuiAsync(self._id, WIDGET_INFO)
	self._widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
	self._widget.Name = `Single Key Widget {self._id}`
	self._widget.Enabled = false

	self._frame = Assets.Widgets.SingleKeyWidget:Clone()
	self._frame.Parent = self._widget

	self._uiMessages = UIMessages.new(self._theme)
	self._uiMessages:setParent(self._widget)

	self._styleStates = {
		BackgroundStyleState.from(self._theme, self._frame.Background),
	}

	self._editKeyFrame = Assets.Views.EditKey:Clone()
	self._editKeyFrame.Position = UDim2.fromOffset(0, 0)
	self._editKeyFrame.Size = UDim2.fromScale(1, 1)
	self._editKeyFrame.Parent = self._widget
	self._editKeyView = nil :: any

	self._deleted = Instance.new("BindableEvent")
	self.deleted = self._deleted.Event :: RBXScriptSignal

	local success, err = (self :: any):_trySwapEditKeyView(params.keyName)
	if not success then
		return nil, err
	end
	self._widget.Enabled = true

	self._connections = {
		self._widget.Destroying:Connect(function()
			self:destroy()
		end),

		self._widget:GetPropertyChangedSignal("Enabled"):Connect(function()
			if not self._widget.Enabled then
				self:destroy()
			end
		end),

		self._theme.colorsChanged:Connect(function()
			StyleStateHelper.update(self._styleStates :: any)
		end),
	}

	return self
end

function SingleKeyWidget:_trySwapEditKeyView(keyName: string, version: string?, session: Session.Session?)
	local success: boolean, err: string?
	local forkedSession = session or self._session:fork()
	if version then
		success, err = forkedSession:tryLoadVersion(keyName, version)
	else
		success, err = forkedSession:tryLoadKey(keyName)
	end

	task.wait()

	if not success then
		return false, err
	end

	local versionsView = nil

	if self._editKeyView then
		-- preserve the versions view if it is the same session
		if self._editKeyView.session == session then
			versionsView = self._editKeyView.versionsView
			versionsView:hide(true)
		else
			self._editKeyView.versionsView:destroy()
		end

		self._editKeyView:destroy()
	end

	if not versionsView then
		versionsView = VersionsView.new({
			theme = self._theme,
			uiMessages = self._uiMessages,
			widget = self._widget,
			container = self._editKeyFrame,
			session = forkedSession,
			loadVersion = function(version: string)
				local editKeyView = self._editKeyView
				if not editKeyView then return false end

				return editKeyView:handleLoadVersion(version)
			end,
		})
	end

	local editKeyView = EditKeyView.from({
		theme = self._theme,
		uiMessages = self._uiMessages,
		frame = self._editKeyFrame,
		inputReceiver = self._frame.HotkeyInputReceiver,
		session = forkedSession,
		transactor = self._transactor,
		versionsView = versionsView,
		swapCallback = function(keyName, version, session)
			return self:_trySwapEditKeyView(keyName, version, session)
		end,
		isVersionLoaded = not not version,
		openWithVersionsView = not not session,
	})

	editKeyView.deleted:Connect(function()
		self._deleted:Fire()
		self:destroy()
	end)

	editKeyView.idLookedUp:Connect(function(id)
		-- TODO: add this feature
		self._uiMessages:warn("This feature is not yet implemented for single key widgets.")
		--self:_tryLookupId(id)
	end)

	self._editKeyView = editKeyView

	self._widget.Title = if self._session.connection.scope and (self._session.connection.scope ~= "")
		then `{self._session.connection.name}/{self._session.connection.scope}/{keyName}`
		else `{self._session.connection.name}/{keyName}`

	return true, nil
end

function SingleKeyWidget:destroy()
	StyleStateHelper.destroy(self._styleStates :: any)

	self._uiMessages:destroy()
	self._editKeyView:destroy(true)

	if self._widget.Parent then
		self._widget:Destroy()
	end

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

	table.clear(self :: any)
end

return SingleKeyWidget


local rfmt = require(script.Parent.rfmt)

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

local PopupHelper = require(script.PopupHelper)
local Tooltip = require(script.Tooltip)

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

local StyleStateWrapper = require(StyleState.StyleStateWrapper)
local StyleStateHelper = require(StyleState.StyleStateHelper)

local BackgroundStyleState = require(StyleState.BackgroundStyleState)
local ScrollingFrameStyleState = require(StyleState.ScrollingFrameStyleState)
local BreadcrumbsStyleState = require(StyleState.BreadcrumbsStyleState)
local ButtonStyleState = require(StyleState.ButtonStyleState)
local SeparatorStyleState = require(StyleState.SeparatorStyleState)
local LabelStyleState = require(StyleState.LabelStyleState)
local LoadMoreListStyleState = require(StyleState.LoadMoreListStyleState)
local TabsStyleState = require(StyleState.TabsStyleState)

local SelectStyleState = require(StyleState.Input.SelectStyleState)
local ShortcutsTextBoxStyleState = require(StyleState.Input.ShortcutsTextBoxStyleState)

local SkeletonStyleState = require(StyleState.Utility.SkeletonStyleState)
local ContextMenuStyleState = require(StyleState.Utility.ContextMenuStyleState)

local OrderedDataStoreView = require(script.Views.OrderedDataStoreView)
local SettingsView = require(script.Views.SettingsView)
local EditKeyView = require(script.Views.EditKeyView)

local createTabSwitchGroup = require(script.Utilities.createTabSwitchGroup)
local showShadowOnScroll = require(script.Utilities.showShadowOnScroll)

local UI = {}
UI.__index = UI

function UI.new(theme: Theme.Theme, uiMessages)
	local gui = Assets.Widgets.MainApp:Clone()
	local self = setmetatable({
		theme = theme,
		parent = nil,
		gui = gui,
		uiMessages = uiMessages,

		connections = {},

		activeView = nil,
	}, UI)

	self.styleStates = {
		headerBackground = BackgroundStyleState.from(theme, gui.HeaderBackground, { style = "header" }),
		background = BackgroundStyleState.from(theme, gui.Background),
		breadcrumbs = BreadcrumbsStyleState.from(theme, gui.Breadcrumbs),
		breadcrumbsSeparator = SeparatorStyleState.from(theme, gui.BreadcrumbsSeparator),
		settingsButton = ButtonStyleState.from(theme, gui.SettingsButton, {
			style = "dormant",
		}),

		connect = {
			scrollingFrame = ScrollingFrameStyleState.from(theme, gui.ConnectDataStore.Content),
			tabs = TabsStyleState.from(theme, gui.ConnectDataStore.Content.Form.Tabs),

			prefix = ShortcutsTextBoxStyleState.from(theme, gui.ConnectDataStore.Content.Form.Prefix.TextBox),
			listButton = ButtonStyleState.from(theme, gui.ConnectDataStore.Content.Form.Prefix.List, {
				style = "primary",
			}),

			connectButton = ButtonStyleState.from(theme, gui.ConnectDataStore.Content.Form.Connect.Connect, {
				style = "primary",
			}),
			dataStoreNameTitle = LabelStyleState.from(theme, gui.ConnectDataStore.Content.Form.DataStoreName.Title),
			dataStoreNameTextBox = ShortcutsTextBoxStyleState.from(
				theme,
				gui.ConnectDataStore.Content.Form.DataStoreName.TextBox
			),

			dataStoreScopeTitle = LabelStyleState.from(theme, gui.ConnectDataStore.Content.Form.DataStoreScope.Title),
			dataStoreScopeTextBox = ShortcutsTextBoxStyleState.from(
				theme,
				gui.ConnectDataStore.Content.Form.DataStoreScope.TextBox
			),

			dataStoreTypeTitle = LabelStyleState.from(
				theme,
				gui.ConnectDataStore.Content.Form.DataStoreType.Title
			),
			dataStoreTypeSelect = SelectStyleState.from(
				theme,
				gui.ConnectDataStore.Content.Form.DataStoreType.Select,
				{
					default = "Normal",
					fitButton = true,
					options = {
						{ icon = theme.icons.datastore, name = "Normal" },
						{ icon = theme.icons.orderedDatastore, name = "Ordered" },
						{ icon = theme.icons.allScopesDatastore, name = "AllScopes" },
						{ icon = theme.icons.globalDatastore, name = "Global" },
					}
				}
			),

			separators = SeparatorStyleState.fromDescendants(theme, gui.ConnectDataStore),

			loadMore = LoadMoreListStyleState.from(theme, gui.ConnectDataStore.Content.DataStoreList.Table, {
				createItemStyleState = function(loadMoreListStyleState, info: DataStoreInfo)
					local button = Assets.DataStoreRow:Clone()
					button.DataStore.Text = info.DataStoreName
					button.Created.Text = DateTime.fromUnixTimestampMillis(info.CreatedTime)
						:FormatLocalTime("ll LT", "en-us")
					button.Updated.Text = DateTime.fromUnixTimestampMillis(info.UpdatedTime)
						:FormatLocalTime("ll LT", "en-us")

					button.Parent = loadMoreListStyleState.frame

					button.Activated:Connect(function()
						self._dataStoreInfoSelected:Fire(info)
					end)

					return ButtonStyleState.from(theme, button, {
						style = "transparent",
					})
				end,
			}),

			datastoreTableLabels = LabelStyleState.fromDescendants(theme, gui.ConnectDataStore.Content.DataStoreList),
		},
		browse = {
			scrollingFrame = ScrollingFrameStyleState.from(theme, gui.BrowseKeys.Keys),
			key = ShortcutsTextBoxStyleState.from(theme, gui.BrowseKeys.Key),

			separators = SeparatorStyleState.fromDescendants(theme, gui.BrowseKeys),

			prefix = ShortcutsTextBoxStyleState.from(theme, gui.BrowseKeys.Prefix),
			listButton = ButtonStyleState.from(theme, gui.BrowseKeys.List, {
				style = "primary",
			}),
			loadMore = LoadMoreListStyleState.from(theme, gui.BrowseKeys.Keys.Table, {
				createItemStyleState = function(loadMoreListStyleState, info: DataStoreKey)
					local button = Assets.KeyRow:Clone()
					local styleState = ButtonStyleState.from(theme, button, {
						style = "transparent",
					})
					button.Key.Text = info.KeyName

					button.Parent = loadMoreListStyleState.frame

					button.Activated:Connect(function()
						self._keySelected:Fire(info)
					end)

					button.MouseButton2Click:Connect(function()
						local resetSelecting = loadMoreListStyleState:setSelectingStyleState(styleState)
						local option = ContextMenuStyleState.prompt(
							self.theme,
							self.parent,
							self.parent:GetRelativeMousePosition(),
							{
								options = {
									{ icon = self.theme.icons.import, text = "Load" },
									{ icon = self.theme.icons.lookup, text = "Lookup Id" },
									{ icon = self.theme.icons.delete, text = "Delete" },
									ContextMenuStyleState.SEPARATOR,
									{ icon = self.theme.icons.externalLink, text = "Open in New Widget" },
								},
							}
						)
						resetSelecting()

						if option == "Load" then
							self.styleStates.browse.key.textBox.Text = info.KeyName:gsub("@", "\\@")
							self.styleStates.browse.key.textBox:CaptureFocus()
						elseif option == "Lookup Id" then
							self._idLookedUp:Fire(info)
						elseif option == "Delete" then
							self._keyDeleted:Fire(info)
						elseif option == "Open in New Widget" then
							self._keySelected:Fire(info, true)
						end
					end)

					return styleState, info.KeyName
				end,
			}),

			separator = SeparatorStyleState.from(theme, gui.BrowseKeys.Separator),

			datastoreTableLabels = LabelStyleState.fromDescendants(theme, gui.BrowseKeys.Keys),
		},
	}

	-- Views

	Assets.Views.EditKey:Clone().Parent = gui

	self.orderedDataStoreView = OrderedDataStoreView.from(theme, uiMessages, gui.EditOrderedDataStore)
	self.settingsView = SettingsView.from(theme, uiMessages, gui.Settings)

	showShadowOnScroll(gui.BrowseKeys.Keys, gui.BrowseKeys.ScrollShadow)

	-- Extra setting up
	self.styleStates.connect.tabs:select("Connect")
	self:switchConnectTab("connect")

	-- Hack to get AutomaticSize to not mess up since the overlay textlabel messes with that
	-- This also causes the overlay text label to not render when the user has typed a lot of text and is far from its origin
	self.styleStates.connect.dataStoreNameTextBox.overlay:GetPropertyChangedSignal("Size"):Connect(function(size)
		if self.styleStates.connect.dataStoreNameTextBox.overlay.Size.X.Scale ~= 1 then
			self.styleStates.connect.dataStoreNameTextBox.overlay.Size = UDim2.fromScale(1, 1)
		end
	end)
	self.styleStates.connect.dataStoreScopeTextBox.overlay:GetPropertyChangedSignal("Size"):Connect(function(size)
		if self.styleStates.connect.dataStoreScopeTextBox.overlay.Size.X.Scale ~= 1 then
			self.styleStates.connect.dataStoreScopeTextBox.overlay.Size = UDim2.fromScale(1, 1)
		end
	end)

	-- Tab switch groups

	createTabSwitchGroup({
		self.styleStates.connect.dataStoreNameTextBox.textBox,
		self.styleStates.connect.dataStoreScopeTextBox.textBox,
	})

	createTabSwitchGroup({
		self.styleStates.browse.key.textBox,
		self.styleStates.browse.prefix.textBox,
	})

	-- Add tooltips to everything

	Tooltip.bind(self.theme, self.gui.SettingsButton, "Settings", {
		on = self.gui.SettingsButton.ImageLabel,
		offsetAnchor = Vector2.new(1, 1),
		tooltipAnchor = Vector2.new(1, 0),
	})

	--[=[ disabling these they don't work well
	Tooltip.bindButtonGeneric(
		self.theme,
		self.gui.ConnectDataStore.Content.Form.Connect.Connect,
		`This is equivalent to a call to {rfmt.code("GetDataStore")}`
	)

	-- MouseButton1 event doesn't trigger on textboxes
	--[[Tooltip.bind(self.gui, self.gui.ConnectDataStore.Content.Form.Prefix.TextBox,
		"Prefix",
		"List only DataStores that begin with a certain prefix.")]]
	Tooltip.bindButtonGeneric(
		self.theme,
		self.gui.ConnectDataStore.Content.Form.Prefix.List,
		`List all DataStores in this game. Does not include OrderedDataStores. This is equivalent to a call to {rfmt.code(
			"ListDataStoresAsync"
		)}.`
	)

	-- MouseButton1 event doesn't trigger on textboxes
	--Tooltip.bind(self.gui, self.gui.BrowseKeys.Key, "Get Key", "Equivalent to a call to GetAsync.")
	Tooltip.bindButtonGeneric(
		self.theme,
		self.gui.BrowseKeys.List,
		`List all keys in the DataStore. Equivalent to a call to {rfmt.code("ListKeysAsync")}.`
	)]=]

	-- Events

	self._dataStoreInfoSelected = Instance.new("BindableEvent")
	self.dataStoreInfoSelected = self._dataStoreInfoSelected.Event

	self._keySelected = Instance.new("BindableEvent")
	self.keySelected = self._keySelected.Event

	self._idLookedUp = Instance.new("BindableEvent")
	self.idLookedUp = self._idLookedUp.Event

	-- Fired when a key is deleted from the list
	self._keyDeleted = Instance.new("BindableEvent")
	self.keyDeleted = self._keyDeleted.Event

	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
	self.connectionRecordDeleted = self._connectionRecordDeleted.Event
	self.connectionRecordPinChanged = self._connectionRecordPinChanged.Event
	self.connnectionRecordLoaded = self._connectionRecordLoaded.Event

	self._connectionRecordStyleStates = {}

	table.insert(
		self.connections,
		theme.colorsChanged:Connect(function()
			task.wait()
			StyleStateHelper.update(self.styleStates, "slow")

			for _, connectionRecordStyleStates in self._connectionRecordStyleStates do
				connectionRecordStyleStates:update("slow")
			end
		end)
	)

	return self
end

function UI:setParent(parent: LayerCollector)
	self.gui.Parent = parent
	self.parent = parent
end

function UI:setActiveView(view: "game" | "datastore" | "key" | "orderedDatastore" | "settings")
	if self.activeView == "settings" and view ~= "settings" then
		self.settingsView:afterClose()
	elseif self.activeView ~= "settings" and view == "settings" then
		self.settingsView:beforeOpen()
	end

	self.activeView = view
	local frames = {
		game = self.gui.ConnectDataStore,
		orderedDatastore = self.gui.EditOrderedDataStore,
		settings = self.gui.Settings,
		datastore = self.gui.BrowseKeys,
		key = self.gui.EditKey,
	}
	for name, frame in frames do
		frame.Visible = (name == view)
	end

	self:autoFocus()

	if view == "game" then
		-- Window might've resized, so update this if it's too small to do center alignment
		self:_updateConnectCentering()
	end
end

function UI:autoFocus()
	-- Delay to ignore the key press if enter was pressed
	task.delay(0, function()
		if self.parent and self.parent.Enabled then
			if self.activeView == "game" then
				if self.styleStates.connect.tabs:isSelecting("connect") then
					self.styleStates.connect.dataStoreNameTextBox.textBox:CaptureFocus()
				elseif self.styleStates.connect.tabs:isSelecting("list") then
					self.styleStates.connect.prefix.textBox:CaptureFocus()
				end
			elseif self.activeView == "datastore" then
				self.styleStates.browse.key.textBox:CaptureFocus()
			elseif self.activeView == "orderedDatastore" then
				self.orderedDataStoreView.styleStates.getKey.textBox:CaptureFocus()
			end
		end
	end)
end

function UI:updateSettingsButtonPosition()
	if self.gui.Settings.Visible or self.gui.Breadcrumbs.Visible then
		self.styleStates.settingsButton.button.Parent = self.gui
		self.styleStates.settingsButton.button.Position = UDim2.new(1, -4, 0, 3)
	else
		self.styleStates.settingsButton.button.Parent =
			self.gui.ConnectDataStore.Content["Put settings button here when breadcrumbs is disabled"]
		self.styleStates.settingsButton.button.Position = UDim2.new(1, -3, 0, 6)
	end
end

local BODY_FRAMES = { "BrowseKeys", "ConnectDataStore", "EditKey", "EditOrderedDataStore" }
function UI:showBreadcrumbs()
	if self.gui.Breadcrumbs.Visible then
		return
	end

	self.gui.Breadcrumbs.Visible = true
	self.gui.HeaderBackground.Visible = true
	self.gui.BreadcrumbsSeparator.Visible = true

	self:updateSettingsButtonPosition()

	for _, frame in BODY_FRAMES do
		self.gui[frame].Size = UDim2.new(1, 0, 1, -34)
		self.gui[frame].Position = UDim2.fromOffset(0, 34)
	end
end

function UI:hideBreadcrumbs()
	if not self.gui.Breadcrumbs.Visible then
		return
	end

	self.gui.Breadcrumbs.Visible = false
	self.gui.HeaderBackground.Visible = false
	self.gui.BreadcrumbsSeparator.Visible = false

	self:updateSettingsButtonPosition()

	for _, frame in BODY_FRAMES do
		self.gui[frame].Size = UDim2.new(1, 0, 1, 0)
		self.gui[frame].Position = UDim2.fromOffset(0, 0)
	end
end

function UI:updateBreadcrumbsVisibility()
	if #self.styleStates.breadcrumbs.crumbs > 1 then
		self:showBreadcrumbs()
	else
		self:hideBreadcrumbs()
	end
end

function UI:_updateConnectCentering()
	if
		(
			self.gui.ConnectDataStore.Content.DataStoreList.Visible
			and self.gui.ConnectDataStore.Content.DataStoreList.Table.Visible
			and self.styleStates.connect.tabs:isSelecting("list")
		) or (self.styleStates.connect.tabs:isSelecting("history"))
	then
		self.gui.ConnectDataStore.Content.UIListLayout.HorizontalAlignment = Enum.HorizontalAlignment.Left
		self.gui.ConnectDataStore.Content.UIListLayout.VerticalAlignment = Enum.VerticalAlignment.Top
	else
		self.gui.ConnectDataStore.Content.UIListLayout.HorizontalAlignment = Enum.HorizontalAlignment.Center
		self.gui.ConnectDataStore.Content.UIListLayout.VerticalAlignment = Enum.VerticalAlignment.Center
	end

	-- Gui too small for center aligment
	if self.gui.AbsoluteSize.X <= 360 then
		self.gui.ConnectDataStore.Content.UIListLayout.HorizontalAlignment = Enum.HorizontalAlignment.Left
	end
end

function UI:updateConnectOptions()
	local datastoreType = self.styleStates.connect.dataStoreTypeSelect.selection:lower()
	self.gui.ConnectDataStore.Content.Form.DataStoreType.Visible = true
	self.gui.ConnectDataStore.Content.Form.DataStoreType.Size = UDim2.fromOffset(200, 0)
	if datastoreType == "normal" then
		self.gui.ConnectDataStore.Content.Form.DataStoreName.Visible = true
		self.gui.ConnectDataStore.Content.Form.DataStoreScope.Visible = true
	elseif datastoreType == "ordered" then
		self.gui.ConnectDataStore.Content.Form.DataStoreName.Visible = true
		self.gui.ConnectDataStore.Content.Form.DataStoreScope.Visible = true
	elseif datastoreType == "allscopes" then
		self.gui.ConnectDataStore.Content.Form.DataStoreName.Visible = true
		self.gui.ConnectDataStore.Content.Form.DataStoreScope.Visible = false
	elseif datastoreType == "global" then
		self.gui.ConnectDataStore.Content.Form.DataStoreName.Visible = false
		self.gui.ConnectDataStore.Content.Form.DataStoreScope.Visible = false
		-- stop it from shrinking since the form alignment will change
		self.gui.ConnectDataStore.Content.Form.Size = UDim2.fromOffset(360, 0)
	end
end

function UI:switchConnectTab(tab: "connect" | "list" | "history")
	local connectVisible = (tab == "connect")
	local listVisible = (tab == "list")
	local historyVisible = (tab == "history")

	self.gui.ConnectDataStore.Content.Form.Connect.Visible = connectVisible
	if connectVisible then
		self:updateConnectOptions()
	else
		self.gui.ConnectDataStore.Content.Form.DataStoreName.Visible = false
		self.gui.ConnectDataStore.Content.Form.DataStoreScope.Visible = false
		self.gui.ConnectDataStore.Content.Form.DataStoreType.Visible = false
	end

	self.gui.ConnectDataStore.Content.Form.Prefix.Visible = listVisible
	self.gui.ConnectDataStore.Content.DataStoreList.Visible = listVisible

	self.gui.ConnectDataStore.Content.History.Visible = historyVisible

	self:_updateConnectCentering()
end

function UI:updateHistoryTabVisible(visible: boolean)
	self.styleStates.connect.tabs.frame.History.Visible = visible
	if not visible then
		if self.styleStates.connect.tabs:isSelecting("history") then
			self.styleStates.connect.tabs:select("Connect")
		end
	end
end

-- Returns a style state that starts off with a skeleton
-- Call :populate on this styleState to populate it with info
-- Call :abort to delete the styleState
-- Automatically handles destruction on the UI side
function UI:createUserCard()
	local card = Assets.Cards.UserCard:Clone()
	local skeletonStyleState = SkeletonStyleState.from(self.theme, card)
	local styleState = StyleStateWrapper.new(self.theme, {
		background = BackgroundStyleState.from(self.theme, card, {
			style = "popup",
		}),
		imageBackground = BackgroundStyleState.from(self.theme, card.ImageLabel, {
			style = "image",
		}),
		username = LabelStyleState.from(self.theme, card.Frame.Username),
		id = LabelStyleState.from(self.theme, card.Frame.Id),
		displayName = LabelStyleState.from(self.theme, card.Frame.DisplayName),
		verifiedBadge = LabelStyleState.from(self.theme, card.Frame.VerifiedBadge),
		skeletonStyleState = skeletonStyleState,
	})
	task.defer(function()
		skeletonStyleState:update("instant")
		skeletonStyleState:animate()
	end)

	local modal = PopupHelper.modal(card, self.gui.Parent)

	local function cleanup()
		if card.Parent then
			modal:destroy()
			styleState:destroy()
			skeletonStyleState:destroy()
			card:Destroy()
		end
	end

	modal.cancelled:Once(cleanup)

	function styleState:abort()
		cleanup()
	end

	function styleState:populate(
		thumbnailUrl: string,
		info: {
			Id: number,
			Username: string,
			DisplayName: string,
			HasVerifiedBadge: boolean,
		}
	)
		if card.Parent then
			skeletonStyleState:destroy()
			styleState.styleStates.imageBackground:update("instant")
			card.ImageLabel.Image = thumbnailUrl
			card.Frame.Username.Text = `@{info.Username}`
			card.Frame.Id.Text = `User Id: {info.Id}`
			card.Frame.DisplayName.Text = `Display Name: {info.DisplayName}`
			card.Frame.VerifiedBadge.Text = `Has Verified Badge: {info.HasVerifiedBadge}`
		end
	end

	return styleState
end

function UI:destroy()
	for _, connection in self.connections do
		connection:Disconnect()
	end
	for _, styleStates in self._connectionRecordStyleStates do
		styleStates:destroy(true)
	end
	self._connectionRecordStyleStates = nil

	self.settingsView:destroy()
	self.orderedDataStoreView:destroy()
end

export type UI = typeof(UI.new(Theme.global))
return UI


--!strict

export type Validator = (any) -> (boolean, string?) -- return 1: success, return 2: reason
local Validators: { [string]: Validator } = {}

local function isValidInteger(str: unknown): boolean
	local number = tonumber(str)
	if number then
		local isValidInteger = (number == math.floor(number)) and number == number and (math.abs(number) ~= math.huge)
		return isValidInteger
	end
	return false
end

Validators.empty = function(_): (boolean, string?)
	return true, nil
end

Validators.integer = function(a: any): (boolean, string?)
	if isValidInteger(a) then
		return true, nil
	else
		return false, "Must be an integer."
	end
end

Validators.positiveNonZeroInteger = function(a: any): (boolean, string?)
	if isValidInteger(a) then
		if tonumber(a) > 0 then
			return true, nil
		else
			return false, "Must be more than zero."
		end
	else
		return false, "Must be more than zero."
	end
end

Validators.userId = function(text: unknown): (boolean, string?)
	if isValidInteger(text) then
		local number = tonumber(text)
		if number > 0 then
			return true, nil
		else
			return false, "UserId must be a positive integer."
		end
	else
		return false, "UserId must be a positive integer."
	end
end

Validators.dataStoreName = function(text: string): (boolean, string?)
	if #text > 50 then
		return false, "DataStore name must be 50 characters or less."
	else
		return true, nil
	end
end

Validators.dataStoreScope = function(text: string): (boolean, string?)
	if #text > 50 then
		return false, "DataStore scope must be 50 characters or less."
	else
		return true, nil
	end
end

Validators.dataStoreKey = function(text: string): (boolean, string?)
	if #text > 50 then
		return false, "Key name must be 50 characters or less."
	else
		return true, nil
	end
end

Validators.orderedDataStoreValue = function(text: string): (boolean, string?)
	if not isValidInteger(text) then
		return false, "Value must be an integer."
	else
		return true, nil
	end
end

Validators.allScopesKey = function(text: string): (boolean, string?)
	local split = text:split("/")
	if #split < 2 then
		return false, "AllScopes keys must be in the format scope/key."
	else
		local scope = split[1]
		local key = table.concat(split, "", 2)
		if scope == "" then
			return false, "Scope must be provided. Default scope is global."
		elseif key == "" then
			return false, "Key cannot be empty."
		elseif #key > 50 then
			return false, "Key name must be 50 characters or less."
		elseif #scope > 50 then
			return false, "Scope name must be 50 characters or less."
		else
			return true, nil
		end
	end
end

return Validators


--!strict

type App = {
	new: (id: string) -> App,

	toggleEnabled: (App) -> (),
	getEnabled: (App) -> boolean,
	enabledChanged: RBXScriptSignal,
}

local IS_CANARY = script.Parent.IsCanaryRelease.Value

local RunService = game:GetService("RunService")

local App = require(script.Parent.App) :: App
local ClientFallbackApp = require(script.Parent.ClientFalbackApp) :: App

local History = require(script.Parent.History).global
local Settings = require(script.Parent.Settings)
local Theme = require(script.Parent.UI.Theme)

local globalApp: App
local appName = if IS_CANARY then "DataDelve Canary" else "DataDelve"

local function initSettings()
	History:load(plugin)
	Settings.load(plugin)
	Theme.global:setPreset(Settings.data.themePreset)
	Theme.global:setAccent(Settings.data.themeAccent)

	Settings.changed:Connect(function(key, value)
		if key == "themePreset" then
			Theme.global:setPreset(value)
		elseif key == "themeAccent" then
			Theme.global:setAccent(value)
		end
	end)
end

local function initApp()
	-- Use the ClientFallbackApp when play testing as a client because it does not
	-- have access to the DataStore APIs.
	if RunService:IsRunning() and RunService:IsClient() then
		globalApp = ClientFallbackApp.new(appName)
	else
		globalApp = App.new(appName)
	end
end

local function initPluginToolbar()
	local toolbar: PluginToolbar = plugin:CreateToolbar(appName)
	local pluginButton: PluginToolbarButton = toolbar:CreateButton(
		appName,
		"easy interface for datastores",
		if IS_CANARY then "rbxassetid://18476887048" else "rbxassetid://18476917665"
	)
	pluginButton.ClickableWhenViewportHidden = true

	plugin.Unloading:Connect(function()
		History:save(plugin)
		Settings.save(plugin)
	end)

	pluginButton.Click:Connect(function()
		globalApp:toggleEnabled()
	end)
	globalApp.enabledChanged:Connect(function(enabled: boolean)
		pluginButton:SetActive(enabled)
	end)
	pluginButton:SetActive(globalApp:getEnabled())
end

local function init()
	initSettings()
	initApp()
	initPluginToolbar()
end

init()


-- Rich-Text ForMaT
-- Convience functions for wrapping things in rich text tags.

local rfmt = {}

function rfmt.code(s: string): string
	return `<font face="RobotoMono">{s}</font>`
end

return rfmt


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

assert(JSONHelper.toInputString({}, "array") == "[]")
assert(JSONHelper.toInputString({ 1, 2, 3, 4, 5, 6 }, "array") == "[]")

assert(JSONHelper.toInputString({}, "object") == "{}")
assert(JSONHelper.toInputString({ hey = "world" }, "object") == "{}")

assert(JSONHelper.toInputString(nil, "null") == "null")

assert(JSONHelper.toInputString("", "string") == "")
assert(JSONHelper.toInputString("hello world", "string") == "hello world")
assert(JSONHelper.toInputString(`"`, "string") == [["\""]])
assert(JSONHelper.toInputString(`'`, "string") == [["'"]])
assert(JSONHelper.toInputString(`"""""""""`, "string") == [["\"\"\"\"\"\"\"\"\""]])
assert(JSONHelper.toInputString("[]", "string") == `"[]"`)
assert(JSONHelper.toInputString("{}", "string") == '"{}"')
assert(JSONHelper.toInputString("[1, 2, 3]", "string") == [["[1, 2, 3]"]])

assert(JSONHelper.toInputString(buffer.create(1), "buffer") == "")


--!strict

-- Actions to generate random JSON+buffer-acceptable value
type Action = (depth: number) -> any

local MAX_DEPTH = 10

local actions: { Action }

local stringAction: Action = function()
	local s = table.create(12)
	while true do
		local got = math.random()
		if got < 0.3 then
			break
		else
			table.insert(s, string.char(math.random(0x21, 0x7E)))
		end
	end
	return table.concat(s)
end

actions = {
	stringAction,
	-- buffer
	function()
		local b = buffer.create(math.random(0, 10))
		for i = 1, buffer.len(b) do
			buffer.writeu8(b, i - 1, math.random(0, 2 ^ 8 - 1))
		end
		return b
	end,
	-- boolean
	function()
		return math.random() >= 0.5
	end,
	-- number
	function()
		return math.random()
	end,
	-- array
	function(depth)
		if depth > 2 * MAX_DEPTH then
			return 1
		elseif depth > MAX_DEPTH then
			return actions[math.random(1, #actions)](depth + 1)
		end

		local len = math.random(0, 5)
		local array = table.create(len)

		for i = 1, len do
			array[i] = actions[math.random(1, #actions)](depth + 1)
		end

		return array
	end,
	-- object
	function(depth)
		if depth > 2 * MAX_DEPTH then
			return 1
		elseif depth > MAX_DEPTH then
			return actions[math.random(1, #actions)](depth + 1)
		end

		local len = math.random(0, 5)
		local object = {}

		for i = 1, math.random(0, 10) do
			object[stringAction(depth)] = actions[math.random(1, #actions)](depth + 1)
		end

		return object
	end,
}

return function(maxDepth: number?)
	return actions[math.random(1, #actions)](MAX_DEPTH - (maxDepth or MAX_DEPTH))
end


--!strict

return function()
	local ids = {}
	for _ = 1, math.random(0, 100) do
		table.insert(ids, math.random(1, 10_000_000))
	end
	return ids
end