from __future__ import annotations

import os
from collections import namedtuple
from copy import copy
from math import pi

import cairocffi
import cairocffi.pixbuf

from libqtile import utils
from libqtile.utils import ColorsType, scan_files


class LoadingError(Exception):
    pass


_SurfaceInfo = namedtuple("_SurfaceInfo", ("surface", "file_type"))


def get_cairo_surface(bytes_img, width=None, height=None):
    try:
        surf, fmt = cairocffi.pixbuf.decode_to_image_surface(bytes_img, width, height)
        return _SurfaceInfo(surf, fmt)
    except TypeError:
        from libqtile.log_utils import logger

        logger.exception(
            "Couldn't load cairo image at specified width and height. "
            "Falling back to image scaling using cairo. "
            "Need cairocffi > v0.8.0"
        )
        surf, fmt = cairocffi.pixbuf.decode_to_image_surface(bytes_img)
        return _SurfaceInfo(surf, fmt)


def get_cairo_surface_for_data(data, format, width, height):
    surf = cairocffi.ImageSurface.create_for_data(data, format, width, height)
    return _SurfaceInfo(surf, format)


def get_cairo_pattern(surface, width=None, height=None, theta=0.0):
    """Return a SurfacePattern from an ImageSurface.

    if width and height are not None scale the pattern
    to be size width and height.

    theta is in degrees ccw
    """
    pattern = cairocffi.SurfacePattern(surface)
    pattern.set_filter(cairocffi.FILTER_BEST)
    matrix = cairocffi.Matrix()

    tr_width, tr_height = 1.0, 1.0
    surf_width, surf_height = surface.get_width(), surface.get_height()
    if (width is not None) and (width != surf_width):
        tr_width = surf_width / width
    if (height is not None) and (height != surf_height):
        tr_height = surf_height / height
    matrix.scale(tr_width, tr_height)

    epsilon = 1.0e-6
    if abs(theta) > epsilon:
        theta_rad = pi / 180.0 * theta
        mat_rot = cairocffi.Matrix()
        # https://cairographics.org/cookbook/transform_about_point/
        xt = surf_width * tr_width * 0.5
        yt = surf_height * tr_height * 0.5
        mat_rot.translate(xt, yt)
        mat_rot.rotate(theta_rad)
        mat_rot.translate(-xt, -yt)
        matrix = mat_rot.multiply(matrix)

    pattern.set_matrix(matrix)
    return pattern


class _Descriptor:
    def __init__(self, name=None, default=None, **opts):
        self.name = name
        self.under_name = "_" + name
        self.default = default
        for key, value in opts.items():
            setattr(self, key, value)

    def __get__(self, obj, cls):
        if obj is None:
            return self
        _getattr = getattr
        try:
            return _getattr(obj, self.under_name)
        except AttributeError:
            return self.get_default(obj)

    def get_default(self, obj):
        return self.default

    def __set__(self, obj, value):
        setattr(obj, self.under_name, value)

    def __delete__(self, obj):
        delattr(obj, self.under_name)


class _Resetter(_Descriptor):
    def __set__(self, obj, value):
        super().__set__(obj, value)
        obj._reset()


class _PixelSize(_Resetter):
    def __set__(self, obj, value):
        value = max(round(value), 1)
        super().__set__(obj, value)

    def get_default(self, obj):
        size = obj.default_size
        return getattr(size, self.name)


class _Rotation(_Resetter):
    def __set__(self, obj, value):
        value = float(value)
        super().__set__(obj, value)


_ImgSize = namedtuple("_ImgSize", ("width", "height"))


class ImageFileBackend:
    """Backend for encoded image files (PNG, JPEG, etc)"""

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

    def get_surface(self, width=None, height=None):
        surf, _ = get_cairo_surface(self.bytes_img, width, height)
        return surf

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, ImageFileBackend):
            return False
        return self.bytes_img == other.bytes_img


