-- stylua: ignore start
import 'CoreLibs/animation.lua'
import 'CoreLibs/animator.lua'
import 'CoreLibs/easing.lua'
import 'CoreLibs/graphics.lua'
import 'CoreLibs/object.lua'
import 'CoreLibs/timer.lua'
import 'CoreLibs/ui.lua'

--- @alias XYPair {
---   x: number,
---   y: number,
--- }

--- @alias Base {
---   x: number,
---   y: number,
--- }

--- @alias Runner {
---   x: number,
---   y: number,
---   nextBase: Base,
---   prevBase: Base | nil,
---   forcedTo: Base | nil,
--- }

--- @alias Fielder {
---   x: number,
---   y: number,
---   target: XYPair | nil,
---   speed: number,
--- }

--- @alias EasingFunc fun(number, number, number, number): number

import 'announcer.lua'
import 'dbg.lua'
import 'graphics.lua'
import 'scoreboard.lua'
import 'utils.lua'
-- stylua: ignore end

local gfx <const> = playdate.graphics

local Screen <const> = {
    W = playdate.display.getWidth(),
    H = playdate.display.getHeight(),
}

local Center <const> = utils.xy(Screen.W / 2, Screen.H / 2)

local BootTune <const> = playdate.sound.sampleplayer.new("sounds/boot-tune.wav")
-- local BootTune <const> = playdate.sound.sampleplayer.new("sounds/boot-tune-organy.wav")
local TinnyBackground <const> = playdate.sound.sampleplayer.new("sounds/tinny-background.wav")
local BatCrackSound <const> = playdate.sound.sampleplayer.new("sounds/bat-crack-reverb.wav")
local GrassBackground <const> = gfx.image.new("images/game/grass.png") --[[@as pd_image]]
local Minimap <const> = gfx.image.new("images/game/minimap.png") --[[@as pd_image]]
local PlayerFrown <const> = gfx.image.new("images/game/player-frown.png") --[[@as pd_image]]
local PlayerSmile <const> = gfx.image.new("images/game/player.png") --[[@as pd_image]]
local PlayerBack <const> = gfx.image.new("images/game/player-back.png") --[[@as pd_image]]

local Glove <const> = gfx.image.new("images/game/glove.png") --[[@as pd_image]]
local GloveHoldingBall <const> = gfx.image.new("images/game/glove-holding-ball.png") --[[@as pd_image]]
local GloveSizeX, GloveSizeY <const> = Glove:getSize()
local GloveOffX, GloveOffY <const> = GloveSizeX / 2, GloveSizeY / 2

local PlayerImageBlipper <const> = blipper.new(100, "images/game/player.png", "images/game/player-lowhat.png")

local DanceBounceMs <const> = 500
local DanceBounceCount <const> = 4

local FielderDanceAnimator <const> = gfx.animator.new(1, 10, 0, utils.easingHill)
FielderDanceAnimator.repeatCount = DanceBounceCount - 1

-- selene: allow(unused_variable)
function fieldersDance()
    FielderDanceAnimator:reset(DanceBounceMs)
end

local BallOffscreen <const> = 999

local PitchFlyMs <const> = 1050
local PitchStartX <const> = 195
local PitchStartY <const>, PitchEndY <const> = 105, 240

local ballAnimatorY = gfx.animator.new(0, BallOffscreen, BallOffscreen, playdate.easingFunctions.linear)
local ballAnimatorX = gfx.animator.new(0, BallOffscreen, BallOffscreen, playdate.easingFunctions.linear)

---@alias PseudoAnimator { currentValue: fun(self): number; reset: fun(self, durationMs: number | nil) }
---@alias Pitch { x: PseudoAnimator, y: PseudoAnimator, z: PseudoAnimator | nil }

