# Released under the MIT License. See LICENSE for details.
#
"""Bot versions of Spaz."""

# pylint: disable=too-many-lines

from __future__ import annotations

import random
import weakref
import logging
from typing import TYPE_CHECKING, override

import bascenev1 as bs
from bascenev1lib.actor.spaz import Spaz

if TYPE_CHECKING:
    from typing import Any, Sequence, Callable
    from bascenev1lib.actor.flag import Flag

LITE_BOT_COLOR = (1.2, 0.9, 0.2)
LITE_BOT_HIGHLIGHT = (1.0, 0.5, 0.6)
DEFAULT_BOT_COLOR = (0.6, 0.6, 0.6)
DEFAULT_BOT_HIGHLIGHT = (0.1, 0.3, 0.1)
PRO_BOT_COLOR = (1.0, 0.2, 0.1)
PRO_BOT_HIGHLIGHT = (0.6, 0.1, 0.05)


class SpazBotPunchedMessage:
    """A message saying a bs.SpazBot got punched."""

    spazbot: SpazBot
    """The bs.SpazBot that got punched."""

    damage: int
    """How much damage was done to the SpazBot."""

    def __init__(self, spazbot: SpazBot, damage: int):
        """Instantiate a message with the given values."""
        self.spazbot = spazbot
        self.damage = damage


class SpazBotDiedMessage:
    """A message saying a bs.SpazBot has died."""

    spazbot: SpazBot
    """The SpazBot that was killed."""

    killerplayer: bs.Player | None
    """The bascenev1.Player that killed it (or None)."""

    how: bs.DeathType
    """The particular type of death."""

    def __init__(
        self,
        spazbot: SpazBot,
        killerplayer: bs.Player | None,
        how: bs.DeathType,
    ):
        """Instantiate with given values."""
        self.spazbot = spazbot
        self.killerplayer = killerplayer
        self.how = how


