-- PRIVATE METHODS
function Zone:_calculateRegion(tableOfParts, dontRound)
	local bounds = {["Min"] = {}, ["Max"] = {}}
	for boundType, details in pairs(bounds) do
		details.Values = {}
		function details.parseCheck(v, currentValue)
			if boundType == "Min" then
				return (v <= currentValue)
			elseif boundType == "Max" then
				return (v >= currentValue)
			end
		end
		function details:parse(valuesToParse)
			for i,v in pairs(valuesToParse) do
				local currentValue = self.Values[i] or v
				if self.parseCheck(v, currentValue) then
					self.Values[i] = v
				end
			end
		end
	end
	for _, part in pairs(tableOfParts) do
		local sizeHalf = part.Size * 0.5
		local corners = {
			part.CFrame * CFrame.new(-sizeHalf.X, -sizeHalf.Y, -sizeHalf.Z),
			part.CFrame * CFrame.new(-sizeHalf.X, -sizeHalf.Y, sizeHalf.Z),
			part.CFrame * CFrame.new(-sizeHalf.X, sizeHalf.Y, -sizeHalf.Z),
			part.CFrame * CFrame.new(-sizeHalf.X, sizeHalf.Y, sizeHalf.Z),
			part.CFrame * CFrame.new(sizeHalf.X, -sizeHalf.Y, -sizeHalf.Z),
			part.CFrame * CFrame.new(sizeHalf.X, -sizeHalf.Y, sizeHalf.Z),
			part.CFrame * CFrame.new(sizeHalf.X, sizeHalf.Y, -sizeHalf.Z),
			part.CFrame * CFrame.new(sizeHalf.X, sizeHalf.Y, sizeHalf.Z),
		}
		for _, cornerCFrame in pairs(corners) do
			local x, y, z = cornerCFrame:GetComponents()
			local values = {x, y, z}
			bounds.Min:parse(values)
			bounds.Max:parse(values)
		end
	end
	local minBound = {}
	local maxBound = {}
	-- Rounding a regions coordinates to multiples of 4 ensures the region optimises the region
	-- by ensuring it aligns on the voxel grid
	local function roundToFour(to_round)
		local ROUND_TO = 4
		local divided = (to_round+ROUND_TO/2) / ROUND_TO
		local rounded = ROUND_TO * math.floor(divided)
		return rounded
	end
	for boundName, boundDetail in pairs(bounds) do
		for _, v in pairs(boundDetail.Values) do
			local newTable = (boundName == "Min" and minBound) or maxBound
			local newV = v
			if not dontRound then
				local roundOffset = (boundName == "Min" and -2) or 2
				newV = roundToFour(v+roundOffset) -- +-2 to ensures the zones region is not rounded down/up
			end
			table.insert(newTable, newV)
		end
	end
	local boundMin = Vector3.new(unpack(minBound))
	local boundMax = Vector3.new(unpack(maxBound))
	local region = Region3.new(boundMin, boundMax)
	return region, boundMin, boundMax
end

function Zone:_displayBounds()
	if not self.displayBoundParts then
		self.displayBoundParts = true
		local boundParts = {BoundMin = self.boundMin, BoundMax = self.boundMax}
		for boundName, boundCFrame in pairs(boundParts) do
			local part = Instance.new("Part")
			part.Anchored = true
			part.CanCollide = false
			part.Transparency = 0.5
			part.Size = Vector3.new(1,1,1)
			part.Color = Color3.fromRGB(255,0,0)
			part.CFrame = CFrame.new(boundCFrame)
			part.Name = boundName
			part.Parent = workspace
			self.janitor:add(part, "Destroy")
		end
	end
end

