--[[
	* Copyright (c) Roblox Corporation. All rights reserved.
	* Licensed under the MIT License (the "License");
	* you may not use this file except in compliance with the License.
	* You may obtain a copy of the License at
	*
	*     https://opensource.org/licenses/MIT
	*
	* Unless required by applicable law or agreed to in writing, software
	* distributed under the License is distributed on an "AS IS" BASIS,
	* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
	* See the License for the specific language governing permissions and
	* limitations under the License.
]]
local CollectionService = game:GetService("CollectionService")
local Packages = script.Parent.Parent.Parent.Parent
local ReactGlobals = require(Packages.ReactGlobals)
local LuauPolyfill = require(Packages.LuauPolyfill)
local Object = LuauPolyfill.Object
local inspect = LuauPolyfill.util.inspect

local __DEV__ = ReactGlobals.__DEV__ :: boolean

local console = require(Packages.Shared).console

local React = require(Packages.React)
local ReactSymbols = require(Packages.Shared).ReactSymbols
local SingleEventManager = require(script.Parent.SingleEventManager)
type EventManager = SingleEventManager.EventManager
local Type = require(Packages.Shared).Type
local getDefaultInstanceProperty = require(script.Parent.getDefaultInstanceProperty)
local ReactRobloxHostTypes = require(script.Parent.Parent["ReactRobloxHostTypes.roblox"])
type HostInstance = ReactRobloxHostTypes.HostInstance
local Tag = require(Packages.React).Tag

-- ROBLOX deviation: Essentially a placeholder for dom-specific logic, taking the place
-- of ReactDOMComponent. Most of the logic will differ pretty dramatically

type Array<T> = { [number]: T }
type Object = { [any]: any }

-- deviation: Can't assign attributes to Roblox instances, so we use maps to
-- store associated data for host instance features like binding and event
-- management
-- ROBLOX FIXME: Stronger typing for EventManager

local instanceToEventManager: { [HostInstance]: EventManager } = {}
local instanceToBindings: { [HostInstance]: { [string]: any } } = {}

local applyPropsError = [[
Error applying initial props to Roblox Instance '%s' (%s):
  %s
]]

local updatePropsError = [[
Error updating props on Roblox Instance '%s' (%s):
  %s
]]

local updateBindingError = [[
Error updating binding or ref assigned to key %s of '%s' (%s).

Updated value:
  %s

Error:
  %s

%s
]]

local function identity(...)
	return ...
end

local function setRobloxInstanceProperty(hostInstance, key, newValue): ()
	if newValue == nil then
		local success, _ = pcall(hostInstance.ResetPropertyToDefault, hostInstance, key)
		if success then
			return
		end

		local hostClass = hostInstance.ClassName
		local _, defaultValue = getDefaultInstanceProperty(hostClass, key)
		newValue = defaultValue
	end

	-- Assign the new value to the object
	hostInstance[key] = newValue
end

local function removeBinding(hostInstance, key)
	local bindings = instanceToBindings[hostInstance]
	if bindings ~= nil then
		local disconnect = bindings[key]
		disconnect()
		bindings[key] = nil
	end
end

local function attachBinding(hostInstance, key, newBinding): ()
	local function updateBoundProperty(newValue)
		local success, errorMessage =
			xpcall(setRobloxInstanceProperty, identity, hostInstance, key, newValue)

		if not success then
			local source = newBinding._source or "<enable DEV mode for stack>"
			local fullMessage = string.format(
				updateBindingError,
				key,
				hostInstance.Name,
				hostInstance.ClassName,
				tostring(newValue),
				errorMessage,
				source
			)
			console.error(fullMessage)
			-- FIXME: Until console.error can be instrumented to send telemetry, we
			-- need to keep the hard error here
			error(fullMessage, 0)
		end
	end

	if instanceToBindings[hostInstance] == nil then
		instanceToBindings[hostInstance] = {}
	end

	instanceToBindings[hostInstance][key] =
		React.__subscribeToBinding(newBinding, updateBoundProperty)

	updateBoundProperty(newBinding:getValue())
end

local function applyTags(hostInstance: Instance, oldTags: string?, newTags: string?)
	if __DEV__ then
		if newTags ~= nil and typeof(newTags) ~= "string" then
			console.error(
				"Type provided for ReactRoblox.Tag is invalid - tags should be "
					.. "specified as a single string, with individual tags delimited "
					.. "by spaces. Instead received:\n%s",
				inspect(newTags)
			)
			return
		end
	end

	local oldTagSet = {}
	for str in string.gmatch(oldTags or "", "%S+") do
		oldTagSet[str] = true
	end
	local newTagSet = {}
	for str in string.gmatch(newTags or "", "%S+") do
		newTagSet[str] = true
	end

	for tag, _ in oldTagSet do
		if not newTagSet[tag] then
			CollectionService:RemoveTag(hostInstance, tag)
		end
	end
	for tag, _ in newTagSet do
		if not oldTagSet[tag] then
			CollectionService:AddTag(hostInstance, tag)
		end
	end
end

local function removeAllTags(hostInstance: Instance)
	for _, tag in CollectionService:GetTags(hostInstance) do
		CollectionService:RemoveTag(hostInstance, tag)
	end
end

