# Copyright (c) 2021, elParaguayo. All rights reserved.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from __future__ import annotations

import copy
import math
from functools import partial
from typing import TYPE_CHECKING

import cairocffi
from cairocffi import Context
from libqtile import bar
from libqtile.backend.base import Drawer
from libqtile.confreader import ConfigError
from libqtile.log_utils import logger
from libqtile.widget import base

if TYPE_CHECKING:
    from typing import Any


class _Decoration(base.PaddingMixin):
    """
    Base decoration class. Should not be called by
    configs directly.
    """

    defaults = [("padding", 0, "Default padding")]  # type: list[tuple[str, Any, str]]

    def __init__(self, **config):
        base.PaddingMixin.__init__(self, **config)
        self.add_defaults(_Decoration.defaults)
        self._extrawidth = 0

    def __eq__(self, other):
        return type(self) == type(other) and self._user_config == other._user_config

    def _configure(self, parent: base._Widget) -> None:
        self.parent = parent

    def single_or_four(self, value, name: str):
        if type(value) in [float, int]:
            n = e = s = w = value
        elif type(value) in [tuple, list]:
            if len(value) == 1:
                n = e = s = w = value[0]
            elif len(value) == 4:
                n, e, s, w = value
            else:
                logger.info(f"{name} should be a single number or a list of 1 or 4 values")
                n = e = s = w = 0
        else:
            logger.info(f"{name} should be a single number or a list of 1 or 4 values")
            n = e = s = w = 0

        return [n, e, s, w]

    def clone(self) -> _Decoration:
        return copy.copy(self)

    @property
    def height(self) -> int:
        if self.parent.bar.horizontal:
            return self.parent.bar.height
        return self.parent.height

    @property
    def width(self) -> int:
        if self.parent.bar.horizontal:
            return self.parent.width
        return self.parent.bar.width

    @property
    def drawer(self) -> Drawer:
        return self.parent.drawer

    @property
    def ctx(self) -> Context:
        return self.parent.drawer.ctx


