-- A private fork of OrbitViewer (Cameras dependency, by John
-- Millard). Forked rather than patched because BlockLibrary's
-- own Main orbits with the shared original too.
--
-- Three changes from the original:
--
-- 1. Double tap no longer snaps back to the origin. Kept as
--    an explicit method instead: BuilderCam:home().
--
-- 2. The two-finger branch of update() no longer writes
--    entity.position before entity.rotation. The original
--    used self.entity.forward while it still held the
--    PREVIOUS frame's rotation, which put the camera on the
--    wrong side of the target and broke the pan that follows.
--
-- 3. Pinch distance is floored. prev.dist / dist with the
--    fingers nearly touching is a division by something close
--    to zero, and one such frame sends zoom off the rails.
--
-- Touch tracking is keyed on touch id via self.seenAt, never
-- written onto the touch itself -- a Codea touch is immutable
-- userdata and each event delivers a new object.

BuilderCam = class()

BuilderCam.MIN_PINCH = 24
BuilderCam.MAX_ZOOM_STEP = 3.0

-- Seconds without an update before a tracked touch is
-- assumed dead. Long enough a stationary finger never drops,
-- short enough a leak clears before it looks broken.
BuilderCam.TOUCH_TTL = 0.5

function BuilderCam:stamp(id)
  self.seenAt = self.seenAt or {}
  self.seenAt[id] = ElapsedTime
end

function BuilderCam:trackTouch(touch)
  self:stamp(touch.id)
  table.insert(self.touches, touch)
end

-- Public: the HUD calls this when it swallows a touch
-- BuilderCam had already claimed, which is the leak this
-- class exists to survive.
function BuilderCam:release(id)
  local hit = false
  for i = #self.touches, 1, -1 do
    if self.touches[i].id == id then
      table.remove(self.touches, i)
      hit = true
    end
  end
  if self.seenAt then self.seenAt[id] = nil end
  if hit then self:pinchReset() end
  return hit
end

function BuilderCam:pinchReset()
  self.mx, self.my = 0, 0
  self.prev.dist = nil
  self.prev.mid = nil
  self.prev.target = nil
end

function BuilderCam:pinchBegin()
  self.prev.target = vec3(self.target:unpack())
  self.prev.mid = self:pinchMid()
  self.prev.dist = math.max(self:pinchDist(), BuilderCam.MIN_PINCH)
  self.prev.zoom = self.zoom
  self.mx, self.my = 0, 0
end

-- The escape hatch. Without it a leaked touch with no
-- matching ENDED stays tracked forever, and every finger
-- after that reads as the second half of a pinch.
function BuilderCam:sweepTouches()
  self.seenAt = self.seenAt or {}
  
  local now = ElapsedTime
  local dropped = false
  
  for i = #self.touches, 1, -1 do
    local id = self.touches[i].id
    local at = self.seenAt[id]
    if not at or now - at > BuilderCam.TOUCH_TTL then
      table.remove(self.touches, i)
      self.seenAt[id] = nil
      dropped = true
    end
  end
  
  if dropped then self:pinchReset() end
  return dropped
end

function BuilderCam:init(entity, target, zoom, minZoom, maxZoom)
  self.entity = entity
  self.camera = entity:get(craft.camera)
  self.seenAt = {}
  
  self.target = target or vec3(0, 0, 0)
  self.origin = self.target
  
  self.zoom = zoom or 5
  self.minZoom = minZoom or 1
  self.maxZoom = maxZoom or 20
  
  self.touches = {}
  self.prev = {}
  
  self.rx = 0
  self.ry = 0
  
  self.mx = 0
  self.my = 0
  
  self.sensitivity = 0.25
  self.doubleTapHome = false
  
  if touches then touches.addHandler(self, 0, true) end
end

function BuilderCam:home()
  self.target = self.origin
  self.mx, self.my = 0, 0
end

function BuilderCam:project(p, z)
  local origin, dir = self.camera:screenToRay(p)
  return origin + dir * z
end

local function scrollDamping(x, s)
  return s * math.log(x + s) - s * math.log(s)
end

