-- 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'
-- stylua: ignore end

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

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

--- @alias LaunchBall fun(destX: number, destY: number, easingFunc: EasingFunc, flyTimeMs: number | nil, floaty: boolean | nil, customBallScaler: pd_animator | nil)

-- stylua: ignore start
import 'utils.lua'
import 'constants.lua'
import 'assets.lua'

import 'action-queue.lua'
import 'announcer.lua'
import 'baserunning.lua'
import 'dbg.lua'
import 'fielding.lua'
import 'graphics.lua'
import 'npc.lua'
import 'draw/overlay.lua'
import 'draw/fielder.lua'
-- stylua: ignore end

-- selene: allow(shadowing)
local gfx <const>, C <const> = playdate.graphics, C

local announcer = Announcer:new()
local baserunning = Baserunning.new(announcer)
local fielding = Fielding.new()

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

local deltaSeconds = 0

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

    xAnimator = utils.staticAnimator(C.BallOffscreen),
    yAnimator = utils.staticAnimator(C.BallOffscreen),

    -- TODO? Replace these with a ballAnimatorZ?
    -- ...that might lose some of the magic of both. Compromise available? idk
    sizeAnimator = utils.staticAnimator(C.SmallestBallRadius),
    floatAnimator = gfx.animator.new(2000, -60, 0, utils.easingHill),
}

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

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

local PlayerTeam <const> = teams.away
local battingTeam = teams.away
local outs = 0
local inning = 1
local offenseState = C.Offense.batting

local throwMeter = 0

-- TODO: Replace with a timer, repeatedly reset, instead of setting to 0
local secondsSinceLastRunnerMove = 0

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

local BatterHandPos <const> = utils.xy(10, 25)

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

local batAngleDeg = C.CrankOffsetDeg

local PlayerImageBlipper <const> = blipper.new(100, Player, PlayerLowHat)

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

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

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

--- Launches 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
local function launchBall(destX, destY, easingFunc, flyTimeMs, floaty, customBallScaler)
    ball.heldBy = nil
    throwMeter = 0

    if not flyTimeMs then
        flyTimeMs = utils.distanceBetween(ball.x, ball.y, destX, destY) * 5
    end

    if customBallScaler then
        ball.sizeAnimator = customBallScaler
    else
        -- TODO? Scale based on distance?
        ball.sizeAnimator = gfx.animator.new(flyTimeMs, 9, C.SmallestBallRadius, utils.easingHill)
    end
    ball.yAnimator = gfx.animator.new(flyTimeMs, ball.y, destY, easingFunc)
    ball.xAnimator = gfx.animator.new(flyTimeMs, ball.x, destX, easingFunc)
    if floaty then
        ball.floatAnimator:reset(flyTimeMs)
    end
end

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

    local current = Pitches[pitchTypeIndex]
    ball.xAnimator = current.x
    ball.yAnimator = current.y or Pitches[1].y

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

    if pitchFlyTimeMs then
        ball.xAnimator:reset(pitchFlyTimeMs)
        ball.yAnimator:reset(pitchFlyTimeMs)
    else
        ball.xAnimator:reset()
        ball.yAnimator:reset()
    end

    secondsSincePitchAllowed = 0
end

---@param runner integer | Runner
---@param message string | nil
local function outRunner(runner, message)
    baserunning:outRunner(runner, message)
    if baserunning.outs < 3 then
        return
    end

    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
        FielderDanceAnimator:reset(C.DanceBounceMs)
        secondsSinceLastRunnerMove = -7
        fielding:benchTo(currentlyFieldingTeam.benchPosition)
        announcer:say("SWITCHING SIDES...")
    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
            fielding:resetFielderPositions()
            if battingTeam == teams.home then
                inning = inning + 1
            end
        end
    end)

end

---@param scoredRunCount number
local function score(scoredRunCount)
    battingTeam.score = battingTeam.score + scoredRunCount
    announcer:say("SCORE!")
end

local function readThrow()
    if throwMeter > C.ThrowMeterMax then
        return (C.PitchFlyMs / (throwMeter / C.ThrowMeterMax))
    end
    return nil