class SpazBot(Spaz):
    """A really dumb AI version of bs.Spaz.

    Add these to a bs.BotSet to use them.

    Note: currently the AI has no real ability to
    navigate obstacles and so should only be used
    on wide-open maps.

    When a SpazBot is killed, it delivers a bs.SpazBotDiedMessage
    to the current activity.

    When a SpazBot is punched, it delivers a bs.SpazBotPunchedMessage
    to the current activity.
    """

    character = 'Spaz'
    punchiness = 0.5
    throwiness = 0.7
    static = False
    bouncy = False
    run = False
    charge_dist_min = 0.0  # When we can start a new charge.
    charge_dist_max = 2.0  # When we can start a new charge.
    run_dist_min = 0.0  # How close we can be to continue running.
    charge_speed_min = 0.4
    charge_speed_max = 1.0
    throw_dist_min = 5.0
    throw_dist_max = 9.0
    throw_rate = 1.0
    default_bomb_type = 'normal'
    default_bomb_count = 3
    start_cursed = False
    color = DEFAULT_BOT_COLOR
    highlight = DEFAULT_BOT_HIGHLIGHT

    def __init__(self) -> None:
        """Instantiate a spaz-bot."""
        super().__init__(
            color=self.color,
            highlight=self.highlight,
            character=self.character,
            source_player=None,
            start_invincible=False,
            can_accept_powerups=False,
        )

        from bascenev1lib.mainmenu import MainMenuActivity

        # If you need to add custom behavior to a bot, set this to a callable
        # which takes one arg (the bot) and returns False if the bot's normal
        # update should be run and True if not.
        self.update_callback: Callable[[SpazBot], Any] | None = None
        activity = self.activity
        assert isinstance(activity, (bs.GameActivity, MainMenuActivity))
        self._map = weakref.ref(activity.map)
        self.last_player_attacked_by: bs.Player | None = None
        self.last_attacked_time = 0.0
        self.last_attacked_type: tuple[str, str] | None = None
        self.target_point_default: bs.Vec3 | None = None
        self.held_count = 0
        self.last_player_held_by: bs.Player | None = None
        self.target_flag: Flag | None = None
        self._charge_speed = 0.5 * (
            self.charge_speed_min + self.charge_speed_max
        )
        self._lead_amount = 0.5
        self._mode = 'wait'
        self._charge_closing_in = False
        self._last_charge_dist = 0.0
        self._running = False
        self._last_jump_time = 0.0

        self._throw_release_time: float | None = None
        self._have_dropped_throw_bomb: bool | None = None
        self._player_pts: list[tuple[bs.Vec3, bs.Vec3]] | None = None

        # These cooldowns didn't exist when these bots were calibrated,
        # so take them out of the equation.
        self._jump_cooldown = 0
        self._pickup_cooldown = 0
        self._fly_cooldown = 0
        self._bomb_cooldown = 0

        if self.start_cursed:
            self.curse()

    @property
    def map(self) -> bs.Map:
        """The map this bot was created on."""
        mval = self._map()
        assert mval is not None
        return mval

    def _get_target_player_pt(self) -> tuple[bs.Vec3 | None, bs.Vec3 | None]:
        """Returns the position and velocity of our target.

        Both values will be None in the case of no target.
        """
        assert self.node
        botpt = bs.Vec3(self.node.position)
        closest_dist: float | None = None
        closest_vel: bs.Vec3 | None = None
        closest: bs.Vec3 | None = None
        assert self._player_pts is not None
        for plpt, plvel in self._player_pts:
            dist = (plpt - botpt).length()

            # Ignore player-points that are significantly below the bot
            # (keeps bots from following players off cliffs).
            if (closest_dist is None or dist < closest_dist) and (
                plpt[1] > botpt[1] - 5.0
            ):
                closest_dist = dist
                closest_vel = plvel
                closest = plpt
        if closest_dist is not None:
            assert closest_vel is not None
            assert closest is not None
            return (
                bs.Vec3(closest[0], closest[1], closest[2]),
                bs.Vec3(closest_vel[0], closest_vel[1], closest_vel[2]),
            )
        return None, None

    def set_player_points(self, pts: list[tuple[bs.Vec3, bs.Vec3]]) -> None:
        """Provide the spaz-bot with the locations of its enemies."""
        self._player_pts = pts

    def update_ai(self) -> None:
        # pylint: disable=too-many-statements
        """Should be called periodically to update the spaz' AI."""
        # pylint: disable=too-many-branches
        if self.update_callback is not None:
            if self.update_callback(self):
                # Bot has been handled.
                return

        if not self.node:
            return

        pos = self.node.position
        our_pos = bs.Vec3(pos[0], 0, pos[2])
        can_attack = True

        target_pt_raw: bs.Vec3 | None
        target_vel: bs.Vec3 | None

        # If we're a flag-bearer, we're pretty simple-minded - just walk
        # towards the flag and try to pick it up.
        if self.target_flag:
            if self.node.hold_node:
                holding_flag = self.node.hold_node.getnodetype() == 'flag'
            else:
                holding_flag = False

            # If we're holding the flag, just walk left.
            if holding_flag:
                # Just walk left.
                self.on_move_left_right(-1.0)
                self.on_move_up_down(0.0)

            # Otherwise try to go pick it up.
            elif self.target_flag.node:
                target_pt_raw = bs.Vec3(*self.target_flag.node.position)
                diff = target_pt_raw - our_pos
                diff = bs.Vec3(diff[0], 0, diff[2])  # Don't care about y.
                dist = diff.length()
                to_target = diff.normalized()

                # If we're holding some non-flag item, drop it.
                if self.node.hold_node:
                    self.on_pickup_press()
                    self.on_pickup_release()
                    return

                # If we're a runner, run only when not super-near the flag.
                if self.run and dist > 3.0:
                    self._running = True
                    self.on_run(1.0)
                else:
                    self._running = False
                    self.on_run(0.0)

                self.on_move_left_right(to_target.x)
                self.on_move_up_down(-to_target.z)
                if dist < 1.25:
                    self.on_pickup_press()
                    self.on_pickup_release()
            return

        # Not a flag-bearer. If we're holding anything but a bomb, drop it.
        if self.node.hold_node:
            holding_bomb = self.node.hold_node.getnodetype() in ['bomb', 'prop']
            if not holding_bomb:
                self.on_pickup_press()
                self.on_pickup_release()
                return

        target_pt_raw, target_vel = self._get_target_player_pt()

        if target_pt_raw is None:
            # Use default target if we've got one.
            if self.target_point_default is not None:
                target_pt_raw = self.target_point_default
                target_vel = bs.Vec3(0, 0, 0)
                can_attack = False

            # With no target, we stop moving and drop whatever we're holding.
            else:
                self.on_move_left_right(0)
                self.on_move_up_down(0)
                if self.node.hold_node:
                    self.on_pickup_press()
                    self.on_pickup_release()
                return

        # We don't want height to come into play.
        target_pt_raw[1] = 0.0
        assert target_vel is not None
        target_vel[1] = 0.0

        dist_raw = (target_pt_raw - our_pos).length()

        # Use a point out in front of them as real target.
        # (more out in front the farther from us they are)
        target_pt = (
            target_pt_raw + target_vel * dist_raw * 0.3 * self._lead_amount
        )

        diff = target_pt - our_pos
        dist = diff.length()
        to_target = diff.normalized()

        if self._mode == 'throw':
            # We can only throw if alive and well.
            if not self._dead and not self.node.knockout:
                assert self._throw_release_time is not None
                time_till_throw = self._throw_release_time - bs.time()

                if not self.node.hold_node:
                    # If we haven't thrown yet, whip out the bomb.
                    if not self._have_dropped_throw_bomb:
                        self.drop_bomb()
                        self._have_dropped_throw_bomb = True

                    # Otherwise our lack of held node means we successfully
                    # released our bomb; lets retreat now.
                    else:
                        self._mode = 'flee'

                # Oh crap, we're holding a bomb; better throw it.
                elif time_till_throw <= 0.0:
                    # Jump and throw.
                    def _pickup() -> None:
                        self.on_pickup_press()
                        self.on_pickup_release()

                    if dist > 5.0:
                        self.on_jump_press()
                        self.on_jump_release()

                        # Throws:
                        bs.timer(0.1, bs.CallStrict(_pickup))
                    else:
                        # Throws:
                        bs.timer(0.1, bs.CallStrict(_pickup))

                if self.static:
                    if time_till_throw < 0.3:
                        speed = 1.0
                    elif time_till_throw < 0.7 and dist > 3.0:
                        speed = -1.0  # Whiplash for long throws.
                    else:
                        speed = 0.02
                else:
                    if time_till_throw < 0.7:
                        # Right before throw charge full speed towards target.
                        speed = 1.0
                    else:
                        # Earlier we can hold or move backward for a whiplash.
                        speed = 0.0125
                self.on_move_left_right(to_target.x * speed)
                self.on_move_up_down(to_target.z * -1.0 * speed)

        elif self._mode == 'charge':
            if random.random() < 0.3:
                self._charge_speed = random.uniform(
                    self.charge_speed_min, self.charge_speed_max
                )

                # If we're a runner we run during charges *except when near
                # an edge (otherwise we tend to fly off easily).
                if self.run and dist_raw > self.run_dist_min:
                    self._lead_amount = 0.3
                    self._running = True
                    self.on_run(1.0)
                else:
                    self._lead_amount = 0.01
                    self._running = False
                    self.on_run(0.0)

            self.on_move_left_right(to_target.x * self._charge_speed)
            self.on_move_up_down(to_target.z * -1.0 * self._charge_speed)

        elif self._mode == 'wait':
            # Every now and then, aim towards our target.
            # Other than that, just stand there.
            if int(bs.time() * 1000.0) % 1234 < 100:
                self.on_move_left_right(to_target.x * (400.0 / 33000))
                self.on_move_up_down(to_target.z * (-400.0 / 33000))
            else:
                self.on_move_left_right(0)
                self.on_move_up_down(0)

        elif self._mode == 'flee':
            # Even if we're a runner, only run till we get away from our
            # target (if we keep running we tend to run off edges).
            if self.run and dist < 3.0:
                self._running = True
                self.on_run(1.0)
            else:
                self._running = False
                self.on_run(0.0)
            self.on_move_left_right(to_target.x * -1.0)
            self.on_move_up_down(to_target.z)

        # We might wanna switch states unless we're doing a throw
        # (in which case that's our sole concern).
        if self._mode != 'throw':
            # If we're currently charging, keep track of how far we are
            # from our target. When this value increases it means our charge
            # is over (ran by them or something).
            if self._mode == 'charge':
                if (
                    self._charge_closing_in
                    and self._last_charge_dist < dist < 3.0
                ):
                    self._charge_closing_in = False
                self._last_charge_dist = dist

            # If we have a clean shot, throw!
            if (
                self.throw_dist_min <= dist < self.throw_dist_max
                and random.random() < self.throwiness
                and can_attack
            ):
                self._mode = 'throw'
                self._lead_amount = (
                    (0.4 + random.random() * 0.6)
                    if dist_raw > 4.0
                    else (0.1 + random.random() * 0.4)
                )
                self._have_dropped_throw_bomb = False
                self._throw_release_time = bs.time() + (
                    1.0 / self.throw_rate
                ) * (0.8 + 1.3 * random.random())

            # If we're static, always charge (which for us means barely move).
            elif self.static:
                self._mode = 'wait'

            # If we're too close to charge (and aren't in the middle of an
            # existing charge) run away.
            elif dist < self.charge_dist_min and not self._charge_closing_in:
                # ..unless we're near an edge, in which case we've got no
                # choice but to charge.
                if self.map.is_point_near_edge(our_pos, self._running):
                    if self._mode != 'charge':
                        self._mode = 'charge'
                        self._lead_amount = 0.2
                        self._charge_closing_in = True
                        self._last_charge_dist = dist
                else:
                    self._mode = 'flee'

            # We're within charging distance, backed against an edge,
            # or farther than our max throw distance.. chaaarge!
            elif (
                dist < self.charge_dist_max
                or dist > self.throw_dist_max
                or self.map.is_point_near_edge(our_pos, self._running)
            ):
                if self._mode != 'charge':
                    self._mode = 'charge'
                    self._lead_amount = 0.01
                    self._charge_closing_in = True
                    self._last_charge_dist = dist

            # We're too close to throw but too far to charge - either run
            # away or just chill if we're near an edge.
            elif dist < self.throw_dist_min:
                # Charge if either we're within charge range or
                # cant retreat to throw.
                self._mode = 'flee'

            # Do some awesome jumps if we're running.
            # FIXME: pylint: disable=too-many-boolean-expressions
            if (
                self._running
                and 1.2 < dist < 2.2
                and bs.time() - self._last_jump_time > 1.0
            ) or (
                self.bouncy
                and bs.time() - self._last_jump_time > 0.4
                and random.random() < 0.5
            ):
                self._last_jump_time = bs.time()
                self.on_jump_press()
                self.on_jump_release()

            # Throw punches when real close.
            if dist < (1.6 if self._running else 1.2) and can_attack:
                if random.random() < self.punchiness:
                    self.on_punch_press()
                    self.on_punch_release()

    @override
    def on_punched(self, damage: int) -> None:
        """
        Method override; sends bs.SpazBotPunchedMessage
        to the current activity.
        """
        bs.getactivity().handlemessage(SpazBotPunchedMessage(self, damage))

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

        # We're being torn down; release our callback(s) so there's
        # no chance of them keeping activities or other things alive.
        self.update_callback = None

    @override
    def handlemessage(self, msg: Any) -> Any:
        # pylint: disable=too-many-branches
        assert not self.expired

        # Keep track of if we're being held and by who most recently.
        if isinstance(msg, bs.PickedUpMessage):
            super().handlemessage(msg)  # Augment standard behavior.
            self.held_count += 1
            picked_up_by = msg.node.source_player
            if picked_up_by:
                self.last_player_held_by = picked_up_by

        elif isinstance(msg, bs.DroppedMessage):
            super().handlemessage(msg)  # Augment standard behavior.
            self.held_count -= 1
            if self.held_count < 0:
                print('ERROR: spaz held_count < 0')

            # Let's count someone dropping us as an attack.
            try:
                if msg.node:
                    picked_up_by = msg.node.source_player
                else:
                    picked_up_by = None
            except Exception:
                logging.exception('Error on SpazBot DroppedMessage.')
                picked_up_by = None

            if picked_up_by:
                self.last_player_attacked_by = picked_up_by
                self.last_attacked_time = bs.time()
                self.last_attacked_type = ('picked_up', 'default')

        elif isinstance(msg, bs.DieMessage):
            # Report normal deaths for scoring purposes.
            if not self._dead and not msg.immediate:
                killerplayer: bs.Player | None

                # If this guy was being held at the time of death, the
                # holder is the killer.
                if self.held_count > 0 and self.last_player_held_by:
                    killerplayer = self.last_player_held_by
                else:
                    # If they were attacked by someone in the last few
                    # seconds that person's the killer.
                    # Otherwise it was a suicide.
                    if (
                        self.last_player_attacked_by
                        and bs.time() - self.last_attacked_time < 4.0
                    ):
                        killerplayer = self.last_player_attacked_by
                    else:
                        killerplayer = None
                activity = self._activity()

                # (convert dead player refs to None)
                if not killerplayer:
                    killerplayer = None
                if activity is not None:
                    activity.handlemessage(
                        SpazBotDiedMessage(self, killerplayer, msg.how)
                    )
            super().handlemessage(msg)  # Augment standard behavior.

        # Keep track of the player who last hit us for point rewarding.
        elif isinstance(msg, bs.HitMessage):
            source_player = msg.get_source_player(bs.Player)
            if source_player:
                self.last_player_attacked_by = source_player
                self.last_attacked_time = bs.time()
                self.last_attacked_type = (msg.hit_type, msg.hit_subtype)
            super().handlemessage(msg)
        else:
            super().handlemessage(msg)


