-- If the user is play-testing on the client, fallback to this since the plugin won't work
local plugin = script:FindFirstAncestorOfClass("Plugin")

local ClientFallbackApp = {}
ClientFallbackApp.__index = ClientFallbackApp

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

local StyleState = script.Parent.UI.StyleState
local BackgroundStyleState = require(StyleState.BackgroundStyleState)
local LabelStyleState = require(StyleState.LabelStyleState)

local WIDGET_INFO = DockWidgetPluginGuiInfo.new(
	Enum.InitialDockState.Float, -- Widget will be initialized in floating panel
	false, -- Widget will be initially enabled
	false, -- Don't override the previous enabled state
	400, -- Default width of the floating window
	250, -- Default height of the floating window
	350, -- Minimum width of the floating window
	230 -- Minimum height of the floating window
)

function ClientFallbackApp.new(id: string)
	local self = setmetatable({
		id = id,
	}, ClientFallbackApp)

	self.widget = plugin:CreateDockWidgetPluginGuiAsync(id, WIDGET_INFO)
	self.widget.Title = id
	self.widget.Name = id
	self.widget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling

	self.background = script.Background:Clone()
	self.background.Parent = self.widget

	self.styleStates = {
		background = BackgroundStyleState.from(Theme, self.background),
		imageLabel = LabelStyleState.from(Theme, self.background.ImageLabel),
		textLabel = LabelStyleState.from(Theme, self.background.TextLabel),
	}

	-- Enabled
	self._enabledChanged = Instance.new("BindableEvent")
	self.enabledChanged = self._enabledChanged.Event
	self.widget:GetPropertyChangedSignal("Enabled"):Connect(function()
		self._enabledChanged:Fire(self.widget.Enabled)
	end)

	return self
end

function ClientFallbackApp:toggleEnabled()
	self.widget.Enabled = not self.widget.Enabled
end

function ClientFallbackApp:getEnabled()
	return self.widget.Enabled
end

return ClientFallbackApp


--!strict

-- Stuff for deserialization with buffers.

local plugin = script:FindFirstAncestorOfClass("Plugin")

local DESERIALIZATION_KEY = "DataDelve_Deserialization"

type PlaceSettings = {
	-- path to the default deserializer
	defaultDeserializer: {string}?,
}

type DeserializationSettings = {
	[--[[placeId:]] string]: PlaceSettings
}

export type FunctionDeserializer = {
	type: "function",
	deserialize: (buf: buffer) -> unknown,
	serialize: (data: unknown) -> buffer,
}
export type Editor = {
	object: GuiObject,
	getValue: () -> buffer?,
	cleanup: (() -> ())?,
}
export type EditorDeserializer = {
	type: "editor",
	createEditor: (buf: buffer) -> Editor,
}
export type Deserializer = FunctionDeserializer | EditorDeserializer

local DeserializeState = {}

local placeSettings: PlaceSettings do
	local got = plugin:GetSetting(DESERIALIZATION_KEY)
	if got then
		placeSettings = got[tostring(game.PlaceId)]
	end

	if not placeSettings then
		placeSettings = {
			defaultDeserializer = nil,
		}
	end
end

local function getPath(instance: Instance): {string}
	local path = {}
	local current: Instance? = instance
	while current and current ~= game do
		table.insert(path, 1, current.Name)
		current = current.Parent
	end
	return path
end

local function takePath(path: {string}): Instance?
	local current = game
	for _, name in path do
		current = current:FindFirstChild(name)
		if not current then
			break
		end
	end
	return current
end

function DeserializeState.getDefaultDeserializerScript(): Instance?
	if placeSettings.defaultDeserializer then
		return takePath(placeSettings.defaultDeserializer)
	end
	return nil
end

-- returns modules script or error message if none
function DeserializeState.getDefaultDeserializer(): ({name: string, deserializer: Deserializer}?, string?)
	if placeSettings.defaultDeserializer then
		local name = table.concat(placeSettings.defaultDeserializer, ".")
		local got = takePath(placeSettings.defaultDeserializer)
		if got and got:IsA("ModuleScript") then
			local deserializer, err = DeserializeState.tryLoadDeserializer(got)
			if err then
				return nil, `Invalid value for {name}. {err}`
			end

			return {
				name = name,
				deserializer = deserializer :: Deserializer,
			}, nil
		elseif got then
			return nil, `{name} is not a ModuleScript.`
		else
			return nil, `{name} was not found.`
		end
	end
	return nil, nil
end

function DeserializeState.trySetDefaultDeserializer(module: ModuleScript)
	if not module:IsDescendantOf(game) then
		return
	end

	local gotKey = plugin:GetSetting(DESERIALIZATION_KEY)
	local newKey: DeserializationSettings
	local newSettings: PlaceSettings = {
		defaultDeserializer = getPath(module),
	}

	if gotKey then
		gotKey[tostring(game.PlaceId)] = newSettings
		newKey = gotKey
	else
		newKey = {
			[tostring(game.PlaceId)] = newSettings
		}
	end

	placeSettings = newSettings
	plugin:SetSetting(DESERIALIZATION_KEY, newKey)
end

function DeserializeState.tryLoadDeserializer(module: ModuleScript): (Deserializer?, string?)
	local success, result = pcall(function()
		return require(module) :: any
	end)
	if not success then
		return nil, `Error while requiring: {result}`
	end

	if typeof(result) ~= "table" then
		return nil, "Must return a table."
	end

	local createEditor = result.createEditor or result.CreateEditor
	if createEditor then
		if typeof(createEditor) ~= "function" then
			return nil, "`getEditor` must be a function."
		end

		return {
			type = "editor",
			createEditor = createEditor,
		}
	end

	local got: FunctionDeserializer = {
		type = "function",
		deserialize = result.deserialize or result.Deserialize,
		serialize = result.serialize or result.Serialize
	}

	if typeof(got.deserialize) ~= "function" then
		return nil, "Must have function named `deserialize` that takes a single buffer argument and returns a value."
	elseif typeof(got.serialize) ~= "function" then
		return nil, "Must have function named `serialize` that takes a single buffer argument and returns a buffer."
	end

	return got, nil
end

return DeserializeState


local plugin = script:FindFirstAncestorWhichIsA("Plugin")

local SelectionService = game:GetService("Selection")
local StudioService = game:GetService("StudioService")
local HttpService = game:GetService("HttpService")

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

local STRING_PROPERTY_SIZE_LIMIT = 200_000

local ExportImportHelper = {}

