#! /usr/bin/python3
# Copyright (C)
#    2007-2008 Don Brown,
#    2010 Spike Burch <spikeb@gmail.com>,
#    2015-2016 Vasya Novikov
#    2018 Olivier Mehani <shtrom+bambam@ssji.net>
#    2018-2022 Marcin Owsiany <marcin@owsiany.pl>
#
#    This program is free software: you can redistribute it and/or modify
#    it under the terms of the GNU General Public License as published by
#    the Free Software Foundation, either version 3 of the License, or
#    (at your option) any later version.
#
#    This program is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#    GNU General Public License for more details.
#
#    You should have received a copy of the GNU General Public License
#    along with this program.  If not, see <http://www.gnu.org/licenses/>.

import argparse
import fnmatch
import gettext
import math
import os
import pygame
import random
import sys
from textwrap import fill


from pygame.locals import Color, QUIT, KEYDOWN, MOUSEMOTION, MOUSEBUTTONDOWN, MOUSEBUTTONUP


# noinspection PyPep8Naming
def N_(s): return s


# TRANSLATORS: command string to mute sounds.
# Must not contain spaces, and should be be at least 4 characters long,
# so that it is unlikely to be generated by a keyboard-mashing baby.
# However it is recommended to keep it shorter than 10 characters so that
# it is relatively easy to type by an adult without making mistakes.
MUTE_STRING = N_('mute')
# TRANSLATORS: command string to unmute sounds.
# Must not contain spaces, and should be be at least 4 characters long,
# so that it is unlikely to be generated by a keyboard-mashing baby.
# However it is recommended to keep it shorter than 10 characters so that
# it is relatively easy to type by an adult without making mistakes.
UNMUTE_STRING = N_('unmute')
# TRANSLATORS: command string to quit the game.
# Must not contain spaces, and should be be at least 4 characters long,
# so that it is unlikely to be generated by a keyboard-mashing baby.
# However it is recommended to keep it shorter than 10 characters so that
# it is relatively easy to type by an adult without making mistakes.
QUIT_STRING = N_('quit')


class BambamException(Exception):
    """Represents a bambam-specific exception."""
    pass


class ResourceLoadException(BambamException):
    """Represents a failure to load a resource."""

    def __init__(self, resource, message):
        self._resource = resource
        self._message = message

    def __str__(self):
        return _('Failed to load file "%(file)s": %(message)s') % dict(file=self._resource, message=self._message)


def init_joysticks():
    pygame.joystick.init()
    """
    Initialize all joysticks.
    """
    joystick_count = pygame.joystick.get_count()
    for i in range(joystick_count):
        joystick = pygame.joystick.Joystick(i)
        joystick.init()


def poll_for_any_key_press(clock):
    while True:
        clock.tick(60)
        for event in pygame.event.get():
            if event.type in [QUIT, KEYDOWN, pygame.JOYBUTTONDOWN, MOUSEBUTTONDOWN]:
                return


