-- No getExpectedType method, because it matches either null or undefined.

function Anything:toAsymmetricMatcher(): string
	return "Anything"
end

local ArrayContaining = {}
ArrayContaining.__index = ArrayContaining
setmetatable(ArrayContaining, AsymmetricMatcher)
function ArrayContaining.new(sample: { any }, inverse: boolean?)
	inverse = if inverse ~= nil then inverse else false
	local self = AsymmetricMatcher.new(sample, inverse)
	setmetatable(self, ArrayContaining)
	return self
end

function ArrayContaining:asymmetricMatch(other: { any })
	if not Array.isArray(self.sample) then
		error(string.format("You must provide an array to %s, not '%s'.", self:toString(), typeof(self.sample)))
	end

	local result = false
	if #self.sample == 0 then
		result = true
	elseif
		Array.isArray(other)
		and Array.every(self.sample, function(item)
			return Array.some(other, function(another)
				return equals(item, another)
			end)
		end)
	then
		result = true
	end

	if self.inverse then
		return not result
	end
	return result
end

function ArrayContaining:toString(): string
	if self.inverse then
		return "ArrayNotContaining"
	end
	return "ArrayContaining"
end

function ArrayContaining:getExpectedType(): string
	return "array"
end

local ObjectContaining = {}
ObjectContaining.__index = ObjectContaining
setmetatable(ObjectContaining, AsymmetricMatcher)
function ObjectContaining.new(sample: { any }, inverse: boolean?)
	inverse = if inverse ~= nil then inverse else false
	local self = AsymmetricMatcher.new(sample, inverse)
	setmetatable(self, ObjectContaining)
	return self
end

function ObjectContaining:asymmetricMatch(other: { any })
	-- ROBLOX deviation: check for type table instead of object
	if typeof(self.sample) ~= "table" then
		error(string.format("You must provide an object to %s, not '%s'.", self:toString(), typeof(self.sample)))
	end

	local result = true

	for property, value in pairs(self.sample) do
		if not hasProperty(other, property) or not equals(value, other[property]) then
			result = false
			break
		end
	end

	return if self.inverse then not result else result
end

function ObjectContaining:toString(): string
	if self.inverse then
		return "ObjectNotContaining"
	end
	return "ObjectContaining"
end

function ObjectContaining:getExpectedType(): string
	return "object"
end

local StringContaining = {}
StringContaining.__index = StringContaining
setmetatable(StringContaining, AsymmetricMatcher)
function StringContaining.new(sample: string, inverse: boolean?)
	inverse = if inverse ~= nil then inverse else false
	if not isA("string", sample) then
		error("Expected is not a String")
	end
	local self = AsymmetricMatcher.new(sample, inverse)
	setmetatable(self, StringContaining)
	return self
end

function StringContaining:asymmetricMatch(other: string): boolean
	local result = isA("string", other) and other:find(self.sample, 1, true)

	if self.inverse then
		return not result
	end
	return not not result
end

function StringContaining:toString(): string
	if self.inverse then
		return "StringNotContaining"
	end
	return "StringContaining"
end

function StringContaining:getExpectedType(): string
	return "string"
end

local StringMatching = {}
StringMatching.__index = StringMatching
setmetatable(StringMatching, AsymmetricMatcher)
function StringMatching.new(sample: string | RegExp, inverse: boolean?)
	inverse = if inverse ~= nil then inverse else false
	-- ROBLOX deviation: we accept matches against a Lua string pattern or RegExp polyfill
	if not isA("string", sample) and not isA("regexp", sample) then
		error("Expected is not a String")
	end
	local self = AsymmetricMatcher.new(sample, inverse)
	setmetatable(self, StringMatching)
	return self
end

function StringMatching:asymmetricMatch(other: string): boolean
	local result = false
	if isA("string", other) then
		-- Lua pattern case
		if isA("string", self.sample) then
			-- ROBLOX deviation: escape chalk sequences if necessary
			self.sample = string.gsub(self.sample, string.char(27) .. "%[", string.char(27) .. "%%[")
			result = other:find(self.sample)
			-- Regex case
		else
			result = self.sample:test(other)
		end
	end

	if self.inverse then
		return not result
	end
	return not not result
end

function StringMatching:toString(): string
	if self.inverse then
		return "StringNotMatching"
	end
	return "StringMatching"
end

function StringMatching:getExpectedType(): string
	return "string"
end

return {
	AsymmetricMatcher = AsymmetricMatcher,
	any = function(expectedObject: any)
		return Any.new(expectedObject)
	end,
	anything = function()
		return Anything.new()
	end,
	arrayContaining = function(sample: { any })
		return ArrayContaining.new(sample)
	end,
	arrayNotContaining = function(sample: { any })
		return ArrayContaining.new(sample, true)
	end,
	objectContaining = function(sample: any)
		return ObjectContaining.new(sample)
	end,
	objectNotContaining = function(sample: any)
		return ObjectContaining.new(sample, true)
	end,
	stringContaining = function(expected: string)
		return StringContaining.new(expected)
	end,
	stringNotContaining = function(expected: string)
		return StringContaining.new(expected, true)
	end,
	stringMatching = function(expected: string | RegExp)
		return StringMatching.new(expected)
	end,
	stringNotMatching = function(expected: string | RegExp)
		return StringMatching.new(expected, true)
	end,
}