--[[
	Help Command

	Displays a list of all available commands and their descriptions.
	Dynamically discovers commands from the commands/ directory.

	Note: Run this command from inside the .lune directory:
	  cd .lune && lune run help
]]

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

local cli = require("./lib/cli/parser")
local color = require("./utils/color")

-- Detect the commands directory path based on where we're running from
local function getCommandsPath(): string
	if fs.isDir("commands") then
		return "commands"
	elseif fs.isDir(".lune/commands") then
		return ".lune/commands"
	else
		error("Could not find commands directory. Please run from the .lune directory.")
	end
end

--[[
	Discovers all commands from the commands/ directory.

	@return A list of command metadata sorted alphabetically
]]
local function discoverCommands(): { { name: string, description: string, usage: string } }
	local commands = {}
	local commandsPath = getCommandsPath()

	for _, entry in fs.readDir(commandsPath) do
		-- Handle both .luau files and directories (with init.luau)
		local name = entry:gsub("%.luau$", "")
		local success, command = pcall(require, `./commands/{name}`)

		if success and type(command) == "table" and command.name then
			table.insert(commands, {
				name = command.name,
				description = command.description or "",
				usage = command.usage or `lune run {command.name}`,
			})
		end
	end

	-- Sort alphabetically
	table.sort(commands, function(a, b)
		return a.name < b.name
	end)

	return commands
end