class BomberBot(SpazBot):
    """A bot that throws regular bombs and occasionally punches.

    category: Bot Classes
    """

    character = 'Spaz'
    punchiness = 0.3


class BomberBotLite(BomberBot):
    """A less aggressive yellow version of bs.BomberBot.

    category: Bot Classes
    """

    color = LITE_BOT_COLOR
    highlight = LITE_BOT_HIGHLIGHT
    punchiness = 0.2
    throw_rate = 0.7
    throwiness = 0.1
    charge_speed_min = 0.6
    charge_speed_max = 0.6


class BomberBotStaticLite(BomberBotLite):
    """A less aggressive generally immobile weak version of bs.BomberBot.

    category: Bot Classes
    """

    static = True
    throw_dist_min = 0.0


class BomberBotStatic(BomberBot):
    """A version of bs.BomberBot who generally stays in one place.

    category: Bot Classes
    """

    static = True
    throw_dist_min = 0.0


class BomberBotPro(BomberBot):
    """A more powerful version of bs.BomberBot.

    category: Bot Classes
    """

    points_mult = 2
    color = PRO_BOT_COLOR
    highlight = PRO_BOT_HIGHLIGHT
    default_bomb_count = 3
    default_boxing_gloves = True
    punchiness = 0.7
    throw_rate = 1.3
    run = True
    run_dist_min = 6.0


