# Released under the MIT License. See LICENSE for details.
#
"""Defines the last stand minigame."""

import random
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, override

import bascenev1 as bs
from bascenev1 import classicassets
from bascenev1 import builtinassets

from bascenev1lib.actor.playerspaz import PlayerSpaz
from bascenev1lib.actor.bomb import TNTSpawner
from bascenev1lib.actor.scoreboard import Scoreboard
from bascenev1lib.actor.powerupbox import PowerupBoxFactory, PowerupBox
from bascenev1lib.actor.spazbot import (
    SpazBotSet,
    SpazBotDiedMessage,
    BomberBot,
    BomberBotPro,
    BomberBotProShielded,
    BrawlerBot,
    BrawlerBotPro,
    BrawlerBotProShielded,
    TriggerBot,
    TriggerBotPro,
    TriggerBotProShielded,
    ChargerBot,
    StickyBot,
    ExplodeyBot,
)

if TYPE_CHECKING:
    from typing import Any, Sequence

    from bascenev1lib.actor.spazbot import SpazBot


@dataclass
class SpawnInfo:
    """Spawning info for a particular bot type."""

    spawnrate: float
    increase: float
    dincrease: float


class Player(bs.Player['Team']):
    """Our player type for this game."""


class Team(bs.Team[Player]):
    """Our team type for this game."""


