#
# Copyright 2011-2022 Martin Owens <doctormo@geek-2.com>
#
# 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/>
#
"""
Provides wrappers for pixmap access.
"""

import os
import logging

from typing import List
from collections.abc import Iterable
from gi.repository import Gtk, GLib, GdkPixbuf

ICON_THEME = Gtk.IconTheme.get_default()
BILINEAR = GdkPixbuf.InterpType.BILINEAR
HYPER = GdkPixbuf.InterpType.HYPER

SIZE_ASPECT = 0
SIZE_ASPECT_GROW = 1
SIZE_ASPECT_CROP = 2
SIZE_STRETCH = 3


class PixmapLoadError(ValueError):
    """Failed to load a pixmap"""


class PixmapFilter:  # pylint: disable=too-few-public-methods
    """Base class for filtering the pixmaps in a manager's output.

    Usage:

    .. code-block:: python

        class Foo(PixmapManager):
            filters = [ PixmapFilterFoo ]
    """

    required: List[str] = []
    """List of values required for this filter."""

    optional: List[str] = []

    def __init__(self, **kwargs):
        self.enabled = True
        for key in self.required:
            if key not in kwargs:
                self.enabled = False
            else:
                setattr(self, key, kwargs[key])

        for key in self.optional:
            if key in kwargs:
                setattr(self, key, kwargs[key])

    def filter(self, img, **kwargs):
        """Run filter, replace this method by your own"""
        raise NotImplementedError(
            "Please add 'filter' method to your PixmapFilter class %s."
            % type(self).__name__
        )

    @staticmethod
    def to_size(dat):
        """Tries to calculate a size that will work for the data"""
        if isinstance(dat, (int, float)):
            return (dat, dat)
        if isinstance(dat, Iterable) and len(dat) >= 2:
            return (dat[0], dat[1])
        return None


class OverlayFilter(PixmapFilter):
    """Adds an overlay to output images, overlay can be any name that
    the owning pixmap manager can find.

        * overlay  : Name of overlay image
        * position : Location of the image:
            * 0      - Full size (1 to 1 overlay, default)
            * (x,y)  - Percentage from one end to the other position 0-1
        * alpha    : Blending alpha, 0 - 255

    """

    optional = ["position", "overlay", "alpha"]

    def __init__(self, *args, **kwargs):
        self.position = (0, 0)
        self.overlay = None
        self.alpha = 255
        super().__init__(*args, **kwargs)
        self.pad_x, self.pad_y = self.to_size(self.position)

    def get_overlay(self, **kwargs):
        if "manager" not in kwargs:
            raise ValueError("PixmapManager must be provided when adding an overlay.")
        return kwargs["manager"].get(
            kwargs.get("overlay", None) or self.overlay, no_overlay=True
        )

    def filter(self, img, no_overlay=False, **kwargs):
        # Recursion protection
        if no_overlay:
            return img

        overlay = self.get_overlay(**kwargs)
        if overlay:
            img = img.copy()

            (x, y, width, height) = self.set_position(overlay, img)
            overlay.composite(
                img, x, y, width, height, x, y, 1, 1, BILINEAR, self.alpha
            )
        return img

    def set_position(self, overlay, img):
        """Sets the position of img on the given width and height"""
        img_w, img_h = img.get_width(), img.get_height()
        ovl_w, ovl_h = overlay.get_width(), overlay.get_height()
        return (
            max([0, (img_w - ovl_w) * self.pad_x]),
            max([0, (img_h - ovl_h) * self.pad_y]),
            min([ovl_w, img_w]),
            min([ovl_h, img_h]),
        )


class SizeFilter(PixmapFilter):
    """Resizes images to a certain size:

    :attr:`resize_mode` - Way in which the size is calculated

    * 0 - Best Aspect, don't grow
    * 1 - Best Aspect, grow
    * 2 - Cropped Aspect
    * 3 - Stretch
    """

    required = ["size"]
    optional = ["resize_mode"]

    def __init__(self, *args, **kwargs):
        self.size = None
        self.resize_mode = SIZE_ASPECT
        super().__init__(*args, **kwargs)
        self.img_w, self.img_h = self.to_size(self.size) or (0, 0)

    def aspect(self, img_w, img_h):
        """Get the aspect ratio of the image resized"""
        if self.resize_mode == SIZE_STRETCH:
            return (self.img_w, self.img_h)

        if (
            self.resize_mode == SIZE_ASPECT
            and img_w < self.img_w
            and img_h < self.img_h
        ):
            return (img_w, img_h)
        (pcw, pch) = (self.img_w / img_w, self.img_h / img_h)
        factor = (
            max(pcw, pch) if self.resize_mode == SIZE_ASPECT_CROP else min(pcw, pch)
        )
        return (int(img_w * factor), int(img_h * factor))

    def filter(self, img, **kwargs):
        if self.size is not None:
            (width, height) = self.aspect(img.get_width(), img.get_height())
            return img.scale_simple(width, height, HYPER)
        return img


