-- ROBLOX TODO: ADO-1552 add toMatchInlineSnapshot

function _toMatchSnapshot(config: MatchSnapshotConfig)
	local context = config.context
	local hint = config.hint
	local inlineSnapshot = config.inlineSnapshot
	local isInline = config.isInline
	local matcherName = config.matcherName
	local properties = config.properties
	local received = config.received

	--[[
		deviation: we modify the TestEZ test runner to record the test context in
		a global state for jest-snapshot to find the correct snapshot
		deviation: loads an empty snapshot state if one doesn't exist, Jest creates
		one by default but we cannot do so because of permission issues
	--]]
	local snapshotFileName = _G[JEST_TEST_CONTEXT].instance.Name:match("(.*)%.spec") .. ".snap"
	if _G[JEST_TEST_CONTEXT].snapshotState == nil then
		local snapshotPath = nil
		pcall(function()
			snapshotPath = _G[JEST_TEST_CONTEXT].instance.Parent.__snapshots__[snapshotFileName]
		end)
		local ok, result = pcall(function()
			return SnapshotState.new(
				snapshotPath,
				{ updateSnapshot = _G.UPDATESNAPSHOT or "none", snapshotFormat = {} }
			)
		end)
		if ok then
			_G[JEST_TEST_CONTEXT].snapshotState = result
		else
			return {
				message = function()
					return "Jest-Roblox: Error while loading snapshot file"
				end,
				pass = false,
			}
		end
	end
	context.snapshotState = context.snapshotState or _G[JEST_TEST_CONTEXT].snapshotState
	context.currentTestName = context.currentTestName or table.concat(_G[JEST_TEST_CONTEXT].blocks, " ")

	-- ROBLOX deviation: we don't call dontThrow because we don't yet have the functionality in
	-- place where we add errors to global matcher state and deal with them accordingly
	-- so we instead rely on throwing the actual errors
	-- local _ = context.dontThrow and context.dontThrow()

	local currentTestName = context.currentTestName
	local isNot = context.isNot
	local snapshotState = context.snapshotState

	if isNot then
		error(AssertionError.new({
			message = matcherErrorMessage(matcherHintFromConfig(config, false), NOT_SNAPSHOT_MATCHERS),
		}))
	end

	if snapshotState == nil then
		-- Because the state is the problem, this is not a matcher error.
		-- Call generic stringify from jest-matcher-utils package
		-- because uninitialized snapshot state does not need snapshot serializers.
		error(AssertionError.new({
			message = matcherHintFromConfig(config, false)
				.. "\n\n"
				.. "Snapshot state must be initialized"
				.. "\n\n"
				.. printWithType("Snapshot state", snapshotState, stringify),
		}))
	end

	local fullTestName = if currentTestName and hint
		then currentTestName .. ": " .. hint :: string
		else currentTestName or "" -- future BREAKING change: || hint

	if typeof(properties) == "table" then
		-- ROBLOX deviation: Roblox Instance matchers
		-- allow property matchers against received Instance values
		if received == nil or (typeof(received) ~= "table" and getType(received) ~= "Instance") then
			error(AssertionError.new({
				message = matcherErrorMessage(
					matcherHintFromConfig(config, false),
					RECEIVED_COLOR("received")
						.. " value must be an object when the matcher has "
						.. EXPECTED_COLOR("properties"),
					printWithType("Received", received, printReceived)
				),
			}))
		end

		local propertyPass = context.equals(received, properties, {
			context.utils.iterableEquality,
			context.utils.subsetEquality,
			-- ROBLOX deviation: Roblox Instance matchers
			-- ROBLOX TODO: uncomment when implementing snapshot property matchers on Instances
			-- context.utils.instanceSubsetEquality,
		})

		if not propertyPass then
			local key = snapshotState:fail(fullTestName, received)
			local matched = key:match("(%d+)$")
			-- ROBLOX deviation START: no way for Luau to know if matched is non-nil then tonumber(matched) is guaranteed to be non-nil
			local count = if matched == nil then 1 else tonumber(matched) :: number
			-- ROBLOX deviation END

			local message = function()
				return matcherHintFromConfig(config, false)
					.. "\n\n"
					.. printSnapshotName(currentTestName, hint, count)
					.. "\n\n"
					.. printPropertiesAndReceived(properties, received, snapshotState.expand)
			end

			return {
				message = message,
				name = matcherName,
				pass = false,
			}
		else
			received = utils.deepMerge(received, properties)
		end
	end

	local result = snapshotState:match({
		error = context.error,
		inlineSnapshot = inlineSnapshot,
		isInline = isInline,
		received = received,
		testName = fullTestName,
	})
	local actual = result.actual
	local count = result.count
	local expected = result.expected
	local pass = result.pass

	if pass then
		return {
			message = function()
				return ""
			end,
			pass = true,
		}
	end

	local message
	if expected == nil then
		message = function()
			local retval = matcherHintFromConfig(config, true)
				.. "\n\n"
				.. printSnapshotName(currentTestName, hint, count)
				.. "\n\n"
				.. "New snapshot was "
				.. BOLD_WEIGHT("not written")
				.. ". The update flag "
				.. "must be explicitly passed to write a new snapshot.\n\n"
				.. "This is likely because this test is run in a continuous integration "
				.. "(CI) environment in which snapshots are not written by default.\n\n"
				.. "Received:"

			if actual:find("\n") then
				retval = retval .. "\n"
			else
				retval = retval .. " "
			end

			retval = retval .. bReceivedColor(actual)

			return retval
		end
	else
		message = function()
			return matcherHintFromConfig(config, true)
				.. "\n\n"
				.. printSnapshotName(currentTestName, hint, count)
				.. "\n\n"
				.. printSnapshotAndReceived(expected, actual, received, snapshotState.expand)
		end
	end

	-- Passing the actual and expected objects so that a custom reporter
	-- could access them, for example in order to display a custom visual diff,
	-- or create a different error message
	return {
		actual = actual,
		expected = expected,
		message = message,
		name = matcherName,
		pass = false,
	}