-- Returns if the export went through
-- Optionally returns an error message
function ExportImportHelper.export(fileName: string, value: any): (boolean, string?)
	if RunService:IsRunning() then
		return false, "Cannot export while game is running."
	end

	local originalSelection = SelectionService:Get()

	local json: string
	local success, err = pcall(function()
		json = HttpService:JSONEncode(value)
	end)
	if not success then
		return false, err
	end

	local moduleScript = Instance.new("ModuleScript")

	moduleScript.Archivable = false
	moduleScript.Name = "DataDelve Export"

	-- Write to source
	local success, err = pcall(function()
		if #json > STRING_PROPERTY_SIZE_LIMIT then
			for i = 1, (math.ceil(#json / STRING_PROPERTY_SIZE_LIMIT)) do
				local slice = json:sub((i - 1) * STRING_PROPERTY_SIZE_LIMIT + 1, i * STRING_PROPERTY_SIZE_LIMIT)
				ScriptEditorService:UpdateSourceAsync(moduleScript, function(old)
					if i == 1 then
						return slice
					else
						return old .. slice
					end
				end)
			end
		else
			ScriptEditorService:UpdateSourceAsync(moduleScript, function()
				return json
			end)
		end
	end)

	if not success then
		if err:find("denied") then
			return false,
				"Script injection permissions is required to export to JSON (a script must be created for export)."
		else
			return false, err
		end
	end

	-- Prompt export

	moduleScript.Parent = game:GetService("TweenService")

	SelectionService:Set({ moduleScript })
	local exported = plugin:PromptSaveSelection(fileName)

	SelectionService:Set(originalSelection)
	moduleScript:Destroy()
	return exported
end

-- Returns if the user selected a valid JSON file
-- The second return is either the JSON value or error message
function ExportImportHelper.import(): (boolean, (string | unknown)?)
	local file = StudioService:PromptImportFile({"txt", "lua", "luau", "json"})
	if file then
		local contents = file:GetBinaryContents()
		local json: unknown?
		local success, err = pcall(function()
			json = HttpService:JSONDecode(contents)
		end)

		if not success then
			return false, "Failed to parse JSON."
		else
			return true, json
		end
	else
		return false, "No file selected for import."
	end
end

return ExportImportHelper


-- History for datastore connections and keys

local MAIN_HISTORY_KEY = "DataDelveHistory alpha 1"

local Types = require(script.Parent.Types)

local History = {}
History.__index = History

function History.new(gameId: number)
	local self = setmetatable({
		gameId = tostring(gameId),
	}, History)

	-- Sorted by most recent
	self.connectionRecords = {}

	self._connectionsChanged = Instance.new("BindableEvent")
	self.connectionsChanged = self._connectionsChanged.Event

	return self
end

local function shallowEquals(a, b): boolean
	for i, v in a do
		if b[i] ~= v then
			return false
		end
	end
	for i, v in b do
		if a[i] ~= v then
			return false
		end
	end
	return true
end

function History:addConnection(connection: Types.DataStoreConnection)
	for i, v in self.connectionRecords do
		if shallowEquals(v.connection, connection) then
			v.lastAccess = DateTime.now().UnixTimestamp
			table.remove(self.connectionRecords, i)
			table.insert(self.connectionRecords, 1, v)

			if #self.connectionRecords > 32 then
				table.remove(self.connectionRecords)
			end

			self._connectionsChanged:Fire()
			return
		end
	end

	-- If it did not find a match
	table.insert(self.connectionRecords, 1, {
		connection = table.freeze(connection),
		lastAccess = DateTime.now().UnixTimestamp,
	})

	if #self.connectionRecords > 32 then
		table.remove(self.connectionRecords)
	end

	self._connectionsChanged:Fire()
end

function History:pinConnectionRecord(connection: Types.DataStoreConnection)
	for _, v in self.connectionRecords do
		if shallowEquals(v.connection, connection) then
			v.pinned = true
			self._connectionsChanged:Fire()
			break
		end
	end
end

function History:unpinConnectionRecord(connection: Types.DataStoreConnection)
	for _, v in self.connectionRecords do
		if shallowEquals(v.connection, connection) then
			v.pinned = false
			self._connectionsChanged:Fire()
			break
		end
	end
end

function History:removeConnectionRecord(connection: Types.DataStoreConnection)
	for i, v in self.connectionRecords do
		if shallowEquals(v.connection, connection) then
			table.remove(self.connectionRecords, i)
			self._connectionsChanged:Fire()
			return
		end
	end
end

local function convertOldConnectionToNew(connection: Types.OldDataStoreConnection): Types.DataStoreConnection
	if connection.allScopes then
		return {
			type = "allScopes",
			name = connection.name
		}
	elseif connection.isOrdered then
		return {
			type = "ordered",
			name = connection.name,
			scope = connection.scope,
		}
	else
		return {
			type = "normal",
			name = connection.name,
			scope = connection.scope,
		}
	end
end

local function convertRecordsToNew(records)
	local newRecords = {}

	for _, v in records do
		table.insert(newRecords, {
			connection = convertOldConnectionToNew(v.connection),
			lastAccess = v.lastAccess,
			pinned = v.pinned,
		})
	end

	return newRecords
end

function History:load(plugin: Plugin)
	local loadedData = plugin:GetSetting(MAIN_HISTORY_KEY) or {
		games = {},
	}

	local gameLoadedData = loadedData.games[self.gameId]
	if not gameLoadedData then
		gameLoadedData = {
			isNew = true,
			connectionRecords = {},
		}
		loadedData.games[self.gameId] = gameLoadedData
	end

	self.connectionRecords =
		if gameLoadedData.isNew then gameLoadedData.connectionRecords
		else convertRecordsToNew(gameLoadedData.connectionRecords)
end

function History:save(plugin: Plugin)
	local loadedData = plugin:GetSetting(MAIN_HISTORY_KEY) or {
		games = {},
	}

	loadedData.games[self.gameId] = {
		isNew = true,
		connectionRecords = self.connectionRecords,
	}

	-- TODO: undo this!!
	plugin:SetSetting(MAIN_HISTORY_KEY, loadedData)
end

History.global = History.new(game.GameId)
export type History = typeof(History.new(1))
return History


-- Wrapper for page APIs
-- TBH this causes too much indirection, kind of annoying to use

local Pages = {}
Pages.__index = Pages

type PagesState = unknown
type PagesInterface = {
	initialize: () -> (PagesState?, string?),
	next: (PagesState) -> { items: { unknown }?, isFinished: boolean } | { error: string },
}
function Pages.new(interface: PagesInterface)
	local self = setmetatable({
		isFinished = false,
		pageNumber = 0,
		cache = {},

		interface = interface,

		_loadMoreListStyleState = nil,
	}, Pages)

	local state, err = interface.initialize()
	if err then
		return nil, err
	end

	self.state = state

	local _, err = self:increment()
	if err then
		return nil, err
	end
	return self
end

function Pages.wrapListing(callback: () -> DataStoreListingPages)
	return Pages.new({
		initialize = function()
			local success, result = pcall(callback)

			if not success then
				return nil, result
			end

			return { didFirst = false, pages = result }
		end,
		next = function(state: { didFirst: boolean, pages: DataStoreListingPages })
			if state.didFirst then
				local success, err = pcall(function()
					state.pages:AdvanceToNextPageAsync()
				end)
				if not success then
					return { error = err }
				else
					return { items = state.pages:GetCurrentPage(), isFinished = state.pages.IsFinished }
				end
			else
				state.didFirst = true
				return { items = state.pages:GetCurrentPage(), isFinished = state.pages.IsFinished }
			end
		end,
	} :: PagesInterface)
end

function Pages:get(pageNumber: number): ({ unknown }?, string?)
	if pageNumber < 1 then
		return nil, "Page number must be greater than 1."
	end
	if pageNumber == #self.cache + 1 then
		if self.isFinished then
			return nil, "Page number exceeds max page."
		end

		local result = self.interface.next(self.state)

		if result.error then
			return nil, result.error
		end

		self.isFinished = result.isFinished
		self.cache[pageNumber] = result.items

		return result.items
	elseif pageNumber > #self.cache then
		return nil, "Page number not loaded yet."
	end

	return
end

function Pages:getCurrent(): ({ unknown }?, string?)
	return self:get(self.pageNumber)
end

function Pages:increment(): ({ unknown }?, string?)
	local items, err = self:get(self.pageNumber + 1)
	if err then
		return nil, err
	else
		self.pageNumber += 1

		if self._loadMoreListStyleState then
			for _, item in items do
				self._loadMoreListStyleState:add(item)
			end
			self._loadMoreListStyleState:setLoadMoreVisible(not self.isFinished)
		end

		return items
	end
end

function Pages:isEmpty(): boolean
	return (#self.cache == 1) and (#self.cache[1] == 0) and self.isFinished
end

function Pages:syncWithLoadMoreListStyleState(styleState)
	self._loadMoreListStyleState = styleState
	for _, page in self.cache do
		for _, item in page do
			self._loadMoreListStyleState:add(item)
		end
	end

	self._loadMoreListStyleState:setLoadMoreVisible(not self.isFinished)
end

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

return Pages


-- Main interface with datastores

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

local DataStoreService = game:GetService("DataStoreService")

local Settings = require(script.Parent.Settings)

local Session = {}
Session.__index = Session

-- Static

function Session.new()
	local self = setmetatable({
		datastore = nil :: DataStore?,
		connection = nil :: Types.DataStoreConnection?,

		dataStorePages = nil :: Pages.Pages?,
		keyPages = nil :: Pages.Pages?,

		currentKey = nil :: string?,
		currentValue = nil :: any,
		currentKeyInfo = nil :: DataStoreKeyInfo?,
		currentVersion = nil :: string?,
		isLoadedVersion = false :: boolean,
		versionPages = nil :: Pages.Pages?,
	}, Session)

	return self
end

function Session.areConnectionsSame(a: Types.DataStoreConnection, b: Types.DataStoreConnection): boolean
	for i, v in a :: any do
		if b[i] ~= v then
			return false
		end
	end
	return true
end

-- Methods

function Session:fork()
	local forked = setmetatable(table.clone(self), Session)
	forked.dataStorePages = nil
	forked.keyPages = nil
	forked.versionPages = nil
	return forked
end

local actionRequestBudgets = {
	list = { [Enum.DataStoreRequestType.ListAsync] = 1 },
	listOrdered = { [Enum.DataStoreRequestType.GetSortedAsync] = 1 },
	["duplicate"] = { [Enum.DataStoreRequestType.SetIncrementAsync] = 1, [Enum.DataStoreRequestType.GetAsync] = 1 },
	["move"] = { [Enum.DataStoreRequestType.SetIncrementAsync] = 2, [Enum.DataStoreRequestType.GetAsync] = 1 },
}
function Session:hasBudgetFor(action: "list" | "listOrdered" | "duplicate" | "move"): boolean
	for requestType, amount in actionRequestBudgets[action] do
		if DataStoreService:GetRequestBudgetForRequestType(requestType) < amount then
			return false
		end
	end
	return true
end

function Session:tryConnect(connection: Types.DataStoreConnection): (boolean, string?)
	local datastore = nil
	if connection.type == "ordered" then
		local success, err = pcall(function()
			datastore = DataStoreService:GetOrderedDataStore(connection.name, connection.scope)
		end)

		if not success then
			return false, err
		end

		self:resetKey()
		self.datastore = datastore
	else
		local params = Instance.new("DataStoreOptions")
		params.AllScopes = connection.type == "allScopes"
		local scope = if connection.type == "allScopes" then "" else connection.scope

		local success, err = pcall(function()
			if connection.type == "global" then
				datastore = DataStoreService:GetGlobalDataStore()
			else
				datastore = DataStoreService:GetDataStore(connection.name, scope, params)
			end
		end)

		if not success then
			return false, err
		end

		self:resetKey()
		self.datastore = datastore
	end

	self.keyPages = nil
	self.connection = connection

	return true
end

function Session:tryLoadKey(key: string): (boolean, string?)
	local params = Instance.new("DataStoreGetOptions")
	params.UseCache = false

	local value, keyInfo = nil, nil
	local success, err = pcall(function()
		value, keyInfo = self.datastore:GetAsync(key, params)
	end)

	if not success then
		return false, err
	end

	self:resetKey()

	self.currentKey = key
	self.currentValue = value
	self.currentKeyInfo = keyInfo
	self.currentVersion = keyInfo and keyInfo.Version
	self.isLoadedVersion = false

	return true
end

function Session:tryLoadVersion(key: string, version: string): (boolean, string?)
	assert(self.connection.type ~= "ordered")

	local value, keyInfo = nil, nil
	local success, err = pcall(function()
		value, keyInfo = self.datastore:GetVersionAsync(key, version)
	end)

	if not success then
		return false, err
	end

	self.currentKey = key
	self.currentValue = value
	self.currentKeyInfo = keyInfo
	self.currentVersion = version
	self.isLoadedVersion = true

	return true
end

function Session:resetKey()
	self.currentKey = nil
	self.currentValue = nil
	self.currentKeyInfo = nil
	self.currentVersion = nil
	self.versionPages = nil
end

function Session:trySeeKeyChanged(): "changed" | "failed" | "unchanged"
	assert(self.connection.type ~= "ordered")

	if self.isLoadedVersion then
		-- Loading a version will always have the UpdatedTime before the latest UpdatedTime,
		-- so just ignore this check
		return "failed"
	end

	local params = Instance.new("DataStoreGetOptions")
	params.UseCache = false

	local gotValue, gotKeyInfo = nil, nil
	local success, err = pcall(function()
		gotValue, gotKeyInfo = self.datastore:GetAsync(self.currentKey, params)
	end)

	if success then
		if self.currentKeyInfo then
			if (self.currentKeyInfo :: any).UpdatedTime + 1 < gotKeyInfo.UpdatedTime then
				return "changed"
			else
				return "unchanged"
			end
		elseif gotKeyInfo then
			return "changed"
		else
			return "failed"
		end
	else
		return "failed"
	end
end

function Session:trySaveCurrentKey(value: any, userIds: { number }, metadata: { [string]: any })
	assert(self.connection.type ~= "ordered")

	local newValue, newKeyInfo = nil, nil
	local success, err = pcall(function()
		-- NOTE: error in the callback will not propagate to the pcall so
		newValue, newKeyInfo = self.datastore:UpdateAsync(self.currentKey, function()
			if self.currentKeyInfo then
				return value, userIds or self.currentKeyInfo:GetUserIds(), metadata or self.currentKeyInfo:GetMetadata()
			else
				return value, nil :: any, nil :: any
			end
		end)
	end)

	if success  then
		self.currentValue = newValue
		-- if there is no new keyinfo that probably means they saved null to an empty key
		if newKeyInfo then
			self.currentKeyInfo = newKeyInfo
			self.currentVersion = newKeyInfo.Version
		end
		self.isLoadedVersion = false
	end

	return success, err
end

function Session:tryDeleteCurrentKey()
	local success, err = pcall(function()
		self.datastore:RemoveAsync(self.currentKey)
	end)

	if success then
		self:resetKey()
	end

	return success, err
end

function Session:tryDeleteKey(keyName: string)
	if keyName == self.currentKey then
		return self:tryDeleteCurrentKey()
	else
		local success, err = pcall(function()
			self.datastore:RemoveAsync(keyName)
		end)

		return success, err
	end
end

export type DuplicateKeyOptions = {
	data: any,
	metadata: any,
	userIds: number,

	newKey: string,
	deleteKey: boolean,
}

-- Must call tryLoadKey after to load the transferred key.
function Session:tryDuplicateKey(options: DuplicateKeyOptions): (boolean, string?)
	assert(self.connection.type ~= "ordered")
	assert(self.datastore)

	local success, err = pcall(function()
		local setOptions = Instance.new("DataStoreSetOptions")
		setOptions:SetMetadata(options.metadata)

		self.datastore:SetAsync(options.newKey, options.data, options.userIds, setOptions)
	end)

	if not success then
		return false, err
	end

	if options.deleteKey then
		local success, err = pcall(function()
			self.datastore:RemoveAsync(self.currentKey)
		end)

		if not success then
			return false, err
		end
	end

	return true
end

function Session:tryDeleteCurrentKeyVersion(version: string)
	assert(self.connection.type ~= "ordered")

	local success, err = pcall(function()
		self.datastore:RemoveVersionAsync(self.currentKey, version)
	end)

	return success, err
end

function Session:tryCreateVersionPages(
	order: Enum.SortDirection,
	minDate: number?,
	maxDate: number?
): (boolean, string?)
	assert(self.connection.type ~= "ordered")

	local pages, err = Pages.wrapListing(function()
		return self.datastore:ListVersionsAsync(self.currentKey, order, minDate, maxDate, 16)
	end)

	if err then
		return false, err
	else
		self.versionPages = pages
		return true
	end
end

-- DataStore listing
function Session:tryCreateDataStorePages(prefix: string?): (boolean, string?)
	local pages, err = Pages.wrapListing(function()
		return DataStoreService:ListDataStoresAsync(prefix)
	end)

	if err then
		return false, err
	else
		self.dataStorePages = pages
		return true
	end
end

function Session:tryCreateKeyPages(prefix: string?): (boolean, string?)
	assert(self.connection.type ~= "ordered")

	-- TODO: if all keys are deleted in the page, nothing will happen
	--       user should be informed that all the keys were deleted
	local pages, err = Pages.wrapListing(function()
		return self.datastore:ListKeysAsync(prefix, nil, nil, not Settings.get("listDeletedKeys"))
	end)

	if err then
		return false, err
	else
		self.keyPages = pages
		return true
	end
end

-- Ordered data store
function Session:tryCreateOrderedKeyPages(
	order: "ascending" | "descending",
	min: number?,
	max: number?
): (boolean, string?)
	assert(self.connection.type == "ordered")

	local pages, err = Pages.wrapListing(function()
		return self.datastore:GetSortedAsync(order == "ascending", 50, min, max)
	end)

	if err then
		return false, err
	else
		self.keyPages = pages
		return true
	end
end

function Session:trySetOrderedKey(key: string, value: number): (boolean, string?)
	assert(self.connection.type == "ordered")

	local success, err = pcall(function()
		self.datastore:SetAsync(key, value)
	end)

	if success then
		return true
	else
		return success, err
	end
end

function Session:tryRemoveOrderedKey(key: string): (boolean, string?)
	assert(self.connection.type == "ordered")

	local success, err = pcall(function()
		self.datastore:RemoveAsync(key)
	end)

	if success then
		return true
	else
		return success, err
	end
end

function Session:tryGetOrderedKey(key: string): (number?, string?)
	assert(self.connection.type == "ordered")

	local value = nil
	local success, err = pcall(function()
		value = self.datastore:GetAsync(key)
	end)

	if success then
		return value
	else
		return nil, err
	end
end

function Session:destroy() end

export type Session = typeof(Session.new())

return Session


--!strict

local ViewerTypes = require(script.Parent.UI.Viewers.Types)
local BufferViewerTypes = require(script.Parent.UI.BufferViewers.Types)

local MAIN_SETTINGS_KEY = "DataDelveSetting alpha 1"

export type SettingsData = {
	themePreset: string,
	themeAccent: string,
	hideGameName: boolean,
	highlightColors: "Default" | "Script Editor",
	useAlternatingKeyColors: boolean,
	listDeletedKeys: boolean,
	automaticallyList: boolean,
	clickToOpen: boolean,
	viewerMode: ViewerTypes.ViewerMode,
	bufferViewerMode: BufferViewerTypes.BufferViewerMode,
}

local defaultSettings: SettingsData = {
	themePreset = "studio",
	themeAccent = "blue",
	hideGameName = true,
	highlightColors = "Default",
	useAlternatingKeyColors = false,
	listDeletedKeys = false,
	automaticallyList = true,
	clickToOpen = false,
	viewerMode = "Tree",
	bufferViewerMode = "Hex",
}

local Settings = {}
Settings.data = defaultSettings

local changed = Instance.new("BindableEvent")
Settings.changed = changed.Event

local function fillWithDefaults(a, defaults)
	for key, default in defaults do
		if a[key] == nil then
			a[key] = default
		end
	end
end

function Settings.load(plugin: Plugin)
	Settings.data = plugin:GetSetting(MAIN_SETTINGS_KEY) or {}
	fillWithDefaults(Settings.data, defaultSettings)
end

function Settings.save(plugin: Plugin)
	plugin:SetSetting(MAIN_SETTINGS_KEY, Settings.data)
end

function Settings.set(setting: string, value: any)
	Settings.data[setting] = value
	changed:Fire(setting, value)
end

function Settings.get(setting: string)
	return Settings.data[setting]
end

return Settings


--!strict

export type Transaction = () -> ()

export type TransactorImplementation = {
	__index: TransactorImplementation,

	new: () -> Transactor,

	destroy: (Transactor) -> (),
	transact: (Transactor, Transaction) -> boolean,
}

export type TransactorFields = {
	locked: boolean,
	lockedChanged: RBXScriptSignal,
	_lockedChanged: BindableEvent,
}

export type Transactor = typeof(setmetatable({} :: TransactorFields, {} :: TransactorImplementation))

local Transactor = {} :: TransactorImplementation
Transactor.__index = Transactor

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

	self.locked = false
	self._lockedChanged = Instance.new("BindableEvent")
	self.lockedChanged = self._lockedChanged.Event :: RBXScriptSignal

	return self
end

function Transactor:destroy()
	self._lockedChanged:Destroy()
end

function Transactor:transact(transaction): boolean
	if self.locked then
		warn("Transaction failed. Already doing one.", debug.traceback())
		return false
	end

	self.locked = true
	self._lockedChanged:Fire(self.locked)

	transaction()

	-- Wait a bit in case of race conditions...
	task.wait(0)
	self.locked = false
	self._lockedChanged:Fire(self.locked)

	return true
end

return Transactor


--!strict

-- I was going to use this more, but I forgot about it. Now it houses these two types.

type OldDataStoreConnectionBase = {
	name: string,
	scope: string?,
	isOrdered: boolean,
}
type OldAllScopesDataStoreConnection = OldDataStoreConnectionBase & {
	isOrdered: false,
	allScopes: true,
}
type OldNormalDataStoreConnection = OldDataStoreConnectionBase & {
	isOrdered: boolean,
	allScopes: false,
}
export type OldDataStoreConnection = OldAllScopesDataStoreConnection | OldNormalDataStoreConnection

export type NormalDataStoreConnection = {
	type: "normal",
	name: string,
	scope: string?,
}
export type OrderedDataStoreConnection = {
	type: "ordered",
	name: string,
	scope: string,
}
export type AllScopesDataStoreConnection = {
	type: "allScopes",
	name: string
}
export type GlobalDataStoreConnection = {
	type: "global",
	-- this is just for the UI so we don't have to special-case this
	name: "global",
}
export type DataStoreConnection = NormalDataStoreConnection | OrderedDataStoreConnection | AllScopesDataStoreConnection | GlobalDataStoreConnection

export type DataStoreConnectionRecord = {
	connection: DataStoreConnection,
	lastAccess: typeof(DateTime.now().UnixTimestamp),
	-- newly added stuff
	pinned: boolean?,
}

return {}


--!strict

local SelectionService = game:GetService("Selection")
local ScriptEditorService = game:GetService("ScriptEditorService")

local Types = require(script.Parent.Types)
local ViewerTypes = require(script.Parent.Parent.Viewers.Types)
local UIMessages = require(script.Parent.Parent.UIMessages)
local Theme = require(script.Parent.Parent.Theme)
local Tooltip = require(script.Parent.Parent.Tooltip)
local PopupHelper = require(script.Parent.Parent.PopupHelper)
local rfmt = require(script.Parent.Parent.Parent.rfmt)
local DeserializerState = require(script.Parent.Parent.Parent.DeserializerState)
local ModalState = require(script.Parent.Parent.ModalState)

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

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

export type DeserializerFrame = typeof(Assets.Views.DeserializerView)

local function getStyleStates(theme: Theme.Theme, frame: DeserializerFrame)
	return {
		errorLabel = LabelStyleState.from(theme, frame.Container.Error.Message),
		errorIcon = LabelStyleState.from(theme, frame.Container.Error.Icon, { style = "error" }),
		deserializer = LabelStyleState.from(theme, frame.Top.Deserializer),
		useSelection = ButtonStyleState.from(theme, frame.Top.UseSelection),
		help = ButtonStyleState.from(theme, frame.Top.Help, {
			style = "dormant",
		}),
	}
end

export type DeserializerImplementation = {
	__index: DeserializerImplementation,
	from: (buf: buffer, container: Frame, options: Types.BufferViewerFromOptions) -> Deserializer,
	setValue: (Deserializer, buf: buffer) -> (),
	getValue: (Deserializer) -> buffer,
	destroy: (Deserializer) -> (),

	undo: (Deserializer) -> (),
	redo: (Deserializer) -> (),
	canUndo: (Deserializer) -> boolean,
	canRedo: (Deserializer) -> boolean,

	_setError: (Deserializer, message: string) -> (),
	_tryUseSelection: (Deserializer) -> (),
	_setDeserializer: (Deserializer, name: string, DeserializerState.Deserializer, buf: buffer?) -> (),
	_cleanupEditor: (Deserializer) -> (),

	_showHelpPopup: (Deserializer) -> (),
	_closeHelpPopup: (Deserializer) -> (),
}

export type DeserializerFields = {
	_historyChanged: BindableEvent,
	historyChanged: RBXScriptSignal,

	_inputReceiver: GuiObject,
	_container: Frame,
	_frame: DeserializerFrame,

	_theme: Theme.Theme,
	_uiMessages: UIMessages.UIMessages,
	_styleStates: typeof(getStyleStates({} :: any, {} :: any)),
	_helpTooltipDestructor: Tooltip.TooltipDestructor,

	_originalValue: buffer,
	_editor: (DeserializerState.Editor & { viewer: any? })?,

	_deserializerName: string?,
	_deserializer: DeserializerState.Deserializer?,

	_helpPopup: any,
	_helpPopupModal: any,
}

export type Deserializer = typeof(setmetatable({} :: DeserializerFields, {} :: DeserializerImplementation))

local Deserializer: DeserializerImplementation = {} :: DeserializerImplementation
Deserializer.__index = Deserializer

function Deserializer.from(buf: buffer, container: Frame, options: Types.BufferViewerFromOptions)
	local self = setmetatable({}, Deserializer)

	self._historyChanged = Instance.new("BindableEvent")
	self.historyChanged = self._historyChanged.Event :: RBXScriptSignal

	self._inputReceiver = options.inputReceiver
	self._theme = options.theme
	self._uiMessages = options.uiMessages
	self._container = container
	self._frame = Assets.Views.DeserializerView:Clone()
	self._frame.Parent = container
	self._styleStates = getStyleStates(options.theme, self._frame)

	self._originalValue = buf
	self._editor = nil

	self._helpTooltipDestructor = Tooltip.bindButtonGeneric(self._theme, self._styleStates.help.button, "Help")

	self._frame.Top.Deserializer.Activated:Connect(function()
		local moduleScript = DeserializerState.getDefaultDeserializerScript()
		if moduleScript and moduleScript:IsA("ModuleScript") then
			SelectionService:Set({moduleScript})
			ScriptEditorService:OpenScriptDocumentAsync(moduleScript)
		end
	end)

	self._styleStates.useSelection.button.Activated:Connect(function()
		self:_tryUseSelection()
	end)

	self._styleStates.help.button.Activated:Connect(function()
		self:_showHelpPopup()
	end)

	local deserializer, err = DeserializerState.getDefaultDeserializer()
	if err then
		self:_setError(`Error deserializing: {err}`)
	elseif deserializer then
		self:_setDeserializer(deserializer.name, deserializer.deserializer)
	end

	return self
end

function Deserializer._setError(self: Deserializer, message: string)
	self._uiMessages:error(message)
	self._styleStates.errorLabel.label.Text = message
	self._frame.Container.Error.Visible = true
	self._frame.Container.Content.Visible = false
end

function Deserializer._tryUseSelection(self: Deserializer)
	local selection = SelectionService:Get()
	if #selection == 0 then
		self._uiMessages:error("Select a ModuleScript.")
		return
	elseif #selection > 1 then
		self._uiMessages:error("Select exactly on ModuleScript.")
		return
	end

	local module = selection[1]
	if not module:IsA("ModuleScript") then
		self._uiMessages:error("Select a ModuleScript.")
		return
	end

	local deserializer, err = DeserializerState.tryLoadDeserializer(module)
	if err then
		self._uiMessages:error(err)
		return
	end

	DeserializerState.trySetDefaultDeserializer(module)

	self:_setDeserializer(module:GetFullName(), deserializer :: DeserializerState.Deserializer)
end

local function hasMixedTable(t: unknown): boolean
	if typeof(t) ~= "table" then
		return false
	else
		local hasNumeric = false
		local hasNonNumeric = false

		for k in t :: any do
			if type(k) == "number" then
				hasNumeric = true
			else
				hasNonNumeric = true
			end

			if hasNumeric and hasNonNumeric then
				return true
			end
		end

		for _, v in t :: any do
			if hasMixedTable(v) then
				return true
			end
		end

		return false
	end
end

function Deserializer._setDeserializer(self: Deserializer, name: string, deserializer: DeserializerState.Deserializer, buf: buffer?)
	self:_cleanupEditor()

	self._deserializerName = name
	self._deserializer = deserializer
	self._styleStates.deserializer.label.Text = `Deserializer: {name}`

	self._frame.Container.Error.Visible = false
	self._frame.Container.Content.Visible = true

	if deserializer.type == "function" then
		local value: unknown
		local success, err = pcall(function()
			value = deserializer.deserialize(buf or self:getValue())
		end)

		if not success then
			self:_setError(`Deserializer error: {err}`)
			return
		end

		if hasMixedTable(value) then
			self:_setError("Deserializer error: Mixed tables are not allowed!")
			return
		end

		-- circular dependency...
		local Tree = require(script.Parent.Parent.Viewers.Tree) :: any

		local container = Instance.new("Frame")
		container.BackgroundTransparency = 1
		container.Size = UDim2.fromScale(1, 1)
		container.Parent = self._frame.Container.Content

		local viewer = Tree.new(self._theme, self._uiMessages, container, {
			data = value,
			readOnly = false,
			showKeyInfo = false,
			inputReceiver = self._inputReceiver,
			modalState = ModalState.new(),
		})

		local connection = viewer.historyChanged:Connect(function()
			self._historyChanged:Fire()
		end)

		self._editor = {
			object = container :: any,
			getValue = function()
				local result = viewer:getValue()
				if result.kind == "success" then
					return deserializer.serialize(result.value)
				end

				return nil :: any
			end,
			cleanup = function()
				viewer:destroy()
			end,
			viewer = viewer :: any,
		}
	elseif deserializer.type == "editor" then
		local editor: DeserializerState.Editor
		local success, err = pcall(function()
			local gotEditor: any = deserializer.createEditor(buf or self:getValue())

			if typeof(gotEditor) ~= "table" then
				error("Deserializer error: expected `createEditor` to return a table.", 0)
			end

			if typeof(gotEditor.object) ~= "Instance" then
				error("Deserializer error: expected editor to contain `object` key of type Instance.", 0)
			end

			if typeof(gotEditor.getValue) ~= "function" then
				error("Deserializer error: expected editor to contain `getValue` key of type `() -> buffer?`.", 0)
			end

			if gotEditor.cleanup and typeof(gotEditor.cleanup) ~= "function" then
				error("Deserializer error: expected editor to contain `getValue` key of type `() -> buffer?`.", 0)
			end

			editor = gotEditor
		end)

		-- TODO: the error might belong to the serializer of the previous editor. Need a better way to separate the error in `createEditor` and `getValue`
		if not success then
			self:_setError(`Deserializer error: {err}`)
			return
		end

		editor.object.Parent = self._frame.Container.Content
		self._editor = editor :: any
	end

	self._historyChanged:Fire()
end

function Deserializer._cleanupEditor(self: Deserializer)
	if self._editor and self._editor.cleanup then
		self._editor.cleanup()
		self._editor.object:Destroy()
	end
	self._editor = nil
end

function Deserializer._showHelpPopup(self: Deserializer)
	self:_closeHelpPopup()

	local popup = Assets.Views.DeserializerHelpPopup:Clone()
	local wrapper = StyleStateWrapper.new(self._theme, {
		background = BackgroundStyleState.from(self._theme, popup),
		close = ButtonStyleState.from(self._theme, popup["Escape Layout"].Close),
		paragraphs = LabelStyleState.fromDescendants(self._theme, popup.Content),
		title = LabelStyleState.from(self._theme, popup.Title),
	})

	local modal = PopupHelper.modal(popup, self._container:FindFirstAncestorWhichIsA("LayerCollector") :: any)

	wrapper.styleStates.close.button.Activated:Connect(function()
		self:_closeHelpPopup()
	end)

	modal.cancelled:Connect(function()
		self:_closeHelpPopup()
	end)

	self._helpPopup = wrapper
	self._helpPopupModal = modal
end

function Deserializer._closeHelpPopup(self: Deserializer)
	if self._helpPopup then
		self._helpPopup:destroy(true)
	end
	if self._helpPopupModal then
		self._helpPopupModal:destroy()
	end

	self._helpPopup = nil
	self._helpPopupModal = nil
end

function Deserializer.getValue(self: Deserializer): buffer
	if self._editor then
		local buf: buffer?
		local success, err = pcall(function()
			buf = self._editor.getValue()
		end)

		if not success then
			self._uiMessages:error(err)
			return self._originalValue
		end

		return buf or self._originalValue
	else
		return self._originalValue
	end
end

function Deserializer.setValue(self: Deserializer, buf: buffer)
	if self._deserializerName and self._deserializer then
		self._originalValue = buf
		self:_setDeserializer(self._deserializerName, self._deserializer, buf)
	else
		self._originalValue = buf
	end
end

function Deserializer.canUndo(self: Deserializer)
	if self._editor and self._editor.viewer then
		return self._editor.viewer:canUndo()
	else
		return false
	end
end

function Deserializer.canRedo(self: Deserializer)
	if self._editor and self._editor.viewer then
		return self._editor.viewer:canRedo()
	else
		return false
	end
end

function Deserializer.undo(self: Deserializer)
	if self._editor and self._editor.viewer then
		self._editor.viewer:undo()
	end
end

function Deserializer.redo(self: Deserializer)
	if self._editor and self._editor.viewer then
		self._editor.viewer:redo()
	end
end

function Deserializer.destroy(self: Deserializer)
	self:_cleanupEditor()

	for _, v in self._styleStates do
		(v :: any):destroy()
	end

	self._helpTooltipDestructor()

	self:_closeHelpPopup()

	self._historyChanged:Destroy()
	self._frame:Destroy()
end

return Deserializer


--!strict
-- I will document later. For now, good luck! 🫡

export type DataInspectorSizing = "fillContainer" | "fillContainerY"

export type DataInspectorNewParams = {
	container: GuiObject,
	-- Default is "fillContainer"
	sizing: DataInspectorSizing?,
	theme: ThemeData,
}

export type ThemeData = {
	background: Color3,
	bodyPadding: number,
	editorMargin: number, -- How far from the hex editor to place it

	headerBackground: Color3,
	headerText: Color3,

	keyText: Color3,
	valueText: Color3,
	lightValueText: Color3,
	keyValuePadding: number,
	rowPadding: number,

	font: Font,
	textSize: number,

	scrollingFrameProperties: { [string]: unknown },
}

--

local TextService = game:GetService("TextService")

local HexEditor = require(script.Parent.HexEditor)

export type DataInspectorImplementation = {
	__index: DataInspectorImplementation,

	new: (HexEditor.HexEditor, DataInspectorNewParams) -> DataInspector,

	setTheme: (DataInspector, ThemeData) -> (),

	addRow: (DataInspector, RowInfo) -> (),

	destroy: (DataInspector) -> (),

	--[[ Private ]]

	_setTheme: (DataInspector, ThemeData) -> (),

	_buildHeader: (DataInspector) -> (),
	_buildRow: (DataInspector, rowName: string) -> (),
	_updateRows: (DataInspector) -> (),
	_buildUi: (DataInspector) -> (),
	_updatePosition: (DataInspector) -> (),
	_clearUi: (DataInspector) -> (),
}

export type DataInspectorFields = {
	editor: HexEditor.HexEditor,
	horizontalSpaceUsed: number,

	-- Access at your own risk.
	scrollingFrame: ScrollingFrame?,

	--[[ Private ]]
	_sizing: DataInspectorSizing,
	_theme: ThemeData,

	_container: GuiObject,

	_headerHeight: number,
	_rowHeight: number,
	_keyWidth: number,
	_valueWidth: number,
	_rowFramesCount: number,

	_header: TextLabel?,
	_rowFrames: { [string]: { frame: Frame, key: TextLabel, value: TextBox } },

	_lastOffset: number?,
	_rowInfos: { RowInfo },

	_connections: { RBXScriptConnection },
}

export type DataInspector = typeof(setmetatable({} :: DataInspectorFields, {} :: DataInspectorImplementation))

type RowInfo = { name: string, strategy: RowStrategy }
export type RowStrategy = {
	requiredBytes: number,
	func: (buffer: buffer, offset: number) -> { text: string, isLight: boolean } | string | number | nil,
}

local SPECIAL_ASCII = {
	[string.byte("\0")] = "\\0",
	[string.byte("\n")] = "\\n",
	[string.byte("\r")] = "\\r",
	[string.byte("\t")] = "\\t",
	[string.byte("\v")] = "\\v",
	[string.byte("\a")] = "\\a",
	[string.byte("\b")] = "\\b",
	[string.byte("\f")] = "\\f",
}

local builtInRows: { RowInfo } = {
	{
		name = "ascii",
		strategy = {
			requiredBytes = 1,
			func = function(b, offset)
				local ascii = buffer.readu8(b, offset)
				if ascii > 127 then
					return nil
				elseif HexEditor.isCharacterPrintable(ascii) then
					return string.char(ascii)
				elseif SPECIAL_ASCII[ascii] then
					return { text = SPECIAL_ASCII[ascii], isLight = true }
				else
					return { text = "non-printable", isLight = true }
				end
			end,
		},
	},
	{
		name = "utf-8",
		strategy = {
			requiredBytes = 1,
			func = function(b, offset)
				local count = math.min(4, buffer.len(b) - offset)
				local s: string
				for i = count, 0, -1 do
					if i == 0 then
						return nil
					end

					s = buffer.readstring(b, offset, count)
					if utf8.len(s) then
						break
					end
				end

				local start, finish = s:find(utf8.charpattern)
				if start and finish then
					return `{s:sub(start, finish)} ({finish - start + 1} {finish > start and "bytes" or "byte"})`
				else
					return nil
				end
			end,
		},
	},
	{
		name = "binary",
		strategy = {
			requiredBytes = 1,
			func = function(b, offset)
				local n = buffer.readu8(b, offset)
				local s = {}

				for i = 7, 0, -1 do
					if bit32.band(bit32.lshift(0b1, i), n) == 0 then
						table.insert(s, "0")
					else
						table.insert(s, "1")
					end
				end

				return table.concat(s, "")
			end,
		},
	},
	{
		name = "i8",
		strategy = {
			requiredBytes = 1,
			func = buffer.readi8,
		},
	},
	{
		name = "u8",
		strategy = {
			requiredBytes = 1,
			func = buffer.readu8,
		},
	},
	{
		name = "i16",
		strategy = {
			requiredBytes = 2,
			func = buffer.readi16,
		},
	},
	{
		name = "u16",
		strategy = {
			requiredBytes = 2,
			func = buffer.readu16,
		},
	},
	{
		name = "i32",
		strategy = {
			requiredBytes = 4,
			func = buffer.readi32,
		},
	},
	{
		name = "u32",
		strategy = {
			requiredBytes = 4,
			func = buffer.readu32,
		},
	},
	{
		name = "f32",
		strategy = {
			requiredBytes = 4,
			func = buffer.readf32,
		},
	},
	{
		name = "f64",
		strategy = {
			requiredBytes = 8,
			func = buffer.readf64,
		},
	},
}

local DataInspector: DataInspectorImplementation = {} :: DataInspectorImplementation
DataInspector.__index = DataInspector

function DataInspector.new(editor, params)
	local self = setmetatable({}, DataInspector) :: DataInspector
	self._container = params.container
	self._sizing = params.sizing :: DataInspectorSizing
	self:_setTheme(params.theme)

	self.editor = editor
	self.horizontalSpaceUsed = 0

	self._rowFrames = {}

	self._rowInfos = table.clone(builtInRows)

	self:_buildUi()
	self:_updatePosition()

	self._connections = {
		editor.sizeChanged:Connect(function()
			self:_updatePosition()
		end),
		editor.cursorMoved:Connect(function()
			self:_updateRows()
			self._lastOffset = self.editor:getCursorOffset()
		end),
	}

	return self
end

function DataInspector:setTheme(theme)
	self:_setTheme(theme)
	self:_buildUi()
end

function DataInspector:addRow(rowInfo: RowInfo)
	for _, info in self._rowInfos do
		if info.name == rowInfo.name then
			error(`Duplicate row name: {rowInfo.name}`)
		end
	end

	table.insert(self._rowInfos, rowInfo)
	self:_buildRow(rowInfo.name)
	self:_updateRows()
end

function DataInspector:destroy()
	self:_clearUi()
	for _, connection in self._connections do
		connection:Disconnect()
	end
	self._connections = {}
end

function DataInspector:_setTheme(theme)
	self._theme = theme

	local textParams = Instance.new("GetTextBoundsParams")
	textParams.Font = theme.font
	textParams.Size = theme.textSize
	textParams.Text = "1.7976931348623157e+308" -- Largest float64
	textParams.Width = math.huge

	local valueBounds = TextService:GetTextBoundsAsync(textParams)

	textParams.Text = "binary"
	local keyBounds = TextService:GetTextBoundsAsync(textParams)

	self._keyWidth = keyBounds.X
	self._valueWidth = valueBounds.X
	self._rowHeight = valueBounds.Y + theme.rowPadding
	self._headerHeight = valueBounds.Y + 2 * theme.rowPadding
end

function DataInspector:_buildHeader()
	local label = Instance.new("TextLabel")
	label.BorderSizePixel = 0
	label.BackgroundColor3 = self._theme.headerBackground
	label.TextXAlignment = Enum.TextXAlignment.Left
	label.TextColor3 = self._theme.headerText
	label.FontFace = Font.new(self._theme.font.Family, Enum.FontWeight.Bold, self._theme.font.Style)
	label.TextSize = self._theme.textSize
	label.Size = UDim2.fromOffset(self.horizontalSpaceUsed, self._headerHeight)
	label.Text = "Data Inspector"
	label.Position = UDim2.fromOffset(self._theme.bodyPadding, 0)
	label.Name = "Header"
	label.Parent = self.scrollingFrame
	self._header = label
end

function DataInspector:_buildRow(rowName: string)
	local frame = Instance.new("Frame")
	frame.BackgroundTransparency = 1
	frame.Size = UDim2.fromOffset(self._keyWidth + self._theme.keyValuePadding + self._valueWidth, self._rowHeight)
	frame.Position =
		UDim2.fromOffset(self._theme.bodyPadding, self._headerHeight + self._rowHeight * self._rowFramesCount)
	frame.Name = rowName

	local key = Instance.new("TextLabel")
	key.BackgroundTransparency = 1
	key.Size = UDim2.fromOffset(self._keyWidth, self._rowHeight)
	key.TextColor3 = self._theme.keyText
	key.FontFace = self._theme.font
	key.TextSize = self._theme.textSize
	key.TextXAlignment = Enum.TextXAlignment.Left
	key.Text = rowName
	key.Parent = frame

	local value = Instance.new("TextBox")
	value.BackgroundTransparency = 1
	value.FontFace = self._theme.font
	value.TextXAlignment = Enum.TextXAlignment.Left
	value.TextColor3 = self._theme.lightValueText
	value.TextEditable = false
	value.TextSize = self._theme.textSize
	value.ClearTextOnFocus = false
	value.Text = "none"
	value.Size = UDim2.fromOffset(self._valueWidth, self._rowHeight)
	value.Position = UDim2.fromOffset(self._keyWidth + self._theme.keyValuePadding, 0)
	value.Parent = frame

	frame.Parent = self.scrollingFrame

	self._rowFrames[rowName] = {
		frame = frame,
		key = key,
		value = value,
	}
	self._rowFramesCount += 1
end

function DataInspector:_updateRows()
	local b, bSize = self.editor:getRawBuffer()
	local offset = self.editor:getCursorOffset() or self._lastOffset
	local remaining = 0
	if offset then
		remaining = bSize - offset + 1
	end

	for _, row in self._rowInfos do
		local strategy = row.strategy
		local frame = self._rowFrames[row.name]

		local result = if offset and remaining > strategy.requiredBytes then strategy.func(b, offset) else nil

		if result == nil then
			frame.value.Text = "none"
			frame.value.TextColor3 = self._theme.lightValueText
		elseif typeof(result) == "table" then
			frame.value.Text = result.text
			frame.value.TextColor3 = if result.isLight
				then self._theme.lightValueText
				else self._theme.valueText
		else
			frame.value.Text = tostring(result)
			frame.value.TextColor3 = self._theme.valueText
		end
	end
end

function DataInspector:_buildUi()
	self:_clearUi()

	local scrollingFrame = Instance.new("ScrollingFrame")
	self.scrollingFrame = scrollingFrame
	scrollingFrame.BackgroundColor3 = self._theme.background
	scrollingFrame.BorderSizePixel = 0
	scrollingFrame.Size = UDim2.fromScale(1, 1)
	scrollingFrame.Parent = self._container

	self.horizontalSpaceUsed = 2 * self._theme.bodyPadding
		+ self._keyWidth
		+ self._theme.keyValuePadding
		+ self._valueWidth

	if self._sizing == "fillContainerY" then
		local sizeConstraint = Instance.new("UISizeConstraint")
		sizeConstraint.MaxSize = Vector2.new(self.horizontalSpaceUsed, math.huge)
		sizeConstraint.Parent = scrollingFrame
	end

	self:_buildHeader()

	for _, row in self._rowInfos do
		self:_buildRow(row.name)
	end

	scrollingFrame.CanvasSize = UDim2.fromOffset(0, self._headerHeight + self._rowHeight * self._rowFramesCount)

	local scrollingFrameProperties = self._theme.scrollingFrameProperties
	if scrollingFrameProperties then
		for property, value in scrollingFrameProperties do
			(scrollingFrame :: any)[property] = value
		end
	end

	self:_updateRows()
end

function DataInspector:_updatePosition()
	if self.editor.scrollingFrame and self.scrollingFrame then
		self.scrollingFrame.Position = self.editor.scrollingFrame.Position
			+ UDim2.fromOffset(self.editor.horizontalSpaceUsed + self._theme.editorMargin, 0)
	end
end

function DataInspector:_clearUi()
	local header = self._header
	if header then
		header:Destroy()
		self._header = nil
	end

	for _, row in self._rowFrames do
		row.frame:Destroy()
	end
	self._rowFramesCount = 0
	self._rowFrames = {}

	local scrollingFrame = self.scrollingFrame
	if scrollingFrame then
		scrollingFrame:Destroy()
		self.scrollingFrame = nil
	end
end

return DataInspector


--!strict

local HexEditor = require(script.Parent.HexEditor)

local Validators = require(script.Parent.Parent.Parent.Parent.Validators)
local constrainTextBoxStyleState = require(script.Parent.Parent.Parent.Utilities.constrainTextBoxStyleState)

local StyleState = script.Parent.Parent.Parent.StyleState
local LabelStyleState = require(StyleState.LabelStyleState)
local TextBoxStyleState = require(StyleState.Input.TextBoxStyleState)
local ContextMenuStyleState = require(StyleState.Utility.ContextMenuStyleState)
local PopupFormStyleState = require(StyleState.Utility.PopupFormStyleState)

local FormProvider = {}

function FormProvider.generate(
	theme: any,
	hexEditorTheme: HexEditor.ThemeData,
	inputReceiver: GuiObject | UserInputService,
	container: GuiBase2d
): HexEditor.HexEditorFormProvider
	local betterContextMenuOptions: { [any]: {ContextMenuStyleState.Option} } = {
		[HexEditor.SINGLE_BYTE_CONTEXT_MENU_OPTIONS] = {
			{ icon = theme.icons.insert, text = "Insert Null Bytes" },
			{ icon = theme.icons.delete, text = "Delete Byte" },
			ContextMenuStyleState.SEPARATOR,
			{ icon = theme.icons.select, text = "Select All" },
		},
		[HexEditor.ADD_CELL_CONTEXT_MENU_OPTIONS] = {
			{ icon = theme.icons.insert, text = "Insert Null Bytes" },
			ContextMenuStyleState.SEPARATOR,
			{ icon = theme.icons.select, text = "Select All" },
		},
		[HexEditor.SELECTION_CONTEXT_MENU_OPTIONS] = {
			{ icon = theme.icons.fill, text = "Fill Selection" },
			{ icon = theme.icons.delete, text = "Delete Selection" },
			ContextMenuStyleState.SEPARATOR,
			{ icon = theme.icons.insert, text = "Insert Null Bytes" },
			{ icon = theme.icons.select, text = "Select All" },
		},
	}
	return {
		promptContextMenu = function(input, options)
			return ContextMenuStyleState.prompt(theme, container, (input.Position :: any) :: Vector2, {
				options = betterContextMenuOptions[options] or options,
			})
		end,
		promptFillWith = function(input)
			local editor: HexEditor.HexEditor
			local popup: any, modal =
				PopupFormStyleState.createPopup(theme, container, (input.Position :: any) :: Vector2, {
					title = "Fill With",
					buttons = {
						{ text = "Fill", options = { style = "primary" } },
						{ text = "Cancel", options = { style = "secondary" } },
					},
					addContents = function(frame)
						local label = script.PatternLabel:Clone()
						label.Parent = frame

						local editorContainer = Instance.new("Frame")
						editorContainer.BorderColor3 = Color3.new(0, 0, 0)
						editorContainer.BackgroundColor3 = theme.colors.background
						editorContainer.Size = UDim2.fromOffset(200, 44)

						editor = HexEditor.new(buffer.create(1), {
							container = editorContainer,
							sizing = "fillContainerY",
							theme = hexEditorTheme,
							layout = {
								columns = 8,
								showGutter = false,
								showHeader = false,
								showDecodedText = false,
							},
						}, {
							inputReceiver = inputReceiver,
						})

						editorContainer.Parent = frame

						return { LabelStyleState.from(theme, label) }
					end,
				})

			popup.frame.Size = UDim2.fromOffset(0, 0)

			local clicked = popup.interacted:Wait()

			modal:destroy()
			popup:destroy(true)

			if clicked == "Fill" then
				return editor:getBuffer()
			else
				return nil
			end
		end,
		promptInsertNullBytes = function(input)
			local textBoxStyleState: TextBoxStyleState.TextBoxStyleState
			local popup: any, modal =
				PopupFormStyleState.createPopup(theme, container, (input.Position :: any) :: Vector2, {
					title = "Insert Null Bytes",
					buttons = {
						{ text = "Insert", options = { style = "primary" } },
						{ text = "Cancel", options = { style = "secondary" } },
					},
					addContents = function(frame)
						local insertAmountRow = script.InsertAmountRow:Clone()
						local labelStyleState = LabelStyleState.from(theme, insertAmountRow.Title)
						textBoxStyleState = TextBoxStyleState.from(theme, insertAmountRow.TextBox)
						constrainTextBoxStyleState(textBoxStyleState, { Validators.positiveNonZeroInteger })

						insertAmountRow.Parent = frame

						return { labelStyleState, textBoxStyleState }
					end,
				})

			popup.frame.Size = UDim2.fromOffset(150, 0)

			textBoxStyleState.errorChanged:Connect(function()
				popup.wrapper.styleStates.buttons.Insert:setDisabled(not not textBoxStyleState.error):update()
			end)

			textBoxStyleState.textBox.FocusLost:Connect(function(entered, input)
				if entered and input then
					popup:interact("Insert")
				end
			end)

			local clicked = popup.interacted:Wait()

			modal:destroy()
			popup:destroy(true)

			if clicked == "Insert" then
				return tonumber(textBoxStyleState:getText())
			else
				return nil
			end
		end,
	}
end

return FormProvider