function BuilderCam:update()
  self:sweepTouches()
  
  if #self.touches == 0 and not self.capturedScroll then
    self.rx = self.rx + self.mx * DeltaTime
    self.ry = self.ry + self.my * DeltaTime
    self.mx = self.mx * 0.9
    self.my = self.my * 0.9
    
    if self.zoom > self.maxZoom then
      local overshoot = (self.zoom - self.maxZoom) * 0.9
      self.zoom = self.maxZoom + overshoot
    elseif self.zoom < self.minZoom then
      local overshoot = (self.minZoom - self.zoom) * 0.9
      self.zoom = self.minZoom - overshoot
    end
    
  elseif #self.touches == 2 then
    if not self.prev.mid or not self.prev.target then
      self:pinchBegin()
    else
      local mid = self:pinchMid()
      local dist = math.max(self:pinchDist(), BuilderCam.MIN_PINCH)
      local pd = math.max(self.prev.dist or dist, BuilderCam.MIN_PINCH)
      
      local p1 = self:project(self.prev.mid, self.zoom)
      local p2 = self:project(mid, self.zoom)
      
      self.target = self.prev.target + (p1 - p2)
      
      local k = pd / dist
      k = math.max(1 / BuilderCam.MAX_ZOOM_STEP,
      math.min(BuilderCam.MAX_ZOOM_STEP, k))
      self.zoom = self:clampZoom(self.prev.zoom * k)
    end
  end
  
  self.rx = math.min(math.max(self.rx, -90), 90)
  
  if self.zoom ~= self.zoom or self.target.x ~= self.target.x
  or self.target.y ~= self.target.y
  or self.target.z ~= self.target.z then
    print("BuilderCam: bad camera state, resetting")
    self.target = self.origin
    self.zoom = math.min(math.max(self.minZoom * 2, 1), self.maxZoom)
    self.mx, self.my = 0, 0
  end
  
  self.entity.rotation = quat.eulerAngles(self.rx, self.ry, 0)
  local t = vec3(self.target.x, self.target.y, self.target.z)
  self.entity.position = t + self.entity.forward * -self.zoom
end

function BuilderCam:pinchDist()
  local p1 = vec2(self.touches[1].x, self.touches[1].y)
  local p2 = vec2(self.touches[2].x, self.touches[2].y)
  return p1:dist(p2)
end

function BuilderCam:pinchMid()
  local p1 = vec2(self.touches[1].x, self.touches[1].y)
  local p2 = vec2(self.touches[2].x, self.touches[2].y)
  return (p1 + p2) * 0.5
end

function BuilderCam:clampZoom(zoom)
  if zoom > self.maxZoom then
    local overshoot = scrollDamping(zoom - self.maxZoom, 10.0)
    zoom = self.maxZoom + overshoot
  elseif zoom < self.minZoom then
    local overshoot = scrollDamping(self.minZoom - zoom, 10.0)
    zoom = self.minZoom - overshoot
  end
  return zoom
end

function BuilderCam:rotate(x, y)
  self.rx = self.rx - y * self.sensitivity
  self.ry = self.ry - x * self.sensitivity
end

function BuilderCam:pan(p1, p2)
  local a = self:project(p1, self.zoom)
  local b = self:project(p2, self.zoom)
  self.target = self.target + (a - b)
end

function BuilderCam:scroll(gesture)
  local panMode = gesture.shift
  local zoomMode = gesture.alt
  
  if gesture.state == BEGAN then
    if #self.touches > 0 then return false end
    
    self.capturedScroll = true
    self.prev.zoom = self.zoom
    self.prev.mid = gesture.location
    
    return true
  elseif gesture.state == MOVING then
    if panMode then
      self:pan(gesture.location - gesture.delta, gesture.location)
    elseif zoomMode then
      self.zoom = self:clampZoom(self.prev.zoom
      + (gesture.location - self.prev.mid).y * self.sensitivity)
    else
      self:rotate(gesture.delta.x, gesture.delta.y)
    end
    self.prevGestureDelta = gesture.delta
  elseif gesture.state == ENDED or gesture.state == CANCELLED then
    self.capturedScroll = false
    
    if not panMode and not zoomMode then
      local delta = self.prevGestureDelta
      if delta then
        self.mx = -delta.y / DeltaTime * self.sensitivity
        self.my = -delta.x / DeltaTime * self.sensitivity
      end
    end
  end
end

function BuilderCam:touched(touch)
  if touch.tapCount == 2 and self.doubleTapHome then
    self:home()
  end
  
  if self.capturedScroll then return false end
  
  self.seenAt = self.seenAt or {}
  
  for i = 1, #self.touches do
    if self.touches[i].id == touch.id then
      self:stamp(touch.id)
      self.touches[i] = touch
    end
  end
  
  if touch.state == BEGAN then
    -- An id arriving as BEGAN while already tracked means the
    -- previous life of that id never ended -- ids get reused.
    for i = #self.touches, 1, -1 do
      if self.touches[i].id == touch.id then
        table.remove(self.touches, i)
        self:pinchReset()
      end
    end
    
    if #self.touches < 2 then
      self:trackTouch(touch)
      if #self.touches == 2 then
        self:pinchBegin()
      end
      return true
    end
    
    return false
  end
  
  if touch.state == ENDED or touch.state == CANCELLED then
    for i = #self.touches, 1, -1 do
      if self.touches[i].id == touch.id then
        table.remove(self.touches, i)
        break
      end
    end
    
    self.seenAt[touch.id] = nil
    if #self.touches < 2 then self:pinchReset() end
  end
  
  if #self.touches == 0 then
    self.mx = -touch.deltaY / DeltaTime * self.sensitivity
    self.my = -touch.deltaX / DeltaTime * self.sensitivity
    if math.abs(self.mx) < 70 then self.mx = 0 end
    if math.abs(self.my) < 70 then self.my = 0 end
  elseif #self.touches == 1 then
    self:rotate(touch.deltaX, touch.deltaY)
  end
  
  return false
end
