local Selection = game:GetService("Selection")

local PluginStore = require("@root/Plugin/PluginStore")
local React = require("@pkg/React")
local ReactSignals = require("@pkg/ReactCharm")
local useZoom = require("@root/Common/useZoom")

local e = React.createElement
local useRef = React.useRef
local useCallback = React.useCallback
local useContext = React.useContext
local useState = React.useState

local useSignalState = ReactSignals.useSignalState

export type StoryActionsContext = {
	isDisabled: boolean,
	isMountedInViewport: boolean,

	storyContainerRef: { current: GuiObject? }?,

	viewportPreview: () -> (),
	viewCode: () -> (),
	viewExplorer: () -> (),

	zoom: {
		value: number,
		zoomIn: () -> (),
		zoomOut: () -> (),
	},
}

local StoryActionsContext = React.createContext({} :: StoryActionsContext)

export type StoryActionsProviderProps = {
	children: React.ReactNode,
	story: ModuleScript?,
}

local function StoryActionsProvider(props: StoryActionsProviderProps)
	local pluginStore = useSignalState(PluginStore.get)
	local plugin = useSignalState(pluginStore.getPlugin)

	local isMountedInViewport, setIsMountedInViewport = useState(false)
	local storyContainerRef = useRef(nil :: GuiObject?)
	local zoom = useZoom(props.story)

	local viewportPreview = useCallback(function()
		setIsMountedInViewport(function(currentValue)
			return not currentValue
		end)
	end, {})

	local viewCode = useCallback(function()
		if props.story == nil then
			return
		end

		Selection:Set({ props.story })

		if plugin ~= nil then
			plugin:OpenScript(props.story)
		end
	end, { plugin, props.story } :: { any })

	local viewExplorer = useCallback(function()
		if storyContainerRef.current == nil then
			return
		end

		Selection:Set({ storyContainerRef.current:FindFirstAncestorWhichIsA("GuiObject") or storyContainerRef.current })
	end, {})

	return e(StoryActionsContext.Provider, {
		value = {
			isDisabled = props.story == nil,
			isMountedInViewport = isMountedInViewport,

			storyContainerRef = storyContainerRef,

			viewportPreview = viewportPreview,
			viewCode = viewCode,
			viewExplorer = viewExplorer,

			zoom = zoom,
		},
	}, props.children)
end

local function useStoryActions(): StoryActionsContext
	return useContext(StoryActionsContext)
end

return {
	Context = StoryActionsContext,
	Provider = StoryActionsProvider,
	useStoryActions = useStoryActions,
}


local Constants = require("@root/constants")
local Foundation = require("@rbxpkg/Foundation")
local LuauPolyfill = require("@pkg/LuauPolyfill")
local React = require("@pkg/React")
local ReactSignals = require("@pkg/ReactCharm")
local ResizablePanel = require("@root/Panels/ResizablePanel")
local UserSettingsStore = require("@root/UserSettings/UserSettingsStore")
local useSortedControls = require("@root/Storybook/useSortedControls")

local Checkbox = Foundation.Checkbox
local Divider = Foundation.Divider
local Dropdown = Foundation.Dropdown
local InputSize = Foundation.Enums.InputSize
local NumberInput = Foundation.NumberInput
local ScrollView = Foundation.ScrollView
local Text = Foundation.Text
local TextInput = Foundation.TextInput
local View = Foundation.View
local useTokens = Foundation.Hooks.useTokens
local withCommonProps = Foundation.Utility.withCommonProps

local Array = LuauPolyfill.Array

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

local useSignalState = ReactSignals.useSignalState

type StoryControlsProps = {
	controls: { [string]: any },
	modifiedControls: { [string]: any },
	setControl: (key: string, value: any) -> (),
} & Foundation.CommonProps

-- NOTE(Paul): In the future, we'll want to store more than just
-- controls here, we'll also want to have tabs for stuff like
-- accessibility as well. I was planning to use the <Foundation.Tabs />
-- component to show-off the Accessibility tab for future purposes,
-- but turns out it doesn't work when in a `col` with a `shrink size-full`
-- as it's sibling.
local function StoryControls(props: StoryControlsProps)
	local tokens = useTokens()

	local userSettingsStore = useSignalState(UserSettingsStore.get)
	local userSettings = useSignalState(userSettingsStore.getStorage)

	local sortedControls = useSortedControls(props.controls)
	local pendingControl, setPendingControl = useState(nil :: string?)
	local controlElements: { [string]: React.ReactNode } = {}

	for index, control in sortedControls do
		local function setControl(newValue: any)
			local newValueAsNumber = tonumber(newValue)

			if newValueAsNumber ~= nil then
				newValue = newValueAsNumber
			end

			props.setControl(control.key, newValue)
		end

		local controlType = typeof(control.value)
		local controlInput: React.ReactNode = nil

		if controlType == "boolean" then
			controlInput = e(Checkbox, {
				isChecked = control.value,
				label = "",
				onActivated = setControl,
				size = InputSize.Small,
			})
		elseif controlType == "number" then
			controlInput = e(NumberInput, {
				label = "",
				onChanged = setControl,
				size = InputSize.Small,
				step = 1,
				value = control.value,
				width = UDim.new(1, 0),
			})
		elseif controlType == "string" then
			controlInput = e(TextInput, {
				label = "",
				onChanged = function(newText)
					setPendingControl(newText)
				end,
				onFocusLost = function()
					setControl(pendingControl)
				end,
				onReturnPressed = function()
					setControl(pendingControl)
				end,
				text = control.value,
				size = InputSize.Small,
				width = UDim.new(1, 0),
			})
		elseif controlType == "table" and Array.isArray(control.value) then
			local changedValue = props.modifiedControls[control.key]
			local controlItems = Array.map(control.value, function(value): Foundation.DropdownItem
				return {
					id = value,
					text = tostring(value),
				}
			end)

			controlInput = e(Dropdown.Root, {
				items = controlItems,
				label = "",
				onItemChanged = setControl,
				size = InputSize.Small,
				value = if changedValue ~= nil then changedValue else control.value[1],
				width = UDim.new(1, 0),
			})
		end

		controlElements[control.key] = e(View, {
			LayoutOrder = index,
			tag = "auto-y col size-full-0",
		}, {
			Content = e(View, {
				LayoutOrder = 1,
				tag = "align-y-center auto-y gap-medium padding-medium row size-full-0",
			}, {
				Title = e(Text, {
					LayoutOrder = 1,
					Size = UDim2.fromScale(0.2, 0),
					Text = control.key,
					tag = "auto-y text-align-x-left text-label-medium",
				}),

				Option = e(View, {
					LayoutOrder = 2,
					tag = "align-x-left auto-y fill row",
				}, {
					-- Keying by the identity of sortedControls fixes a bug where
					-- the options visually do not update when two stories have the
					-- exact same controls.
					[`Option_{sortedControls}`] = controlInput,
				}),
			}),

			Divider = index < #sortedControls and e(Divider, {
				LayoutOrder = 2,
			}),
		})
	end

	return e(
		ResizablePanel,
		withCommonProps(props, {
			dragHandles = { "Top" :: "Top" },
			initialSize = UDim2.new(1, 0, 0, userSettings.controlsHeight),
			maxSize = Vector2.new(0, Constants.CONTROLS_MAX_HEIGHT),
			minSize = Vector2.new(0, Constants.CONTROLS_MIN_HEIGHT),
		}),
		{
			Content = e(View, {
				tag = "bg-surface-100 col size-full",
			}, {
				-- NOTE(Paul): Adding a temporary pseudo-tab element for the time
				-- being so that we have separation between the controls panel
				-- and the story view.
				Tabs = e(View, {
					LayoutOrder = 1,
					tag = "auto-y col size-full-0",
				}, {
					Tab = e(View, {
						LayoutOrder = 1,
						onActivated = function() end,
						tag = "col size-2000-1100",
					}, {
						Content = e(View, {
							tag = "align-x-center align-y-center row size-full",
						}, {
							Text = e(Text, {
								Text = "Controls",
								tag = "anchor-center-center auto-xy content-emphasis position-center-center text-label-medium",
							}),
						}),

						Border = e(View, {
							Size = UDim2.new(1, 0, 0, tokens.Stroke.Thick),
							backgroundStyle = tokens.Color.System.Contrast,
							tag = "anchor-bottom-center position-bottom-center",
						}),
					}),

					Divider = e(Divider, {
						LayoutOrder = 2,
					}),
				}),

				ScrollContent = e(ScrollView, {
					LayoutOrder = 2,
					scroll = {
						AutomaticCanvasSize = Enum.AutomaticSize.Y,
						CanvasSize = UDim2.fromScale(0, 0),
						ScrollingDirection = Enum.ScrollingDirection.Y,
					},
					layout = {
						FillDirection = Enum.FillDirection.Vertical,
					},
					tag = "shrink size-full",
				}, {
					Header = e(View, {
						LayoutOrder = 0,
						tag = "auto-y col size-full-0",
					}, {
						Content = e(View, {
							LayoutOrder = 1,
							tag = "auto-y align-y-center gap-medium padding-medium row size-full-0",
						}, {
							Name = e(Text, {
								LayoutOrder = 1,
								Size = UDim2.fromScale(0.2, 0),
								Text = "Name",
								tag = "auto-y content-muted text-align-x-left text-title-medium",
							}),

							Control = e(Text, {
								LayoutOrder = 2,
								Text = "Control",
								tag = "auto-y content-muted fill text-align-x-left text-title-medium",
							}),
						}),

						Divider = e(Divider, {
							LayoutOrder = 2,
						}),
					}),

					Controls = e(React.Fragment, nil, controlElements),
				}),
			}),
		}
	)
end

return React.memo(StoryControls)


local React = require("@pkg/React")

local StoryControls = require("@root/Storybook/StoryControls")

return {
	summary = "Panel for configuring the controls of a story",
	story = function()
		return React.createElement(StoryControls, {
			controls = {
				foo = "bar",
				checkbox = false,
				number = 10,
				dropdown = {
					"Option 1",
					"Option 2",
					"Option 3",
				},
				onlyTwentyCharacters = "OnlyTwentyCharacters",
				badControlType = function() end,
			},
			modifiedControls = {},
			setControl = function() end,
		})
	end,
}


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")

local CodeBlock = require("@root/Common/CodeBlock")

local useTokens = Foundation.Hooks.useTokens

export type Props = {
	LayoutOrder: number?,
	errorMessage: string,
}

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

	return React.createElement(Foundation.ScrollView, {
		LayoutOrder = props.LayoutOrder,
		scroll = {
			ScrollingDirection = Enum.ScrollingDirection.Y,
			AutomaticCanvasSize = Enum.AutomaticSize.Y,
			CanvasSize = UDim2.new(1, 0),
		},
		layout = {
			FillDirection = Enum.FillDirection.Vertical,
		},
		tag = "size-full padding-small gap-medium",
	}, {
		CodeBlock = React.createElement(CodeBlock, {
			source = props.errorMessage,
			sourceColor = tokens.Color.ActionAlert.Foreground.Color3,
		}),
	})
end

return StoryError


local React = require("@pkg/React")

local StoryError = require("@root/Storybook/StoryError")

return {
	summary = "Component for displaying error messages to the user",
	story = function()
		local _, result = xpcall(function()
			error("Oops!")
		end, debug.traceback)

		return React.createElement(StoryError, {
			errorMessage = result,
		})
	end,
}


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")
local Storyteller = require("@pkg/Storyteller")

local nextLayoutOrder = require("@root/Common/nextLayoutOrder")

local MAX_SUMMARY_SIZE = 600

local e = React.createElement

type LoadedStory = Storyteller.LoadedStory<unknown>

export type Props = {
	story: LoadedStory,
	layoutOrder: number?,
}