---@type Pitch[]
local Pitches <const> = {
    -- Fastball
    {
        x = gfx.animator.new(0, PitchStartX, PitchStartX, playdate.easingFunctions.linear),
        y = gfx.animator.new(PitchFlyMs / 1.3, PitchStartY, PitchEndY, playdate.easingFunctions.linear),
    },
    -- Curve ball
    {
        x = gfx.animator.new(PitchFlyMs, PitchStartX + 20, PitchStartX, utils.easingHill),
        y = gfx.animator.new(PitchFlyMs, PitchStartY, PitchEndY, playdate.easingFunctions.linear),
    },
    -- Slider
    {
        x = gfx.animator.new(PitchFlyMs, PitchStartX - 20, PitchStartX, utils.easingHill),
        y = gfx.animator.new(PitchFlyMs, PitchStartY, PitchEndY, playdate.easingFunctions.linear),
    },
    -- Wobbbleball
    {
        x = {
            currentValue = function()
                return PitchStartX + (10 * math.sin((ballAnimatorY:currentValue() - PitchStartY) / 10))
            end,
            reset = function() end,
        },
        y = gfx.animator.new(PitchFlyMs * 1.3, PitchStartY, PitchEndY, playdate.easingFunctions.linear),
    },
}

local CrankOffsetDeg <const> = 90
local BatOffset <const> = utils.xy(10, 25)

local batBase <const> = utils.xy(Center.x - 34, 215)
local batTip <const> = utils.xy(0, 0)

local TagDistance <const> = 15

local SmallestBallRadius <const> = 6

local ball <const> = {
    x = Center.x --[[@as number]],
    y = Center.y --[[@as number]],
    size = SmallestBallRadius,
    heldBy = nil --[[@type Runner | nil]],
}

local BatLength <const> = 50

local Offense <const> = {
    batting = {},
    running = {},
}

local Sides <const> = {
    offense = {},
    defense = {},
}

local offenseMode = Offense.batting

---@alias Team { score: number, benchPosition: XYPair }

---@type table<string, Team>
local teams <const> = {
    home = {
        score = 0,
        benchPosition = utils.xy(Screen.W + 10, Center.y),
    },
    away = {
        score = 0,
        benchPosition = utils.xy(-10, Center.y),
    },
}

local PlayerTeam <const> = teams.home
local battingTeam = teams.away
local outs = 0
local inning = 1

---@return boolean playerIsOnSide, boolean playerIsOnOtherSide
function playerIsOn(side)
    local ret
    if PlayerTeam == battingTeam then
        ret = side == Sides.offense
    else
        ret = side == Sides.defense
    end
    return ret, not ret
end

-- TODO? Replace this AND ballSizeAnimator with a ballHeightAnimator
-- ...that might lose some of the magic of both. Compromise available? idk
local ballFloatAnimator = gfx.animator.new(2000, -60, 0, utils.easingHill)
local BallSizeMs = 2000
local ballSizeAnimator = gfx.animator.new(BallSizeMs, 9, SmallestBallRadius, utils.easingHill)

local HitMult = 20

local deltaSeconds = 0

local First <const>, Second <const>, Third <const>, Home <const> = 1, 2, 3, 4

---@type Base[]
local Bases = {
    utils.xy(Screen.W * 0.93, Screen.H * 0.52),
    utils.xy(Screen.W * 0.47, Screen.H * 0.19),
    utils.xy(Screen.W * 0.03, Screen.H * 0.52),
    utils.xy(Screen.W * 0.474, Screen.H * 0.79),
}

-- Pseudo-base for batter to target
local RightHandedBattersBox <const> = utils.xy(Bases[Home].x - 35, Bases[Home].y)

---@type table<Base, Base | nil>
local NextBaseMap <const> = {
    [RightHandedBattersBox] = nil, -- Runner should not escape the box before a hit!
    [Bases[First]] = Bases[Second],
    [Bases[Second]] = Bases[Third],
    [Bases[Third]] = Bases[Home],
}

---@param name string
---@param speed number
---@return Fielder
function newFielder(name, speed)
    return {
        name = name,
        speed = speed,
    }
end

---@type table<string, Fielder>
local fielders <const> = {
    first = newFielder("First", 40),
    second = newFielder("Second", 40),
    shortstop = newFielder("Shortstop", 40),
    third = newFielder("Third", 40),
    pitcher = newFielder("Pitcher", 30),
    catcher = newFielder("Catcher", 35),
    left = newFielder("Left", 40),
    center = newFielder("Center", 40),
    right = newFielder("Right", 40),
}

local PitcherStartPos <const> = {
    x = Screen.W * 0.48,
    y = Screen.H * 0.40,
}

