# Copyright (c) 2008, Aldo Cortesi. 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 typing
from collections import defaultdict

from libqtile import configurable, hook
from libqtile.command.base import CommandObject, expose_command
from libqtile.log_utils import logger
from libqtile.utils import has_transparency, is_valid_colors

if typing.TYPE_CHECKING:
    import asyncio
    from typing import Any

    from libqtile.backend.base import Drawer, Internal, WindowType
    from libqtile.command.base import ItemT
    from libqtile.config import Screen
    from libqtile.core.manager import Qtile
    from libqtile.utils import ColorsType
    from libqtile.widget.base import _Widget


NESW = ("top", "right", "bottom", "left")


class Gap:
    """A gap placed along one of the edges of the screen

    Qtile will avoid covering gaps with windows.

    Parameters
    ==========
    size :
        The "thickness" of the gap, i.e. the height of a horizontal gap, or the
        width of a vertical gap.
    """

    def __init__(self, size: int) -> None:
        # 'size' corresponds to the height of a horizontal gap, or the width
        # of a vertical gap
        self._size = size
        self._initial_size = size
        # '_length' corresponds to the width of a horizontal gap, or the height
        # of a vertical gap
        self._length: int = 0
        self.qtile: Qtile | None = None
        self.screen: Screen | None = None
        self.x: int = 0
        self.y: int = 0
        self.width: int = 0
        self.height: int = 0
        self.horizontal: bool = False

        # Additional reserved around the gap/bar, used when space is dynamically
        # reserved e.g. by third-party bars.
        self.margin: list[int] = [0, 0, 0, 0]  # [N, E, S, W]

    def _configure(self, qtile: Qtile, screen: Screen, reconfigure: bool = False) -> None:
        self.qtile = qtile
        self.screen = screen
        self._size = self._initial_size
        # If both horizontal and vertical gaps are present, screen corners are
        # given to the horizontal ones
        if screen.top is self:
            self.x = screen.x + self.margin[3]
            self.y = screen.y + self.margin[0]
            self._length = screen.width - self.margin[1] - self.margin[3]
            self.width = self._length
            self.height = self._initial_size
            self.horizontal = True
            self._size += self.margin[0] + self.margin[2]
        elif screen.bottom is self:
            self.x = screen.x + self.margin[3]
            self.y = screen.dy + screen.dheight - self.margin[2]
            self._length = screen.width - self.margin[1] - self.margin[3]
            self.width = self._length
            self.height = self._initial_size
            self.horizontal = True
            self._size += self.margin[0] + self.margin[2]
        elif screen.left is self:
            self.x = screen.x + self.margin[3]
            self.y = screen.dy + self.margin[0]
            self._length = screen.dheight - self.margin[0] - self.margin[2]
            self.width = self._initial_size
            self.height = self._length
            self.horizontal = False
            self._size += self.margin[1] + self.margin[3]
        else:  # right
            self.x = screen.dx + screen.dwidth - self.margin[1]
            self.y = screen.dy + self.margin[0]
            self._length = screen.dheight - self.margin[0] - self.margin[2]
            self.width = self._initial_size
            self.height = self._length
            self.horizontal = False
            self._size += self.margin[1] + self.margin[3]

    def draw(self) -> None:
        pass

    def finalize(self) -> None:
        pass

    def geometry(self) -> tuple[int, int, int, int]:
        return (self.x, self.y, self.width, self.height)

    @property
    def size(self) -> int:
        # Enforce immutability of gap.size/bar.size
        return self._size

    @property
    def position(self) -> str:
        for i in NESW:
            if getattr(self.screen, i) is self:
                return i
        assert False, "Not reached"

    def adjust_reserved_space(self, size: int) -> None:
        for i, side in enumerate(NESW):
            if getattr(self.screen, side) is self:
                self.margin[i] += size
            if self.margin[i] < 0:
                raise ValueError("Gap/Bar can't reserve negative space.")

    @expose_command()
    def info(self) -> dict[str, Any]:
        """
        Info for this object.
        """
        return dict(position=self.position)


