# pylint: disable=no-member
# pylint:disable=using-constant-test
# pylint:disable=comparison-with-callable
from gettext import gettext as _
from math import floor

import gi

from lutris.util.jobs import schedule_at_idle
from lutris.util.log import logger

gi.require_version("PangoCairo", "1.0")

import cairo
from gi.repository import Gdk, GObject, Gtk, Pango, PangoCairo

from lutris.gui.widgets.utils import (
    MEDIA_CACHE_INVALIDATED,
    get_default_icon_path,
    get_runtime_icon_path,
    get_scaled_surface_by_path,
    get_surface_size,
)
from lutris.services.service_media import resolve_media_path
from lutris.util.path_cache import MISSING_GAMES

_MEDIA_CACHE_GENERATION_NUMBER = 0


class GridViewCellRendererText(Gtk.CellRendererText):
    """CellRendererText adjusted for grid view display, removes extra padding
    and caches cell metrics for improved resize performance."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.props.alignment = Pango.Alignment.CENTER
        self.props.wrap_mode = Pango.WrapMode.WORD
        self.props.xalign = 0.5
        self.props.yalign = 0
        self.fixed_width = None
        self.cached_height = {}
        self.cached_width = {}

    def set_width(self, width):
        self.fixed_width = width
        self.props.wrap_width = width
        self.clear_caches()

    def clear_caches(self):
        self.cached_height.clear()
        self.cached_width.clear()

    def do_get_preferred_width(self, widget):
        text = self.props.text  # pylint:disable=no-member
        if self.fixed_width and text in self.cached_width:
            return self.cached_width[text]

        width = Gtk.CellRendererText.do_get_preferred_width(self, widget)

        if self.fixed_width:
            self.cached_width[text] = width

        return width

    def do_get_preferred_width_for_height(self, widget, width):
        text = self.props.text  # pylint:disable=no-member
        if self.fixed_width and text in self.cached_width:
            return self.cached_width[text]

        width = Gtk.CellRendererText.do_get_preferred_width_for_height(self, widget, width)

        if self.fixed_width:
            self.cached_width[text] = width

        return width

    def do_get_preferred_height(self, widget):
        text = self.props.text  # pylint:disable=no-member
        if self.fixed_width and text in self.cached_height:
            return self.cached_height[text]

        height = Gtk.CellRendererText.do_get_preferred_height(self, widget)

        if self.fixed_width:
            self.cached_height[text] = height

        return height

    def do_get_preferred_height_for_width(self, widget, width):
        text = self.props.text  # pylint:disable=no-member
        if self.fixed_width and text in self.cached_height:
            return self.cached_height[text]

        height = Gtk.CellRendererText.do_get_preferred_height_for_width(self, widget, width)

        if self.fixed_width:
            self.cached_height[text] = height

        return height


class GridViewCellRendererImage(Gtk.CellRenderer):
    """A pixbuf cell renderer that takes not the pixbuf but a path to an image file;
    it loads that image only when rendering. It also has properties for its width
    and height, so it need not load the pixbuf to know its size."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._game_id = None
        self._service = None
        self._media_paths = []
        self._show_badges = True
        self._platform = None
        self._is_installed = True
        self.cached_surfaces_new = {}
        self.cached_surfaces_old = {}
        self.cached_surfaces_loaded = 0
        self.cached_surface_generation = 0
        self.badge_size = 0, 0
        self.badge_alpha = 0.6
        self.badge_fore_color = 1, 1, 1
        self.badge_back_color = 0, 0, 0
        self._inset_fractions = {}
        self._expected_width = None
        self._expected_height = None

    def inset_game(self, game_id: str, fraction: float) -> bool:
        """This function indicates that a particular game should be displayed inset by a certain fraction of
        its total size; 0 is full size, 0.1 would show it at 90% size, but centered.

        This is not bound as an attribute; it's used for an ephemeral animation, and we wouldn't want
        to mess with the GameStore to do it. Instead, the cell renderer tracks these per game ID, and
        the caller uses queue_draw() to trigger a redraw.

        Set the fraction to 0 for a game to remove the effect when done.

        This returns True if it alters the inset of a game, and False if not because it was
        already set that way."""
        if fraction > 0.0:
            if fraction != self._inset_fractions.get(game_id):
                self._inset_fractions[game_id] = fraction
                return True
        elif game_id in self._inset_fractions:
            del self._inset_fractions[game_id]
            return True

        return False

    def is_library_view(self):
        """Returns True if this is rendering for the library view (not a service view)"""
        return self.service is None

    def set_expected_size(self, width, height):
        """Sets the expected dimensions for library view based on the selected zoom level"""
        self._expected_width = width
        self._expected_height = height

    @GObject.Property(type=str)
    def game_id(self):
        """This is the path to the media file to be displayed."""
        return self._game_id

    @game_id.setter
    def game_id(self, value):
        self._game_id = value

    @GObject.Property(type=GObject.TYPE_PYOBJECT)
    def service(self):
        return self._service

    @service.setter
    def service(self, value):
        self._service = value

    @GObject.Property(type=GObject.TYPE_PYOBJECT)
    def media_paths(self):
        """This is the list of paths where the media to be displayed may be."""
        return self._media_paths

    @media_paths.setter
    def media_paths(self, value):
        self._media_paths = value

    @GObject.Property(type=bool, default=True)
    def show_badges(self):
        """This is the path to the media file to be displayed."""
        return self._show_badges

    @show_badges.setter
    def show_badges(self, value):
        self._show_badges = value

    @GObject.Property(type=str)
    def platform(self):
        """This is the platform text, a comma separated list; we try to convert
        this into icons, if it is not None."""
        return self._platform

    @platform.setter
    def platform(self, value):
        self._platform = value

    @GObject.Property(type=bool, default=True)
    def is_installed(self):
        """This flag indicates if the game is installed; if not the media is shown
        faded out."""
        return self._is_installed

    @is_installed.setter
    def is_installed(self, value):
        self._is_installed = value

    def _get_preferred_size(self):
        paths = self.media_paths
        if paths:
            path = paths[0]
            return path.width, path.height
        return 0, 0

    def do_get_preferred_width(self, widget):
        if self.is_library_view() and self._expected_width:
            return self._expected_width, self._expected_width
        size = self._get_preferred_size()
        return size[0], size[0]

    def do_get_preferred_height(self, widget):
        if self.is_library_view() and self._expected_height:
            return self._expected_height, self._expected_height
        size = self._get_preferred_size()
        return size[1], size[1]

    def do_render(self, cr, widget, background_area, cell_area, flags):
        media_path = resolve_media_path(self.media_paths) if self.media_paths else None
        if not media_path:
            return

        media_width = media_path.width
        media_height = media_path.height
        path = media_path.path
        alpha = 1 if self.is_installed else 100 / 255

        if media_width > 0 and media_height > 0 and path:
            surface = self._get_cached_surface_by_path(widget, path, size=(media_width, media_height))
            if not surface:
                # The default icon needs to be scaled to fill the cell space.
                path = get_default_icon_path((media_width, media_height))
                surface = self._get_cached_surface_by_path(
                    widget, path, size=(media_width, media_height), preserve_aspect_ratio=False
                )
            if surface:
                media_area = self.get_media_area(surface, cell_area)
                self.select_badge_metrics(surface, media_width, media_height)

                cr.save()

                # Adjust context to place media_area at 0,0 and scale it.
                inset_fraction = self._inset_fractions.get(self.game_id) or 0.0 if self.game_id else 0.0
                if inset_fraction > 0:
                    media_area.x += (media_area.width * inset_fraction) / 2
                    media_area.y += (media_area.height * inset_fraction) / 2
                    cr.translate(media_area.x, media_area.y)
                    cr.scale(1 - inset_fraction, 1 - inset_fraction)
                else:
                    cr.translate(media_area.x, media_area.y)

                media_area.x = 0
                media_area.y = 0

                if alpha >= 1:
                    self.render_media(cr, widget, surface, 0, 0)
                    if self.show_badges:
                        self._render_badges(cr, widget, surface, media_area)
                else:
                    cr.push_group()
                    self.render_media(cr, widget, surface, 0, 0)
                    if self.show_badges:
                        self._render_badges(cr, widget, surface, media_area)
                    cr.pop_group_to_source()
                    cr.paint_with_alpha(alpha)
                cr.restore()

                if inset_fraction > 0:
                    # Since we are restoring a scale change _again_ we need
                    # to update the layout with that change too, or the other text
                    # in the view will be broken. So lame.
                    layout = widget.create_pango_layout("")
                    PangoCairo.update_layout(cr, layout)

            # Idle time will wait until the widget has drawn whatever it wants to;
            # we can then discard surfaces we aren't using anymore.
            schedule_at_idle(self.cycle_cache)

    def select_badge_metrics(self, surface, media_width, media_height):
        """Updates fields holding data about the appearance of the badges;
        this sets self.badge_size to None if no badges should be shown at all."""

        def get_badge_icon_size():
            """Returns the size of the badge icons to render, or None to hide them. We check
            width for the smallest size because Dolphin has very thin banners, but we only hide
            badges for icons, not banners."""
            if media_width < 64:
                return None
            if media_height < 128:
                return 16, 16
            if media_height < 256:
                return 24, 24
            return 32, 32

        self.badge_size = get_badge_icon_size()
        on_bright_surface = self.badge_size and GridViewCellRendererImage.is_bright_corner(surface, self.badge_size)

        bright_color = 0.8, 0.8, 0.8
        dark_color = 0.2, 0.2, 0.2
        self.badge_fore_color = dark_color if on_bright_surface else bright_color
        self.badge_back_color = bright_color if on_bright_surface else dark_color

    @staticmethod
    def is_bright_corner(surface, corner_size):
        """Tests several pixels near the corner of the surface where the badges
        are drawn. If all are 'bright', we'll render the badges differently. This
        means all 4 components must be at least 128/255."""
        surface_format = surface.get_format()

        # We only use the ARGB32 format, so we just give up
        # for anything else.
        if surface_format != cairo.FORMAT_ARGB32:  # pylint:disable=no-member
            return False

        # Scale the corner according to the surface's scale factor -
        # normally the same as our UI scale factor.
        device_scale_x, device_scale_y = surface.get_device_scale()
        corner_pixel_width = int(corner_size[0] * device_scale_x)
        corner_pixel_height = int(corner_size[1] * device_scale_y)
        pixel_width = surface.get_width()
        pixel_height = surface.get_height()

        def is_bright_pixel(x, y):
            # Checks if a pixel is 'bright'; this does not care
            # if the pixel is big or little endian- it just checks
            # all four channels.
            if 0 <= x < pixel_width and 0 <= y < pixel_height:
                stride = surface.get_stride()
                data = surface.get_data()

                offset = (y * stride) + x * 4
                pixel = data[offset : offset + 4]

                for channel in pixel:
                    if channel < 128:
                        return False
                return True
            return False

        return (
            is_bright_pixel(pixel_width - 1, pixel_height - 1)
            and is_bright_pixel(pixel_width - corner_pixel_width, pixel_height - 1)
            and is_bright_pixel(pixel_width - 1, pixel_height - corner_pixel_height)
            and is_bright_pixel(pixel_width - corner_pixel_width, pixel_height - corner_pixel_height)
        )

    def get_media_area(self, surface, cell_area):
        """Computes the position of the upper left corner where we will render
        a surface within the cell area."""
        media_area = Gdk.Rectangle()
        width, height = get_surface_size(surface)

        # Horizontal centering (same for both library and service views)
        media_area.x = round(cell_area.x + (cell_area.width - width) / 2)

        if self.is_library_view():
            # For library view, center vertically within cell height
            media_area.y = round(cell_area.y + (cell_area.height - height) / 2)
        else:
            # Service views: bottom-aligned
            media_area.y = round(cell_area.y + cell_area.height - height)

        media_area.width, media_area.height = width, height
        return media_area

    def render_media(self, cr, widget, surface, x, y):
        """Renders the media itself, given the surface containing it
        and the position."""
        width, height = get_surface_size(surface)

        cr.set_source_surface(surface, x, y)
        cr.get_source().set_extend(cairo.Extend.PAD)  # pylint: disable=no-member
        cr.rectangle(x, y, width, height)
        cr.fill()

    def _render_badges(self, cr, widget, surface, media_area):
        self.render_platforms(cr, widget, surface, 0, media_area)

        game_id = self.game_id
        if game_id:
            if self.service:
                game_id = self.service.resolve_game_id(game_id)

            if game_id in MISSING_GAMES.missing_game_ids:
                self.render_text_badge(cr, widget, _("Missing"), 0, media_area.y + media_area.height)

    def render_platforms(self, cr, widget, surface, surface_x, media_area):
        """Renders the stack of platform icons."""
        platform = self.platform
        if platform and self.badge_size:
            icon_paths = self.get_platform_icon_paths(platform)
            if icon_paths:
                self.render_badge_stack(cr, widget, surface, surface_x, icon_paths, media_area)

    @staticmethod
    def get_platform_icon_paths(platform):
        if platform in GridViewCellRendererImage._platform_icon_paths:
            return GridViewCellRendererImage._platform_icon_paths[platform]

        if "," in platform:
            platforms = platform.split(",")  # pylint:disable=no-member
        else:
            platforms = [platform]

        icon_paths = []
        for p in platforms:
            icon_path = get_runtime_icon_path(p + "-symbolic")
            if icon_path:
                icon_paths.append(icon_path)

        GridViewCellRendererImage._platform_icon_paths[platform] = icon_paths
        return icon_paths

    _platform_icon_paths = {}

    def render_badge_stack(self, cr, widget, surface, surface_x, icon_paths, media_area):
        """Renders a vertical stack of badges, placed at the edge of the media, just to the left
        of 'media_area.right'. The icons in icon_paths are drawn from top to bottom, and spaced
        to fit in 'media_area', even if they overlap because of this."""

        badge_width = self.badge_size[0]
        badge_height = self.badge_size[1]
        alpha = self.badge_alpha
        fore_color = self.badge_fore_color
        back_color = self.badge_back_color

        def render_badge(badge_x, badge_y, path):
            cr.rectangle(badge_x, badge_y, badge_width, badge_height)
            cr.set_source_rgba(back_color[0], back_color[1], back_color[2], alpha)
            cr.fill()

            icon = self._get_cached_surface_by_path(widget, path, size=self.badge_size)
            cr.set_source_rgba(fore_color[0], fore_color[1], fore_color[2], alpha)
            cr.mask_surface(icon, badge_x, badge_y)

        media_right = surface_x + get_surface_size(surface)[0]

        x = media_right - badge_width
        spacing = (media_area.height - badge_height * len(icon_paths)) / max(1, len(icon_paths) - 1)
        spacing = min(spacing, 1)
        y_offset = floor(badge_height + spacing)
        y = media_area.y + media_area.height - badge_height - y_offset * (len(icon_paths) - 1)

        for icon_path in icon_paths:
            render_badge(x, y, icon_path)
            y = y + y_offset

    def render_text_badge(self, cr, widget, text, left, bottom):
        """Draws a short text in the lower left corner of the media, in the
        style of a badge."""

        def get_layout():
            """Constructs a layout with the text to draw, but also returns its size
            in pixels. This is boldfaced, but otherwise in the default font."""
            lo = widget.create_pango_layout(text)
            font = lo.get_context().get_font_description()
            font.set_weight(Pango.Weight.BOLD)
            lo.set_font_description(font)
            _, text_bounds = lo.get_extents()
            return lo, text_bounds.width / Pango.SCALE, text_bounds.height / Pango.SCALE

        if self.badge_size:
            alpha = self.badge_alpha
            fore_color = self.badge_fore_color
            back_color = self.badge_back_color

            layout, text_width, text_height = get_layout()

            cr.save()

            # To get the text to be as tall as a badge, we'll scale it
            # with Cairo. Scaling the font size does not work; the font
            # size measures the wrong height for this.
            text_scale = self.badge_size[1] / text_height
            text_height = self.badge_size[1]
            text_width = round(text_width * text_scale)

            cr.rectangle(left, bottom - text_height, text_width + 4, text_height)
            cr.set_source_rgba(back_color[0], back_color[1], back_color[2], alpha)
            cr.fill()

            cr.translate(left + 2, bottom - text_height)
            cr.scale(text_scale, text_scale)
            cr.set_source_rgba(fore_color[0], fore_color[1], fore_color[2], alpha)
            PangoCairo.update_layout(cr, layout)
            PangoCairo.show_layout(cr, layout)

            cr.restore()

            # Looks like we need to make cr.restore() take effect for
            # explicitly, or further text in this cairo context winds up scaled.
            # It must be doing something squirrely with the context that we just
            # spoiled with cr.restore(), and this fixes that.
            PangoCairo.update_layout(cr, layout)

    def clear_cache(self):
        """Discards all cached surfaces; used when some properties are changed."""
        self.cached_surfaces_old.clear()
        self.cached_surfaces_new.clear()

    def cycle_cache(self) -> None:
        """Is the key cache size control trick. When called, the surfaces cached or used
        since the last call are preserved, but those not touched are discarded.

        We call this at idle time after rendering a cell; this should keep all the surfaces
        rendered at that time, so during scrolling the visible media are kept and scrolling is smooth.
        At other times we may discard almost all surfaces, saving memory.

        We skip clearing anything if no surfaces have been loaded; this happens if drawing was
        serviced entirely from cache. GTK may have redrawn just one image or something, so
        let's not disturb the cache for that."""
        if self.cached_surfaces_loaded > 0:
            self.cached_surfaces_old = self.cached_surfaces_new
            self.cached_surfaces_new = {}
            self.cached_surfaces_loaded = 0

    def _get_cached_surface_by_path(self, widget, path, size, preserve_aspect_ratio=True):
        """This obtains the scaled surface to rander for a given media path; this is cached
        in this render, but we'll clear that cache when the media generation number is changed,
        or certain properties are. We also age surfaces from the cache at idle time after
        rendering."""
        if self.cached_surface_generation != _MEDIA_CACHE_GENERATION_NUMBER:
            self.cached_surface_generation = _MEDIA_CACHE_GENERATION_NUMBER
            self.clear_cache()

        key = widget, path, size, preserve_aspect_ratio

        if key in self.cached_surfaces_new:
            return self.cached_surfaces_new[key]

        if key in self.cached_surfaces_old:
            surface = self.cached_surfaces_old[key]
        else:
            surface = self._get_surface_by_path(widget, path, size, preserve_aspect_ratio)
            if surface:
                # We cache missing surfaces too, but only a successful load trigger
                # cache cycling
                self.cached_surfaces_loaded += 1

        self.cached_surfaces_new[key] = surface
        return surface

    def _get_surface_by_path(self, widget, path, size, preserve_aspect_ratio=True):
        cell_size = size
        scale_factor = widget.get_scale_factor() if widget else 1
        try:
            return get_scaled_surface_by_path(
                path, cell_size, scale_factor, preserve_aspect_ratio=preserve_aspect_ratio
            )
        except Exception as ex:
            # We need to survive nasty data in the media files, so the user can replace
            # them.
            logger.exception("Unable to load media '%s': %s", path, ex)
            return None


def _on_media_cached_invalidated() -> None:
    # Increment a counter, so we can passively detect when the media cache is invalid
    # without a per-object handler. We have no way to unregister that handler, but we never
    # need to unregister this global one.
    global _MEDIA_CACHE_GENERATION_NUMBER
    _MEDIA_CACHE_GENERATION_NUMBER += 1


MEDIA_CACHE_INVALIDATED.register(_on_media_cached_invalidated)