class BomberBotProShielded(BomberBotPro):
    """A more powerful version of bs.BomberBot who starts with shields.

    category: Bot Classes
    """

    points_mult = 3
    default_shields = True


class BomberBotProStatic(BomberBotPro):
    """A more powerful bs.BomberBot who generally stays in one place.

    category: Bot Classes
    """

    static = True
    throw_dist_min = 0.0


class BomberBotProStaticShielded(BomberBotProShielded):
    """A powerful bs.BomberBot with shields who is generally immobile.

    category: Bot Classes
    """

    static = True
    throw_dist_min = 0.0


class BrawlerBot(SpazBot):
    """A bot who walks and punches things.

    category: Bot Classes
    """

    character = 'Kronk'
    punchiness = 0.9
    charge_dist_max = 9999.0
    charge_speed_min = 1.0
    charge_speed_max = 1.0
    throw_dist_min = 9999
    throw_dist_max = 9999


class BrawlerBotLite(BrawlerBot):
    """A weaker version of bs.BrawlerBot.

    category: Bot Classes
    """

    color = LITE_BOT_COLOR
    highlight = LITE_BOT_HIGHLIGHT
    punchiness = 0.3
    charge_speed_min = 0.6
    charge_speed_max = 0.6


class BrawlerBotPro(BrawlerBot):
    """A stronger version of bs.BrawlerBot.

    category: Bot Classes
    """

    color = PRO_BOT_COLOR
    highlight = PRO_BOT_HIGHLIGHT
    run = True
    run_dist_min = 4.0
    default_boxing_gloves = True
    punchiness = 0.95
    points_mult = 2