local function StoryMeta(props: Props)
	return e(Foundation.View, {
		tag = "size-full-0 auto-y col gap-medium",
		LayoutOrder = props.layoutOrder,
	}, {
		Title = e(Foundation.Text, {
			tag = "auto-xy text-heading-large",
			Text = props.story.name,
			LayoutOrder = nextLayoutOrder(),
		}),

		Summary = props.story.summary and e(Foundation.Text, {
			tag = "auto-xy text-body-medium text-wrap",
			LayoutOrder = nextLayoutOrder(),
			Text = props.story.summary,
			sizeConstraint = {
				MaxSize = Vector2.new(MAX_SUMMARY_SIZE, math.huge),
			},
		}),
	})
end

return StoryMeta


local React = require("@pkg/React")
local Storyteller = require("@pkg/Storyteller")

local StoryMeta = require("./StoryMeta")

return {
	story = function()
		local storybooks = Storyteller.useStorybooks(game)
		local story, setStory = React.useState(nil :: Storyteller.LoadedStory<any>?)

		React.useEffect(function()
			local storybook = storybooks.available[1]

			if storybook then
				local storyModule = Storyteller.findStoryModulesForStorybook(storybook)[1]
				if storyModule then
					setStory(Storyteller.loadStoryModule(storyModule, storybook))
				end
			end
		end, { storybooks.available })

		if not story then
			return
		end

		return React.createElement(StoryMeta, {
			story = story,
		})
	end,
}


local CoreGui = game:GetService("CoreGui")

local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")
local Storyteller = require("@pkg/Storyteller")

local StoryActionsContext = require("@root/Storybook/StoryActionsContext")
local StoryError = require("@root/Storybook/StoryError")
local types = require("@root/Storybook/types")

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

type LoadedStory = Storyteller.LoadedStory<unknown>

export type Props = {
	LayoutOrder: number?,
	story: LoadedStory,
	controls: Storyteller.StoryControls,
	extraProps: types.ExtraStoryProps,
}

local function StoryPreview(props: Props): React.Node
	local storyActions = StoryActionsContext.useStoryActions()
	local err: string?, setErr = useState(nil :: string?)

	useEffect(function()
		setErr(nil)
	end, { props.story })

	if err then
		return e(StoryError, {
			LayoutOrder = props.LayoutOrder,
			errorMessage = err,
		})
	end

	if storyActions.isMountedInViewport then
		return ReactRoblox.createPortal({
			Story = e("ScreenGui", {
				ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
				ref = storyActions.storyContainerRef,
			}, {
				StoryContainer = e(Storyteller.StoryContainer, {
					story = props.story,
					controls = props.controls,
					extraProps = props.extraProps,
					onError = setErr,
				}),
			}),
		}, CoreGui)
	end

	return e(Foundation.ScrollView, {
		tag = "size-full",
		scroll = {
			ScrollingDirection = Enum.ScrollingDirection.XY,
			AutomaticCanvasSize = Enum.AutomaticSize.XY,
			CanvasSize = UDim2.new(0, 0),
		},
		layout = {
			FillDirection = Enum.FillDirection.Vertical,
		},
		LayoutOrder = props.LayoutOrder,
		ref = storyActions.storyContainerRef,
	}, {
		Scale = e("UIScale", {
			Scale = 1 + storyActions.zoom.value,
		}),

		StoryContainer = e(Storyteller.StoryContainer, {
			story = props.story,
			controls = props.controls,
			extraProps = props.extraProps,
			onError = setErr,
		}),
	})
end

return StoryPreview


local Foundation = require("@rbxpkg/Foundation")
local LuauPolyfill = require("@pkg/LuauPolyfill")
local React = require("@pkg/React")
local ReactCharm = require("@pkg/ReactCharm")
local Storyteller = require("@pkg/Storyteller")

local PluginStore = require("@root/Plugin/PluginStore")
local StoryControls = require("@root/Storybook/StoryControls")
local StoryError = require("@root/Storybook/StoryError")
local StoryPreview = require("@root/Storybook/StoryPreview")
local types = require("@root/Storybook/types")
local useThemeName = require("@root/Common/useThemeName")

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

local Object = LuauPolyfill.Object

local e = React.createElement
local useCallback = React.useCallback
local useEffect = React.useEffect
local useMemo = React.useMemo
local useState = React.useState
local useSignalState = ReactCharm.useSignalState
local useStory = Storyteller.useStory
local useOverlay = Foundation.Hooks.useOverlay

type LoadedStorybook = Storyteller.LoadedStorybook
type ExtraStoryProps = types.ExtraStoryProps

type StoryViewProps = {
	storyModule: ModuleScript,
	storybook: LoadedStorybook,
}

local function StoryView(props: StoryViewProps)
	local story, storyError = useStory(props.storyModule, props.storybook)
	local theme = useThemeName()
	local pluginStore = useSignalState(PluginStore.get)
	local plugin = useSignalState(pluginStore.getPlugin)
	local overlayGui = useOverlay()

	local modifiedControls, setModifiedControls = useState({})
	local controls = useMemo(function()
		local newControls = {}

		if story ~= nil and story.controls ~= nil then
			for key, value in story.controls do
				if modifiedControls[key] ~= nil and typeof(value) ~= "table" then
					newControls[key] = modifiedControls[key]
				else
					if typeof(value) == "table" then
						newControls[key] = value.default
					else
						newControls[key] = value
					end
				end
			end
		end

		return newControls
	end, { story, modifiedControls } :: { any })

	local extraProps = useMemo(function(): ExtraStoryProps
		return {
			theme = theme :: useThemeName.ThemeName,
			locale = "en-us",
			plugin = plugin,
			widget = overlayGui,
		}
	end, { theme, plugin, overlayGui } :: { unknown })

	local setControl = useCallback(function(key: string, value: any)
		setModifiedControls(function(currentValue)
			return Object.assign({}, currentValue, {
				[key] = value,
			})
		end)
	end, {})

	useEffect(function()
		setModifiedControls({})
	end, { story })

	return e(View, {
		tag = "size-full bg-surface-200",
	}, {
		Error = storyError ~= nil and e(StoryError, {
			errorMessage = storyError,
		}),

		Story = story ~= nil and e(View, {
			tag = "col size-full",
		}, {
			Info = e(View, {
				LayoutOrder = 1,
				tag = "auto-y col gap-small padding-large size-full-0",
			}, {
				Title = e(Text, {
					LayoutOrder = 1,
					Text = story.name,
					tag = {
						-- NOTE (Paul): Super nitpick-y, but keeping all this on
						-- one line made for some really weird formatting that I
						-- couldn't accept.
						["auto-y content-emphasis size-full-0"] = true,
						["text-align-x-left text-align-y-top text-heading-large text-wrap"] = true,
					},
				}),

				Summary = story.summary ~= nil and e(Text, {
					LayoutOrder = 2,
					Text = story.summary,
					sizeConstraint = { MaxSize = Vector2.new(600, math.huge) },
					tag = "auto-y size-full-0 text-align-x-left text-align-y-top text-body-medium text-wrap",
				}),
			}),

			Divider = e(Divider, {
				LayoutOrder = 2,
			}),

			Preview = e(View, {
				LayoutOrder = 3,
				tag = "padding-large shrink size-full",
			}, {
				StoryPreview = React.createElement(StoryPreview, {
					story = story,
					controls = controls,
					extraProps = extraProps,
				}),
			}),

			Controls = next(controls) ~= nil and e(StoryControls, {
				LayoutOrder = 4,
				controls = controls,
				modifiedControls = modifiedControls,
				setControl = setControl,
			}),
		}),
	})
end

return React.memo(StoryView)


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")
local ReactCharm = require("@pkg/ReactCharm")
local Sift = require("@pkg/Sift")

local UserSettingsStore = require("@root/UserSettings/UserSettingsStore")
local nextLayoutOrder = require("@root/Common/nextLayoutOrder")

local e = React.createElement

local noop = function() end

local useSignalState = ReactCharm.useSignalState
local useCallback = React.useCallback

type Props = {
	layoutOrder: number?,
	onPreviewInViewport: (() -> ())?,
	onZoomIn: (() -> ())?,
	onZoomOut: (() -> ())?,
	onViewCode: (() -> ())?,
	onExplorer: (() -> ())?,
}

local function StoryViewNavbar(props: Props)
	local userSettingsStore = useSignalState(UserSettingsStore.get)
	local userSettings = useSignalState(userSettingsStore.getStorage)

	local onThemeChanged = useCallback(function(itemId: string | number)
		userSettingsStore.setStorage(function(prev)
			return Sift.Dictionary.join(prev, {
				theme = itemId,
			})
		end)
	end, { userSettingsStore.setStorage })

	return e(Foundation.View, {
		tag = "size-full-0 auto-y row gap-small align-y-center",
		LayoutOrder = props.layoutOrder,
	}, {
		Zoom = e(Foundation.View, {
			tag = "auto-xy row",
			LayoutOrder = nextLayoutOrder(),
		}, {
			Magnify = e(Foundation.IconButton, {
				icon = Foundation.Enums.IconName.MagnifyingGlassPlus,
				onActivated = props.onZoomIn or noop,
				LayoutOrder = nextLayoutOrder(),
			}),

			Minify = e(Foundation.IconButton, {
				icon = Foundation.Enums.IconName.MagnifyingGlassMinus,
				onActivated = props.onZoomOut or noop,
				LayoutOrder = nextLayoutOrder(),
			}),
		}),

		-- Need to wrap the divider to constrain its height. Without this
		-- the divider will expand to the full height of the viewport
		VerticalDivider = e(Foundation.View, {
			tag = "size-0-800 auto-x",
			LayoutOrder = nextLayoutOrder(),
		}, {
			Divider = e(Foundation.Divider, {
				orientation = Foundation.Enums.DividerOrientation.Vertical,
			}),
		}),

		Explorer = e(Foundation.Button, {
			text = "Explorer",
			size = Foundation.Enums.ButtonSize.Small,
			onActivated = props.onExplorer or noop,
			LayoutOrder = nextLayoutOrder(),
		}),

		ViewCode = e(Foundation.Button, {
			text = "View Code",
			size = Foundation.Enums.ButtonSize.Small,
			onActivated = props.onViewCode or noop,
			LayoutOrder = nextLayoutOrder(),
		}),

		PreviewInViewport = e(Foundation.Button, {
			text = "Preview in Viewport",
			size = Foundation.Enums.ButtonSize.Small,
			onActivated = props.onPreviewInViewport or noop,
			LayoutOrder = nextLayoutOrder(),
		}),

		ThemeSelection = e(Foundation.Dropdown.Root, {
			size = Foundation.Enums.InputSize.Small,
			label = "",
			onItemChanged = onThemeChanged,
			width = UDim.new(0, 150),
			value = userSettings.theme,
			items = {
				{
					id = "system",
					text = "System",
				},
				{
					id = "dark",
					text = "Dark",
				},
				{
					id = "light",
					text = "Light",
				},
			},
			LayoutOrder = nextLayoutOrder(),
		}),
	})
end

return StoryViewNavbar


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")
local Storyteller = require("@pkg/Storyteller")

local CodeBlock = require("@root/Common/CodeBlock")
local nextLayoutOrder = require("@root/Common/nextLayoutOrder")

local useTokens = Foundation.Hooks.useTokens

type UnavailableStorybook = Storyteller.UnavailableStorybook

export type Props = {
	unavailableStorybook: UnavailableStorybook,
	layoutOrder: number?,
}

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

	local storybookSource = props.unavailableStorybook.storybook.source.Source

	return React.createElement(Foundation.ScrollView, {
		tag = "size-full padding-large gap-large",
		scroll = {
			ScrollingDirection = Enum.ScrollingDirection.XY,
			AutomaticCanvasSize = Enum.AutomaticSize.XY,
			CanvasSize = UDim2.fromScale(0, 0),
		},
		layout = {
			FillDirection = Enum.FillDirection.Vertical,
		},
		LayoutOrder = props.layoutOrder,
	}, {
		MainText = React.createElement(Foundation.Text, {
			tag = "auto-xy text-body-medium text-align-x-left",
			Text = `Failed to load {props.unavailableStorybook.storybook.name}`,
			LayoutOrder = nextLayoutOrder(),
		}),

		Problem = React.createElement(Foundation.View, {
			tag = "auto-xy gap-medium col",
			LayoutOrder = nextLayoutOrder(),
		}, {
			Title = React.createElement(Foundation.Text, {
				tag = "auto-xy text-heading-medium",
				Text = "Error",
				LayoutOrder = nextLayoutOrder(),
			}),

			CodeBlock = React.createElement(CodeBlock, {
				source = props.unavailableStorybook.problem,
				sourceColor = tokens.Color.ActionAlert.Foreground.Color3,
				layoutOrder = nextLayoutOrder(),
			}),
		}),

		StorybookSource = React.createElement(Foundation.View, {
			tag = "auto-xy gap-medium col",
			LayoutOrder = nextLayoutOrder(),
		}, {
			Title = React.createElement(Foundation.Text, {
				tag = "auto-xy text-heading-medium",
				Text = "Storybook Source",
				LayoutOrder = nextLayoutOrder(),
			}),

			CodeBlock = React.createElement(CodeBlock, {
				source = storybookSource,
				layoutOrder = nextLayoutOrder(),
			}),
		}),
	})
