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 is_valid_colors

if typing.TYPE_CHECKING:
    import asyncio
    from typing import Any

    from libqtile.backend.base import Drawer, Internal, Window
    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:
        self.length: int = 0  # width of a horizontal gap or the height of a vertical gap
        self.size: int = size  # height of a horizontal gap or the width of a vertical gap
        self.fullsize: int = size  # sum of 'size' and margins
        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.fullsize = self.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.size
            self.horizontal = True
            self.fullsize += 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.size
            self.horizontal = True
            self.fullsize += 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.size
            self.height = self.length
            self.horizontal = False
            self.fullsize += 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.size
            self.height = self.length
            self.horizontal = False
            self.fullsize += 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 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: Window | 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):
            self.margin = [self.margin] * 4

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

        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.fullsize += 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.fullsize += 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

            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):
                if not i.hide_crash:
                    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()
        for widget in self.widgets:
            if not widget.finalized:
                widget.finalize()
        if hasattr(self, "drawer"):
            self.drawer.finalize()
            del self.drawer
        if self.window:
            self.window.kill()
            self.window = None

    def _resize(self, length: int, widgets: list[_Widget]) -> None:
        """
        Resize stretch widgets to fill bar:
        1 block of spacers uses all available space
        2 blocks of spacers centre text between blocks
        3 or more blocks evenly space widgets between blocks.
        """
        # 1) Identify stretch group heads and their followers
        stretches: list[_Widget] = []
        consecutive_stretches: defaultdict[_Widget, list[_Widget]] = defaultdict(list)
        prev_stretch: _Widget | None = None

        for w in widgets:
            if w.length_type == STRETCH:
                if prev_stretch is None:
                    stretches.append(w)  # start of a new stretch group
                    prev_stretch = w
                else:
                    consecutive_stretches[prev_stretch].append(w)  # follower
            else:
                prev_stretch = None

        # 2) If there are stretch groups, allocate space to them
        if stretches:
            # Total space available to all stretch groups
            fixed_total = sum(w.length for w in widgets if w.length_type != STRETCH)
            stretchspace = max(length - fixed_total, 0)

            def assign_group_allocation(head: _Widget, group_allocation: int) -> None:
                """Distribute group_allocation among head + its followers evenly; head keeps any remainder."""
                followers = consecutive_stretches.get(head, [])
                count = 1 + len(followers)
                each = group_allocation // count
                leftover = group_allocation - each * count
                head.length = each + leftover
                for f in followers:
                    f.length = each

            num_groups = len(stretches)
            blocks: list[int] = []

            if num_groups == 1:
                # Single group gets all available space
                assign_group_allocation(stretches[0], stretchspace)

            elif num_groups == 2:
                # Special centering: center the fixed content between groups within the bar.
                acc = 0
                for w in widgets:
                    if w.length_type != STRETCH:
                        acc += w.length
                    elif w in stretches:  # only group heads
                        blocks.append(acc)
                        acc = 0
                blocks.append(acc)

                start = blocks[0] if blocks else 0
                end = blocks[-1] if blocks else 0

                # L + R = stretchspace, and start + L == R + end  =>  L = (stretchspace + end - start) // 2
                left_alloc = (stretchspace + end - start) // 2
                left_alloc = max(0, min(stretchspace, left_alloc))
                right_alloc = stretchspace - left_alloc

                assign_group_allocation(stretches[0], left_alloc)
                assign_group_allocation(stretches[1], right_alloc)

            else:
                # 3+ groups: block-aware distribution
                # Centres of blocks of non-stretch widgets are spaced evenly
                # 1) Compute fixed-width blocks between group heads (and before first / after last)
                acc = 0
                for w in widgets:
                    if w.length_type != STRETCH:
                        acc += w.length
                    elif w in stretches:  # count heads only, not followers
                        blocks.append(acc)
                        acc = 0
                blocks.append(acc)  # trailing block after last head

                # 2) Tentative sizes using full bar interval minus adjacent block penalties
                interval = length // num_groups
                group_sizes: list[int] = []
                for idx in range(num_groups):
                    if idx == 0:
                        size = interval - blocks[0] - (blocks[1] // 2 if len(blocks) > 1 else 0)
                    elif idx == num_groups - 1:
                        size = interval - blocks[-1] - (blocks[-2] // 2 if len(blocks) > 1 else 0)
                    else:
                        # halves around the middle groups;
                        left_half = blocks[idx] / 2 if idx < len(blocks) else 0
                        right_half = blocks[idx + 1] / 2 if idx + 1 < len(blocks) else 0
                        size = int(interval - left_half - right_half)

                    group_sizes.append(max(0, size))

                # 3) Remainder from integer math goes to first and last
                remainder = stretchspace - sum(group_sizes)
                if remainder:
                    add_left = remainder // 2
                    add_right = remainder - add_left
                    group_sizes[0] += add_left
                    group_sizes[-1] += add_right

                # 4) Distribute each group to head + followers
                for head, alloc in zip(stretches, group_sizes):
                    assign_group_allocation(head, alloc)

        # 3) Set offsets
        if self.horizontal:
            offset = self.border_width[3]
            for w in widgets:
                w.offsetx = offset
                offset += w.length
        else:
            offset = self.border_width[0]
            for w in widgets:
                w.offsety = offset
                offset += w.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:
            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:
            try:
                i.draw()
            except Exception:
                logger.exception("Widget failed to 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]
            widget_end = i.offsetx + i.length
        else:
            bar_end = self.length + self.border_width[0]
            widget_end = i.offsety + 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(
            length=self.length,
            size=self.size,
            fullsize=self.fullsize,
            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.fullsize != 0

    def show(self, is_show: bool = True) -> None:
        if is_show != self.is_show():
            if is_show:
                self.fullsize = self._saved_size
                if self.window:
                    self.window.unhide()
            else:
                self._saved_size = self.fullsize
                self.fullsize = 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.fullsize:
            # is this necessary?
            self.fullsize = self.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, x: int, y: int, button: int = 1) -> None:
        """
        Fake a mouse-button-press on the bar. Coordinates are relative
        to the top-left corner of the bar.

        Parameters
        ==========
        x :
            X coordinate of the mouse button press.
        y :
            Y coordinate of the mouse button press.
        button:
            Mouse button, for more details, see :ref:`mouse-events`.
        """
        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 = Bar | Gap