class TheLastStandGame(bs.CoopGameActivity[Player, Team]):
    """Slow motion how-long-can-you-last game."""

    name = 'The Last Stand'
    description = 'Final glorious epic slow motion battle to the death.'

    # Show messages when players die since it matters here.
    announce_player_deaths = True

    # And of course the most important part.
    slow_motion = True

    default_music = bs.MusicType.EPIC

    def __init__(self, settings: dict):
        settings['map'] = 'Rampage'
        super().__init__(settings)

        # Set here rather than as a class attribute: string accessors
        # are gated until construct-mode hands off, and class bodies
        # run at import.
        self.tips = [classicassets.strings.tips.endless_high_score]

        self._new_wave_sound = classicassets.audio.score_hit01.get()
        self._winsound = classicassets.audio.score.get()
        self._cashregistersound = builtinassets.audio.cash_register.get()
        self._spawn_center = (0, 5.5, -4.14)
        self._tntspawnpos = (0, 5.5, -6)
        self._powerup_center = (0, 7, -4.14)
        self._powerup_spread = (7, 2)
        self._preset = str(settings.get('preset', 'default'))
        self._excludepowerups: list[str] = []
        self._scoreboard: Scoreboard | None = None
        self._score = 0
        self._bots = SpazBotSet()
        self._dingsound = classicassets.audio.ding_small.get()
        self._dingsoundhigh = classicassets.audio.ding_small_high.get()
        self._tntspawner: TNTSpawner | None = None
        self._bot_update_interval: float | None = None
        self._bot_update_timer: bs.Timer | None = None
        self._powerup_drop_timer = None

        # For each bot type: [spawnrate, increase, d_increase]
        self._bot_spawn_types = {
            BomberBot: SpawnInfo(1.00, 0.00, 0.000),
            BomberBotPro: SpawnInfo(0.00, 0.05, 0.001),
            BomberBotProShielded: SpawnInfo(0.00, 0.02, 0.002),
            BrawlerBot: SpawnInfo(1.00, 0.00, 0.000),
            BrawlerBotPro: SpawnInfo(0.00, 0.05, 0.001),
            BrawlerBotProShielded: SpawnInfo(0.00, 0.02, 0.002),
            TriggerBot: SpawnInfo(0.30, 0.00, 0.000),
            TriggerBotPro: SpawnInfo(0.00, 0.05, 0.001),
            TriggerBotProShielded: SpawnInfo(0.00, 0.02, 0.002),
            ChargerBot: SpawnInfo(0.30, 0.05, 0.000),
            StickyBot: SpawnInfo(0.10, 0.03, 0.001),
            ExplodeyBot: SpawnInfo(0.05, 0.02, 0.002),
        }

    @override
    def on_transition_in(self) -> None:
        # (Pylint bug?) pylint: disable=missing-function-docstring

        super().on_transition_in()
        bs.timer(1.3, self._new_wave_sound.play)
        self._scoreboard = Scoreboard(
            label=classicassets.strings.game.score, score_split=0.5
        )

    @override
    def on_begin(self) -> None:
        super().on_begin()

        # Spit out a few powerups and start dropping more shortly.
        self._drop_powerups(standard_points=True)
        bs.timer(2.0, bs.WeakCallStrict(self._start_powerup_drops))
        bs.timer(0.001, bs.WeakCallStrict(self._start_bot_updates))
        self.setup_low_life_warning_sound()
        self._update_scores()
        self._tntspawner = TNTSpawner(
            position=self._tntspawnpos, respawn_time=10.0
        )

    @override
    def spawn_player(self, player: Player) -> bs.Actor:
        # (Pylint bug?) pylint: disable=missing-function-docstring

        pos = (
            self._spawn_center[0] + random.uniform(-1.5, 1.5),
            self._spawn_center[1],
            self._spawn_center[2] + random.uniform(-1.5, 1.5),
        )
        return self.spawn_player_spaz(player, position=pos)

    def _start_bot_updates(self) -> None:
        self._bot_update_interval = 3.3 - 0.3 * (len(self.players))
        self._update_bots()
        self._update_bots()
        if len(self.players) > 2:
            self._update_bots()
        if len(self.players) > 3:
            self._update_bots()
        self._bot_update_timer = bs.Timer(
            self._bot_update_interval, bs.WeakCallStrict(self._update_bots)
        )

    def _drop_powerup(self, index: int, poweruptype: str | None = None) -> None:
        if poweruptype is None:
            poweruptype = PowerupBoxFactory.get().get_random_powerup_type(
                excludetypes=self._excludepowerups
            )
        PowerupBox(
            position=self.map.powerup_spawn_points[index],
            poweruptype=poweruptype,
        ).autoretain()

    def _start_powerup_drops(self) -> None:
        self._powerup_drop_timer = bs.Timer(
            3.0, bs.WeakCallStrict(self._drop_powerups), repeat=True
        )

    def _drop_powerups(
        self, standard_points: bool = False, force_first: str | None = None
    ) -> None:
        """Generic powerup drop."""
        from bascenev1lib.actor import powerupbox

        if standard_points:
            pts = self.map.powerup_spawn_points
            for i in range(len(pts)):
                bs.timer(
                    1.0 + i * 0.5,
                    bs.WeakCallStrict(
                        self._drop_powerup, i, force_first if i == 0 else None
                    ),
                )
        else:
            drop_pt = (
                self._powerup_center[0]
                + random.uniform(
                    -1.0 * self._powerup_spread[0],
                    1.0 * self._powerup_spread[0],
                ),
                self._powerup_center[1],
                self._powerup_center[2]
                + random.uniform(
                    -self._powerup_spread[1], self._powerup_spread[1]
                ),
            )

            # Drop one random one somewhere.
            powerupbox.PowerupBox(
                position=drop_pt,
                poweruptype=PowerupBoxFactory.get().get_random_powerup_type(
                    excludetypes=self._excludepowerups
                ),
            ).autoretain()

    def do_end(self, outcome: str) -> None:
        """End the game."""
        if outcome == 'defeat':
            self.fade_to_red()
        self.end(
            delay=2.0,
            results={
                'outcome': outcome,
                'score': self._score,
                'playerinfos': self.initialplayerinfos,
            },
        )

    def _update_bots(self) -> None:
        assert self._bot_update_interval is not None
        self._bot_update_interval = max(0.5, self._bot_update_interval * 0.98)
        self._bot_update_timer = bs.Timer(
            self._bot_update_interval, bs.WeakCallStrict(self._update_bots)
        )
        botspawnpts: list[Sequence[float]] = [
            [-5.0, 5.5, -4.14],
            [0.0, 5.5, -4.14],
            [5.0, 5.5, -4.14],
        ]
        dists = [0.0, 0.0, 0.0]
        playerpts: list[Sequence[float]] = []
        for player in self.players:
            try:
                if player.is_alive():
                    assert isinstance(player.actor, PlayerSpaz)
                    assert player.actor.node
                    playerpts.append(player.actor.node.position)
            except Exception:
                logging.exception('Error updating bots.')
        for i in range(3):
            for playerpt in playerpts:
                dists[i] += abs(playerpt[0] - botspawnpts[i][0])
            dists[i] += random.random() * 5.0  # Minor random variation.
        if dists[0] > dists[1] and dists[0] > dists[2]:
            spawnpt = botspawnpts[0]
        elif dists[1] > dists[2]:
            spawnpt = botspawnpts[1]
        else:
            spawnpt = botspawnpts[2]

        spawnpt = (
            spawnpt[0] + 3.0 * (random.random() - 0.5),
            spawnpt[1],
            2.0 * (random.random() - 0.5) + spawnpt[2],
        )

        # Normalize our bot type total and find a random number within that.
        total = 0.0
        for spawninfo in self._bot_spawn_types.values():
            total += spawninfo.spawnrate
        randval = random.random() * total

        # Now go back through and see where this value falls.
        total = 0
        bottype: type[SpazBot] | None = None
        for spawntype, spawninfo in self._bot_spawn_types.items():
            total += spawninfo.spawnrate
            if randval <= total:
                bottype = spawntype
                break
        spawn_time = 1.0
        assert bottype is not None
        self._bots.spawn_bot(bottype, pos=spawnpt, spawn_time=spawn_time)

        # After every spawn we adjust our ratios slightly to get more
        # difficult.
        for spawninfo in self._bot_spawn_types.values():
            spawninfo.spawnrate += spawninfo.increase
            spawninfo.increase += spawninfo.dincrease

    def _update_scores(self) -> None:
        score = self._score

        # Achievements apply to the default preset only.
        if self._preset == 'default':
            if score >= 250:
                self._award_achievement('Last Stand Master')
            if score >= 500:
                self._award_achievement('Last Stand Wizard')
            if score >= 1000:
                self._award_achievement('Last Stand God')
        assert self._scoreboard is not None
        self._scoreboard.set_team_value(self.teams[0], score, max_score=None)

    @override
    def handlemessage(self, msg: Any) -> Any:
        # (Pylint bug?) pylint: disable=missing-function-docstring

        if isinstance(msg, bs.PlayerDiedMessage):
            player = msg.getplayer(Player)
            self.stats.player_was_killed(player)
            bs.timer(0.1, self._checkroundover)

        elif isinstance(msg, bs.PlayerScoredMessage):
            self._score += msg.score
            self._update_scores()

        elif isinstance(msg, SpazBotDiedMessage):
            pts, importance = msg.spazbot.get_death_points(msg.how)
            target: Sequence[float] | None
            if msg.killerplayer:
                assert msg.spazbot.node
                target = msg.spazbot.node.position
                self.stats.player_scored(
                    msg.killerplayer,
                    pts,
                    target=target,
                    kill=True,
                    screenmessage=False,
                    importance=importance,
                )
                diesound = (
                    self._dingsound if importance == 1 else self._dingsoundhigh
                )
                diesound.play(volume=0.6)

            # Normally we pull scores from the score-set, but if there's no
            # player lets be explicit.
            else:
                self._score += pts
            self._update_scores()
        else:
            super().handlemessage(msg)

    @override
    def end_game(self) -> None:
        # (Pylint bug?) pylint: disable=missing-function-docstring

        # Tell our bots to celebrate just to rub it in.
        self._bots.final_celebrate()
        bs.setmusic(None)
        bs.pushcall(bs.WeakCallStrict(self.do_end, 'defeat'))

    def _checkroundover(self) -> None:
        """End the round if conditions are met."""
        if not any(player.is_alive() for player in self.teams[0].players):
            self.end_game()
