# Released under the MIT License. See LICENSE for details.
#
"""Various functionality related to achievements."""
from __future__ import annotations

import logging
from typing import TYPE_CHECKING

from bacommon.bs import ClassicChestAppearance
from baclassic._chest import (
    CHEST_APPEARANCE_DISPLAY_INFOS,
    CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT,
)
import babase
import bascenev1
import bauiv1

if TYPE_CHECKING:
    from typing import Any, Sequence

    import baclassic

# This could use some cleanup.
# We wear the cone of shame.
# pylint: disable=too-many-lines
# pylint: disable=too-many-statements
# pylint: disable=too-many-locals
# pylint: disable=too-many-branches

# FIXME: We should probably point achievements
#  at coop levels instead of hard-coding names.
#  (so level name substitution works right and whatnot).
ACH_LEVEL_NAMES = {
    'Boom Goes the Dynamite': 'Pro Onslaught',
    'Boxer': 'Onslaught Training',
    'Flawless Victory': 'Rookie Onslaught',
    'Gold Miner': 'Uber Onslaught',
    'Got the Moves': 'Uber Football',
    'Last Stand God': 'The Last Stand',
    'Last Stand Master': 'The Last Stand',
    'Last Stand Wizard': 'The Last Stand',
    'Mine Games': 'Rookie Onslaught',
    'Off You Go Then': 'Onslaught Training',
    'Onslaught God': 'Infinite Onslaught',
    'Onslaught Master': 'Infinite Onslaught',
    'Onslaught Training Victory': 'Onslaught Training',
    'Onslaught Wizard': 'Infinite Onslaught',
    'Precision Bombing': 'Pro Runaround',
    'Pro Boxer': 'Pro Onslaught',
    'Pro Football Shutout': 'Pro Football',
    'Pro Football Victory': 'Pro Football',
    'Pro Onslaught Victory': 'Pro Onslaught',
    'Pro Runaround Victory': 'Pro Runaround',
    'Rookie Football Shutout': 'Rookie Football',
    'Rookie Football Victory': 'Rookie Football',
    'Rookie Onslaught Victory': 'Rookie Onslaught',
    'Runaround God': 'Infinite Runaround',
    'Runaround Master': 'Infinite Runaround',
    'Runaround Wizard': 'Infinite Runaround',
    'Stayin\' Alive': 'Uber Runaround',
    'Super Mega Punch': 'Pro Football',
    'Super Punch': 'Rookie Football',
    'TNT Terror': 'Uber Onslaught',
    'The Great Wall': 'Uber Runaround',
    'The Wall': 'Pro Runaround',
    'Uber Football Shutout': 'Uber Football',
    'Uber Football Victory': 'Uber Football',
    'Uber Onslaught Victory': 'Uber Onslaught',
    'Uber Runaround Victory': 'Uber Runaround',
}