class BrawlerBotProShielded(BrawlerBotPro):
    """A stronger version of bs.BrawlerBot who starts with shields.

    category: Bot Classes
    """

    default_shields = True
    points_mult = 3


class ChargerBot(SpazBot):
    """A speedy melee attack bot.

    category: Bot Classes
    """

    character = 'Snake Shadow'
    punchiness = 1.0
    run = True
    charge_dist_min = 10.0
    charge_dist_max = 9999.0
    charge_speed_min = 1.0
    charge_speed_max = 1.0
    throw_dist_min = 9999
    throw_dist_max = 9999
    points_mult = 2


class BouncyBot(SpazBot):
    """A speedy attacking melee bot that jumps constantly.

    category: Bot Classes
    """

    color = (1, 1, 1)
    highlight = (1.0, 0.5, 0.5)
    character = 'Easter Bunny'
    punchiness = 1.0
    run = True
    bouncy = True
    default_boxing_gloves = True
    charge_dist_min = 10.0
    charge_dist_max = 9999.0
    charge_speed_min = 1.0
    charge_speed_max = 1.0
    throw_dist_min = 9999
    throw_dist_max = 9999
    points_mult = 2


class ChargerBotPro(ChargerBot):
    """A stronger bs.ChargerBot.

    category: Bot Classes
    """

    color = PRO_BOT_COLOR
    highlight = PRO_BOT_HIGHLIGHT
    default_boxing_gloves = True
    points_mult = 3