local function printGeneralHelp()
	local commands = discoverCommands()

	print(color.bold("Studio Activity CLI"))
	print()
	print("A collection of development tools for building, testing, and managing the project.")
	print()
	print(color.bold("Usage:") .. " lune run <command> [arguments]")
	print()
	print(color.bold("Commands:"))
	print()

	-- Find longest name for alignment
	local maxLen = 0
	for _, cmd in commands do
		maxLen = math.max(maxLen, #cmd.name)
	end

	for _, cmd in commands do
		local padding = string.rep(" ", maxLen - #cmd.name + 2)
		print(`  {color.cyan(cmd.name)}{padding}{cmd.description}`)
	end

	print()
	print(color.dim("Run 'lune run <command> --help' for command-specific options."))
end

local function printCommandHelp(commandName: string)
	local success, command = pcall(require, `./commands/{commandName}`)

	if not success or type(command) ~= "table" or not command.name then
		print(color.bold(color.red(`Unknown command: {commandName}`)))
		print()
		print("Run 'lune run help' to see available commands.")
		process.exit(1)
		return
	end

	print(color.bold(command.name))
	print()
	print(command.description)
	print()
	print(color.bold("Usage:") .. ` {command.usage}`)
	print()
	print(color.dim(`Run 'lune run {command.name} --help' for detailed options.`))
end

local function main()
	local parser = cli.parser()
	parser:setDescription("Displays available commands and their descriptions")
	parser:setCommandName("lune run help")
	parser:add("command", "positional", {
		help = "The command to get help for",
		required = false,
	})
	parser:parse(process.args)

	local commandName = parser:get("command")
	if commandName then
		printCommandHelp(commandName)
	else
		printGeneralHelp()
	end
end

main()


--[[
	Install Entry Point

	Installs all packages.
]]

local process = require("@lune/process")

local cli = require("./lib/cli/parser")
local command = require("./commands/install")

local function main()
	local parser = cli.parser()
	parser:setDescription(command.description)
	parser:setCommandName(`lune run {command.name}`)

	for _, arg: any in command.arguments do
		parser:add(arg.name, arg.kind, arg.options)
	end

	parser:parse(process.args)

	command.run({
		quiet = parser:has("quiet"),
	})
end

main()


--[[
	Error Handling Utilities

	Provides standardized error handling and reporting for CLI commands.
]]

local color = require("../../utils/color")
local process = require("@lune/process")

--[[
	Reports a validation error and exits.

	@param message The error message
	@param code Optional exit code (default: 1)
]]
local function validationError(message: string, code: number?)
	print(color.bold(color.red("Error: " .. message)))
	process.exit(code or 1)
end

--[[
	Reports a process execution error and exits.

	@param message The error message
	@param exitCode The process exit code
	@param stderr Optional stderr output
]]
local function processError(message: string, exitCode: number, stderr: string?)
	print(color.bold(color.red(message)))
	if stderr then
		print(stderr)
	end
	process.exit(exitCode)
end

--[[
	Reports a general error and exits.

	@param message The error message
	@param code Optional exit code (default: 1)
]]
local function error(message: string, code: number?)
	print(color.bold(color.red("Error: " .. message)))
	process.exit(code or 1)
end

return {
	validationError = validationError,
	processError = processError,
	error = error,
}


--[[
	Enhanced Process Execution

	Provides a consistent interface for executing external processes with
	better error handling, quiet/verbose modes, and output management.
]]

local process = require("@lune/process")

type Options = {
	cwd: string?,
	env: { [string]: any }?,
	quiet: boolean?,
}

--[[
	Executes a program with the given parameters.

	@param program The program to execute
	@param params The parameters to pass to the program
	@param options Optional execution options
	@return The stdout output (trimmed of trailing newline)
	@error Throws an error if the process exits with a non-zero code
]]
local function run(program: string, params: { string }, options: Options?): string
	local quiet = if options and options.quiet ~= nil then options.quiet else false

	if not quiet then
		print(`RUN > {program} {table.concat(params, " ")}`)
	end

	local result = process.exec(program, params, {
		stdio = if quiet then "default" else "inherit",
		shell = true,
		cwd = if options then options.cwd else nil,
		env = if options then options.env else nil,
	})

	if result.code == 0 then
		return result.stdout:gsub("\n$", "")
	else
		error(result.stderr)
	end
end

return {
	run = run,
}


--[[
	Rojo Operations

	Provides common Rojo-related operations used across multiple commands.
]]

local process = require("@lune/process")

local rojo = {}

export type BuildOptions = {
	projectFile: string?,
	output: string?,
	plugin: string?,
	quiet: boolean?,
	watch: boolean?,
	stdio: process.ExecStdioKind?,
}

export type BuildResult = {
	ok: boolean,
	code: number,
	stderr: string?,
}

--[[
	Builds a Rojo project to a place file.

	@param options Build options
	@return Build result with success status and any error info
]]
function rojo.build(options: BuildOptions): BuildResult
	local args = { "build" }

	if options.projectFile then
		table.insert(args, options.projectFile)
	end

	if options.output then
		table.insert(args, "--output")
		table.insert(args, options.output)
	end

	if options.plugin then
		table.insert(args, "--plugin")
		table.insert(args, options.plugin)
	end

	if options.watch then
		table.insert(args, "--watch")
	end

	local result = process.exec("rojo", args, {
		stdio = options.stdio,
	})

	return {
		ok = result.ok,
		code = result.code,
		stderr = if result.ok then nil else result.stderr,
	}
end

export type ServeOptions = {
	projectFile: string,
}

--[[
	Starts a Rojo serve session.

	@param options Serve options
	@return Serve result with success status and exit code
]]
function rojo.serve(options: ServeOptions): BuildResult
	local result = process.exec("rojo", {
		"serve",
		options.projectFile,
	}, {
		stdio = "forward",
	})

	return {
		ok = result.ok,
		code = result.code,
		stderr = if result.ok then nil else result.stderr,
	}
end

export type SourcemapOptions = {
	projectFile: string?,
	output: string?,
	includeNonScripts: boolean?,
	absolute: boolean?,
}

--[[
	Generates a Rojo sourcemap.

	@param options Sourcemap options
	@return success, error message
]]
function rojo.sourcemap(options_: SourcemapOptions?): (boolean, string)
	local options: SourcemapOptions = options_ or {}
	local projectFile = if options.projectFile ~= nil then options.projectFile else "default.project.jsonc"
	local output = if options.output ~= nil then options.output else "sourcemap.json"
	local includeNonScripts = if options.includeNonScripts ~= nil then options.includeNonScripts else true
	local absolute = if options.absolute ~= nil then options.absolute else true

	local args = {
		"sourcemap",
		projectFile,
		"--output",
		output,
	}

	if includeNonScripts then
		table.insert(args, "--include-non-scripts")
	end

	if absolute then
		table.insert(args, "--absolute")
	end

	local result = process.exec("rojo", args)
	if not result.ok then
		return false, result.stderr
	end

	return true, result.stdout
end

return rojo


--[[
	Patch Entry Point

	Creates or updates a patch for a Wally package.
]]

local process = require("@lune/process")

local cli = require("./lib/cli/parser")
local command = require("./commands/patch")

local function main()
	local parser = cli.parser()
	parser:setDescription(command.description)
	parser:setCommandName(`lune run {command.name}`)

	for _, arg: any in command.arguments do
		parser:add(arg.name, arg.kind, arg.options)
	end

	parser:parse(process.args)

	command.run({
		packagePath = parser:get("package-path") :: string,
	})
end

main()


--[[
	Setup Entry Point

	Sets up the project for development or CI.
]]

local process = require("@lune/process")

local cli = require("./lib/cli/parser")
local command = require("./commands/setup")

local function main()
	local parser = cli.parser()
	parser:setDescription(command.description)
	parser:setCommandName(`lune run {command.name}`)

	for _, arg: any in command.arguments do
		parser:add(arg.name, arg.kind, arg.options)
	end

	parser:parse(process.args)

	command.run({
		quiet = parser:has("quiet"),
	})
end

main()


local ESCAPE = string.char(27)
local ANSI_16 = `{ESCAPE}[%dm`

export type Styler = (text: string) -> string

local function createStylerFunction(opener: number, closer: number): Styler
	local open = string.format(ANSI_16, opener)
	local close = string.format(ANSI_16, closer)

	return function(text: string)
		return `{open}{text}{close}`
	end
end

local function combineStyles(styleArray: { (text: string) -> string }): Styler
	return function(text: string)
		local result = text

		for _, style in styleArray do
			result = style(result)
		end

		return result
	end
end

return {
	combineStyles = combineStyles,

	dim = createStylerFunction(2, 22),
	reset = createStylerFunction(0, 0),
	bold = createStylerFunction(1, 22),
	italic = createStylerFunction(3, 23),
	hidden = createStylerFunction(8, 28),
	inverse = createStylerFunction(7, 27),
	underline = createStylerFunction(4, 24),
	overline = createStylerFunction(53, 55),
	strikethrough = createStylerFunction(9, 29),

	red = createStylerFunction(31, 39),
	blue = createStylerFunction(34, 39),
	cyan = createStylerFunction(36, 39),
	gray = createStylerFunction(90, 39),
	grey = createStylerFunction(90, 39),
	black = createStylerFunction(30, 39),
	green = createStylerFunction(32, 39),
	white = createStylerFunction(37, 39),
	yellow = createStylerFunction(33, 39),
	magenta = createStylerFunction(35, 39),
	redBright = createStylerFunction(91, 39),
	blueBright = createStylerFunction(94, 39),
	cyanBright = createStylerFunction(96, 39),
	blackBright = createStylerFunction(90, 39),
	greenBright = createStylerFunction(92, 39),
	whiteBright = createStylerFunction(97, 39),
	yellowBright = createStylerFunction(93, 39),
	magentaBright = createStylerFunction(95, 39),
}


--[[
	Environment Variable Utilities

	Loads environment variables from process environment or .env.luau file.
]]

local process = require("@lune/process")

local env = {}

local function loadEnvFile()
	local envFile = require("../../.env") :: any
	return envFile
end

--[[
	Loads an environment variable from the process environment or .env.luau file.

	@param name The environment variable name
	@return The environment variable value, or nil if not found
]]
local function loadEnvVar(name: string): string?
	local keyInEnv = process.env[name]
	if keyInEnv then
		return keyInEnv
	end

	local success, envFile = pcall(loadEnvFile)
	if not success then
		return nil
	end

	return envFile[name]
end
env.loadEnvVar = loadEnvVar

return env


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

local env = require("./env")
local exec = require("../lib/process/exec")

local BuildVarsModule = require("../../plugin/src/BuildVars/Types")
type BuildVars = BuildVarsModule.BuildVars

local OUTPUT_PATH = "plugin/src/BuildVars/BuildVars.json"
local WALLY_TOML_PATH = "plugin/wally.toml"

type Config = {
	production: boolean,
}

local function injectBuildVars(config: Config)
	local isProd = config.production

	local clientId = env.loadEnvVar("DISCORD_CLIENT_ID") :: string
	if not clientId then
		if isProd then
			error("No `DISCORD_CLIENT_ID` provided in environment variables (required for production builds)")
		end
		clientId = "MISSING_CLIENT_ID"
	end

	local gitHash = exec.run("git", { "rev-parse", "--short", "HEAD" }, { quiet = true })

	local wallyToml = serde.decode("toml", fs.readFile(WALLY_TOML_PATH)) :: any
	local version: string = wallyToml.package.version or "0.0.0"

	local vars: BuildVars = {
		build = {
			version = version,
			channel = "live",
			hash = gitHash,
			isDev = not isProd,
		},

		api = {
			secure = isProd,
			host = if isProd then "activity.brooke.sh" else "localhost:8787",
		},

		discord = {
			clientId = clientId,
		},
	}

	local contents = serde.encode("json", vars, true)
	fs.writeFile(OUTPUT_PATH, contents)
end

return injectBuildVars


local setup = require(script.Parent.setup)
local setupMain = require(script.Parent.setupMain)
setup(plugin, setupMain)


--[[
	The purpose of this module is to prevent the plugin loading heavy libraries
	(COUGH react) until the user actually interacts with it.
]]

local StudioService = game:GetService("StudioService")

--[[
	Creates a signal that buffers events fired before any listener connects.
	On first connect, all buffered events are flushed to that listener.
	Calling clearBuffer() discards pending events without delivering them.
]]
local function createBufferedSignal()
	local listeners: { (...any) -> () } = {}
	local buffer: { { [number]: any, n: number } }? = {}

	local function fire(...: any)
		if #listeners > 0 then
			for _, listener in listeners do
				task.spawn(listener, ...)
			end
		elseif buffer then
			table.insert(buffer, table.pack(...))
		end
	end

	local function connect(callback: (...any) -> ()): { Disconnect: () -> () }
		table.insert(listeners, callback)

		if buffer then
			local pending = buffer
			buffer = nil
			for _, args in pending do
				task.spawn(callback, table.unpack(args, 1, args.n))
			end
		end

		return {
			Disconnect = function()
				local index = table.find(listeners, callback)
				if index then
					table.remove(listeners, index)
				end
			end,
		}
	end

	local function clearBuffer()
		buffer = nil
	end

	local function destroy()
		listeners = {}
		buffer = nil
	end

	return {
		Fire = fire,
		Connect = connect,
		ClearBuffer = clearBuffer,
		Destroy = destroy,
	}
end

export type BufferedSignal = typeof(createBufferedSignal())

export type GetLocalizedTextFunc = ((string, string) -> string) -> string

export type ButtonInfo = {
	getName: GetLocalizedTextFunc,
	getDescription: GetLocalizedTextFunc,
	icon: string?,
	text: (string | GetLocalizedTextFunc)?,
	clickableWhenViewportHidden: boolean?,
	enabled: boolean?,
}

export type DockWidgetInfo = {
	dockWidgetPluginGuiInfo: DockWidgetPluginGuiInfo,
	getDockTitle: GetLocalizedTextFunc,
	id: string,
	name: (string | GetLocalizedTextFunc)?,
	zIndexBehavior: Enum.ZIndexBehavior?,
}

export type Args = {
	plugin: Plugin,
	pluginName: string,
	translationResourceTable: LocalizationTable,
	overrideLocaleId: string?,
	getToolbarName: GetLocalizedTextFunc?,
	buttonInfo: ButtonInfo?,
	dockWidgetInfo: DockWidgetInfo?,
	shouldImmediatelyOpen: (() -> boolean)?,
}

export type PluginLoaderContext = {
	plugin: Plugin,
	toolbar: PluginToolbar?,
	mainButton: PluginToolbarButton?,
	mainDockWidget: DockWidgetPluginGui?,
	mainButtonClickedSignal: BufferedSignal,
	waitForUserInteraction: () -> boolean,
	destroy: () -> (),
}

local function getLocale(overrideLocaleId: string?): string
	if overrideLocaleId then
		return overrideLocaleId
	end
	return StudioService["StudioLocaleId"]
end

local function createLocalizer(
	translationResourceTable: LocalizationTable,
	overrideLocaleId: string?
): (string, string) -> string
	local locale = getLocale(overrideLocaleId)
	local translator = translationResourceTable:GetTranslator(locale)

	return function(scope: string, key: string): string
		local stringKey = `{scope}.{key}`
		local success, result = pcall(translator.FormatByKey, translator, stringKey)
		if success then
			return result
		end
		return stringKey
	end
end

local function createPluginLoader(args: Args): PluginLoaderContext
	local pluginInstance = args.plugin
	local connections: { RBXScriptConnection } = {}
	local signals: { BufferedSignal } = {}
	local destroyed = false
	local userHasInteracted = false
	local interactionEvent = Instance.new("BindableEvent")

	local localize = createLocalizer(args.translationResourceTable, args.overrideLocaleId)

	local function onUserInteracted()
		if userHasInteracted then
			return
		end
		userHasInteracted = true
		interactionEvent:Fire()
	end

	-- Create toolbar and button
	local toolbar: PluginToolbar? = nil
	local mainButton: PluginToolbarButton? = nil
	local mainButtonClickedSignal = createBufferedSignal()
	table.insert(signals, mainButtonClickedSignal)

	if args.buttonInfo then
		local buttonInfo = args.buttonInfo

		local toolbarName = if args.getToolbarName then args.getToolbarName(localize) else args.pluginName
		toolbar = pluginInstance:CreateToolbar(toolbarName)

		mainButton = (toolbar :: PluginToolbar):CreateButton(
			buttonInfo.getName(localize),
			buttonInfo.getDescription(localize),
			buttonInfo.icon or "",
			if type(buttonInfo.text) == "function"
				then (buttonInfo.text :: GetLocalizedTextFunc)(localize)
				else buttonInfo.text
		)

		if buttonInfo.clickableWhenViewportHidden then
			(mainButton :: PluginToolbarButton).ClickableWhenViewportHidden = true
		end
		if buttonInfo.enabled ~= nil then
			(mainButton :: PluginToolbarButton).Enabled = buttonInfo.enabled :: boolean
		end

		local buttonConnection = (mainButton :: PluginToolbarButton).Click:Connect(function()
			onUserInteracted()
			mainButtonClickedSignal.Fire()
		end)
		table.insert(connections, buttonConnection)
	end

	-- Create dock widget
	local mainDockWidget: DockWidgetPluginGui? = nil

	if args.dockWidgetInfo then
		local dockInfo = args.dockWidgetInfo
		local dockTitle = dockInfo.getDockTitle(localize)

		mainDockWidget = pluginInstance:CreateDockWidgetPluginGuiAsync(dockInfo.id, dockInfo.dockWidgetPluginGuiInfo)

		local widget = mainDockWidget :: DockWidgetPluginGui
		widget.Title = dockTitle
		widget.ZIndexBehavior = dockInfo.zIndexBehavior or Enum.ZIndexBehavior.Sibling

		if dockInfo.name then
			widget.Name = if type(dockInfo.name) == "function"
				then (dockInfo.name :: GetLocalizedTextFunc)(localize)
				else dockInfo.name :: string
		end

		local widgetConnection = widget:GetPropertyChangedSignal("Enabled"):Connect(function()
			if widget.Enabled then
				onUserInteracted()
			end
			if mainButton then
				(mainButton :: PluginToolbarButton):SetActive(widget.Enabled)
			end
		end)
		table.insert(connections, widgetConnection)

		if widget.Enabled then
			onUserInteracted()
		end
		if mainButton then
			(mainButton :: PluginToolbarButton):SetActive(widget.Enabled)
		end
	end

	if args.shouldImmediatelyOpen and args.shouldImmediatelyOpen() then
		onUserInteracted()
	end

	local function waitForUserInteraction(): boolean
		if not userHasInteracted then
			interactionEvent.Event:Wait()
		end

		-- Discard any events buffered during the wait so that the initial
		-- interaction (e.g. the button click that opened the plugin) does not
		-- replay when the app later connects its listeners.
		mainButtonClickedSignal.ClearBuffer()

		return not destroyed
	end

	local function destroy()
		destroyed = true

		-- Resume any threads blocked in waitForUserInteraction
		interactionEvent:Fire()

		for _, connection in connections do
			connection:Disconnect()
		end
		for _, signal in signals do
			signal.Destroy()
		end

		interactionEvent:Destroy()
	end

	return {
		plugin = pluginInstance,
		toolbar = toolbar,
		mainButton = mainButton,
		mainDockWidget = mainDockWidget,
		mainButtonClickedSignal = mainButtonClickedSignal,
		waitForUserInteraction = waitForUserInteraction,
		destroy = destroy,
	}
end

return createPluginLoader


local Plugin = script:FindFirstAncestor("StudioActivity")

local createPluginLoader = require(script.Parent.createPluginLoader)

-- The main function runs when the plugin is opened for the first time.
-- Returns a cleanup function to be called during plugin unload.
local function main(plugin: Plugin, pluginLoaderContext: createPluginLoader.PluginLoaderContext): () -> ()
	local createPresencePlugin = require(Plugin.Source.Plugin.createPresencePlugin)
	local presencePlugin = createPresencePlugin(plugin, pluginLoaderContext)

	presencePlugin.mount()

	return function()
		presencePlugin.destroy()
	end
end

return main


local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Foundation = require(Packages.Foundation)
local React = require(Packages.React)
local ReactUtils = require(Packages.ReactUtils)

local Types = require(script.Parent.Types)

local View = Foundation.View
local Text = Foundation.Text

local createNextOrder = ReactUtils.createNextOrder

local e = React.createElement

type Props = {
	activity: Types.Activity,
	LayoutOrder: number?,
}

local function ActivityDetails(props: Props)
	local nextOrder = createNextOrder()

	return e(View, {
		tag = "auto-xy col gap-xxsmall",
		LayoutOrder = props.LayoutOrder,
	}, {
		Name = e(Text, {
			tag = "auto-xy text-title-medium content-emphasis",
			Text = props.activity.name,
			LayoutOrder = nextOrder(),
		}),

		Details = props.activity.details and e(Text, {
			tag = "auto-xy text-body-small content-default",
			Text = props.activity.details,
			LayoutOrder = nextOrder(),
		}),

		State = props.activity.state and e(Text, {
			tag = "auto-xy text-body-small content-default",
			Text = props.activity.state,
			LayoutOrder = nextOrder(),
		}),
	})
end

return ActivityDetails


local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Foundation = require(Packages.Foundation)
local React = require(Packages.React)
local ReactCharm = require(Packages.ReactCharm)

local AppAssetsStore = require(Plugin.Source.Api.AppAssetsStore)
local Types = require(script.Parent.Types)
local WebImage = require(script.Parent.WebImage)

local View = Foundation.View
local Tooltip = Foundation.Tooltip
local PopoverSide = Foundation.Enums.PopoverSide
local PopoverAlign = Foundation.Enums.PopoverAlign
local useTokens = Foundation.Hooks.useTokens

local useSignalState = ReactCharm.useSignalState

local e = React.createElement

type Props = {
	assets: Types.ActivityAssets?,
	LayoutOrder: number?,
}

local function ActivityImage(props: Props)
	local tokens = useTokens()
	local appAssets = AppAssetsStore.get()

	-- Subscribe to the assets signal so the component re-renders when
	-- the async fetch completes and the name->id map becomes available.
	useSignalState(appAssets.getAssetsByName)

	local rawLargeImage = props.assets and props.assets.largeImage
	local largeText = props.assets and props.assets.largeText
	local rawSmallImage = props.assets and props.assets.smallImage
	local smallText = props.assets and props.assets.smallText

	local largeImageUrl = if rawLargeImage then appAssets.resolveUrl(rawLargeImage) else nil
	local smallImageUrl = if rawSmallImage then appAssets.resolveUrl(rawSmallImage) else nil

	local largeBlock = largeImageUrl
		and e(View, {
			tag = "size-full",
		}, {
			Image = e(WebImage, {
				url = largeImageUrl,
				size = "big",
			}),
		})

	local smallImageBlock = smallImageUrl and e(WebImage, {
		url = smallImageUrl,
		size = "small",
	})

	return e(View, {
		tag = "size-1600-1600",
		LayoutOrder = props.LayoutOrder,
	}, {
		LargeImageBlock = largeBlock and if largeText
			then e(Tooltip, {
				title = largeText,
				align = PopoverAlign.Center,
				side = PopoverSide.Top,
			}, largeBlock)
			else largeBlock,

		SmallImageBlock = smallImageUrl and e(View, {
			tag = "size-700-700 bg-surface-300 radius-circle",
			stroke = {
				Color = tokens.Color.Surface.Surface_300.Color3,
				Thickness = tokens.Stroke.Thicker,
			},
			AnchorPoint = Vector2.new(0.5, 0.5),
			Position = UDim2.new(1, -9, 1, -9),
			ZIndex = 2,
		}, {
			Image = if smallText
				then e(Tooltip, {
					title = smallText,
					align = PopoverAlign.Center,
					side = PopoverSide.Top,
					ZIndex = 2,
				}, smallImageBlock)
				else smallImageBlock,
		}),
	})
end

return React.memo(ActivityImage)


local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Foundation = require(Packages.Foundation)
local React = require(Packages.React)
local ReactUtils = require(Packages.ReactUtils)

local ActivityDetails = require(script.Parent.ActivityDetails)
local ActivityImage = require(script.Parent.ActivityImage)
local ActivityTimer = require(script.Parent.ActivityTimer)
local Flags = require(Plugin.Source.Flags)
local Types = require(script.Parent.Types)

local View = Foundation.View
local Text = Foundation.Text

local createNextOrder = ReactUtils.createNextOrder

local e = React.createElement

type Props = {
	activity: Types.Activity,
	LayoutOrder: number?,
}

local function ActivityPreview(props: Props)
	local nextOrder = createNextOrder()

	return e(View, {
		tag = "size-full-0 auto-y bg-surface-300 col padding-small gap-medium radius-small stroke-emphasis stroke-thick",
		LayoutOrder = props.LayoutOrder,
	}, {
		Shadow = Flags.isShadowAvailable and e("UIShadow", {
			Transparency = 0.85,
			BlurRadius = UDim.new(0, 12),
		}),

		Title = e(Text, {
			tag = "auto-xy text-caption-small content-default",
			Text = "Live Preview",
			LayoutOrder = nextOrder(),
		}),

		Activity = e(View, {
			tag = "auto-xy row gap-large padding-x-xsmall align-y-center",
			LayoutOrder = nextOrder(),
		}, {
			ActivityImage = e(ActivityImage, {
				assets = props.activity.assets,
				LayoutOrder = nextOrder(),
			}),

			DetailsBlock = e(View, {
				tag = "auto-xy col gap-xsmall",
				LayoutOrder = nextOrder(),
			}, {
				ActivityDetails = e(ActivityDetails, {
					activity = props.activity,
					LayoutOrder = nextOrder(),
				}),

				ActivityTimer = e(ActivityTimer, {
					startedAt = props.activity.startedAt,
					LayoutOrder = nextOrder(),
				}),
			}),
		}),
	})
end

return ActivityPreview


local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Foundation = require(Packages.Foundation)
local React = require(Packages.React)
local Storyteller = require(Packages.Storyteller)

local ActivityPreview = require(script.Parent.ActivityPreview)

local View = Foundation.View

local e = React.createElement

local story: Storyteller.Story<any> = {
	summary = "ActivityPreview gives a visual preview of a Discord Rich Presence (Activity) widget.",
	story = function()
		return e(View, {
			tag = "anchor-center-center position-center-center row align-x-center align-y-center",
			Size = UDim2.new(0, 260, 1, 0),
		}, {
			Story = e(ActivityPreview, {
				activity = {
					name = "Roblox Studio 1",
					details = "Roblox Studio 2",
					state = "Roblox Studio 3",
					assets = {
						largeImage = "",
						largeText = "Editing in Roblox Studio",

						smallImage = "",
						smallText = "Lorem Ipsum",
					},
				},
			}),
		})
	end,
}

return story


local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Foundation = require(Packages.Foundation)
local React = require(Packages.React)
local ReactUtils = require(Packages.ReactUtils)

local useTimerText = require(script.Parent.useTimerText)

local View = Foundation.View
local Text = Foundation.Text
local Icon = Foundation.Icon
local IconName = Foundation.Enums.IconName
local IconVariant = Foundation.Enums.IconVariant
local IconSize = Foundation.Enums.IconSize
local useTokens = Foundation.Hooks.useTokens

local createNextOrder = ReactUtils.createNextOrder

local e = React.createElement
local useMemo = React.useMemo

type Props = {
	startedAt: number?,
	LayoutOrder: number?,
}

local function ActivityTimer(props: Props)
	local nextOrder = createNextOrder()

	local tokens = useTokens()
	local contentColor = tokens.Color.Extended.Green.Green_500

	local startTime = useMemo(function()
		return props.startedAt or os.time()
	end, { props.startedAt })

	local timerText = useTimerText(startTime)

	return e(View, {
		tag = "auto-xy row align-y-center gap-xsmall",
		LayoutOrder = props.LayoutOrder,
	}, {
		Icon = e(Icon, {
			name = IconName.PersonPlayClock,
			variant = IconVariant.Filled,
			size = IconSize.XSmall,
			style = contentColor,
			LayoutOrder = nextOrder(),
		}),

		TimerText = e(Text, {
			tag = "auto-xy text-body-small",
			textStyle = contentColor,
			Text = timerText,
			LayoutOrder = nextOrder(),
		}),
	})
end

return ActivityTimer


export type ActivityAssets = {
	largeImage: string?,
	largeText: string?,
	smallImage: string?,
	smallText: string?,
}

export type Activity = {
	name: string,
	details: string?,
	detailsUrl: string?,
	state: string?,
	stateUrl: string?,
	startedAt: number?,
	assets: ActivityAssets?,
}

return nil


local AssetService = game:GetService("AssetService")

local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Foundation = require(Packages.Foundation)
local React = require(Packages.React)
local png = require(Packages.png)

local Flags = require(Plugin.Source.Flags)
local Logger = require(Plugin.Source.Logger)
local useWebImage = require(script.Parent.useWebImage)

local View = Foundation.View
local Icon = Foundation.Icon
local IconName = Foundation.Enums.IconName
local IconSize = Foundation.Enums.IconSize
local useTokens = Foundation.Hooks.useTokens

local e = React.createElement
local useState = React.useState
local useEffect = React.useEffect

type Props = {
	url: string,
	size: "big" | "small",
	LayoutOrder: number?,
}

local function WebImage(props: Props)
	local tokens = useTokens()

	local imageContent, setImageContent = useState<<Content?>>(nil)
	local hasError, setHasError = useState(false)
	local imageData = useWebImage(props.url)

	local isBig = props.size == "big"

	useEffect(function()
		if imageData == nil then
			setHasError(true)
			return
		end

		local editableImage
		local success, err = pcall(function()
			local decoded = png.decode(imageData)

			local size = Vector2.new(decoded.width, decoded.height)
			editableImage = AssetService:CreateEditableImage({
				Size = size,
			})

			editableImage:WritePixelsBuffer(Vector2.zero, size, decoded.pixels)

			local editableImageContent = Content.fromObject(editableImage)
			if Flags.isDataModelContentApiAvailable then
				-- TODO: Remove the type cast when this API has proper types
				local result, content = (AssetService :: any):CreateDataModelContentAsync(editableImageContent)
				-- TODO Luau: This enum is too new for the LSP to type check
				if result == (Enum :: any).CreateContentResult.Success then
					setImageContent(content)
					editableImage:Destroy()
					editableImage = nil :: any
				else
					setImageContent(editableImageContent)
				end
			else
				setImageContent(editableImageContent)
			end

			-- TODO: Migrate to the static content API once available. Should save
			-- a bit of memory.
		end)

		if not success then
			setHasError(true)
			Logger:Warn("Error while rendering web image", {
				error = err,
			})
		else
			setHasError(false)
		end

		return function()
			if editableImage then
				editableImage:Destroy()
			end
			setImageContent(nil)
		end
	end, { imageData })

	return e(View, {
		tag = {
			["size-full bg-shift-100 row align-y-center align-x-center"] = true,
			["radius-small"] = isBig,
			["radius-circle"] = not isBig,
		},
		LayoutOrder = props.LayoutOrder,
	}, {
		ErrorIcon = hasError and e(Icon, {
			name = IconName.SquareFaceEmoji,
			size = if isBig then IconSize.Medium else IconSize.XSmall,
		}),

		Image = (not hasError) and e("ImageLabel", {
			Size = UDim2.fromScale(1, 1),
			BackgroundTransparency = 1,
			ImageContent = imageContent,
		}, {
			UICorner = e("UICorner", {
				CornerRadius = if isBig then UDim.new(0, tokens.Radius.Small) else UDim.new(1, 0),
			}),
		}),
	})
end

return WebImage


local Plugin = script:FindFirstAncestor("StudioActivity")

local Common = Plugin.Source.Common
local Packages = Plugin.Packages

local React = require(Packages.React)
local formatElapsedTime = require(Common.formatElapsedTime)

local useBinding = React.useBinding
local useEffect = React.useEffect

local function useTimerText(startTime: number): React.Binding<string>
	local timerText, setTimerText = useBinding(formatElapsedTime(0))

	-- Every second, update the timer text with how much time has passed since
	-- the start time.
	useEffect(function()
		local isRunning = true

		task.spawn(function()
			while isRunning do
				local currentTime = os.time()
				local elapsedTime = currentTime - startTime

				setTimerText(formatElapsedTime(elapsedTime))
				task.wait(1)
			end
		end)

		return function()
			isRunning = false
		end
	end, { startTime })

	return timerText
end

return useTimerText


local HttpService = game:GetService("HttpService")

local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local React = require(Packages.React)

local Logger = require(Plugin.Source.Logger)

local useState = React.useState
local useEffect = React.useEffect
local useRef = React.useRef

local function useWebImage(url: string): buffer?
	local imageData, setImageData = useState<<buffer?>>(nil)

	-- Download image from web
	local downloadThread = useRef<<thread?>>(nil)
	useEffect(function()
		if downloadThread.current then
			pcall(task.cancel, downloadThread.current)
		end

		downloadThread.current = task.spawn(function()
			local success, result = pcall(function()
				return HttpService:RequestAsync({
					Url = url,
					Method = "GET",
					Compress = Enum.HttpCompression.Gzip,
				})
			end)

			if not success then
				Logger:Warn("Failed to download web image (network error)", {
					result = result,
				})
				return
			end

			if not result.Success then
				Logger:Warn("Failed to download web image (API error)", {
					result = result,
				})
				return
			end

			local contentType = result.Headers["content-type"] or ""
			if not string.find(contentType, "image/png", 1, true) then
				Logger:Warn("Downloaded image wasn't a PNG", {
					url = url,
					contentType = contentType,
				})
				return
			end

			-- We downloaded the image!
			setImageData(buffer.fromstring(result.Body :: string))
		end)

		return function()
			if downloadThread.current then
				pcall(task.cancel, downloadThread.current)
			end
		end
	end, { url })

	return imageData
end

return useWebImage


local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Foundation = require(Packages.Foundation)
local React = require(Packages.React)
local ReactUtils = require(Packages.ReactUtils)

local Telemetry = require(Plugin.Source.Telemetry.fireEventAsync)
local Types = require(Plugin.Source.Telemetry.Types)

local AuthContext = require(script.Parent.AuthContext)
local StartPage = require(script.Parent.StartPage.StartPage)
local Title = require(script.Parent.Title)

local Sheet = Foundation.Sheet
local DialogSize = Foundation.Enums.DialogSize

local createNextOrder = ReactUtils.createNextOrder
local useEffect = React.useEffect

local e = React.createElement

type Props = {
	isOpen: boolean,
	onClose: (result: any?) -> (),
}

local function AddAccountFlow(props: Props)
	local nextOrder = createNextOrder()

	useEffect(function()
		if props.isOpen then
			Telemetry.fireEvent({ type = "accountLinkStarted", value = Types.AccountLinkStarted.new() })
		end
	end, { props.isOpen })

	return e(Sheet.Root, {
		isOpen = props.isOpen,
		preferCenterSheet = true,
		size = DialogSize.Small,
		onClose = props.onClose,
	}, {
		Header = e(Sheet.Header, {
			hasCloseAffordance = true,
		}, {
			Title = e(Title, {
				LayoutOrder = nextOrder(),
			}),
		}),

		Content = props.isOpen and e(Sheet.Content, nil, {
			Context = e(ReactUtils.ContextStack, {
				providers = {
					e(AuthContext.Provider, {
						onClose = props.onClose,
					}),
				},
			}, {
				-- This was setup to have multiple pages, but not sure if it
				-- will anymore.
				StartPage = e(StartPage, {
					LayoutOrder = nextOrder(),
				}),
			}),
		}),
	})
end

return AddAccountFlow


local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local React = require(Packages.React)
local Storyteller = require(Packages.Storyteller)

local AddAccountFlow = require(script.Parent.AddAccountFlow)

local e = React.createElement

local story: Storyteller.Story<any> = {
	story = function()
		return e(AddAccountFlow, {
			isOpen = true,
			onClose = function() end,
		})
	end,
}

return story


local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Charm = require(Packages.Charm)
local React = require(Packages.React)

local BackendApi = require(Plugin.Source.Api.Backend)
local BuildVars = require(Plugin.Source.BuildVars)
local DiscordApi = require(Plugin.Source.Api.Discord)

local BrowserCodeFlow = require(Plugin.Source.Authentication.Flows.BrowserCodeFlow)
local DeviceCodeFlow = require(Plugin.Source.Authentication.Flows.DeviceCodeFlow)
type BrowserCodeFlow = BrowserCodeFlow.BrowserCodeFlow
type DeviceCodeFlow = DeviceCodeFlow.DeviceCodeFlow

local e = React.createElement
local useRef = React.useRef
local useEffect = React.useEffect
local useContext = React.useContext

export type AuthContext = {
	deviceCodeFlow: DeviceCodeFlow,
	browserCodeFlow: BrowserCodeFlow,
}

local AuthContext = React.createContext<<AuthContext?>>(nil)
AuthContext.displayName = "AuthContext"

-- Construct fresh flow instances. Cheap (just signals + closures) — the
-- network calls only start once `requestAsync` is invoked from the effect.
local function createFlows(): AuthContext
	local discord = DiscordApi.new({
		clientId = BuildVars.discord.clientId,
	})
	local backend = BackendApi.new(BuildVars.api)

	return {
		deviceCodeFlow = DeviceCodeFlow.create({ discord = discord }),
		browserCodeFlow = BrowserCodeFlow.create({ discord = discord, backend = backend }),
	}
end

local function AuthContextProvider(props: {
	onClose: () -> (),
	children: React.ReactNode,
}): React.ReactNode
	-- Lazy ref init: build the flows exactly once per real component
	-- lifetime. The ref keeps the same object across re-renders, and a
	-- fresh component instance gets a fresh ref, so we never leak flows
	-- and never observe nil during render.
	local flowsRef = useRef<<AuthContext?>>(nil)
	if flowsRef.current == nil then
		flowsRef.current = createFlows()
	end
	local flows = flowsRef.current :: AuthContext

	useEffect(function()
		flows.deviceCodeFlow.requestAsync()
		flows.browserCodeFlow.requestAsync()

		-- Close the sheet as soon as either flow authorizes.
		local dispose = Charm.effect(function()
			local deviceStatus: DeviceCodeFlow.Status = flows.deviceCodeFlow.status() :: DeviceCodeFlow.Status
			local browserStatus: BrowserCodeFlow.Status = flows.browserCodeFlow.status() :: BrowserCodeFlow.Status

			if deviceStatus == "authorized" or browserStatus == "authorized" then
				props.onClose()
			end
		end)

		return function()
			dispose()
			-- destroy() cancels in-flight polling and resets each flow
			-- to "idle" without freeing the object. Strict Mode's
			-- simulated unmount→remount will simply re-`requestAsync`
			-- on the same instances, which is exactly the behavior we
			-- want for a real "user reopens the sheet" cycle too.
			flows.deviceCodeFlow.destroy()
			flows.browserCodeFlow.destroy()
		end
	end, {})

	return e(AuthContext.Provider, {
		value = flows,
	}, props.children)
end

local function useAuth(): AuthContext
	local context = useContext(AuthContext)
	if context == nil then
		error("useAuth must be used from within an AuthContextProvider")
	end
	return context
end

return {
	Context = AuthContext,
	Provider = AuthContextProvider,
	useAuth = useAuth,
}


local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Foundation = require(Packages.Foundation)
local React = require(Packages.React)
local ReactCharm = require(Packages.ReactCharm)

local AuthContext = require(script.Parent.Parent.AuthContext)
local Steps = require(script.Parent.Steps)

local TextInput = Foundation.TextInput
local InputSize = Foundation.Enums.InputSize

local useSignalState = ReactCharm.useSignalState

local e = React.createElement
local useState = React.useState
local useEffect = React.useEffect
local useCallback = React.useCallback

local function BrowserFlow()
	local auth = AuthContext.useAuth()

	local status = useSignalState(auth.browserCodeFlow.status)
	local sessionInfo = useSignalState(auth.browserCodeFlow.sessionInfo)
	local errorText = useSignalState(auth.browserCodeFlow.error)

	local textbox, setTextbox = useState<<TextBox?>>(nil)

	local selectAll = useCallback(function()
		if textbox then
			local len = #textbox.Text
			if len == 0 then
				return
			end

			if not textbox:IsFocused() then
				textbox:CaptureFocus()
			end

			textbox.SelectionStart = 1
			textbox.CursorPosition = len + 1
		end
	end, { textbox })

	useEffect(function()
		if textbox then
			textbox.TextEditable = false

			local focusConnection = textbox.Focused:Connect(selectAll)

			-- Re-select all if the user clicks to move the cursor while focused.
			-- CursorPosition changes when the user clicks within the box or
			-- arrow-keys around, so we catch that and snap the selection back.
			local cursorChangedConnection = textbox:GetPropertyChangedSignal("CursorPosition"):Connect(function()
				-- CursorPosition is -1 when the box isn't focused
				if textbox.CursorPosition == -1 then
					return
				end

				selectAll()
			end)

			return function()
				focusConnection:Disconnect()
				cursorChangedConnection:Disconnect()
			end :: any
		end

		return nil
	end, { textbox :: any, selectAll })

	-- Show whichever message best reflects the current state.
	local hint: string?
	if status == "requesting" then
		hint = "Generating link…"
	elseif status == "exchanging" then
		hint = "Linking your account…"
	elseif status == "authorized" then
		hint = "Account linked. You're all set."
	elseif status == "denied" then
		hint = "You cancelled the request in Discord."
	elseif status == "expired" then
		hint = "This link expired. Try again."
	elseif status == "error" then
		hint = `Something went wrong: {errorText or "unknown error"}`
	else
		hint = nil
	end

	local startUrl = if sessionInfo then sessionInfo.startUrl else ""

	return e(Steps, {
		steps = {
			{
				id = "step-1",
				text = "Select and copy this link",
				content = e(TextInput, {
					onReturnPressed = function() end,
					text = startUrl,
					label = "",
					hint = hint,
					width = UDim.new(1, 0),
					size = InputSize.Small,
					ref = function(rbx: Frame?)
						if rbx then
							setTextbox(rbx:FindFirstChildWhichIsA("TextBox", true))
						end
					end,
					onChanged = function() end,
				}),
			},
			{
				id = "step-2",
				text = "Paste into your browser and authorize",
			},
			{
				id = "step-3",
				text = "Return here when done",
			},
		},
	})
end

return React.memo(BrowserFlow)


local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Foundation = require(Packages.Foundation)
local React = require(Packages.React)
local ReactCharm = require(Packages.ReactCharm)
local ReactUtils = require(Packages.ReactUtils)

local AuthContext = require(script.Parent.Parent.AuthContext)
local QRCode = require(Plugin.Source.QRCode)

local View = Foundation.View
local Text = Foundation.Text
local Loading = Foundation.Loading
local Icon = Foundation.Icon
local IconName = Foundation.Enums.IconName
local IconSize = Foundation.Enums.IconSize
local useTokens = Foundation.Hooks.useTokens

local createNextOrder = ReactUtils.createNextOrder
local useSignalState = ReactCharm.useSignalState

local e = React.createElement

type Props = {
	LayoutOrder: number?,
}

local function QRCodeFlow(props: Props)
	local tokens = useTokens()
	local nextOrder = createNextOrder()
	local auth = AuthContext.useAuth()

	local status = useSignalState(auth.deviceCodeFlow.status)
	local errorText = useSignalState(auth.deviceCodeFlow.error)
	local sessionInfo = useSignalState(auth.deviceCodeFlow.sessionInfo)

	local showQrCode = status == "polling" or status == "authorized"

	return e(View, {
		tag = "size-full-0 auto-y row gap-large align-y-center padding-top-large",
		LayoutOrder = props.LayoutOrder,
	}, {
		QRCodeContainer = e(View, {
			tag = "size-2400-2400 padding-small radius-small row align-x-center align-y-center",
			backgroundStyle = tokens.LightMode.Surface.Surface_100,
			LayoutOrder = nextOrder(),
		}, {
			Loading = (status == "requesting") and e(Loading, {
				size = IconSize.Small,
			}),

			ErrorIcon = (status == "error") and e(Icon, {
				name = IconName.X,
				size = IconSize.Large,
			}),

			QRCode = (sessionInfo and showQrCode) and e(QRCode.QRCodeDisplay, {
				text = sessionInfo.verificationUrlComplete,
				size = tokens.Size.Size_2000,
				errorCorrection = QRCode.ErrorCorrection.Low,
			}),
		}),

		TextBlock = e(View, {
			tag = "grow auto-y col gap-xsmall",
			LayoutOrder = nextOrder(),
		}, {
			Title = e(Text, {
				tag = "size-full-0 auto-y text-wrap text-align-x-left text-label-medium content-emphasis",
				Text = "Scan with your phone",
				LayoutOrder = nextOrder(),
			}),

			Subtitle = e(Text, {
				tag = {
					["size-full-0 auto-y text-wrap text-align-x-left text-body-small"] = true,
					["content-default"] = status ~= "error",
					["content-alert"] = status == "error",
				},
				Text = if status == "error"
					then `We ran into an issue: {errorText}`
					else "Opens in the Discord app. Authorize there, then return to Studio.",
				LayoutOrder = nextOrder(),
			}),
		}),
	})
end

return React.memo(QRCodeFlow)


local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Foundation = require(Packages.Foundation)
local React = require(Packages.React)
local ReactUtils = require(Packages.ReactUtils)

local BrowserFlow = require(script.Parent.BrowserFlow)
local QRCodeFlow = require(script.Parent.QRCodeFlow)

local View = Foundation.View
local Tabs = Foundation.Tabs
local InputSize = Foundation.Enums.InputSize
local FillBehavior = Foundation.Enums.FillBehavior

local createNextOrder = ReactUtils.createNextOrder

local e = React.createElement

type Props = {
	LayoutOrder: number?,
}

local function StartPage(props: Props)
	local nextOrder = createNextOrder()

	return e(View, {
		tag = "size-full-0 auto-y col gap-small",
		sizeConstraint = {
			MinSize = Vector2.new(0, 180),
		},
		LayoutOrder = props.LayoutOrder,
	}, {
		TabSelector = e(Tabs, {
			defaultActiveTabId = "browser",
			onActivated = function() end,
			size = InputSize.Small,
			fillBehavior = FillBehavior.Fill,
			LayoutOrder = nextOrder(),
			tabs = {
				{
					id = "browser",
					text = "Desktop",
					content = e(BrowserFlow) :: any,
				},
				{
					id = "qr-code",
					text = "Mobile",
					content = e(QRCodeFlow) :: any,
				},
			},
		}),
	})
end

return StartPage


local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Foundation = require(Packages.Foundation)
local React = require(Packages.React)
local ReactUtils = require(Packages.ReactUtils)

local View = Foundation.View
local Text = Foundation.Text

local createNextOrder = ReactUtils.createNextOrder

local e = React.createElement

type Step = {
	id: string,
	text: string,
	content: React.ReactNode,
}

type StepProps = Step & {
	index: number,
	LayoutOrder: number?,
}

local function Step(props: StepProps)
	local nextOrder = createNextOrder()

	return e(View, {
		tag = "size-full-0 auto-y row gap-medium",
		LayoutOrder = props.LayoutOrder,
	}, {
		StepLabel = e(View, {
			tag = "size-600-600 radius-circle bg-shift-100 row align-x-center align-y-center",
			LayoutOrder = nextOrder(),
		}, {
			Label = e(Text, {
				tag = "auto-xy text-body-small content-default",
				Text = tostring(props.index),
				LayoutOrder = nextOrder(),
			}),
		}),

		ContentBlock = e(View, {
			tag = "grow auto-y col gap-small padding-top-xsmall",
			LayoutOrder = nextOrder(),
		}, {
			StepText = e(Text, {
				tag = "size-full-0 auto-y text-label-small content-default text-align-x-left text-wrap",
				Text = props.text,
				LayoutOrder = nextOrder(),
			}),

			CustomContentWrapper = props.content and e(View, {
				tag = "size-full-0 auto-y col gap-small",
				LayoutOrder = nextOrder(),
			}, props.content),
		}),
	})
end

type Props = {
	steps: { Step },
	LayoutOrder: number?,
}

local function Steps(props: Props)
	local stepChildren: { React.ReactNode } = {}
	for index, step in props.steps do
		table.insert(
			stepChildren,
			e(Step, {
				key = step.id,
				id = step.id,
				text = step.text,
				content = step.content,

				index = index,
				LayoutOrder = index,
			})
		)
	end

	return e(View, {
		tag = "size-full-0 auto-y gap-medium padding-y-medium",
		LayoutOrder = props.LayoutOrder,
	}, stepChildren)
end

return Steps


local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Foundation = require(Packages.Foundation)
local React = require(Packages.React)
local ReactUtils = require(Packages.ReactUtils)

local View = Foundation.View
local Text = Foundation.Text
local Icon = Foundation.Icon
local IconName = Foundation.Enums.IconName
local IconSize = Foundation.Enums.IconSize
local useTokens = Foundation.Hooks.useTokens

local createNextOrder = ReactUtils.createNextOrder

local e = React.createElement

type Props = {
	LayoutOrder: number?,
}

local function Title(props: Props)
	local tokens = useTokens()
	local nextOrder = createNextOrder()

	return e(View, {
		tag = "size-full-0 auto-y row gap-medium padding-top-small align-y-center",
		LayoutOrder = props.LayoutOrder,
	}, {
		Discord = e(Icon, {
			name = IconName.Discord,
			size = IconSize.Large,
			style = tokens.Color.Content.Emphasis,
			LayoutOrder = nextOrder(),
		}),

		TextBlock = e(View, {
			tag = "size-full-0 auto-y col",
			LayoutOrder = nextOrder(),
		}, {
			Title = e(Text, {
				tag = "size-full-0 auto-y text-title-medium content-emphasis text-wrap text-align-x-left",
				Text = "Link Discord",
				LayoutOrder = nextOrder(),
			}),

			Subtitle = e(Text, {
				tag = "size-full-0 auto-y text-caption-medium content-default text-wrap text-align-x-left",
				Text = "Show Studio activity on your profile",
				LayoutOrder = nextOrder(),
			}),
		}),
	})
end

return Title


local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Charm = require(Packages.Charm)

local BuildVars = require(Plugin.Source.BuildVars)
local DiscordApi = require(Plugin.Source.Api.Discord)
local Logger = require(Plugin.Source.Logger)

type ApplicationAsset = DiscordApi.ApplicationAsset

local CDN_BASE = "https://cdn.discordapp.com"

local function createAppAssetsStore(discord: DiscordApi.DiscordApi)
	local clientId = BuildVars.discord.clientId
	local getAssetsByName, setAssetsByName = Charm.signal({} :: { [string]: ApplicationAsset })

	-- Fetch assets once on creation
	task.spawn(function()
		local result = discord.getApplicationAssetsAsync()
		if not result.ok then
			Logger:Warn("Failed to fetch application assets", {
				errorType = result.error.type,
			})
			return
		end

		local byName: { [string]: ApplicationAsset } = {}
		for _, asset in result.value do
			byName[asset.name] = asset
		end
		setAssetsByName(byName)

		Logger:Info(`Cached {#result.value} application assets`)
	end)

	local function resolveId(nameOrId: string): string
		local assets = getAssetsByName()
		local asset = assets[nameOrId]
		if asset then
			return asset.id
		end
		return nameOrId
	end

	local function resolveUrl(nameOrId: string): string
		if string.sub(nameOrId, 1, 4) == "http" then
			return nameOrId
		end

		local id = resolveId(nameOrId)
		return `{CDN_BASE}/app-assets/{clientId}/{id}.png`
	end

	return {
		getAssetsByName = getAssetsByName,
		resolveId = resolveId,
		resolveUrl = resolveUrl,
	}
end

return {
	get = Charm.computed(function()
		local discord = DiscordApi.new({
			clientId = BuildVars.discord.clientId,
		})

		return createAppAssetsStore(discord)
	end),
}


local base64 = require("@self/base64")
local message = require("@self/message")
local typeRegistry = require("@self/typeRegistry")

export type Descriptor = message.Descriptor
export type Message<T, Fields> = message.Message<T, Fields>
export type CustomJson<T, Json> = message.CustomJson<T, Json>
export type TypeRegistry = typeRegistry.TypeRegistry

local proto = {}

export type Enum<T> = {
	fromName: (string) -> T?,

	fromNumber: (number) -> T?,
	toNumber: (T) -> number,
}

-- TODO: Round-trip all numbers in range for varint reader functions

local function readVarIntBytes(input: buffer, cursor: number): (number, number)
	local lo = 0
	local hi = 0
	local shift = 0

	repeat
		local b = buffer.readu8(input, cursor)
		local n = bit32.band(b, 0b0111_1111)
		lo = bit32.bor(lo, bit32.lshift(n, shift))
		hi = bit32.bor(hi, bit32.lshift(n, shift - 32))
		shift += 7
		cursor += 1
	until not bit32.btest(b, 0b1000_0000)

	return lo, hi, cursor
end

function proto.readVarIntU64(input: buffer, cursor: number): (number, number)
	local lo, hi
	lo, hi, cursor = readVarIntBytes(input, cursor)

	return hi * 0x100000000 + lo, cursor
end

function proto.readVarIntU32(input: buffer, cursor: number): (number, number)
	local lo, _
	lo, _, cursor = readVarIntBytes(input, cursor)

	return lo, cursor
end

function proto.readVarIntI64(input: buffer, cursor: number): (number, number)
	local lo, hi
	lo, hi, cursor = readVarIntBytes(input, cursor)

	if hi < 0x80000000 then
		return hi * 0x100000000 + lo, cursor
	end

	lo = bit32.bnot(lo) + 1
	hi = bit32.bnot(hi)

	if lo > 0xFFFFFFFF then
		lo = 0
		hi += 1
	end

	return -(hi * 0x100000000 + lo), cursor
end

function proto.readVarIntI32(input: buffer, cursor: number): (number, number)
	local lo, _
	lo, _, cursor = readVarIntBytes(input, cursor)

	if lo < 0x80000000 then
		return lo, cursor
	end

	return lo - 0x100000000, cursor
end

function proto.readVarIntS64(input: buffer, cursor: number): (number, number)
	local lo, hi
	lo, hi, cursor = readVarIntBytes(input, cursor)

	local isOdd = bit32.btest(lo, 1) -- will be negative
	if isOdd then
		-- add 1
		lo += 1
		if lo > 0xFFFFFFFF then
			lo = 0
			hi += 1
		end
	end

	-- edge case: hi overflows 32-bit after adding 1
	-- this only happens when the encoded value is -2^63
	if hi > 0xFFFFFFFF then
		return -2 ^ 63, cursor
	end

	-- divide by 2
	lo = bit32.bor(bit32.rshift(lo, 1), bit32.lshift(bit32.band(hi, 0b1), 31))
	hi = bit32.rshift(hi, 1)

	if isOdd then
		return -(hi * 0x100000000 + lo), cursor
	end

	return hi * 0x100000000 + lo, cursor
end

function proto.readVarIntS32(input: buffer, cursor: number): (number, number)
	local lo, _
	lo, _, cursor = readVarIntBytes(input, cursor)

	if bit32.btest(lo, 1) then
		return -(lo + 1) / 2, cursor
	end

	return lo / 2, cursor
end

proto.readVarInt = proto.readVarIntI64

function proto.readFloat(input: buffer, cursor: number): (number, number)
	return buffer.readf32(input, cursor), cursor + 4
end

function proto.readDouble(input: buffer, cursor: number): (number, number)
	return buffer.readf64(input, cursor), cursor + 8
end

function proto.readFixed32(input: buffer, cursor: number): (number, number)
	return buffer.readu32(input, cursor), cursor + 4
end

function proto.readSignedFixed32(input: buffer, cursor: number): (number, number)
	return buffer.readi32(input, cursor), cursor + 4
end

-- TODO: Test with 0 to 2^32
function proto.readFixed64(output: buffer, cursor: number): (number, number)
	-- =(
	local hex = string.format("%08x", buffer.readu32(output, cursor + 4))
		.. string.format("%08x", buffer.readu32(output, cursor))

	return assert(tonumber(hex, 16), "Couldn't parse created hex"), cursor + 8
end

function proto.readSignedFixed64(_output: buffer, _cursor: number): (number, number)
	error("TODO: Read sfixed64")
end

function proto.readTag(input: buffer, cursor: number): (number, number, number)
	local varInt, newCursor = proto.readVarInt(input, cursor)
	return bit32.rshift(varInt, 3), bit32.band(varInt, 0b111), newCursor
end

function proto.readBuffer(input: buffer, cursor: number): (buffer, number)
	local length, newCursor = proto.readVarInt(input, cursor)
	local output = buffer.create(length)
	buffer.copy(output, 0, input, newCursor, length)
	return output, newCursor + length
end

local function reserveBuffer(output: buffer, cursor: number, amount: number): buffer
	if buffer.len(output) - cursor < amount then
		local newBuffer = buffer.create(3 ^ math.ceil(math.log(buffer.len(output) + amount, 3)))
		buffer.copy(newBuffer, 0, output, 0, cursor)
		return newBuffer
	else
		return output
	end
end

proto.wireTypes = {
	varint = 0,
	i64 = 1,
	lengthDelimited = 2,
	sgroup = 3,
	egroup = 4,
	i32 = 5,
}

function proto.writeVarInt(output: buffer, cursor: number, number: number): (buffer, number)
	if number >= 0 then
		repeat
			output = reserveBuffer(output, cursor, 1)
			local nextByte = number % 0x80
			number = number // 0x80
			buffer.writeu8(output, cursor, if number == 0 then nextByte else nextByte + 0x80)
			cursor += 1
		until number == 0
	else
		local positive = -number
		local hi = bit32.bnot(positive // 0x100000000)
		local lo = bit32.bnot(positive % 0x100000000) + 1
		if lo == 0x100000000 then
			lo = 0
			hi += 1
		end

		output = reserveBuffer(output, cursor, 10)
		buffer.writeu8(output, cursor, bit32.bor(0x80, bit32.band(lo, 0x7F)))
		buffer.writeu8(output, cursor + 1, bit32.bor(0x80, bit32.band(bit32.rshift(lo, 7), 0x7F)))
		buffer.writeu8(output, cursor + 2, bit32.bor(0x80, bit32.band(bit32.rshift(lo, 14), 0x7F)))
		buffer.writeu8(output, cursor + 3, bit32.bor(0x80, bit32.band(bit32.rshift(lo, 21), 0x7F)))
		buffer.writeu8(
			output,
			cursor + 4,
			bit32.bor(0x80, bit32.lshift(bit32.band(hi, 0b111), 4), bit32.rshift(lo, 28))
		)
		buffer.writeu8(output, cursor + 5, bit32.bor(0x80, bit32.band(bit32.rshift(hi, 3), 0x7F)))
		buffer.writeu8(output, cursor + 6, bit32.bor(0x80, bit32.band(bit32.rshift(hi, 10), 0x7F)))
		buffer.writeu8(output, cursor + 7, bit32.bor(0x80, bit32.band(bit32.rshift(hi, 17), 0x7F)))
		buffer.writeu8(output, cursor + 8, bit32.bor(0x80, bit32.band(bit32.rshift(hi, 24), 0x7F)))
		buffer.writeu8(output, cursor + 9, bit32.rshift(hi, 31))
		cursor += 10
	end

	return output, cursor
end

function proto.writeVarIntZigZag(output: buffer, cursor: number, number: number): (buffer, number)
	local isNegative = number < 0
	number = math.abs(number)

	local hi = number // 0x100000000
	local lo = number % 0x100000000

	-- multiply by 2
	local overflow = bit32.extract(lo, 31)
	lo = bit32.lshift(lo, 1)
	hi = bit32.bor(bit32.lshift(hi, 1), overflow)

	if isNegative then
		-- subtract 1
		lo -= 1
		if lo < 0 then
			lo = 0xFFFFFFFF
			hi -= 1
		end
	end

	repeat
		output = reserveBuffer(output, cursor, 1)
		local nextByte = lo % 0x80
		lo = bit32.bor(bit32.rshift(lo, 7), bit32.lshift(bit32.band(hi, 0x7F), 25))
		hi = bit32.rshift(hi, 7)
		local last = lo == 0 and hi == 0
		buffer.writeu8(output, cursor, if last then nextByte else nextByte + 0x80)
		cursor += 1
	until last

	return output, cursor
end

function proto.writeFloat(output: buffer, cursor: number, float: number): (buffer, number)
	output = reserveBuffer(output, cursor, 4)
	buffer.writef32(output, cursor, float)
	return output, cursor + 4
end

function proto.writeDouble(output: buffer, cursor: number, double: number): (buffer, number)
	output = reserveBuffer(output, cursor, 8)
	buffer.writef64(output, cursor, double)
	return output, cursor + 8
end

function proto.writeFixed32(output: buffer, cursor: number, input: number): (buffer, number)
	output = reserveBuffer(output, cursor, 4)
	buffer.writeu32(output, cursor, input)
	return output, cursor + 4
end

function proto.writeSignedFixed32(output: buffer, cursor: number, input: number): (buffer, number)
	output = reserveBuffer(output, cursor, 4)
	buffer.writei32(output, cursor, input)
	return output, cursor + 4
end

function proto.writeFixed64(output: buffer, cursor: number, input: number): (buffer, number)
	output = reserveBuffer(output, cursor, 8)

	-- =(
	local formatted = string.format("%016x", input)
	buffer.writeu32(output, cursor, (assert(tonumber(formatted:sub(9, 16), 16), "Couldn't parse created hex")))
	buffer.writeu32(output, cursor + 4, (assert(tonumber(formatted:sub(1, 8), 16), "Couldn't parse created hex")))

	return output, cursor + 8
end

function proto.writeSignedFixed64(_output: buffer, _cursor: number, _input: number): (buffer, number)
	error("TODO: Write sfixed64")
end

function proto.writeTag(output: buffer, cursor: number, field: number, wireType: number): (buffer, number)
	return proto.writeVarInt(output, cursor, bit32.lshift(field, 3) + wireType)
end

function proto.writeBuffer(output: buffer, cursor: number, input: buffer, length: number): (buffer, number)
	output, cursor = proto.writeVarInt(output, cursor, length)

	output = reserveBuffer(output, cursor, length)
	buffer.copy(output, cursor, input, 0, length)

	return output, cursor + length
end

-- U+FFFD REPLACEMENT CHARACTER in UTF-8
-- selene: allow(bad_string_escape)
local UNICODE_REPLACEMENT_CHARACTER = "\xEF\xBF\xBD"

-- This puts in more Unicode replacement characters than it should, or at least more than Rust does.
-- fn main() {
--    let x = String::from_utf8_lossy(b"\xE0\xA0");
--    println!("{x}");
--}
-- ...only prints one replacement character, which is apparently more correct.
local function sanitizeUtf8(input: string): string
	while true do
		local length, invalidBytePosition = utf8.len(input)
		if length ~= nil then
			return input
		end

		if invalidBytePosition == nil then
			-- Entire string is bad
			return string.rep(UNICODE_REPLACEMENT_CHARACTER, #input)
		else
			input = input:sub(1, invalidBytePosition - 1)
				.. UNICODE_REPLACEMENT_CHARACTER
				.. input:sub(invalidBytePosition + 1)
		end
	end
end

function proto.writeString(output: buffer, cursor: number, text: string): (buffer, number)
	text = sanitizeUtf8(text)
	return proto.writeBuffer(output, cursor, buffer.fromstring(text), #text)
end

function proto.decodeString(input: buffer): string
	return sanitizeUtf8(buffer.tostring(input))
end

proto.json = {}

function proto.json.serializeNumber(input: number): string | number
	return if input == math.huge
		then "Infinity"
		elseif input == -math.huge then "-Infinity"
		elseif input ~= input then "NaN"
		else input
end

function proto.json.deserializeNumber(input: string | number): number
	if input == "Infinity" then
		return math.huge
	elseif input == "-Infinity" then
		return -math.huge
	elseif input == "NaN" then
		return 0 / 0
	elseif typeof(input) == "string" then
		local number = tonumber(input)
		assert(number ~= nil, "Number given that could not be parsed")
		return number
	else
		return input
	end
end

function proto.json.serializeBuffer(input: buffer): string
	return base64.encode(buffer.tostring(input))
end

function proto.json.deserializeBuffer(input: string): buffer
	return buffer.fromstring(base64.decode(input))
end

return proto


export type Descriptor = {
	name: string,
	fullName: string,
}

export type Message<T, Fields> = {
	new: (fields: Fields?) -> T,

	encode: (T) -> buffer,
	decode: (buffer) -> T,

	jsonEncode: (T) -> any,
	jsonDecode: ({ [string]: any }) -> T,

	descriptor: Descriptor,
} & T

-- Note that all consumers of CustomJson intentionally `any` their jsonDecode.
-- This is because it's the best way we can get Luau to actually "override".
export type CustomJson<T, Json> = {
	jsonEncode: (T) -> Json,
	jsonDecode: (Json) -> T,
}

return nil


--!strict
local TypeRegistry = {}
TypeRegistry.__index = TypeRegistry

local message = require("./message")

export type TypeRegistry = typeof(setmetatable(
	{} :: {
		_types: { [string]: message.Message<unknown, unknown> },
	},
	TypeRegistry
))

function TypeRegistry.new(inputTypes: { message.Message<any, any> })
	local types = {}

	for _, inputType in inputTypes do
		types[inputType.descriptor.fullName] = inputType
	end

	return setmetatable({
		_types = types,
	}, TypeRegistry)
end

function TypeRegistry.findMessage(self: TypeRegistry, fullName: string): message.Message<unknown, unknown>?
	return self._types[fullName]
end

function TypeRegistry.register(self: TypeRegistry, inputType: message.Message<any, any>)
	self._types[inputType.descriptor.fullName] = inputType
end

local default = TypeRegistry.new({})

return {
	TypeRegistry = TypeRegistry,
	default = default,
}


export type Ok<T> = {
	ok: true,
	value: T,
}

export type Err<E> = {
	ok: false,
	error: E,
}

export type Result<T, E> = Ok<T> | Err<E>

local Result = {}

--[[
	Creates a successful result.
]]
function Result.ok<T>(value: T): Ok<T>
	return {
		ok = true,
		value = value,
	}
end

--[[
	Creates an error result.
]]
function Result.err<E>(error: E): Err<E>
	return {
		ok = false,
		error = error,
	}
end

--[[
	Unwraps a result, returning the value if ok or throwing if error.
	@param result The result to unwrap
	@param errorMessage Optional custom error message
]]
function Result.unwrap<T, E>(result: Result<T, E>, errorMessage: string?): T
	if result.ok then
		return result.value
	else
		error(errorMessage or tostring(result.error))
	end
end

--[[
	Unwraps a result, returning the value if ok or a default value if error.
]]
function Result.unwrapOr<T, E>(result: Result<T, E>, default: T): T
	if result.ok then
		return result.value
	else
		return default
	end
end

--[[
	Maps a successful result's value using the provided function.
]]
function Result.map<T, U, E>(result: Result<T, E>, fn: (T) -> U): Result<U, E>
	if result.ok then
		return Result.ok(fn(result.value))
	else
		return result :: any
	end
end

--[[
	Maps an error result's error using the provided function.
]]
function Result.mapErr<T, E, F>(result: Result<T, E>, fn: (E) -> F): Result<T, F>
	if result.ok then
		return result :: any
	else
		return Result.err(fn(result.error))
	end
end

return Result


export type BackoffConfig = {
	--- Initial delay in seconds for the first retry
	initialDelaySeconds: number,
	--- Maximum delay in seconds (caps exponential growth)
	maxDelaySeconds: number,
	--- Base multiplier for exponential growth (default: 2)
	multiplier: number?,
	--- Jitter factor (0 to 1, default: 0.1 = 10% jitter)
	jitterFactor: number?,
}

export type RetryState = {
	--- Number of retry attempts made
	attempt: number,
	--- Last calculated delay in seconds
	lastDelay: number,
}

local DEFAULT_MULTIPLIER = 2
local DEFAULT_JITTER_FACTOR = 0.1

local RetryBackoff = {}

--[[
	Creates a new retry state.
]]
function RetryBackoff.createState(): RetryState
	return {
		attempt = 0,
		lastDelay = 0,
	}
end

--[[
	Calculates the base exponential delay without jitter.

	@param config Backoff configuration
	@param attempt Current attempt number (1-indexed)
	@return Base delay in seconds
]]
function RetryBackoff.calculateBaseDelay(config: BackoffConfig, attempt: number): number
	local multiplier = config.multiplier or DEFAULT_MULTIPLIER

	-- For attempt 1, use initialDelaySeconds
	-- For attempt 2, use initialDelaySeconds * multiplier
	-- etc.
	local baseDelay = config.initialDelaySeconds * math.pow(multiplier, attempt - 1)

	-- Cap at maximum delay
	return math.min(baseDelay, config.maxDelaySeconds)
end

--[[
	Applies jitter to a delay value.

	@param delay Base delay in seconds
	@param jitterFactor Jitter factor (0 to 1)
	@param randomValue Optional random value for testing (0 to 1)
	@return Delay with jitter applied
]]
function RetryBackoff.applyJitter(delay: number, jitterFactor: number, randomValue: number?): number
	local random = if randomValue ~= nil then randomValue else math.random()

	-- Apply jitter: delay * (1 +/- jitterFactor * random)
	-- This gives us a range of [delay * (1 - jitterFactor), delay * (1 + jitterFactor)]
	local jitterRange = delay * jitterFactor
	local jitter = (random * 2 - 1) * jitterRange -- Random value in [-jitterRange, +jitterRange]

	return math.max(0, delay + jitter)
end

--[[
	Calculates the next retry delay with jitter.

	@param config Backoff configuration
	@param state Current retry state
	@param randomValue Optional random value for testing (0 to 1)
	@return Delay in seconds for the next retry
]]
function RetryBackoff.calculateDelay(config: BackoffConfig, state: RetryState, randomValue: number?): number
	local jitterFactor = config.jitterFactor or DEFAULT_JITTER_FACTOR
	local baseDelay = RetryBackoff.calculateBaseDelay(config, state.attempt)

	return RetryBackoff.applyJitter(baseDelay, jitterFactor, randomValue)
end

--[[
	Advances the retry state to the next attempt and calculates delay.

	@param config Backoff configuration
	@param state Current retry state (will be mutated)
	@param serverRetryAfter Optional server-provided Retry-After value in seconds
	@param randomValue Optional random value for testing (0 to 1)
	@return Delay in seconds for this retry attempt
]]
function RetryBackoff.nextAttempt(
	config: BackoffConfig,
	state: RetryState,
	serverRetryAfter: number?,
	randomValue: number?
): number
	state.attempt += 1

	local calculatedDelay = RetryBackoff.calculateDelay(config, state, randomValue)

	-- If server provided Retry-After, use the maximum of calculated and server-provided
	local delay = if serverRetryAfter ~= nil and serverRetryAfter > 0
		then math.max(calculatedDelay, serverRetryAfter)
		else calculatedDelay

	state.lastDelay = delay

	return delay
end

--[[
	Resets the retry state to initial values.
]]
function RetryBackoff.reset(state: RetryState)
	state.attempt = 0
	state.lastDelay = 0
end

--[[
	Creates a default backoff configuration suitable for API requests.
]]
function RetryBackoff.defaultConfig(): BackoffConfig
	return {
		initialDelaySeconds = 1,
		maxDelaySeconds = 30,
		multiplier = 2,
		jitterFactor = 0.1,
	}
end

--[[
	Creates a more aggressive backoff configuration for rate-limited requests.
	Uses longer delays since rate limits typically have longer windows.
]]
function RetryBackoff.rateLimitConfig(): BackoffConfig
	return {
		initialDelaySeconds = 5,
		maxDelaySeconds = 60,
		multiplier = 2,
		jitterFactor = 0.2,
	}
end

return RetryBackoff


local Plugin = script:FindFirstAncestor("StudioActivity")

local Api = Plugin.Source.Api
local Packages = Plugin.Packages
local JestGlobals = require(Packages.JestGlobals)

local describe = JestGlobals.describe
local expect = JestGlobals.expect
local it = JestGlobals.it
local beforeEach = JestGlobals.beforeEach

local RetryBackoff = require(Api.RetryBackoff)

describe("RetryBackoff", function()
	describe("createState", function()
		it("should create initial state with zero values", function()
			local state = RetryBackoff.createState()

			expect(state.attempt).toBe(0)
			expect(state.lastDelay).toBe(0)
		end)
	end)

	describe("calculateBaseDelay", function()
		local config: RetryBackoff.BackoffConfig

		beforeEach(function()
			config = {
				initialDelaySeconds = 1,
				maxDelaySeconds = 30,
				multiplier = 2,
			}
		end)

		it("should return initial delay for first attempt", function()
			local delay = RetryBackoff.calculateBaseDelay(config, 1)
			expect(delay).toBe(1)
		end)

		it("should double delay for each subsequent attempt", function()
			expect(RetryBackoff.calculateBaseDelay(config, 1)).toBe(1)
			expect(RetryBackoff.calculateBaseDelay(config, 2)).toBe(2)
			expect(RetryBackoff.calculateBaseDelay(config, 3)).toBe(4)
			expect(RetryBackoff.calculateBaseDelay(config, 4)).toBe(8)
			expect(RetryBackoff.calculateBaseDelay(config, 5)).toBe(16)
		end)

		it("should cap delay at maxDelaySeconds", function()
			local delay = RetryBackoff.calculateBaseDelay(config, 10)
			expect(delay).toBe(30)
		end)

		it("should work with custom multiplier", function()
			config.multiplier = 3

			expect(RetryBackoff.calculateBaseDelay(config, 1)).toBe(1)
			expect(RetryBackoff.calculateBaseDelay(config, 2)).toBe(3)
			expect(RetryBackoff.calculateBaseDelay(config, 3)).toBe(9)
			expect(RetryBackoff.calculateBaseDelay(config, 4)).toBe(27)
		end)

		it("should use default multiplier of 2 when not specified", function()
			config.multiplier = nil

			expect(RetryBackoff.calculateBaseDelay(config, 1)).toBe(1)
			expect(RetryBackoff.calculateBaseDelay(config, 2)).toBe(2)
			expect(RetryBackoff.calculateBaseDelay(config, 3)).toBe(4)
		end)

		it("should handle different initial delays", function()
			config.initialDelaySeconds = 5

			expect(RetryBackoff.calculateBaseDelay(config, 1)).toBe(5)
			expect(RetryBackoff.calculateBaseDelay(config, 2)).toBe(10)
			expect(RetryBackoff.calculateBaseDelay(config, 3)).toBe(20)
			expect(RetryBackoff.calculateBaseDelay(config, 4)).toBe(30) -- Capped
		end)
	end)

	describe("applyJitter", function()
		it("should return exact delay when random is 0.5 (center)", function()
			-- When random = 0.5, jitter = 0 (centered)
			-- jitter = (0.5 * 2 - 1) * jitterRange = 0 * jitterRange = 0
			local delay = RetryBackoff.applyJitter(10, 0.1, 0.5)
			expect(delay).toBe(10)
		end)

		it("should increase delay when random is high", function()
			-- When random = 1.0, jitter = +jitterRange
			-- jitter = (1.0 * 2 - 1) * (10 * 0.1) = 1 * 1 = 1
			local delay = RetryBackoff.applyJitter(10, 0.1, 1.0)
			expect(delay).toBe(11)
		end)

		it("should decrease delay when random is low", function()
			-- When random = 0.0, jitter = -jitterRange
			-- jitter = (0.0 * 2 - 1) * (10 * 0.1) = -1 * 1 = -1
			local delay = RetryBackoff.applyJitter(10, 0.1, 0.0)
			expect(delay).toBe(9)
		end)

		it("should handle larger jitter factors", function()
			-- With 50% jitter factor
			local delayHigh = RetryBackoff.applyJitter(10, 0.5, 1.0)
			local delayLow = RetryBackoff.applyJitter(10, 0.5, 0.0)

			expect(delayHigh).toBe(15) -- 10 + 5
			expect(delayLow).toBe(5) -- 10 - 5
		end)

		it("should not return negative delays", function()
			-- Even with aggressive jitter, delay should not go negative
			local delay = RetryBackoff.applyJitter(1, 0.9, 0.0)
			expect(delay).toBeGreaterThanOrEqual(0)
		end)

		it("should handle zero delay", function()
			local delay = RetryBackoff.applyJitter(0, 0.1, 0.5)
			expect(delay).toBe(0)
		end)

		it("should handle zero jitter factor", function()
			local delay = RetryBackoff.applyJitter(10, 0, 0.0)
			expect(delay).toBe(10)
		end)
	end)

	describe("calculateDelay", function()
		local config: RetryBackoff.BackoffConfig
		local state: RetryBackoff.RetryState

		beforeEach(function()
			config = {
				initialDelaySeconds = 1,
				maxDelaySeconds = 30,
				multiplier = 2,
				jitterFactor = 0.1,
			}
			state = RetryBackoff.createState()
		end)

		it("should calculate delay with jitter applied", function()
			state.attempt = 1

			-- With random = 0.5, jitter is 0, so delay = 1
			local delay = RetryBackoff.calculateDelay(config, state, 0.5)
			expect(delay).toBe(1)
		end)

		it("should use default jitter factor when not specified", function()
			config.jitterFactor = nil
			state.attempt = 1

			-- Default jitter factor is 0.1
			local delayMin = RetryBackoff.calculateDelay(config, state, 0.0)
			local delayMax = RetryBackoff.calculateDelay(config, state, 1.0)

			expect(delayMin).toBe(0.9) -- 1 - 0.1
			expect(delayMax).toBe(1.1) -- 1 + 0.1
		end)
	end)

	describe("nextAttempt", function()
		local config: RetryBackoff.BackoffConfig
		local state: RetryBackoff.RetryState

		beforeEach(function()
			config = {
				initialDelaySeconds = 1,
				maxDelaySeconds = 30,
				multiplier = 2,
				jitterFactor = 0.1,
			}
			state = RetryBackoff.createState()
		end)

		it("should increment attempt counter", function()
			expect(state.attempt).toBe(0)

			RetryBackoff.nextAttempt(config, state, nil, 0.5)
			expect(state.attempt).toBe(1)

			RetryBackoff.nextAttempt(config, state, nil, 0.5)
			expect(state.attempt).toBe(2)
		end)

		it("should update lastDelay", function()
			local delay = RetryBackoff.nextAttempt(config, state, nil, 0.5)

			expect(state.lastDelay).toBe(delay)
		end)

		it("should return calculated delay without server retry-after", function()
			local delay = RetryBackoff.nextAttempt(config, state, nil, 0.5)

			-- First attempt: 1 second base, no jitter at 0.5
			expect(delay).toBe(1)
		end)

		it("should use server retry-after when larger than calculated", function()
			-- Calculated would be ~1 second, but server says 10
			local delay = RetryBackoff.nextAttempt(config, state, 10, 0.5)

			expect(delay).toBe(10)
		end)

		it("should ignore server retry-after when smaller than calculated", function()
			-- Make state have higher attempt for larger calculated delay
			state.attempt = 4
			-- Next attempt (5) would have base delay = 1 * 2^4 = 16
			local delay = RetryBackoff.nextAttempt(config, state, 5, 0.5)

			-- Calculated ~16, server says 5, should use 16
			expect(delay).toBe(16)
		end)

		it("should ignore negative server retry-after", function()
			local delay = RetryBackoff.nextAttempt(config, state, -5, 0.5)

			expect(delay).toBe(1) -- Just the calculated delay
		end)

		it("should ignore zero server retry-after", function()
			local delay = RetryBackoff.nextAttempt(config, state, 0, 0.5)

			expect(delay).toBe(1) -- Just the calculated delay
		end)

		it("should produce increasing delays for sequential attempts", function()
			local delay1 = RetryBackoff.nextAttempt(config, state, nil, 0.5)
			local delay2 = RetryBackoff.nextAttempt(config, state, nil, 0.5)
			local delay3 = RetryBackoff.nextAttempt(config, state, nil, 0.5)

			expect(delay1).toBe(1)
			expect(delay2).toBe(2)
			expect(delay3).toBe(4)
		end)
	end)

	describe("reset", function()
		it("should reset state to initial values", function()
			local state = RetryBackoff.createState()
			state.attempt = 5
			state.lastDelay = 16

			RetryBackoff.reset(state)

			expect(state.attempt).toBe(0)
			expect(state.lastDelay).toBe(0)
		end)
	end)

	describe("defaultConfig", function()
		it("should return a valid configuration", function()
			local config = RetryBackoff.defaultConfig()

			expect(config.initialDelaySeconds).toBe(1)
			expect(config.maxDelaySeconds).toBe(30)
			expect(config.multiplier).toBe(2)
			expect(config.jitterFactor).toBe(0.1)
		end)

		it("should work with calculateBaseDelay", function()
			local config = RetryBackoff.defaultConfig()

			-- Should not throw
			local delay = RetryBackoff.calculateBaseDelay(config, 1)
			expect(delay).toBe(1)
		end)
	end)

	describe("rateLimitConfig", function()
		it("should return a configuration suitable for rate limits", function()
			local config = RetryBackoff.rateLimitConfig()

			expect(config.initialDelaySeconds).toBe(5)
			expect(config.maxDelaySeconds).toBe(60)
			expect(config.multiplier).toBe(2)
			expect(config.jitterFactor).toBe(0.2)
		end)

		it("should have longer delays than default config", function()
			local defaultConfig = RetryBackoff.defaultConfig()
			local rateLimitConfig = RetryBackoff.rateLimitConfig()

			expect(rateLimitConfig.initialDelaySeconds).toBeGreaterThan(defaultConfig.initialDelaySeconds)
			expect(rateLimitConfig.maxDelaySeconds).toBeGreaterThan(defaultConfig.maxDelaySeconds)
		end)
	end)

	describe("integration: full retry sequence", function()
		it("should produce reasonable delays for a typical retry sequence", function()
			local config = RetryBackoff.defaultConfig()
			local state = RetryBackoff.createState()

			-- Simulate 5 retries with consistent randomness
			local delays = {}
			for _ = 1, 5 do
				table.insert(delays, RetryBackoff.nextAttempt(config, state, nil, 0.5))
			end

			-- Expected: 1, 2, 4, 8, 16 (with no jitter at random=0.5)
			expect(delays[1]).toBe(1)
			expect(delays[2]).toBe(2)
			expect(delays[3]).toBe(4)
			expect(delays[4]).toBe(8)
			expect(delays[5]).toBe(16)
		end)

		it("should cap delays at maximum", function()
			local config = {
				initialDelaySeconds = 1,
				maxDelaySeconds = 10,
				multiplier = 2,
				jitterFactor = 0,
			}
			local state = RetryBackoff.createState()

			-- After enough retries, delay should cap at 10
			for _ = 1, 10 do
				RetryBackoff.nextAttempt(config, state, nil, 0.5)
			end

			expect(state.lastDelay).toBe(10)
		end)

		it("should reset and start over correctly", function()
			local config = RetryBackoff.defaultConfig()
			local state = RetryBackoff.createState()

			-- Do some retries
			RetryBackoff.nextAttempt(config, state, nil, 0.5)
			RetryBackoff.nextAttempt(config, state, nil, 0.5)
			RetryBackoff.nextAttempt(config, state, nil, 0.5)

			expect(state.attempt).toBe(3)

			-- Reset
			RetryBackoff.reset(state)

			-- Start over
			local delay = RetryBackoff.nextAttempt(config, state, nil, 0.5)

			expect(state.attempt).toBe(1)
			expect(delay).toBe(1)
		end)
	end)
end)


local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Charm = require(Packages.Charm)

local signal = Charm.signal
local effect = Charm.effect
local peek = Charm.peek

local DiscordApi = require(Plugin.Source.Api.Discord)
local api = require(Plugin.Source.Api.Protos.api.v1.api)
type ApiUser = DiscordApi.User
type AvatarImage = DiscordApi.AvatarImage
type AccountLinkFlow = api.AccountLinkFlow

local AuthStore = require(script.Parent.Parent.AuthStore)
local LocalStorageStore = require(Plugin.Source.Plugin.LocalStorageStore)
local NotificationStore = require(Plugin.Source.Notification.NotificationStore)
local Telemetry = require(Plugin.Source.Telemetry.fireEventAsync)
local Types = require(Plugin.Source.Telemetry.Types)

export type LocalUser =
	| {
		status: "loading",
		cachedDetails: ApiUser?,
	}
	| {
		status: "error",
		problemType: string,
		cachedDetails: ApiUser?,
	}
	| {
		status: "ready",
		details: ApiUser,
	}

-- Tracks the avatar hash that was used to fetch the cached image,
-- so we can invalidate when the user changes their avatar.
type CachedAvatar = {
	image: AvatarImage,
	avatarHash: string?,
}

export type AccountStore = {
	cachedUsers: () -> { [string]: LocalUser },
	cachedUserImages: () -> { [string]: AvatarImage },

	addUserAsync: (account: AuthStore.Account, linkFlow: AccountLinkFlow) -> boolean,
	removeUser: (id: string) -> (),
	destroy: () -> (),
}

-- Returns the user details from any LocalUser variant, preferring
-- fresh details over cached ones.
local function getUserDetails(user: LocalUser): ApiUser?
	if user.status == "ready" then
		return user.details
	end
	return user.cachedDetails
end

-- Read user details previously persisted to local storage.
local function readDetailsDiskCache(): { [string]: ApiUser }
	local store = peek(LocalStorageStore.get)
	local storage = peek(store.getStorage)
	return storage.userDetailsCache or {}
end

-- Persist user details to local storage so they survive restarts.
local function writeDetailsDiskCache(cache: { [string]: ApiUser })
	local store = peek(LocalStorageStore.get)
	store.setStorage(function(prev)
		local new = table.clone(prev)
		new.userDetailsCache = cache
		return new
	end)
end

local function createAccountStore(discord: DiscordApi.DiscordApi): AccountStore
	local auth = AuthStore.get()

	local getCachedUsers, setCachedUsers = signal<<{ [string]: LocalUser }>>({})
	local getCachedAvatars, setCachedAvatars = signal<<{ [string]: CachedAvatar }>>({})

	-- Public read-only projection that strips the internal avatarHash tracking
	local function getCachedUserImages(): { [string]: AvatarImage }
		local avatars = getCachedAvatars()
		local images: { [string]: AvatarImage } = {}
		for id, cached in avatars do
			images[id] = cached.image
		end
		return images
	end

	--[[
		Cache user details when the underlying auth storage changes.

		The effect synchronously reads `auth.getStorage()` to register
		the charm dependency, then spawns a thread to do the async
		fetches. If the effect re-fires before the thread finishes,
		the previous thread is cancelled.
	]]
	local userDetailsThread: thread?
	local disposeUserDetailsEffect = effect(function()
		local authAccounts = auth.getStorage().accounts

		-- Cancel any in-flight work from a previous run
		if userDetailsThread then
			pcall(task.cancel, userDetailsThread)
		end

		userDetailsThread = task.spawn(function()
			local diskCache = readDetailsDiskCache()

			-- Pre-fill: preserve already-resolved users, set everything else
			-- to loading. Built from scratch so removed accounts are pruned.
			local working: { [string]: LocalUser } = {}
			local currentCache = peek(getCachedUsers)
			for id, _ in authAccounts do
				local existing = currentCache[id]
				if existing and existing.status == "ready" then
					working[id] = existing
				else
					working[id] = { status = "loading", cachedDetails = diskCache[id] }
				end
			end

			setCachedUsers(table.clone(working))

			local diskCacheUpdated = false
			local newDiskCache = table.clone(diskCache)

			-- Fetch any users not already cached
			for id, account in authAccounts do
				if working[id].status == "ready" then
					continue
				end

				local result = discord.getCurrentUserAsync(account.accessToken)
				if result.ok then
					working[id] = {
						status = "ready",
						details = result.value,
					}
					newDiskCache[id] = result.value
					diskCacheUpdated = true
				else
					working[id] = {
						status = "error",
						problemType = result.error.type :: string,
						cachedDetails = diskCache[id],
					}
				end
			end

			-- Prune removed accounts from disk cache
			for id in newDiskCache do
				if not authAccounts[id] then
					newDiskCache[id] = nil
					diskCacheUpdated = true
				end
			end

			setCachedUsers(table.clone(working))

			if diskCacheUpdated then
				writeDetailsDiskCache(newDiskCache)
			end

			userDetailsThread = nil
		end)
	end)

	--[[
		Cache user avatar images when cached users change.

		Same pattern: read the signal synchronously to register the
		dependency, then spawn async work. Compares avatar hashes so
		a changed avatar triggers a re-download.

		Avatars are also resolved for loading/error users that have
		cachedDetails, since the avatar CDN is public and does not
		require authentication.
	]]
	local avatarThread: thread?
	local disposeAvatarEffect = effect(function()
		local cachedUsers = getCachedUsers()

		if avatarThread then
			pcall(task.cancel, avatarThread)
		end

		avatarThread = task.spawn(function()
			local previousAvatars = peek(getCachedAvatars)

			-- Start from a pruned copy: only keep entries for users
			-- that still exist and whose avatar hash hasn't changed.
			local working: { [string]: CachedAvatar } = {}
			for id, user in cachedUsers do
				local details = getUserDetails(user)
				if not details then
					continue
				end

				local existing = previousAvatars[id]
				if existing and existing.avatarHash == details.avatar then
					-- Avatar unchanged, keep the cached image
					working[id] = existing
				end
			end

			-- Download missing or invalidated avatars
			for id, user in cachedUsers do
				local details = getUserDetails(user)
				if not details then
					continue
				end

				if working[id] then
					continue
				end

				local result = discord.getUserAvatarAsync(details)
				if result.ok then
					working[id] = {
						image = result.value,
						avatarHash = details.avatar,
					}
				end

				-- On failure we silently skip — the UI can show a fallback.
				-- The next time the effect runs (e.g. on refresh) it will retry
				-- since there's no entry in `working` for this user.
			end

			setCachedAvatars(table.clone(working))
			avatarThread = nil
		end)
	end)

	local function addUserAsync(account: AuthStore.Account, linkFlow: AccountLinkFlow): boolean
		-- This is a duplicate API call but we need to do it to get the user ID.
		-- It's only a duplicate in the adding a new user case.
		local detailsResult = discord.getCurrentUserAsync(account.accessToken)
		if not detailsResult.ok then
			NotificationStore.get().push("error", "Failed to link account")
			return false
		end

		local id = detailsResult.value.id
		auth.setStorage(function(prev)
			local new = table.clone(prev)
			new.accounts = table.clone(prev.accounts)
			new.accounts[id] = account
			return new
		end)

		NotificationStore.get().push("info", `Linked account {detailsResult.value.username}`)

		local accountCount = 0
		for _ in auth.getStorage().accounts do
			accountCount += 1
		end
		Telemetry.fireEvent({
			type = "accountLinked",
			value = Types.AccountLinked.new({
				accountCount = accountCount,
				isFirstAccount = accountCount == 1,
				linkFlow = linkFlow :: any,
			}),
		})

		return true
	end

	local function removeUser(id: string)
		auth.setStorage(function(prev)
			local new = table.clone(prev)
			new.accounts = table.clone(prev.accounts)
			new.accounts[id] = nil
			return new
		end)

		local accountCount = 0
		for _ in auth.getStorage().accounts do
			accountCount += 1
		end
		Telemetry.fireEvent({
			type = "accountRemoved",
			value = Types.AccountRemoved.new({ accountCount = accountCount }),
		})
	end

	local function destroy()
		disposeUserDetailsEffect()
		disposeAvatarEffect()

		if userDetailsThread then
			pcall(task.cancel, userDetailsThread)
			userDetailsThread = nil
		end

		if avatarThread then
			pcall(task.cancel, avatarThread)
			avatarThread = nil
		end
	end

	return {
		cachedUsers = getCachedUsers,
		cachedUserImages = getCachedUserImages,

		addUserAsync = addUserAsync,
		removeUser = removeUser,
		destroy = destroy,
	}
end

return createAccountStore


local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Charm = require(Packages.Charm)

local BuildVars = require(Plugin.Source.BuildVars)
local DiscordApi = require(Plugin.Source.Api.Discord)
local createAccountStore = require(script.createAccountStore)

return table.freeze({
	get = Charm.computed(function()
		local discord = DiscordApi.new({
			clientId = BuildVars.discord.clientId,
		})

		return createAccountStore(discord)
	end),
})


local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Charm = require(Packages.Charm)
local t = require(Packages.t)

local createPluginSettingsStore = require(Plugin.Source.Plugin.createPluginSettingsStore)

export type Account = {
	accessToken: string,
	refreshToken: string,
	--- Timestamp in the future for when we need to refresh the access token.
	expiresAt: number,
}

export type AuthStore = {
	-- Keyed by Discord account ID
	accounts: { [string]: Account },
}

local defaultValue: AuthStore = {
	accounts = {},
}

local validate = t.interface({
	accounts = t.map(
		t.string,
		t.strictInterface({
			accessToken = t.string,
			refreshToken = t.string,
			expiresAt = t.number,
		})
	),
})

return {
	get = Charm.computed(function()
		return createPluginSettingsStore("PresenceAuthStore", defaultValue, validate)
	end),
}


--[[
	DeviceCodeFlow

	Implements the Discord device code OAuth2 grant for Studio plugins.
	Exposes reactive charm signals for driving UI updates.

	The flow is fully restartable — calling requestAsync() again cancels
	any in-progress polling and starts fresh with a new device code.

	Usage:

		local flow = DeviceCodeFlow.create({
			discord = DiscordApi.new({ clientId = "YOUR_CLIENT_ID" }),
		})

		-- Start or restart the flow (can be called multiple times)
		flow.requestAsync()

		-- UI reads state via charm signals:
		--   flow.status()       → "idle" | "requesting" | "polling" | "authorized" | "denied" | "expired" | "error"
		--   flow.sessionInfo()  → SessionInfo? (available once status is "polling")
		--   flow.error()        → string? (available when status is "error")

		-- Cleanup when done
		flow.destroy()
]]

local Plugin = script:FindFirstAncestor("StudioActivity")

local Packages = Plugin.Packages
local Charm = require(Packages.Charm)

local AccountStore = require(script.Parent.Parent.AccountStore)
local DiscordApi = require(Plugin.Source.Api.Discord)
local Telemetry = require(Plugin.Source.Telemetry.fireEventAsync)
local Types = require(Plugin.Source.Telemetry.Types)

local signal = Charm.signal
local batch = Charm.batch

-- Polling bounds to protect against server misconfig or bugs
local MIN_POLL_INTERVAL = 1
local MAX_POLL_INTERVAL = 30

--[[ Types ]]

export type Status = "idle" | "requesting" | "polling" | "authorized" | "denied" | "expired" | "error"

export type SessionInfo = {
	deviceCode: string,
	userCode: string,
	verificationUrl: string,
	verificationUrlComplete: string,
	expiresIn: number,
	interval: number,
}

export type Config = {
	discord: DiscordApi.DiscordApi,
}

--[[ Module ]]

local function create(config: Config)
	local discord = config.discord

	-- Reactive state
	local status, setStatus = signal("idle" :: Status)
	local sessionInfo, setSessionInfo = signal(nil :: SessionInfo?)
	local errorMessage, setErrorMessage = signal(nil :: string?)

	local accountStore = AccountStore.get()

	-- Active thread tracking for cancellation
	local activeThread: thread? = nil

	local function cancelActive()
		if activeThread then
			pcall(task.cancel, activeThread)
			activeThread = nil
		end
	end

	local function resetState()
		batch(function()
			setStatus("idle")
			setSessionInfo(nil)
			setErrorMessage(nil)
		end)
	end

	local function flowAsync()
		-- Phase 1: Request device code
		batch(function()
			setStatus("requesting")
			setErrorMessage(nil)
		end)

		local deviceResult = discord.requestDeviceCodeAsync()

		if not deviceResult.ok then
			local err = deviceResult.error
			local message: string
			if err.type == "network" then
				message = err.message
			elseif err.type == "invalid_scope" then
				message = err.description or "Invalid OAuth scope"
			else
				message = `Request failed ({err.statusCode})`
			end

			Telemetry.fireEvent({
				type = "deviceCodeFlowFailed",
				value = Types.DeviceCodeFlowFailed.new({ error = err.type :: string }),
			})

			batch(function()
				setStatus("error")
				setErrorMessage(message)
			end)
			return
		end

		local session = deviceResult.value
		local info: SessionInfo = {
			deviceCode = session.deviceCode,
			userCode = session.userCode,
			verificationUrl = session.verificationUrl,
			verificationUrlComplete = session.verificationUrlComplete,
			expiresIn = session.expiresIn,
			interval = session.interval,
		}

		batch(function()
			setStatus("polling")
			setSessionInfo(info)
		end)

		-- Phase 2: Poll for token
		local interval = math.clamp(info.interval, MIN_POLL_INTERVAL, MAX_POLL_INTERVAL)
		local deadline = os.time() + info.expiresIn

		while os.time() < deadline do
			task.wait(interval)

			local pollResult = discord.exchangeDeviceCodeAsync(info.deviceCode)

			if pollResult.ok then
				local token = pollResult.value
				local addOk = accountStore.addUserAsync({
					accessToken = token.accessToken,
					refreshToken = token.refreshToken or "",
					expiresAt = token.expiresAt,
				}, "ACCOUNT_LINK_FLOW_DEVICE_CODE")

				if addOk then
					setStatus("authorized")
				else
					Telemetry.fireEvent({
						type = "deviceCodeFlowFailed",
						value = Types.DeviceCodeFlowFailed.new({ error = "add_user_failed" }),
					})
					batch(function()
						setStatus("error")
						setErrorMessage("Failed to link account")
					end)
				end
				return
			end

			local errType = pollResult.error.type

			if errType == "authorization_pending" then
				continue
			end

			if errType == "slow_down" then
				interval = math.min(interval + 1, MAX_POLL_INTERVAL)
				continue
			end

			if errType == "expired_token" then
				Telemetry.fireEvent({
					type = "deviceCodeFlowFailed",
					value = Types.DeviceCodeFlowFailed.new({ error = "expired" }),
				})
				batch(function()
					setStatus("expired")
					setSessionInfo(nil)
				end)
				return
			end

			if errType == "access_denied" then
				Telemetry.fireEvent({
					type = "deviceCodeFlowFailed",
					value = Types.DeviceCodeFlowFailed.new({ error = "denied" }),
				})
				batch(function()
					setStatus("denied")
					setSessionInfo(nil)
				end)
				return
			end

			-- Network or unexpected error — stop polling
			local message: string
			if errType == "network" then
				message = (pollResult.error :: DiscordApi.NetworkProblem).message
			else
				message = `Polling failed ({(pollResult.error :: DiscordApi.UnexpectedProblem).statusCode})`
			end

			Telemetry.fireEvent({
				type = "deviceCodeFlowFailed",
				value = Types.DeviceCodeFlowFailed.new({ error = errType }),
			})

			batch(function()
				setStatus("error")
				setErrorMessage(message)
			end)
			return
		end

		-- Fell through the loop — code expired by wall clock
		batch(function()
			setStatus("expired")
			setSessionInfo(nil)
		end)
	end

	--[[ Public API ]]

	local function requestAsync()
		cancelActive()
		resetState()

		activeThread = task.defer(function()
			flowAsync()
			activeThread = nil
		end)
	end

	local function destroy()
		cancelActive()
		resetState()
	end

	return table.freeze({
		status = status,
		sessionInfo = sessionInfo,
		error = errorMessage,

		requestAsync = requestAsync,
		destroy = destroy,
	})
end

export type DeviceCodeFlow = typeof(create(nil :: any))

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