end

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

    -- Power for this throw has already been determined
    throwMeter = 0

    fielding:playerThrowTo(targetBase, launchBall, throwFlyMs)
    secondsSinceLastRunnerMove = 0
    offenseState = C.Offense.running

    return true
end

local function nextBatter()
    baserunning.batter = nil
    playdate.timer.new(2000, function()
        pitchTracker:reset()
        if not baserunning.batter then
            baserunning.batter = baserunning:newRunner()
        end
    end)
end

local function walk()
    announcer:say("Walk!")
    baserunning.batter.nextBase = C.Bases[C.First]
    baserunning.batter.prevBase = C.Bases[C.Home]
    offenseState = C.Offense.walking
    baserunning.batter = nil
    baserunning:updateForcedRunners()
    nextBatter()
end

local function strikeOut()
    local outBatter = baserunning.batter
    baserunning.batter = nil
    outRunner(outBatter --[[@as Runner]], "Strike out!")
    nextBatter()
end

---@param batDeg number
local function updateBatting(batDeg, batSpeed)
    local batAngle = math.rad(batDeg)
    -- TODO: animate bat-flip or something
    batBase.x = baserunning.batter and (baserunning.batter.x + BatterHandPos.x) or 0
    batBase.y = baserunning.batter and (baserunning.batter.y + BatterHandPos.y) or 0
    batTip.x = batBase.x + (C.BatLength * math.sin(batAngle))
    batTip.y = batBase.y + (C.BatLength * math.cos(batAngle))

    if
        batSpeed > 0
        and utils.pointDirectlyUnderLine(ball.x, ball.y, batBase.x, batBase.y, batTip.x, batTip.y, C.Screen.H)
        and ball.y < 232
    then
        BatCrackReverb:play()
        offenseState = C.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 * C.BattingPower)
        local ballDestY = ball.y + (ballVelY * C.BattingPower)
        -- Hit!
        local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill)
        launchBall(ballDestX, ballDestY, playdate.easingFunctions.outQuint, 2000, nil, hitBallScaler)

        baserunning.batter.nextBase = C.Bases[C.First]
        baserunning.batter.prevBase = C.Bases[C.Home]
        baserunning:updateForcedRunners()
        baserunning.batter.forcedTo = C.Bases[C.First]
        baserunning.batter = nil -- Demote batter to a mere runner

        fielding:haveSomeoneChase(ballDestX, ballDestY)
    end
end

---@param appliedSpeed number
---@return boolean someRunnerMoved
local function updateNonBatterRunners(appliedSpeed, forcedOnly)
    local runnerMoved, runnersScored = baserunning:updateRunning(appliedSpeed, forcedOnly, deltaSeconds)
    if runnersScored ~= 0 then
        score(runnersScored)
    end
    return runnerMoved
end