--- Actually only benches the infield, because outfielders are far away!
---@param position XYPair
function benchAllFielders(position)
    fielders.first.target = position
    fielders.second.target = position
    fielders.shortstop.target = position
    fielders.third.target = position
    fielders.pitcher.target = position
    fielders.catcher.target = position
end

--- Resets the target positions of all fielders to their defaults (at their field positions).
---@param fromOffTheField XYPair | nil If provided, also sets all runners' current position to one centralized location.
function resetFielderPositions(fromOffTheField)
    if fromOffTheField then
        for _, fielder in pairs(fielders) do
            fielder.x = fromOffTheField.x
            fielder.y = fromOffTheField.y
        end
    end

    fielders.first.target = utils.xy(Screen.W - 65, Screen.H * 0.48)
    fielders.second.target = utils.xy(Screen.W * 0.70, Screen.H * 0.30)
    fielders.shortstop.target = utils.xy(Screen.W * 0.30, Screen.H * 0.30)
    fielders.third.target = utils.xy(Screen.W * 0.1, Screen.H * 0.48)
    fielders.pitcher.target = utils.xy(PitcherStartPos.x, PitcherStartPos.y)
    fielders.catcher.target = utils.xy(Screen.W * 0.475, Screen.H * 0.92)
    fielders.left.target = utils.xy(Screen.W * -1, Screen.H * -0.2)
    fielders.center.target = utils.xy(Center.x, Screen.H * -0.4)
    fielders.right.target = utils.xy(Screen.W * 2, fielders.left.target.y)
end

local BatterStartingX <const> = Bases[Home].x - 40
local BatterStartingY <const> = Bases[Home].y - 3

--- @type Runner[]
local runners <const> = {}

--- @type Runner[]
local outRunners <const> = {}

