"""Painless animated sprites which are pygame.sprite.Sprite objects.

Animated sprites are loaded from GIF, but there will be other ways
very soon! When using an AnimatedSprite you must remember to update
it every main loop iteration with timedelta:

  >>> AnimatedSprite.from_gif('example.gif')  # doctest: +SKIP
  >>> timedelta = clock.get_time()  # doctest: +SKIP
  >>> animated_sprite.update(timedelta)  # doctest: +SKIP

A word on `rect` and `mask`:
    `pygame.Sprite` has the `rect` attribute and _optionally_ the
    `mask` attribute. AnimatedSprite gets those attributes from
    its frames, however there are some important notes regarding
    these two properties, AnimatedSprite.rect (always there) and
    AnimatedSprite.mask (optional)...

    AnimatedSprite.rect.size is updated with the current frame's
    size every time you use AnimatedSprite.update(). This means you
    can use AnimatedSprite.rect for position on screen or position
    on map, whatever, because AnimatedSprite.rect.topleft won't change
    unless _you_ change it!

    Likewise, AnimatedSprite.mask is set to the current frame's mask,
    if you told AnimatedSprite to generate a mask (it's optional).
    There are plans in the near future to allow for a single custom
    mask or ways to provide custom frame masks.

    When you load an animated sprite, you can specify a threshold:

      >>> AnimatedSprite.from_gif('example.gif',
      ...                         mask_threshold=254)  # doctest: +SKIP

    Whereas, the threshold denotes the alpha transparency value for a
    pixel, which is opaque enough to be "set".

What's supported:
    Right now there's only support for GIFs, but I'd like to expand that
    (especially since GIFs can't really take advantage of alpha
    transparency).

"""

import pygame
from PIL import Image


# NOTE: could be a sprite...
class Frame(object):
    """A frame of an AnimatedSprite animation.

    Attributes:
        surface (pygame.Surface): The PyGame image which is used
            for a frame of an animation.
        mask (pygame.Mask): Mask automatically generated from the
            supplied surface (see above). Only exists if instantiated
            with mask_threshold >0.
        duration (integer): Milliseconds this frame lasts. How
            long this frame is displayed in corresponding animation.
        start_time (integer): The animation position in milleseconds,
            when this frame will start being displayed.
        stop_time (integer): The animation position in milleseconds,
            when this frame will stop being displayed.

    See Also:
        * AnimatedSprite.frames_from_gif()
        * AnimatedSprite.animation_position

    """

    def __init__(self, surface, start_time, duration, mask_threshold=0):
        """Create a frame using a pygame surface, the start time,
        and the duration time.

        Args:
            surface (pygame.Surface): The surface/image for this
                frame.
            start_time (int): Millisecond this frame starts. This
                frame is a part of a larger series of frames and
                in order to render the animation properly we
                need to know when each frame begins to be drawn,
                while duration signifies when it ends.
            duration (integer): Milleseconds this frame lasts. See:
                start_time argument description.
            mask_threshold (int): Valid values 0-254. Alpha values
                ABOVE this provided number are marked as "solid"/
                collidable/set. If this is not greater than zero,
                the mask is not generated.

        """

        self.surface = surface
        self.duration = duration
        self.start_time = start_time
        self.end_time = start_time + duration

        if mask_threshold > 0:
            self.mask = pygame.mask.from_surface(surface, mask_threshold)

    def __repr__(self):
        s = "<Frame duration(%s) start_time(%s) end_time(%s)>"

        return s % (self.duration, self.start_time, self.end_time)