class AchievementSubsystem:
    """Subsystem for achievement handling.

    Access the single shared instance of this class at 'ba.app.ach'.
    """

    def __init__(self) -> None:
        self.achievements: list[Achievement] = []
        self.achievements_to_display: list[
            tuple[baclassic.Achievement, bool]
        ] = []
        self.achievement_display_timer: bascenev1.BaseTimer | None = None
        self.last_achievement_display_time: float = 0.0
        self.achievement_completion_banner_slots: set[int] = set()
        self._init_achievements()

    def _init_achievements(self) -> None:
        """Fill in available achievements."""

        self.achievements += [
            Achievement(
                'In Control',
                'achievementInControl',
                (1, 1, 1),
                '',
                award=5,
            ),
            Achievement(
                'Sharing is Caring',
                'achievementSharingIsCaring',
                (1, 1, 1),
                '',
                award=15,
            ),
            Achievement(
                'Dual Wielding',
                'achievementDualWielding',
                (1, 1, 1),
                '',
                award=10,
            ),
            Achievement(
                'Free Loader',
                'achievementFreeLoader',
                (1, 1, 1),
                '',
                award=10,
            ),
            Achievement(
                'Team Player',
                'achievementTeamPlayer',
                (1, 1, 1),
                '',
                award=20,
            ),
            Achievement(
                'Onslaught Training Victory',
                'achievementOnslaught',
                (1, 1, 1),
                'Default:Onslaught Training',
                award=5,
            ),
            Achievement(
                'Off You Go Then',
                'achievementOffYouGo',
                (1, 1.1, 1.3),
                'Default:Onslaught Training',
                award=5,
            ),
            Achievement(
                'Boxer',
                'achievementBoxer',
                (1, 0.6, 0.6),
                'Default:Onslaught Training',
                award=10,
                hard_mode_only=True,
            ),
            Achievement(
                'Rookie Onslaught Victory',
                'achievementOnslaught',
                (0.5, 1.4, 0.6),
                'Default:Rookie Onslaught',
                award=10,
            ),
            Achievement(
                'Mine Games',
                'achievementMine',
                (1, 1, 1.4),
                'Default:Rookie Onslaught',
                award=10,
            ),
            Achievement(
                'Flawless Victory',
                'achievementFlawlessVictory',
                (1, 1, 1),
                'Default:Rookie Onslaught',
                award=15,
                hard_mode_only=True,
            ),
            Achievement(
                'Rookie Football Victory',
                'achievementFootballVictory',
                (1.0, 1, 0.6),
                'Default:Rookie Football',
                award=10,
            ),
            Achievement(
                'Super Punch',
                'achievementSuperPunch',
                (1, 1, 1.8),
                'Default:Rookie Football',
                award=10,
            ),
            Achievement(
                'Rookie Football Shutout',
                'achievementFootballShutout',
                (1, 1, 1),
                'Default:Rookie Football',
                award=15,
                hard_mode_only=True,
            ),
            Achievement(
                'Pro Onslaught Victory',
                'achievementOnslaught',
                (0.3, 1, 2.0),
                'Default:Pro Onslaught',
                award=15,
            ),
            Achievement(
                'Boom Goes the Dynamite',
                'achievementTNT',
                (1.4, 1.2, 0.8),
                'Default:Pro Onslaught',
                award=15,
            ),
            Achievement(
                'Pro Boxer',
                'achievementBoxer',
                (2, 2, 0),
                'Default:Pro Onslaught',
                award=20,
                hard_mode_only=True,
            ),
            Achievement(
                'Pro Football Victory',
                'achievementFootballVictory',
                (1.3, 1.3, 2.0),
                'Default:Pro Football',
                award=15,
            ),
            Achievement(
                'Super Mega Punch',
                'achievementSuperPunch',
                (2, 1, 0.6),
                'Default:Pro Football',
                award=15,
            ),
            Achievement(
                'Pro Football Shutout',
                'achievementFootballShutout',
                (0.7, 0.7, 2.0),
                'Default:Pro Football',
                award=20,
                hard_mode_only=True,
            ),
            Achievement(
                'Pro Runaround Victory',
                'achievementRunaround',
                (1, 1, 1),
                'Default:Pro Runaround',
                award=15,
            ),
            Achievement(
                'Precision Bombing',
                'achievementCrossHair',
                (1, 1, 1.3),
                'Default:Pro Runaround',
                award=20,
                hard_mode_only=True,
            ),
            Achievement(
                'The Wall',
                'achievementWall',
                (1, 0.7, 0.7),
                'Default:Pro Runaround',
                award=25,
                hard_mode_only=True,
            ),
            Achievement(
                'Uber Onslaught Victory',
                'achievementOnslaught',
                (2, 2, 1),
                'Default:Uber Onslaught',
                award=30,
            ),
            Achievement(
                'Gold Miner',
                'achievementMine',
                (2, 1.6, 0.2),
                'Default:Uber Onslaught',
                award=30,
                hard_mode_only=True,
            ),
            Achievement(
                'TNT Terror',
                'achievementTNT',
                (2, 1.8, 0.3),
                'Default:Uber Onslaught',
                award=30,
                hard_mode_only=True,
            ),
            Achievement(
                'Uber Football Victory',
                'achievementFootballVictory',
                (1.8, 1.4, 0.3),
                'Default:Uber Football',
                award=30,
            ),
            Achievement(
                'Got the Moves',
                'achievementGotTheMoves',
                (2, 1, 0),
                'Default:Uber Football',
                award=30,
                hard_mode_only=True,
            ),
            Achievement(
                'Uber Football Shutout',
                'achievementFootballShutout',
                (2, 2, 0),
                'Default:Uber Football',
                award=40,
                hard_mode_only=True,
            ),
            Achievement(
                'Uber Runaround Victory',
                'achievementRunaround',
                (1.5, 1.2, 0.2),
                'Default:Uber Runaround',
                award=30,
            ),
            Achievement(
                'The Great Wall',
                'achievementWall',
                (2, 1.7, 0.4),
                'Default:Uber Runaround',
                award=40,
                hard_mode_only=True,
            ),
            Achievement(
                'Stayin\' Alive',
                'achievementStayinAlive',
                (2, 2, 1),
                'Default:Uber Runaround',
                award=40,
                hard_mode_only=True,
            ),
            Achievement(
                'Last Stand Master',
                'achievementMedalSmall',
                (2, 1.5, 0.3),
                'Default:The Last Stand',
                award=20,
                hard_mode_only=True,
            ),
            Achievement(
                'Last Stand Wizard',
                'achievementMedalMedium',
                (2, 1.5, 0.3),
                'Default:The Last Stand',
                award=40,
                hard_mode_only=True,
            ),
            Achievement(
                'Last Stand God',
                'achievementMedalLarge',
                (2, 1.5, 0.3),
                'Default:The Last Stand',
                award=60,
                hard_mode_only=True,
            ),
            Achievement(
                'Onslaught Master',
                'achievementMedalSmall',
                (0.7, 1, 0.7),
                'Challenges:Infinite Onslaught',
                award=5,
            ),
            Achievement(
                'Onslaught Wizard',
                'achievementMedalMedium',
                (0.7, 1.0, 0.7),
                'Challenges:Infinite Onslaught',
                award=15,
            ),
            Achievement(
                'Onslaught God',
                'achievementMedalLarge',
                (0.7, 1.0, 0.7),
                'Challenges:Infinite Onslaught',
                award=30,
            ),
            Achievement(
                'Runaround Master',
                'achievementMedalSmall',
                (1.0, 1.0, 1.2),
                'Challenges:Infinite Runaround',
                award=5,
            ),
            Achievement(
                'Runaround Wizard',
                'achievementMedalMedium',
                (1.0, 1.0, 1.2),
                'Challenges:Infinite Runaround',
                award=15,
            ),
            Achievement(
                'Runaround God',
                'achievementMedalLarge',
                (1.0, 1.0, 1.2),
                'Challenges:Infinite Runaround',
                award=30,
            ),
        ]

    def award_local_achievement(self, achname: str) -> None:
        """For non-game-based achievements such as controller-connection."""
        plus = babase.app.plus
        if plus is None:
            logging.warning('achievements require plus feature-set')
            return
        try:
            ach = self.get_achievement(achname)
            if not ach.complete:
                # Report new achievements to the game-service.
                plus.report_achievement(achname)

                # And to our account.
                plus.add_v1_account_transaction(
                    {'type': 'ACHIEVEMENT', 'name': achname}
                )

                # Now attempt to show a banner.
                self.display_achievement_banner(achname)

        except Exception:
            logging.exception('Error in award_local_achievement.')

    def display_achievement_banner(self, achname: str) -> None:
        """Display a completion banner for an achievement.

        (internal)

        Used for server-driven achievements.
        """
        try:
            # FIXME: Need to get these using the UI context or some other
            #  purely local context somehow instead of trying to inject these
            #  into whatever activity happens to be active
            #  (since that won't work while in client mode).
            activity = bascenev1.get_foreground_host_activity()
            if activity is not None:
                with activity.context:
                    self.get_achievement(achname).announce_completion()
        except Exception:
            logging.exception('Error in display_achievement_banner.')

    def set_completed_achievements(self, achs: Sequence[str]) -> None:
        """Set the current state of completed achievements.

        (internal)

        All achievements not included here will be set incomplete.
        """

        # Note: This gets called whenever game-center/game-circle/etc tells
        # us which achievements we currently have.  We always defer to them,
        # even if that means we have to un-set an achievement we think we have.

        cfg = babase.app.config
        cfg['Achievements'] = {}
        for a_name in achs:
            self.get_achievement(a_name).set_complete(True)
        cfg.commit()

    def get_achievement(self, name: str) -> Achievement:
        """Return an Achievement by name."""
        achs = [a for a in self.achievements if a.name == name]
        assert len(achs) < 2
        if not achs:
            raise ValueError("Invalid achievement name: '" + name + "'")
        return achs[0]

    def achievements_for_coop_level(self, level_name: str) -> list[Achievement]:
        """Given a level name, return achievements available for it."""

        # For the Easy campaign we return achievements for the Default
        # campaign too. (want the user to see what achievements are part of the
        # level even if they can't unlock them all on easy mode).
        return [
            a
            for a in self.achievements
            if a.level_name
            in (level_name, level_name.replace('Easy', 'Default'))
        ]

    def _test(self) -> None:
        """For testing achievement animations."""

        def testcall1() -> None:
            self.achievements[0].announce_completion()
            self.achievements[1].announce_completion()
            self.achievements[2].announce_completion()

        def testcall2() -> None:
            self.achievements[3].announce_completion()
            self.achievements[4].announce_completion()
            self.achievements[5].announce_completion()

        bascenev1.basetimer(3.0, testcall1)
        bascenev1.basetimer(7.0, testcall2)