function Zone:_update()
	local container = self.container
	local zoneParts = {}
	local updateQueue = 0
	self._updateConnections:clean()

	local containerType = typeof(container)
	local holders = {}
	local INVALID_TYPE_WARNING = "The zone container must be a model, folder, basepart or table!"
	if containerType == "table" then
		for _, part in pairs(container) do
			if part:IsA("BasePart") then
				table.insert(zoneParts, part)
			end
		end
	elseif containerType == "Instance" then
		if container:IsA("BasePart") then
			table.insert(zoneParts, container)
		else
			table.insert(holders, container)
			for _, part in pairs(container:GetDescendants()) do
				if part:IsA("BasePart") then
					table.insert(zoneParts, part)
				else
					table.insert(holders, part)
				end
			end
		end
	end
	self.zoneParts = zoneParts
	self.overlapParams = {}
	
	local allZonePartsAreBlocksNew = true
	for _, zonePart in pairs(zoneParts) do
		local success, shapeName = pcall(function() return zonePart.Shape.Name end)
		if shapeName ~= "Block" then
			allZonePartsAreBlocksNew = false
		end
	end
	self.allZonePartsAreBlocks = allZonePartsAreBlocksNew
	
	local zonePartsWhitelist = OverlapParams.new()
	zonePartsWhitelist.FilterType = Enum.RaycastFilterType.Whitelist
	zonePartsWhitelist.MaxParts = #zoneParts
	zonePartsWhitelist.FilterDescendantsInstances = zoneParts
	self.overlapParams.zonePartsWhitelist = zonePartsWhitelist

	local zonePartsIgnorelist = OverlapParams.new()
	zonePartsIgnorelist.FilterType = Enum.RaycastFilterType.Blacklist
	zonePartsIgnorelist.FilterDescendantsInstances = zoneParts
	self.overlapParams.zonePartsIgnorelist = zonePartsIgnorelist
	
	-- this will call update on the zone when the container parts size or position changes, and when a
	-- child is removed or added from a holder (anything which isn't a basepart)
	local function update()
		if self.autoUpdate then
			local executeTime = os.clock()
			if self.respectUpdateQueue then
				updateQueue += 1
				executeTime += 0.1
			end
			local updateConnection
			updateConnection = runService.Heartbeat:Connect(function()
				if os.clock() >= executeTime then
					updateConnection:Disconnect()
					if self.respectUpdateQueue then
						updateQueue -= 1
					end
					if updateQueue == 0 and self.zoneId then
						self:_update()
					end
				end
			end)
		end
	end
	local partProperties = {"Size", "Position"}
	local function verifyDefaultCollision(instance)
		if instance.CollisionGroupId ~= 0 then
			assert(nil, "Zone parts must belong to the 'Default' (0) CollisionGroup! Consider using zone:relocate() if you wish to move zones outside of workspace to prevent them interacting with other parts.")
		end
	end
	for _, part in pairs(zoneParts) do
		for _, prop in pairs(partProperties) do
			self._updateConnections:add(part:GetPropertyChangedSignal(prop):Connect(update), "Disconnect")
		end
		verifyDefaultCollision(part)
		self._updateConnections:add(part:GetPropertyChangedSignal("CollisionGroupId"):Connect(function()
			verifyDefaultCollision(part)
		end), "Disconnect")
	end
	local containerEvents = {"ChildAdded", "ChildRemoved"}
	for _, holder in pairs(holders) do
		for _, event in pairs(containerEvents) do
			self._updateConnections:add(self.container[event]:Connect(function(child)
				if child:IsA("BasePart") then
					update()
				end
			end), "Disconnect")
		end
	end
	
	local region, boundMin, boundMax = self:_calculateRegion(zoneParts)
	local exactRegion, _, _ = self:_calculateRegion(zoneParts, true)
	self.region = region
	self.exactRegion = exactRegion
	self.boundMin = boundMin
	self.boundMax = boundMax
	local rSize = region.Size
	self.volume = rSize.X*rSize.Y*rSize.Z
	
	-- Update: I was going to use this for the old part detection until the CanTouch property was released
	-- everything below is now irrelevant however I'll keep just in case I use again for future
	-------------------------------------------------------------------------------------------------
	-- When a zones region is determined, we also check for parts already existing within the zone
	-- these parts are likely never to move or interact with the zone, so we set the number of these
	-- to the baseline MaxParts value. 'recommendMaxParts' is then determined through the sum of this
	-- and maxPartsAddition. This ultimately optimises region checks as they can be generated with
	-- minimal MaxParts (i.e. recommendedMaxParts can be used instead of math.huge every time)
	--[[
	local result = self.worldModel:FindPartsInRegion3(region, nil, math.huge)
	local maxPartsBaseline = #result
	self.recommendedMaxParts = maxPartsBaseline + self.maxPartsAddition
	--]]
	
	self:_updateTouchedConnections()
	
	self.updated:Fire()
end

function Zone:_updateOccupants(trackerName, newOccupants)
	local previousOccupants = self.occupants[trackerName]
	if not previousOccupants then
		previousOccupants = {}
		self.occupants[trackerName] = previousOccupants
	end
	local signalsToFire = {}
	for occupant, prevItem in pairs(previousOccupants) do
		local newItem = newOccupants[occupant]
		if newItem == nil or newItem ~= prevItem then
			previousOccupants[occupant] = nil
			if not signalsToFire.exited then
				signalsToFire.exited = {}
			end
			table.insert(signalsToFire.exited, occupant)
		end
	end
	for occupant, _ in pairs(newOccupants) do
		if previousOccupants[occupant] == nil then
			local isAPlayer = occupant:IsA("Player")
			previousOccupants[occupant] = (isAPlayer and occupant.Character) or true
			if not signalsToFire.entered then
				signalsToFire.entered = {}
			end
			table.insert(signalsToFire.entered, occupant)
		end
	end 
	return signalsToFire
end

function Zone:_formTouchedConnection(triggerType)
	local touchedJanitorName = "_touchedJanitor"..triggerType
	local touchedJanitor = self[touchedJanitorName]
	if touchedJanitor then
		touchedJanitor:clean()
	else
		touchedJanitor = self.janitor:add(Janitor.new(), "destroy")
		self[touchedJanitorName] = touchedJanitor
	end
	self:_updateTouchedConnection(triggerType)
end

function Zone:_updateTouchedConnection(triggerType)
	local touchedJanitorName = "_touchedJanitor"..triggerType
	local touchedJanitor = self[touchedJanitorName]
	if not touchedJanitor then return end
	for _, basePart in pairs(self.zoneParts) do
		touchedJanitor:add(basePart.Touched:Connect(self.touchedConnectionActions[triggerType], self), "Disconnect")
	end
end

function Zone:_updateTouchedConnections()
	for triggerType, _ in pairs(self.touchedConnectionActions) do
		local touchedJanitorName = "_touchedJanitor"..triggerType
		local touchedJanitor = self[touchedJanitorName]
		if touchedJanitor then
			touchedJanitor:cleanup()
			self:_updateTouchedConnection(triggerType)
		end
	end
end

function Zone:_disconnectTouchedConnection(triggerType)
	local touchedJanitorName = "_touchedJanitor"..triggerType
	local touchedJanitor = self[touchedJanitorName]
	if touchedJanitor then
		touchedJanitor:cleanup()
		self[touchedJanitorName] = nil
	end
end

local function round(number, decimalPlaces)
	return math.round(number * 10^decimalPlaces) * 10^-decimalPlaces
end
function Zone:_partTouchedZone(part)
	local trackingDict = self.trackingTouchedTriggers["part"]
	if trackingDict[part] then return end
	local nextCheck = 0
	local verifiedEntrance = false
	local enterPosition = part.Position
	local enterTime = os.clock()
	local partJanitor = self.janitor:add(Janitor.new(), "destroy")
	trackingDict[part] = partJanitor
	local instanceClassesToIgnore = {Seat = true, VehicleSeat = true}
	local instanceNamesToIgnore = {HumanoidRootPart = true}
	if not (instanceClassesToIgnore[part.ClassName] or not instanceNamesToIgnore[part.Name])  then
		part.CanTouch = false
	end
	--
	local partVolume = round((part.Size.X * part.Size.Y * part.Size.Z), 5)
	self.totalPartVolume += partVolume
	--
	partJanitor:add(heartbeat:Connect(function()
		local clockTime = os.clock()
		if clockTime >= nextCheck then
			----
			local cooldown = enum.Accuracy.getProperty(self.accuracy)
			nextCheck = clockTime + cooldown
			----

			-- We initially perform a singular point check as this is vastly more lightweight than a large part check
			-- If the former returns false, perform a whole part check in case the part is on the outer bounds.
			local withinZone = self:findPoint(part.CFrame)
			if not withinZone then
				withinZone = self:findPart(part)
			end
			if not verifiedEntrance then
				if withinZone then
					verifiedEntrance = true
					self.partEntered:Fire(part)
				elseif (part.Position - enterPosition).Magnitude > 1.5 and clockTime - enterTime >= cooldown then
					-- Even after the part has exited the zone, we track it for a brief period of time based upon the criteria
					-- in the line above to ensure the .touched behaviours are not abused
					partJanitor:cleanup()
				end
			elseif not withinZone then
				verifiedEntrance = false
				enterPosition = part.Position
				enterTime = os.clock()
				self.partExited:Fire(part)
			end
		end
	end), "Disconnect")
	partJanitor:add(function()
		trackingDict[part] = nil
		part.CanTouch = true
		self.totalPartVolume = round((self.totalPartVolume - partVolume), 5)
	end, true)
end

local partShapeActions = {
	["Ball"] = function(part)
		return "GetPartBoundsInRadius", {part.Position, part.Size.X}
	end,
	["Block"] = function(part)
		return "GetPartBoundsInBox", {part.CFrame, part.Size}
	end,
	["Other"] = function(part)
		return "GetPartsInPart", {part}
	end,
}
function Zone:_getRegionConstructor(part, overlapParams)
	local success, shapeName = pcall(function() return part.Shape.Name end)
	local methodName, args
	if success and self.allZonePartsAreBlocks then
		local action = partShapeActions[shapeName]
		if action then
			methodName, args = action(part)
		end
	end
	if not methodName then
		methodName, args = partShapeActions.Other(part)
	end
	if overlapParams then
		table.insert(args, overlapParams)
	end
	return methodName, args
end