--------- MODULE BEGIN ---------

local richText = {}
local textService = game:GetService("TextService")
local runService = game:GetService("RunService")
local animationCount = 0

function getLayerCollector(frame)
	if not frame then
		return nil
	elseif frame:IsA("LayerCollector") then
		return frame
	elseif frame and frame.Parent then
		return getLayerCollector(frame.Parent)
	else
		return nil
	end
end

function shallowCopy(tab)
	local ret = {}
	for key, value in pairs(tab) do
		ret[key] = value
	end
	return ret
end

function getColorFromString(value)
	if colors[value] then
		 return colors[value]
	else
		local r, g, b = value:match("(%d+),(%d+),(%d+)")
		return Color3.new(r / 255, g / 255, b / 255)
	end
end

function getVector2FromString(value)
	local x, y = value:match("(%d+),(%d+)")
	return Vector2.new(x, y)
end

function richText:New(frame, text, startingProperties)
	for _, v in pairs(frame:GetChildren()) do
		v:Destroy()
	end
	
	local properties = {}
	local defaultProperties = {}
	
	local lineFrames = {}
	local textFrames = {}
	local frameProperties = {}
	local linePosition = 0
	local textLabel = Instance.new("TextLabel")
	local imageLabel = Instance.new("ImageLabel")
	local layerCollector = getLayerCollector(frame)
	
	local applyProperty, applyMarkup, formatLabel, printText, printImage, printSeries	
	
	----- Apply properties / markups -----
	function applyMarkup(key, value)
		key = propertyShortcuts[key] or key
		if value == "/" then
			if defaultProperties[key] then
				value = defaultProperties[key]
			else
				warn("Attempt to default <"..key.."> to value with no default")
			end
		end
		if tonumber(value) then
			value = tonumber(value)
		elseif value == "false" or value == "true" then
			value = value == "true"
		end
		properties[key] = value
		
		if applyProperty(key, value) then
			-- Ok
		elseif defaults[key] then
			-- Ok
		elseif key == "Img" then
			printImage(value)
		else
			-- Unknown value
			return false	
		end
		return true
	end
	
	function applyProperty(name, value, frame)
		local propertyType
		local ret = false
		for _, label in pairs(frame and {frame} or {textLabel, imageLabel}) do
			local isProperty = pcall(function() propertyType = typeof(label[name]) end) -- is there a better way to check if it's a property?
			if isProperty then
				if propertyType == "Color3" then
					label[name] = getColorFromString(value)
				elseif propertyType == "Vector2" then
					label[name] = getVector2FromString(value)
				else
					label[name] = value	
				end
				ret = true	
			end
		end
		return ret
	end
	
	----- Set up default properties -----
	for name, value in pairs(defaults) do
		applyMarkup(name, value)
		defaultProperties[propertyShortcuts[name] or name] = properties[propertyShortcuts[name] or name]
	end
	for name, value in pairs(startingProperties or {}) do
		applyMarkup(name, value)
		defaultProperties[propertyShortcuts[name] or name] = properties[propertyShortcuts[name] or name]
	end
	
	----- Lines -----
	local function newLine()
		local lineFrame = Instance.new("Frame")
		lineFrame.Name = string.format("Line%03d", #lineFrames + 1)
		lineFrame.Size = UDim2.new(0, 0, 0, 0)
		lineFrame.BackgroundTransparency = 1		
		local textContainer = Instance.new("Frame", lineFrame)
		textContainer.Name = "Container"
		textContainer.Size = UDim2.new(0, 0, 0, 0)
		textContainer.BackgroundTransparency = 1
		if properties.ContainerHorizontalAlignment == "Left" then
			textContainer.AnchorPoint = Vector2.new(0, 0)
			textContainer.Position = UDim2.new(0, 0, 0, 0)
		elseif properties.ContainerHorizontalAlignment == "Center" then
			textContainer.AnchorPoint = Vector2.new(0.5, 0)
			textContainer.Position = UDim2.new(0.5, 0, 0, 0)
		elseif properties.ContainerHorizontalAlignment == "Right" then
			textContainer.AnchorPoint = Vector2.new(1, 0)
			textContainer.Position = UDim2.new(1, 0, 0, 0)
		end
		
		lineFrame.Parent = frame
		table.insert(lineFrames, lineFrame)
		textFrames[#lineFrames] = {}
		linePosition = 0
	end
	newLine()
	
	----- Get vertical size -----
	local function getTextSize()
		if properties.TextScaled == true then
			local relativeHeight
			if properties.TextScaleRelativeTo == "Screen" then
				relativeHeight = layerCollector.AbsoluteSize.Y
			elseif properties.TextScaleRelativeTo == "Frame" then
				relativeHeight = frame.AbsoluteSize.Y
			end
			return math.min(properties.TextScale * relativeHeight, 100)
		else
			return properties.TextSize
		end
	end
	
	----- Label printing -----
	local function addFrameProperties(frame)
		frameProperties[frame] = shallowCopy(properties)
		frameProperties[frame].InitialSize = frame.Size
		frameProperties[frame].InitialPosition = frame.Position
		frameProperties[frame].InitialAnchorPoint = frame.AnchorPoint
	end
	
	function formatLabel(newLabel, labelHeight, labelWidth, endOfLineCallback)
		local lineFrame = lineFrames[#lineFrames]
		
		local verticalAlignment = tostring(properties.TextYAlignment)
		if verticalAlignment  == "Top" then
			newLabel.Position = UDim2.new(0, linePosition, 0, 0)
			newLabel.AnchorPoint = Vector2.new(0, 0)
		elseif verticalAlignment  == "Center" then
			newLabel.Position = UDim2.new(0, linePosition, 0.5, 0)
			newLabel.AnchorPoint = Vector2.new(0, 0.5)
		elseif verticalAlignment  == "Bottom" then
			newLabel.Position = UDim2.new(0, linePosition, 1, 0)
			newLabel.AnchorPoint = Vector2.new(0, 1)
		end
		
		linePosition = linePosition + labelWidth
		if linePosition > frame.AbsoluteSize.X and not (linePosition == labelWidth) then
			-- Newline, get rid of label and redo on next line
			newLabel:Destroy()
			local lastLabel = textFrames[#lineFrames][#textFrames[#lineFrames]]
			if lastLabel:IsA("TextLabel") and lastLabel.Text == " " then -- get rid of trailing space
				lineFrame.Container.Size = UDim2.new(0, linePosition - labelWidth - lastLabel.Size.X.Offset, 1, 0)
				lastLabel:Destroy()
				table.remove(textFrames[#lineFrames])
			end
			newLine()
			endOfLineCallback()
		else
			-- Label is ok
			newLabel.Size = UDim2.new(0, labelWidth, 0, labelHeight)
			lineFrame.Container.Size = UDim2.new(0, linePosition, 1, 0)
			lineFrame.Size = UDim2.new(1, 0, 0, math.max(lineFrame.Size.Y.Offset, labelHeight))
			newLabel.Name = string.format("Group%03d", #textFrames[#lineFrames] + 1)
			newLabel.Parent = lineFrame.Container
			table.insert(textFrames[#lineFrames], newLabel)
			addFrameProperties(newLabel)
			properties.AnimateYield = 0
		end
	end
	
	function printText(text)
		if text == "\n" then
			newLine()
			return
		elseif text == " " and linePosition == 0 then
			return
		end
		
		local textSize = getTextSize()
		local textWidth = textService:GetTextSize(text, textSize, textLabel.Font, Vector2.new(layerCollector.AbsoluteSize.X, textSize)).X
		
		local newTextLabel = textLabel:Clone()
		newTextLabel.TextScaled = false
		newTextLabel.TextSize = textSize
		newTextLabel.Text = text -- This text is never actually displayed. We just use it as a reference for knowing what the group string is.
		newTextLabel.TextTransparency = 1
		newTextLabel.TextStrokeTransparency = 1
		
		-- Keep the real text in individual frames per character:
		local charPos = 0
		for i = 1, utf8.len(text), 1 do
			local character = string.sub(text, i, i)
			local characterWidth = textService:GetTextSize(character, textSize, textLabel.Font, Vector2.new(layerCollector.AbsoluteSize.X, textSize)).X
			local characterLabel = textLabel:Clone()
			characterLabel.Text = character
			characterLabel.TextScaled = false
			characterLabel.TextSize = textSize
			characterLabel.Position = UDim2.new(0, charPos, 0, 0)
			characterLabel.Size = UDim2.new(0, characterWidth, 0, textSize)
			characterLabel.Name = string.format("Char%03d", i)
			characterLabel.Parent = newTextLabel
			characterLabel.Visible = false
			addFrameProperties(characterLabel)
			charPos = charPos + characterWidth
		end

		formatLabel(newTextLabel, textSize, textWidth, function() printText(text) end)
	end
	
	function printImage(imageId)
		local imageHeight = getTextSize()
		local imageWidth = imageHeight -- Would be nice if we could get aspect ratio of image to get width properly.
		
		local newImageLabel = imageLabel:Clone()
		newImageLabel.Image = "rbxassetid://"..(images[imageId] or imageId)
		newImageLabel.Size = UDim2.new(0, imageHeight, 0, imageWidth)
		newImageLabel.Visible = false

		formatLabel(newImageLabel, imageHeight, imageWidth, function() printImage(imageId) end)
	end
	
	function printSeries(labelSeries)	
		for _, t in pairs(labelSeries) do
			local markupKey, markupValue = string.match(t, "<(.+)=(.+)>")
			if markupKey and markupValue then
				if not applyMarkup(markupKey, markupValue) then
					warn("Could not apply markup: ", t)
				end			
			else
				printText(t)
			end
		end	
	end
	
	----- Text traversal + parsing -----
	local textPos = 1
	local textLength = utf8.len(text)
	local labelSeries = {}
	
	while textPos and textPos <= textLength do
		local nextMarkupStart, nextMarkupEnd = string.find(text, "<.->", textPos)
		local nextSpaceStart, nextSpaceEnd = string.find(text, "[ \t\n]", textPos)
		
		local nextBreakStart, nextBreakEnd, breakIsWhitespace
		if nextMarkupStart and nextMarkupEnd and (not nextSpaceStart or nextMarkupStart < nextSpaceStart) then
			nextBreakStart, nextBreakEnd = nextMarkupStart, nextMarkupEnd
		else
			nextBreakStart, nextBreakEnd = nextSpaceStart or textLength + 1, nextSpaceEnd or textLength + 1
			breakIsWhitespace = true
		end
		
		local nextWord = nextBreakStart > textPos and string.sub(text, textPos, nextBreakStart - 1) or nil
		local nextBreak = nextBreakStart <= textLength and string.sub(text, nextBreakStart, nextBreakEnd) or nil
		table.insert(labelSeries, nextWord)
		
		if breakIsWhitespace then
			printSeries(labelSeries)
			printSeries({nextBreak})
			labelSeries = {}
		else
			table.insert(labelSeries, nextBreak)
		end
		
		textPos = nextBreakEnd + 1
		--textPos = utf8.offset(text, 2, nextBreakEnd)
	end
	printSeries(labelSeries)

	----- Alignment layout -----
	local listLayout = Instance.new("UIListLayout")
	listLayout.HorizontalAlignment = properties.ContainerHorizontalAlignment
	listLayout.VerticalAlignment = properties.ContainerVerticalAlignment
	listLayout.Parent = frame
	
	----- Calculate content size -----
	local contentHeight = 0
	local contentLeft = frame.AbsoluteSize.X
	local contentRight = 0
	for _, lineFrame in pairs(lineFrames) do
		contentHeight = contentHeight + lineFrame.Size.Y.Offset
		local container = lineFrame.Container
		local left, right
		if container.AnchorPoint.X == 0 then
			left = container.Position.X.Offset
			right = container.Size.X.Offset
		elseif container.AnchorPoint.X == 0.5 then
			left = lineFrame.AbsoluteSize.X / 2 - container.Size.X.Offset / 2
			right = lineFrame.AbsoluteSize.X / 2 + container.Size.X.Offset / 2
		elseif container.AnchorPoint.X == 1 then
			left = lineFrame.AbsoluteSize.X - container.Size.X.Offset
			right = lineFrame.AbsoluteSize.X
		end
		contentLeft = math.min(contentLeft, left)
		contentRight = math.max(contentRight, right)
	end
	
	----- Animation -----
	animationCount = animationCount + 1
	local animationDone = false
	local animationRenderstepBinding = "TextAnimation"..animationCount
	local animateQueue = {}
	
	local function updateAnimations()
		if animationDone then
			runService:UnbindFromRenderStep(animationRenderstepBinding)
			animateQueue = {}
			return
		end
		local t = tick()
		for i = #animateQueue, 1, -1 do
			local set = animateQueue[i]
			local properties = set.Settings
			local animateStyle = animationStyles[properties.AnimateStyle]
			if not animateStyle then
				warn("No animation style found for: ", properties.AnimateStyle, ", defaulting to Appear")
				animateStyle = animationStyles.Appear
			end
			local animateAlpha = math.min((t - set.Start) / properties.AnimateStyleTime, 1)
			animateStyle(set.Char, animateAlpha, properties) 
			if animateAlpha >= 1 then
				table.remove(animateQueue, i)
			end
		end
	end
	
	local function setFrameToDefault(frame)
		frame.Position = frameProperties[frame].InitialPosition
		frame.Size = frameProperties[frame].InitialSize
		frame.AnchorPoint = frameProperties[frame].InitialAnchorPoint
		for name, value in pairs(frameProperties[frame]) do
			applyProperty(name, value, frame)
		end
	end

	local function setGroupVisible(frame, visible)
		frame.Visible = visible
		for _, v in pairs(frame:GetChildren()) do
			v.Visible = visible
			if visible then
				setFrameToDefault(v)
			end
		end
		if visible and frame:IsA("ImageLabel") then
			setFrameToDefault(frame)
		end
	end

	local function animate()
		animationDone = false
		runService:BindToRenderStep(animationRenderstepBinding, Enum.RenderPriority.Last.Value, updateAnimations)

		local stepGrouping
		local stepTime
		local stepFrequency
		local numAnimated
		
		-- Make everything invisible to start
		for lineNum, list in pairs(textFrames) do
			for _, frame in pairs(list) do
				setGroupVisible(frame, false)
			end
		end
	
		local function animateCharacter(char, properties)
			table.insert(animateQueue, {Char = char, Settings = properties, Start = tick()})
		end
		
		local function yield()
			if numAnimated % stepFrequency == 0 and stepTime >= 0 then
				wait(stepTime > 0 and stepTime or nil)
			end
		end
		
		for lineNum, list in pairs(textFrames) do
			for _, frame in pairs(list) do
				local properties = frameProperties[frame]
				if not (properties.AnimateStepGrouping  == stepGrouping) or not (properties.AnimateStepFrequency == stepFrequency) then
					numAnimated = 0
				end
				stepGrouping = properties.AnimateStepGrouping
				stepTime = properties.AnimateStepTime
				stepFrequency = properties.AnimateStepFrequency
			
				if properties.AnimateYield > 0 then
					wait(properties.AnimateYield)
				end

				if stepGrouping == "Word" or stepGrouping == "All" then
					--if not (frame:IsA("TextLabel") and (frame.Text == " ")) then
						if frame:IsA("TextLabel") then
							frame.Visible = true
							for _, v in pairs(frame:GetChildren()) do
								animateCharacter(v, frameProperties[v])
							end
						else
							animateCharacter(frame, properties)
						end
						if stepGrouping == "Word" then
							numAnimated = numAnimated + 1
							yield()
						end
					--end
				elseif stepGrouping == "Letter" then
					if frame:IsA("TextLabel") --[[and not (frame.Text == " ") ]]then
						frame.Visible = true
						local text = frame.Text
						for i = 1, utf8.len(text), 1 do
							local v = frame[string.format("Char%03d", i)]
							animateCharacter(v, frameProperties[v])
							numAnimated = numAnimated + 1
							yield()
							if animationDone then
								return
							end
						end
					else
						animateCharacter(frame, properties)
						numAnimated = numAnimated + 1
						yield()
					end
				else
					warn("Invalid step grouping: ", stepGrouping)
				end
				
				if animationDone then
					return
				end
			end
		end
			
		while #animateQueue > 0 do
			runService.RenderStepped:Wait()
		end
		
		animationDone = true
	end
	

	----- Return object API -----
	local textObject = {}
	
	textObject.ContentSize = Vector2.new(contentRight - contentLeft, contentHeight)
	
	function textObject:Animate(yield)
		if yield then
			animate()
		else
			coroutine.wrap(animate)()
		end
	end
	
	function textObject:Show()
		animationDone = true
		for lineNum, list in pairs(textFrames) do
			for _, frame in pairs(list) do
				setGroupVisible(frame, true)
			end
		end
	end
	
	function textObject:Hide()
		animationDone = true
		for lineNum, list in pairs(textFrames) do
			for _, frame in pairs(list) do
				setGroupVisible(frame, false)
			end
		end
	end
	
	return textObject
end


return richText