--[[
	Main application logic, where the main business logic for server-client replication lives.
]]
function App:init()
	self.state = {
		-- Maps a canvas ID to its spots UUID
		--
		-- Schema:
		-- {
		--		[canvasId] = {
		--			spots = { <Spot UUID>, ... },
		--		},
		--		...
		-- }
		canvases = {},

		-- Maps spot UUID to its art metadata
		--
		-- Schema:
		-- {
		--		[spotId] = {
		--			artId =  "<art ID (NOT ASSET ID)>",
		--			ownerUserId = "<UserId of player who placed this art>",
		--		},
		--		...
		-- }
		spots = {},

		-- Active spot index that the player is assigned to place the art in
		activeSpotId = nil,

		-- Canvas ID that we are showing proximity prompt for
		activeCanvasId = nil,

		-- Error message shown to user as a modal when something goes wrong
		errorMessage = nil,

		-- Flag to mark if a canvas has the initial spots information loaded
		isLoaded = {},
	}

	-- Map between spot UUID and its corresponding ref for usage by CameraModule to animate
	self.spotRefs = {}

	-- Keep track of the SurfaceGui for each canvas for CameraModule to animate
	self.canvasRefs = {}

	-- Keeps track of selector GUI
	self.selectorRef = Roact.createRef()

	-- Keeps track of error message GUI
	self.errorMessageRef = Roact.createRef()

	-- Controls camera movement
	self.cameraModule = CameraModule.new(workspace.CurrentCamera)

	-- Controls user's input
	self.userInputController = UserInputController.new()

	-- Controls user's active GUI
	self.guiController = GuiController.new({ self.selectorRef, self.errorMessageRef })

	-- Controls user's (and potentially other Player's) Humanoid
	-- Maps between UserId and their HumanoidController.
	self.humanoidControllers = {}
	for _, player in pairs(Players:GetPlayers()) do
		self.humanoidControllers[player.UserId] = HumanoidController.new(player)
	end

	-- Set to true when we are transitioning between camera scenes
	self.isAnimating = false

	self.getTagged = modules.getTagged(self)
	self.fetchArt = modules.fetchArt(self)
	self.fetchAllArt = modules.fetchAllArt(self)
	self.applyArt = modules.applyArt(self)
	self.getRandomSpot = modules.getRandomSpot(self)
	self.clearErrorMessage = modules.clearErrorMessage(self)
	self.hideHumanoids = modules.hideHumanoids(self)
	self.showHumanoids = modules.showHumanoids(self)
	self.waitForHumanoidAnimations = modules.waitForHumanoidAnimations(self)
	self.getModelFace = modules.getModelFace()
	self.cleanup = modules.cleanup(self)
	self.onSpotChanged = modules.onSpotChanged(self, events)
	self.onPromptShown = modules.onPromptShown(self)
	self.onPromptHidden = modules.onPromptHidden(self)
	self.onCancel = modules.onCancel(self)
	self.onPlayerAdded = function(player)
		self.humanoidControllers[player.UserId] = HumanoidController.new(player)
	end
	self.onPlayerRemoving = function(player)
		self.humanoidControllers[player.UserId] = nil
	end
end

function App:render()
	-- Render nothing if we turn off the dev module
	if not self.props.configuration.enabled then
		return nil
	end

	local surfaceGuis = self:_renderSurfaceGuis()
	local proximityPrompts = self:_renderProximityPrompts()
	local selector = self:_renderSelector()
	local canvasAttributeListeners = self:_canvasAttributeListeners()

	return Roact.createFragment(
		Cryo.Dictionary.join(selector, surfaceGuis, proximityPrompts, canvasAttributeListeners, {
			ErrorMessage = self.state.errorMessage and Roact.createElement("ScreenGui", {
				IgnoreGuiInset = true,
				ResetOnSpawn = false,
				[Roact.Ref] = self.errorMessageRef,
			}, {
				Roact.createElement(ErrorMessage, {
					errorCode = self.state.errorMessage,
					onActivated = self.clearErrorMessage,
				}),
			}),

			FetchAllEvent = Roact.createElement(ExternalEventConnection, {
				event = self.props.SpotChanged.OnClientEvent,
				callback = self.onSpotChanged,
			}),

			InitializeHumanoidController = Roact.createElement(ExternalEventConnection, {
				event = Players.PlayerAdded,
				callback = self.onPlayerAdded,
			}),

			CleanupHumanoidController = Roact.createElement(ExternalEventConnection, {
				event = Players.PlayerRemoving,
				callback = self.onPlayerRemoving,
			}),
		})
	)
end

function App:didMount()
	self.fetchAllArt()
end

function App:didUpdate(_, prevState)
	if prevState.activeSpotId ~= self.state.activeSpotId then
		if self.state.activeSpotId ~= nil then
			events.selectorShown:Fire()
		else
			events.selectorHidden:Fire()
		end
	end
end