class ChargerBotProShielded(ChargerBotPro):
    """A stronger bs.ChargerBot who starts with shields.

    category: Bot Classes
    """

    default_shields = True
    points_mult = 4


class TriggerBot(SpazBot):
    """A slow moving bot with trigger bombs.

    category: Bot Classes
    """

    character = 'Zoe'
    punchiness = 0.75
    throwiness = 0.7
    charge_dist_max = 1.0
    charge_speed_min = 0.3
    charge_speed_max = 0.5
    throw_dist_min = 3.5
    throw_dist_max = 5.5
    default_bomb_type = 'impact'
    points_mult = 2


class TriggerBotStatic(TriggerBot):
    """A bs.TriggerBot who generally stays in one place.

    category: Bot Classes
    """

    static = True
    throw_dist_min = 0.0


class TriggerBotPro(TriggerBot):
    """A stronger version of bs.TriggerBot.

    category: Bot Classes
    """

    color = PRO_BOT_COLOR
    highlight = PRO_BOT_HIGHLIGHT
    default_bomb_count = 3
    default_boxing_gloves = True
    charge_speed_min = 1.0
    charge_speed_max = 1.0
    punchiness = 0.9
    throw_rate = 1.3
    run = True
    run_dist_min = 6.0
    points_mult = 3


class TriggerBotProShielded(TriggerBotPro):
    """A stronger version of bs.TriggerBot who starts with shields.

    category: Bot Classes
    """

    default_shields = True
    points_mult = 4


class StickyBot(SpazBot):
    """A crazy bot who runs and throws sticky bombs.

    category: Bot Classes
    """

    character = 'Mel'
    punchiness = 0.9
    throwiness = 1.0
    run = True
    charge_dist_min = 4.0
    charge_dist_max = 10.0
    charge_speed_min = 1.0
    charge_speed_max = 1.0
    throw_dist_min = 0.0
    throw_dist_max = 4.0
    throw_rate = 2.0
    default_bomb_type = 'sticky'
    default_bomb_count = 3
    points_mult = 3


class StickyBotStatic(StickyBot):
    """A crazy bot who throws sticky-bombs but generally stays in one place.

    category: Bot Classes
    """

    static = True


class ExplodeyBot(SpazBot):
    """A bot who runs and explodes in 5 seconds.

    category: Bot Classes
    """

    character = 'Jack Morgan'
    run = True
    charge_dist_min = 0.0
    charge_dist_max = 9999
    charge_speed_min = 1.0
    charge_speed_max = 1.0
    throw_dist_min = 9999
    throw_dist_max = 9999
    start_cursed = True
    points_mult = 4


class ExplodeyBotNoTimeLimit(ExplodeyBot):
    """A bot who runs but does not explode on his own.

    category: Bot Classes
    """

    curse_time = None


class ExplodeyBotShielded(ExplodeyBot):
    """A bs.ExplodeyBot who starts with shields.

    category: Bot Classes
    """

    default_shields = True
    points_mult = 5


