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

---@class Baserunning
---@field runners Runner[]
---@field outRunners Runner[]
---@field scoredRunners Runner[]
---@field batter Runner | nil
---@field outs number
-- TODO: Replace with timer, repeatedly reset, instead of constantly setting to 0
---@field secondsSinceLastRunnerMove number
---@field announcer Announcer
---@field onThirdOut fun()
Baserunning = {}

-- TODO: Implement slides? Would require making fielders' gloves "real objects" whose state is tracked.
-- TODO: Don't allow runners to occupy the same base!

---@param announcer Announcer
---@return Baserunning
function Baserunning.new(announcer, onThirdOut)
    local o = setmetatable({
        runners = {},
        outRunners = {},
        scoredRunners = {},
        batter = nil,
        --- Since this object is what ultimately *mutates* the out count,
        --- it seems sensible to store the value here.
        outs = 0,
        announcer = announcer,
        onThirdOut = onThirdOut,
    }, { __index = Baserunning })

    o:pushNewBatter()

    return o
end

---@param runner number | Runner
---@param message string | nil
---@return boolean wasThirdOut
function Baserunning:outRunner(runner, message)
    self.outs = self.outs + 1
    if type(runner) ~= "number" then
        for i, maybe in ipairs(self.runners) do
            if runner == maybe then
                runner = i
            end
        end
    end
    if type(runner) ~= "number" then
        error("Expected runner to have type 'number', but was: " .. type(runner))
    end
    self.outRunners[#self.outRunners + 1] = self.runners[runner]
    table.remove(self.runners, runner)

    self:updateForcedRunners()

    self.announcer:say(message or "YOU'RE OUT!")
    if self.outs < 3 then
        return false
    end
    self.onThirdOut()
    self.outs = 0

    while #self.runners > 0 do
        self.outRunners[#self.outRunners + 1] = table.remove(self.runners, #self.runners)
    end

    return true
end

---@param fielder Fielder
---@return boolean outedSomeRunner
function Baserunning:outEligibleRunners(fielder)
    local touchedBase = utils.isTouchingBase(fielder.x, fielder.y)
    local didOutRunner = false

    for i, runner in pairs(self.runners) do
        local runnerOnBase = utils.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) < C.TagDistance
        then
            local wasThirdOut = self:outRunner(i)
            if wasThirdOut then
                return true -- Don't keep running up self.outs after it's been reset
            end
            didOutRunner = true
        end
    end

    return didOutRunner
end

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

function Baserunning:convertBatterToRunner()
    self.batter.nextBase = C.Bases[C.First]
    self.batter.prevBase = C.Bases[C.Home]
    self:updateForcedRunners()
    self.batter.forcedTo = C.Bases[C.First]
    self.batter = nil -- Demote batter to a mere runner
end

local function walkWayOutRunner(deltaSeconds, runner)
    if runner.x < C.Screen.W + 50 and runner.y < C.Screen.H + 50 then
        runner.x = runner.x + (deltaSeconds * 25)
        runner.y = runner.y + (deltaSeconds * 25)
        return true
    end
    return false
end

---@param deltaSeconds number
function Baserunning:walkAwayOutRunners(deltaSeconds)
    for i, runner in ipairs(self.outRunners) do
        if not walkWayOutRunner(deltaSeconds, runner) then
            table.remove(self.outRunners, i)
        end
    end
    for i, runner in ipairs(self.scoredRunners) do
        if not walkWayOutRunner(deltaSeconds, runner) then
            table.remove(self.scoredRunners, i)
        end
    end
end

---@return Runner
function Baserunning:pushNewBatter()
    local new = {
        -- imageSet = math.random() < C.WokeMeter and FemmeSet or MascSet, -- TODO? lol.
        x = C.RightHandedBattersBox.x - 60,
        y = C.RightHandedBattersBox.y + 60,
        nextBase = C.RightHandedBattersBox,
        prevBase = nil,
        forcedTo = C.Bases[C.First],
    }
    self.runners[#self.runners + 1] = new
    self.batter = new
    return new
end

---@param self table
---@param runnerIndex number
function Baserunning:runnerScored(runnerIndex)
    -- TODO: outRunners/scoredRunners split
    self.scoredRunners[#self.scoredRunners + 1] = self.runners[runnerIndex]
    table.remove(self.runners, runnerIndex)
end

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

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

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

    if
        nearestBaseDistance < 5
        and runnerIndex ~= nil
        and runner ~= self.batter --runner.prevBase
        and runner.nextBase == C.Bases[C.Home]
        and nearestBase == C.Bases[C.Home]
    then
        self:runnerScored(runnerIndex)
        return true, true
    end

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

    -- TODO: Do a better job drifting runners toward their bases when appliedSpeed is low/zero
    if distance < 2 then
        if runner.prevBase ~= nearestBase then
            runner.prevBase = runner.nextBase
            runner.nextBase = C.NextBaseMap[runner.nextBase]
        end
        runner.forcedTo = nil
        return false, 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

    -- TODO: Make this less "sticky" for the user.
    -- Currently it can be a little hard to run *past* a base.

    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, false
end

--- Update non-batter runners.
--- Returns true only if at least one of the given runners moved during this update
---@param appliedSpeed number | fun(runner: Runner): number
---@return boolean runnersStillMoving, number runnersScored, number secondsSinceLastMove
function Baserunning:updateNonBatterRunners(appliedSpeed, forcedOnly, deltaSeconds)
    local runnersStillMoving = false
    local runnersScored = 0

    local speedIsFunction = type(appliedSpeed) == "function"
    -- TODO: Filter for the runner closest to the currently-held direction button
    for runnerIndex, runner in ipairs(self.runners) do
        if runner ~= self.batter and (not forcedOnly or runner.forcedTo) then
            local speed = appliedSpeed
            if speedIsFunction then
                speed = appliedSpeed(runner)
            end
            local thisRunnerMoved, thisRunnerScored = self:updateRunner(runner, runnerIndex, speed, deltaSeconds)
            runnersStillMoving = runnersStillMoving or thisRunnerMoved
            if thisRunnerScored then
                runnersScored = runnersScored + 1
            end
        end
    end

    if runnersStillMoving then
        self.secondsSinceLastRunnerMove = 0
        self:updateForcedRunners()
    else
        self.secondsSinceLastRunnerMove = self.secondsSinceLastRunnerMove + deltaSeconds
    end

    return runnersStillMoving, runnersScored, self.secondsSinceLastRunnerMove
end

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