# Copyright (C) 2024 Urwid developers
#    This library is free software; you can redistribute it and/or
#    modify it under the terms of the GNU Lesser General Public
#    License as published by the Free Software Foundation; either
#    version 2.1 of the License, or (at your option) any later version.
#
#    This library is distributed in the hope that it will be useful,
#    but WITHOUT ANY WARRANTY; without even the implied warranty of
#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
#    Lesser General Public License for more details.
#
#    You should have received a copy of the GNU Lesser General Public
#    License along with this library; if not, write to the Free Software
#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
#
# Urwid web site: https://urwid.org/
#
# Copyright (C) 2017-2024 rndusr (https://github.com/rndusr)
# Re-licensed from gpl-3.0 with author permission.
# Permission comment link: https://github.com/markqvist/NomadNet/pull/46#issuecomment-1892712616

from __future__ import annotations

import contextlib
import enum
import typing

from .constants import BOX_SYMBOLS, SHADE_SYMBOLS, Sizing
from .widget_decoration import WidgetDecoration, WidgetError

if typing.TYPE_CHECKING:
    from collections.abc import Iterator

    from typing_extensions import Literal

    from urwid import Canvas, CompositeCanvas

    from .widget import Widget


__all__ = ("ScrollBar", "Scrollable", "ScrollableError", "ScrollbarSymbols")


WrappedWidget = typing.TypeVar("WrappedWidget", bound="SupportsScroll")


class ScrollableError(WidgetError):
    """Scrollable specific widget errors."""


# Scroll actions
SCROLL_LINE_UP = "line up"
SCROLL_LINE_DOWN = "line down"
SCROLL_PAGE_UP = "page up"
SCROLL_PAGE_DOWN = "page down"
SCROLL_TO_TOP = "to top"
SCROLL_TO_END = "to end"

# Scrollbar positions
SCROLLBAR_LEFT = "left"
SCROLLBAR_RIGHT = "right"


class ScrollbarSymbols(str, enum.Enum):
    """Common symbols suitable for scrollbar."""

    FULL_BLOCK = SHADE_SYMBOLS.FULL_BLOCK
    DARK_SHADE = SHADE_SYMBOLS.DARK_SHADE
    MEDIUM_SHADE = SHADE_SYMBOLS.MEDIUM_SHADE
    LITE_SHADE = SHADE_SYMBOLS.LITE_SHADE

    DRAWING_LIGHT = BOX_SYMBOLS.LIGHT.VERTICAL
    DRAWING_LIGHT_2_DASH = BOX_SYMBOLS.LIGHT.VERTICAL_2_DASH
    DRAWING_LIGHT_3_DASH = BOX_SYMBOLS.LIGHT.VERTICAL_3_DASH
    DRAWING_LIGHT_4_DASH = BOX_SYMBOLS.LIGHT.VERTICAL_4_DASH

    DRAWING_HEAVY = BOX_SYMBOLS.HEAVY.VERTICAL
    DRAWING_HEAVY_2_DASH = BOX_SYMBOLS.HEAVY.VERTICAL_2_DASH
    DRAWING_HEAVY_3_DASH = BOX_SYMBOLS.HEAVY.VERTICAL_3_DASH
    DRAWING_HEAVY_4_DASH = BOX_SYMBOLS.HEAVY.VERTICAL_4_DASH

    DRAWING_DOUBLE = BOX_SYMBOLS.DOUBLE.VERTICAL


@typing.runtime_checkable
class WidgetProto(typing.Protocol):
    """Protocol for widget.

    Due to protocol cannot inherit non-protocol bases, define several obligatory Widget methods.
    """

    # Base widget methods (from Widget)
    def sizing(self) -> frozenset[Sizing]: ...

    def selectable(self) -> bool: ...

    def pack(self, size: tuple[int, int], focus: bool = False) -> tuple[int, int]: ...

    @property
    def base_widget(self) -> Widget:
        raise NotImplementedError

    def keypress(self, size: tuple[int, int], key: str) -> str | None: ...

    def mouse_event(
        self,
        size: tuple[int, int],
        event: str,
        button: int,
        col: int,
        row: int,
        focus: bool,
    ) -> bool | None: ...

    def render(self, size: tuple[int, int], focus: bool = False) -> Canvas: ...


