--[[
	Apply a patch to the DOM. Returns any portions of the patch that weren't
	possible to apply.

	Patches can come from the server or be generated by the client.
]]

local ChangeHistoryService = game:GetService("ChangeHistoryService")

local Packages = script.Parent.Parent.Parent.Packages
local Log = require(Packages.Log)

local PatchSet = require(script.Parent.Parent.PatchSet)
local Types = require(script.Parent.Parent.Types)
local invariant = require(script.Parent.Parent.invariant)

local decodeValue = require(script.Parent.decodeValue)
local reify = require(script.Parent.reify)
local setProperty = require(script.Parent.setProperty)

local function applyPatch(instanceMap, patch)
	local patchTimestamp = DateTime.now():FormatLocalTime("LTS", "en-us")

	-- Tracks any portions of the patch that could not be applied to the DOM.
	local unappliedPatch = PatchSet.newEmpty()

	for _, removedIdOrInstance in ipairs(patch.removed) do
		if Types.RbxId(removedIdOrInstance) then
			instanceMap:destroyId(removedIdOrInstance)
		else
			instanceMap:destroyInstance(removedIdOrInstance)
		end
	end

	for id, virtualInstance in pairs(patch.added) do
		if instanceMap.fromIds[id] ~= nil then
			-- This instance already exists. We might've already added it in a
			-- previous iteration of this loop, or maybe this patch was not
			-- supposed to list this instance.
			--
			-- It's probably fine, right?
			continue
		end

		-- Find the first ancestor of this instance that is marked for an
		-- addition.
		--
		-- This helps us make sure we only reify each instance once, and we
		-- start from the top.
		while patch.added[virtualInstance.Parent] ~= nil do
			id = virtualInstance.Parent
			virtualInstance = patch.added[id]
		end

		local parentInstance = instanceMap.fromIds[virtualInstance.Parent]

		if parentInstance == nil then
			-- This would be peculiar. If you create an instance with no
			-- parent, were you supposed to create it at all?
			invariant(
				"Cannot add an instance from a patch that has no parent.\nInstance {} with parent {}.\nState: {:#?}",
				id,
				virtualInstance.Parent,
				instanceMap
			)
		end

		local failedToReify = reify(instanceMap, patch.added, id, parentInstance)

		if not PatchSet.isEmpty(failedToReify) then
			Log.debug("Failed to reify as part of applying a patch: {:#?}", failedToReify)
			PatchSet.assign(unappliedPatch, failedToReify)
		end
	end

	for _, update in ipairs(patch.updated) do
		local instance = instanceMap.fromIds[update.id]

		if instance == nil then
			-- We can't update an instance that doesn't exist.
			table.insert(unappliedPatch.updated, update)
			continue
		end

		-- Pause updates on this instance to avoid picking up our changes when
		-- two-way sync is enabled.
		instanceMap:pauseInstance(instance)

		-- Track any part of this update that could not be applied.
		local unappliedUpdate = {
			id = update.id,
			changedProperties = {},
		}
		local partiallyApplied = false

		-- If the instance's className changed, we have a bumpy ride ahead while
		-- we recreate this instance and move all of its children into the new
		-- version atomically...ish.
		if update.changedClassName ~= nil then
			-- If the instance's name also changed, we'll do it here, since this
			-- branch will skip the rest of the loop iteration.
			local newName = update.changedName or instance.Name

			-- TODO: When changing between instances that have similar sets of
			-- properties, like between an ImageLabel and an ImageButton, we
			-- should preserve all of the properties that are shared between the
			-- two classes unless they're changed as part of this patch. This is
			-- similar to how "class changer" Studio plugins work.
			--
			-- For now, we'll only apply properties that are mentioned in this
			-- update. Patches with changedClassName set only occur in specific
			-- circumstances, usually between Folder and ModuleScript instances.
			-- While this may result in some issues, like not preserving the
			-- "Archived" property, a robust solution is sufficiently
			-- complicated that we're pushing it off for now.
			local newProperties = update.changedProperties

			-- If the instance's ClassName changed, we'll kick into reify to
			-- create this instance. We'll handle moving all of children between
			-- the instances after the new one is created.
			local mockVirtualInstance = {
				Id = update.id,
				Name = newName,
				ClassName = update.changedClassName,
				Properties = newProperties,
				Children = {},
			}

			local mockAdded = {
				[update.id] = mockVirtualInstance,
			}

			local failedToReify = reify(instanceMap, mockAdded, update.id, instance.Parent)

			local newInstance = instanceMap.fromIds[update.id]

			-- Some parts of reify may have failed, but this is not necessarily
			-- critical. If the instance wasn't recreated or has the wrong Name,
			-- we'll consider our attempt a failure.
			if instance == newInstance or newInstance.Name ~= newName then
				table.insert(unappliedPatch.updated, update)
				continue
			end

			-- Here are the non-critical failures. We know that the instance
			-- succeeded in creating and that assigning Name did not fail, but
			-- other property assignments might've failed.
			if not PatchSet.isEmpty(failedToReify) then
				PatchSet.assign(unappliedPatch, failedToReify)
			end

			-- Watch out, this is the scary part! Move all of the children of
			-- instance into newInstance.
			--
			-- TODO: If this fails part way through, should we move everything
			-- back? For now, we assume that moving things will not fail.
			for _, child in ipairs(instance:GetChildren()) do
				child.Parent = newInstance
			end

			-- See you later, original instance.
			--
			-- TODO: Can this fail? Some kinds of instance may not appreciate
			-- being destroyed, like services.
			instance:Destroy()

			-- This completes your rebuilding a plane mid-flight safety
			-- instruction. Please sit back, relax, and enjoy your flight.
			continue
		end

		if update.changedName ~= nil then
			instance.Name = update.changedName
		end

		if update.changedMetadata ~= nil then
			-- TODO: Support changing metadata. This will become necessary when
			-- Rojo persistently tracks metadata for each instance in order to
			-- remove extra instances.
			unappliedUpdate.changedMetadata = update.changedMetadata
			partiallyApplied = true
		end

		if update.changedProperties ~= nil then
			for propertyName, propertyValue in pairs(update.changedProperties) do
				local ok, decodedValue = decodeValue(propertyValue, instanceMap)
				if not ok then
					unappliedUpdate.changedProperties[propertyName] = propertyValue
					partiallyApplied = true
					continue
				end

				local ok = setProperty(instance, propertyName, decodedValue)
				if not ok then
					unappliedUpdate.changedProperties[propertyName] = propertyValue
					partiallyApplied = true
				end
			end
		end

		if partiallyApplied then
			table.insert(unappliedPatch.updated, unappliedUpdate)
		end
	end

	ChangeHistoryService:SetWaypoint("Rojo: Patch " .. patchTimestamp)

	return unappliedPatch
end

return applyPatch
