# coding=utf-8
from __future__ import division
from __future__ import absolute_import
from __future__ import print_function
from builtins import object
from builtins import range
import copy
from random import randint, random
from future.utils import with_metaclass
from abc import ABCMeta, abstractproperty, abstractmethod
from pyfiglet import Figlet, DEFAULT_FONT
from PIL import Image
import re
from .screen import Screen


#: Attribute conversion table for the ${c,a} form of attributes for
#: :py:obj:`.paint`.
ATTRIBUTES = {
    "1": Screen.A_BOLD,
    "2": Screen.A_NORMAL,
    "3": Screen.A_REVERSE,
    "4": Screen.A_UNDERLINE,
}


class Renderer(with_metaclass(ABCMeta, object)):
    """
    A Renderer is simply a class that will return one or more text renderings
    for display by an Effect.

    In the simple case, this can be a single string that contains some
    unchanging content - e.g. a simple text message.

    It can also represent a sequence of strings that can be played one after
    the other to make a simple animation sequence - e.g. a rotating globe.
    """

    @abstractproperty
    def max_width(self):
        """
        :return: The max width of the rendered text (across all images if an
            animated renderer.
        """

    @abstractproperty
    def rendered_text(self):
        """
        :return: The next image and colour map in the sequence as a tuple.
        """

    @abstractproperty
    def images(self):
        """
        :return: An iterator of all the images in the Renderer.
        """

    @abstractproperty
    def max_height(self):
        """
        :return: The max height of the rendered text (across all images if an
            animated renderer.
        """

    def __repr__(self):
        """
        :returns: a plain string representation of the next rendered image.
        """
        return "\n".join(self.rendered_text[0])


class StaticRenderer(Renderer):
    """
    A StaticRenderer is a Renderer that can create all possible images in
    advance.  After construction the images will not change, but can by cycled
    for animation purposes.

    This class will also convert text like ${c,a} into colour c, attribute a
    for any subsequent text in the line, thus allowing multi-coloured text.  The
    attribute is optional.
    """

    # Regular expression for use to find colour sequences in multi-colour text.
    # It should match ${n} or ${m,n}
    _colour_esc_code = r"^\$\{((\d+),(\d+)|(\d+))\}(.*)"
    _colour_sequence = re.compile(_colour_esc_code)

    def __init__(self, images=None, animation=None):
        """
        :param images: An optional set of ascii images to be rendered.
        :param animation: A function to pick the image (from images) to be
                          rendered for any given frame.
        """
        self._images = images if images is not None else []
        self._index = 0
        self._max_width = 0
        self._max_height = 0
        self._animation = animation
        self._colour_map = None
        self._plain_images = []

    def _convert_images(self):
        """
        Convert any images into a more Screen-friendly format.
        """
        self._plain_images = []
        self._colour_map = []
        for image in self._images:
            colour_map = []
            new_image = []
            for line in image.split("\n"):
                new_line = ""
                attributes = (None, None)
                colours = []
                while len(line) > 0:
                    match = self._colour_sequence.match(line)
                    if match is None:
                        new_line += line[0]
                        colours.append(attributes)
                        line = line[1:]
                    else:
                        # The regexp either matches ${c,a} for group 2,3 or
                        # ${c} for group 4.
                        if match.group(3) is None:
                            attributes = (int(match.group(4)), 0)
                        else:
                            attributes = (int(match.group(2)),
                                          ATTRIBUTES[match.group(3)])
                        line = match.group(5)
                new_image.append(new_line)
                colour_map.append(colours)
            self._plain_images.append(new_image)
            self._colour_map.append(colour_map)

    @property
    def images(self):
        """
        :return: An iterator of all the images in the Renderer.
        """
        if len(self._plain_images) <= 0:
            self._convert_images()

        return iter(self._plain_images)

    @property
    def rendered_text(self):
        """
        :return: The next image and colour map in the sequence as a tuple.
        """
        if len(self._plain_images) <= 0:
            self._convert_images()

        if self._animation is None:
            index = self._index
            self._index += 1
            if self._index >= len(self._plain_images):
                self._index = 0
        else:
            index = self._animation()
        return (self._plain_images[index],
                self._colour_map[index])

    @property
    def max_height(self):
        """
        :return: The max height of the rendered text (across all images if an
            animated renderer.
        """
        if len(self._plain_images) <= 0:
            self._convert_images()

        if self._max_height == 0:
            for image in self._plain_images:
                self._max_height = max(len(image), self._max_height)
        return self._max_height

    @property
    def max_width(self):
        """
        :return: The max width of the rendered text (across all images if an
            animated renderer.
        """
        if len(self._plain_images) <= 0:
            self._convert_images()

        if self._max_width == 0:
            for image in self._plain_images:
                new_max = max([len(x) for x in image])
                self._max_width = max(new_max, self._max_width)
        return self._max_width