@typing.runtime_checkable
class SupportsScroll(WidgetProto, typing.Protocol):
    """Scroll specific methods."""

    def get_scrollpos(self, size: tuple[int, int], focus: bool = False) -> int: ...

    def rows_max(self, size: tuple[int, int] | None = None, focus: bool = False) -> int: ...


@typing.runtime_checkable
class SupportsRelativeScroll(WidgetProto, typing.Protocol):
    """Relative scroll-specific methods."""

    def require_relative_scroll(self, size: tuple[int, int], focus: bool = False) -> bool: ...

    def get_first_visible_pos(self, size: tuple[int, int], focus: bool = False) -> int: ...

    def get_visible_amount(self, size: tuple[int, int], focus: bool = False) -> int: ...


def orig_iter(w: Widget) -> Iterator[Widget]:
    visited = {w}
    yield w
    while (w := getattr(w, "original_widget", w)) not in visited:
        visited.add(w)
        yield w


class Scrollable(WidgetDecoration[WrappedWidget]):
    def sizing(self) -> frozenset[Sizing]:
        return frozenset((Sizing.BOX,))

    def selectable(self) -> bool:
        return True

    def __init__(self, widget: WrappedWidget, force_forward_keypress: bool = False) -> None:
        """Box widget that makes a fixed or flow widget vertically scrollable

        .. note::
            Focusable widgets are handled, including switching focus, but possibly not intuitively,
            depending on the arrangement of widgets.

            When switching focus to a widget that is ouside of the visible part of the original widget,
            the canvas scrolls up/down to the focused widget.

            It would be better to scroll until the next focusable widget is in sight first.
            But for that to work we must somehow obtain a list of focusable rows in the original canvas.
        """
        if not widget.sizing() & frozenset((Sizing.FIXED, Sizing.FLOW)):
            raise ValueError(f"Not a fixed or flow widget: {widget!r}")

        self._trim_top = 0
        self._scroll_action = None
        self._forward_keypress = None
        self._old_cursor_coords = None
        self._rows_max_cached = 0
        self.force_forward_keypress = force_forward_keypress
        super().__init__(widget)

    def render(
        self,
        size: tuple[int, int],  # type: ignore[override]
        focus: bool = False,
    ) -> CompositeCanvas:
        from urwid import canvas

        maxcol, maxrow = size

        def automove_cursor() -> None:
            ch = 0
            last_hidden = False
            first_visible = False
            for pwi, (w, _o) in enumerate(ow.contents):
                wcanv = w.render((maxcol,))

                if wh := wcanv.rows():
                    ch += wh

                if not last_hidden and ch >= self._trim_top:
                    last_hidden = True

                elif last_hidden:
                    if not first_visible:
                        first_visible = True

                    if not w.selectable():
                        continue

                    ow.focus_item = pwi

                    st = None
                    nf = ow.get_focus()
                    if hasattr(nf, "key_timeout"):
                        st = nf
                    elif hasattr(nf, "original_widget"):
                        no = nf.original_widget
                        if hasattr(no, "original_widget"):
                            st = no.original_widget
                        elif hasattr(no, "key_timeout"):
                            st = no

                    if st and hasattr(st, "key_timeout") and callable(getattr(st, "keypress", None)):
                        st.keypress(None, None)

                    break

        # Render complete original widget
        ow = self._original_widget
        ow_size = self._get_original_widget_size(size)
        canv_full = ow.render(ow_size, focus)

        # Make full canvas editable
        canv = canvas.CompositeCanvas(canv_full)
        canv_cols, canv_rows = canv.cols(), canv.rows()

        if canv_cols <= maxcol and (pad_width := maxcol - canv_cols) > 0:
            # Canvas is narrower than available horizontal space
            canv.pad_trim_left_right(0, pad_width)

        if canv_rows <= maxrow and (fill_height := maxrow - canv_rows) > 0:
            # Canvas is lower than available vertical space
            canv.pad_trim_top_bottom(0, fill_height)

        if canv_cols <= maxcol and canv_rows <= maxrow:
            # Canvas is small enough to fit without trimming
            return canv

        self._adjust_trim_top(canv, size)

        # Trim canvas if necessary
        trim_top = self._trim_top
        trim_end = canv_rows - maxrow - trim_top
        trim_right = canv_cols - maxcol
        if trim_top > 0:
            canv.trim(trim_top)
        if trim_end > 0:
            canv.trim_end(trim_end)
        if trim_right > 0:
            canv.pad_trim_left_right(0, -trim_right)

        # Disable cursor display if cursor is outside of visible canvas parts
        if canv.cursor is not None:
            # Pylint check acts here a bit weird.
            _curscol, cursrow = canv.cursor  # pylint: disable=unpacking-non-sequence,useless-suppression
            if cursrow >= maxrow or cursrow < 0:
                canv.cursor = None

        # Figure out whether we should forward keypresses to original widget
        if canv.cursor is not None:
            # Trimmed canvas contains the cursor, e.g. in an Edit widget
            self._forward_keypress = True
        elif canv_full.cursor is not None:
            # Full canvas contains the cursor, but scrolled out of view
            self._forward_keypress = False

            # Reset cursor position on page/up down scrolling
            if getattr(ow, "automove_cursor_on_scroll", False):
                with contextlib.suppress(Exception):
                    automove_cursor()

        else:
            # Original widget does not have a cursor, but may be selectable

            # FIXME: Using ow.selectable() is bad because the original
            # widget may be selectable because it's a container widget with
            # a key-grabbing widget that is scrolled out of view.
            # ow.selectable() returns True anyway because it doesn't know
            # how we trimmed our canvas.
            #
            # To fix this, we need to resolve ow.focus and somehow
            # ask canv whether it contains bits of the focused widget.  I
            # can't see a way to do that.
            self._forward_keypress = ow.selectable()

        return canv

    def keypress(
        self,
        size: tuple[int, int],  # type: ignore[override]
        key: str,
    ) -> str | None:
        from urwid.command_map import Command

        # Maybe offer key to original widget
        if self._forward_keypress or self.force_forward_keypress:
            ow = self._original_widget
            ow_size = self._get_original_widget_size(size)

            # Remember the previous cursor position if possible
            if hasattr(ow, "get_cursor_coords"):
                self._old_cursor_coords = ow.get_cursor_coords(ow_size)

            key = ow.keypress(ow_size, key)
            if key is None:
                return None

        # Handle up/down, page up/down, etc.
        command_map = self._command_map
        if command_map[key] == Command.UP:
            self._scroll_action = SCROLL_LINE_UP
        elif command_map[key] == Command.DOWN:
            self._scroll_action = SCROLL_LINE_DOWN

        elif command_map[key] == Command.PAGE_UP:
            self._scroll_action = SCROLL_PAGE_UP
        elif command_map[key] == Command.PAGE_DOWN:
            self._scroll_action = SCROLL_PAGE_DOWN

        elif command_map[key] == Command.MAX_LEFT:  # 'home'
            self._scroll_action = SCROLL_TO_TOP
        elif command_map[key] == Command.MAX_RIGHT:  # 'end'
            self._scroll_action = SCROLL_TO_END

        else:
            return key

        self._invalidate()
        return None

    def mouse_event(
        self,
        size: tuple[int, int],  # type: ignore[override]
        event: str,
        button: int,
        col: int,
        row: int,
        focus: bool,
    ) -> bool | None:
        ow = self._original_widget
        if hasattr(ow, "mouse_event"):
            ow_size = self._get_original_widget_size(size)
            row += self._trim_top
            return ow.mouse_event(ow_size, event, button, col, row, focus)

        return False

    def _adjust_trim_top(self, canv: Canvas, size: tuple[int, int]) -> None:
        """Adjust self._trim_top according to self._scroll_action"""
        action = self._scroll_action
        self._scroll_action = None

        _maxcol, maxrow = size
        trim_top = self._trim_top
        canv_rows = canv.rows()

        if trim_top < 0:
            # Negative trim_top values use bottom of canvas as reference
            trim_top = canv_rows - maxrow + trim_top + 1

        if canv_rows <= maxrow:
            self._trim_top = 0  # Reset scroll position
            return

        def ensure_bounds(new_trim_top: int) -> int:
            return max(0, min(canv_rows - maxrow, new_trim_top))

        if action == SCROLL_LINE_UP:
            self._trim_top = ensure_bounds(trim_top - 1)
        elif action == SCROLL_LINE_DOWN:
            self._trim_top = ensure_bounds(trim_top + 1)

        elif action == SCROLL_PAGE_UP:
            self._trim_top = ensure_bounds(trim_top - maxrow + 1)
        elif action == SCROLL_PAGE_DOWN:
            self._trim_top = ensure_bounds(trim_top + maxrow - 1)

        elif action == SCROLL_TO_TOP:
            self._trim_top = 0
        elif action == SCROLL_TO_END:
            self._trim_top = canv_rows - maxrow

        else:
            self._trim_top = ensure_bounds(trim_top)

        # If the cursor was moved by the most recent keypress, adjust trim_top
        # so that the new cursor position is within the displayed canvas part.
        # But don't do this if the cursor is at the top/bottom edge so we can still scroll out
        if self._old_cursor_coords is not None and self._old_cursor_coords != canv.cursor and canv.cursor is not None:
            self._old_cursor_coords = None
            _curscol, cursrow = canv.cursor
            if cursrow < self._trim_top:
                self._trim_top = cursrow
            elif cursrow >= self._trim_top + maxrow:
                self._trim_top = max(0, cursrow - maxrow + 1)

    def _get_original_widget_size(
        self,
        size: tuple[int, int],  # type: ignore[override]
    ) -> tuple[int] | tuple[()]:
        ow = self._original_widget
        sizing = ow.sizing()
        if Sizing.FLOW in sizing:
            return (size[0],)
        if Sizing.FIXED in sizing:
            return ()
        raise ScrollableError(f"{ow!r} sizing is not supported")

    def get_scrollpos(self, size: tuple[int, int] | None = None, focus: bool = False) -> int:
        """Current scrolling position.

        Lower limit is 0, upper limit is the maximum number of rows with the given maxcol minus maxrow.

        ..note::
            The returned value may be too low or too high if the position has
            changed but the widget wasn't rendered yet.
        """
        return self._trim_top

    def set_scrollpos(self, position: typing.SupportsInt) -> None:
        """Set scrolling position

        If `position` is positive it is interpreted as lines from the top.
        If `position` is negative it is interpreted as lines from the bottom.

        Values that are too high or too low values are automatically adjusted during rendering.
        """
        self._trim_top = int(position)
        self._invalidate()

    def rows_max(self, size: tuple[int, int] | None = None, focus: bool = False) -> int:
        """Return the number of rows for `size`

        If `size` is not given, the currently rendered number of rows is returned.
        """
        if size is not None:
            ow = self._original_widget
            ow_size = self._get_original_widget_size(size)
            sizing = ow.sizing()
            if Sizing.FIXED in sizing:
                self._rows_max_cached = ow.pack(ow_size, focus)[1]
            elif Sizing.FLOW in sizing:
                self._rows_max_cached = ow.rows(ow_size, focus)
            else:
                raise ScrollableError(f"Not a flow/box widget: {self._original_widget!r}")
        return self._rows_max_cached


