--[[
	Given a Model in 3D space, this function figures out how far the camera
	has to be pushed back to fit the model within the viewport.

	Check out this great video demonstrating the implementation:
	https://youtu.be/2J-2v_UqNDw
]]
local function getDepthToFitObject(fov: number, instance: Instance)
	local size: Vector3

	if instance:IsA("Model") then
		local _, boxSize = instance:GetBoundingBox()
		size = boxSize
	elseif instance:IsA("BasePart") then
		size = instance.Size
	else
		local part = instance:FindFirstChildWhichIsA("BasePart")
		size = part.Size
	end

	local sizeY = math.abs(size.Y)
	local sizeZ = math.abs(size.Z)

	return (sizeY / (math.tan(math.rad(fov / 2)) * 2)) + (sizeZ / 2)
end

local defaultProps = {
	size = UDim2.fromScale(1, 1),
	previewRotationSpeed = 15,
	rotation = 120,

	zoomRange = NumberRange.new(-DEPTH_PADDING, 5), -- studs
	zoomIncrement = 2.5, -- studs

	-- Mocks
	spring = Otter.spring,
	UserInputService = UserInputService,
	RunService = RunService,
}

export type Props = typeof(defaultProps) & {
	itemInfo: types.ItemInfo,
	previewMode: string,

	size: UDim2?,
	previewRotationSpeed: number?,
	rotation: number?,
	previewAvatar: Model?,

	UserInputService: UserInputService,
	RunService: RunService,
}

local ItemViewport = Roact.Component:extend("ItemViewport")

ItemViewport.defaultProps = defaultProps

function ItemViewport:init()
	self.isUserRotating = false
	self.isUserPinching = false

	self.zoomOffset = 0
	self.zoomMotor = Otter.createSingleMotor(self.zoomOffset)
	self.zoomMotor:onStep(function(value: number)
		self.zoomOffset = value
	end)

	self.rotation = self.props.rotation

	self.viewportCamera = Instance.new("Camera")
	self.lastViewportInputPosition = Vector3.new()
	self.viewportRef = Roact.createRef()

	self.heartbeatFunction = function(delta: number)
		local viewport: ViewportFrame = self.viewportRef:getValue()
		local viewportCamera: Camera = self.viewportCamera
		local viewportObject = viewport and viewport:FindFirstChildWhichIsA("Instance")

		if not viewportObject then
			return
		end

		local rotationSpeed = self.props.previewRotationSpeed
		if not self.isUserRotating then
			rotationSpeed = rotationSpeed * 0.95 + rotationSpeed * 0.05
			self.rotation = (self.rotation + delta * rotationSpeed) % 360
		end

		local props: Props = self.props
		local rotation = CFrame.Angles(0, math.rad(self.rotation), 0)
		local position = getInstanceCenter(viewportObject)
		local depth = getDepthToFitObject(viewportCamera.FieldOfView, viewportObject)
		depth = math.clamp(depth, props.zoomRange.Min, props.zoomRange.Max)

		viewportCamera.CFrame = CFrame.new(position)
			* rotation
			* CFrame.new(0, 0, depth + DEPTH_PADDING + self.zoomOffset)
	end

	self.zoom = function(direction: number)
		local props: Props = self.props
		local goal =
			math.clamp(self.zoomOffset - (props.zoomIncrement * direction), props.zoomRange.Min, props.zoomRange.Max)

		self.zoomMotor:setGoal(props.spring(goal, constants.Springs.Zippy))
	end

	self.inputBegan = function(_, input: InputObject)
		if input.UserInputType == Enum.UserInputType.MouseWheel then
			self.zoom(input.Position.Z)
		end

		if self.isUserPinching then
			return
		end

		if
			input.UserInputType == Enum.UserInputType.MouseButton1
			or input.UserInputType == Enum.UserInputType.Touch
		then
			self.isUserRotating = true

			self:setState({ viewportInput = true })
			self.lastViewportInputPosition = input.Position
		end
	end

	self.inputEnded = function(_rbx, input: InputObject)
		if self.isUserRotating then
			self.isUserRotating = false

			if
				input.UserInputType == Enum.UserInputType.MouseButton1
				or input.UserInputType == Enum.UserInputType.Touch
			then
				self:setState({ viewportInput = false })
			end
		end
	end

	self.inputChanged = function(_rbx, input: InputObject)
		if input.UserInputType == Enum.UserInputType.MouseWheel then
			self.zoom(input.Position.Z)
		end

		if self.isUserRotating and not self.isUserPinching then
			if
				input.UserInputType == Enum.UserInputType.MouseMovement
				or input.UserInputType == Enum.UserInputType.Touch
			then
				local delta = input.Position - self.lastViewportInputPosition -- input.Delta doesn't seem to work
				self.lastViewportInputPosition = input.Position
				self.rotation = self.rotation - delta.X
			end
		end
	end

	local lastPinchDistance: number
	self.touchPinch = function(_rbx, touchPositions: { Vector2 }, _scale, _velocity, state: Enum.UserInputState)
		if not touchPositions[2] or state == Enum.UserInputState.End then
			self.isUserPinching = false
			return
		end

		self.isUserPinching = true

		local pinchDistance = (touchPositions[1] - touchPositions[2]).Magnitude

		if state == Enum.UserInputState.Change then
			local props: Props = self.props
			local pinchDelta = pinchDistance - lastPinchDistance
			local goal = self.zoomOffset - (props.zoomIncrement * pinchDelta * PINCH_DELTA_TO_ZOOM_DELTA)

			self.zoomOffset = math.clamp(goal, props.zoomRange.Min, props.zoomRange.Max)
		end

		lastPinchDistance = pinchDistance
	end

	self.updateViewportItem = function()
		local props: Props = self.props
		local viewport: ViewportFrame = self.viewportRef:getValue()

		viewport:ClearAllChildren()

		if not props.itemInfo then
			return
		end

		if props.previewMode == enums.PreviewModes.Object then
			if props.itemInfo.viewportObject then
				local clone = props.itemInfo.viewportObject:Clone()
				clone.Parent = viewport
			else
				warn("Merch booth item preview: could not find item viewport object")
			end
		else
			if props.previewAvatar then
				local clone = props.previewAvatar:Clone()
				clone.Parent = viewport
			else
				warn("Merch booth item preview: could not find preview avatar")
			end
		end
	end
end

function ItemViewport:render()
	return Roact.createElement("ViewportFrame", {
		Active = true,
		LayoutOrder = self.props.LayoutOrder,
		Size = self.props.size,
		BackgroundTransparency = 1,
		CurrentCamera = self.viewportCamera,

		[Roact.Event.InputBegan] = self.inputBegan,
		[Roact.Event.InputEnded] = self.inputEnded,
		[Roact.Event.InputChanged] = self.inputChanged,
		[Roact.Event.TouchPinch] = self.touchPinch,
		[Roact.Ref] = self.viewportRef,
	}, {
		HeartbeatEventConnection = Roact.createElement(ExternalEventConnection, {
			event = self.props.RunService.Heartbeat,
			callback = self.heartbeatFunction,
		}),
	})
end

function ItemViewport:didMount()
	self.updateViewportItem()
end

function ItemViewport:didUpdate(previousProps: Props)
	local props: Props = self.props
	if
		previousProps.itemInfo ~= props.itemInfo
		or previousProps.previewMode ~= props.previewMode
		or props.previewAvatar ~= previousProps.previewAvatar
	then
		self.updateViewportItem()
	end
end

return ItemViewport