class DynamicRenderer(with_metaclass(ABCMeta, Renderer)):
    """
    A DynamicRenderer is a Renderer that creates each image as requested.  It
    has a defined maximum size on construction.`
    """

    def __init__(self, height, width):
        """
        :param height: The max height of the rendered image.
        :param width: The max width of the rendered image.
        """
        super(DynamicRenderer, self).__init__()
        self._height = height
        self._width = width
        self._plain_image = []
        self._colour_map = []

    def _clear(self):
        """
        Clear the current image.
        """
        self._plain_image = [" " * self._width for _ in range(self._height)]
        self._colour_map = [[(None, 0, 0) for _ in range(self._width)]
                            for _ in range(self._height)]

    def _write(self, text, x, y, colour=Screen.COLOUR_WHITE,
               attr=Screen.A_NORMAL, bg=Screen.COLOUR_BLACK):
        """
        Write some text to the specified location in the current image.

        :param text: The text to be added.
        :param x: The X coordinate in the image.
        :param y: The Y coordinate in the image.
        :param colour: The colour of the text to add.
        :param attr: The attribute of the image.
        :param bg: The background colour of the text to add.
        """
        self._plain_image[y] = text.join(
            [self._plain_image[y][:x], self._plain_image[y][x+len(text):]])
        for i, c in enumerate(text):
            self._colour_map[y][x + i] = (colour, attr, bg)

    @abstractmethod
    def _render_now(self):
        """
        Common method to render the latest image.

        :returns: A tuple of the plain image and the colour map as per
        :py:meth:`.rendered_text`.
        """

    @property
    def images(self):
        # We can't return all, so just return the latest rendered image.
        self._clear()
        return [self._render_now()[0]]

    @property
    def rendered_text(self):
        self._clear()
        return self._render_now()

    @property
    def max_height(self):
        return self._height

    @property
    def max_width(self):
        return self._width


class FigletText(StaticRenderer):
    """
    This class renders the supplied text using the specified Figlet font.
    See http://www.figlet.org/ for details of available fonts.
    """

    def __init__(self, text, font=DEFAULT_FONT):
        """
        :param text: The text string to convert with Figlet.
        :param font: The Figlet font to use (optional).
        """
        super(FigletText, self).__init__()
        self._images = [Figlet(font=font, width=200).renderText(text)]


class _ImageSequence(object):
    """
    Simple class to make an iterator for a PIL Image object.
    """

    def __init__(self, im):
        self.im = im

    def __getitem__(self, ix):
        try:
            if ix:
                self.im.seek(ix)
            return self.im
        except EOFError:
            raise IndexError