class RectDecoration(_Decoration):
    """
    Widget decoration that draws a rectangle behind the widget contents.

    Only one colour can be set but decorations can be layered to achieve
    multi-coloured effects.

    Rectangles can be drawn as just the the outline or filled.

    Curved corners can be obtained by setting the ``radius`` parameter.

    To have the effect of multiple widgets using the same decoration (e.g.
    the curved ends appearing on the first and last widgets) use the
    ``group=True`` option.

    The advantage of using the ``group`` option is that the group is dynamic
    meaning that it is drawn according to the widgets that are currently visible
    in the group. The group will adjust if a window becomes visible or hidden.

    .. code:: python

        decoration_group = {
            "decorations": [
                RectDecoration(colour="#004040", radius=10, filled=True, padding_y=4, group=True)
            ],
            "padding": 10,
        }

        screens = [
            Screen(
                top=Bar(
                    [
                        extrawidgets.CurrentLayout(**decoration_group),
                        widget.Spacer(),
                        extrawidgets.StatusNotifier(**decoration_group),
                        extrawidgets.Mpris2(**decoration_group),
                        extrawidgets.Clock(format="%H:%M:%S", **decoration_group),
                        extrawidgets.ScriptExit(
                            default_text="[X]",
                            countdown_format="[{}]",
                            countdown_start=2,
                            **decoration_group
                        ),
                    ]
                ),
                28
            )
        ]

    There are two groups in this config: Group 1 is the CurrentLayout widget. Group 2 is the StatusNotifier, Mpris2,
    Clock and ScriptExit widgets. The groups are separated by the Spacer widget.

    When there is no active icon in the StatusNotifier, the bar looks like this:

    .. image:: /_static/images/rect_decoration_group1.png

    When there is an icon, the group is expanded to include the widget:

    .. image:: /_static/images/rect_decoration_group2.png

    Note the group is not broken despite the Mpris2 widget having no contents.


    Groups are determined by looking for:
      - widgets using the identical configuration for the decoration
      - widgets in a consecutive groups

    Groups can therefore be broken by changing the configuration of the group
    (e.g. by adding an additional parameter such as ``group_id=1``) or having
    an undecorated separator between groups.

    Setting ``clip=True`` will result in the widget's contents being restricted to the area covered
    by the decoration. This may be desirable for widgets like ``ALSAWidget`` and
    ``BrightnessControl`` which draw their levels in the bar.

    .. image:: /_static/images/rect_decoration_clip.png

    |

    """

    defaults = [
        ("filled", False, "Whether to fill shape"),
        ("radius", 4, "Corner radius as int or list of ints [TL TR BR BL]. 0 is square"),
        ("colour", "#000000", "Colour for decoration"),
        ("line_width", 2, "Line width for decoration"),
        (
            "use_widget_background",
            False,
            "Paint the decoration using the colour from the widget's `background` property. "
            "The widget's background will then be the bar's background colour.",
        ),
        (
            "group",
            False,
            "When set to True, the decoration will be applied as if the widgets were grouped. See documentation for more.",
        ),
        ("clip", False, "Clip contents of widget to decoration area."),
    ]  # type: list[tuple[str, Any, str]]

    _screenshots = [
        ("rect_decoration.png", "Single decoration"),
        ("rect_decoration_stacked.png", "Two decorations stacked"),
    ]

    def __init__(self, **config):
        _Decoration.__init__(self, **config)
        self.add_defaults(RectDecoration.defaults)
        self.corners = self.single_or_four(self.radius, "Corner radius")

    def _get_parent_group(self):
        """Finds the group of widgets containing the current widget."""
        widgets = self.parent.bar.widgets
        grouped = self._get_grouped_widgets()

        groups = []
        current = [grouped[0]]

        for w in grouped[1:]:
            # If the next grouped widget is not adjacent to the previous grouped widget...
            if widgets.index(w) != widgets.index(current[-1]) + 1:
                # Append the current group to the list of groups and start a new group with widget
                groups.append(current)
                current = [w]
            # Otherwise, append the widget to the current group.
            else:
                current.append(w)
        groups.append(current)

        # Find the group containing the parent widget
        parent_group = [g for g in groups if self.parent in g]

        return parent_group[0]

    def _get_grouped_widgets(self):
        def is_grouped(widget):
            for dec in getattr(widget, "decorations", list()):
                if dec == self and dec.group:
                    return True

            return False

        widgets = self.parent.bar.widgets
        grouped = [w for w in widgets if is_grouped(w)]

        return grouped

    @property
    def parent_index(self):
        return self.parent.bar.widgets.index(self.parent)

    @property
    def is_first(self):
        grouped = self._get_parent_group()
        visible = [w for w in grouped if w.length > 0]
        return visible and self.parent is visible[0]

    @property
    def is_last(self):
        grouped = self._get_parent_group()
        visible = [w for w in grouped if w.length > 0]
        return visible and self.parent is visible[-1]

    def draw(self) -> None:
        box_height = self.height - 2 * self.padding_y
        box_width = self.width - 2 * self.padding_x

        self.fill_colour = self.parent.background if self.use_widget_background else self.colour

        self.drawer.set_source_rgb(self.fill_colour)

        if not self.radius:

            self.ctx.rectangle(self.padding_x, self.padding_y, box_width, box_height)

        else:
            if self.group:

                corners = [0, 0, 0, 0]

                if self.is_first:
                    corners[0] = self.corners[0]
                    corners[3] = self.corners[3]
                if self.is_last:
                    corners[1] = self.corners[1]
                    corners[2] = self.corners[2]

            else:
                corners = self.corners

            degrees = math.pi / 180.0

            self.ctx.new_sub_path()

            # Top left
            radius = corners[0]
            delta = radius + self.line_width / 2 - 1
            self.ctx.arc(
                self.padding_x + delta,
                self.padding_y + delta,
                radius,
                180 * degrees,
                270 * degrees,
            )

            # Top right
            radius = corners[1]
            delta = radius + self.line_width / 2 - 1
            self.ctx.arc(
                self.padding_x + box_width - delta,
                self.padding_y + delta,
                radius,
                -90 * degrees,
                0 * degrees,
            )

            # Bottom right
            radius = corners[2]
            delta = radius + self.line_width / 2 - 1
            self.ctx.arc(
                self.padding_x + box_width - delta,
                self.padding_y + box_height - delta,
                radius,
                0 * degrees,
                90 * degrees,
            )

            # Bottom left
            radius = corners[3]
            delta = radius + self.line_width / 2 - 1
            self.ctx.arc(
                self.padding_x + delta,
                self.padding_y + box_height - delta,
                radius,
                90 * degrees,
                180 * degrees,
            )

            self.ctx.close_path()

        if self.clip:
            self.ctx.clip_preserve()

        if self.filled:
            self.ctx.fill()
        else:
            self.ctx.set_line_width(self.line_width)
            self.ctx.stroke()