end

local function toThrowErrorMatchingSnapshot(
	this: types.Context,
	received: any,
	hint: string | any,
	fromPromise: boolean
)
	local matcherName = "toThrowErrorMatchingSnapshot"

	-- Future breaking change: Snapshot hint must be a string
	-- if (hint !== undefined && typeof hint !== string) {}

	return _toThrowErrorMatchingSnapshot({
		context = this,
		hint = hint,
		isInline = false,
		matcherName = matcherName,
		received = received,
	}, fromPromise)
end

function _toThrowErrorMatchingSnapshot(config: types.MatchSnapshotConfig, fromPromise: boolean?)
	local context = config.context
	local hint = config.hint
	local inlineSnapshot = config.inlineSnapshot
	local isInline = config.isInline
	local matcherName = config.matcherName
	local received = config.received

	-- ROBLOX deviation: we don't call dontThrow because we don't yet have the functionality in
	-- place where we add errors to global matcher state and deal with them accordingly
	-- so we instead rely on throwing the actual errors
	-- local _ = context.dontThrow and context.dontThrow()

	local isNot = context.isNot
	local promise = context.promise

	if not fromPromise then
		if typeof(received) ~= "function" then
			local options: JestMatcherUtils.MatcherHintOptions = { isNot = isNot, promise = promise }

			error(AssertionError.new({
				message = matcherErrorMessage(
					matcherHint(matcherName, nil, "", options),
					RECEIVED_COLOR("received") .. " value must be a function",
					printWithType("Received", received, printReceived)
				),
			}))
		end
	end

	if isNot then
		error(AssertionError.new({
			message = matcherErrorMessage(matcherHintFromConfig(config, false), NOT_SNAPSHOT_MATCHERS),
		}))
	end

	local error_

	if fromPromise then
		error_ = received
	else
		local ok, result = pcall(function()
			received()
		end)
		if not ok then
			error_ = result
		end
	end

	if error_ == nil then
		-- Because the received value is a function, this is not a matcher error.
		error(AssertionError.new({ message = matcherHintFromConfig(config, false) .. "\n\n" .. DID_NOT_THROW }))
	end

	-- ROBLOX deviation: instead of being able to set received = error.message in our
	-- _toMatchSnapshot call, we have to deal with different cases since in Lua
	-- we could be dealing with our Error polyfill, a thrown object, or a thrown
	-- string
	if instanceof(error_, Error) or typeof(error_) == "table" and rawget(error_, "message") ~= nil then
		error_ = error_.message
	elseif typeof(error_) ~= "string" then
		error_ = tostring(error_)
	end

	return _toMatchSnapshot({
		context = context,
		hint = hint,
		inlineSnapshot = inlineSnapshot,
		isInline = isInline,
		matcherName = matcherName,
		received = error_,
	})
end

return {
	-- EXTENSION = EXTENSION,
	SnapshotState = SnapshotState,
	addSerializer = addSerializer,
	-- buildSnapshotResolver = buildSnapshotResolver,
	-- cleanup = cleanup,
	getSerializers = getSerializers,
	-- isSnapshotPath = isSnapshotPath,
	-- toMatchInlineSnapshot = toMatchInlineSnapshot,
	toMatchSnapshot = toMatchSnapshot,
	toThrowErrorMatchingSnapshot = toThrowErrorMatchingSnapshot,
	utils = utils,
	-- WORKSPACES FIXME: needs to be exported for consumption by workspace neighbors
	plugins = require(CurrentModule.plugins),
}