end

return StorybookError


local React = require("@pkg/React")
local Storyteller = require("@pkg/Storyteller")

local StorybookError = require("@root/Storybook/StorybookError")

type UnavailableStorybook = Storyteller.UnavailableStorybook

return {
	summary = "Component for displaying error messages to the user",
	story = function()
		local storybookModule = script.Parent.Parent["init.storybook"]
		local unavailableStorybook: UnavailableStorybook = {
			problem = "Something went wrong!",
			storybook = {
				name = storybookModule.Name,
				source = storybookModule,
				loader = {} :: any,
				storyRoots = {},
			},
		}

		return React.createElement(StorybookError, {
			unavailableStorybook = unavailableStorybook,
		})
	end,
}


local React = require("@pkg/React")
local ReactCharm = require("@pkg/ReactCharm")
local Sift = require("@pkg/Sift")
local Storyteller = require("@pkg/Storyteller")
local TreeView = require("@root/TreeView")

local PinnedInstanceStore = require("@root/Storybook/PinnedInstanceStore")
local createTreeNodeForStoryModule = require("@root/Storybook/createTreeNodeForStoryModule")
local createTreeNodesForStorybook = require("@root/Storybook/createTreeNodesForStorybook")
local useLastOpenedStory = require("@root/Storybook/useLastOpenedStory")
local usePrevious = require("@root/Common/usePrevious")

type TreeNodeStore = TreeView.TreeNodeStore
type LoadedStorybook = Storyteller.LoadedStorybook
type UnavailableStorybook = Storyteller.UnavailableStorybook
type LoadedStory<T> = Storyteller.LoadedStory<T>

local useCallback = React.useCallback
local useEffect = React.useEffect
local useMemo = React.useMemo
local useRef = React.useRef
local useSignalState = ReactCharm.useSignalState
local useStorybooks = Storyteller.useStorybooks
local useOrphanedStoryModules = Storyteller.useOrphanedStoryModules

export type Props = {
	searchTerm: string?,
	onStoryChanged: ((storyModule: ModuleScript?, storybook: LoadedStorybook?) -> ())?,
	onShowErrorPage: ((unavailableStorybook: UnavailableStorybook) -> ())?,
	layoutOrder: number?,
}