class PadFilter(SizeFilter):
    """Add padding to the image to make it a standard size"""

    optional = ["padding"]

    def __init__(self, *args, **kwargs):
        self.size = None
        self.padding = 0.5
        super().__init__(*args, **kwargs)
        self.pad_x, self.pad_y = self.to_size(self.padding)

    def filter(self, img, **kwargs):
        (width, height) = (img.get_width(), img.get_height())
        if width < self.img_w or height < self.img_h:
            target = GdkPixbuf.Pixbuf.new(
                img.get_colorspace(),
                True,
                img.get_bits_per_sample(),
                max([width, self.img_w]),
                max([height, self.img_h]),
            )
            target.fill(0x0)  # Transparent black

            x = (target.get_width() - width) * self.pad_x
            y = (target.get_height() - height) * self.pad_y

            img.composite(target, x, y, width, height, x, y, 1, 1, BILINEAR, 255)
            return target
        return img


class PixmapManager:
    """Manage a set of cached pixmaps, returns the default image
    if it can't find one or the missing image if that's available."""

    missing_image = "image-missing"
    default_image = "application-default-icon"
    icon_theme = ICON_THEME
    theme_size = 32
    filters: List[type] = []
    pixmap_dir = None

    def __init__(self, location="", **kwargs):
        self.location = location
        if self.pixmap_dir and not os.path.isabs(location):
            self.location = os.path.join(self.pixmap_dir, location)

        self.loader_size = PixmapFilter.to_size(kwargs.pop("load_size", None))

        # Add any instance specified filters first
        self._filters = []
        for item in kwargs.get("filters", []) + self.filters:
            if isinstance(item, PixmapFilter):
                self._filters.append(item)
            elif callable(item):
                # Now add any class specified filters with optional kwargs
                self._filters.append(item(**kwargs))

        self.cache = {}
        self.get_pixmap(self.default_image)

    def get(self, *args, **kwargs):
        """Get a pixmap of any kind"""
        return self.get_pixmap(*args, **kwargs)

    def get_missing_image(self):
        """Get a missing image when other images aren't found"""
        return self.get(self.missing_image)

    @staticmethod
    def data_is_file(data):
        """Test the file to see if it's a filename or not"""
        return isinstance(data, str) and "<svg" not in data

    def get_pixmap(self, data, **kwargs):
        """
        There are three types of images this might return.

        #. A named gtk-image such as "gtk-stop"
        #. A file on the disk such as "/tmp/a.png"
        #. Data as either svg or binary png

        All pixmaps are cached for multiple use.
        """
        if "manager" not in kwargs:
            kwargs["manager"] = self

        if not data:
            if not self.default_image:
                return None
            data = self.default_image

        key = data[-30:]  # bytes or string
        if not key in self.cache:
            # load the image from data or a filename/theme icon
            img = None
            try:
                if self.data_is_file(data):
                    img = self.load_from_name(data)
                else:
                    img = self.load_from_data(data)
            except PixmapLoadError as err:
                logging.warning(str(err))
                return self.get_missing_image()

            if img is not None:
                self.cache[key] = self.apply_filters(img, **kwargs)

        return self.cache[key]

    def apply_filters(self, img, **kwargs):
        """Apply all the filters to the given image"""
        for lens in self._filters:
            if lens.enabled:
                img = lens.filter(img, **kwargs)
        return img

    def load_from_data(self, data):
        """Load in memory picture file (jpeg etc)"""
        # This doesn't work yet, returns None *shrug*
        loader = GdkPixbuf.PixbufLoader()
        if self.loader_size:
            loader.set_size(*self.loader_size)
        try:
            if isinstance(data, str):
                data = data.encode("utf-8")
            loader.write(data)
            loader.close()
        except GLib.GError as err:
            raise PixmapLoadError(f"Failed to load pixbuf from data: {err}")
        return loader.get_pixbuf()

    def load_from_name(self, name):
        """Load a pixbuf from a name, filename or theme icon name"""
        pixmap_path = self.pixmap_path(name)
        if os.path.exists(pixmap_path):
            try:
                return GdkPixbuf.Pixbuf.new_from_file(pixmap_path)
            except RuntimeError as msg:
                raise PixmapLoadError(f"Failed to load pixmap '{pixmap_path}', {msg}")
        elif (
            self.icon_theme and "/" not in name and "." not in name and "<" not in name
        ):
            return self.theme_pixmap(name, size=self.theme_size)
        raise PixmapLoadError(f"Failed to find pixmap '{name}' in {self.location}")

    def theme_pixmap(self, name, size=32):
        """Internal user: get image from gnome theme"""
        size = size or 32
        if not self.icon_theme.has_icon(name):
            name = "image-missing"
        return self.icon_theme.load_icon(name, size, 0)

    def pixmap_path(self, name):
        """Returns the pixmap path based on stored location"""
        for filename in (
            name,
            os.path.join(self.location, f"{name}.svg"),
            os.path.join(self.location, f"{name}.png"),
        ):
            if os.path.exists(filename) and os.path.isfile(filename):
                return name
        return os.path.join(self.location, name)