def _get_ach_mult(include_pro_bonus: bool = False) -> int:
    """Return the multiplier for achievement pts.

    (just for display; changing this here won't affect actual rewards)
    """
    plus = babase.app.plus
    classic = babase.app.classic
    if plus is None or classic is None:
        return 5
    val: int = plus.get_v1_account_misc_read_val('achAwardMult', 5)
    assert isinstance(val, int)
    if include_pro_bonus and classic.accounts.have_pro():
        val *= 2
    return val


def _display_next_achievement() -> None:
    # Pull the first achievement off the list and display it, or kill the
    # display-timer if the list is empty.
    app = babase.app
    assert app.classic is not None
    ach_ss = app.classic.ach
    if app.classic.ach.achievements_to_display:
        try:
            ach, sound = ach_ss.achievements_to_display.pop(0)
            ach.show_completion_banner(sound)
        except Exception:
            logging.exception('Error in _display_next_achievement.')
            ach_ss.achievements_to_display = []
            ach_ss.achievement_display_timer = None
    else:
        ach_ss.achievement_display_timer = None


class Achievement:
    """Represents attributes and state for an individual achievement."""

    def __init__(
        self,
        name: str,
        icon_name: str,
        icon_color: tuple[float, float, float],
        level_name: str,
        *,
        award: int,
        hard_mode_only: bool = False,
    ):
        self._name = name
        self._icon_name = icon_name
        assert len(icon_color) == 3
        self._icon_color = icon_color + (1.0,)
        self._level_name = level_name
        self._completion_banner_slot: int | None = None
        self._award = award
        self._hard_mode_only = hard_mode_only

    @property
    def name(self) -> str:
        """The name of this achievement."""
        return self._name

    @property
    def level_name(self) -> str:
        """The name of the level this achievement applies to."""
        return self._level_name

    def get_icon_ui_texture(self, complete: bool) -> bauiv1.Texture:
        """Return the icon texture to display for this achievement"""
        return bauiv1.gettexture(
            self._icon_name if complete else 'achievementEmpty'
        )

    def get_icon_texture(self, complete: bool) -> bascenev1.Texture:
        """Return the icon texture to display for this achievement"""
        return bascenev1.gettexture(
            self._icon_name if complete else 'achievementEmpty'
        )

    def get_icon_color(self, complete: bool) -> Sequence[float]:
        """Return the color tint for this Achievement's icon."""
        if complete:
            return self._icon_color
        return 1.0, 1.0, 1.0, 0.6

    @property
    def hard_mode_only(self) -> bool:
        """Whether this Achievement is only unlockable in hard-mode."""
        return self._hard_mode_only

    @property
    def complete(self) -> bool:
        """Whether this Achievement is currently complete."""
        val: bool = self._getconfig()['Complete']
        assert isinstance(val, bool)
        return val

    def announce_completion(self, sound: bool = True) -> None:
        """Kick off an announcement for this achievement's completion."""

        app = babase.app
        plus = app.plus
        classic = app.classic
        if plus is None or classic is None:
            logging.warning('ach account_completion not available.')
            return

        ach_ss = classic.ach

        # Even though there are technically achievements when we're not
        # signed in, lets not show them (otherwise we tend to get
        # confusing 'controller connected' achievements popping up while
        # waiting to sign in which can be confusing).
        if plus.get_v1_account_state() != 'signed_in':
            return

        # If we're being freshly complete, display/report it and whatnot.
        if (self, sound) not in ach_ss.achievements_to_display:
            ach_ss.achievements_to_display.append((self, sound))

        # If there's no achievement display timer going, kick one off
        # (if one's already running it will pick this up before it dies).

        # Need to check last time too; its possible our timer wasn't able to
        # clear itself if an activity died and took it down with it.
        if (
            ach_ss.achievement_display_timer is None
            or babase.apptime() - ach_ss.last_achievement_display_time > 2.0
        ) and bascenev1.getactivity(doraise=False) is not None:
            ach_ss.achievement_display_timer = bascenev1.BaseTimer(
                1.0, _display_next_achievement, repeat=True
            )

            # Show the first immediately.
            _display_next_achievement()

    def set_complete(self, complete: bool = True) -> None:
        """Set an achievement's completed state.

        note this only sets local state; use a transaction to
        actually award achievements.
        """
        config = self._getconfig()
        if complete != config['Complete']:
            config['Complete'] = complete

    @property
    def display_name(self) -> babase.Lstr:
        """Return a babase.Lstr for this Achievement's name."""
        name: babase.Lstr | str
        try:
            if self._level_name != '':
                campaignname, campaign_level = self._level_name.split(':')
                classic = babase.app.classic
                assert classic is not None
                name = (
                    classic.getcampaign(campaignname)
                    .getlevel(campaign_level)
                    .displayname
                )
            else:
                name = ''
        except Exception:
            name = ''
            logging.exception('Error calcing achievement display-name.')
        return babase.Lstr(
            resource='achievements.' + self._name + '.name',
            subs=[('${LEVEL}', name)],
        )

    @property
    def description(self) -> babase.Lstr:
        """Get a babase.Lstr for the Achievement's brief description."""
        if (
            'description'
            in babase.app.lang.get_resource('achievements')[self._name]
        ):
            return babase.Lstr(
                resource='achievements.' + self._name + '.description'
            )
        return babase.Lstr(
            resource='achievements.' + self._name + '.descriptionFull'
        )

    @property
    def description_complete(self) -> babase.Lstr:
        """Get a babase.Lstr for the Achievement's description when complete."""
        if (
            'descriptionComplete'
            in babase.app.lang.get_resource('achievements')[self._name]
        ):
            return babase.Lstr(
                resource='achievements.' + self._name + '.descriptionComplete'
            )
        return babase.Lstr(
            resource='achievements.' + self._name + '.descriptionFullComplete'
        )

    @property
    def description_full(self) -> babase.Lstr:
        """Get a babase.Lstr for the Achievement's full description."""
        return babase.Lstr(
            resource='achievements.' + self._name + '.descriptionFull',
            subs=[
                (
                    '${LEVEL}',
                    babase.Lstr(
                        translate=(
                            'coopLevelNames',
                            ACH_LEVEL_NAMES.get(self._name, '?'),
                        )
                    ),
                )
            ],
        )

    @property
    def description_full_complete(self) -> babase.Lstr:
        """Get a babase.Lstr for the Achievement's full desc. when completed."""
        return babase.Lstr(
            resource='achievements.' + self._name + '.descriptionFullComplete',
            subs=[
                (
                    '${LEVEL}',
                    babase.Lstr(
                        translate=(
                            'coopLevelNames',
                            ACH_LEVEL_NAMES.get(self._name, '?'),
                        )
                    ),
                )
            ],
        )

    def get_award_chest_type(self) -> ClassicChestAppearance:
        """Return the type of chest given for this achievement.

        :meta private:
        """

        # For now just map our old ticket values to chest types.
        # Can add distinct values if need be later.
        plus = babase.app.plus
        assert plus is not None
        t = plus.get_v1_account_misc_read_val(
            f'achAward.{self.name}', self._award
        )
        return (
            ClassicChestAppearance.L6
            if t >= 30
            else (
                ClassicChestAppearance.L5
                if t >= 25
                else (
                    ClassicChestAppearance.L4
                    if t >= 20
                    else (
                        ClassicChestAppearance.L3
                        if t >= 15
                        else (
                            ClassicChestAppearance.L2
                            if t >= 10
                            else ClassicChestAppearance.L1
                        )
                    )
                )
            )
        )

    # def get_award_ticket_value(self, include_pro_bonus: bool = False) -> int:
    #     """Get the ticket award value for this achievement."""
    #     plus = babase.app.plus
    #     if plus is None:
    #         return 0
    #     val: int = plus.get_v1_account_misc_read_val(
    #         'achAward.' + self._name, self._award
    #     ) * _get_ach_mult(include_pro_bonus)
    #     assert isinstance(val, int)
    #     return val

    @property
    def power_ranking_value(self) -> int:
        """Get the power-ranking award value for this achievement."""
        plus = babase.app.plus
        if plus is None:
            return 0
        val: int = plus.get_v1_account_misc_read_val(
            'achLeaguePoints.' + self._name, self._award
        )
        assert isinstance(val, int)
        return val

    def create_display(
        self,
        x: float,
        y: float,
        delay: float,
        *,
        outdelay: float | None = None,
        color: Sequence[float] | None = None,
        style: str = 'post_game',
    ) -> list[bascenev1.Actor]:
        """Create a display for the Achievement.

        Shows the Achievement icon, name, and description.
        """
        # pylint: disable=cyclic-import
        from bascenev1 import CoopSession
        from bascenev1lib.actor.image import Image
        from bascenev1lib.actor.text import Text

        # Yeah this needs cleaning up.
        if style == 'post_game':
            in_game_colors = False
            in_main_menu = False
            h_attach = Text.HAttach.CENTER
            v_attach = Text.VAttach.CENTER
            attach = Image.Attach.CENTER
        elif style == 'in_game':
            in_game_colors = True
            in_main_menu = False
            h_attach = Text.HAttach.LEFT
            v_attach = Text.VAttach.TOP
            attach = Image.Attach.TOP_LEFT
        elif style == 'news':
            in_game_colors = True
            in_main_menu = True
            h_attach = Text.HAttach.CENTER
            v_attach = Text.VAttach.TOP
            attach = Image.Attach.TOP_CENTER
        else:
            raise ValueError('invalid style "' + style + '"')

        # Attempt to determine what campaign we're in
        # (so we know whether to show "hard mode only").
        if in_main_menu:
            hmo = False
        else:
            try:
                session = bascenev1.getsession()
                if isinstance(session, CoopSession):
                    campaign = session.campaign
                    assert campaign is not None
                    hmo = self._hard_mode_only and campaign.name == 'Easy'
                else:
                    hmo = False
            except Exception:
                logging.exception('Error determining campaign.')
                hmo = False

        objs: list[bascenev1.Actor]

        if in_game_colors:
            objs = []
            out_delay_fin = (delay + outdelay) if outdelay is not None else None
            if color is not None:
                cl1 = (2.0 * color[0], 2.0 * color[1], 2.0 * color[2], color[3])
                cl2 = color
            else:
                cl1 = (1.5, 1.5, 2, 1.0)
                cl2 = (0.8, 0.8, 1.0, 1.0)

            if hmo:
                cl1 = (cl1[0], cl1[1], cl1[2], cl1[3] * 0.6)
                cl2 = (cl2[0], cl2[1], cl2[2], cl2[3] * 0.2)

            objs.append(
                Image(
                    self.get_icon_texture(False),
                    host_only=True,
                    color=cl1,
                    position=(x - 25, y + 5),
                    attach=attach,
                    transition=Image.Transition.FADE_IN,
                    transition_delay=delay,
                    vr_depth=4,
                    transition_out_delay=out_delay_fin,
                    scale=(40, 40),
                ).autoretain()
            )
            txt = self.display_name
            txt_s = 0.85
            txt_max_w = 300
            objs.append(
                Text(
                    txt,
                    host_only=True,
                    maxwidth=txt_max_w,
                    position=(x, y + 2),
                    transition=Text.Transition.FADE_IN,
                    scale=txt_s,
                    flatness=0.6,
                    shadow=0.5,
                    h_attach=h_attach,
                    v_attach=v_attach,
                    color=cl2,
                    transition_delay=delay + 0.05,
                    transition_out_delay=out_delay_fin,
                ).autoretain()
            )
            txt2_s = 0.62
            txt2_max_w = 400
            objs.append(
                Text(
                    self.description_full if in_main_menu else self.description,
                    host_only=True,
                    maxwidth=txt2_max_w,
                    position=(x, y - 14),
                    transition=Text.Transition.FADE_IN,
                    vr_depth=-5,
                    h_attach=h_attach,
                    v_attach=v_attach,
                    scale=txt2_s,
                    flatness=1.0,
                    shadow=0.5,
                    color=cl2,
                    transition_delay=delay + 0.1,
                    transition_out_delay=out_delay_fin,
                ).autoretain()
            )

            if hmo:
                txtactor = Text(
                    babase.Lstr(resource='difficultyHardOnlyText'),
                    host_only=True,
                    maxwidth=txt2_max_w * 0.7,
                    position=(x + 60, y + 5),
                    transition=Text.Transition.FADE_IN,
                    vr_depth=-5,
                    h_attach=h_attach,
                    v_attach=v_attach,
                    h_align=Text.HAlign.CENTER,
                    v_align=Text.VAlign.CENTER,
                    scale=txt_s * 0.8,
                    flatness=1.0,
                    shadow=0.5,
                    color=(1, 1, 0.6, 1),
                    transition_delay=delay + 0.1,
                    transition_out_delay=out_delay_fin,
                ).autoretain()
                txtactor.node.rotate = 10
                objs.append(txtactor)

            # Chest award.
            award_x = -100
            chesttype = self.get_award_chest_type()
            chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
                chesttype, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
            )
            objs.append(
                Image(
                    # Provide magical extended dict version of texture
                    # that Image actor supports.
                    texture={
                        'texture': bascenev1.gettexture(
                            chestdisplayinfo.texclosed
                        ),
                        'tint_texture': bascenev1.gettexture(
                            chestdisplayinfo.texclosedtint
                        ),
                        'tint_color': chestdisplayinfo.tint,
                        'tint2_color': chestdisplayinfo.tint2,
                        'mask_texture': None,
                    },
                    color=chestdisplayinfo.color + (0.5 if hmo else 1.0,),
                    position=(x + award_x + 37, y + 12),
                    scale=(32.0, 32.0),
                    transition=Image.Transition.FADE_IN,
                    transition_delay=delay + 0.05,
                    transition_out_delay=out_delay_fin,
                    host_only=True,
                    attach=Image.Attach.TOP_LEFT,
                ).autoretain()
            )

            # objs.append(
            #     Text(
            #         babase.charstr(babase.SpecialChar.TICKET),
            #         host_only=True,
            #         position=(x + award_x + 33, y + 7),
            #         transition=Text.Transition.FADE_IN,
            #         scale=1.5,
            #         h_attach=h_attach,
            #         v_attach=v_attach,
            #         h_align=Text.HAlign.CENTER,
            #         v_align=Text.VAlign.CENTER,
            #         color=(1, 1, 1, 0.2 if hmo else 0.4),
            #         transition_delay=delay + 0.05,
            #         transition_out_delay=out_delay_fin,
            #     ).autoretain()
            # )
            # objs.append(
            #     Text(
            #         '+' + str(self.get_award_ticket_value()),
            #         host_only=True,
            #         position=(x + award_x + 28, y + 16),
            #         transition=Text.Transition.FADE_IN,
            #         scale=0.7,
            #         flatness=1,
            #         h_attach=h_attach,
            #         v_attach=v_attach,
            #         h_align=Text.HAlign.CENTER,
            #         v_align=Text.VAlign.CENTER,
            #         color=cl2,
            #         transition_delay=delay + 0.05,
            #         transition_out_delay=out_delay_fin,
            #     ).autoretain()
            # )

        else:
            complete = self.complete
            objs = []
            c_icon = self.get_icon_color(complete)
            if hmo and not complete:
                c_icon = (c_icon[0], c_icon[1], c_icon[2], c_icon[3] * 0.3)
            objs.append(
                Image(
                    self.get_icon_texture(complete),
                    host_only=True,
                    color=c_icon,
                    position=(x - 25, y + 5),
                    attach=attach,
                    vr_depth=4,
                    transition=Image.Transition.IN_RIGHT,
                    transition_delay=delay,
                    transition_out_delay=None,
                    scale=(40, 40),
                ).autoretain()
            )
            if complete:
                objs.append(
                    Image(
                        bascenev1.gettexture('achievementOutline'),
                        host_only=True,
                        mesh_transparent=bascenev1.getmesh(
                            'achievementOutline'
                        ),
                        color=(2, 1.4, 0.4, 1),
                        vr_depth=8,
                        position=(x - 25, y + 5),
                        attach=attach,
                        transition=Image.Transition.IN_RIGHT,
                        transition_delay=delay,
                        transition_out_delay=None,
                        scale=(40, 40),
                    ).autoretain()
                )
            else:
                if not complete:
                    award_x = -100
                    # objs.append(
                    #     Text(
                    #         babase.charstr(babase.SpecialChar.TICKET),
                    #         host_only=True,
                    #         position=(x + award_x + 33, y + 7),
                    #         transition=Text.Transition.IN_RIGHT,
                    #         scale=1.5,
                    #         h_attach=h_attach,
                    #         v_attach=v_attach,
                    #         h_align=Text.HAlign.CENTER,
                    #         v_align=Text.VAlign.CENTER,
                    #         color=(1, 1, 1, (0.1 if hmo else 0.2)),
                    #         transition_delay=delay + 0.05,
                    #         transition_out_delay=None,
                    #     ).autoretain()
                    # )
                    chesttype = self.get_award_chest_type()
                    chestdisplayinfo = CHEST_APPEARANCE_DISPLAY_INFOS.get(
                        chesttype, CHEST_APPEARANCE_DISPLAY_INFO_DEFAULT
                    )
                    objs.append(
                        Image(
                            # Provide magical extended dict version of texture
                            # that Image actor supports.
                            texture={
                                'texture': bascenev1.gettexture(
                                    chestdisplayinfo.texclosed
                                ),
                                'tint_texture': bascenev1.gettexture(
                                    chestdisplayinfo.texclosedtint
                                ),
                                'tint_color': chestdisplayinfo.tint,
                                'tint2_color': chestdisplayinfo.tint2,
                                'mask_texture': None,
                            },
                            color=chestdisplayinfo.color
                            + (0.5 if hmo else 1.0,),
                            position=(x + award_x + 38, y + 14),
                            scale=(32.0, 32.0),
                            transition=Image.Transition.IN_RIGHT,
                            transition_delay=delay + 0.05,
                            transition_out_delay=None,
                            host_only=True,
                            attach=attach,
                        ).autoretain()
                    )

                    # objs.append(
                    #     Text(
                    #         '+' + str(self.get_award_ticket_value()),
                    #         host_only=True,
                    #         position=(x + award_x + 28, y + 16),
                    #         transition=Text.Transition.IN_RIGHT,
                    #         scale=0.7,
                    #         flatness=1,
                    #         h_attach=h_attach,
                    #         v_attach=v_attach,
                    #         h_align=Text.HAlign.CENTER,
                    #         v_align=Text.VAlign.CENTER,
                    #         color=(0.6, 0.6, 0.6, (0.2 if hmo else 0.4)),
                    #         transition_delay=delay + 0.05,
                    #         transition_out_delay=None,
                    #     ).autoretain()
                    # )

                    # Show 'hard-mode-only' only over incomplete achievements
                    # when that's the case.
                    if hmo:
                        txtactor = Text(
                            babase.Lstr(resource='difficultyHardOnlyText'),
                            host_only=True,
                            maxwidth=300 * 0.7,
                            position=(x + 60, y + 5),
                            transition=Text.Transition.FADE_IN,
                            vr_depth=-5,
                            h_attach=h_attach,
                            v_attach=v_attach,
                            h_align=Text.HAlign.CENTER,
                            v_align=Text.VAlign.CENTER,
                            scale=0.85 * 0.8,
                            flatness=1.0,
                            shadow=0.5,
                            color=(1, 1, 0.6, 1),
                            transition_delay=delay + 0.05,
                            transition_out_delay=None,
                        ).autoretain()
                        assert txtactor.node
                        txtactor.node.rotate = 10
                        objs.append(txtactor)

            objs.append(
                Text(
                    self.display_name,
                    host_only=True,
                    maxwidth=300,
                    position=(x, y + 2),
                    transition=Text.Transition.IN_RIGHT,
                    scale=0.85,
                    flatness=0.6,
                    h_attach=h_attach,
                    v_attach=v_attach,
                    color=(
                        (0.8, 0.93, 0.8, 1.0)
                        if complete
                        else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))
                    ),
                    transition_delay=delay + 0.05,
                    transition_out_delay=None,
                ).autoretain()
            )
            objs.append(
                Text(
                    self.description_complete if complete else self.description,
                    host_only=True,
                    maxwidth=400,
                    position=(x, y - 14),
                    transition=Text.Transition.IN_RIGHT,
                    vr_depth=-5,
                    h_attach=h_attach,
                    v_attach=v_attach,
                    scale=0.62,
                    flatness=1.0,
                    color=(
                        (0.6, 0.6, 0.6, 1.0)
                        if complete
                        else (0.6, 0.6, 0.6, (0.2 if hmo else 0.4))
                    ),
                    transition_delay=delay + 0.1,
                    transition_out_delay=None,
                ).autoretain()
            )
        return objs

    def _getconfig(self) -> dict[str, Any]:
        """
        Return the sub-dict in settings where this achievement's
        state is stored, creating it if need be.
        """
        val: dict[str, Any] = babase.app.config.setdefault(
            'Achievements', {}
        ).setdefault(self._name, {'Complete': False})
        assert isinstance(val, dict)
        return val

    def _remove_banner_slot(self) -> None:
        classic = babase.app.classic
        assert classic is not None
        assert self._completion_banner_slot is not None
        classic.ach.achievement_completion_banner_slots.remove(
            self._completion_banner_slot
        )
        self._completion_banner_slot = None

    def show_completion_banner(self, sound: bool = True) -> None:
        """Create the banner/sound for an acquired achievement announcement."""
        from bascenev1lib.actor.text import Text
        from bascenev1lib.actor.image import Image

        app = babase.app
        assert app.classic is not None
        app.classic.ach.last_achievement_display_time = babase.apptime()

        # Just piggy-back onto any current activity
        # (should we use the session instead?..)
        activity = bascenev1.getactivity(doraise=False)

        # If this gets called while this achievement is occupying a slot
        # already, ignore it. (probably should never happen in real
        # life but whatevs).
        if self._completion_banner_slot is not None:
            return

        if activity is None:
            print('show_completion_banner() called with no current activity!')
            return

        if sound:
            bascenev1.getsound('achievement').play(host_only=True)
        else:
            bascenev1.timer(
                0.5, lambda: bascenev1.getsound('ding').play(host_only=True)
            )

        in_time = 0.300
        out_time = 3.5

        base_vr_depth = 200

        # Find the first free slot.
        i = 0
        while True:
            if i not in app.classic.ach.achievement_completion_banner_slots:
                app.classic.ach.achievement_completion_banner_slots.add(i)
                self._completion_banner_slot = i

                # Remove us from that slot when we close.
                # Use an app-timer in an empty context so the removal
                # runs even if our activity/session dies.
                with babase.ContextRef.empty():
                    babase.apptimer(
                        in_time + out_time, self._remove_banner_slot
                    )
                break
            i += 1
        assert self._completion_banner_slot is not None
        y_offs = 110 * self._completion_banner_slot
        objs: list[bascenev1.Actor] = []
        obj = Image(
            bascenev1.gettexture('shadow'),
            position=(-30, 30 + y_offs),
            front=True,
            attach=Image.Attach.BOTTOM_CENTER,
            transition=Image.Transition.IN_BOTTOM,
            vr_depth=base_vr_depth - 100,
            transition_delay=in_time,
            transition_out_delay=out_time,
            color=(0.0, 0.1, 0, 1),
            scale=(1000, 300),
        ).autoretain()
        objs.append(obj)
        assert obj.node
        obj.node.host_only = True
        obj = Image(
            bascenev1.gettexture('light'),
            position=(-180, 60 + y_offs),
            front=True,
            attach=Image.Attach.BOTTOM_CENTER,
            vr_depth=base_vr_depth,
            transition=Image.Transition.IN_BOTTOM,
            transition_delay=in_time,
            transition_out_delay=out_time,
            color=(1.8, 1.8, 1.0, 0.0),
            scale=(40, 300),
        ).autoretain()
        objs.append(obj)
        assert obj.node
        obj.node.host_only = True
        obj.node.premultiplied = True
        combine = bascenev1.newnode(
            'combine', owner=obj.node, attrs={'size': 2}
        )
        bascenev1.animate(
            combine,
            'input0',
            {
                in_time: 0,
                in_time + 0.4: 30,
                in_time + 0.5: 40,
                in_time + 0.6: 30,
                in_time + 2.0: 0,
            },
        )
        bascenev1.animate(
            combine,
            'input1',
            {
                in_time: 0,
                in_time + 0.4: 200,
                in_time + 0.5: 500,
                in_time + 0.6: 200,
                in_time + 2.0: 0,
            },
        )
        combine.connectattr('output', obj.node, 'scale')
        bascenev1.animate(obj.node, 'rotate', {0: 0.0, 0.35: 360.0}, loop=True)
        obj = Image(
            self.get_icon_texture(True),
            position=(-180, 60 + y_offs),
            attach=Image.Attach.BOTTOM_CENTER,
            front=True,
            vr_depth=base_vr_depth - 10,
            transition=Image.Transition.IN_BOTTOM,
            transition_delay=in_time,
            transition_out_delay=out_time,
            scale=(100, 100),
        ).autoretain()
        objs.append(obj)
        assert obj.node
        obj.node.host_only = True

        # Flash.
        color = self.get_icon_color(True)
        combine = bascenev1.newnode(
            'combine', owner=obj.node, attrs={'size': 3}
        )
        keys = {
            in_time: 1.0 * color[0],
            in_time + 0.4: 1.5 * color[0],
            in_time + 0.5: 6.0 * color[0],
            in_time + 0.6: 1.5 * color[0],
            in_time + 2.0: 1.0 * color[0],
        }
        bascenev1.animate(combine, 'input0', keys)
        keys = {
            in_time: 1.0 * color[1],
            in_time + 0.4: 1.5 * color[1],
            in_time + 0.5: 6.0 * color[1],
            in_time + 0.6: 1.5 * color[1],
            in_time + 2.0: 1.0 * color[1],
        }
        bascenev1.animate(combine, 'input1', keys)
        keys = {
            in_time: 1.0 * color[2],
            in_time + 0.4: 1.5 * color[2],
            in_time + 0.5: 6.0 * color[2],
            in_time + 0.6: 1.5 * color[2],
            in_time + 2.0: 1.0 * color[2],
        }
        bascenev1.animate(combine, 'input2', keys)
        combine.connectattr('output', obj.node, 'color')

        obj = Image(
            bascenev1.gettexture('achievementOutline'),
            mesh_transparent=bascenev1.getmesh('achievementOutline'),
            position=(-180, 60 + y_offs),
            front=True,
            attach=Image.Attach.BOTTOM_CENTER,
            vr_depth=base_vr_depth,
            transition=Image.Transition.IN_BOTTOM,
            transition_delay=in_time,
            transition_out_delay=out_time,
            scale=(100, 100),
        ).autoretain()
        assert obj.node
        obj.node.host_only = True

        # Flash.
        color = (2, 1.4, 0.4, 1)
        combine = bascenev1.newnode(
            'combine', owner=obj.node, attrs={'size': 3}
        )
        keys = {
            in_time: 1.0 * color[0],
            in_time + 0.4: 1.5 * color[0],
            in_time + 0.5: 6.0 * color[0],
            in_time + 0.6: 1.5 * color[0],
            in_time + 2.0: 1.0 * color[0],
        }
        bascenev1.animate(combine, 'input0', keys)
        keys = {
            in_time: 1.0 * color[1],
            in_time + 0.4: 1.5 * color[1],
            in_time + 0.5: 6.0 * color[1],
            in_time + 0.6: 1.5 * color[1],
            in_time + 2.0: 1.0 * color[1],
        }
        bascenev1.animate(combine, 'input1', keys)
        keys = {
            in_time: 1.0 * color[2],
            in_time + 0.4: 1.5 * color[2],
            in_time + 0.5: 6.0 * color[2],
            in_time + 0.6: 1.5 * color[2],
            in_time + 2.0: 1.0 * color[2],
        }
        bascenev1.animate(combine, 'input2', keys)
        combine.connectattr('output', obj.node, 'color')
        objs.append(obj)

        objt = Text(
            babase.Lstr(
                value='${A}:',
                subs=[('${A}', babase.Lstr(resource='achievementText'))],
            ),
            position=(-120, 91 + y_offs),
            front=True,
            v_attach=Text.VAttach.BOTTOM,
            vr_depth=base_vr_depth - 10,
            transition=Text.Transition.IN_BOTTOM,
            flatness=0.5,
            transition_delay=in_time,
            transition_out_delay=out_time,
            color=(1, 1, 1, 0.8),
            scale=0.65,
        ).autoretain()
        objs.append(objt)
        assert objt.node
        objt.node.host_only = True

        objt = Text(
            self.display_name,
            position=(-120, 50 + y_offs),
            front=True,
            v_attach=Text.VAttach.BOTTOM,
            transition=Text.Transition.IN_BOTTOM,
            vr_depth=base_vr_depth,
            flatness=0.5,
            transition_delay=in_time,
            transition_out_delay=out_time,
            flash=True,
            color=(1, 0.8, 0, 1.0),
            scale=1.5,
        ).autoretain()
        objs.append(objt)
        assert objt.node
        objt.node.host_only = True

        # objt = Text(
        #     babase.charstr(babase.SpecialChar.TICKET),
        #     position=(-120 - 170 + 5, 75 + y_offs - 20),
        #     front=True,
        #     v_attach=Text.VAttach.BOTTOM,
        #     h_align=Text.HAlign.CENTER,
        #     v_align=Text.VAlign.CENTER,
        #     transition=Text.Transition.IN_BOTTOM,
        #     vr_depth=base_vr_depth,
        #     transition_delay=in_time,
        #     transition_out_delay=out_time,
        #     flash=True,
        #     color=(0.5, 0.5, 0.5, 1),
        #     scale=3.0,
        # ).autoretain()
        # objs.append(objt)
        # assert objt.node
        # objt.node.host_only = True

        # print('FIXME SHOW ACH CHEST3')
        # objt = Text(
        #     '+' + str(self.get_award_ticket_value()),
        #     position=(-120 - 180 + 5, 80 + y_offs - 20),
        #     v_attach=Text.VAttach.BOTTOM,
        #     front=True,
        #     h_align=Text.HAlign.CENTER,
        #     v_align=Text.VAlign.CENTER,
        #     transition=Text.Transition.IN_BOTTOM,
        #     vr_depth=base_vr_depth,
        #     flatness=0.5,
        #     shadow=1.0,
        #     transition_delay=in_time,
        #     transition_out_delay=out_time,
        #     flash=True,
        #     color=(0, 1, 0, 1),
        #     scale=1.5,
        # ).autoretain()
        # objs.append(objt)

        assert objt.node
        objt.node.host_only = True

        # Add the 'x 2' if we've got pro.
        # if app.classic.accounts.have_pro():
        #     objt = Text(
        #         'x 2',
        #         position=(-120 - 180 + 45, 80 + y_offs - 50),
        #         v_attach=Text.VAttach.BOTTOM,
        #         front=True,
        #         h_align=Text.HAlign.CENTER,
        #         v_align=Text.VAlign.CENTER,
        #         transition=Text.Transition.IN_BOTTOM,
        #         vr_depth=base_vr_depth,
        #         flatness=0.5,
        #         shadow=1.0,
        #         transition_delay=in_time,
        #         transition_out_delay=out_time,
        #         flash=True,
        #         color=(0.4, 0, 1, 1),
        #         scale=0.9,
        #     ).autoretain()
        #     objs.append(objt)
        #     assert objt.node
        #     objt.node.host_only = True

        objt = Text(
            self.description_complete,
            position=(-120, 30 + y_offs),
            front=True,
            v_attach=Text.VAttach.BOTTOM,
            transition=Text.Transition.IN_BOTTOM,
            vr_depth=base_vr_depth - 10,
            flatness=0.5,
            transition_delay=in_time,
            transition_out_delay=out_time,
            color=(1.0, 0.7, 0.5, 1.0),
            scale=0.8,
        ).autoretain()
        objs.append(objt)
        assert objt.node
        objt.node.host_only = True

        for actor in objs:
            bascenev1.timer(
                out_time + 1.000,
                babase.WeakCallStrict(
                    actor.handlemessage, bascenev1.DieMessage()
                ),
            )