class ScrollBar(WidgetDecoration[WrappedWidget]):
    Symbols = ScrollbarSymbols

    def sizing(self) -> frozenset[Sizing]:
        return frozenset((Sizing.BOX,))

    def selectable(self) -> bool:
        return True

    def __init__(
        self,
        widget: WrappedWidget,
        thumb_char: str = ScrollbarSymbols.FULL_BLOCK,
        trough_char: str = " ",
        side: Literal["left", "right"] = SCROLLBAR_RIGHT,
        width: int = 1,
    ) -> None:
        """Box widget that adds a scrollbar to `widget`

        `widget` must be a box widget with the following methods:
          - `get_scrollpos` takes the arguments `size` and `focus` and returns the index of the first visible row.
          - `set_scrollpos` (optional; needed for mouse click support) takes the index of the first visible row.
          - `rows_max` takes `size` and `focus` and returns the total number of rows `widget` can render.

        `thumb_char` is the character used for the scrollbar handle.
        `trough_char` is used for the space above and below the handle.
        `side` must be 'left' or 'right'.
        `width` specifies the number of columns the scrollbar uses.
        """
        if Sizing.BOX not in widget.sizing():
            raise ValueError(f"Not a box widget: {widget!r}")

        if not any(isinstance(w, SupportsScroll) for w in orig_iter(widget)):
            raise TypeError(f"Not a scrollable widget: {widget!r}")

        super().__init__(widget)
        self._thumb_char = thumb_char
        self._trough_char = trough_char
        self.scrollbar_side = side
        self.scrollbar_width = max(1, width)
        self._original_widget_size = (0, 0)

    def render(
        self,
        size: tuple[int, int],  # type: ignore[override]
        focus: bool = False,
    ) -> Canvas:
        from urwid import canvas

        def render_no_scrollbar() -> Canvas:
            self._original_widget_size = size
            return ow.render(size, focus)

        def render_for_scrollbar() -> Canvas:
            self._original_widget_size = ow_size
            return ow.render(ow_size, focus)

        maxcol, maxrow = size

        ow_size = (max(0, maxcol - self._scrollbar_width), maxrow)
        sb_width = maxcol - ow_size[0]

        ow = self._original_widget
        ow_base = self.scrolling_base_widget

        # Use hasattr instead of protocol: hasattr will return False in case of getattr raise AttributeError
        # Use __length_hint__ first since it's less resource intensive
        use_relative = (
            isinstance(ow_base, SupportsRelativeScroll)
            and any(hasattr(ow_base, attrib) for attrib in ("__length_hint__", "__len__"))
            and ow_base.require_relative_scroll(size, focus)
        )

        if use_relative:
            # `operator.length_hint` is Protocol (Spec) over class based and can end false-negative on the instance
            # use length_hint-like approach with safe `AttributeError` handling
            ow_len = getattr(ow_base, "__len__", getattr(ow_base, "__length_hint__", int))()
            ow_canv = render_for_scrollbar()
            visible_amount = ow_base.get_visible_amount(ow_size, focus)
            pos = ow_base.get_first_visible_pos(ow_size, focus)

            # in the case of estimated length, it can be smaller than real widget length
            ow_len = max(ow_len, visible_amount, pos)
            posmax = ow_len - visible_amount
            thumb_weight = min(1.0, visible_amount / max(1, ow_len))

            if ow_len == visible_amount:
                # Corner case: formally all contents indexes should be visible, but this does not mean all rows
                use_relative = False

        if not use_relative:
            if ow_base.rows_max(size, focus) > maxrow:
                ow_canv = render_for_scrollbar()
                # re-calculate using wrapped size
                ow_rows_max = ow_base.rows_max(ow_size, focus)
                pos = ow_base.get_scrollpos(ow_size, focus)
                posmax = ow_rows_max - maxrow
                thumb_weight = min(1.0, maxrow / max(1, ow_rows_max))

            else:
                # Canvas fits without scrolling - no scrollbar needed
                return render_no_scrollbar()

        # Thumb shrinks/grows according to the ratio of <number of visible lines> / <number of total lines>
        thumb_height = max(1, round(thumb_weight * maxrow))  # pylint: disable=possibly-used-before-assignment

        # Thumb may only touch top/bottom if the first/last row is visible
        top_weight = float(pos) / max(1, posmax)  # pylint: disable=possibly-used-before-assignment
        top_height = int((maxrow - thumb_height) * top_weight)
        if top_height == 0 and top_weight > 0:
            top_height = min(1, maxrow - thumb_height)

        # Bottom part is remaining space
        bottom_height = maxrow - thumb_height - top_height

        # Create scrollbar canvas
        # Creating SolidCanvases of correct height may result in
        # "cviews do not fill gaps in shard_tail!" or "cviews overflow gaps in shard_tail!" exceptions.
        # Stacking the same SolidCanvas is a workaround.
        # https://github.com/urwid/urwid/issues/226#issuecomment-437176837
        top = canvas.SolidCanvas(self._trough_char, sb_width, 1)
        thumb = canvas.SolidCanvas(self._thumb_char, sb_width, 1)
        bottom = canvas.SolidCanvas(self._trough_char, sb_width, 1)
        sb_canv = canvas.CanvasCombine(
            (
                *((top, None, False) for _ in range(top_height)),
                *((thumb, None, False) for _ in range(thumb_height)),
                *((bottom, None, False) for _ in range(bottom_height)),
            ),
        )

        combinelist = [
            (ow_canv, None, True, ow_size[0]),  # pylint: disable=possibly-used-before-assignment
            (sb_canv, None, False, sb_width),
        ]

        if self._scrollbar_side != SCROLLBAR_LEFT:
            return canvas.CanvasJoin(combinelist)

        return canvas.CanvasJoin(reversed(combinelist))

    @property
    def scrollbar_width(self) -> int:
        """Columns the scrollbar uses"""
        return max(1, self._scrollbar_width)

    @scrollbar_width.setter
    def scrollbar_width(self, width: typing.SupportsInt) -> None:
        self._scrollbar_width = max(1, int(width))
        self._invalidate()

    @property
    def scrollbar_side(self) -> Literal["left", "right"]:
        """Where to display the scrollbar; must be 'left' or 'right'"""
        return self._scrollbar_side

    @scrollbar_side.setter
    def scrollbar_side(self, side: Literal["left", "right"]) -> None:
        if side not in {SCROLLBAR_LEFT, SCROLLBAR_RIGHT}:
            raise ValueError(f'scrollbar_side must be "left" or "right", not {side!r}')
        self._scrollbar_side = side
        self._invalidate()

    @property
    def scrolling_base_widget(self) -> SupportsScroll | SupportsRelativeScroll:
        """Nearest `original_widget` that is compatible with the scrolling API"""

        w = self

        for w in orig_iter(self):
            if isinstance(w, SupportsScroll):
                return w

        raise ScrollableError(f"Not compatible to be wrapped by ScrollBar: {w!r}")

    def keypress(
        self,
        size: tuple[int, int],  # type: ignore[override]
        key: str,
    ) -> str | None:
        return self._original_widget.keypress(self._original_widget_size, key)

    def mouse_event(
        self,
        size: tuple[int, int],  # type: ignore[override]
        event: str,
        button: int,
        col: int,
        row: int,
        focus: bool,
    ) -> bool | None:
        ow = self._original_widget
        ow_size = self._original_widget_size
        handled: bool | None = False
        if hasattr(ow, "mouse_event"):
            handled = ow.mouse_event(ow_size, event, button, col, row, focus)

        if not handled and hasattr(ow, "set_scrollpos"):
            if button == 4:  # scroll wheel up
                pos = ow.get_scrollpos(ow_size)
                newpos = max(pos - 1, 0)
                ow.set_scrollpos(newpos)
                return True
            if button == 5:  # scroll wheel down
                pos = ow.get_scrollpos(ow_size)
                ow.set_scrollpos(pos + 1)
                return True

        return handled
