--# CardTouch
-- Everything a finger does to the cards. Dragging, the stack that builds up
-- behind a held card, and the loop gesture that gathers a mess into a pile.
--
-- A loop wins over a drag, so circling while holding a card gathers the circle
-- and lets go of the card.

-- Watches for a finger drawing a closed loop. Points are only kept when the
-- finger has moved far enough to be worth recording.

LoopWatcher = class()

function LoopWatcher:init()
  self.paths = {}
  self.pointsNeeded = 8
end

function LoopWatcher:begin(touch)
  self.paths[touch.id] = {points = {touch.pos}, length = 0}
end

function LoopWatcher:finish(touch)
  self.paths[touch.id] = nil
end

-- returns the loop's points once the finger closes it, otherwise nil
function LoopWatcher:moved(touch, spacing, closeRadius, minLength)
  local path = self.paths[touch.id]
  if not path then return nil end
  local step = touch.pos:dist(path.points[#path.points])
  if step < spacing then return nil end
  table.insert(path.points, touch.pos)
  path.length = path.length + step
  if #path.points < self.pointsNeeded then return nil end
  if path.length < minLength then return nil end
  if touch.pos:dist(path.points[1]) > closeRadius then return nil end
  -- a there and back again scribble closes too, so insist on real width
  local left, right, bottom, top = boundsOfPoints(path.points)
  if (right - left) < closeRadius or (top - bottom) < closeRadius then return nil end
  local points = path.points
  self.paths[touch.id] = nil
  return points
end

function boundsOfPoints(points)
  local left, right = points[1].x, points[1].x
  local bottom, top = points[1].y, points[1].y
  for _, point in ipairs(points) do
    left = math.min(left, point.x)
    right = math.max(right, point.x)
    bottom = math.min(bottom, point.y)
    top = math.max(top, point.y)
  end
  return left, right, bottom, top
end

-- standard crossing count. points are the loop, in order.
function pointInsideLoop(point, points)
  local inside = false
  local previous = #points
  for i = 1, #points do
    local a, b = points[i], points[previous]
    if (a.y > point.y) ~= (b.y > point.y) then
      local crossing = (b.x - a.x) * (point.y - a.y) / (b.y - a.y) + a.x
      if point.x < crossing then inside = not inside end
    end
    previous = i
  end
  return inside
end

function nearLoopEdge(point, points, reach)
  local previous = points[#points]
  for _, current in ipairs(points) do
    if distanceToLineSegment(point, previous, current) <= reach then return true end
    previous = current
  end
  return false
end

function centerOfPoints(points)
  local left, right, bottom, top = boundsOfPoints(points)
  return vec2((left + right) * 0.5, (bottom + top) * 0.5)
end

function shuffleCenter()
  return vec2(WIDTH / 2, HEIGHT / 2)
end

-- Watches for a finger scrubbing back and forth starting near the middle of
-- the screen. The first leg just establishes a direction; every leg after
-- that only has to travel back past the same distance in roughly the
-- opposite direction to count as a reversal, so the size of each leg does
-- not need to match the others, and the direction can be anything, as long
-- as it keeps alternating the same way. Only the starting point has to be
-- central: once a scrub is underway it is free to wander, since that is what
-- a real back-and-forth motion does.

ScrubWatcher = class()

function ScrubWatcher:init()
  self.tracks = {}
  self.reversalsNeeded = 6
end

function ScrubWatcher:begin(touch, centerRadius)
  if touch.pos:dist(shuffleCenter()) > centerRadius then return end
  self.tracks[touch.id] = {legStart = touch.pos, dir = nil, reversals = 0}
end

function ScrubWatcher:finish(touch)
  self.tracks[touch.id] = nil
end

-- returns true once enough reversals have happened. the distance a reversal
-- has to cover is a fraction of the leg that preceded it, so a big sweeping
-- scrub is judged loosely and a small tight one still needs real travel.
-- The direction is re-read from each leg too, so a scrub that drifts round
-- as it goes still counts.
function ScrubWatcher:moved(touch, minLegLength)
  local track = self.tracks[touch.id]
  if not track then return false end
  local travel = touch.pos - track.legStart
  if not track.dir then
    if travel:len() < minLegLength then return false end
    track.dir = travel:normalize()
    track.lastLeg = travel:len()
    return false
  end
  local backwards = -travel:dot(track.dir)
  local needed = math.min(minLegLength, math.max(minLegLength * 0.35, (track.lastLeg or minLegLength) * 0.3))
  if backwards > needed then
    track.reversals = track.reversals + 1
    track.lastLeg = travel:len()
    track.legStart = touch.pos
    track.dir = travel:normalize()
    if track.reversals >= self.reversalsNeeded then
      self.tracks[touch.id] = nil
      return true
    end
  end
  return false
end

CardTouch = class()

-- how far out from the middle of a card counts as its end or its corner,
-- as a fraction of half the card. 0.6 of half is the outer fifth.
CardTouch.endZoneFraction = 0.6
CardTouch.tapSlop = 8 --a touch that moves less than this was a tap, not a drag

function CardTouch:init(lab, cardTable)
  self.lab = lab
  self.cardTable = cardTable
  self.touchMap = {}
  self.loops = LoopWatcher()
  self.scrub = ScrubWatcher()
  self.holdToPickUp = 0.5
  self.dragGain = 100
  self.dragDamping = 8
  self.carryDistance = 100
end

-- Where on a card a finger landed decides how the card behaves while dragged:
--   "end"     the outer fifth at either end. follows the finger exactly from
--             the point that was touched, free to spin, which is how you turn
--             a card deliberately
--   "middle"  squares to the nearest quarter turn and collects a stack
-- A stack's badge is handled separately, and is the one grip that drags a
-- whole pile squarely without collecting anything.
function CardTouch:grabZone(body, touchPos)
  local card = self.cardTable.cardsWithBodiesAsKeys[body]
  if not card then return "middle" end
  local grabbedAt = body:getLocalPoint(touchPos)
  if math.abs(grabbedAt.y) > (card.height * 0.5) * CardTouch.endZoneFraction then
    return "end"
  end
  return "middle"
end

-- how far apart recorded loop points are, how near the start counts as closed,
-- and how much travel a loop needs before it is taken seriously
function CardTouch:loopSizes()
  local small = math.min(WIDTH, HEIGHT)
  return small * 0.03, small * 0.13, small * 0.7
end

-- how far each leg of the scrub has to travel, and how close to the middle
-- of the screen the gesture has to start
function CardTouch:scrubSizes()
  local small = math.min(WIDTH, HEIGHT)
  return small * 0.035, small * 0.4
end

function CardTouch:touched(touch)
  if touch.state == BEGAN then
    if self:handledDealPrompt(touch) then return end
    if self:handledShelfTap(touch) then return end
    self.loops:begin(touch)
    local _, centerRadius = self:scrubSizes()
    self.scrub:begin(touch, centerRadius)
    local grabbed = self:grabBadge(touch) or self:grab(touch)
    if grabbed then self.cardTable:matchOrderTo(self.lab.bodies) end
  elseif touch.state == MOVING then
    if self:gatheredALoop(touch) then return end
    if self:scrubbedForShuffle(touch) then return end
    local hold = self.touchMap[touch.id]
    if hold then
      hold.tp = touch.pos
      self.cardTable.shelves:aimAt(hold.body)
      if hold.body.isPickerUpper then self:gatherFollowers(touch) end
    end
  elseif touch.state == ENDED or touch.state == CANCELLED then
    self.loops:finish(touch)
    self.scrub:finish(touch)
    self:letGo(touch, touch.state == ENDED)
  end
end

-- while the deal prompt is showing, a tap on the deck deals and any other
-- touch just dismisses the prompt and falls through to normal handling
function CardTouch:handledDealPrompt(touch)
  local prompt = self.cardTable.dealPrompt
  if not prompt.active then return false end
  local onDeck = prompt:touchIsOnDeck(touch)
  prompt:hide()
  if onDeck then
    self.cardTable:dealShuffledDeck()
    return true
  end
  return false
end

-- a tap on a retracted shelf reveals it (and hides whichever was showing),
-- and consumes the touch, so the tap that opens a hand never also picks a
-- card out of it. a tap on the shelf that is already open falls through, so
-- its cards can be selected and dragged. a tap anywhere else shuts it.
function CardTouch:handledShelfTap(touch)
  local shelves = self.cardTable.shelves
  local shelf = shelves:shelfAt(touch)
  if shelf and shelf ~= shelves.shownShelf then
    shelves:show(shelf)
    return true
  end
  if shelves.shownShelf and shelf ~= shelves.shownShelf then
    shelves:hideShown()
  end
  return false
end

-- the number badge is a handle for the whole pile. dragging it keeps the
-- cards square, and never collects anything else on the way, so several
-- small piles can be shuffled about the table without merging into one.
function CardTouch:grabBadge(touch)
  local stack = self.cardTable:stackAtBadge(touch.pos)
  if not stack then return nil end
  local leader = stack[#stack]
  local followers = {}
  for _, card in ipairs(stack) do
    self.cardTable.arranger:cancel(card.body)
    if card ~= leader then table.insert(followers, card.body) end
  end
  self.touchMap[touch.id] = {
    tp = touch.pos,
    body = leader.body,
    anchor = vec2(0, 0),
    startPoint = touch.pos,
    startTime = ElapsedTime,
    followers = followers,
    zone = "badge",
    lockAngle = nearestQuarterAngle(leader.body.angle),
    canStack = false
  }
  return leader.body
end

-- counts backwards through the lab's bodies so the frontmost card wins. a
-- card that is still in a shelf when grabbed stays there for now: dragHeld
-- decides moment to moment whether that is a reorder or a pull-out.
function CardTouch:grab(touch)
  local bodies = self.lab.bodies
  for i = #bodies, 1, -1 do
    local body = bodies[i]
    if body.owningClass == "card" and body.type == DYNAMIC and body:testPoint(touch.pos) then
      self.cardTable.arranger:cancel(body)
      table.remove(bodies, i)
      table.insert(bodies, body)
      local zone = self:grabZone(body, touch.pos)
      local lockAngle = nil
      if zone ~= "end" then lockAngle = nearestQuarterAngle(body.angle) end
      self.touchMap[touch.id] = {
        tp = touch.pos,
        body = body,
        anchor = body:getLocalPoint(touch.pos),
        startPoint = touch.pos,
        startTime = ElapsedTime,
        followers = {},
        shelf = body.shelf,
        zone = zone,
        lockAngle = lockAngle,
        canStack = (zone == "middle")
      }
      return body
    end
  end
  return nil
end

function CardTouch:letGo(touch, wasTapped)
  local hold = self.touchMap[touch.id]
  if not hold then return end
  local wasATap = wasTapped and hold.tp:dist(hold.startPoint) < CardTouch.tapSlop
  if hold.shelf then
    -- never left the shelf: a tap picks the card out of the fan, a drag
    -- just finishes settling everyone into their reordered slots
    if wasATap then
      local card = self.cardTable.cardsWithBodiesAsKeys[hold.body]
      if hold.shelf.selected == card then
        hold.shelf:setSelected(nil)
      else
        hold.shelf:setSelected(card)
      end
    else
      hold.shelf:layOut()
    end
    self:handOnVelocity(hold)
    self.touchMap[touch.id] = nil
    return
  end
  local held = hold.body
  local droppedInShelf = false
  if wasTapped then
    droppedInShelf = self.cardTable.shelves:dropIfAimed(held, touch.pos)
  else
    self.cardTable.shelves:clearAim()
  end
  self:handOnVelocity(hold)
  held.isPickerUpper = nil
  self.touchMap[touch.id] = nil
  -- a single tap on a loose card turns it over
  if wasATap and not droppedInShelf and held:testPoint(touch.pos) then
    local card = self.cardTable.cardsWithBodiesAsKeys[held]
    if card then card:flip() end
  end
end

-- followers keep the leader's momentum, unless something else owns them now
function CardTouch:handOnVelocity(hold)
  for _, body in ipairs(hold.followers) do
    if not body.shelf and not self.cardTable.arranger:isArranging(body) then
      body.angularVelocity = hold.body.angularVelocity
      body.linearVelocity = hold.body.linearVelocity
    end
  end
end

-- a closed loop, in either direction, gathers what it drew around into a
-- stack under the finger
function CardTouch:gatheredALoop(touch)
  local points = self.loops:moved(touch, self:loopSizes())
  if not points then return false end
  self:gatherCaughtCards(touch, points)
  return true
end

-- four back-and-forths only count if a card is actually being dragged when
-- they complete, so scrubbing empty felt does nothing
function CardTouch:scrubbedForShuffle(touch)
  local minLegLength = self:scrubSizes()
  if not self.scrub:moved(touch, minLegLength) then return false end
  if not self.touchMap[touch.id] then return false end
  self:abortHold(touch)
  self.cardTable:beginShuffle()
  return true
end

-- whatever the loop caught becomes a held stack, so it keeps travelling with
-- the finger if the touch continues, or simply stays where it gathered if the
-- touch ends right away
function CardTouch:gatherCaughtCards(touch, points)
  self:abortHold(touch)
  local caught = self.cardTable:cardsInLoop(points)
  if #caught == 0 then return end
  self.cardTable.shelves:clearAim()
  self:holdAsStack(touch, caught, centerOfPoints(points))
end

function CardTouch:abortHold(touch)
  local hold = self.touchMap[touch.id]
  if not hold then return end
  hold.body.isPickerUpper = nil
  self.touchMap[touch.id] = nil
end

-- the caught card nearest the loop's centre leads, everyone else follows,
-- exactly like a card grabbed by hand
function CardTouch:holdAsStack(touch, cards, center)
  local leader, shortest = cards[1], cards[1].body.position:dist(center)
  for _, card in ipairs(cards) do
    local d = card.body.position:dist(center)
    if d < shortest then leader, shortest = card, d end
  end
  local followers = {}
  for _, card in ipairs(cards) do
    self.cardTable.arranger:cancel(card.body)
    if card ~= leader then table.insert(followers, card.body) end
  end
  self.touchMap[touch.id] = {
    tp = touch.pos,
    body = leader.body,
    anchor = vec2(0, 0),
    startPoint = touch.pos,
    startTime = ElapsedTime,
    followers = followers,
    zone = "middle",
    lockAngle = nearestQuarterAngle(leader.body.angle),
    canStack = true
  }
  leader.body.isPickerUpper = true
end

-- a card the finger passes into, having started outside it, joins the stack
function CardTouch:gatherFollowers(touch)
  local hold = self.touchMap[touch.id]
  for _, body in ipairs(self.lab.bodies) do
    if body.owningClass == "card" and body.type == DYNAMIC and body ~= hold.body
    and not body.shelf and body:testPoint(touch.pos)
    and not body:testPoint(touch.prevPos) then
      self.cardTable.arranger:cancel(body)
      table.insert(hold.followers, body)
    end
  end
end

-- Called every frame. Held cards are pulled toward their finger with a spring,
-- which is what stops them teleporting, and turned upright as they go.

function CardTouch:update()
  for _, hold in pairs(self.touchMap) do
    self:markPickerUpper(hold)
    self:dragHeld(hold)
    self:dragFollowers(hold)
  end
end

-- a finger that lands and stays put is asking to collect a stack, unless it
-- grabbed by a corner, which is how you move one pile without merging others
function CardTouch:markPickerUpper(hold)
  if not hold.canStack then return end
  if hold.tp == hold.startPoint and ElapsedTime - hold.startTime > self.holdToPickUp then
    hold.body.isPickerUpper = true
  end
end

function CardTouch:dragHeld(hold)
  if hold.shelf then
    self:dragShelfCard(hold)
    return
  end
  local worldAnchor = hold.body:getWorldPoint(hold.anchor)
  local pull = hold.tp - worldAnchor
  local speed = hold.body:getLinearVelocityFromWorldPoint(worldAnchor)
  hold.body:applyForce(pull * self.dragGain - speed * self.dragDamping, worldAnchor)
  -- an end grab is left alone so the card can be spun; anything else is
  -- held at whichever square angle it was already nearest when picked up
  if hold.lockAngle then driveAngle(hold.body, hold.lockAngle) end
end

-- how far a card can be pulled off its shelf before that counts as taking it
-- out, rather than just sliding it along to reorder. measured against the
-- open size so the threshold does not move while the shelf is animating.
function CardTouch:shelfReleaseDistance(shelf)
  return shelf:expandedDepth() * 1.6
end

-- a card still nominally in its shelf either slides along it, reordering as
-- it goes, or gets pulled far enough out to leave the shelf behind
function CardTouch:dragShelfCard(hold)
  local shelf = hold.shelf
  local card = self.cardTable.cardsWithBodiesAsKeys[hold.body]
  if shelf:distanceFrom(hold.tp) > self:shelfReleaseDistance(shelf) then
    self.cardTable.shelves:release(hold.body)
    -- cards come out of a hand face down, so they are played hidden and
    -- turned over deliberately
    if card then card:faceDown() end
    hold.shelf = nil
    hold.body.position = hold.tp
    hold.body.linearVelocity = vec2(0, 0)
    hold.anchor = vec2(0, 0)
    hold.canStack = false
    hold.lockAngle = nearestQuarterAngle(hold.body.angle)
    return
  end
  local along = shelf:clampAlong(shelf:alongOf(hold.tp))
  shelf:reorderTo(card, along)
  hold.body.position = shelf:slotAt(along, shelf.popFor[card] or 0)
  hold.body.angle = Shelf.angleOf[shelf.edge]
  hold.body.linearVelocity = vec2(0, 0)
  hold.body.angularVelocity = 0
end

-- cards close to the leader are carried, cards that have fallen behind are
-- shoved after it
function CardTouch:dragFollowers(hold)
  for _, body in ipairs(hold.followers) do
    if body.position:dist(hold.body.position) > self.carryDistance then
      local center = body:getWorldPoint(vec2(0, 0))
      local speed = body:getLinearVelocityFromWorldPoint(center)
      local pull = hold.body:getWorldPoint(vec2(0, 0)) - center
      body:applyForce(pull * self.dragGain - speed * self.dragDamping, center)
    else
      local keepAngle = body.angle
      body.position = hold.body.position
      body.angle = keepAngle
    end
    if hold.lockAngle then driveAngle(body, hold.lockAngle) end
  end
end