local function applyProp(hostInstance: Instance, key, newValue, oldValue): ()
	-- ROBLOX performance: gets checked in applyProps so we can assume the key is valid
	-- if key == "ref" or key == "children" then
	--   return
	-- end

	local internalKeyType = Type.of(key)

	if internalKeyType == Type.HostEvent or internalKeyType == Type.HostChangeEvent then
		local eventManager = instanceToEventManager[hostInstance]
		if eventManager == nil then
			eventManager = (SingleEventManager.new(hostInstance) :: any) :: EventManager
			instanceToEventManager[hostInstance] = eventManager
		end

		local eventName = key.name

		if internalKeyType == Type.HostChangeEvent then
			eventManager:connectPropertyChange(eventName, newValue)
		else
			eventManager:connectEvent(eventName, newValue)
		end

		return
	end

	-- Handle bindings
	local newIsBinding = typeof(newValue) == "table"
		and newValue["$$typeof"] == ReactSymbols.REACT_BINDING_TYPE
	local oldIsBinding = oldValue ~= nil
		and typeof(oldValue) == "table"
		and oldValue["$$typeof"] == ReactSymbols.REACT_BINDING_TYPE
	if oldIsBinding then
		removeBinding(hostInstance, key)
	end

	if newIsBinding then
		attachBinding(hostInstance, key, newValue)
	elseif key == Tag then
		applyTags(hostInstance, oldValue, newValue)
	else
		setRobloxInstanceProperty(hostInstance, key, newValue)
	end
end

local function applyProps(hostInstance: Instance, props: Object): ()
	for propKey, value in props do
		-- ROBLOX performance: avoid the function call by inlining check here
		if propKey == "ref" or propKey == "children" then
			continue
		end

		applyProp(hostInstance, propKey, value)
	end
end

local function setInitialProperties(
	domElement: HostInstance,
	_tag: string,
	rawProps: Object,
	_rootContainerElement: HostInstance
): ()
	-- deviation: Use Roact's prop application logic
	local success, errorMessage = xpcall(applyProps, identity, domElement, rawProps)
	-- ROBLOX deviation: Roblox renderer doesn't currently track where instances
	-- were created the way that legacy Roact did, but DEV mode should include
	-- component stack traces as a separate warning
	if not success then
		local fullMessage = string.format(
			applyPropsError,
			domElement.Name,
			domElement.ClassName,
			errorMessage
		)
		console.error(fullMessage)
		-- FIXME: Until console.error can be instrumented to send telemetry, we need
		-- to keep the hard error here
		error(fullMessage, 0)
	end

	if instanceToEventManager[domElement] ~= nil then
		instanceToEventManager[domElement]:resume()
	end
end

local function safelyApplyProperties(
	domElement: HostInstance,
	updatePayload: Array<any>,
	lastProps: Object
): ()
	local updatePayloadCount = #updatePayload
	for i = 1, updatePayloadCount, 2 do
		local propKey = updatePayload[i]
		local value = updatePayload[i + 1]
		if value == Object.None then
			value = nil
		end
		-- ROBLOX performance: avoid the function call by inlining check here
		if propKey ~= "ref" and propKey ~= "children" then
			applyProp(domElement, propKey, value, lastProps[propKey])
		end
	end
end

local function updateProperties(
	domElement: HostInstance,
	updatePayload: Array<any>,
	lastProps: Object
): ()
	-- deviation: Use Roact's prop application logic
	if instanceToEventManager[domElement] ~= nil then
		instanceToEventManager[domElement]:suspend()
	end

	local success, errorMessage =
		xpcall(safelyApplyProperties, identity, domElement, updatePayload, lastProps)

	if not success then
		-- ROBLOX deviation: Roblox renderer doesn't currently track where instances
		-- were created the way that legacy Roact did, but DEV mode should include
		-- component stack traces as a separate warning
		local fullMessage = string.format(
			updatePropsError,
			domElement.Name,
			domElement.ClassName,
			errorMessage
		)
		console.error(fullMessage)
		-- FIXME: Until console.error can be instrumented to send telemetry, we need
		-- to keep the hard error here
		error(fullMessage, 0)
	end

	if instanceToEventManager[domElement] ~= nil then
		instanceToEventManager[domElement]:resume()
	end
end

local _, FFlagReactFixBindingMemoryLeak = xpcall(function()
	return game:DefineFastFlag("ReactFixBindingMemoryLeak", false)
end, function()
	return true
end)

local function cleanupBindings(domElement: HostInstance)
	local instanceBindings = instanceToBindings[domElement]
	if instanceBindings ~= nil then
		for _, disconnectBinding in instanceBindings do
			disconnectBinding()
		end

		instanceToBindings[domElement] = nil
	end
end

-- ROBLOX deviation: Clear out references to components when they unmount so we
-- avoid leaking memory when they're removed
local function cleanupHostComponent(domElement: HostInstance)
	-- We do not need to manually disconnect the events since the element is being destroyed.
	if instanceToEventManager[domElement] ~= nil then
		instanceToEventManager[domElement] = nil
	end

	if FFlagReactFixBindingMemoryLeak then
		cleanupBindings(domElement)
	else
		if instanceToBindings[domElement] ~= nil then
			instanceToBindings[domElement] = nil
		end
	end

	-- ROBLOX https://jira.rbx.com/browse/LUAFDN-718: Tables are somehow ending up
	-- in this function that expects Instances. In that case, we won't be able to
	-- iterate through its descendants.
	if typeof(domElement :: any) ~= "Instance" then
		return
	end

	removeAllTags(domElement)
	for _, descElement in domElement:GetDescendants() do
		if instanceToEventManager[descElement] ~= nil then
			instanceToEventManager[descElement] = nil
		end

		if FFlagReactFixBindingMemoryLeak then
			cleanupBindings(descElement)
		else
			if instanceToBindings[descElement] ~= nil then
				instanceToBindings[descElement] = nil
			end
		end
		removeAllTags(domElement)
	end
end

return {
	setInitialProperties = setInitialProperties,
	updateProperties = updateProperties,
	cleanupHostComponent = cleanupHostComponent,

	-- ROBLOX deviation: expose maps to test for Instance cleanups
	_instanceToEventManager = instanceToEventManager,
	_instanceToBindings = instanceToBindings,
}