class Obj:
    def __init__(self, name: str) -> None:
        self.name = name

    def __str__(self) -> str:
        return self.name

    def __repr__(self) -> str:
        return self.name


STRETCH = Obj("STRETCH")
CALCULATED = Obj("CALCULATED")
STATIC = Obj("STATIC")


class Bar(Gap, configurable.Configurable, CommandObject):
    """A bar, which can contain widgets

    Parameters
    ==========
    widgets :
        A list of widget objects.
    size :
        The "thickness" of the bar, i.e. the height of a horizontal bar, or the
        width of a vertical bar.
    """

    defaults = [
        ("background", "#000000", "Background colour."),
        ("opacity", 1, "Bar window opacity."),
        ("margin", 0, "Space around bar as int or list of ints [N E S W]."),
        ("border_color", "#000000", "Border colour as str or list of str [N E S W]"),
        ("border_width", 0, "Width of border as int of list of ints [N E S W]"),
        (
            "reserve",
            True,
            "Reserve screen space (when set to 'False', bar will be drawn above windows).",
        ),
    ]

    def __init__(self, widgets: list[_Widget], size: int, **config: Any) -> None:
        Gap.__init__(self, size)
        configurable.Configurable.__init__(self, **config)
        self.add_defaults(Bar.defaults)
        # We need to create a new widget list here as users may have the same list for multiple
        # screens. In that scenario, if we replace the widget with a mirror, it replaces it in every
        # bar as python is referring to the same list.
        self.widgets = widgets.copy()
        self.window: Internal | None = None
        self.drawer: Drawer
        self._configured = False
        self._draw_queued = False
        self.future: asyncio.Handle | None = None

        # The part of the margins that was reserved by clients
        self._reserved_space: list[int] = [0, 0, 0, 0]  # [N, E, S, W]
        self._reserved_space_updated = False

        # Size saved when hiding the bar
        self._saved_size = 0

        # Previous window when the bar grabs the keyboard
        self._saved_focus: WindowType | None = None

        # Track widgets that are receiving input
        self._has_cursor: _Widget | None = None
        self._has_keyboard: _Widget | None = None

        # Because Gap.__init__ also sets self.margin
        self.margin = config.get("margin", self.margin)

        # Hacky solution that shows limitations of typing Configurable. We want the
        # option to accept `int | list[int]` but the attribute to be `list[int]`.
        self.margin: list[int]
        if isinstance(self.margin, int):  # type: ignore [unreachable]
            self.margin = [self.margin] * 4  # type: ignore [unreachable]

        self.border_width: list[int]
        if isinstance(self.border_width, int):  # type: ignore [unreachable]
            self.border_width = [self.border_width] * 4  # type: ignore [unreachable]

        self.border_color: ColorsType

        # Check if colours are valid but don't convert to rgba here
        if is_valid_colors(self.border_color):
            if not isinstance(self.border_color, list):
                self.border_color = [self.border_color] * 4
        else:
            logger.warning("Invalid border_color specified. Borders will not be displayed.")
            self.border_width = [0, 0, 0, 0]

    def _configure(self, qtile: Qtile, screen: Screen, reconfigure: bool = False) -> None:
        """
        Configure the bar. `reconfigure` is set to True when screen dimensions
        change, forcing a recalculation of the bar's dimensions.
        """
        # We only want to adjust margin sizes once unless there's new space being
        # reserved or we're reconfiguring the bar because the screen has changed
        if not self._configured or self._reserved_space_updated or reconfigure:
            Gap._configure(self, qtile, screen)

            if any(self.margin) or any(self.border_width) or self._reserved_space_updated:
                # Increase the margin size for the border. The border will be drawn
                # in this space so the empty space will just be the margin.
                margin = [b + s for b, s in zip(self.border_width, self._reserved_space)]

                if self.horizontal:
                    self.x += margin[3] - self.border_width[3]
                    self.width -= margin[1] + margin[3]
                    self._length = self.width
                    self._size += margin[0] + margin[2]
                    if screen.top is self:
                        self.y += margin[0] - self.border_width[0]
                    else:
                        self.y -= margin[2] + self.border_width[2]

                else:
                    self.y += margin[0] - self.border_width[0]
                    self.height -= margin[0] + margin[2]
                    self._length = self.height
                    self._size += margin[1] + margin[3]
                    if screen.left is self:
                        self.x += margin[3] - self.border_width[3]
                    else:
                        self.x -= margin[1] + self.border_width[1]

            if screen.bottom is self and not self.reserve:
                self.y -= self.height + self.margin[2]
            elif screen.right is self and not self.reserve:
                self.x -= self.width + self.margin[1]

            self._reserved_space_updated = False

        width = self.width + (self.border_width[1] + self.border_width[3])
        height = self.height + (self.border_width[0] + self.border_width[2])

        if self.window:
            # We get _configure()-ed with an existing window when screens are getting
            # reconfigured but this screen is present both before and after
            self.window.place(self.x, self.y, width, height, 0, None)

        else:
            # Whereas we won't have a window if we're startup up for the first time or
            # the window has been killed by us no longer using the bar's screen

            # X11 only:
            # To preserve correct display of SysTray widget, we need a 24-bit
            # window where the user requests an opaque bar.
            if qtile.core.name == "x11":
                depth = (
                    32
                    if has_transparency(self.background)
                    else qtile.core.conn.default_screen.root_depth
                )

                self.window = qtile.core.create_internal(  # type: ignore [call-arg]
                    self.x, self.y, width, height, depth
                )

            else:
                self.window = qtile.core.create_internal(self.x, self.y, width, height)

            self.window.opacity = self.opacity
            self.window.unhide()

            self.window.process_window_expose = self.draw
            self.window.process_button_click = self.process_button_click
            self.window.process_button_release = self.process_button_release
            self.window.process_pointer_enter = self.process_pointer_enter
            self.window.process_pointer_leave = self.process_pointer_leave
            self.window.process_pointer_motion = self.process_pointer_motion
            self.window.process_key_press = self.process_key_press

        if hasattr(self, "drawer"):
            self.drawer.width = width
            self.drawer.height = height
        else:
            self.drawer = self.window.create_drawer(width, height)
        self.drawer.clear(self.background)

        crashed_widgets: set[_Widget] = set()
        qtile.renamed_widgets = []
        if self._configured:
            for i in self.widgets:
                if not self._configure_widget(i):
                    crashed_widgets.add(i)
        else:
            for idx, i in enumerate(self.widgets):
                # Create a mirror if this widget is already configured but isn't a Mirror
                # We don't do isinstance(i, Mirror) because importing Mirror (at the top)
                # would give a circular import as libqtile.widget.base imports lbqtile.bar
                if i.configured and i.__class__.__name__ != "Mirror":
                    i = i.create_mirror()
                    self.widgets[idx] = i
                if self._configure_widget(i):
                    qtile.register_widget(i)
                else:
                    crashed_widgets.add(i)

        # Alert the user that we've renamed some widgets
        if qtile.renamed_widgets:
            logger.info(
                "The following widgets were renamed in qtile.widgets_map: %s "
                "To bind commands, rename the widget or use lazy.widget[new_name].",
                ", ".join(qtile.renamed_widgets),
            )
            qtile.renamed_widgets.clear()

        hook.subscribe.setgroup(self.set_layer)
        hook.subscribe.startup_complete(self.set_layer)

        self._remove_crashed_widgets(crashed_widgets)
        self.draw()
        self._resize(self._length, self.widgets)
        self._configured = True

    def _configure_widget(self, widget: _Widget) -> bool:
        assert self.qtile is not None

        if widget.supported_backends and (self.qtile.core.name not in widget.supported_backends):
            logger.warning(
                "Widget removed: %s does not support %s.",
                widget.__class__.__name__,
                self.qtile.core.name,
            )
            return False

        try:
            widget._configure(self.qtile, self)

            if self.horizontal:
                widget.offsety = self.border_width[0]
            else:
                widget.offsetx = self.border_width[3]

            widget.configured = True
        except Exception:
            logger.exception(
                "%s widget crashed during _configure with error:", widget.__class__.__name__
            )
            return False

        return True

    def _remove_crashed_widgets(self, crashed_widgets: set[_Widget]) -> None:
        if not crashed_widgets:
            return

        assert self.qtile is not None
        from libqtile.widget.config_error import ConfigErrorWidget

        for i in crashed_widgets:
            index = self.widgets.index(i)

            # Widgets that aren't available on the current backend should not
            # be shown as "crashed" as the behaviour is expected. Only notify
            # for genuine crashes.
            if not i.supported_backends or (self.qtile.core.name in i.supported_backends):
                crash = ConfigErrorWidget(widget=i)
                crash._configure(self.qtile, self)
                if self.horizontal:
                    crash.offsety = self.border_width[0]
                else:
                    crash.offsetx = self.border_width[3]
                self.widgets.insert(index, crash)

            self.widgets.remove(i)

    def _items(self, name: str) -> ItemT:
        if name == "screen" and self.screen is not None:
            return True, []
        elif name == "widget" and self.widgets:
            return False, [w.name for w in self.widgets]
        return None

    def _select(self, name: str, sel: str | int | None) -> CommandObject | None:
        if name == "screen":
            return self.screen
        elif name == "widget":
            for widget in self.widgets:
                if widget.name == sel:
                    return widget
        return None

    def finalize(self) -> None:
        if self.future:
            self.future.cancel()
        self.drawer.finalize()
        if self.window:
            self.window.kill()
            self.window = None
        self.widgets.clear()

    def _resize(self, length: int, widgets: list[_Widget]) -> None:
        # We want consecutive stretch widgets to split one 'block' of space between them
        stretches = []
        consecutive_stretches: defaultdict[_Widget, list[_Widget]] = defaultdict(list)
        prev_stretch: _Widget | None = None
        for widget in widgets:
            if widget.length_type == STRETCH:
                if prev_stretch:
                    consecutive_stretches[prev_stretch].append(widget)
                else:
                    stretches.append(widget)
                    prev_stretch = widget
            else:
                prev_stretch = None

        if stretches:
            stretchspace = length - sum(i.length for i in widgets if i.length_type != STRETCH)
            stretchspace = max(stretchspace, 0)
            num_stretches = len(stretches)

            if num_stretches == 1:
                stretches[0].length = stretchspace
            else:
                block = 0
                blocks = []
                for i in widgets:
                    if i.length_type != STRETCH:
                        block += i.length
                    elif i in stretches:  # False for consecutive_stretches
                        blocks.append(block)
                        block = 0
                if block:
                    blocks.append(block)
                interval = length // num_stretches

                for idx, i in enumerate(stretches):
                    if idx == 0:
                        i.length = interval - blocks[0] - blocks[1] // 2
                    elif idx == num_stretches - 1:
                        i.length = interval - blocks[-1] - blocks[-2] // 2
                    else:
                        i.length = int(interval - blocks[idx] / 2 - blocks[idx + 1] / 2)
                    stretchspace -= i.length

                stretches[0].length += stretchspace // 2
                stretches[-1].length += stretchspace - stretchspace // 2

            for i, followers in consecutive_stretches.items():
                length = i.length // (len(followers) + 1)
                rem = i.length - length
                i.length = length
                for f in followers:
                    f.length = length
                    rem -= length
                i.length += rem

        if self.horizontal:
            offset = self.border_width[3]
            for i in widgets:
                i.offsetx = offset
                offset += i.length
        else:
            offset = self.border_width[0]
            for i in widgets:
                i.offsety = offset
                offset += i.length

    def get_widget_in_position(self, x: int, y: int) -> _Widget | None:
        if self.horizontal:
            for i in self.widgets:
                if x < i.offsetx + i.length:
                    return i
        else:
            for i in self.widgets:
                if y < i.offsety + i.length:
                    return i
        return None

    def process_button_click(self, x: int, y: int, button: int) -> None:
        assert self.qtile is not None

        # If we're clicking on a bar that's not on the current screen, focus that screen
        if self.screen and self.screen is not self.qtile.current_screen:
            if self.qtile.core.name == "x11" and self.qtile.current_window:
                self.qtile.current_window._grab_click()
            index = self.qtile.screens.index(self.screen)
            self.qtile.focus_screen(index, warp=False)

        widget = self.get_widget_in_position(x, y)
        if widget:
            widget.button_press(
                x - widget.offsetx,
                y - widget.offsety,
                button,
            )

    def process_button_release(self, x: int, y: int, button: int) -> None:
        widget = self.get_widget_in_position(x, y)
        if widget:
            widget.button_release(
                x - widget.offsetx,
                y - widget.offsety,
                button,
            )

    def process_pointer_enter(self, x: int, y: int) -> None:
        widget = self.get_widget_in_position(x, y)
        if widget:
            widget.mouse_enter(
                x - widget.offsetx,
                y - widget.offsety,
            )
        self._has_cursor = widget

    def process_pointer_leave(self, x: int, y: int) -> None:
        if self._has_cursor:
            self._has_cursor.mouse_leave(
                x - self._has_cursor.offsetx,
                y - self._has_cursor.offsety,
            )
            self._has_cursor = None

    def process_pointer_motion(self, x: int, y: int) -> None:
        widget = self.get_widget_in_position(x, y)
        if widget and self._has_cursor and widget is not self._has_cursor:
            self._has_cursor.mouse_leave(
                x - self._has_cursor.offsetx,
                y - self._has_cursor.offsety,
            )
            widget.mouse_enter(
                x - widget.offsetx,
                y - widget.offsety,
            )
        self._has_cursor = widget

    def process_key_press(self, keycode: int) -> None:
        if self._has_keyboard:
            self._has_keyboard.process_key_press(keycode)

    def widget_grab_keyboard(self, widget: _Widget) -> None:
        """
        A widget can call this method to grab the keyboard focus
        and receive keyboard messages. When done,
        widget_ungrab_keyboard() must be called.
        """
        assert self.qtile is not None

        self._has_keyboard = widget
        self._saved_focus = self.qtile.current_window
        if self.window:
            self.window.focus(False)

    def widget_ungrab_keyboard(self) -> None:
        """
        Removes keyboard focus from the widget.
        """
        if self._saved_focus is not None:
            self._saved_focus.focus(False)
        self._has_keyboard = None

    def draw(self) -> None:
        assert self.qtile is not None

        if not self.widgets:
            return  # calling self._actual_draw in this case would cause a NameError.
        if not self._draw_queued:
            # Delay actually drawing the bar until the event loop is idle, and only once
            # even if this method is called multiple times during the same task.
            self.future = self.qtile.call_soon(self._actual_draw)
            self._draw_queued = True

    def _actual_draw(self) -> None:
        self._draw_queued = False
        self._resize(self._length, self.widgets)
        # We draw the border before the widgets
        if any(self.border_width):
            # The border is drawn "outside" of the bar (i.e. not in the space that the
            # widgets occupy) so we need to add the additional space
            width = self.width + self.border_width[1] + self.border_width[3]
            height = self.height + self.border_width[0] + self.border_width[2]

            # line_opts is a list of tuples where each tuple represents the borders
            # in the order N, E, S, W. The border tuple contains two pairs of
            # co-ordinates for the start and end of the border.
            rects = [
                (0, 0, width, self.border_width[0]),
                (
                    width - (self.border_width[1]),
                    self.border_width[0],
                    self.border_width[1],
                    height - self.border_width[0] - self.border_width[2],
                ),
                (0, height - self.border_width[2], width, self.border_width[2]),
                (
                    0,
                    self.border_width[0],
                    self.border_width[3],
                    height - self.border_width[0] - self.border_width[2],
                ),
            ]

            for border_width, colour, rect in zip(self.border_width, self.border_color, rects):
                if not border_width:
                    continue

                # Draw the border
                self.drawer.clear_rect(*rect)
                self.drawer.ctx.rectangle(*rect)
                self.drawer.set_source_rgb(colour)  # type: ignore[arg-type]
                self.drawer.ctx.fill()
                src_x, src_y, width, height = rect

                self.drawer.draw(
                    offsetx=src_x,
                    offsety=src_y,
                    width=width,
                    height=height,
                    src_x=src_x,
                    src_y=src_y,
                )

        for i in self.widgets:
            i.draw()

        # We need to check if there is any unoccupied space in the bar
        # This can happen where there are no SPACER-type widgets to fill
        # empty space.
        # In that scenario, we fill the empty space with the bar background colour
        # We do this, instead of just filling the bar completely at the start of this
        # method to avoid flickering.

        # Widgets are offset by the top/left border but this is not included in self._length
        # so we adjust the end of the bar area for this offset
        if self.horizontal:
            bar_end = self._length + self.border_width[3]
        else:
            bar_end = self._length + self.border_width[0]

        widget_end = i.offset + i.length

        if widget_end < bar_end:
            # Defines a rectangle for the area enclosed by the bar's borders and the end of the
            # last widget.
            if self.horizontal:
                rect = (widget_end, self.border_width[0], bar_end - widget_end, self.height)
            else:
                rect = (self.border_width[3], widget_end, self.width, bar_end - widget_end)

            # Clear that area (i.e. don't clear borders) and fill with background colour
            self.drawer.clear_rect(*rect)
            self.drawer.ctx.rectangle(*rect)
            self.drawer.set_source_rgb(self.background)
            self.drawer.ctx.fill()
            x, y, w, h = rect
            self.drawer.draw(offsetx=x, offsety=y, height=h, width=w, src_x=x, src_y=y)

    @expose_command()
    def info(self) -> dict[str, Any]:
        return dict(
            size=self._size,
            length=self._length,
            width=self.width,
            height=self.height,
            position=self.position,
            widgets=[i.info() for i in self.widgets],
            window=self.window.wid if self.window else None,
        )

    def is_show(self) -> bool:
        return self._size != 0

    def show(self, is_show: bool = True) -> None:
        if is_show != self.is_show():
            if is_show:
                self._size = self._saved_size
                if self.window:
                    self.window.unhide()
            else:
                self._saved_size = self._size
                self._size = 0
                if self.window:
                    self.window.hide()
            if self.screen and self.screen.group:
                self.screen.group.layout_all()

    def adjust_reserved_space(self, size: int) -> None:
        if self._size:
            # is this necessary?
            self._size = self._initial_size

        for i, side in enumerate(NESW):
            if getattr(self.screen, side) is self:
                self._reserved_space[i] += size
            if self._reserved_space[i] < 0:
                raise ValueError("Gap/Bar can't reserve negative space.")

        self._reserved_space_updated = True

    @expose_command()
    def fake_button_press(
        self, screen: int, position: str, x: int, y: int, button: int = 1
    ) -> None:
        """
        Fake a mouse-button-press on the bar. Co-ordinates are relative
        to the top-left corner of the bar.

        Parameters
        ==========
        widgets :
            A list of widget objects.
        size :
            The "thickness" of the bar, i.e. the height of a horizontal bar, or the
            width of a vertical bar.
        """
        # TODO: drop the screen and position args, update relevant tests
        self.process_button_click(x, y, button)

    def set_layer(self) -> None:
        if self.window:
            if self.reserve:
                self.window.keep_below(enable=True)
            else:
                # Bar is not reserving screen space so let's keep above other windows
                self.window.keep_above(enable=True)


BarType = typing.Union[Bar, Gap]