class DemoBot(SpazBot):
    """A bs.SpazBot who lacks many specific traits and is used for the "Bots
    Free-for-All" easter egg.

    category: Bot Classes
    """

    run = True

    @classmethod
    def randomize_traits(cls, appearance: str) -> None:
        """Randomize the behavioral traits of the bot. Should be called
        everytime before creating a new instance.
        """

        cls.color = (random.random(), random.random(), random.random())
        cls.highlight = (random.random(), random.random(), random.random())
        cls.character = appearance
        cls.punchiness = random.uniform(0.5, 1.0)
        cls.throwiness = random.uniform(0.5, 1.0)
        cls.bouncy = appearance == 'Easter Bunny'
        cls.throw_rate = random.uniform(0.5, 2.0)
        cls.default_bomb_type = random.choice(
            ('normal', 'sticky', 'ice', 'impact')
        )
        cls.default_boxing_gloves = random.choice((True, False, False, False))

    @override
    def __init__(self) -> None:
        super().__init__()
        self._init_time = bs.time()

    @override
    def handlemessage(self, msg: Any) -> Any:
        if (
            isinstance(msg, bs.HitMessage)
            and self.node
            and bs.time() - self._init_time <= 1.0
        ):
            assert msg.force_direction is not None
            self.node.handlemessage(
                'impulse',
                msg.pos[0],
                msg.pos[1],
                msg.pos[2],
                msg.velocity[0],
                msg.velocity[1],
                msg.velocity[2],
                msg.magnitude * self.impact_scale,
                msg.velocity_magnitude * self.impact_scale,
                msg.radius,
                0,
                msg.force_direction[0],
                msg.force_direction[1],
                msg.force_direction[2],
            )
            self.node.handlemessage('hurt_sound')
            return None
        return super().handlemessage(msg)