local 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 function updateGameState()
    deltaSeconds = playdate.getElapsedTime() or 0
    playdate.resetElapsedTime()
    local crankChange = playdate.getCrankChange() --[[@as number]]
    local crankLimited = crankChange == 0 and 0 or (math.log(math.abs(crankChange)) * C.CrankPower)
    if crankChange < 0 then
        crankLimited = crankLimited * -1
    end

    if ball.heldBy then
        ball.x = ball.heldBy.x
        ball.y = ball.heldBy.y
        ball.size = C.SmallestBallRadius
    else
        ball.x = ball.xAnimator:currentValue()
        ball.z = ball.floatAnimator:currentValue()
        ball.y = ball.yAnimator:currentValue() + ball.z
        ball.size = ball.sizeAnimator:currentValue()
    end

    local playerOnOffense, playerOnDefense = playerIsOn(C.Sides.offense)

    if playerOnDefense then
        throwMeter = math.max(0, throwMeter - (deltaSeconds * C.ThrowMeterDrainPerSec))
        throwMeter = throwMeter + math.abs(crankLimited * C.PitchPower)
    end

    if offenseState == C.Offense.batting then
        if ball.y < C.StrikeZoneStartY then
            pitchTracker.recordedPitchX = nil
        elseif not pitchTracker.recordedPitchX then
            pitchTracker.recordedPitchX = ball.x
        end

        local pitcher = fielding.fielders.pitcher
        if utils.distanceBetween(pitcher.x, pitcher.y, C.PitchStartX, C.PitchStartY) < C.BaseHitbox then
            secondsSincePitchAllowed = secondsSincePitchAllowed + deltaSeconds
        end

        if secondsSincePitchAllowed > 3.5 and not catcherThrownBall then
            local outcome = pitchTracker:updatePitchCounts()
            if outcome == PitchOutcomes.StrikeOut then
                strikeOut()
            elseif outcome == PitchOutcomes.Walk then
                walk()
            end
            launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
            catcherThrownBall = true
        end

        local batSpeed
        if playerOnOffense then
            batAngleDeg = (playdate.getCrankPosition() + C.CrankOffsetDeg) % 360
            batSpeed = crankLimited
        else
            batAngleDeg = npc.updateBatAngle(ball, catcherThrownBall, deltaSeconds)
            batSpeed = npc.batSpeed() * deltaSeconds
        end

        updateBatting(batAngleDeg, batSpeed)

        -- Walk batter to the plate
        -- TODO: Ensure batter can't be nil, here
        baserunning:updateRunner(baserunning.batter, nil, crankLimited, deltaSeconds)

        if secondsSincePitchAllowed > C.PitchAfterSeconds then
            if playerOnDefense then
                local throwFly = readThrow()
                if throwFly and not buttonControlledThrow(throwFly, true) then
                    playerPitch(throwFly)
                end
            else
                pitch(C.PitchFlyMs, math.random(#Pitches))
            end
        end
    elseif offenseState == C.Offense.running then
        local appliedSpeed = playerOnOffense and crankLimited or npc.runningSpeed(baserunning.runners)
        if updateNonBatterRunners(appliedSpeed) then
            secondsSinceLastRunnerMove = 0
        else
            secondsSinceLastRunnerMove = secondsSinceLastRunnerMove + deltaSeconds
            if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
                launchBall(C.PitchStartX, C.PitchStartY, playdate.easingFunctions.linear, nil, true)
                fielding:resetFielderPositions()
                offenseState = C.Offense.batting
                if not baserunning.batter then
                    baserunning.batter = baserunning:newRunner()
                end
            end
        end
    elseif offenseState == C.Offense.walking then
        if not updateNonBatterRunners(C.WalkedRunnerSpeed, true) then
            offenseState = C.Offense.batting
        end
    end

    local fielderHoldingBall = fielding:updateFielderPositions(ball, deltaSeconds)

    if playerOnDefense then
        local throwFly = readThrow()
        if throwFly then
            buttonControlledThrow(throwFly)
        end
    end
    if fielderHoldingBall then
        local outedSomeRunner = baserunning:outEligibleRunners(fielderHoldingBall)
        if playerOnOffense then
            npc.fielderAction(fielding, baserunning, offenseState, fielderHoldingBall, outedSomeRunner, ball, launchBall)
        end
    end

    baserunning:walkAwayOutRunners(deltaSeconds)
    actionQueue:runWaiting(deltaSeconds)
end

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

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

    local offsetX, offsetY = getDrawOffset(ball.x, ball.y)
    gfx.setDrawOffset(offsetX, offsetY)

    GrassBackground:draw(-400, -240)

    local fielderDanceHeight = FielderDanceAnimator:currentValue()
    local ballIsHeld = false
    for _, fielder in pairs(fielding.fielders) do
        ballIsHeld = drawFielder(ball, fielder.x, fielder.y + fielderDanceHeight) or ballIsHeld
    end

    if offenseState == C.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(baserunning.runners) do
        if runner == baserunning.batter then
            if batAngleDeg > 50 and batAngleDeg < 200 then
                PlayerBack:draw(runner.x, runner.y)
            else
                Player: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(baserunning.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(baserunning.runners, fielding.fielders)
    end
    drawScoreboard(0, C.Screen.H * 0.77, teams, outs, battingTeam, inning)
    drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes)
    announcer:draw(C.Center.x, 10)
end

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

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

init()
