-- 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'
import 'CoreLibs/utilities/where.lua'
-- stylua: ignore end

--- @alias Scene { update: fun(self: self) }

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

---@class InputHandler
---@field update fun(self, deltaSeconds: number)
---@field updateBat fun(self, ball: Ball, pitchIsOver: boolean, deltaSeconds: number)
---@field runningSpeed fun(self, runner: Runner, ball: Ball)
---@field pitch fun(self)
---@field fielderAction fun(self, fielderHoldingBall: Fielder | nil, outedSomeRunner: boolean, ball: Ball)

--- @alias LaunchBall fun(
---   self: self,
---   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 'main-menu.lua'

import 'action-queue.lua'
import 'announcer.lua'
import 'ball.lua'
import 'baserunning.lua'
import 'dbg.lua'
import 'fielding.lua'
import 'graphics.lua'
import 'npc.lua'
import 'pitching.lua'
import 'statistics.lua'
import 'user-input.lua'

import 'draw/box-score.lua'
import 'draw/fans.lua'
import 'draw/fielder.lua'
import 'draw/overlay.lua'
import 'draw/panner.lua'
import 'draw/player.lua'
import 'draw/throw-meter.lua'
import 'draw/transitions.lua'
-- stylua: ignore end

-- TODO: Customizable field structure. E.g. stands and ads etc.

---@type pd_graphics_lib
local gfx <const>, C <const> = playdate.graphics, C

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

---@alias TeamId 'home' | 'away'

--- Well, maybe not "Settings", but passive state that probably won't change much, if at all, during a game.
---@class Settings
---@field finalInning number
---@field userTeam TeamId | nil
---@field awayTeamSpriteGroup SpriteCollection
---@field homeTeamSpriteGroup SpriteCollection

---@class MutableState
---@field deltaSeconds number
---@field ball Ball
---@field battingTeam TeamId
---@field pitchIsOver boolean
---@field didSwing boolean
---@field offenseState OffenseState
---@field inning number
---@field stats Statistics
---@field batBase XyPair
---@field batTip XyPair
---@field batAngleDeg number
-- These are only sort-of global state. They are purely graphical,
-- but they need to be kept in sync with the rest of the globals.
---@field runnerBlipper Blipper
---@field battingTeamSprites SpriteCollection
---@field fieldingTeamSprites SpriteCollection

---@class Game
---@field private settings Settings
---@field private announcer Announcer
---@field private fielding Fielding
---@field private baserunning Baserunning
---@field private npc InputHandler
---@field private userInput InputHandler
---@field private homeTeamBlipper Blipper
---@field private awayTeamBlipper Blipper
---@field private panner Panner
---@field private state MutableState
Game = {}

---@param settings Settings
---@param announcer Announcer | nil
---@param fielding Fielding | nil
---@param baserunning Baserunning | nil
---@param npc InputHandler | nil
---@param state MutableState | nil
---@return Game
function Game.new(settings, announcer, fielding, baserunning, npc, state)
    announcer = announcer or Announcer.new()
    fielding = fielding or Fielding.new()
    settings.userTeam = "home" -- "away"

    local homeTeamBlipper = blipper.new(100, settings.homeTeamSpriteGroup)
    local awayTeamBlipper = blipper.new(100, settings.awayTeamSpriteGroup)
    local battingTeam = "away"
    local runnerBlipper = battingTeam == "away" and awayTeamBlipper or homeTeamBlipper
    local ball = Ball.new(gfx.animator)

    local o = setmetatable({
        settings = settings,
        announcer = announcer,
        fielding = fielding,
        homeTeamBlipper = homeTeamBlipper,
        awayTeamBlipper = awayTeamBlipper,
        panner = Panner.new(ball),
        state = state or {
            batBase = utils.xy(C.Center.x - 34, 215),
            batTip = utils.xy(0, 0),
            batAngleDeg = C.CrankOffsetDeg,
            deltaSeconds = 0,
            ball = ball,
            battingTeam = battingTeam,
            offenseState = C.Offense.batting,
            inning = 1,
            pitchIsOver = true,
            didSwing = false,
            battingTeamSprites = settings.awayTeamSpriteGroup,
            fieldingTeamSprites = settings.homeTeamSpriteGroup,
            runnerBlipper = runnerBlipper,
            stats = Statistics.new(),
        },
    }, { __index = Game })

    o.baserunning = baserunning or Baserunning.new(announcer, function()
        o:nextHalfInning()
    end)
    o.userInput = UserInput.new(function(throwFly, forbidThrowHome)
        return o:buttonControlledThrow(throwFly, forbidThrowHome)
    end)
    o.npc = npc or Npc.new(o.baserunning.runners, o.fielding.fielders)

    o.fielding:resetFielderPositions(teams.home.benchPosition)
    playdate.timer.new(2000, function()
        o:returnToPitcher()
    end)

    BootTune:play()
    BootTune:setFinishCallback(function()
        TinnyBackground:play()
    end)

    return o
end

---@param teamId TeamId
---@return TeamId
local function getOppositeTeamId(teamId)
    if teamId == "home" then
        return "away"
    elseif teamId == "away" then
        return "home"
    else
        error("Unknown TeamId: " .. (teamId or "nil"))
    end
end

function Game:getFieldingTeam()
    return teams[getOppositeTeamId(self.state.battingTeam)]
end

---@param side Side
---@return boolean userIsOnSide, boolean userIsOnOtherSide
function Game:userIsOn(side)
    if self.settings.userTeam == nil then
        -- Both teams are NPC-driven
        return false, false
    end
    local ret
    if self.settings.userTeam == self.state.battingTeam then
        ret = side == "offense"
    else
        ret = side == "defense"
    end
    return ret, not ret
end

---@return InputHandler offense, InputHandler defense
function Game:currentInputHandlers()
    local userOnOffense, userOnDefense = self:userIsOn("offense")
    local offenseInput = userOnOffense and self.userInput or self.npc
    local defenseInput = userOnDefense and self.userInput or self.npc
    offenseInput:update(self.state.deltaSeconds)
    defenseInput:update(self.state.deltaSeconds)

    return offenseInput, defenseInput
end

---@param pitchFlyTimeMs number | nil
---@param pitchTypeIndex number | nil
---@param accuracy number The closer to 1.0, the better
function Game:pitch(pitchFlyTimeMs, pitchTypeIndex, accuracy)
    if pitchTypeIndex == nil then
        return -- No throw!
    end
    self.state.ball:markUncatchable()
    self.state.ball.heldBy = nil
    self.state.pitchIsOver = false
    self.state.offenseState = C.Offense.batting

    local current = Pitches[pitchTypeIndex](accuracy, self.state.ball)
    self.state.ball.xAnimator = current.x
    self.state.ball.yAnimator = current.y or Pitches[1](self.state.ball).y

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

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

    pitchTracker.secondsSinceLastPitch = 0
end

function Game:pitcherIsOnTheMound()
    return utils.distanceBetweenPoints(self.fielding.fielders.pitcher, C.PitcherStartPos) < C.BaseHitbox
end

function Game:pitcherIsReady()
    local pitcher = self.fielding.fielders.pitcher
    return self:pitcherIsOnTheMound()
        and (
            self.state.ball.heldBy == pitcher
            or utils.distanceBetweenPoints(pitcher, self.state.ball) < C.BallCatchHitbox
            or utils.distanceBetweenPoints(self.state.ball, C.PitchStart) < 2
        )
end

function Game:checkForGameOver()
    Fielding.celebrate()

    local state = self.state
    if state.stats:gameIsOver(state.inning, self.settings.finalInning, state.battingTeam) then
        self.announcer:say("THAT'S THE BALL GAME!")
        playdate.timer.new(3000, function()
            transitionTo(BoxScore.new(self.state.stats))
        end)
        return true
    end

    return false
end

function Game:nextHalfInning()
    pitchTracker:reset()
    if self:checkForGameOver() then
        return
    end

    self.fielding:benchTo(self:getFieldingTeam().benchPosition)
    self.announcer:say("SWITCHING SIDES...")

    self.fielding:resetFielderPositions()
    if self.state.battingTeam == "home" then
        self.state.inning = self.state.inning + 1
        self.state.stats:pushInning()
    end
    self.state.battingTeam = getOppositeTeamId(self.state.battingTeam)
    playdate.timer.new(2000, function()
        if self.state.battingTeam == "home" then
            self.state.battingTeamSprites = self.settings.homeTeamSpriteGroup
            self.state.runnerBlipper = self.homeTeamBlipper
            self.state.fieldingTeamSprites = self.settings.awayTeamSpriteGroup
        else
            self.state.battingTeamSprites = self.settings.awayTeamSpriteGroup
            self.state.fieldingTeamSprites = self.settings.homeTeamSpriteGroup
            self.state.runnerBlipper = self.awayTeamBlipper
        end
    end)
end

---@return TeamInningData
function Game:battingTeamCurrentInning()
    return self.state.stats.innings[self.state.inning][self.state.battingTeam]
end

---@return TeamInningData
function Game:fieldingTeamCurrentInning()
    return self.state.stats.innings[self.state.inning][getOppositeTeamId(self.state.battingTeam)]
end

---@param scoredRunCount number
function Game:score(scoredRunCount)
    local battingTeamStats = self:battingTeamCurrentInning()
    battingTeamStats.score = battingTeamStats.score + scoredRunCount
    self.announcer:say("SCORE!")
    self:checkForGameOver()
end

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

    self.fielding:userThrowTo(C.Bases[targetBase], self.state.ball, throwFlyMs)
    self.baserunning.secondsSinceLastRunnerMove = 0
    self.state.offenseState = C.Offense.running

    return true
end

function Game:nextBatter()
    pitchTracker.secondsSinceLastPitch = -3
    self.baserunning.batter = nil
    playdate.timer.new(2000, function()
        pitchTracker:reset()
        if not self.baserunning.batter then
            self.baserunning:pushNewBatter()
        end
    end)
end

function Game:walk()
    self.announcer:say("Walk!")
    -- TODO? Use self.baserunning:convertBatterToRunner()
    self.baserunning.batter.nextBase = C.Bases[C.First]
    self.baserunning.batter.prevBase = C.Bases[C.Home]
    self.state.offenseState = C.Offense.walking
    self.baserunning.batter = nil
    self.baserunning:updateForcedRunners()
    self:nextBatter()
end

function Game:strikeOut()
    local outBatter = self.baserunning.batter
    self.baserunning.batter = nil
    self.baserunning:outRunner(outBatter, "Strike out!")
    self:nextBatter()
end

local SwingBackDeg <const> = 30
local SwingForwardDeg <const> = 170

---@param offenseHandler InputHandler
function Game:updateBatting(offenseHandler)
    local ball = self.state.ball
    local batDeg, batSpeed = offenseHandler:updateBat(ball, self.state.pitchIsOver, self.state.deltaSeconds)
    self.state.batAngleDeg = batDeg

    if not self.state.pitchIsOver and batDeg > SwingBackDeg and batDeg < SwingForwardDeg then
        self.state.didSwing = true
    end
    -- TODO? Make the bat angle work more like the throw meter.
    -- Would instead constantly drift toward a default value, giving us a little more control,
    -- and letting the user find a crank position and direction that works for them
    local batAngle = math.rad(batDeg)
    -- TODO: animate bat-flip or something
    local batter = self.baserunning.batter
    self.state.batBase.x = batter and (batter.x + C.BatterHandPos.x) or -999
    self.state.batBase.y = batter and (batter.y + C.BatterHandPos.y) or -999
    self.state.batTip.x = self.state.batBase.x + (C.BatLength * math.sin(batAngle))
    self.state.batTip.y = self.state.batBase.y + (C.BatLength * math.cos(batAngle))

    local ballWasHit = batSpeed > 0
        and ball.y < 232
        and utils.pointDirectlyUnderLine(ball, self.state.batBase, self.state.batTip, C.Screen.H)

    if not ballWasHit then
        return
    end

    -- Hit!
    BatCrackReverb:play()
    self.state.offenseState = C.Offense.running

    local ballAngle = batAngle + math.rad(90)
    local mult = math.abs(batSpeed / 15)
    local ballVelX = mult * C.BattingPower * 10 * math.sin(ballAngle)
    local ballVelY = mult * C.BattingPower * 5 * math.cos(ballAngle)
    if ballVelY > 0 then
        ballVelX = ballVelX * -1
        ballVelY = ballVelY * -1
    end

    local ballDest = utils.xy(ball.x + ballVelX, ball.y + ballVelY)

    pitchTracker:reset()
    local flyTimeMs = 2000
    -- TODO? A dramatic eye-level view on a home-run could be sick.
    local battingTeamStats = self:battingTeamCurrentInning()
    battingTeamStats.hits[#battingTeamStats.hits + 1] = ballDest

    if utils.isFoulBall(ballDest.x, ballDest.y) then
        self.announcer:say("Foul ball!")
        pitchTracker.strikes = math.min(pitchTracker.strikes + 1, 2)
        -- TODO: Have a fielder chase for the fly-out
        return
    end

    if utils.pointIsSquarelyAboveLine(utils.xy(ballDest.x, ballDest.y), C.OutfieldWall) then
        playdate.timer.new(flyTimeMs, function()
            -- Verify that the home run wasn't intercepted
            if utils.within(1, ball.x, ballDest.x) and utils.within(1, ball.y, ballDest.y) then
                self.announcer:say("HOME RUN!")
                self.state.offenseState = C.Offense.homeRun
                -- Linger on the home-run ball for a moment, before panning to the bases.
                playdate.timer.new(1000, function()
                    self.panner:panTo(utils.xy(0, 0), function()
                        return self:pitcherIsReady()
                    end)
                end)
            end
        end)
    end

    local hitBallScaler = gfx.animator.new(2000, 9 + (mult * mult * 0.5), C.SmallestBallRadius, utils.easingHill)
    ball:launch(ballDest.x, ballDest.y, playdate.easingFunctions.outQuint, flyTimeMs, nil, hitBallScaler)

    self.baserunning:convertBatterToRunner()
    self.fielding:haveSomeoneChase(ballDest.x, ballDest.y)
end

---@param appliedSpeed number | fun(runner: Runner): number
---@param forcedOnly boolean
---@param isAutoRun boolean
---@return boolean runnersStillMoving, number secondsSinceLastRunnerMove
function Game:updateNonBatterRunners(appliedSpeed, forcedOnly, isAutoRun)
    local runnersStillMoving, runnersScored, secondsSinceLastRunnerMove =
        self.baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, isAutoRun, self.state.deltaSeconds)
    if runnersScored ~= 0 then
        self:score(runnersScored)
    end
    return runnersStillMoving, secondsSinceLastRunnerMove
end

function Game:returnToPitcher()
    self.fielding:resetFielderPositions()

    if self:pitcherIsReady() then
        return -- Don't then!
    end

    actionQueue:newOnly("returnToPitcher", 60 * 1000, function()
        while not self:pitcherIsOnTheMound() do
            coroutine.yield()
        end
        if not self.baserunning.batter then
            self.baserunning:pushNewBatter()
        end
        self.state.ball:launch(C.PitchStart.x, C.PitchStart.y, playdate.easingFunctions.linear, nil, true)
    end)
end

---@param defenseHandler InputHandler
function Game:updatePitching(defenseHandler)
    pitchTracker:recordIfPassed(self.state.ball)

    if self:pitcherIsOnTheMound() then
        pitchTracker.secondsSinceLastPitch = pitchTracker.secondsSinceLastPitch + self.state.deltaSeconds
    end

    if pitchTracker.secondsSinceLastPitch > C.ReturnToPitcherAfterSeconds and not self.state.pitchIsOver then
        local outcome = pitchTracker:updatePitchCounts(self.state.didSwing, self:fieldingTeamCurrentInning())
        if outcome == PitchOutcomes.StrikeOut then
            self:strikeOut()
        elseif outcome == PitchOutcomes.Walk then
            self:walk()
        end
        self:returnToPitcher()
        self.state.pitchIsOver = true
        self.state.didSwing = false
    end

    if pitchTracker.secondsSinceLastPitch > C.PitchAfterSeconds then
        self:pitch(defenseHandler:pitch())
    end
end

function Game:updateGameState()
    playdate.timer.updateTimers()
    gfx.animation.blinker.updateAll()
    self.state.deltaSeconds = playdate.getElapsedTime() or 0
    playdate.resetElapsedTime()
    self.state.ball:updatePosition(self.state.deltaSeconds)

    local offenseHandler, defenseHandler = self:currentInputHandlers()

    local fielderHoldingBall = self.fielding:updateFielderPositions(self.state.ball, self.state.deltaSeconds)

    if self.state.offenseState == C.Offense.batting then
        self:updatePitching(defenseHandler)
        self:updateBatting(offenseHandler)

        -- Walk batter to the plate
        self.baserunning:updateRunner(self.baserunning.batter, nil, 0, false, self.state.deltaSeconds)
    elseif self.state.offenseState == C.Offense.running then
        local appliedSpeed = function(runner)
            return offenseHandler:runningSpeed(runner, self.state.ball)
        end

        local _, secondsSinceLastRunnerMove = self:updateNonBatterRunners(appliedSpeed, false, false)
        if secondsSinceLastRunnerMove > C.ResetFieldersAfterSeconds then
            -- End of play. Throw the ball back to the pitcher
            self.state.offenseState = C.Offense.batting
            self:returnToPitcher()
        end

        local outedSomeRunner = false
        if fielderHoldingBall then
            outedSomeRunner = self.baserunning:outEligibleRunners(fielderHoldingBall)
        end
        defenseHandler:fielderAction(fielderHoldingBall, outedSomeRunner, self.state.ball)
    elseif self.state.offenseState == C.Offense.walking then
        if not self:updateNonBatterRunners(C.WalkedRunnerSpeed, true, true) then
            self.state.offenseState = C.Offense.batting
        end
    elseif self.state.offenseState == C.Offense.homeRun then
        self:updateNonBatterRunners(C.WalkedRunnerSpeed * 2, false, true)
        if #self.baserunning.runners == 0 then
            -- Give the player a moment to enjoy their home run.
            playdate.timer.new(1500, function()
                self:returnToPitcher()
                actionQueue:upsert("waitForPitcherToHaveBall", 10000, function()
                    while not self:pitcherIsReady() do
                        coroutine.yield()
                    end
                    self.state.offenseState = C.Offense.batting
                end)
            end)
        end
    end

    self.baserunning:walkAwayOutRunners(self.state.deltaSeconds)
    actionQueue:runWaiting(self.state.deltaSeconds)
end

function Game:update()
    self:updateGameState()

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

    local offsetX, offsetY = self.panner:get(self.state.deltaSeconds)
    gfx.setDrawOffset(offsetX, offsetY)

    fans.draw()
    GrassBackground:draw(-400, -720)

    ---@type { y: number, drawAction: fun() }[]
    local characterDraws = {}
    function addDraw(y, drawAction)
        characterDraws[#characterDraws + 1] = { y = y, drawAction = drawAction }
    end

    local ball = self.state.ball

    local danceOffset = FielderDanceAnimator:currentValue()
    ---@type Fielder | nil
    local ballHeldBy
    for _, fielder in pairs(self.fielding.fielders) do
        addDraw(fielder.y + danceOffset, function()
            local ballHeldByThisFielder = drawFielder(
                self.state.fieldingTeamSprites[fielder.spriteIndex],
                ball,
                fielder.x,
                fielder.y + danceOffset
            )
            if ballHeldByThisFielder then
                ballHeldBy = fielder
            end
        end)
    end

    local playerHeightOffset = 20
    for _, runner in pairs(self.baserunning.runners) do
        addDraw(runner.y, function()
            if runner == self.baserunning.batter then
                if self.state.batAngleDeg > 50 and self.state.batAngleDeg < 200 then
                    self.state.battingTeamSprites[runner.spriteIndex].back:draw(runner.x, runner.y - playerHeightOffset)
                else
                    self.state.battingTeamSprites[runner.spriteIndex].smiling:draw(
                        runner.x,
                        runner.y - playerHeightOffset
                    )
                end
            else
                -- TODO? Change blip speed depending on runner speed?
                self.state.runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset, runner)
            end
        end)
    end

    table.sort(characterDraws, function(a, b)
        return a.y < b.y
    end)
    for _, character in pairs(characterDraws) do
        character.drawAction()
    end

    if self.state.offenseState == C.Offense.batting then
        gfx.setLineWidth(7)
        gfx.drawLine(self.state.batBase.x, self.state.batBase.y, self.state.batTip.x, self.state.batTip.y)

        gfx.setColor(gfx.kColorWhite)
        gfx.setLineCapStyle(gfx.kLineCapStyleRound)
        gfx.setLineWidth(3)
        gfx.drawLine(self.state.batBase.x, self.state.batBase.y, self.state.batTip.x, self.state.batTip.y)

        gfx.setColor(gfx.kColorBlack)
    end

    for _, runner in pairs(self.baserunning.outRunners) do
        self.state.battingTeamSprites[runner.spriteIndex].frowning:draw(runner.x, runner.y - playerHeightOffset)
    end
    for _, runner in pairs(self.baserunning.scoredRunners) do
        self.state.runnerBlipper:draw(false, runner.x, runner.y - playerHeightOffset, runner)
    end

    if self:userIsOn("defense") then
        throwMeter:drawNearFielder(ballHeldBy)
    end

    if not ballHeldBy 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(self.baserunning.runners, self.fielding.fielders)
    end

    local homeScore, awayScore = utils.totalScores(self.state.stats)
    drawScoreboard(
        0,
        C.Screen.H * 0.77,
        homeScore,
        awayScore,
        self.baserunning.outs,
        self.state.battingTeam,
        self.state.inning
    )
    drawBallsAndStrikes(290, C.Screen.H - 20, pitchTracker.balls, pitchTracker.strikes)
    self.announcer:draw(C.Center.x, 10)

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

-- luacheck: ignore
if not playdate or playdate.TEST_MODE then
    return Game
end

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

MainMenu.start(Game)