class SpazBotSet:
    """A container/controller for one or more bs.SpazBots.

    category: Bot Classes
    """

    def __init__(self) -> None:
        """Create a bot-set."""

        # We spread our bots out over a few lists so we can update
        # them in a staggered fashion.
        self._bot_list_count = 5
        self._bot_add_list = 0
        self._bot_update_list = 0
        self._bot_lists: list[list[SpazBot]] = [
            [] for _ in range(self._bot_list_count)
        ]
        self._spawn_sound = bs.getsound('spawn')
        self._spawning_count = 0
        self._bot_update_timer: bs.Timer | None = None
        self.start_moving()

    def __del__(self) -> None:
        self.clear()

    def spawn_bot(
        self,
        bot_type: type[SpazBot],
        pos: Sequence[float],
        spawn_time: float = 3.0,
        on_spawn_call: Callable[[SpazBot], Any] | None = None,
    ) -> None:
        """Spawn a bot from this set."""
        from bascenev1lib.actor.spawner import Spawner

        Spawner(
            pt=pos,
            spawn_time=spawn_time,
            send_spawn_message=False,
            spawn_callback=bs.CallStrict(
                self._spawn_bot, bot_type, pos, on_spawn_call
            ),
        )
        self._spawning_count += 1

    def _spawn_bot(
        self,
        bot_type: type[SpazBot],
        pos: Sequence[float],
        on_spawn_call: Callable[[SpazBot], Any] | None,
    ) -> None:
        spaz = bot_type()
        self._spawn_sound.play(position=pos)
        assert spaz.node
        spaz.node.handlemessage('flash')
        spaz.node.is_area_of_interest = False
        spaz.handlemessage(bs.StandMessage(pos, random.uniform(0, 360)))
        self.add_bot(spaz)
        self._spawning_count -= 1
        if on_spawn_call is not None:
            on_spawn_call(spaz)

    def have_living_bots(self) -> bool:
        """Return whether any bots in the set are alive or spawning."""
        return self._spawning_count > 0 or any(
            any(b.is_alive() for b in l) for l in self._bot_lists
        )

    def get_living_bots(self) -> list[SpazBot]:
        """Get the living bots in the set."""
        bots: list[SpazBot] = []
        for botlist in self._bot_lists:
            for bot in botlist:
                if bot.is_alive():
                    bots.append(bot)
        return bots

    def _update(self) -> None:
        # Update one of our bot lists each time through.
        # First off, remove no-longer-existing bots from the list.
        try:
            bot_list = self._bot_lists[self._bot_update_list] = [
                b for b in self._bot_lists[self._bot_update_list] if b
            ]
        except Exception:
            bot_list = []
            logging.exception(
                'Error updating bot list: %s',
                self._bot_lists[self._bot_update_list],
            )
        self._bot_update_list = (
            self._bot_update_list + 1
        ) % self._bot_list_count

        # Update our list of player points for the bots to use.
        player_pts = []
        for player in bs.getactivity().players:
            assert isinstance(player, bs.Player)
            try:
                # TODO: could use abstracted player.position here so we
                # don't have to assume their actor type, but we have no
                # abstracted velocity as of yet.
                if player.is_alive():
                    assert isinstance(player.actor, Spaz)
                    assert player.actor.node
                    player_pts.append(
                        (
                            bs.Vec3(player.actor.node.position),
                            bs.Vec3(player.actor.node.velocity),
                        )
                    )
            except Exception:
                logging.exception('Error on bot-set _update.')

        for bot in bot_list:
            bot.set_player_points(player_pts)
            bot.update_ai()

    def clear(self) -> None:
        """Immediately clear out any bots in the set."""

        # Don't do this if the activity is shutting down or dead.
        activity = bs.getactivity(doraise=False)
        if activity is None or activity.expired:
            return

        for i, bot_list in enumerate(self._bot_lists):
            for bot in bot_list:
                bot.handlemessage(bs.DieMessage(immediate=True))
            self._bot_lists[i] = []

    def start_moving(self) -> None:
        """Start processing bot AI updates so they start doing their thing."""
        self._bot_update_timer = bs.Timer(
            0.05, bs.WeakCallStrict(self._update), repeat=True
        )

    def stop_moving(self) -> None:
        """Tell all bots to stop moving and stops updating their AI.

        Useful when players have won and you want the
        enemy bots to just stand and look bewildered.
        """
        self._bot_update_timer = None
        for botlist in self._bot_lists:
            for bot in botlist:
                if bot.node:
                    bot.node.move_left_right = 0
                    bot.node.move_up_down = 0

    def celebrate(self, duration: float) -> None:
        """Tell all living bots in the set to celebrate momentarily.

        Duration is given in seconds.
        """
        msg = bs.CelebrateMessage(duration=duration)
        for botlist in self._bot_lists:
            for bot in botlist:
                if bot:
                    bot.handlemessage(msg)

    def final_celebrate(self) -> None:
        """Tell all bots in the set to stop what they were doing and celebrate.

        Use this when the bots have won a game.
        """
        self._bot_update_timer = None

        # At this point stop doing anything but jumping and celebrating.
        for botlist in self._bot_lists:
            for bot in botlist:
                if bot:
                    assert bot.node  # (should exist if 'if bot' was True)
                    bot.node.move_left_right = 0
                    bot.node.move_up_down = 0
                    bs.timer(
                        0.5 * random.random(),
                        bs.CallStrict(bot.handlemessage, bs.CelebrateMessage()),
                    )
                    jump_duration = random.randrange(400, 500)
                    j = random.randrange(0, 200)
                    for _i in range(10):
                        bot.node.jump_pressed = True
                        bot.node.jump_pressed = False
                        j += jump_duration
                    bs.timer(
                        random.uniform(0.0, 1.0),
                        bs.CallStrict(bot.node.handlemessage, 'attack_sound'),
                    )
                    bs.timer(
                        random.uniform(1.0, 2.0),
                        bs.CallStrict(bot.node.handlemessage, 'attack_sound'),
                    )
                    bs.timer(
                        random.uniform(2.0, 3.0),
                        bs.CallStrict(bot.node.handlemessage, 'attack_sound'),
                    )

    def add_bot(self, bot: SpazBot) -> None:
        """Add a bs.SpazBot instance to the set."""
        self._bot_lists[self._bot_add_list].append(bot)
        self._bot_add_list = (self._bot_add_list + 1) % self._bot_list_count


class DemoSpazBotSet(SpazBotSet):
    """A bs.SpazBotSet that has its bs.SpazBots attack every other bs.Spaz
    instead of only going after bs.Players.

    category: Bot Classes
    """

    @override
    def _update(self) -> None:
        # Update one of our bot lists each time through.
        # First off, remove no-longer-existing bots from the list.
        try:
            bot_list = self._bot_lists[self._bot_update_list] = [
                b for b in self._bot_lists[self._bot_update_list] if b
            ]
        except Exception:
            bot_list = []
            logging.exception(
                'Error updating bot list: %s',
                self._bot_lists[self._bot_update_list],
            )
        self._bot_update_list = (
            self._bot_update_list + 1
        ) % self._bot_list_count

        # Update our list of player points for the bots to use.
        spaz_pts = []
        our_bots = self.get_living_bots()
        for node in bs.getnodes():
            spaz = node.getdelegate(Spaz)
            if spaz and spaz.is_alive() and spaz not in our_bots:
                spaz_pts.append(
                    (bs.Vec3(node.position), bs.Vec3(node.velocity))
                )

        for bot in bot_list:
            bot.set_player_points(spaz_pts)
            bot.update_ai()