local function StorybookTreeView(props: Props)
	local storybookByNodeId = useRef({} :: { [string]: LoadedStorybook })
	local unavailableStorybookByNodeId = useRef({} :: { [string]: UnavailableStorybook })
	local lastOpenedStory, setLastOpenedStory = useLastOpenedStory()
	local storybooks = useStorybooks()
	local orphanedStoryModules = useOrphanedStoryModules()
	local nodesByInstance = useRef({} :: { [Instance]: TreeNodeStore })

	local pinnedInstanceStore = useSignalState(PinnedInstanceStore.get)

	local rootNode = useMemo(function()
		local node = TreeView.createTreeNodeStore()
		node.setName("<root>")
		return node
	end, {})

	local pinned = useMemo(function()
		local node = TreeView.createTreeNodeStore()
		node.setName("Pinned")
		node.setIcon(TreeView.TreeNodeIcon.Pin)
		node.setIsExpanded(true)
		node.setIsVisible(false)
		node.parentTo(rootNode)

		return node
	end, { rootNode })

	local unavailableStorybooks = useMemo(function()
		local node = TreeView.createTreeNodeStore()
		node.setName("Unavailable Storybooks")
		node.setIcon(TreeView.TreeNodeIcon.Folder)
		node.setIsVisible(false)
		node.parentTo(rootNode)

		return node
	end, { rootNode })

	local unknownStories = useMemo(function()
		local node = TreeView.createTreeNodeStore()
		node.setName("Unknown Stories")
		node.setIcon(TreeView.TreeNodeIcon.Folder)
		node.setIsVisible(false)
		node.parentTo(rootNode)

		return node
	end, { rootNode })

	local pinnedChildren = useSignalState(pinned.getChildren)
	local pinnedNodes = useSignalState(rootNode.getPinnedDescendants)
	local prevPinnedNodes = usePrevious(pinnedNodes)

	local selectedNode = useSignalState(rootNode.getSelectedDescendants)[1]
	local prevSelectedNode: TreeNodeStore? = usePrevious(selectedNode)

	local filter = useCallback(function(node: TreeNodeStore)
		if not props.searchTerm or props.searchTerm == "" then
			return false
		end

		return node.getName():lower():match(props.searchTerm:lower()) == nil
	end, { props.searchTerm })

	-- There can be pinned paths that were never apart of the current DataModel,
	-- so only add the "Pinned" root if there are children to show
	useEffect(function()
		pinned.setIsVisible(#pinnedChildren > 0)
	end, { pinnedChildren } :: { unknown })

	-- This handles automatically moving a node under the Pinned node once the
	-- node's instance becomes pinned
	useEffect(function()
		if pinnedNodes ~= prevPinnedNodes then
			-- Put any previously pinned nodes back to the root
			if prevPinnedNodes then
				for _, node in prevPinnedNodes do
					if not node.getIsPinned() then
						node.parentTo(rootNode)
					end
				end
			end

			for _, node in pinnedNodes do
				node.parentTo(pinned)
			end
		end
	end, { pinnedNodes, prevPinnedNodes, pinned, rootNode } :: { unknown })

	-- Convert available storybooks into nodes
	useEffect(function()
		for _, storybook in storybooks.available do
			if nodesByInstance.current[storybook.source] then
				continue
			end

			local node = createTreeNodesForStorybook(storybook :: any)

			nodesByInstance.current[storybook.source] = node
			storybookByNodeId.current[node.id] = storybook :: any

			if pinnedInstanceStore.isPinned(storybook.source) then
				node.setIsPinned(true)
				node.parentTo(pinned)
			else
				node.parentTo(rootNode)
			end
		end
	end, { storybooks.available, pinnedInstanceStore.isPinned, pinned, rootNode } :: { unknown })

	-- Convert unavailable storybooks into nodes
	useEffect(function()
		unavailableStorybooks.setIsVisible(#storybooks.unavailable > 0)

		for _, unavailableStorybook in storybooks.unavailable do
			local source = unavailableStorybook.storybook.source

			if nodesByInstance.current[source] then
				continue
			end

			local node = TreeView.createTreeNodeStore()
			node.setName(unavailableStorybook.storybook.name)
			node.setIcon(TreeView.TreeNodeIcon.Alert)
			node.parentTo(unavailableStorybooks)

			nodesByInstance.current[source] = node
			unavailableStorybookByNodeId.current[node.id] = unavailableStorybook
		end
	end, { storybooks.unavailable, unavailableStorybooks })

	-- Convert orphaned stories into nodes
	useEffect(function()
		unknownStories.setIsVisible(#orphanedStoryModules > 0)

		for _, orphan in orphanedStoryModules do
			if nodesByInstance.current[orphan] then
				continue
			end

			local node = createTreeNodeForStoryModule(orphan)

			node.parentTo(unknownStories)
			nodesByInstance.current[orphan] = node
		end
	end, { orphanedStoryModules, unknownStories } :: { unknown })

	-- Handle the removal of any nodes when instances get removed
	useEffect(function()
		local instances: { Instance } = Sift.List.join(
			Sift.List.map(storybooks.available, function(storybook)
				return storybook.source
			end),
			Sift.List.map(storybooks.unavailable, function(unavailableStorybook)
				return unavailableStorybook.storybook.source
			end),
			orphanedStoryModules
		)

		for instance, node in nodesByInstance.current do
			if not table.find(instances, instance) then
				node.parentTo(nil)
				nodesByInstance.current[instance] = nil
			end
		end
	end, { storybooks.available, storybooks.unavailable, orphanedStoryModules } :: { unknown })

	-- Reopening the story from the previous session
	local wasLastStoryOpened = useRef(false)
	local descendants = useSignalState(rootNode.getDescendants)
	useEffect(
		function()
			if wasLastStoryOpened.current then
				return
			end

			if lastOpenedStory then
				local node = rootNode.findFirstDescendant(function(descendant)
					return descendant.getInstance() == lastOpenedStory
				end)

				if node then
					wasLastStoryOpened.current = true
					node.activate()
					node.expandUp()
				end
			end
		end,
		{
			lastOpenedStory,
			rootNode,
			-- This value isn't used in the effect body but is necessary so that
			-- the effect will run again if the last opened story has not but
			-- found yet
			descendants,
		} :: { unknown }
	)

	useEffect(
		function()
			if selectedNode ~= prevSelectedNode then
				if props.onStoryChanged then
					if selectedNode then
						local icon = selectedNode.getIcon()
						local instance = selectedNode.getInstance()

						if icon == TreeView.TreeNodeIcon.Story and instance and instance:IsA("ModuleScript") then
							local storybookNode = selectedNode.findFirstAncestor(function(ancestor)
								return ancestor.getIcon() == TreeView.TreeNodeIcon.Storybook
							end)

							local storybook = if storybookNode then storybookByNodeId.current[storybookNode.id] else nil

							props.onStoryChanged(instance, storybook)
							setLastOpenedStory(instance)
						end
					else
						props.onStoryChanged(nil, nil)
					end
				end

				if props.onShowErrorPage then
					if selectedNode and selectedNode.getIcon() == TreeView.TreeNodeIcon.Alert then
						local unavailableStorybook = unavailableStorybookByNodeId.current[selectedNode.id]
						if unavailableStorybook then
							props.onShowErrorPage(unavailableStorybook)
						end
					end
				end
			end
		end,
		{
			props.onShowErrorPage,
			props.onStoryChanged,
			selectedNode,
			prevSelectedNode,
			setLastOpenedStory,
		} :: { unknown }
	)

	return React.createElement(TreeView.TreeView, {
		root = rootNode,
		filter = filter,
		layoutOrder = props.layoutOrder,
	})
end

return StorybookTreeView


local React = require("@pkg/React")
local Storyteller = require("@pkg/Storyteller")

local StorybookTreeView = require("./StorybookTreeView")

return {
	story = function()
		local storybooks = Storyteller.useStorybooks(game)

		return React.createElement("Frame", {
			Size = UDim2.fromOffset(300, 0),
			AutomaticSize = Enum.AutomaticSize.Y,
			BackgroundTransparency = 1,
		}, {
			StorybookTreeView = React.createElement(StorybookTreeView, {
				storybooks = storybooks,
				onStoryChanged = function(storyModule)
					if storyModule then
						print("selected", storyModule:GetFullName())
					end
				end,
			}),
		})
	end,
}


local ModuleLoader = require("@pkg/ModuleLoader")
local Storyteller = require("@pkg/Storyteller")

type LoadedStorybook = Storyteller.LoadedStorybook

local function createLoadedStorybook(): LoadedStorybook
	return {
		name = "Storybook",
		loader = ModuleLoader.new(),
		source = Instance.new("ModuleScript"),
		storyRoots = {},
	}
end

return createLoadedStorybook


local TreeView = require("@root/TreeView")

type TreeNodeStore = TreeView.TreeNodeStore

local function createTreeNodeForStoryModule(storyModule: ModuleScript): TreeNodeStore
	local name = storyModule.Name:gsub("%.story", "")

	local node = TreeView.createTreeNodeStore()
	node.setName(name)
	node.setIcon(TreeView.TreeNodeIcon.Story)
	node.setInstance(storyModule)

	return node
end

return createTreeNodeForStoryModule


local Storyteller = require("@pkg/Storyteller")

local TreeView = require("@root/TreeView")
local createTreeNodeForStoryModule = require("@root/Storybook/createTreeNodeForStoryModule")
local getAncestors = require("@root/Common/getAncestors")

type TreeNodeStore = TreeView.TreeNodeStore
type LoadedStorybook = Storyteller.LoadedStorybook
type LoadedStory<any> = Storyteller.LoadedStory<any>

local function createTreeNodesForStorybook(storybook: LoadedStorybook): TreeNodeStore
	local root = TreeView.createTreeNodeStore()
	root.setName(if storybook.name then storybook.name else "Unnamed Storybook")
	root.setIcon(TreeView.TreeNodeIcon.Storybook)
	root.setInstance(storybook.source)

	for _, storyModule in Storyteller.findStoryModulesForStorybook(storybook) do
		local currentNode = createTreeNodeForStoryModule(storyModule)

		for _, ancestor in getAncestors(storyModule) do
			-- This is probably expensive to be doing so frequently but it seems
			-- to be fine for now. May hit a point where this bit in particular
			-- becomes a bottleneck though
			local existingNode = root.findFirstDescendant(function(descendant)
				return descendant.getInstance() == ancestor
			end)

			if existingNode then
				currentNode.parentTo(existingNode)
				break
			end

			if table.find(storybook.storyRoots, ancestor) then
				currentNode.parentTo(root)
				break
			end

			local node = TreeView.createTreeNodeStore()
			node.setName(ancestor.Name)
			node.setIcon(TreeView.TreeNodeIcon.Folder)
			node.setInstance(ancestor)

			currentNode.parentTo(node)
			currentNode = node
		end
	end

	return root
end

return createTreeNodesForStorybook


local useThemeName = require("@root/Common/useThemeName")

type ThemeName = useThemeName.ThemeName

export type ExtraStoryProps = {
	theme: ThemeName,
	locale: string,

	-- TODO: Make these reequired in the future. Only reason they're not is
	-- because we'll need to massage the types in StoryPreview to clear the nil
	-- value. And I didn't want to do that yet
	plugin: Plugin?,
	widget: GuiBase2d?,
}

return nil


local React = require("@pkg/React")
local ReactCharm = require("@pkg/ReactCharm")
local Sift = require("@pkg/Sift")

local LocalStorageStore = require("@root/Plugin/LocalStorageStore")
local UserSettingsStore = require("@root/UserSettings/UserSettingsStore")
local getInstanceFromPath = require("@root/Common/getInstanceFromPath")
local getInstancePath = require("@root/Common/getInstancePath")

local useCallback = React.useCallback
local useMemo = React.useMemo
local useSignalState = ReactCharm.useSignalState

local function useLastOpenedStory(): (ModuleScript?, (storyModule: ModuleScript?) -> ())
	local localStorageStore = useSignalState(LocalStorageStore.get)
	local localStorage = useSignalState(localStorageStore.getStorage)

	local userSettingsStore = useSignalState(UserSettingsStore.get)
	local userSettings = useSignalState(userSettingsStore.getStorage)

	local setLastOpenedStory = useCallback(function(storyModule: ModuleScript?)
		localStorageStore.setStorage(function(prev)
			return Sift.Dictionary.join(prev, {
				lastOpenedStoryPath = if storyModule then getInstancePath(storyModule) else nil,
			})
		end)
	end, { localStorageStore })

	local lastOpenedStory = useMemo(function(): ModuleScript?
		local rememberLastOpenedStory = userSettings.rememberLastOpenedStory

		if not rememberLastOpenedStory then
			return nil
		end

		local lastOpenedStoryPath = localStorage.lastOpenedStoryPath

		if lastOpenedStoryPath then
			local instance = getInstanceFromPath(lastOpenedStoryPath)

			if instance and instance:IsA("ModuleScript") then
				return instance
			end
		end

		return nil
	end, { userSettings, localStorage } :: { unknown })

	return lastOpenedStory, setLastOpenedStory
end

return useLastOpenedStory


local React = require("@pkg/React")
local Sift = require("@pkg/Sift")

local Array = Sift.Array
local Dictionary = Sift.Dictionary

local useMemo = React.useMemo

export type Control = {
	key: string,
	value: any,
}

local function filterControls(controlA: Control, controlB: Control)
	return controlA.key < controlB.key
end

local function useSortedControls(controls: { [string]: any }): { Control }
	return useMemo(function()
		return Array.sort(
			Array.map(Dictionary.entries(controls), function(entry)
				return { key = entry[1], value = entry[2] }
			end),
			filterControls
		)
	end, { controls })
end

return useSortedControls


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")
local ReactCharm = require("@pkg/ReactCharm")
local Sift = require("@pkg/Sift")

local LocalStorageStore = require("@root/Plugin/LocalStorageStore")
local UserSettingsStore = require("@root/UserSettings/UserSettingsStore")
local fireEventAsync = require("@root/Telemetry/fireEventAsync")
local nextLayoutOrder = require("@root/Common/nextLayoutOrder")

local useSignalState = ReactCharm.useSignalState
local useCallback = React.useCallback
local useState = React.useState

local function TelemetryOptOutDialog(): React.Node?
	local userSettingsStore = useSignalState(UserSettingsStore.get)
	local localStorageStore = useSignalState(LocalStorageStore.get)
	local storage = useSignalState(localStorageStore.getStorage)
	local isStorageLoading = useSignalState(localStorageStore.getIsLoading)
	local isOptedIn, setIsOptedIn = useState(true)

	local onClose = useCallback(function()
		localStorageStore.setStorage(function(prev)
			return Sift.Dictionary.join(prev, {
				wasUserPromptedForTelemetry = true,
			})
		end)

		userSettingsStore.setStorage(function(prev)
			return Sift.Dictionary.join(prev, {
				collectAnonymousUsageData = isOptedIn,
			})
		end)

		task.spawn(function()
			if isOptedIn then
				fireEventAsync({
					eventName = "TelemetryOptedIn",
				})
			else
				fireEventAsync({
					eventName = "TelemetryOptedOut",
				})
			end
		end)
	end, { localStorageStore.setStorage, userSettingsStore.setStorage, isOptedIn } :: { unknown })

	-- We check `isStorageLoading` to prevent the dialog from briefly flashing
	-- on screen
	if isStorageLoading or storage.wasUserPromptedForTelemetry then
		return nil
	end

	return React.createElement(Foundation.Dialog.Root, {
		disablePortal = false,
		hasBackdrop = true,
		onClose = onClose,
		onPressedOutside = onClose,
	}, {
		Title = React.createElement(Foundation.Dialog.Title, {
			text = "Help us improve Flipbook",
		}),

		Content = React.createElement(Foundation.Dialog.Content, {
			LayoutOrder = nextLayoutOrder(),
		}, {
			Wrapper = React.createElement(Foundation.View, {
				tag = "auto-xy col gap-large",
			}, {
				MainParagraph = React.createElement(Foundation.Dialog.Text, {
					tag = "auto-xy text-wrap text-body-medium",
					Text = "Flipbook would like to send anonymized usage data to help us improve the quality of the plugin.",
					LayoutOrder = nextLayoutOrder(),
				}),

				WhereToOptOut = React.createElement(Foundation.Dialog.Text, {
					tag = "auto-xy text-wrap text-body-medium",
					Text = "You can opt out at any time in the settings.",
					LayoutOrder = nextLayoutOrder(),
				}),

				Checkbox = React.createElement(Foundation.Checkbox, {
					label = "Help improve Flipbook by sending anonymous usage data",
					isChecked = isOptedIn,
					onActivated = setIsOptedIn,
					LayoutOrder = nextLayoutOrder(),
				}),
			}),
		}),

		Actions = React.createElement(Foundation.Dialog.Actions, {
			actions = {
				{
					text = "Done",
					variant = Foundation.Enums.ButtonVariant.Emphasis,
					onActivated = onClose,
				},
			},
			LayoutOrder = nextLayoutOrder(),
		}),
	})
end

return TelemetryOptOutDialog


local React = require("@pkg/React")

local TelemetryOptOutDialog = require("./TelemetryOptOutDialog")

return {
	story = function()
		return React.createElement(TelemetryOptOutDialog)
	end,
}


local UserSettingsStore = require("@root/UserSettings/UserSettingsStore")
local getAnonymizedUserId = require("@root/Telemetry/getAnonymizedUserId")
local logger = require("@root/logger")
local requestAsync = require("@root/Http/requestAsync")
local types = require("@root/Telemetry/types")

type TelemetryEvent = types.TelemetryEvent

local function fireEventAsync(event: TelemetryEvent)
	local userSettingsStore = UserSettingsStore.get()
	local userSettings = userSettingsStore.getStorage()

	if not userSettings.collectAnonymousUsageData then
		return
	end

	local body = {
		eventName = event.eventName,
		properties = (event :: any).properties,
		anonymizedUserId = getAnonymizedUserId(),
		buildVersion = _G.BUILD_VERSION,
		buildChannel = _G.BUILD_CHANNEL,
		buildHash = _G.BUILD_HASH,
	}

	logger:Info(`firing event {event.eventName}`)

	return requestAsync(`{_G.BASE_URL}/telemetry`, {
		method = "POST",
		body = body,
	})
end

return fireEventAsync


local sha256 = require("@pkg/sha256")

local getLocalUserId = require("@root/Telemetry/getLocalUserId")

--[[
	The events we send to the backend need some persistent identifier so we can
	group them by the user they come from. However, we do not collect PII so we
	first need to anonymize the UserId before sending it off. That's what this
	function is for.
]]
local function getAnonymizedUserId(): string
	local plainUserId = getLocalUserId()
	local hash = sha256(tostring(plainUserId))
	return hash
end

return getAnonymizedUserId


local Players = game:GetService("Players")
local RunService = game:GetService("RunService")
local StudioService = if RunService:IsStudio() then game:GetService("StudioService") else nil

local function getLocalUserId(): number
	if StudioService then
		return StudioService:GetUserId()
	end
	if Players.LocalPlayer then
		return Players.LocalPlayer.UserId
	end
	return -1
end

return getLocalUserId


-- The events in this file mirror the TelementryEventName enum in the backend.
-- The backend must be updated first before adding new events.

export type AppOpened = {
	eventName: "AppOpened",
}

export type AppClosed = {
	eventName: "AppClosed",
}

export type NodePinned = {
	eventName: "NodePinned",
}

export type NodeUnpinned = {
	eventName: "NodeUnpinned",
}

export type PageChanged = {
	eventName: "PageChanged",
	properties: {
		page: string,
	},
}
export type StoryOpened = {
	eventName: "StoryOpened",
}

export type StoryClosed = {
	eventName: "StoryClosed",
}

export type TelemetryOptedIn = {
	eventName: "TelemetryOptedIn",
}

export type TelemetryOptedOut = {
	eventName: "TelemetryOptedOut",
}

export type FeedbackDialogOpened = {
	eventName: "FeedbackDialogOpened",
}

export type FeedbackSubmitted = {
	eventName: "FeedbackSubmitted",
}

export type FeedbackDiscarded = {
	eventName: "FeedbackDiscarded",
}

export type TelemetryEvent =
	AppOpened
	| AppClosed
	| NodePinned
	| NodeUnpinned
	| PageChanged
	| StoryClosed
	| StoryOpened
	| TelemetryOptedIn
	| TelemetryOptedOut
	| FeedbackDialogOpened
	| FeedbackSubmitted
	| FeedbackDiscarded

return nil


local MockPlugin = {}
MockPlugin.__index = MockPlugin

local function simulateDiskAccess()
	task.wait()
	task.wait()
end

function MockPlugin.new(): Plugin
	local self = {
		_settings = {},
	}

	return setmetatable(self, MockPlugin) :: any
end

function MockPlugin:GetSetting(settingName: string)
	simulateDiskAccess()
	return self._settings[settingName]
end

function MockPlugin:SetSetting(settingName: string, newValue: any)
	simulateDiskAccess()
	self._settings[settingName] = newValue
end

function MockPlugin:GetMouse()
	return {
		Icon = "rbxassetid://-1",
	}
end

return MockPlugin


local function newFolder(children: { [string]: Instance }): Folder
	local folder = Instance.new("Folder")
	folder.Name = "Root"

	for name, child in pairs(children) do
		child.Name = name
		child.Parent = folder
	end

	return folder
end

return newFolder


local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")

local act = ReactRoblox.act

type Options = {
	wrapper: React.ComponentType<{
		children: React.ReactNode,
	}>,
}

local function renderHook<T...>(hook: () -> T..., options: Options?): () -> T...
	local result = React.createRef()

	local function TestComponent()
		result.current = table.pack(hook())
	end

	local container = Instance.new("Folder")
	local root = ReactRoblox.createRoot(container)

	local element = React.createElement(TestComponent)

	if options and options.wrapper then
		element = React.createElement(options.wrapper, nil, element)
	end

	act(function()
		root:render(element)
	end)

	return function()
		return table.unpack(result.current)
	end
end

return renderHook


local Selection = game:GetService("Selection")

local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")
local ReactCharm = require("@pkg/ReactCharm")

local PinnedInstanceStore = require("@root/Storybook/PinnedInstanceStore")
local Types = require("@root/TreeView/types")
local useTreeNodeIcon = require("@root/TreeView/useTreeNodeIcon")

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

local e = React.createElement
local useCallback = React.useCallback
local useMemo = React.useMemo
local useState = React.useState
local useRef = React.useRef

local useSignalState = ReactCharm.useSignalState

type TreeNodeStore = Types.TreeNodeStore

type TreeNodeProps = {
	node: TreeNodeStore,
	onActivated: (node: TreeNodeStore) -> ()?,
} & Foundation.CommonProps

local function TreeNode(props: TreeNodeProps)
	local ancestors = useSignalState(props.node.getAncestors)
	local icon = useSignalState(props.node.getIcon)
	local instance = useSignalState(props.node.getInstance)
	local isExpanded = useSignalState(props.node.getIsExpanded)
	local isSelected = useSignalState(props.node.getIsSelected)
	local isVisible = useSignalState(props.node.getIsVisible)
	local isFiltered = useSignalState(props.node.getIsFiltered)
	local name = useSignalState(props.node.getName)
	local nodeChildren = useSignalState(props.node.getChildren)
	local iconName, iconStyle = useTreeNodeIcon(icon :: Types.TreeNodeIcon)

	local isContextMenuOpen, setIsContextMenuOpen = useState(false)
	local ref = useRef(nil :: GuiObject?)

	local tokens = useTokens()
	local pinnedInstanceStore = useSignalState(PinnedInstanceStore.get)
	local isPinned = if instance then pinnedInstanceStore.isPinned(instance) else false

	local children = useMemo(function()
		local elements: { [string]: React.ReactNode } = {}

		for index, childNode in nodeChildren do
			elements[childNode.getName()] = e(TreeNode, {
				LayoutOrder = index,
				node = childNode,
				onActivated = props.onActivated,
			})
		end

		return elements
	end, { nodeChildren })

	local onActivated = useCallback(function()
		props.node.activate()

		if props.onActivated ~= nil then
			props.onActivated(props.node)
		end
	end, { props.onActivated, props.node } :: { unknown })

	local onSecondaryActivated = useCallback(function()
		setIsContextMenuOpen(true)
	end, {})

	local onTogglePin = useCallback(
		function()
			if instance then
				if isPinned then
					pinnedInstanceStore.unpin(instance)
					props.node.setIsPinned(false)
				else
					pinnedInstanceStore.pin(instance)
					props.node.setIsPinned(true)
				end
			end
		end,
		{
			instance,
			isPinned,
			pinnedInstanceStore.pin,
			pinnedInstanceStore.unpin,
			props.node.setIsPinned,
		} :: { unknown }
	)

	local contextMenuItems = useMemo(function()
		local items: { Foundation.MenuItem } = {}

		if icon == Types.TreeNodeIcon.Storybook then
			table.insert(items, {
				id = "pin",
				text = if isPinned then "Unpin" else "Pin",
				onActivated = function()
					onTogglePin()
					setIsContextMenuOpen(false)
				end,
			})
		end

		if instance then
			table.insert(items, {
				id = "select-in-explorer",
				text = "Select in Explorer",
				onActivated = function()
					Selection:Set({ instance })
					setIsContextMenuOpen(false)
				end,
			})
		end

		if #nodeChildren > 0 then
			table.insert(items, {
				id = "expand-all",
				text = "Expand all",
				onActivated = function()
					props.node.expandDown()
					setIsContextMenuOpen(false)
				end,
			})
			table.insert(items, {
				id = "collapse-all",
				text = "Collapse all",
				onActivated = function()
					props.node.collapseDown()
					setIsContextMenuOpen(false)
				end,
			})
		end

		return items
	end, { isPinned, icon, instance, nodeChildren, props.node } :: { unknown })

	if isVisible == false or isFiltered == true then
		return nil
	end

	return e(
		View,
		withCommonProps(props, {
			tag = "auto-y col size-full-0",
		}),
		{
			Node = e(View, {
				LayoutOrder = 1,
				backgroundStyle = if isSelected then tokens.Color.ActionEmphasis.Background else nil,
				onActivated = onActivated,
				onSecondaryActivated = onSecondaryActivated,
				padding = {
					left = UDim.new(0, tokens.Padding.Medium * #ancestors),
				},
				tag = "align-y-center auto-y gap-medium padding-right-medium padding-y-small row size-full-0",
				ref = ref,
			}, {
				Icon = e(Icon, {
					LayoutOrder = 1,
					name = iconName,
					size = IconSize.Small,
					style = iconStyle,
					variant = if isSelected then IconVariant.Filled else nil,
				}),

				Title = e(Text, {
					LayoutOrder = 2,
					Text = name,
					tag = {
						["auto-y shrink size-full-0 text-align-x-left text-label-medium text-truncate-end"] = true,
						["content-emphasis"] = isSelected,
					},
				}),

				Arrow = #nodeChildren > 0 and e(Icon, {
					LayoutOrder = 3,
					name = if isExpanded then IconName.ChevronSmallUp else IconName.ChevronSmallDown,
					size = IconSize.XSmall,
				}),
			}),

			ContextMenu = if isContextMenuOpen and #contextMenuItems > 0
				then React.createElement(Foundation.Menu, {
					isOpen = true,
					size = Foundation.Enums.InputSize.Medium,
					items = contextMenuItems,
					onPressedOutside = function()
						setIsContextMenuOpen(false)
					end,
					anchorRef = ref,
				})
				else nil,

			Children = isExpanded and e(View, {
				LayoutOrder = 2,
				tag = "auto-xy col",
			}, children),
		}
	)
end

return React.memo(TreeNode)


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")
local ReactCharm = require("@pkg/ReactCharm")

local TreeNode = require("@root/TreeView/TreeNode")
local types = require("@root/TreeView/types")
local useTreeNodeFilter = require("@root/TreeView/useTreeNodeFilter")

local useSignalState = ReactCharm.useSignalState
local useEffect = React.useEffect

type TreeNodeStore = types.TreeNodeStore
type TreeNodeFilter = types.TreeNodeFilter

export type Props = {
	root: TreeNodeStore,
	filter: TreeNodeFilter?,
	layoutOrder: number?,
}

local function TreeView(props: Props)
	local rootNodeChildren = useSignalState(props.root.getChildren)

	useTreeNodeFilter(props.root, props.filter)

	useEffect(function()
		props.root.setSortOrder(function(a: TreeNodeStore, b: TreeNodeStore)
			if a.getIcon() ~= b.getIcon() then
				-- Sort by type
				return a.getIcon() < b.getIcon()
			else
				-- Sort alphabetically
				return a.getName():lower() < b.getName():lower()
			end
		end)
	end, { props.root })

	local children: { [string]: React.Node } = {}
	for index, node in rootNodeChildren do
		children[node.id] = React.createElement(TreeNode, {
			node = node,
			LayoutOrder = index,
		})
	end

	return React.createElement(Foundation.View, {
		tag = "auto-xy col",
		LayoutOrder = props.layoutOrder,
	}, children)
end

return TreeView


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")

local TreeView = require("@root/TreeView/TreeView")
local createTreeNodeStore = require("@root/TreeView/createTreeNodeStore")
local types = require("@root/TreeView/types")
local useTreeNodeFilter = require("@root/TreeView/useTreeNodeFilter")

local useCallback = React.useCallback
local useState = React.useState

type TreeNodeStore = types.TreeNodeStore

local root = createTreeNodeStore()
root.setName("<root>")

local deepStory

do
	local pins = createTreeNodeStore()
	pins.setName("Pinned Storybooks")
	pins.setIcon(types.TreeNodeIcon.Pin)
	pins.parentTo(root)

	local storybook = createTreeNodeStore()
	storybook.setName("Storybook 1")
	storybook.setIcon(types.TreeNodeIcon.Storybook)
	storybook.parentTo(root)

	local folder1 = createTreeNodeStore()
	folder1.setName("Folder 1")
	folder1.setIcon(types.TreeNodeIcon.Folder)
	folder1.parentTo(storybook)

	local folder2 = createTreeNodeStore()
	folder2.setName("Folder 2")
	folder2.setIcon(types.TreeNodeIcon.Folder)
	folder2.parentTo(folder1)

	local folder3 = createTreeNodeStore()
	folder3.setName("Folder 3")
	folder3.setIcon(types.TreeNodeIcon.Folder)
	folder3.parentTo(folder2)
end

do
	local storybook = createTreeNodeStore()
	storybook.setName("Storybook 2")
	storybook.setIcon(types.TreeNodeIcon.Storybook)
	storybook.parentTo(root)

	local folder1 = createTreeNodeStore()
	folder1.setName("Folder 1")
	folder1.setIcon(types.TreeNodeIcon.Folder)
	folder1.parentTo(storybook)

	local folder2 = createTreeNodeStore()
	folder2.setName("Folder 2")
	folder2.setIcon(types.TreeNodeIcon.Folder)
	folder2.parentTo(folder1)

	local folder3 = createTreeNodeStore()
	folder3.setName("Folder 3")
	folder3.setIcon(types.TreeNodeIcon.Folder)
	folder3.parentTo(folder2)

	deepStory = createTreeNodeStore()
	deepStory.setName("Deeply Nested Story")
	deepStory.setIcon(types.TreeNodeIcon.Story)
	deepStory.parentTo(folder3)
end

do
	local storybook = createTreeNodeStore()
	storybook.setName("Storybook 3")
	storybook.setIcon(types.TreeNodeIcon.Storybook)
	storybook.parentTo(root)

	local folder = createTreeNodeStore()
	folder.setName("Folder")
	folder.setIcon(types.TreeNodeIcon.Folder)
	folder.parentTo(storybook)

	local siblingStory1 = createTreeNodeStore()
	siblingStory1.setName("Story 1")
	siblingStory1.setIcon(types.TreeNodeIcon.Story)
	siblingStory1.parentTo(folder)

	local siblingStory2 = createTreeNodeStore()
	siblingStory2.setName("Story 2")
	siblingStory2.setIcon(types.TreeNodeIcon.Story)
	siblingStory2.parentTo(folder)

	local story1 = createTreeNodeStore()
	story1.setName("Story 1")
	story1.setIcon(types.TreeNodeIcon.Story)
	story1.parentTo(storybook)

	local story2 = createTreeNodeStore()
	story2.setName("Story 2")
	story2.setIcon(types.TreeNodeIcon.Story)
	story2.parentTo(storybook)

	local story3 = createTreeNodeStore()
	story3.setName("Story 3")
	story3.setIcon(types.TreeNodeIcon.Story)
	story3.parentTo(storybook)
end

do
	local storybook = createTreeNodeStore()
	storybook.setName("Storybook 4")
	storybook.setIcon(types.TreeNodeIcon.Storybook)
	storybook.parentTo(root)
end

return {
	story = function()
		local searchTerm: string?, setSearchTerm = useState(nil :: string?)

		local onExpand = useCallback(function()
			local node = root.findFirstDescendant(function(descendant)
				return descendant.id == deepStory.id
			end)

			if node then
				node.activate()
				node.expandUp()
			end
		end, {})

		useTreeNodeFilter(
			root,
			useCallback(function(node: TreeNodeStore)
				if not searchTerm or searchTerm == "" then
					return false
				end

				return node.getName():lower():match(searchTerm:lower()) == nil
			end, { searchTerm })
		)

		return React.createElement(Foundation.View, {
			tag = "auto-y col gap-large",
			Size = UDim2.fromOffset(300, 0),
		}, {
			Topbar = React.createElement(Foundation.View, {
				tag = "size-full-0 auto-y row gap-large",
				LayoutOrder = 1,
			}, {
				Search = React.createElement(Foundation.TextInput, {
					text = "",
					label = "",
					placeholder = "Search...",
					size = Foundation.Enums.InputSize.Medium,
					width = UDim.new(1, 0),
					iconLeading = Foundation.Enums.IconName.MagnifyingGlass,
					onChanged = setSearchTerm,
					LayoutOrder = 1,
				}),

				ExpandToNode = React.createElement(Foundation.Button, {
					text = "Expand",
					size = Foundation.Enums.ButtonSize.Small,
					onActivated = onExpand,
					LayoutOrder = 2,
				}),
			}),

			TreeView = React.createElement(TreeView, {
				root = root,
				layoutOrder = 2,
			}),
		})
	end,
}


local HttpService = game:GetService("HttpService")

local Charm = require("@pkg/Charm")
local Sift = require("@pkg/Sift")

local CharmTypes = require("@root/Common/CharmTypes")
local types = require("@root/TreeView/types")

type TreeNodeStore = types.TreeNodeStore
type TreeNodeSnapshot = types.TreeNodeSnapshot
type TreeNodeIcon = types.TreeNodeIcon
type TreeNodeSort = types.TreeNodeSort

local function createTreeNodeStore(): TreeNodeStore
	local id = HttpService:GenerateGUID()

	local getName, setName = Charm.signal("Node")
	local getIcon, setIcon = Charm.signal(nil :: TreeNodeIcon?)
	local getIsSelected, setIsSelected = Charm.signal(false)
	local getIsExpanded, setIsExpanded = Charm.signal(false)
	local getIsVisible, setIsVisible = Charm.signal(true)
	local getIsFiltered, setIsFiltered = Charm.signal(false)
	local getIsPinned, setIsPinned = Charm.signal(false)

	local getInstance, setInstance = Charm.signal(nil :: Instance?)

	local getParent, setParent = Charm.signal(nil :: TreeNodeStore?)
	local getUnsortedChildren, setUnsortedChildren = Charm.signal({} :: { TreeNodeStore })

	local getSortOrder, setSortOrderSignal = Charm.signal(nil :: TreeNodeSort?)

	local self: TreeNodeStore

	local getChildren = Charm.computed(function()
		local children = table.clone(getUnsortedChildren())
		local sort = getSortOrder()

		if sort then
			table.sort(children, function(a, b)
				return sort(a, b)
			end)
		end

		return children
	end)

	local getAncestors = Charm.computed(function()
		local ancestors: { TreeNodeStore } = {}
		local candidate: TreeNodeStore? = getParent()

		while candidate do
			table.insert(ancestors, candidate)
			candidate = candidate.getParent()
		end

		return ancestors
	end)

	local getDescendants = Charm.computed(function()
		local descendants: { TreeNodeStore } = {}
		local stack = table.clone(getUnsortedChildren())

		while #stack > 0 do
			local candidate = table.remove(stack)
			if candidate then
				table.insert(descendants, candidate)
				stack = Sift.List.join(stack, candidate.getChildren())
			end
		end

		return descendants
	end)

	local function setSortOrder(sort: TreeNodeSort?)
		setSortOrderSignal(function()
			-- FIXME: Need to cast to `any` here. It's suspected this is related
			-- to how Signals types its setter functions, where it can take
			-- either a value or an update function. Since we _want_ to store a
			-- function, that might be what's tripping it up
			return sort :: any
		end)

		-- Because this isn't scoped newly added descendants won't have the same
		-- sorting function. This is aleviated by parentTo assigning the new
		-- parent's sorting function to the node being moved
		for _, descendant in Charm.untracked(getDescendants) do
			descendant.setSortOrder(sort)
		end
	end

	local function createDescendantMatcher(predicate: (descendant: TreeNodeStore) -> boolean?): () -> { TreeNodeStore }
		return Charm.computed(function()
			local matches = {}
			for _, descendant in getDescendants() do
				local result = predicate(descendant)
				if result == true then
					table.insert(matches, descendant)
				elseif result == false then
					continue
				elseif result == nil then
					break
				end
			end
			return matches
		end)
	end

	local getRoot = Charm.computed(function()
		local ancestors = getAncestors()
		return ancestors[#ancestors]
	end)

	local getPath = Charm.computed(function()
		local path = getName()
		for _, ancestor in getAncestors() do
			path = `{ancestor.getName()}/{path}`
		end
		return path
	end)

	local getLeafNodes = createDescendantMatcher(function(descendant)
		return #descendant.getChildren() == 0
	end)

	local getSelectedDescendants = createDescendantMatcher(function(descendant)
		return descendant.getIsSelected()
	end)

	local getPinnedDescendants = createDescendantMatcher(function(descendant)
		return descendant.getIsPinned()
	end)

	local function findFirstDescendant(predicate: (node: TreeNodeStore) -> boolean): TreeNodeStore?
		for _, descendant in Charm.untracked(getDescendants) do
			if predicate(descendant) then
				return descendant
			end
		end
		return nil
	end

	local function findFirstAncestor(predicate: (node: TreeNodeStore) -> boolean): TreeNodeStore?
		for _, ancestor in Charm.untracked(getAncestors) do
			if predicate(ancestor) then
				return ancestor
			end
		end
		return nil
	end

	local function parentTo(newParent: TreeNodeStore?)
		local parent = Charm.untracked(getParent)

		if parent ~= nil then
			if newParent == parent then
				return
			end

			parent.setChildren(function(prev)
				return Sift.List.filter(prev, function(otherNode)
					return otherNode.id ~= id
				end)
			end)
		end

		setParent(newParent)

		if newParent ~= nil then
			newParent.setChildren(function(prev)
				return Sift.List.join(prev, { self })
			end)

			-- Inherit the sorting function from the new parent
			setSortOrder(Charm.untracked(newParent.getSortOrder))
		end
	end

	local function clearChildren()
		for _, child in Charm.untracked(getChildren) do
			child.parentTo(nil)
		end
	end

	local function expandDown()
		setIsExpanded(true)
		for _, descendant in Charm.untracked(getDescendants) do
			descendant.setIsExpanded(true)
		end
	end

	local function expandUp()
		setIsExpanded(true)
		for _, ancestor in Charm.untracked(getAncestors) do
			ancestor.setIsExpanded(true)
		end
	end

	local function collapseDown()
		setIsExpanded(false)
		for _, descendant in Charm.untracked(getDescendants) do
			descendant.setIsExpanded(false)
		end
	end

	local function activate()
		if #Charm.untracked(getUnsortedChildren) > 0 then
			setIsExpanded(function(prev)
				return not prev
			end)
		else
			local root = Charm.untracked(getRoot)
			if root then
				for _, selection in Charm.untracked(root.getSelectedDescendants) do
					if selection ~= self then
						selection.setIsSelected(false)
					end
				end
			end

			setIsSelected(function(prev)
				return not prev
			end)
		end
	end

	local function snapshot(): TreeNodeSnapshot
		local root = {
			id = id,
			name = Charm.untracked(getName),
			icon = Charm.untracked(getIcon),
			isVisible = Charm.untracked(getIsVisible),
			isSelected = Charm.untracked(getIsSelected),
			children = {},
		}

		for _, child in Charm.untracked(getUnsortedChildren) do
			table.insert(root.children, child.snapshot())
		end

		return root
	end

	self = {
		id = id,

		-- Node properties and states
		getName = getName,
		setName = setName,
		getIcon = getIcon :: () -> TreeNodeIcon?,
		setIcon = (setIcon :: any) :: CharmTypes.Setter<TreeNodeIcon?>,
		getIsVisible = getIsVisible,
		setIsVisible = setIsVisible,
		getIsSelected = getIsSelected,
		setIsSelected = setIsSelected,
		activate = activate,
		setIsFiltered = setIsFiltered,
		getIsFiltered = getIsFiltered,
		setIsPinned = setIsPinned,
		getIsPinned = getIsPinned,
		getLeafNodes = getLeafNodes,

		-- Expand/Collapse
		collapseDown = collapseDown,
		expandDown = expandDown,
		expandUp = expandUp,
		getIsExpanded = getIsExpanded,
		setIsExpanded = setIsExpanded,

		-- Hierarchy
		getAncestors = getAncestors,
		getChildren = getChildren,
		getDescendants = getDescendants,
		getSelectedDescendants = getSelectedDescendants,
		getPinnedDescendants = getPinnedDescendants,
		getParent = getParent,
		parentTo = parentTo,
		clearChildren = clearChildren,
		setChildren = setUnsortedChildren,
		findFirstAncestor = findFirstAncestor,
		findFirstDescendant = findFirstDescendant,
		getSortOrder = getSortOrder,
		setSortOrder = setSortOrder,
		getPath = getPath,

		-- DataModel
		getInstance = getInstance,
		setInstance = setInstance,

		-- Other
		snapshot = snapshot,
	}

	return self
end

return createTreeNodeStore


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

export type TreeNodeStore = types.TreeNodeStore

return {
	-- Enums
	TreeNodeIcon = types.TreeNodeIcon,

	-- Stores
	createTreeNodeStore = require("@self/createTreeNodeStore"),

	-- Components
	TreeView = require("@self/TreeView"),
}


local CharmTypes = require("@root/Common/CharmTypes")

local types = {}

export type TreeNodeIcon = "none" | "story" | "storybook" | "folder" | "alert" | "pin"

types.TreeNodeIcon = {
	None = "none" :: "none",
	Story = "story" :: "story",
	Storybook = "storybook" :: "storybook",
	Folder = "folder" :: "folder",
	Alert = "alert" :: "alert",
	Pin = "pin" :: "pin",
}

export type TreeNodeFilter = (node: TreeNodeStore) -> boolean

export type TreeNodeSort = (a: TreeNodeStore, b: TreeNodeStore) -> boolean

export type TreeNodeStore = {
	id: string,

	-- Node properties and states
	getName: CharmTypes.Getter<string>,
	setName: CharmTypes.Setter<string>,
	getIcon: CharmTypes.Getter<TreeNodeIcon?>,
	setIcon: CharmTypes.Setter<TreeNodeIcon?>,
	getIsSelected: CharmTypes.Getter<boolean>,
	setIsSelected: CharmTypes.Setter<boolean>,
	getIsVisible: CharmTypes.Getter<boolean>,
	setIsVisible: CharmTypes.Setter<boolean>,
	activate: () -> (),
	getIsFiltered: CharmTypes.Getter<boolean>,
	setIsFiltered: CharmTypes.Setter<boolean>,
	getIsPinned: CharmTypes.Getter<boolean>,
	setIsPinned: CharmTypes.Setter<boolean>,

	-- Expand/Collapse
	getIsExpanded: CharmTypes.Getter<boolean>,
	setIsExpanded: CharmTypes.Setter<boolean>,
	expandDown: () -> (),
	expandUp: () -> (),
	collapseDown: () -> (),

	-- Hierarchy
	getChildren: CharmTypes.Getter<{ TreeNodeStore }>,
	setChildren: CharmTypes.Setter<{ TreeNodeStore }>,
	getAncestors: CharmTypes.Getter<{ TreeNodeStore }>,
	getDescendants: CharmTypes.Getter<{ TreeNodeStore }>,
	getLeafNodes: CharmTypes.Getter<{ TreeNodeStore }>,
	getPath: CharmTypes.Getter<string>,
	getSelectedDescendants: CharmTypes.Getter<{ TreeNodeStore }>,
	getPinnedDescendants: CharmTypes.Getter<{ TreeNodeStore }>,
	getParent: CharmTypes.Getter<TreeNodeStore?>,
	parentTo: (newParent: TreeNodeStore?) -> (),
	clearChildren: () -> (),
	findFirstDescendant: (predicate: (node: TreeNodeStore) -> boolean) -> TreeNodeStore?,
	findFirstAncestor: (predicate: (node: TreeNodeStore) -> boolean) -> TreeNodeStore?,
	getSortOrder: CharmTypes.Getter<TreeNodeSort?>,
	setSortOrder: (sort: TreeNodeSort?) -> (),

	-- DataModel
	getInstance: CharmTypes.Getter<Instance?>,
	setInstance: CharmTypes.Setter<Instance?>,

	-- Other
	snapshot: () -> TreeNodeSnapshot,
	-- destroy: () -> (),
}

export type TreeNodeSnapshot = {
	id: string,
	name: string,
	icon: string?,
	isSelected: boolean,
	isVisible: boolean,
	instance: Instance?,
	children: { TreeNodeSnapshot },
}

return types


local React = require("@pkg/React")
local ReactCharm = require("@pkg/ReactCharm")
local Sift = require("@pkg/Sift")

local types = require("@root/TreeView/types")

local useSignalState = ReactCharm.useSignalState
local useEffect = React.useEffect

type TreeNodeStore = types.TreeNodeStore
type TreeNodeFilter = types.TreeNodeFilter

local function useTreeNodeFilter(root: TreeNodeStore, filter: TreeNodeFilter?)
	local leaves = useSignalState(root.getLeafNodes)

	useEffect(function()
		if filter then
			-- Resetting the filtered state to do it all over again for the new
			-- filter function
			root.setIsFiltered(false)
			for _, descendant in root.getDescendants() do
				descendant.setIsFiltered(false)
			end

			local included = {}

			for _, leaf in leaves do
				local includeRemainingNodes = false

				-- This orders the nodes starting from the leaf and then each
				-- parent going up the ancestry chain. This is useful so when we
				-- hit at least one node that shouldn't be filtered we know we
				-- have to keep the remaining items after that point unfiltered
				-- too.
				local nodes: { TreeNodeStore } = Sift.List.join({ leaf }, leaf.getAncestors())

				for _, node in nodes do
					-- We've already marked this node as unfiltered, which means
					-- this node and all of the remaining ancestor nodes we'd be
					-- iterating through should also be unfiltered. So we just
					-- exit out here
					if included[node.id] then
						break
					end

					-- If at least one of the nodes in `nodes` shouldn't be
					-- filtered, then all ancestors after that point shouldn't
					-- be filtered either.
					if includeRemainingNodes then
						included[node.id] = true
						node.setIsFiltered(false)
						continue
					end

					if filter(node) then
						node.setIsFiltered(true)
					else
						included[node.id] = true
						node.setIsFiltered(false)
						includeRemainingNodes = true
					end
				end
			end
		end
	end, { filter, leaves } :: { unknown })
end

return useTreeNodeFilter


local Foundation = require("@rbxpkg/Foundation")

local types = require("./types")

local useTokens = Foundation.Hooks.useTokens

local IconName = Foundation.Enums.IconName

type TreeNodeIcon = types.TreeNodeIcon

local function useTreeNodeIcon(icon: TreeNodeIcon): (string, Foundation.ColorStyle)
	local tokens = useTokens()

	if icon == types.TreeNodeIcon.Story then
		return IconName.DiamondSimplified, tokens.Color.Extended.Green.Green_500
	elseif icon == types.TreeNodeIcon.Storybook then
		return IconName.SquareBooks, tokens.Color.Content.Default
	elseif icon == types.TreeNodeIcon.Folder then
		return IconName.FileBox, tokens.Color.Extended.Purple.Purple_500
	elseif icon == types.TreeNodeIcon.Pin then
		return IconName.Pin, tokens.Color.Extended.Yellow.Yellow_800
	elseif icon == types.TreeNodeIcon.Alert then
		return IconName.TriangleExclamation, tokens.Color.ActionAlert.Foreground
	end

	return "", tokens.Color.Content.Default
end

return useTreeNodeIcon


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")
local ReactCharm = require("@pkg/ReactCharm")
local Sift = require("@pkg/Sift")

local UserSettingsStore = require("@root/UserSettings/UserSettingsStore")
local defaultSettings = require("@root/UserSettings/defaultSettings")
local useColors = require("@root/Common/useColors")

local useSignalState = ReactCharm.useSignalState
local useCallback = React.useCallback
local useMemo = React.useMemo

type Setting = defaultSettings.Setting
type SettingChoice = defaultSettings.SettingChoice

export type Props = {
	setting: Setting,
	layoutOrder: number?,
}

local function SettingRow(props: Props)
	local colors = useColors()
	local userSettingsStore = useSignalState(UserSettingsStore.get)
	local userSettings = useSignalState(userSettingsStore.getStorage)

	local setSetting = useCallback(function(newValue: any)
		userSettingsStore.setStorage(function(prev)
			return Sift.Dictionary.join(prev, {
				[props.setting.name] = newValue,
			})
		end)
	end, { userSettingsStore.setStorage, props.setting } :: { unknown })

	local optionElement = useMemo(function(): React.Node
		local value = userSettings[props.setting.name]

		if props.setting.settingType == "checkbox" then
			return React.createElement(Foundation.Checkbox, {
				label = props.setting.displayName,
				hint = props.setting.description,
				isChecked = value,
				onActivated = setSetting,
			})
		elseif props.setting.settingType == "dropdown" then
			local items = Sift.List.map(props.setting.choices, function(choice: SettingChoice): Foundation.DropdownItem
				return {
					id = choice.name,
					text = choice.displayName,
				}
			end)

			return React.createElement(Foundation.Dropdown.Root, {
				label = props.setting.displayName,
				hint = props.setting.description,
				value = value,
				items = items,
				onItemChanged = setSetting,
			})
		elseif props.setting.settingType == "number" then
			local range = props.setting.range

			return React.createElement(Foundation.NumberInput, {
				value = value,
				label = props.setting.displayName,
				hint = props.setting.description,
				step = 10,
				maximum = if range then range.Max else nil,
				minimum = if range then range.Min else nil,
				onChanged = setSetting,
			})
		end
		error(`no handling for setting type {props.setting.settingType}`)
	end, { props.setting, userSettings } :: { unknown })

	local hasBeenChanged = not userSettingsStore.isSettingDefault(props.setting.name)

	return React.createElement(Foundation.View, {
		tag = "size-full-0 auto-y",
		LayoutOrder = props.layoutOrder,
	}, {
		ChangedIndicator = if hasBeenChanged
			then React.createElement(Foundation.View, {
				tag = "size-50-full",
				backgroundStyle = colors.accent,
			})
			else nil,

		OptionWrapper = React.createElement(Foundation.View, {
			tag = "auto-xy padding-x-medium",
		}, {
			Option = optionElement,
		}),
	})
end

return SettingRow


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")
local Sift = require("@pkg/Sift")

local NavigationContext = require("@root/Navigation/NavigationContext")
local SettingRow = require("@root/UserSettings/SettingRow")
local defaultSettings = require("@root/UserSettings/defaultSettings")
local nextLayoutOrder = require("@root/Common/nextLayoutOrder")

local useMemo = React.useMemo

type Setting = defaultSettings.Setting
type SettingChoice = defaultSettings.SettingChoice

type SettingsGroup = {
	name: string,
	settings: { Setting },
}

local function SettingsView()
	local navigation = NavigationContext.use()
	local settingsByGroup: { SettingsGroup } = useMemo(function()
		local settings = Sift.Dictionary.values(defaultSettings)

		local sorted = Sift.Array.sort(settings, function(a: Setting, b: Setting)
			return a.name < b.name
		end)

		local groups = Sift.Array.reduce(sorted, function(accumulator: { [string]: SettingsGroup }, setting: Setting)
			local group: SettingsGroup
			if accumulator[setting.group] then
				group = accumulator[setting.group]
			else
				group = {
					name = setting.group,
					settings = {},
				}
				accumulator[setting.group] = group
			end

			table.insert(group.settings, setting)

			return accumulator
		end, {})

		return Sift.Array.sort(Sift.Dictionary.values(groups), function(a: SettingsGroup, b: SettingsGroup)
			return a.name < b.name
		end)
	end, { defaultSettings })

	local children: { [string]: React.Node } = {}
	for _, group in settingsByGroup do
		children[group.name] = React.createElement(Foundation.Text, {
			tag = "auto-xy text-heading-medium padding-medium",
			Text = group.name,
			LayoutOrder = nextLayoutOrder(),
		})

		for _, setting in group.settings do
			children[setting.name] = React.createElement(SettingRow, {
				setting = setting,
				layoutOrder = nextLayoutOrder(),
			})
		end
	end

	return React.createElement(Foundation.ScrollView, {
		tag = "size-full bg-surface-200 gap-large padding-y-large",
		scroll = {
			AutomaticCanvasSize = Enum.AutomaticSize.Y,
			CanvasSize = UDim2.fromScale(1, 0),
			ScrollingDirection = Enum.ScrollingDirection.Y,
		},
		layout = {
			FillDirection = Enum.FillDirection.Vertical,
		},
	}, {
		Title = React.createElement(Foundation.View, {
			LayoutOrder = nextLayoutOrder(),
			tag = "align-y-center auto-y padding-right-medium row size-full-0",
		}, {
			Title = React.createElement(Foundation.Text, {
				LayoutOrder = nextLayoutOrder(),
				Text = "Settings",
				tag = "auto-y size-full-0 shrink text-align-x-left text-heading-large padding-medium",
			}),

			Close = React.createElement(Foundation.IconButton, {
				LayoutOrder = nextLayoutOrder(),
				icon = "x",
				isCircular = true,
				onActivated = function()
					navigation.navigateTo("Home")
				end,
				variant = Foundation.Enums.ButtonVariant.OverMedia,
			}),
		}),

		Settings = React.createElement(Foundation.View, {
			tag = "size-full-0 auto-y col gap-xlarge",
			LayoutOrder = nextLayoutOrder(),
		}, children),
	})
end

return SettingsView


local React = require("@pkg/React")

local SettingsView = require("./SettingsView")

return {
	story = function()
		return React.createElement(SettingsView)
	end,
}


local Charm = require("@pkg/Charm")
local t = require("@pkg/t")

local createPluginSettingsStore = require("@root/Plugin/createPluginSettingsStore")
local defaultSettings = require("@root/UserSettings/defaultSettings")

export type UserSettings = {
	rememberLastOpenedStory: boolean,
	theme: string,
	sidebarWidth: number,
	controlsHeight: number,
	collectAnonymousUsageData: boolean,
}

local defaultValue: UserSettings = {
	rememberLastOpenedStory = defaultSettings.rememberLastOpenedStory.value,
	theme = defaultSettings.theme.choices[1].name,
	sidebarWidth = defaultSettings.sidebarWidth.value,
	controlsHeight = defaultSettings.controlsHeight.value,
	collectAnonymousUsageData = defaultSettings.collectAnonymousUsageData.value,
}

local validate = t.interface({
	rememberLastOpenedStory = t.boolean,
	theme = t.string,
	sidebarWidth = t.number,
	controlsHeight = t.number,
	collectAnonymousUsageData = t.boolean,
})

-- defaultValue needs to be kept in sync with the keys in defaultSettings
for key in defaultSettings do
	assert(defaultValue[key] ~= nil, "setting with key {key} is missing from UserSettingsStore")
end

-- Initialize the store eagerly at module level so createPluginSettingsStore's
-- internal Charm.effect is a top-level effect, not one nested inside a computed.
local get = Charm.signal(createPluginSettingsStore("FlipbookUserSettings", defaultValue, validate))

return {
	get = get,
}


local constants = require("@root/constants")

export type SettingType = "checkbox" | "dropdown" | "number"

local SettingType = {
	Checkbox = "checkbox" :: "checkbox",
	Dropdown = "dropdown" :: "dropdown",
	Number = "number" :: "number",
}

export type SettingGroup = "UI" | "Stories" | "Telemetry"

local SettingGroup = {
	UI = "UI" :: "UI",
	Stories = "Stories" :: "Stories",
	Telemetry = "Telemetry" :: "Telemetry",
}

export type SettingChoice = {
	description: string,
	displayName: string,
	name: string,
}

type BaseSetting = {
	name: string,
	group: SettingGroup,
	displayName: string,
	description: string,
	settingType: SettingType,
}

type CheckboxSetting = BaseSetting & {
	settingType: "checkbox",
	value: boolean,
}

type DropdownSetting = BaseSetting & {
	settingType: "dropdown",
	choices: { SettingChoice },
}

type NumberSetting = BaseSetting & {
	settingType: "number",
	range: NumberRange?,
	value: number,
}

export type Setting = CheckboxSetting | DropdownSetting | NumberSetting

-- local expandNodesOnStart: Setting = {
-- 	name = "expandNodesOnStart",
-- 	displayName = "Expand nodes on start",
-- 	description = "Re-open the storybooks and folders from before",
-- 	settingType = SettingType.Dropdown,
-- 	choices = {
-- 		{
-- 			name = "off",
-- 			description = "Keep all explorer nodes closed on startup",
-- 		},
-- 		{
-- 			name = "all",
-- 			description = "All explorer nodes are opened on startup",
-- 		},
-- 		{
-- 			name = "lastOpened",
-- 			description = "Reopen the nodes that were opened from previous sessions on startup",
-- 		},
-- 	},
-- }

local rememberLastOpenedStory: CheckboxSetting = {
	name = "rememberLastOpenedStory",
	group = SettingGroup.Stories,
	displayName = "Remember last opened story",
	description = "Open the last viewed story when starting",
	settingType = SettingType.Checkbox,
	value = true,
}

local theme: DropdownSetting = {
	name = "theme",
	group = SettingGroup.UI,
	displayName = "UI theme",
	description = "Select the UI theme to use. By default, Flipbook will use the same theme as Studio",
	settingType = SettingType.Dropdown,
	choices = {
		{
			name = "system",
			displayName = "System",
			description = "Match the theme selected for Studio",
		},
		{
			name = "dark",
			displayName = "Dark",
			description = "Force the theme to use dark mode",
		},
		{
			name = "light",
			displayName = "Light",
			description = "Force the theme to use light mode",
		},
	},
}

local sidebarWidth: NumberSetting = {
	group = SettingGroup.UI,
	name = "sidebarWidth",
	displayName = "Sidebar panel width",
	description = `The default width (in pixels) of the sidebar. This can be between {constants.SIDEBAR_MIN_WIDTH}-{constants.SIDEBAR_MAX_WIDTH} px`,
	settingType = SettingType.Number,
	range = NumberRange.new(constants.SIDEBAR_MIN_WIDTH, constants.SIDEBAR_MAX_WIDTH),
	value = constants.SIDEBAR_INITIAL_WIDTH,
}

local controlsHeight: NumberSetting = {
	group = SettingGroup.UI,
	name = "controlsHeight",
	displayName = "Controls panel height",
	description = `The default height (in pixels) of the 'Controls' panel. This can be between {constants.CONTROLS_MIN_HEIGHT}-{constants.CONTROLS_MAX_HEIGHT} px`,
	settingType = SettingType.Number,
	range = NumberRange.new(constants.CONTROLS_MIN_HEIGHT, constants.CONTROLS_MAX_HEIGHT),
	value = constants.CONTROLS_INITIAL_HEIGHT,
}

local collectAnonymousUsageData: CheckboxSetting = {
	name = "collectAnonymousUsageData",
	group = SettingGroup.Telemetry,
	displayName = "Help improve Flipbook by sending anonymous usage data",
	description = "Allow Flipbook to collect anonymous usage data to help improve the product. No personal data or story content is collected",
	settingType = SettingType.Checkbox,
	value = true,
}

local settings = {
	-- expandNodesOnStart = expandNodesOnStart,
	rememberLastOpenedStory = rememberLastOpenedStory,
	theme = theme,
	sidebarWidth = sidebarWidth,
	controlsHeight = controlsHeight,
	collectAnonymousUsageData = collectAnonymousUsageData,
}

export type Settings = typeof(settings)

return settings


local constants = require("@self/constants")

return {
	FLIPBOOK_LOGO = constants.FLIPBOOK_LOGO,

	createFlipbookPlugin = require("@self/Plugin/createFlipbookPlugin"),
}


local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")

local ContextProviders = require("@root/Common/ContextProviders")
local MockPlugin = require("@root/Testing/MockPlugin")

return {
	name = "FlipbookCore",
	storyRoots = {
		script.Parent,
	},
	packages = {
		React = React,
		ReactRoblox = ReactRoblox,
	},
	mapStory = function(Story)
		return function(props)
			return React.createElement(ContextProviders, {
				plugin = MockPlugin.new(),
				overlayGui = props.container,
			}, {
				Story = React.createElement(Story, props),
			})
		end
	end,
}


return {
	testMatch = { "**/*.spec" },
	testPathIgnorePatterns = { "Packages", "RobloxPackages" },
}


local Log = require("@pkg/Log")

local LogsStore = require("@root/Logs/LogsStore")

local function stringToLogLevel(logLevel: string)
	if logLevel == "info" then
		return Log.LogLevel.Information
	elseif logLevel == "warn" or logLevel == "warning" then
		return Log.LogLevel.Warning
	elseif logLevel == "debug" then
		return Log.LogLevel.Debugging
	elseif logLevel == "err" or logLevel == "error" then
		return Log.LogLevel.Error
	end
	return Log.LogLevel.Warning
end

local sinks = {
	Log.LogSink.new(function(event)
		LogsStore.get().addLine(event)
	end),
}

if _G.ENABLE_OUTPUT_LOGGING == "true" then
	table.insert(sinks, Log.Common.RobloxLogSink())
end

local logger = Log.Logger.new(
	{
		Log.Common.Enrichers.LogLevelEnricher(),
		Log.Common.Enrichers.DynSourceEnricher(true),
	},
	sinks,
	{
		Log.Common.Filters.MinLevelFilter(stringToLogLevel(_G.LOG_LEVEL)),
	}
)

return logger


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")

local FoundationProvider = Foundation.FoundationProvider
local Image = Foundation.Image

local function App()
	return React.createElement(FoundationProvider, nil, {
		Background = React.createElement(Image, {
			Image = "rbxassetid://109801102767935",
			ScaleType = Enum.ScaleType.Tile,
			TileSize = UDim2.fromOffset(4, 4),
			imageStyle = { Color3 = Color3.new(1, 1, 1), Transparency = 0.975 },
			tag = "bg-surface-0 padding-medium size-full",
		}),
	})
end

return React.memo(App)


local SHADOW_IMAGE = "component_assets/dropshadow_17_8"
local SHADOW_SIZE = 16
local SHADOW_RECT = Rect.new(SHADOW_SIZE, SHADOW_SIZE, SHADOW_SIZE + 1, SHADOW_SIZE + 1)

local Constants = {
	SHADOW_IMAGE = SHADOW_IMAGE,
	SHADOW_RECT = SHADOW_RECT,
	SHADOW_SIZE = SHADOW_SIZE,
}

return Constants


local App = require("@root/Components/App")
local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")

local function createPlugin(plugin: Plugin, widget: DockWidgetPluginGui, button: PluginToolbarButton?)
	local connections: { RBXScriptConnection } = {}
	local root = ReactRoblox.createRoot(widget)
	local app = React.createElement(App)

	local function mount()
		root:render(app)
	end

	local function unmount()
		root:unmount()
	end

	if button ~= nil then
		table.insert(
			connections,
			button.Click:Connect(function()
				widget.Enabled = not widget.Enabled
			end)
		)

		table.insert(
			connections,
			widget:GetPropertyChangedSignal("Enabled"):Connect(function()
				button:SetActive(widget.Enabled)
			end)
		)
	end

	table.insert(
		connections,
		widget:GetPropertyChangedSignal("Enabled"):Connect(function()
			if widget.Enabled then
				mount()
			else
				unmount()
			end
		end)
	)

	if widget.Enabled then
		mount()
	end

	local function destroy()
		unmount()

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

	return {
		destroy = destroy,
		mount = mount,
		unmount = unmount,
	}
end

return createPlugin


local FlipbookNext = {
	Utility = {
		createPlugin = require("@root/Utility/createPlugin"),
	},
}

return FlipbookNext


local Foundation = require("@rbxpkg/Foundation")
local React = require("@pkg/React")
local ReactRoblox = require("@pkg/ReactRoblox")

local FoundationProvider = Foundation.FoundationProvider

return {
	name = "FlipbookNext",
	mapStory = function(Story)
		return function(storyProps)
			return React.createElement(FoundationProvider, nil, {
				Story = React.createElement(Story, storyProps),
			})
		end
	end,
	packages = { React = React, ReactRoblox = ReactRoblox },
	storyRoots = { script.Parent },
}


local Charm = require("@pkg/Charm")
local Jest = require("@pkg/Jest")

local function runTests()
	local root = script.Parent["flipbook-core"]

	_G.__DEV__ = true
	_G.__ROACT_17_MOCK_SCHEDULER__ = true

	Charm.flags.strict = true
	Charm.flags.frozen = true

	local status, result = Jest.runCLI(root, {
		verbose = false,
		ci = false,
		testPathPattern = _G.JEST_TEST_PATH_PATTERN,
	}, { root }):awaitStatus()

	if status == "Rejected" then
		print(result)
	end

	if status ~= "Resolved" or result.results.numFailedTestSuites > 0 or result.results.numFailedTests > 0 then
		error(1)
	end
end

return {
	runTests = runTests,
}


--!strict

return {
	STATIONARY = bit32.lshift(1, 9),
	MOVING = bit32.lshift(1, 10),
	AIR = bit32.lshift(1, 11),
	INTANGIBLE = bit32.lshift(1, 12),
	SWIMMING = bit32.lshift(1, 13),
	METAL_WATER = bit32.lshift(1, 14),
	SHORT_HITBOX = bit32.lshift(1, 15),
	RIDING_SHELL = bit32.lshift(1, 16),
	INVULNERABLE = bit32.lshift(1, 17),
	BUTT_OR_STOMACH_SLIDE = bit32.lshift(1, 18),
	DIVING = bit32.lshift(1, 19),
	ON_POLE = bit32.lshift(1, 20),
	HANGING = bit32.lshift(1, 21),
	IDLE = bit32.lshift(1, 22),
	ATTACKING = bit32.lshift(1, 23),
	ALLOW_VERTICAL_WIND_ACTION = bit32.lshift(1, 24),
	CONTROL_JUMP_HEIGHT = bit32.lshift(1, 25),
	ALLOW_FIRST_PERSON = bit32.lshift(1, 26),
	PAUSE_EXIT = bit32.lshift(1, 27),
	SWIMMING_OR_FLYING = bit32.lshift(1, 28),
	WATER_OR_TEXT = bit32.lshift(1, 29),
	THROWING = bit32.lshift(1, 31),
}


--!strict

return {
	-- Group Flags
	STATIONARY = bit32.lshift(0, 6),
	MOVING = bit32.lshift(1, 6),
	AIRBORNE = bit32.lshift(2, 6),
	SUBMERGED = bit32.lshift(3, 6),
	CUTSCENE = bit32.lshift(4, 6),
	AUTOMATIC = bit32.lshift(5, 6),
	OBJECT = bit32.lshift(6, 6),

	-- Mask for capturing these Flags
	GROUP_MASK = 0b_000000111000000,
}