class BorderDecoration(_Decoration):
    """
    Widget decoration that draws a straight line on the widget border.
    Padding can be used to adjust the position of the border further.

    Only one colour can be set but decorations can be layered to achieve
    multi-coloured effects.
    """

    defaults = [
        ("colour", "#000000", "Border colour"),
        ("border_width", 2, "Border width as int or list of ints [N E S W]."),
    ]  # type: list[tuple[str, Any, str]]

    _screenshots = [("border_decoration.png", "Stacked borders")]

    def __init__(self, **config):
        _Decoration.__init__(self, **config)
        self.add_defaults(BorderDecoration.defaults)
        self.borders = self.single_or_four(self.border_width, "Border width")

    def draw(self) -> None:
        top, right, bottom, left = self.borders

        self.drawer.set_source_rgb(self.colour)

        if top:
            offset = top / 2
            self._draw_border(
                self.padding_x,  # offset not applied to x coords as seems to create a gap
                offset + self.padding_y,
                self.width - self.padding_x,
                offset + self.padding_y,
                top,
            )

        if right:
            offset = right / 2
            self._draw_border(
                self.width - offset - self.padding_x,
                offset + self.padding_y,
                self.width - offset - self.padding_x,
                self.height - offset - self.padding_y,
                right,
            )

        if bottom:
            offset = bottom / 2
            self._draw_border(
                self.padding_x,  # offset not applied to x coords as seems to create a gap
                self.height - offset - self.padding_y,
                self.width - self.padding_y,
                self.height - offset - self.padding_y,
                bottom,
            )

        if left:
            offset = left / 2
            self._draw_border(
                offset + self.padding_x,
                offset + self.padding_y,
                offset + self.padding_x,
                self.height - offset - self.padding_y,
                left,
            )

    def _draw_border(self, x1: float, y1: float, x2: float, y2: float, line_width: float) -> None:
        self.ctx.move_to(x1, y1)
        self.ctx.line_to(x2, y2)
        self.ctx.set_line_width(line_width)
        self.ctx.stroke()