function App:_renderSurfaceGuis()
	-- Surface GUIs for all canvases
	local surfaceGuis = {}

	for _, model in ipairs(self.getTagged()) do
		local canvasId = model:GetAttribute(constants.Attributes.SurfaceCanvasId)
		if not canvasId then
			warn("[SurfaceArt] CanvasID does not exist for model")
			continue
		end

		-- Future improvement support for dynamic loading of canvases possible by using a table instead of a single flag
		-- e.g. only load artwork on canvas when users are within a threshold distance
		if not self.state.isLoaded[canvasId] then
			continue
		end

		if not self.canvasRefs[canvasId] then
			self.canvasRefs[canvasId] = Roact.createRef()
		end

		local spots = self:_renderSpots(model)
		local surfaceGui = Roact.createElement("SurfaceGui", {
			Adornee = model,
			SizingMode = Enum.SurfaceGuiSizingMode.PixelsPerStud,
			PixelsPerStud = 50,
			Face = self.getModelFace(model),
			ResetOnSpawn = false,
			[Roact.Ref] = self.canvasRefs[canvasId],
		}, {
			SurfaceCanvas = Roact.createElement(SurfaceCanvas, {
				numRows = self.props.configuration.rowsPerCanvas,
				numCols = self.props.configuration.colsPerCanvas,
				paddingLeft = self.props.configuration.canvasPaddingLeft,
				paddingRight = self.props.configuration.canvasPaddingRight,
				paddingTop = self.props.configuration.canvasPaddingTop,
				paddingBottom = self.props.configuration.canvasPaddingBottom,
			}, spots),
		})

		surfaceGuis["canvas_" .. canvasId] = surfaceGui
	end

	return surfaceGuis
end

function App:_renderSpots(model)
	local spots = {}
	local spotsPerCanvas = self.props.configuration.rowsPerCanvas * self.props.configuration.colsPerCanvas
	for i = 1, spotsPerCanvas do
		local spotId = model:GetAttribute(constants.Attributes.SpotIdPrefix .. i)
		local spot = self.state.spots[spotId]
		if not spot then
			continue
		end
		local artId = spot.artId

		-- We may have not loaded the asset ID yet
		local asset = artId and self.props.configuration.assets[artId]

		if not self.spotRefs[spotId] then
			self.spotRefs[spotId] = Roact.createRef()
		end

		local surfaceSpot = Roact.createElement(SurfaceSpot, {
			image = asset and asset.assetId,
			[Roact.Ref] = self.spotRefs[spotId],
		})

		table.insert(spots, surfaceSpot)
	end

	return spots
end

function App:_renderProximityPrompts()
	local proximityPrompts = {}

	for _, model in ipairs(self.getTagged()) do
		local canvasId = model:GetAttribute(constants.Attributes.SurfaceCanvasId)
		if not canvasId then
			warn("[SurfaceArt] CanvasID does not exist for model")
			continue
		end

		local prompt = Roact.createFragment({
			Roact.createElement(Roact.Portal, {
				target = model,
			}, {
				ProximityPrompt = Roact.createElement("ProximityPrompt", {
					Enabled = true,
					KeyboardKeyCode = self.props.configuration.promptKeyCode,
					RequiresLineOfSight = self.props.configuration.promptRequiresLineOfSight,
					MaxActivationDistance = self.props.configuration.promptMaxActivationDistance,
					Exclusivity = self.props.configuration.promptExclusivity,
					Style = Enum.ProximityPromptStyle.Custom,
					[Roact.Event.TriggerEnded] = function()
						self.getRandomSpot(canvasId)
					end,
					[Roact.Event.PromptShown] = function()
						self.onPromptShown(canvasId)
						events.promptShown:Fire(model)
					end,
					[Roact.Event.PromptHidden] = function()
						self.onPromptHidden()
						events.promptHidden:Fire(model)
					end,
				}),
			}),
			SurfacePrompt = Roact.createElement("BillboardGui", {
				Active = true,
				Adornee = model,
				Size = UDim2.fromOffset(60, 60),
				AlwaysOnTop = true,
				ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
				ResetOnSpawn = false,
			}, {
				Roact.createElement(SurfacePrompt, {
					isShown = canvasId == self.state.activeCanvasId and not self.state.activeSpotId,
					onClick = function()
						self.getRandomSpot(canvasId)
					end,
				}),
			}),
		})

		proximityPrompts["prompts_" .. canvasId] = prompt
	end

	return proximityPrompts
end

function App:_renderSelector()
	local artIds = Cryo.Dictionary.keys(self.props.configuration.assets)

	return {
		Selector = self.state.activeSpotId ~= nil and Roact.createElement("ScreenGui", {
			IgnoreGuiInset = true,
			ResetOnSpawn = false,
			ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
			[Roact.Ref] = self.selectorRef,
		}, {
			Roact.createElement(SurfaceArtSelector, {
				images = Cryo.List.map(artIds, function(artId)
					return self.props.configuration.assets[artId].assetId
				end),
				onApply = function(index)
					local artId = artIds[index]
					self.applyArt(artId)
				end,
				onCancel = self.onCancel,
			}),
		}),
	}
end

function App:_canvasAttributeListeners()
	local listeners = {}
	for i, model in ipairs(self.getTagged()) do
		listeners["listener_" .. i] = Roact.createElement(ExternalEventConnection, {
			event = model.AttributeChanged,
			callback = function(attributeName)
				if attributeName == constants.Attributes.SurfaceCanvasId then
					local canvasId = model:GetAttribute(constants.Attributes.SurfaceCanvasId)
					self.fetchArt(canvasId)
				end
			end,
		})
	end

	return listeners
end

return ConfigurationContext.withConfiguration(App)