---@return Runner
function newRunner()
    local new = {
        x = BatterStartingX - 60,
        y = BatterStartingY + 60,
        nextBase = RightHandedBattersBox,
        prevBase = nil,
        forcedTo = Bases[First],
    }
    runners[#runners + 1] = new
    return new
end

---@type Runner | nil
local batter = newRunner()

local throwMeter = 0
local PitchMeterLimit = 15

--- "Throws" the ball from its current position to the given destination.
---@param destX number
---@param destY number
---@param easingFunc EasingFunc
---@param flyTimeMs number | nil
---@param floaty boolean | nil
---@param customBallScaler pd_animator | nil
function throwBall(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
    if not flyTimeMs then
        flyTimeMs = utils.distanceBetween(ball.x, ball.y, destX, destY) * 5
    end
    ball.heldBy = nil
    if customBallScaler then
        ballSizeAnimator = customBallScaler
    else
        -- TODO? Scale based on distance?
        ballSizeAnimator = gfx.animator.new(flyTimeMs, 9, SmallestBallRadius, utils.easingHill)
    end
    ballAnimatorY = gfx.animator.new(flyTimeMs, ball.y, destY, easingFunc)
    ballAnimatorX = gfx.animator.new(flyTimeMs, ball.x, destX, easingFunc)
    if floaty then
        ballFloatAnimator:reset(flyTimeMs)
    end
    throwMeter = 0
end

local PitchAfterSeconds = 7
-- TODO: Replace with a timer, repeatedly reset instead of setting to 0
local secondsSincePitchAllowed = -5

local catcherThrownBall = false

---@param pitchFlyTimeMs number | nil
---@param pitchTypeIndex number | nil
function pitch(pitchFlyTimeMs, pitchTypeIndex)
    catcherThrownBall = false
    offenseMode = Offense.batting

    local current = Pitches[pitchTypeIndex]
    ballAnimatorX = current.x
    ballAnimatorY = current.y or Pitches[1].y

    -- TODO: This would need to be sanely replaced in throwBall() etc.
    -- if current.z then
    -- 	ballFloatAnimator = current.z
    -- 	ballFloatAnimator:reset()
    -- end

    if pitchFlyTimeMs then
        ballAnimatorX:reset(pitchFlyTimeMs)
        ballAnimatorY:reset(pitchFlyTimeMs)
    else
        ballAnimatorX:reset()
        ballAnimatorY:reset()
    end

    secondsSincePitchAllowed = 0
end

local BaseHitbox = 10

--- Returns the base being touched by the player at (x,y), or nil, if no base is being touched
---@param x number
---@param y number
---@return Base | nil
function isTouchingBase(x, y)
    return utils.first(Bases, function(base)
        return utils.distanceBetween(x, y, base.x, base.y) < BaseHitbox
    end)
end

local BallCatchHitbox = 3

--- Returns true only if the given point is touching the ball at its current position
---@param x number
---@param y number
---@return boolean, number
function isTouchingBall(x, y)
    local ballDistance = utils.distanceBetween(x, y, ball.x, ball.y)
    return ballDistance < BallCatchHitbox, ballDistance
end

---@param base Base
---@return Runner | nil
function getRunnerWithNextBase(base)
    return utils.first(runners, function(runner)
        return runner.nextBase == base
    end)
end

function updateForcedRunners()
    local stillForced = true
    for _, base in ipairs(Bases) do
        local runnerTargetingBase = getRunnerWithNextBase(base)
        if runnerTargetingBase then
            if stillForced then
                runnerTargetingBase.forcedTo = base
            else
                runnerTargetingBase.forcedTo = nil
            end
        else
            stillForced = false
        end
    end
end

local ResetFieldersAfterSeconds <const> = 5
-- TODO: Replace with a timer, repeatedly reset, instead of setting to 0
local secondsSinceLastRunnerMove = 0

---@param runnerIndex integer
function outRunner(runnerIndex)
    outRunners[#outRunners + 1] = runners[runnerIndex]
    table.remove(runners, runnerIndex)

    outs = outs + 1
    updateForcedRunners()

    announcer:say("YOU'RE OUT!")
    if outs == 3 then
        local currentlyFieldingTeam = battingTeam == teams.home and teams.away or teams.home
        local gameOver = inning == 9 and teams.away.score ~= teams.home.score
        if not gameOver then
            fieldersDance()
            secondsSinceLastRunnerMove = -7
            benchAllFielders(currentlyFieldingTeam.benchPosition)
            announcer:say("SWITCHING SIDES...")
        end
        while #runners > 0 do
            outRunners[#outRunners + 1] = table.remove(runners, #runners)
        end
        -- Delay to keep end-of-inning on the scoreboard for a few seconds
        playdate.timer.new(3000, function()
            outs = 0
            battingTeam = currentlyFieldingTeam
            if gameOver then
                announcer:say("AND THAT'S THE BALL GAME!")
            else
                if battingTeam == teams.home then
                    inning = inning + 1
                end
            end
        end)
    end
end

---@param runnerIndex number
function score(runnerIndex)
    outRunners[#outRunners + 1] = runners[runnerIndex]
    table.remove(runners, runnerIndex)
    battingTeam.score = battingTeam.score + 1
    announcer:say("SCORE!")
end

---@return Base[]
function getForcedOutTargets()
    local targets = {}
    for _, base in ipairs(Bases) do
        local runnerTargetingBase = getRunnerWithNextBase(base)
        if runnerTargetingBase then
            targets[#targets + 1] = base
        else
            return targets
        end
    end
    return targets
end

--- Returns the position,distance of the basest closest to the runner furthest from a base
---@return Base | nil, number | nil
function getBaseOfStrandedRunner()
    local farRunnersBase, farDistance
    for _, runner in pairs(runners) do
        if runner ~= batter then
            local nearestBase, distance = utils.getNearestOf(Bases, runner.x, runner.y)
            if farRunnersBase == nil or farDistance < distance then
                farRunnersBase = nearestBase
                farDistance = distance
            end
        end
    end

    return farRunnersBase, farDistance
end

--- Returns x,y of the out target
---@return number|nil, number|nil
function getNextOutTarget()
    -- TODO: Handle missed throws, check for fielders at target, etc.
    local targets = getForcedOutTargets()
    if #targets ~= 0 then
        return targets[#targets].x, targets[#targets].y
    end

    local baseCloseToStrandedRunner = getBaseOfStrandedRunner()
    if baseCloseToStrandedRunner then
        return baseCloseToStrandedRunner.x, baseCloseToStrandedRunner.y
    end
end

---@param fielder Fielder
function tryToMakeAnOut(fielder)
    local targetX, targetY = getNextOutTarget()
    if targetX ~= nil and targetY ~= nil then
        local nearestFielder = utils.getNearestOf(fielders, targetX, targetY)
        nearestFielder.target = utils.xy(targetX, targetY)
        if nearestFielder == fielder then
            ball.heldBy = fielder
        else
            throwBall(targetX, targetY, playdate.easingFunctions.linear, nil, true)
        end
    end
end

function readThrow()
    if throwMeter > PitchMeterLimit then
        return (PitchFlyMs / (throwMeter / PitchMeterLimit))
    end
    return nil
end

---@param thrower Fielder
---@param throwFlyMs number
---@return boolean didThrow
function buttonControlledThrow(thrower, throwFlyMs, forbidThrowHome)
    local targetBase
    if playdate.buttonIsPressed(playdate.kButtonLeft) then
        targetBase = Bases[Third]
    elseif playdate.buttonIsPressed(playdate.kButtonUp) then
        targetBase = Bases[Second]
    elseif playdate.buttonIsPressed(playdate.kButtonRight) then
        targetBase = Bases[First]
    elseif not forbidThrowHome and playdate.buttonIsPressed(playdate.kButtonDown) then
        targetBase = Bases[Home]
    else
        return false
    end

    local closestFielder = utils.getNearestOf(fielders, targetBase.x, targetBase.y, function(fielder)
        return fielder ~= thrower
    end)

    throwBall(targetBase.x, targetBase.y, playdate.easingFunctions.linear, throwFlyMs)
    closestFielder.target = targetBase
    secondsSinceLastRunnerMove = 0
    offenseMode = Offense.running

    return true
end

function outEligibleRunners(fielder)
    local touchedBase = isTouchingBase(fielder.x, fielder.y)
    local didOutRunner = false
    for i, runner in pairs(runners) do
        local runnerOnBase = isTouchingBase(runner.x, runner.y)
        if -- Force out
            touchedBase
                and runner.prevBase -- Make sure the runner is not standing at home
                and runner.forcedTo == touchedBase
                and touchedBase ~= runnerOnBase
            -- Tag out
            or not runnerOnBase and utils.distanceBetween(runner.x, runner.y, fielder.x, fielder.y) < TagDistance
        then
            outRunner(i)
            didOutRunner = true
        end
    end

    return didOutRunner
end

function updateNpcFielder(fielder, outedSomeRunner)
    if offenseMode ~= Offense.running then
        return
    end
    if outedSomeRunner then
        playdate.timer.new(750, function()
            tryToMakeAnOut(fielder)
        end)
    else
        tryToMakeAnOut(fielder)
    end
end

---@param fielder Fielder
function updateFielder(fielder)
    if fielder.target ~= nil then
        if not utils.moveAtSpeed(fielder, fielder.speed * deltaSeconds, fielder.target) then
            fielder.target = nil
        end
    end

    if not isTouchingBall(fielder.x, fielder.y) then
        return
    end

    local outedSomeRunner = outEligibleRunners(fielder)

    if playerIsOn(Sides.defense) then
        local throwFly = readThrow()
        if throwFly then
            buttonControlledThrow(fielders.pitcher, throwFly)
        end
    else
        updateNpcFielder(fielder, outedSomeRunner)
    end
end

--- Returns true only if the given runner moved during this update.
---@param runner Runner | nil
---@param runnerIndex integer | nil May only be nil if runner == batter
---@param appliedSpeed number
---@return boolean
function updateRunner(runner, runnerIndex, appliedSpeed)
    local autoRunSpeed = 20 * deltaSeconds
    --autoRunSpeed = 140

    if not runner or not runner.nextBase then
        return false
    end

    local nearestBase, nearestBaseDistance = utils.getNearestOf(Bases, runner.x, runner.y)

    if
        nearestBaseDistance < 5
        and runnerIndex ~= nil
        and runner ~= batter --runner.prevBase
        and runner.nextBase == Bases[Home]
        and nearestBase == Bases[Home]
    then
        score(runnerIndex)
    end

    local nb = runner.nextBase
    local x, y, distance = utils.normalizeVector(runner.x, runner.y, nb.x, nb.y)

    if distance < 2 then
        runner.nextBase = NextBaseMap[runner.nextBase]
        runner.forcedTo = nil
        return false
    end

    local prevX, prevY = runner.x, runner.y
    local mult = 1
    if appliedSpeed < 0 then
        if runner.prevBase then
            mult = -1
        else
            -- Don't allow running backwards when approaching the plate
            appliedSpeed = 0
        end
    end

    local autoRun = (nearestBaseDistance > 40 or runner.forcedTo) and mult * autoRunSpeed
        or nearestBaseDistance < 5 and 0
        or (nearestBase == runner.nextBase and autoRunSpeed or -1 * autoRunSpeed)
    mult = autoRun + (appliedSpeed / 20)
    runner.x = runner.x - (x * mult)
    runner.y = runner.y - (y * mult)

    return prevX ~= runner.x or prevY ~= runner.y
end

---@type number
local batAngleDeg

-- Used for tracking whether or not a pitch was a strike
local recordedPitchX = nil
local balls = 0
local strikes = 0

local StrikeZoneStartX <const> = Center.x - 16
local StrikeZoneEndX <const> = StrikeZoneStartX + 24
local StrikeZoneStartY <const> = Screen.H - 35

function recordStrikePosition()
    if not recordedPitchX and ball.y > StrikeZoneStartY then
        recordedPitchX = ball.x
    end
end

function nextBatter()
    playdate.timer.new(2000, function()
        balls = 0
        strikes = 0
        batter = newRunner()
    end)
end

function walk()
    -- TODO
    nextBatter()
end

function strikeOut()
    -- TODO
    nextBatter()
end

function recordPitch()
    if recordedPitchX > StrikeZoneStartX and recordedPitchX < StrikeZoneEndX then
        strikes = strikes + 1
        if strikes >= 3 then
            strikeOut()
        end
    else
        balls = balls + 1
        if balls >= 4 then
            walk()
        end
    end
end

---@param batDeg number
function updateBatting(batDeg, batSpeed)
    if ball.y < BallOffscreen then
        ball.y = ballAnimatorY:currentValue() + ballFloatAnimator:currentValue()
        ball.size = SmallestBallRadius -- ballFloatAnimator:currentValue()
    end

    local batAngle = math.rad(batDeg)
    -- TODO: animate bat-flip or something
    batBase.x = batter and (batter.x + BatOffset.x) or 0
    batBase.y = batter and (batter.y + BatOffset.y) or 0
    batTip.x = batBase.x + (BatLength * math.sin(batAngle))
    batTip.y = batBase.y + (BatLength * math.cos(batAngle))

    if
        batSpeed > 0
        and utils.pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, Screen.H)
        and ball.y < 232 --not isTouchingBall(fielders.catcher.x, fielders.catcher.y)
    then
        BatCrackSound:play()
        offenseMode = Offense.running
        local ballAngle = batAngle + math.rad(90)

        local mult = math.abs(batSpeed / 15)
        local ballVelX = mult * 10 * math.sin(ballAngle)
        local ballVelY = mult * 5 * math.cos(ballAngle)
        if ballVelY > 0 then
            ballVelX = ballVelX * -1
            ballVelY = ballVelY * -1
        end
        local ballDestX = ball.x + (ballVelX * HitMult)
        local ballDestY = ball.y + (ballVelY * HitMult)
        -- Hit!
        throwBall(
            ballDestX,
            ballDestY,
            playdate.easingFunctions.outQuint,
            2000,
            nil,
            gfx.animator.new(2000, 9 + (mult * mult * 0.5), SmallestBallRadius, utils.easingHill)
        )

        fielders.first.target = Bases[First]
        batter.nextBase = Bases[First]
        batter.prevBase = Bases[Home]
        updateForcedRunners()
        batter.forcedTo = Bases[First]
        batter = nil -- Demote batter to a mere runner

        local chasingFielder = utils.getNearestOf(fielders, ballDestX, ballDestY)
        chasingFielder.target = { x = ballDestX, y = ballDestY }
    end
end

--- Update non-batter runners.
--- Returns true only if at least one of the given runners moved during this update
---@param appliedSpeed number
---@return boolean
function updateRunning(appliedSpeed)
    ball.size = ballSizeAnimator:currentValue()

    local runnerMoved = false

    -- TODO: Filter for the runner closest to the currently-held direction button
    for runnerIndex, runner in ipairs(runners) do
        if runner ~= batter then
            runnerMoved = updateRunner(runner, runnerIndex, appliedSpeed) or runnerMoved
        end
    end

    return runnerMoved
end

function walkAwayOutRunners()
    for i, runner in ipairs(outRunners) do
        if runner.x < Screen.W + 50 and runner.y < Screen.H + 50 then
            runner.x = runner.x + (deltaSeconds * 25)
            runner.y = runner.y + (deltaSeconds * 25)
        else
            table.remove(outRunners, i)
        end
    end
end

function playerPitch(throwFly)
    local aButton = playdate.buttonIsPressed(playdate.kButtonA)
    local bButton = playdate.buttonIsPressed(playdate.kButtonB)
    if not aButton and not bButton then
        pitch(throwFly, 1)
    elseif aButton and not bButton then
        pitch(throwFly, 2)
    elseif not aButton and bButton then
        pitch(throwFly, 3)
    elseif aButton and bButton then
        pitch(throwFly, 4)
    end
end

local npcBatDeg = 0
local BaseNpcBatSpeed <const> = 1500
local npcBatSpeed = 1500

function npcBatAngle()
    if not catcherThrownBall and ball.y > 200 and ball.y < 230 and (ball.x < Center.x + 15) then
        npcBatDeg = npcBatDeg + (deltaSeconds * npcBatSpeed)
    else
        npcBatSpeed = (1 + math.random()) * BaseNpcBatSpeed
        npcBatDeg = 200
    end
    return npcBatDeg
end

function npcBatChange()
    return deltaSeconds * npcBatSpeed
end

function npcRunningSpeed()
    if #runners == 0 then
        return 0
    end
    local touchedBase = isTouchingBase(runners[1].x, runners[1].y)
    if not touchedBase or touchedBase == Bases[Home] then
        return 10
    end
    return 0
end

function updateGameState()
    deltaSeconds = playdate.getElapsedTime() or 0
    playdate.resetElapsedTime()
    local crankChange = playdate.getCrankChange() --[[@as number, number]]

    if ball.heldBy then
        ball.x = ball.heldBy.x
        ball.y = ball.heldBy.y
    else
        ball.x = ballAnimatorX:currentValue()
        ball.y = ballAnimatorY:currentValue() + ballFloatAnimator:currentValue()
    end

    local playerOnOffense, playerOnDefense = playerIsOn(Sides.offense)

    if playerOnDefense then
        throwMeter = math.max(0, throwMeter - (deltaSeconds * 150))
        throwMeter = throwMeter + math.abs(crankChange)
    end

    if offenseMode == Offense.batting then
        recordStrikePosition()
        secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds

        if secondsSincePitchAllowed > 3.5 and not catcherThrownBall then
            recordPitch()
            throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, true)
            catcherThrownBall = true
        end

        local batSpeed
        if playerOnOffense then
            batAngleDeg, batSpeed = (playdate.getCrankPosition() + CrankOffsetDeg) % 360, crankChange
        else
            batAngleDeg, batSpeed = npcBatAngle(), npcBatChange()
        end

        updateBatting(batAngleDeg, batSpeed)

        -- TODO: Ensure batter can't be nil, here
        updateRunner(batter, nil, crankChange)

        if secondsSincePitchAllowed > PitchAfterSeconds then
            if playerOnDefense then
                local throwFly = readThrow()
                if throwFly and not buttonControlledThrow(fielders.pitcher, throwFly, true) then
                    playerPitch(throwFly)
                end
            else
                pitch(PitchFlyMs, math.random(#Pitches))
            end
        end
    elseif offenseMode == Offense.running then
        local appliedSpeed = playerOnOffense and crankChange or npcRunningSpeed()
        if updateRunning(appliedSpeed) then
            secondsSinceLastRunnerMove = 0
        else
            secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds
            if secondsSinceLastRunnerMove > ResetFieldersAfterSeconds then
                throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, true)
                resetFielderPositions()
                offenseMode = Offense.batting
                if not batter then
                    batter = newRunner()
                end
            end
        end
    end

    for _, fielder in pairs(fielders) do
        updateFielder(fielder)
    end
    walkAwayOutRunners()
end

local MinimapSizeX, MinimapSizeY <const> = Minimap:getSize()
local MinimapPosX, MinimapPosY = Screen.W - MinimapSizeX, Screen.H - MinimapSizeY

local FieldHeight <const> = Bases[Home].y - Bases[Second].y

local MinimapMultX <const> = 0.75 * MinimapSizeX / Screen.W
local MinimapOffsetX <const> = MinimapPosX + 5
local MinimapMultY <const> = 0.70 * MinimapSizeY / FieldHeight
local MinimapOffsetY <const> = MinimapPosY - 15

function drawMinimap()
    Minimap:draw(MinimapPosX, MinimapPosY)
    gfx.setColor(gfx.kColorBlack)
    for _, runner in pairs(runners) do
        local x = (MinimapMultX * runner.x) + MinimapOffsetX
        local y = (MinimapMultY * runner.y) + MinimapOffsetY
        gfx.fillRect(x, y, 8, 8)
    end
end

---@param fielder Fielder
---@return boolean isHoldingBall
function drawFielderGlove(fielder)
    local distanceFromBall =
        utils.distanceBetweenZ(fielder.x, fielder.y, 0, ball.x, ball.y, ballFloatAnimator:currentValue())
    local shoulderX, shoulderY = fielder.x + 10, fielder.y + FielderDanceAnimator:currentValue() + 5
    if distanceFromBall > 20 then
        Glove:draw(shoulderX, shoulderY)
        return false
    else
        GloveHoldingBall:draw(ball.x - GloveOffX, ball.y - GloveOffY)
        return true
    end
end

function playdate.update()
    playdate.timer.updateTimers()
    gfx.animation.blinker.updateAll()
    updateGameState()

    gfx.clear()
    gfx.setColor(gfx.kColorBlack)

    local offsetX, offsetY = 0, 0
    if ball.x < BallOffscreen then
        offsetX, offsetY = getDrawOffset(Screen.W, Screen.H, ball.x, ball.y)
        gfx.setDrawOffset(offsetX, offsetY)
    end

    GrassBackground:draw(-400, -240)

    local fielderDanceHeight = FielderDanceAnimator:currentValue()
    local ballIsHeld = false
    for _, fielder in pairs(fielders) do
        local fielderY = fielder.y + fielderDanceHeight
        gfx.fillRect(fielder.x, fielderY, 14, 25)
        ballIsHeld = drawFielderGlove(fielder) or ballIsHeld
    end

    if offenseMode == Offense.batting then
        gfx.setLineWidth(5)
        gfx.drawLine(batBase.x, batBase.y, batTip.x, batTip.y)
    end

    if playdate.isCrankDocked() then
        playdate.ui.crankIndicator:draw()
    end

    -- TODO? Scale sprites down as y increases
    for _, runner in pairs(runners) do
        if runner == batter then
            if batAngleDeg > 50 and batAngleDeg < 200 then
                PlayerBack:draw(runner.x, runner.y)
            else
                PlayerSmile:draw(runner.x, runner.y)
            end
        else
            -- TODO? Change blip speed depending on runner speed?
            PlayerImageBlipper:draw(false, runner.x, runner.y)
        end
    end
    for _, runner in pairs(outRunners) do
        PlayerFrown:draw(runner.x, runner.y)
    end

    if not ballIsHeld then
        gfx.setLineWidth(2)

        gfx.setColor(gfx.kColorWhite)
        gfx.fillCircleAtPoint(ball.x, ball.y, ball.size)

        gfx.setColor(gfx.kColorBlack)
        gfx.drawCircleAtPoint(ball.x, ball.y, ball.size)
    end

    gfx.setDrawOffset(0, 0)
    if math.abs(offsetX) > 10 or math.abs(offsetY) > 10 then
        drawMinimap()
    end
    drawScoreboard(0, Screen.H * 0.77, teams, outs, battingTeam, inning)
    drawBallsAndStrikes(300, Screen.H * 0.77, balls, strikes)
    announcer:draw(Center.x, 10)
end

function init()
    playdate.display.setRefreshRate(50)
    gfx.setBackgroundColor(gfx.kColorWhite)
    playdate.setMenuImage(gfx.image.new("images/game/menu-image.png"))
    resetFielderPositions(teams.home.benchPosition)
    playdate.getSystemMenu():addMenuItem("Restart game", function() end) -- TODO?

    playdate.timer.new(2000, function()
        throwBall(PitchStartX, PitchStartY, playdate.easingFunctions.linear, nil, false)
    end)
    BootTune:play()
    BootTune:setFinishCallback(function()
        TinnyBackground:play()
    end)
end

init()