class Bambam:
    IMAGE_MAX_WIDTH = 700

    @classmethod
    def get_color(cls):
        """
        Return bright color varying over time.
        """
        hue = pygame.time.get_ticks() / 50 % 360
        color = Color('white')
        color.hsva = (hue, 100, 100, 100)
        return color

    @classmethod
    def load_image(cls, fullname):
        """
        Load image/, handling setting of the transparency color key.
        """
        try:
            image = pygame.image.load(fullname)

            size_x, size_y = image.get_rect().size
            if size_x > cls.IMAGE_MAX_WIDTH or size_y > cls.IMAGE_MAX_WIDTH:
                new_size_x = cls.IMAGE_MAX_WIDTH
                new_size_y = int(cls.IMAGE_MAX_WIDTH * (float(size_y)/size_x))
                if new_size_y < 1:
                    raise ResourceLoadException(
                        fullname,
                        _("image has height of 0 after resizing to fit within %(width)dx%(height)d pixels") % dict(
                            width=cls.IMAGE_MAX_WIDTH, height=cls.IMAGE_MAX_WIDTH))

                image = pygame.transform.scale(image, (new_size_x, new_size_y))

        except pygame.error as message:
            raise ResourceLoadException(fullname, message)

        return image.convert()

    @classmethod
    def load_sound(cls, name):
        """
        Load sound file in "data/".
        """
        class NoneSound:
            def play(self): pass
        if not pygame.mixer or not pygame.mixer.get_init():
            return NoneSound()
        try:
            return pygame.mixer.Sound(name)
        except pygame.error as message:
            raise ResourceLoadException(name, message)

    @classmethod
    def load_items(cls, lst, blacklist, load_function, failure_message):
        """
        Runs load_function on elements of lst unless they are blacklisted.
        """
        result = []
        errors_encountered = False
        for name in lst:
            if any(fnmatch.fnmatch(name, p) for p in blacklist):
                # TRANSLATORS: "item" can refer to an image or sound file path
                print(_("Skipping blacklisted item %s") % name)
            else:
                try:
                    result.append(load_function(name))
                except ResourceLoadException as e:
                    print(e)
                    errors_encountered = True
        if not result and errors_encountered:
            raise BambamException(failure_message)
        return result

    def __init__(self):
        self.colors = ((0, 0, 255), (255, 0, 0), (255, 255, 0),
                       (255, 0, 128), (0, 0, 128), (0, 255, 0),
                       (255, 128, 0), (255, 0, 255), (0, 255, 255)
                       )
        self.data_dirs = []
        self.args = None

        self.images = None
        self.sounds = None

        self.background = None
        self.screen = None
        self.display_height = None
        self.display_width = None

        self.sequence = ""
        self.sound_muted = None

    def draw_dot(self):
        """
        draw filled circle at mouse position.
        """
        r = 30
        mouse_x, mouse_y = pygame.mouse.get_pos()

        dot = pygame.Surface((2 * r, 2 * r))
        pygame.draw.circle(dot, self.get_color(), (r, r), r, 0)
        dot.set_colorkey(0, pygame.RLEACCEL)

        self.screen.blit(dot, (mouse_x - r, mouse_y - r))

    def process_keypress(self, event):
        """
        Processes events from keyboard or joystick.
        """
        # check for command words
        if event.type == KEYDOWN and event.unicode.isalpha():
            self.sequence += event.unicode
            if self.sequence.find(_(QUIT_STRING)) > -1:
                sys.exit(0)
            elif self.sequence.find(_(UNMUTE_STRING)) > -1:
                self.sound_muted = False
                # pygame.mixer.unpause()
                self.sequence = ''
            elif self.sequence.find(_(MUTE_STRING)) > -1:
                self.sound_muted = True
                pygame.mixer.fadeout(1000)
                self.sequence = ''

        # Clear the screen 10% of the time
        if random.randint(0, 10) == 1:
            self.screen.blit(self.background, (0, 0))
            pygame.display.flip()

        # play random sound
        if not self.sound_muted:
            if event.type == KEYDOWN and self.args.deterministic_sounds:
                self.sounds[event.key % len(self.sounds)].play()
            else:
                self.sounds[random.randint(
                    0, len(self.sounds) - 1)].play()

        # show self.images
        if event.type == pygame.KEYDOWN and (event.unicode.isalpha() or event.unicode.isdigit()):
            self.print_letter(event.unicode)
        else:
            self.print_image()
        pygame.display.flip()

    def print_image(self):
        """
        Prints an image at a random location.
        """
        img = self.images[random.randint(0, len(self.images) - 1)]
        w = random.randint(0, self.display_width - img.get_width())
        h = random.randint(0, self.display_height - img.get_height())
        self.screen.blit(img, (w, h))

    def print_letter(self, char):
        """
        Prints a letter at a random location.
        """
        font = pygame.font.Font(None, 256)
        if self.args.uppercase:
            char = char.upper()
        text = font.render(
            char, 1, self.colors[random.randint(0, len(self.colors) - 1)])
        text_pos = text.get_rect()
        center = (text_pos.width // 2, text_pos.height // 2)
        w = random.randint(0 + center[0], self.display_width - center[0])
        h = random.randint(0 + center[1], self.display_height - center[1])
        text_pos.centerx = w
        text_pos.centery = h
        self.screen.blit(text, text_pos)

    def glob_dir(self, path, extensions):
        files = []
        for file_name in os.listdir(path):
            path_name = os.path.join(path, file_name)
            if os.path.isdir(path_name):
                files.extend(self.glob_dir(path_name, extensions))
            else:
                for ext in extensions:
                    if path_name.lower().endswith(ext):
                        files.append(path_name)
                        break

        return files

    def glob_data(self, extensions):
        """
        Search for files ending with any of the provided extensions. Eg:
        extensions = ['.abc'] will be similar to `ls *.abc` in the configured
        data dirs. Matching will be case-insensitive.
        """
        extensions = [x.lower() for x in extensions]
        file_list = []
        for data_dir in self.data_dirs:
            file_list.extend(self.glob_dir(data_dir, extensions))
        return file_list

    def _prepare_background(self):
        # noinspection PyArgumentList
        self.background = pygame.Surface(self.screen.get_size()).convert()
        self.background_color = (0, 0, 0) if self.args.dark else (250, 250, 250)
        self.background.fill(self.background_color)
        caption_font = pygame.font.SysFont(None, 20)
        caption_label = caption_font.render(
            # TRANSLATORS: the string is space-separated list of all command strings.
            _("Commands: %s") % " ".join(_(s) for s in [QUIT_STRING, MUTE_STRING, UNMUTE_STRING]),
            True,
            (210, 210, 210),
            self.background_color)
        caption_rect = caption_label.get_rect()
        caption_rect.x = 15
        caption_rect.y = 10
        self.background.blit(caption_label, caption_rect)

    def _prepare_wayland_warning(self):
        font_size = 80
        caption_font = pygame.font.SysFont(None, font_size)
        for i, msg in enumerate([
                _("Error: Wayland display detected."),
                _("Cannot lock the keyboard safely."),
                "",
                _("Press any key to quit.")]):
            caption_label = caption_font.render(
                msg,
                True,
                (250, 0, 0),
                self.background_color)
            caption_rect = caption_label.get_rect()
            caption_rect.x = 150
            caption_rect.y = 100 + (i * font_size)
            self.screen.blit(caption_label, caption_rect)
        pygame.display.flip()

    def _prepare_welcome_message(self, dedicated_session):
        header_font = pygame.font.SysFont(None, 56)
        header_text = _("Please read the following important information!")
        header_label = header_font.render(header_text, True, pygame.Color('blue'), self.background_color)
        header_rect = header_label.get_rect()
        header_rect.x = 150
        header_rect.y = 100
        self.screen.blit(header_label, header_rect)
        header_padding = 20

        text_font_size = 36

        # Draw an arrow starting next to second/third line of text (the text that speaks about the commands)...
        arrow_start = (header_rect.x, int(header_rect.y + header_rect.height + header_padding + text_font_size * 1.5))
        # ... and ending below the list of commands.
        arrow_end = (30, 30)

        arrow_rect = pygame.Rect(arrow_end, (arrow_start[0] - arrow_end[0], arrow_start[1] - arrow_end[1]))
        # The arc is a quarter of an elipse, so the elipse bounds are four times the size of the arrow arc bounds.
        above_arrow_rect = pygame.Rect(arrow_rect)
        above_arrow_rect.bottomleft = arrow_rect.topleft
        east_of_arrow_rect = pygame.Rect(arrow_rect)
        east_of_arrow_rect.bottomleft = arrow_rect.bottomright
        elipse_bounds = pygame.Rect(above_arrow_rect.topleft, (arrow_rect.width*2, arrow_rect.height*2))

        arrow_color = pygame.Color('red')
        arrow_width = 8
        pygame.draw.arc(self.screen, arrow_color, elipse_bounds, math.pi, 3*math.pi/2, arrow_width)
        # Account for the width of the arrow arc.
        arrow_head_start = (arrow_end[0] + int(arrow_width / 2)-1, arrow_end[1])
        arrow_head_end1 = (arrow_head_start[0] - 20, arrow_head_start[1] + 40)
        arrow_head_end2 = (arrow_head_start[0] + 20, arrow_head_start[1] + 40)
        pygame.draw.line(self.screen, arrow_color, arrow_head_start, arrow_head_end1, arrow_width)
        pygame.draw.line(self.screen, arrow_color, arrow_head_start, arrow_head_end2, arrow_width)

        text_font = pygame.font.SysFont(None, text_font_size)
        texts = []
        # TRANSLATORS: the substituted word will be the translated command for quitting the game.
        texts.append(_("To quit the game after it starts, directly type the word %s on the keyboard.") % _(QUIT_STRING))
        # TRANSLATORS: "this" means the word quit from the preceding message, in this context.
        texts.append(_("This, and other available commands are mentioned in the upper left-hand corner of the window."))
        texts.append("")
        texts.append(_(
            "The game tries to grab the keyboard and mouse pointer focus, "
            "to keep your child from causing damage to your files."))
        if dedicated_session:
            texts.append(_(
                "The game is now running in a dedicated login session, which provides some additional safety. "
                "However it may still be possible for the child to accidentally quit the game, "
                "or swich to a different virtual terminal (for example using CTRL+ALT+Fx)."))
            texts.append("")
            texts.append(_(
                "Make sure other user sessions (if any) are locked with a password, "
                "if leaving your child unattended with the game."))
        else:
            texts.append(_(
                "However in some environments it may be possible for the child to exit or "
                "switch away from the game by using a special key combination. "
                "The exact mechanism depends on your graphical environment, window manager, etc. "
                "Examples include the Super (also known as Windows) key, function key combinations (CTRL+ALT+Fx) or "
                "hot corners when using the mouse."))
            texts.append("")
            texts.append(_("We recommend to NOT LEAVE YOUR CHILD UNATTENDED with the game."))
            texts.append(_(
                "Please consider using a dedicated BamBam session instead "
                "(look for a gear icon when logging in), which is safer."))
        texts.append("")
        texts.append("")
        texts.append(_("Press any key or mouse button to start the game now."))
        prev_rect = header_rect
        prev_rect.y += header_padding
        for paragraph in texts:
            for line in fill(paragraph, 70).split("\n"):
                text_label = text_font.render(line, True, pygame.Color('lightblue'), self.background_color)
                text_rect = text_label.get_rect()
                text_rect.x = 150
                text_rect.y = prev_rect.y + prev_rect.height
                self.screen.blit(text_label, text_rect)
                prev_rect = text_rect
        pygame.display.flip()

    def run(self):
        """
        Main application entry point.
        """
        program_base = os.path.dirname(os.path.realpath(sys.argv[0]))

        dist_data_dir = os.path.join(program_base, 'data')
        if os.path.isdir(dist_data_dir):
            print(_('Using data directory %s') % dist_data_dir)
            self.data_dirs.append(dist_data_dir)
        installed_data_dir = os.path.join(os.path.dirname(program_base), 'share')
        xdg_data_home = os.getenv('XDG_DATA_HOME', os.path.expanduser('~/.local/share'))
        for bambam_base_dir in [installed_data_dir, xdg_data_home]:
            extra_data_dir = os.path.join(bambam_base_dir, 'bambam', 'data')
            if os.path.isdir(extra_data_dir):
                print(_('Using data directory %s') % extra_data_dir)
                self.data_dirs.append(extra_data_dir)

        parser = argparse.ArgumentParser(
            description=_('Keyboard mashing and doodling game for babies and toddlers.'))
        parser.add_argument('-u', '--uppercase', action='store_true',
                            help=_('Show UPPER-CASE letters.'))
        parser.add_argument('--sound_blacklist', action='append', default=[],
                            help=_('List of sound filename patterns to never play.'))
        parser.add_argument('--image_blacklist', action='append', default=[],
                            help=_('List of image filename patterns to never show.'))
        parser.add_argument('-d', '--deterministic-sounds', action='store_true',
                            help=_('Produce same sounds on same key presses.'))
        parser.add_argument('-D', '--dark', action='store_true',
                            help=_('Use a dark background instead of a light one.'))
        parser.add_argument('-m', '--mute', action='store_true',
                            help=_('Do not play any sounds.'))
        parser.add_argument('--wayland-ok', action='store_true',
                            help=_('Do not prevent running under Wayland.'))
        parser.add_argument('--in-dedicated-session', action='store_true',
                            help=argparse.SUPPRESS)
        self.args = parser.parse_args()

        pygame.init()

        if not pygame.font:
            print(_('Warning, fonts disabled.'))
        if not pygame.mixer or not pygame.mixer.get_init():
            print(_('Warning, sound disabled.'))

        pygame.display.set_mode((0, 0), pygame.FULLSCREEN)

        # determine display resolution
        display_info = pygame.display.Info()
        self.display_width = display_info.current_w
        self.display_height = display_info.current_h

        # TRANSLATORS: Main game window name.
        pygame.display.set_caption(_('Bam Bam'))
        self.screen = pygame.display.get_surface()

        self._prepare_background()
        clock = pygame.time.Clock()

        self.screen.blit(self.background, (0, 0))
        pygame.display.flip()

        if self.args.in_dedicated_session:
            self._prepare_welcome_message(dedicated_session=True)
        elif not self.args.wayland_ok and (os.getenv('WAYLAND_DISPLAY') or os.getenv('XDG_SESSION_TYPE') == 'wayland'):
            self._prepare_wayland_warning()
            poll_for_any_key_press(clock)
            sys.exit(1)
        else:
            self._prepare_welcome_message(dedicated_session=False)
        poll_for_any_key_press(clock)
        self.screen.blit(self.background, (0, 0))
        pygame.display.flip()

        self.sound_muted = self.args.mute

        self.sounds = self.load_items(
            self.glob_data(['.wav', '.ogg']),
            self.args.sound_blacklist,
            self.load_sound,
            _("All sounds failed to load."))

        self.images = self.load_items(
            self.glob_data(['.gif', '.jpg', '.jpeg', '.png', '.tif', '.tiff']),
            self.args.image_blacklist,
            self.load_image,
            _("All images failed to load."))

        init_joysticks()

        mouse_pressed = False
        while True:
            clock.tick(60)
            for event in pygame.event.get():
                if event.type == QUIT:
                    sys.exit(0)

                elif event.type == KEYDOWN or event.type == pygame.JOYBUTTONDOWN:
                    self.process_keypress(event)

                # mouse motion
                elif event.type == MOUSEMOTION:
                    if mouse_pressed:
                        self.draw_dot()
                        pygame.display.flip()

                # mouse button down
                elif event.type == MOUSEBUTTONDOWN:
                    self.draw_dot()
                    mouse_pressed = True
                    pygame.display.flip()

                # mouse button up
                elif event.type == MOUSEBUTTONUP:
                    mouse_pressed = False


def main():
    gettext.install('bambam')
    try:
        bambam = Bambam()
        bambam.run()
    except BambamException as e:
        print(e)
        sys.exit(1)


if __name__ == '__main__':
    main()