# XXX: make sense to call it AniSprite?
class AnimatedSprite(pygame.sprite.Sprite):
    """An animated sprite.

    The main thing! Treat like a pygame.sprite.Sprite!

    Attributes:
        total_duration (int): The total duration of of this
            animation in milliseconds.
        image (pygame.Surface): Current surface belonging to
            the active frame, both set by AnimatedSprite.update().
        rect (pygame.Rect): When update() is called this
            rect size changes to match the current frame's.
            You'll want to checkout AnimatedSprite.update().
        mask (pygame.Mask): If the first frame had a mask
            attribute, then we assume all do, and this
            (optional) attribute points to the active
            frame's mask attribute.
        frames (list[Frame]): --
        active_frame_index (int): The current frame, by `frames`
            index. This is set by AnimatedSprite.update(). You'll
            want to see the `frames` attribute and of course
            AnimatedSprite.update().
        active_frame: The current surface representing this
            animation at its current animation position. The
            AnimatedSprite.update() method sets this.
        animation_position (int): Animation position in
            milliseconds; milleseconds elapsed in this
            animation. This is used for determining
            which frame to select. Set once per tick through
            the AnimatedSprite.update() method.

    See Also:
        * :class:`pygame.sprite.Sprite`
        * :class:`Frame`

    """

    def __init__(self, frames):
        """Create this AnimatedSprite using
        a list of Frame instances.

        Args:
            frames (list[Frame]): A properly assembled list of frames,
                which assumes that each Frame's start_time is greater
                than the previous element and is the previous element's
                start time + previous element/Frame's duration. Here
                is an example of aformentioned:

                >>> frame_one_surface = pygame.Surface((16, 16))
                >>> frame_one = Frame(frame_one_surface, 0, 100)
                >>> frame_two_surface = pygame.Surface((16, 16))
                >>> frame_two = Frame(frame_two_surface, 100, 50)

        Note:
            In the future I may add a method for verifying the
            validity of Frame start_times and durations.

        """

        super(AnimatedSprite, self).__init__()
        self.frames = frames
        self.total_duration = self.get_total_duration(self.frames)
        self.active_frame_index = 0
        self.active_frame = self.frames[self.active_frame_index]

        # animation position in milliseconds
        self.animation_position = 0

        # this gets updated depending on the frame/time
        # needs to be a surface.
        self.image = self.frames[0].surface

        # represents the animated sprite's position
        # on screen.
        self.rect = self.image.get_rect()

        # making the bold assumption that if the
        # first frame has a mask, as do the rest.
        if hasattr(self.frames[0], 'mask'):
            self.mask = self.frames[0].mask

    def __getitem__(self, frame_index):
        """Return the frame corresponding to
        the supplied frame_index.

        Args:
            frame_index (int): Index number to lookup
                a frame by element number in the
                self.frames list.

        Returns:
            Frame: The frame of this animation at the
                specified index of frame_index.

        """

        return self.frames[frame_index]

    def largest_frame_size(self):
        """Return the largest frame's (by area)
        dimensions as tuple(int x, int y).

        Returns:
            tuple (x, y): pixel dimensions of the largest
                frame surface in this AnimatedSprite.

        """

        largest_frame_size = (0, 0)

        for frame in self.frames:
            largest_x, largest_y = largest_frame_size
            largest_area = largest_x * largest_y

            frame_size = frame.surface.get_size()
            frame_x, frame_y = frame_size
            frame_area = frame_x * frame_y

            if frame_area > largest_area:
                largest_frame_size = (frame_size)

        return largest_frame_size

    @classmethod
    def from_gif(cls, path_or_readable, mask_threshold=0):
        """The default is to create from gif bytes, but this can
        also be done from other methods...

        Create a list of surfaces (frames) and a list of their
        respective frame durations from an animated GIF.

        Args:
            path_or_readable (str|file-like-object): Either a string
                or an object with a read() method. So, either a path
                to an animated GIF, or a file-like-object/buffer of
                an animated GIF.
            mask_threshold (int): An optional keyword argument which
                must be >0 to generate masks automatically per frame.
                This value is used to note which parts are opaque and
                thus collidable, and which values are not. Think of
                RGBA, valid values are 0-254. See also: Frame().

        Returns:
            AnimatedSprite: --

        """

        pil_gif = Image.open(path_or_readable)

        frame_index = 0
        frames = []
        time_position = 0

        try:

            while True:
                duration = pil_gif.info['duration']
                frame_sprite = cls.pil_image_to_pygame_surface(pil_gif)
                frame = Frame(surface=frame_sprite,
                              start_time=time_position,
                              duration=duration,
                              mask_threshold=mask_threshold)
                frames.append(frame)
                frame_index += 1
                time_position += duration
                pil_gif.seek(pil_gif.tell() + 1)

        except EOFError:

            pass  # end of sequence

        return AnimatedSprite(frames)

    def update(self, timedelta):
        """Manipulate the state of this AnimatedSprite, namely
        the on-screen/viewport position (not absolute) and
        using the timedelta to do animation manipulations.

        Using the game's timedelta we decipher the animation position,
        which in turn allows us to locate the correct frame.

        Sets the image attribute to the current frame's image. Updates
        the rect attribute to the new relative position and frame size.

        Warning:
            Since we're changing the rect size on-the-fly, this can
            get the player stuck in certain boundaries. I will be
            remedying this in the future.

        Args:
            timedelta (int|float): Typically from the game
                clock (pygame.time.Clock) via clock.get_time().
                Used to update the animation position.

        """

        self.animation_position += timedelta

        if self.animation_position >= self.total_duration:
            self.animation_position = (self.animation_position %
                                       self.total_duration)
            self.active_frame_index = 0

        while (self.animation_position >
               self.frames[self.active_frame_index].end_time):

            self.active_frame_index += 1

        # NOTE: the fact that I'm using -1 here seems sloppy/hacky
        self.image = self.frames[self.active_frame_index - 1].surface

        self.rect.size = self.image.get_size()
        self.active_frame = self.frames[self.active_frame_index]

        # if we have a mask, let's update our pointer!
        # again, we make the bold assumption that if
        # our first frame had a mask, then the rest do.
        if hasattr(self, 'mask'):
            self.mask = self.active_frame.mask

    @staticmethod
    def get_total_duration(frames):
        """Return the total duration of the animation in milliseconds,
        milliseconds, from animation frame durations.

        Args:
            frames (List[AnimatedSpriteFrame]): --

        Returns:
            int: The sum of all the frame's "duration" attribute.

        """

        return sum([frame.duration for frame in frames])

    @staticmethod
    def pil_image_to_pygame_surface(pil_image):
        """Convert PIL Image() to RGBA pygame Surface.

        Args:
            pil_image (Image): image to convert to pygame.Surface().

        Returns:
            pygame.Surface: the converted image

        Example:
            >>> from PIL import Image
            >>> gif = Image.open('tests/resources/test-scene.gif')
            >>> AnimatedSprite.pil_image_to_pygame_surface(gif)
            <Surface(10x10x32 SW)>

        """

        image_as_string = pil_image.convert('RGBA').tobytes()

        return pygame.image.fromstring(image_as_string,
                                       pil_image.size,
                                       'RGBA')