class PowerLineDecoration(_Decoration):
    """
    Widget decoration that can be used to recreate the PowerLine style.

    The advantages of the decoration are:
      - No fonts needed
      - The same decoration definition can be used for all widgets (the decoration
        works out which background and foreground colours to use)
      - Different styles can be used by changing a few parameters of the decoration

    The decoration works by adding the shape **after** the current widget. The decoration
    will also be hidden if a widget has zero width (i.e. is hidden).

    The shape is drawn in area whose size is defined by the ``size`` parameter. This area is
    drawn after the widget but can be shifted back by using the ``shift`` parameter. Shifting
    too far will result in widget contents being drawn over the shape.

    The default behavious is to draw an arrow pointing right. To change the shape you can
    use pre-defined paths: "arrow_left", "arrow_right", "forward_slash", "back_slash" and
    "zig_zag". Alternatively, you can create a custom shape by defining a path. The format
    is a list of (x, y) tuples. x and y values should be between 0 and 1 to represent the relative position
    in the additional space created by the decoration. (0, 0) is the top left corner (on a horizontal widget)
    and (1, 1) is the bottom right corner. The first point in the list is the starting point and then a line will
    be drawn to each subsequent point. The path is then closed by returning to the first point.
    Finally, the shape is filled with the background of the current widget.

    .. note::

        The decoration currently only works on horizontal bars. The ``padding_y`` parameter can be
        used to adjust the vertical size of the decoration. However, note that this won't change
        the size of the widget's own background. If you want to do that, you can use the following:

        .. code:: python

            powerline = {
                "decorations": [
                    RectDecoration(use_widget_background=True, padding_y=5, filled=True, radius=0),
                    PowerLineDecoration(path="arrow_right", padding_y=5)
                ]
            }

        The RectDecoration has the same padding and will use the widget's ``background`` parameter as
        its own colour.

    Example code:

    .. code:: python

        from qtile_extras import widget
        from qtile_extras.widget.decorations import PowerLineDecoration

        powerline = {
            "decorations": [
                PowerLineDecoration()
            ]
        }

        screens = [
            Screen(
                top=Bar(
                    [
                        widget.CurrentLayoutIcon(background="000000", **powerline),
                        widget.WindowName(background="222222", **powerline),
                        widget.Clock(background="444444", **powerline),
                        widget.QuickExit(background="666666")
                    ],
                    30
                )
            )
        ]

    The above code generates the following bar:

    .. image:: /_static/images/powerline_example.png

    |

    """

    defaults = [
        ("size", 15, "Width of shape"),
        ("path", "arrow_left", "Shape of decoration. See docstring for more info."),
        ("shift", 0, "Number of pixels to shift the decoration back by."),
    ]

    _screenshots = [
        ("powerline_example2.png", "path='arrow_right'"),
        ("powerline_example3.png", "path='rounded_left'"),
        ("powerline_example4.png", "path='rounded_right'"),
        ("powerline_example5.png", "path='forward_slash'"),
        ("powerline_example6.png", "path='back_slash'"),
        ("powerline_example7.png", "path='zig_zag'"),
        (
            "powerline_example8.png",
            "path=[(0, 0), (0.5, 0), (0.5, 0.25), (1, 0.25), (1, 0.75), (0.5, 0.75), (0.5, 1), (0, 1)]",
        ),
    ]

    # Pre-defined paths
    paths = {
        "arrow_left": [(0, 0), (1, 0.5), (0, 1)],
        "arrow_right": [(0, 0), (1, 0), (0, 0.5), (1, 1), (0, 1)],
        "forward_slash": [(0, 0), (1, 0), (0, 1)],
        "back_slash": [(0, 0), (1, 1), (0, 1)],
        "zig_zag": [(0, 0), (1, 0.2), (0, 0.4), (1, 0.6), (0, 0.8), (1, 1), (0, 1)],
    }

    def __init__(self, **config):
        _Decoration.__init__(self, **config)
        self.add_defaults(PowerLineDecoration.defaults)
        self.shift = max(min(self.shift, self.size), 0)
        self._extrawidth = self.size - self.shift

    @property
    def parent_length(self):
        if self.parent.length_type == bar.CALCULATED:
            return int(self.parent.calculate_length())
        return self.parent._length

    def _configure(self, parent):
        _Decoration._configure(self, parent)

        # Add custom shapes
        self.paths["rounded_left"] = self.draw_rounded
        self.paths["rounded_right"] = partial(self.draw_rounded, rotate=True)

        if isinstance(self.path, str):
            shape_path = self.paths.get(self.path, False)
            if callable(shape_path):
                self.draw_func = shape_path
            elif isinstance(shape_path, list):
                self.draw_func = partial(self.draw_path, path=shape_path)
            else:
                raise ConfigError(f"Unknown `path` ({self.path}) for PowerLineDecoration.")
        elif isinstance(self.path, list):
            self.draw_func = partial(self.draw_path, path=self.path)
        else:
            raise ConfigError(f"Unexpected value for PowerLineDecoration `path`: {self.path}.")

        self.parent_background = self.parent.background or self.parent.bar.background
        self.set_next_colour()

    def set_next_colour(self):
        index = self.parent.bar.widgets.index(self.parent)
        widgets = self.parent.bar.widgets

        try:
            next_widget = next(
                w
                for w in widgets
                if hasattr(w, "length") and w.length and widgets.index(w) > index
            )
            self.next_background = next_widget.background or self.parent.bar.background
        except (IndexError, StopIteration):
            self.next_background = self.parent.bar.background

    def paint_background(self, background):
        if self.parent.qtile.core.name == "x11":
            # We need to erase some of the background but the x11 drawer
            # painted the background directly to the XCB surface so we've got
            # to erase content from that surface
            ctx = Context(self.drawer._xcb_surface)
        else:
            ctx = Context(self.drawer._source)

        # Clear the old content
        ctx.save()
        ctx.set_operator(cairocffi.OPERATOR_CLEAR)
        ctx.rectangle(self.parent_length - self.shift, 0, self.size, self.parent.bar.height)
        ctx.fill()
        ctx.restore()

        # Paint the new background
        self.ctx.save()

        # If we have vertical padding then we paint the bar's bacground to the space
        if self.padding_y:
            self.ctx.set_operator(cairocffi.OPERATOR_SOURCE)
            self.drawer.set_source_rgb(self.parent.bar.background)
            self.ctx.rectangle(
                self.parent_length - self.shift, 0, self.size, self.parent.bar.height
            )
            self.ctx.fill()

        # Create a rectangle for the space
        self.ctx.rectangle(
            self.parent_length - self.shift,
            self.padding_y,
            self.size,
            self.parent.bar.height - 2 * self.padding_y,
        )

        # If there's vertical padding then we've already painted the bar's background
        # to this space so we need to clear the part that will have the new backgroun
        if self.padding_y:
            self.ctx.set_operator(cairocffi.OPERATOR_CLEAR)
            self.ctx.fill

        self.ctx.set_operator(cairocffi.OPERATOR_SOURCE)
        self.drawer.set_source_rgb(background)
        self.ctx.fill()
        self.ctx.restore()

    def draw_rounded(self, rotate=False):
        self.fg = self.parent_background if not rotate else self.next_background
        self.bg = self.next_background if not rotate else self.parent_background

        self.paint_background(self.bg)

        self.ctx.save()
        self.ctx.set_operator(cairocffi.OPERATOR_SOURCE)

        # Translate the surface so that the origin is in the middle of the arc
        start = self.parent_length - self.shift if not rotate else self.parent.length
        self.ctx.translate(start, self.parent.bar.height // 2)

        # Rotate 180 degrees if drawing curve in the other direction
        if rotate:
            self.ctx.rotate(math.pi)

        # We use scaling in order to be able to draw ellipses
        x_scale = self.parent.length - (self.parent_length - self.shift)
        y_scale = (self.parent.bar.height / 2) - self.padding_y
        self.ctx.scale(x_scale, y_scale)

        self.drawer.set_source_rgb(self.fg)

        # Radius is 1 as we've scaled the surface
        self.ctx.arc(0, 0, 1, -math.pi / 2, math.pi / 2)
        self.ctx.close_path()
        self.ctx.fill()
        self.ctx.restore()

    def draw_path(self, path=list()):
        if not path:
            return

        path = path.copy()

        self.fg = self.parent_background
        self.bg = self.next_background

        self.paint_background(self.bg)

        width = self.size
        height = self.parent.bar.height - 2 * (self.padding_y)

        self.ctx.save()
        self.ctx.set_operator(cairocffi.OPERATOR_SOURCE)

        self.ctx.translate(self.parent_length - self.shift, self.padding_y)

        x, y = path.pop(0)
        self.ctx.move_to(x * width, y * height)

        for x, y in path:
            self.ctx.line_to(x * width, y * height)

        self.ctx.close_path()
        self.ctx.clip_preserve()
        self.drawer.set_source_rgb(self.fg)
        self.ctx.fill()
        self.ctx.restore()

    def draw(self):
        self.set_next_colour()

        self.ctx.save()
        self.draw_func()
        self.ctx.restore()


def inject_decorations(classdef):
    """
    Method to inject ability for widgets to display decorations.
    """

    def new_clear(self, colour):
        """Draw decorations after clearing background."""
        if self.use_bar_background:
            colour = self.bar.background
        self._clear(colour)

        for decoration in self.decorations:
            decoration.draw()

    def configure_decorations(self):
        self.use_bar_background = False
        if hasattr(self, "decorations"):
            if not self.configured:
                # Give each widget a copy of the decoration objects
                temp_decs = []
                for i, dec in enumerate(self.decorations):
                    cloned_dec = dec.clone()
                    cloned_dec._configure(self)
                    temp_decs.append(cloned_dec)
                    if isinstance(cloned_dec, RectDecoration) and not self.use_bar_background:
                        self.use_bar_background = cloned_dec.use_widget_background
                self.decorations = temp_decs

            self._clear = self.drawer.clear
            self.drawer.clear = self.new_clear

    def new_configure(self, qtile, bar):
        self.old_configure(qtile, bar)
        self.configure_decorations()

    def length_get(self):
        if self.length_type == bar.CALCULATED:
            length = int(self.calculate_length())
        else:
            length = self._length

        if length:
            # Get the largest extra space required by a decoration
            # Max needs an iterator so we need some fallbacks:
            # If the widget doesn't have the decorations attribute then we use an empty list
            # max will error with an empty list so the `default` value is returned in this scenario
            extra = max((x._extrawidth for x in getattr(self, "decorations", list())), default=0)
            return length + extra

        return 0

    def length_set(self, value):
        # Stretch widgets have their length set by the bar.
        # We need to deduct any additional width provided by the decorations as this
        # will be added back when the length of the widget is retrieved.
        if value and hasattr(self, "decorations") and self.length_type == bar.STRETCH:
            extra = max(x._extrawidth for x in self.decorations) if self.decorations else 0
            self._length = value - extra
        else:
            self._length = value

    if not hasattr(classdef, "_injected_decorations"):

        classdef.old_configure = classdef._configure
        classdef.new_clear = new_clear
        classdef.configure_decorations = configure_decorations
        classdef._configure = new_configure
        classdef.length = property(length_get, length_set)

        classdef.defaults.append(("decorations", [], "Decorations for widgets"))

        classdef._injected_decorations = True