class ImageFile(StaticRenderer):
    """
    Renderer to convert an image file (as supported by the Python Imaging
    Library) into an ascii grey scale text image.
    """

    # The ASCII grey scale from darkest to lightest.
    _greyscale = ' .:;rsA23hHG#9&@'

    def __init__(self, filename, height=30, colours=8):
        """
        :param filename: The name of the file to render.
        :param height: The height of the text rendered image.
        :param colours: The number of colours the terminal supports.
        """
        super(ImageFile, self).__init__()
        image = Image.open(filename)
        background = image.info['background'] if 'background' in image.info \
            else None
        for frame in _ImageSequence(image):
            ascii_image = ""
            frame = frame.resize(
                (int(frame.size[0] * height * 2.0 / frame.size[1]), height),
                Image.BICUBIC)
            grey_frame = frame.convert('L')
            for py in range(0, grey_frame.size[1]):
                ascii_image += "\n"
                for px in range(0, grey_frame.size[0]):
                    real_col = frame.getpixel((px, py))
                    col = grey_frame.getpixel((px, py))
                    if real_col == background:
                        ascii_image += " "
                    else:
                        if colours >= 256:
                            ascii_image += "${%d}" % (232 + col * 23//256)
                        else:
                            ascii_image += "${%d,%d}" % (
                                7 if col >= 85 else 0,
                                Screen.A_BOLD if col < 85 or col > 170 else
                                Screen.A_NORMAL
                            )
                        ascii_image += self._greyscale[
                            (int(col) * len(self._greyscale)) // 256]
            self._images.append(ascii_image)


class ColourImageFile(StaticRenderer):
    """
    Renderer to convert an image file (as supported by the Python Imaging
    Library) into an block image of available colours.

    .. warning::

        This is only compatible with 256-colour terminals.  Results in other
        terminals with reduced colour capabilities are severely restricted.
        Since Windows only has 8 base colours, it is recommended that you
        avoid this renderer on that platform.
    """

    def __init__(self, screen, filename, height=30):
        """
        :param screen: The screen to use when displaying the image.
        :param filename: The name of the file to render.
        :param height: The height of the text rendered image.
        """
        super(ColourImageFile, self).__init__()
        image = Image.open(filename)

        # Find any PNG or GIF background colour.
        background = None
        if 'background' in image.info:
            background = image.info['background']
        elif 'transparency' in image.info:
            background = image.info['transparency']

        # Convert each frame in the image.
        for frame in _ImageSequence(image):
            ascii_image = ""
            frame = frame.resize(
                (int(frame.size[0] * height * 2.0 / frame.size[1]), height),
                Image.BICUBIC)
            tmp_img = Image.new("P", (1, 1))
            tmp_img.putpalette(screen.palette)

            # Avoid dithering - this requires a little hack to get directly
            # at the underlying library in PIL.
            new_frame = frame.convert('RGB')
            tmp_img.load()
            new_frame.load()
            new_frame = new_frame._new(new_frame.im.convert("P", 0, tmp_img.im))

            # Blank out any transparent sections of the image for complex
            # images with alpha blending.
            if background is None and frame.mode == 'RGBA':
                mask = Image.eval(frame.split()[-1], lambda a: 255 if a <= 64
                                  else 0)
                new_frame.paste(16, mask)

            # Convert the resulting image to coloured ASCII codes.
            for py in range(0, new_frame.size[1]):
                ascii_image += "\n"
                for px in range(0, new_frame.size[0]):
                    real_col = frame.getpixel((px, py))
                    col = new_frame.getpixel((px, py))
                    if real_col == background or col == 16:
                        ascii_image += " "
                    else:
                        ascii_image += "${%d}#" % col
            self._images.append(ascii_image)


class SpeechBubble(StaticRenderer):
    """
    Renders supplied text into a speech bubble.
    """

    def __init__(self, text, tail=None):
        """
        :param text: The text to be put into a speech bubble.
        :param tail: Where to put the bubble callout tail, specifying "L" or
                     "R" for left or right tails.  Can be None for no tail.
        """
        super(SpeechBubble, self).__init__()
        max_len = max([len(x) for x in text.split("\n")])
        bubble = ".-" + "-" * max_len + "-.\n"
        for line in text.split("\n"):
            bubble += "| " + line + " |\n"
            bubble += "`-" + "-" * max_len + "-`\n"
        if tail == "L":
            bubble += "  )/  \n"
            bubble += "-\"`\n"
        elif tail == "R":
            bubble += (" " * max_len) + "\\(  \n"
            bubble += (" " * max_len) + " `\"-\n"
        self._images = [bubble]


class Box(StaticRenderer):
    """
    Renders a simple box using ASCII characters.  This does not render in
    extended box drawing characters as that requires direct use of the curses
    API to draw non-ASCII characters (which is not supported by Effects that
    take Renderer parameters).
    """

    def __init__(self, width, height):
        """
        :param width: The desired width of the box.
        :param height: The desired height of the box.
        """
        super(Box, self).__init__()
        box = "+" + "-" * (width-2) + "+\n"
        for line in range(height-2):
            box += "|" + " " * (width-2) + "|\n"
        box += "+" + "-" * (width-2) + "+\n"
        self._images = [box]


class Rainbow(StaticRenderer):
    """
    Chained renderer to add rainbow colours to output of another renderer.
    The embedded rendered must not use multi-colour mode (i.e. ${c,a} 
    mark-ups) as these will be converted to explicit text by this renderer.
    """

    # Colour palette when limited to 16 colours (8 dim and 8 bright).
    _16_palette = [1, 1, 3, 3, 2, 2, 6, 6, 4, 4, 5, 5]

    # Colour palette for 256 colour xterm mode.
    _256_palette = [196, 202, 208, 214, 220, 226,
                    154, 118, 82, 46,
                    47, 48, 49, 50, 51,
                    45, 39, 33, 27, 21,
                    57, 93, 129, 201,
                    200, 199, 198, 197]

    def __init__(self, screen, renderer):
        """
        :param screen: The screen object for this renderer.
        :param renderer: The renderer to wrap.
        """
        super(Rainbow, self).__init__()
        palette = self._256_palette if screen.colours > 16 else self._16_palette
        for image in renderer.images:
            new_image = ""
            for y, line in enumerate(image):
                for x, c in enumerate(line):
                    colour = (x + y) % len(palette)
                    new_image += '${%d,1}%s' % (palette[colour], c)
                new_image += "\n"
            self._images.append(new_image)


class BarChart(DynamicRenderer):
    """
    Renderer to create a bar chart using the specified functions as inputs for
    each entry.  Can be used to chart distributions or to imitate a sound
    equalizer.
    """

    #: Which axes to draw when rendering
    NONE = 0
    X_AXIS = 1
    Y_AXIS = 2
    BOTH = 3

    def __init__(self, height, width, functions, char="#",
                 colour=Screen.COLOUR_GREEN, gradient=None, scale=None,
                 axes=Y_AXIS, intervals=None, labels=False, border=True):
        """
        :param height: The max height of the rendered image.
        :param width: The max width of the rendered image.
        :param functions: List of functions to chart.
        :param char: Character to use for the bar.
        :param colour: Default colour to use for the bars.  This can be a
            single value or list of values (to cycle around for each bar).
        :param gradient: Colour gradient for use on all bars.  This is a list of
            tuple pairs specifying a threshold and a colour.
        :param scale: Maximum value for the bars.  This is used to scale the
            function values to the maximum space available.  Any value over this
            will be truncated when drawn.  Defaults to the number of available
            characters in the chart.
        :param axes: Which axes to draw.
        :param intervals: Units for interval markers on the mains axis.
            Defaults to none.
        :param labels: Whether to label the main axis.
        :param border: Whether to draw a border around the chart.
        """
        super(BarChart, self).__init__(height, width)
        self._functions = functions
        self._char = char
        self._colours = [colour] if isinstance(colour, int) else colour
        self._gradient = gradient
        self._scale = scale
        self._axes = axes
        self._intervals = intervals
        self._labels = labels
        self._border = border

    def _render_now(self):
        super(BarChart, self)._render_now()

        # Dimensions for the chart.
        int_h = self._height
        int_w = self._width
        start_x = 0
        start_y = 0
        scale = int_w if self._scale is None else self._scale

        # Create  the box around the chart...
        if self._border:
            self._write("+" + "-" * (self._width-2) + "+", 0, 0)
            for line in range(1, self._height):
                self._write("|", 0, line)
                self._write("|", self._width - 1, line)
            self._write("+" + "-" * (self._width-2) + "+", 0, self._height - 1)
            int_h -= 4
            int_w -= 6
            start_y += 2
            start_x += 3

        # Now add the axes - resizing chart space as required...
        if (self._axes & BarChart.X_AXIS) > 0:
            int_h -= 1

        if (self._axes & BarChart.Y_AXIS) > 0:
            int_w -= 2
            start_x += 1

        if self._labels:
            int_h -= 1

        if (self._axes & BarChart.X_AXIS) > 0:
            self._write("-" * int_w, start_x, start_y + int_h)
        if (self._axes & BarChart.Y_AXIS) > 0:
            for line in range(int_h):
                self._write("|", start_x - 1, start_y + line)
        if self._axes == BarChart.BOTH:
            self._write("+", start_x - 1, start_y + int_h)
        if self._labels:
            self._write("0", start_x, start_y + int_h + 1)
            text = str(scale)
            self._write(text, start_x + int_w - len(text), start_y + int_h + 1)

        # Now add any interval markers if required...
        if self._intervals is not None:
            i = self._intervals
            while i < scale:
                x = start_x + int(i * int_w / scale)
                for line in range(int_h):
                    self._write(":", x, start_y + line)
                self._write("+", x, start_y + int_h)
                if self._labels:
                    val = str(i)
                    self._write(val, x - (len(val) // 2), start_y + int_h + 1)
                i += self._intervals

        # Allow double-width bars if there's space.
        bar_size = 2 if int_h >= (3 * len(self._functions)) - 1 else 1
        gap = 0 if len(self._functions) <= 1 else (int_h - (bar_size * len(
            self._functions))) / (len(self._functions) - 1)

        # Now add the bars...
        for i, fn in enumerate(self._functions):
            bar_len = int(fn() * int_w / scale)
            y = start_y + (i * bar_size) + int(i * gap)
            colour = self._colours[i % len(self._colours)]
            if self._gradient:
                # Colour gradient required - break down into chunks for each
                # color.
                last = 0
                size = 0
                for threshold, colour in self._gradient:
                    value = int(threshold * int_w / scale)
                    if value - last > 0:
                        # Size to fit the available space
                        size = value if bar_len >= value else bar_len
                        if size > int_w:
                            size = int_w
                        for line in range(bar_size):
                            self._write(
                                self._char * (size-last),
                                start_x + last,
                                y + line,
                                colour)

                    # Stop if we reached the end of the line or the chart
                    if bar_len < value or size >= int_w:
                        break
                    last = value
            else:
                # Solid colour - just write the whole block out.
                for line in range(bar_size):
                    self._write(self._char * bar_len, start_x, y + line, colour)

        return self._plain_image, self._colour_map


class Fire(DynamicRenderer):
    """
    Renderer to create a fire effect based on a specified `emitter` that
    defines the heat source.
    """

    _COLOURS_16 = [
        (Screen.COLOUR_RED, 0),
        (Screen.COLOUR_RED, 0),
        (Screen.COLOUR_RED, 0),
        (Screen.COLOUR_RED, 0),
        (Screen.COLOUR_RED, 0),
        (Screen.COLOUR_RED, 0),
        (Screen.COLOUR_RED, 0),
        (Screen.COLOUR_RED, Screen.A_BOLD),
        (Screen.COLOUR_RED, Screen.A_BOLD),
        (Screen.COLOUR_RED, Screen.A_BOLD),
        (Screen.COLOUR_RED, Screen.A_BOLD),
        (Screen.COLOUR_YELLOW, Screen.A_BOLD),
        (Screen.COLOUR_YELLOW, Screen.A_BOLD),
        (Screen.COLOUR_YELLOW, Screen.A_BOLD),
        (Screen.COLOUR_YELLOW, Screen.A_BOLD),
        (Screen.COLOUR_WHITE, Screen.A_BOLD),
    ]

    _COLOURS_256 = [
        (0, 0),
        (52, 0),
        (88, 0),
        (124, 0),
        (160, 0),
        (196, 0),
        (202, 0),
        (208, 0),
        (214, 0),
        (220, 0),
        (226, 0),
        (227, 0),
        (228, 0),
        (229, 0),
        (230, 0),
        (231, 0),
    ]

    _CHARS = " ...::$$$&&&@@"

    def __init__(self, height, width, emitter, intensity, spot, colours,
                 bg=False):
        """
        :param height: Height of the box to contain the flames.
        :param width: Width of the box to contain the flames.
        :param emitter: Heat source for the flames.  Any non-whitespace
            character is treated as part of the heat source.
        :param intensity: The strength of the flames.  The bigger the number,
            the hotter the fire.  0 <= intensity <= 1.0.
        :param spot: Heat of each spot source.  Must be an integer > 0.
        :param colours: Number of colours the screen supports.
        :param bg: (Optional) Whether to render background colours only.
        """
        super(Fire, self).__init__(height, width)
        self._emitter = emitter
        self._intensity = intensity
        self._spot_heat = spot
        self._count = len([c for c in emitter if c not in " \n"])
        line = [0 for _ in range(self._width)]
        self._buffer = [copy.deepcopy(line) for _ in range(self._width * 2)]
        self._colours = self._COLOURS_256 if colours >= 256 else \
            self._COLOURS_16
        self._bg_too = bg

        # Figure out offset of emitter to centre at the bottom of the buffer
        e_width = 0
        e_height = 0
        for line in self._emitter.split("\n"):
            e_width = max(e_width, len(line))
            e_height += 1
        self._x = (width - e_width) // 2
        self._y = height - e_height

    def _render_now(self):
        super(Fire, self)._render_now()

        # First make the fire rise with convection
        for y in range(len(self._buffer) - 1):
            self._buffer[y] = self._buffer[y + 1]
        self._buffer[len(self._buffer) - 1] = [0 for _ in range(self._width)]

        # Seed new hot spots
        x = self._x
        y = self._y
        for c in self._emitter:
            if c not in " \n" and random() < self._intensity:
                self._buffer[y][x] += randint(1, self._spot_heat)
            if c == "\n":
                x = self._x
                y += 1
            else:
                x += 1

        # Seed a few cooler spots
        for _ in range(self._width // 2):
            self._buffer[randint(0, self._height - 1)][
                randint(0, self._width - 1)] -= 10

        # Simulate cooling effect of the resulting environment.
        for y in range(len(self._buffer)):
            for x in range(self._width):
                new_val = self._buffer[y][x]
                if y < len(self._buffer) - 1:
                    new_val += self._buffer[y + 1][x]
                    if x > 0:
                        new_val += self._buffer[y][x - 1]
                    if x < self._width - 1:
                        new_val += self._buffer[y][x + 1]
                self._buffer[y][x] = new_val // 4

        # Now build the rendered text from the simulated flames.
        self._clear()
        for x in range(self._width):
            for y in range(len(self._buffer)):
                    if self._buffer[y][x] > 0:
                        colour = self._colours[min(len(self._colours) - 1,
                                                   self._buffer[y][x])]
                        if self._bg_too:
                            char = " "
                            bg = colour[0]
                        else:
                            char = self._CHARS[min(len(self._CHARS) - 1,
                                               self._buffer[y][x])]
                            bg = 0
                        self._write(char, x, y, colour[0], colour[1], bg)

        return self._plain_image, self._colour_map