class ImageBufferBackend:
    """Backend for raw pixel data"""

    def __init__(self, data, format, width, height):
        self.data = data
        self.format = format
        self.width = width
        self.height = height

    def get_surface(self, width=None, height=None):
        surf, _ = get_cairo_surface_for_data(self.data, self.format, self.width, self.height)
        if width is None or (width == self.width and height == self.height):
            return surf
        scaled = cairocffi.ImageSurface(cairocffi.FORMAT_ARGB32, width, height)
        with cairocffi.Context(scaled) as ctx:
            ctx.scale(width / self.width, height / self.height)
            pattern = cairocffi.SurfacePattern(surf)
            pattern.set_filter(cairocffi.FILTER_BEST)
            ctx.set_source(pattern)
            ctx.paint()
        return scaled

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, ImageBufferBackend):
            return False
        return (
            self.data == other.data
            and self.format == other.format
            and self.width == other.width
            and self.height == other.height
        )


class Img:
    """Img is a class which creates & manipulates cairo SurfacePatterns from an image

    There are three constructors Img(...), Img.from_path(...) and Img.from_data(...)

    The cairo surface pattern is at img.pattern.
    Changing any of the attributes width, height, or theta will update the pattern.

    - width :: pattern width in pixels
    - height :: pattern height in pixels
    - theta :: rotation of pattern counter clockwise in degrees
    Pattern is first stretched, then rotated.
    """

    def __init__(self, bytes_img=None, name="", path=""):
        if bytes_img:
            self._common_init(ImageFileBackend(bytes_img), name=name, path=path)
        else:
            self._common_init(None, name=name, path=path)

    def __copy__(self):
        new = self.__class__.__new__(self.__class__)
        new.__dict__.update(self.__dict__)
        new._operations = list(self._operations)
        new._resources = [copy(r) for r in self._resources]
        new.__dict__.pop("_surface", None)
        new.__dict__.pop("_pattern", None)
        return new

    def _common_init(self, backend, name="", path=""):
        self.backend = backend
        self.name = name
        self.path = path
        self._operations = []
        self._resources = []

    @classmethod
    def from_data(cls, data, format, width, height):
        "Create an Img instance from image data"
        img = cls.__new__(cls)
        img._common_init(ImageBufferBackend(data, format, width, height))
        return img

    @classmethod
    def blank(cls, format, width, height):
        stride = cairocffi.ImageSurface.format_stride_for_width(format, width)
        data = bytearray(stride * height)
        img = cls.__new__(cls)
        img._common_init(ImageBufferBackend(data, format, width, height))
        return img

    @classmethod
    def from_path(cls, image_path):
        "Create an Img instance from image_path"
        with open(image_path, "rb") as fobj:
            bytes_img = fobj.read()
        name = os.path.basename(image_path)
        name, file_type = os.path.splitext(name)
        img = cls.__new__(cls)
        img._common_init(ImageFileBackend(bytes_img), name=name, path=image_path)
        return img

    def _reset(self):
        if hasattr(self, "surface"):
            self.surface.finish()
            del self.surface
        if hasattr(self, "pattern"):
            # patterns do not need to be finish()ed, only surfaces do
            del self.pattern

    @property
    def default_surface(self):
        try:
            return self._default_surface
        except AttributeError:
            surf = self.backend.get_surface()
            self._default_surface = surf
            return surf

    @property
    def default_size(self):
        try:
            return self._default_size
        except AttributeError:
            surf = self.default_surface
            size = _ImgSize(surf.get_width(), surf.get_height())
            self._default_size = size
            return size

    theta = _Rotation("theta", default=0.0)
    width = _PixelSize("width")
    height = _PixelSize("height")

    def resize(self, width=None, height=None):
        if width is None and height is None:
            raise ValueError("You must supply width or height")

        width0, height0 = self.default_size
        width_factor = width / width0 if width is not None else None
        height_factor = height / height0 if height is not None else None

        if width_factor is not None and height_factor is not None:
            return self.scale(width_factor, height_factor, lock_aspect_ratio=False)
        else:
            return self.scale(width_factor, height_factor, lock_aspect_ratio=True)

    def scale(self, width_factor=None, height_factor=None, lock_aspect_ratio=False):
        # First scale any attached image resources
        for resource in self._resources:
            resource.scale(width_factor, height_factor)

        if width_factor is None and height_factor is None:
            raise ValueError("You must supply width_factor or height_factor")
        if lock_aspect_ratio:
            res = self._scale_lock(width_factor, height_factor, self.default_size)
        else:
            res = self._scale_free(width_factor, height_factor, self.default_size)
        self.width, self.height = res

    @staticmethod
    def _scale_lock(width_factor, height_factor, initial_size):
        if width_factor is not None and height_factor is not None:
            raise ValueError("No lock_aspect_ratio scale with width_factor and height_factor")
        width0, height0 = initial_size
        if width_factor is not None:
            width = width0 * width_factor
            height = height0 / width0 * width
        if height_factor is not None:
            height = height0 * height_factor
            width = width0 / height0 * height
        return _ImgSize(width, height)

    @staticmethod
    def _scale_free(width_factor, height_factor, initial_size):
        width_factor = 1 if width_factor is None else width_factor
        height_factor = 1 if height_factor is None else height_factor
        width0, height0 = initial_size
        return _ImgSize(width0 * width_factor, height0 * height_factor)

    def paste(self, overlay: Img, offsetx: int = 0, offsety: int = 0) -> Img:
        overlay = copy(overlay)  # Snapshot overlay when paste is called
        self._resources.append(overlay)
        resource_idx = len(self._resources) - 1

        def func(img, surface):
            with cairocffi.Context(surface) as ctx:
                ctx.translate(offsetx, offsety)
                ctx.set_source(cairocffi.SurfacePattern(img._resources[resource_idx].surface))
                ctx.paint()

        self._operations.append(func)
        self._reset()
        return self

    def paint_mask(self, colour: ColorsType) -> Img:
        def func(img, surface):
            with cairocffi.Context(surface) as ctx:
                if isinstance(colour, list):
                    if len(colour) == 0:
                        # defaults to black
                        ctx.set_source_rgba(0.0, 0.0, 0.0, 1.0)
                    elif len(colour) == 1:
                        ctx.set_source_rgba(*utils.rgb(colour[0]))
                    else:
                        linear = cairocffi.LinearGradient(0.0, 0.0, 0.0, img.height)
                        step_size = 1.0 / (len(colour) - 1)
                        step = 0.0
                        for c in colour:
                            linear.add_color_stop_rgba(step, *utils.rgb(c))
                            step += step_size
                        ctx.set_source(linear)
                else:
                    ctx.set_source_rgba(*utils.rgb(colour))

                ctx.set_operator(cairocffi.OPERATOR_SOURCE)
                ctx.mask(cairocffi.SurfacePattern(surface))
                ctx.fill()

        self._operations.append(func)
        self._reset()
        return self

    @property
    def surface(self):
        try:
            return self._surface
        except AttributeError:
            surf = self.backend.get_surface(self.width, self.height)
            for operation in self._operations:
                operation(self, surf)

            self._surface = surf
            return surf

    @surface.deleter
    def surface(self):
        try:
            del self._surface
        except AttributeError:
            pass

    @property
    def pattern(self):
        try:
            return self._pattern
        except AttributeError:
            pat = get_cairo_pattern(self.surface, self.width, self.height, self.theta)
            self._pattern = pat
            return pat

    @pattern.deleter
    def pattern(self):
        try:
            del self._pattern
        except AttributeError:
            pass

    def __repr__(self):
        return f"<{self.__class__.__name__}: {self.name!r}, {self.width}x{self.height}@{self.theta:.1f}deg, {self.path!r}>"

    def __eq__(self, other):
        if not isinstance(other, self.__class__):
            return False
        return (
            self.backend == other.backend
            and self.theta == other.theta
            and self.width == other.width
            and self.height == other.height
        )


class Loader:
    """Loader - create Img() instances from image names

    load icons with Loader e.g.,
    >>> ldr = Loader('/usr/share/icons/Adwaita/24x24', '/usr/share/icons/Adwaita')
    >>> d_loaded_images = ldr('audio-volume-muted', 'audio-volume-low')
    """

    def __init__(self, *directories, **kwargs):
        for k, v in kwargs.items():
            setattr(self, k, v)
        self.directories = list(directories)

    def __call__(self, *names):
        d = {}
        seen = set()
        set_names = set()
        for n in names:
            root, ext = os.path.splitext(n)
            if ext:
                set_names.add(n)
            else:
                set_names.add(n + ".*")

        for directory in self.directories:
            d_matches = scan_files(directory, *(set_names - seen))
            for name, paths in d_matches.items():
                if paths:
                    d[name if name in names else name[:-2]] = Img.from_path(paths[0])
                    seen.add(name)

        if seen != set_names:
            msg = "Wasn't able to find images corresponding to the names: {}"
            raise LoadingError(msg.format(set_names - seen))

        return d
