"""
Traditional window list applet for the Panorama panel, compatible
with wlroots-based compositors.
Copyright 2025, roundabout-host.com <vlad@roundabout-host.com>

This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public Licence as published by
the Free Software Foundation, either version 3 of the Licence, or
(at your option) any later version.

This program 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 General Public Licence for more details.

You should have received a copy of the GNU General Public Licence
along with this program. If not, see <https://www.gnu.org/licenses/>.
"""

import dataclasses
import os
import sys
import locale
import typing
from pathlib import Path
from pywayland.client import Display, EventQueue
from pywayland.protocol.wayland import WlRegistry, WlSeat, WlSurface, WlCompositor, WlOutput
from pywayland.protocol.wayland.wl_output import WlOutputProxy
from pywayland.protocol.wlr_foreign_toplevel_management_unstable_v1 import (
    ZwlrForeignToplevelManagerV1,
    ZwlrForeignToplevelHandleV1
)
import panorama_panel

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("GdkWayland", "4.0")

from gi.repository import Gtk, GLib, Gtk4LayerShell, Gio, Gdk, Pango, GObject


module_directory = Path(__file__).resolve().parent

locale.bindtextdomain("panorama-window-list", module_directory / "locale")
_ = lambda x: locale.dgettext("panorama-window-list", x)


import ctypes
from cffi import FFI
ffi = FFI()
ffi.cdef("""
void * gdk_wayland_display_get_wl_display (void * display);
void * gdk_wayland_surface_get_wl_surface (void * surface);
void * gdk_wayland_monitor_get_wl_output (void * monitor);
""")
gtk = ffi.dlopen("libgtk-4.so.1")


@Gtk.Template(filename=str(module_directory / "panorama-window-list-options.ui"))
class WindowListOptions(Gtk.Window):
    __gtype_name__ = "WindowListOptions"
    button_width_adjustment: Gtk.Adjustment = Gtk.Template.Child()
    workspace_filter_checkbutton: Gtk.CheckButton = Gtk.Template.Child()

    def __init__(self, **kwargs):
        super().__init__(**kwargs)

        self.connect("close-request", lambda *args: self.destroy())


def split_bytes_into_ints(array: bytes, size: int = 4) -> list[int]:
    if len(array) % size:
        raise ValueError(f"The byte string's length must be a multiple of {size}")

    values: list[int] = []
    for i in range(0, len(array), size):
        values.append(int.from_bytes(array[i : i+size], byteorder=sys.byteorder))

    return values


def get_widget_rect(widget: Gtk.Widget) -> tuple[int, int, int, int]:
    width = int(widget.get_width())
    height = int(widget.get_height())

    toplevel = widget.get_root()
    if not toplevel:
        return None, None, width, height

    x, y = widget.translate_coordinates(toplevel, 0, 0)
    x = int(x)
    y = int(y)

    return x, y, width, height


@dataclasses.dataclass
class WindowState:
    minimised: bool
    maximised: bool
    fullscreen: bool
    focused: bool

    @classmethod
    def from_state_array(cls, array: bytes):
        values = split_bytes_into_ints(array)
        instance = cls(False, False, False, False)
        for value in values:
            match value:
                case 0:
                    instance.maximised = True
                case 1:
                    instance.minimised = True
                case 2:
                    instance.focused = True
                case 3:
                    instance.fullscreen = True

        return instance


from gi.repository import Gtk, Gdk

class WindowButtonOptions:
    def __init__(self, max_width: int):
        self.max_width = max_width

class WindowButtonLayoutManager(Gtk.LayoutManager):
    def __init__(self, options: WindowButtonOptions, **kwargs):
        super().__init__(**kwargs)
        self.options = options

    def do_measure(self, widget, orientation, for_size):
        child = widget.get_first_child()
        if child is None:
            return 0, 0, 0, 0

        if orientation == Gtk.Orientation.HORIZONTAL:
            min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.HORIZONTAL, for_size)
            width = self.options.max_width
            return min_width, width, min_height, nat_height
        else:
            min_width, nat_width, min_height, nat_height = child.measure(Gtk.Orientation.VERTICAL, for_size)
            return min_height, nat_height, 0, 0

    def do_allocate(self, widget, width, height, baseline):
        child = widget.get_first_child()
        if child is None:
            return
        alloc_width = min(width, self.options.max_width)
        alloc = Gdk.Rectangle()
        alloc.x = 0
        alloc.y = 0
        alloc.width = alloc_width
        alloc.height = height
        child.allocate(alloc.width, alloc.height, baseline)


class WindowButton(Gtk.ToggleButton):
    def __init__(self, window_id, window_title, **kwargs):
        super().__init__(**kwargs)

        self.window_id: ZwlrForeignToplevelHandleV1 = window_id
        self.wf_ipc_id: typing.Optional[int] = None
        self.set_has_frame(False)
        self.label = Gtk.Label()
        self.icon = Gtk.Image.new_from_icon_name("application-x-executable")
        box = Gtk.Box()
        box.append(self.icon)
        box.append(self.label)
        self.set_child(box)

        self.window_title = window_title
        self.window_state = WindowState(False, False, False, False)

        self.label.set_ellipsize(Pango.EllipsizeMode.END)
        self.set_hexpand(True)
        self.set_vexpand(True)

        self.drag_source = Gtk.DragSource(actions=Gdk.DragAction.MOVE)
        self.drag_source.connect("prepare", self.provide_drag_data)
        self.drag_source.connect("drag-begin", self.drag_begin)
        self.drag_source.connect("drag-cancel", self.drag_cancel)

        self.add_controller(self.drag_source)

        self.menu = Gio.Menu()
        # TODO: toggle the labels when needed
        self.minimise_item = Gio.MenuItem.new(_("_Minimise"), "button.minimise")
        self.maximise_item = Gio.MenuItem.new(_("Ma_ximise"), "button.maximise")
        self.menu.append_item(self.minimise_item)
        self.menu.append_item(self.maximise_item)
        self.menu.append(_("_Close"), "button.close")
        self.menu.append(_("Window list _options"), "applet.options")
        self.popover_menu = Gtk.PopoverMenu.new_from_model(self.menu)
        self.popover_menu.set_parent(self)
        self.popover_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
        self.popover_menu.set_has_arrow(False)
        self.popover_menu.set_halign(Gtk.Align.END)

        self.right_click_controller = Gtk.GestureClick(button=3)
        self.right_click_controller.connect("pressed", self.show_menu)
        self.add_controller(self.right_click_controller)

        self.action_group = Gio.SimpleActionGroup()
        close_action = Gio.SimpleAction.new("close")
        close_action.connect("activate", self.close_associated)
        self.action_group.insert(close_action)
        minimise_action = Gio.SimpleAction.new("minimise")
        minimise_action.connect("activate", self.minimise_associated)
        self.action_group.insert(minimise_action)
        maximise_action = Gio.SimpleAction.new("maximise")
        maximise_action.connect("activate", self.maximise_associated)
        self.action_group.insert(maximise_action)

        self.insert_action_group("button", self.action_group)

        self.middle_click_controller = Gtk.GestureClick(button=2)
        self.middle_click_controller.connect("released", self.close_associated)
        self.add_controller(self.middle_click_controller)

    def show_menu(self, gesture, n_presses, x, y):
        rect = Gdk.Rectangle()
        rect.x = int(x)
        rect.y = int(y)
        rect.width = 1
        rect.height = 1
        self.popover_menu.popup()

    def close_associated(self, *args):
        self.window_id.close()

    def minimise_associated(self, action, *args):
        if self.window_state.minimised:
            self.window_id.unset_minimized()
        else:
            self.window_id.set_minimized()

    def maximise_associated(self, action, *args):
        if self.window_state.maximised:
            self.window_id.unset_maximized()
        else:
            self.window_id.set_maximized()

    def provide_drag_data(self, source: Gtk.DragSource, x: float, y: float):
        app = self.get_root().get_application()
        app.drags[id(self)] = self
        value = GObject.Value()
        value.init(GObject.TYPE_UINT64)
        value.set_uint64(id(self))
        return Gdk.ContentProvider.new_for_value(value)

    def drag_begin(self, source: Gtk.DragSource, drag: Gdk.Drag):
        paintable = Gtk.WidgetPaintable.new(self).get_current_image()
        source.set_icon(paintable, 0, 0)
        self.hide()

    def drag_cancel(self, source: Gtk.DragSource, drag: Gdk.Drag, reason: Gdk.DragCancelReason):
        self.show()
        return False

    @property
    def window_title(self):
        return self.label.get_text()

    @window_title.setter
    def window_title(self, value):
        self.label.set_text(value)

    def set_icon_from_app_id(self, app_id):
        app_ids = app_id.split()

        # If on Wayfire, find the IPC ID
        for app_id in app_ids:
            if app_id.startswith("wf-ipc-"):
                self.wf_ipc_id = int(app_id.removeprefix("wf-ipc-"))
                break

        # Try getting an icon from the correct theme
        icon_theme = Gtk.IconTheme.get_for_display(self.get_display())

        for app_id in app_ids:
            if icon_theme.has_icon(app_id):
                self.icon.set_from_icon_name(app_id)
                return

        # If that doesn't work, try getting one from .desktop files
        for app_id in app_ids:
            try:
                desktop_file = Gio.DesktopAppInfo.new(app_id + ".desktop")
                if desktop_file:
                    self.icon.set_from_gicon(desktop_file.get_icon())
                    return
            except TypeError:
                # Due to a bug, the constructor may sometimes return C NULL
                pass


class WFWindowList(panorama_panel.Applet):
    name = _("Wayfire window list")
    description = _("Traditional window list (for Wayfire and other wlroots compositors)")

    def __init__(self, orientation=Gtk.Orientation.HORIZONTAL, config=None):
        super().__init__(orientation=orientation, config=config)
        if config is None:
            config = {}

        self.set_homogeneous(True)
        self.window_button_options = WindowButtonOptions(config.get("max_button_width", 256))
        self.show_only_this_wf_workspace = config.get("show_only_this_wf_workspace", True)
        self.show_only_this_output = config.get("show_only_this_output", True)
        
        self.toplevel_buttons: dict[ZwlrForeignToplevelHandleV1, WindowButton] = {}
        self.toplevel_buttons_by_wf_id: dict[int, WindowButton] = {}
        # This button doesn't belong to any window but is used for the button group and to be
        # selected when no window is focused
        self.initial_button = Gtk.ToggleButton()

        self.display = None
        self.my_output = None
        self.wl_surface_ptr = None
        self.registry = None
        self.compositor = None
        self.seat = None

        self.context_menu = self.make_context_menu()
        panorama_panel.track_popover(self.context_menu)

        right_click_controller = Gtk.GestureClick()
        right_click_controller.set_button(3)
        right_click_controller.connect("pressed", self.show_context_menu)

        self.add_controller(right_click_controller)

        action_group = Gio.SimpleActionGroup()
        options_action = Gio.SimpleAction.new("options", None)
        options_action.connect("activate", self.show_options)
        action_group.add_action(options_action)
        self.insert_action_group("applet", action_group)
        # Wait for the widget to be in a layer-shell window before doing this
        self.connect("realize", self.get_wl_resources)

        self.options_window = None

        # Support button reordering
        self.drop_target = Gtk.DropTarget.new(GObject.TYPE_UINT64, Gdk.DragAction.MOVE)
        self.drop_target.set_gtypes([GObject.TYPE_UINT64])
        self.drop_target.connect("drop", self.drop_button)

        self.add_controller(self.drop_target)

        # Make a Wayfire socket for workspace handling
        try:
            import wayfire
            self.wf_socket = wayfire.WayfireSocket()
            self.wf_socket.watch(["view-workspace-changed", "wset-workspace-changed"])
            fd = self.wf_socket.client.fileno()
            GLib.io_add_watch(GLib.IOChannel.unix_new(fd), GLib.IO_IN, self.on_wf_event, priority=GLib.PRIORITY_HIGH)
        except:
            # Wayfire raises Exception itself, so it cannot be narrowed down
            self.wf_socket = None

    def get_wf_output_by_name(self, name):
        if not self.wf_socket:
            return None
        for output in self.wf_socket.list_outputs():
            if output["name"] == name:
                return output
        return None

    def on_wf_event(self, source, condition):
        if condition & GLib.IO_IN:
            try:
                message = self.wf_socket.read_next_event()
                event = message.get("event")
                match event:
                    case "view-workspace-changed":
                        view = message.get("view", {})
                        if view["output-name"] == self.get_root().monitor_name:
                            output = self.get_wf_output_by_name(view["output-name"])
                            current_workspace = output["workspace"]["x"], output["workspace"]["y"]
                            button = self.toplevel_buttons_by_wf_id[view["id"]]
                            if not self.show_only_this_wf_workspace or (message["to"]["x"], message["to"]["y"]) == current_workspace:
                                if button.get_parent() is None and button.output == self.my_output:
                                    self.append(button)
                            else:
                                if button.get_parent() is self:
                                    # Remove out-of-workspace window
                                    self.remove(button)
                    case "wset-workspace-changed":
                        output_name = self.get_root().monitor_name
                        if message["wset-data"]["output-name"] == output_name:
                            # It has changed on this monitor; refresh the window list
                            self.filter_to_wf_workspace()

            except Exception as e:
                print("Error reading Wayfire event:", e)
        return True

    def drop_button(self, drop_target: Gtk.DropTarget, value: int, x: float, y: float):
        button: WindowButton = self.get_root().get_application().drags.pop(value)
        if button.get_parent() is not self:
            # Prevent dropping a button from another window list
            return False

        self.remove(button)
        # Find the position where to insert the applet
        # Probably we could use the assumption that buttons are homogeneous here for efficiency
        child = self.get_first_child()
        while child:
            allocation = child.get_allocation()
            child_x, child_y = self.translate_coordinates(self, 0, 0)
            if self.get_orientation() == Gtk.Orientation.HORIZONTAL:
                midpoint = child_x + allocation.width / 2
                if x < midpoint:
                    button.insert_before(self, child)
                    break
            elif self.get_orientation() == Gtk.Orientation.VERTICAL:
                midpoint = child_y + allocation.height / 2
                if y < midpoint:
                    button.insert_before(self, child)
                    break
            child = child.get_next_sibling()
        else:
            self.append(button)
        button.show()

        self.set_all_rectangles()
        return True

    def filter_to_wf_workspace(self):
        if not self.show_only_this_wf_workspace:
            return

        output = self.get_wf_output_by_name(self.get_root().monitor_name)
        for wf_id, button in self.toplevel_buttons_by_wf_id.items():
            view = self.wf_socket.get_view(wf_id)
            mid_x = view["geometry"]["x"] + view["geometry"]["width"] / 2
            mid_y = view["geometry"]["y"] + view["geometry"]["height"] / 2
            output_width = output["geometry"]["width"]
            output_height = output["geometry"]["height"]
            if 0 <= mid_x < output_width and 0 <= mid_y < output_height and button.output == self.my_output:
                # It is in this workspace; keep it
                if button.get_parent() is None:
                    self.append(button)
            else:
                # Remove it from this window list
                if button.get_parent() is self:
                    self.remove(button)

    def get_wl_resources(self, widget):
        ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
        ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)

        self.display = Display()
        wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
                ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.get_root().get_native().get_display().__gpointer__, None)))
        self.display._ptr = wl_display_ptr
        self.event_queue = EventQueue(self.display)

        # Intentionally commented: the display is already connected by GTK
        # self.display.connect()

        my_monitor = Gtk4LayerShell.get_monitor(self.get_root())

        # Iterate through monitors and get their Wayland output (wl_output)
        # This is a hack to ensure output_enter/leave is called for toplevels
        for monitor in self.get_root().get_application().monitors:
            wl_output = gtk.gdk_wayland_monitor_get_wl_output(ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(monitor.__gpointer__, None)))
            if wl_output and not monitor.output_proxy:
                print("Create proxy")
                output_proxy = WlOutputProxy(wl_output, self.display)
                output_proxy.interface.registry[output_proxy._ptr] = output_proxy
                monitor.output_proxy = output_proxy

            if monitor == my_monitor:
                self.my_output = monitor.output_proxy
        # End hack

        self.manager = None
        self.registry = self.display.get_registry()
        self.registry.dispatcher["global"] = self.on_global
        self.display.roundtrip()
        if self.manager is None:
            print("Could not load wf-window-list. Is foreign-toplevel protocol advertised by the compositor?")
            print("(Wayfire requires enabling foreign-toplevel plugin)")
            return
        self.manager.dispatcher["toplevel"] = self.on_new_toplevel
        self.manager.dispatcher["finished"] = lambda *a: print("Toplevel manager finished")
        self.display.roundtrip()
        fd = self.display.get_fd()
        GLib.io_add_watch(fd, GLib.IO_IN, self.on_display_event)

        if self.wf_socket is not None:
            self.filter_to_wf_workspace()

    def on_display_event(self, source, condition):
        if condition & GLib.IO_IN:
            self.display.dispatch(queue=self.event_queue)
        return True

    def on_global(self, registry, name, interface, version):
        if interface == "zwlr_foreign_toplevel_manager_v1":
            self.print_log("Interface registered")
            self.manager = registry.bind(name, ZwlrForeignToplevelManagerV1, version)
        elif interface == "wl_seat":
            self.print_log("Seat found")
            self.seat = registry.bind(name, WlSeat, version)
        elif interface == "wl_compositor":
            self.compositor = registry.bind(name, WlCompositor, version)
            wl_surface_ptr = gtk.gdk_wayland_surface_get_wl_surface(
                    ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(
                        self.get_root().get_native().get_surface().__gpointer__, None)))
            self.wl_surface = WlSurface()
            self.wl_surface._ptr = wl_surface_ptr

    def on_new_toplevel(self, manager: ZwlrForeignToplevelManagerV1,
                         handle: ZwlrForeignToplevelHandleV1):
        handle.dispatcher["title"] = self.on_title_changed
        handle.dispatcher["app_id"] = self.on_app_id_changed
        handle.dispatcher["output_enter"] = self.on_output_entered
        handle.dispatcher["output_leave"] = self.on_output_left
        handle.dispatcher["state"] = self.on_state_changed
        handle.dispatcher["closed"] = self.on_closed

    def on_output_entered(self, handle, output):
        button = WindowButton(handle, handle.title)
        button.output = None

        if self.show_only_this_output and output != self.my_output:
            return

        button.output = output
        self.set_title(button, handle.title)
        button.set_group(self.initial_button)
        button.set_layout_manager(WindowButtonLayoutManager(self.window_button_options))
        button.connect("clicked", self.on_button_click)
        self.set_app_id(button, handle.app_id)
        self.toplevel_buttons[handle] = button
        self.append(button)
        self.set_all_rectangles()

    def on_output_left(self, handle, output):
        if handle in self.toplevel_buttons:
            button = self.toplevel_buttons[handle]
            if button.get_parent() == self:
                button.output = None
                self.remove(button)
            self.set_all_rectangles()

    def set_title(self, button, title):
        button.window_title = title
        button.set_tooltip_text(title)

    def on_title_changed(self, handle, title):
        handle.title = title
        if handle in self.toplevel_buttons:
            button = self.toplevel_buttons[handle]
            self.set_title(button, title)

    def set_all_rectangles(self):
        child = self.get_first_child()
        while child is not None:
            if isinstance(child, WindowButton):
                child.window_id.set_rectangle(self.wl_surface, *get_widget_rect(child))

            child = child.get_next_sibling()

    def on_button_click(self, button: WindowButton):
        # Set a rectangle for animation
        button.window_id.set_rectangle(self.wl_surface, *get_widget_rect(button))
        if button.window_state.focused:
            # Already pressed in, so minimise the focused window
            button.window_id.set_minimized()
        else:
            button.window_id.unset_minimized()
            button.window_id.activate(self.seat)

    def on_state_changed(self, handle, states):
        if handle in self.toplevel_buttons:
            state_info = WindowState.from_state_array(states)
            button = self.toplevel_buttons[handle]
            button.window_state = state_info
            if state_info.focused:
                button.set_active(True)
            else:
                self.initial_button.set_active(True)

        self.set_all_rectangles()

    def set_app_id(self, button, app_id):
        button.set_icon_from_app_id(app_id)
        app_ids = app_id.split()
        for app_id in app_ids:
            if app_id.startswith("wf-ipc-"):
                self.toplevel_buttons_by_wf_id[int(app_id.removeprefix("wf-ipc-"))] = button

    def on_app_id_changed(self, handle, app_id):
        handle.app_id = app_id
        if handle in self.toplevel_buttons:
            button = self.toplevel_buttons[handle]
            self.set_app_id(button, app_id)

    def on_closed(self, handle):
        button: WindowButton = self.toplevel_buttons[handle]
        wf_id = button.wf_ipc_id
        if handle in self.toplevel_buttons:
            self.remove(self.toplevel_buttons[handle])
            self.toplevel_buttons.pop(handle)
        if wf_id in self.toplevel_buttons_by_wf_id:
            self.toplevel_buttons_by_wf_id.pop(wf_id)

        self.set_all_rectangles()

    def make_context_menu(self):
        menu = Gio.Menu()
        menu.append(_("Window list _options"), "applet.options")
        context_menu = Gtk.PopoverMenu.new_from_model(menu)
        context_menu.set_has_arrow(False)
        context_menu.set_parent(self)
        context_menu.set_halign(Gtk.Align.START)
        context_menu.set_flags(Gtk.PopoverMenuFlags.NESTED)
        return context_menu

    def show_context_menu(self, gesture, n_presses, x, y):
        rect = Gdk.Rectangle()
        rect.x = int(x)
        rect.y = int(y)
        rect.width = 1
        rect.height = 1

        self.context_menu.set_pointing_to(rect)
        self.context_menu.popup()

    def show_options(self, _0=None, _1=None):
        if self.options_window is None:
            self.options_window = WindowListOptions()
            self.options_window.button_width_adjustment.set_value(self.window_button_options.max_width)
            self.options_window.button_width_adjustment.connect("value-changed", self.update_button_options)
            self.options_window.workspace_filter_checkbutton.set_active(self.show_only_this_wf_workspace)
            self.options_window.workspace_filter_checkbutton.connect("toggled", self.update_workspace_filter)

            def reset_window(*args):
                self.options_window = None

            self.options_window.connect("close-request", reset_window)
        self.options_window.present()

    def remove_workspace_filtering(self):
        for button in self.toplevel_buttons.values():
            if button.get_parent() is None:
                self.append(button)

    def update_workspace_filter(self, checkbutton):
        self.show_only_this_wf_workspace = checkbutton.get_active()
        if checkbutton.get_active():
            self.filter_to_wf_workspace()
        else:
            self.remove_workspace_filtering()

    def update_button_options(self, adjustment):
        self.window_button_options.max_width = adjustment.get_value()
        child: Gtk.Widget = self.get_first_child()
        while child:
            child.queue_allocate()
            child.queue_resize()
            child.queue_draw()
            child = child.get_next_sibling()

        self.emit("config-changed")

    def get_config(self):
        return {
            "max_button_width": self.window_button_options.max_width,
            "show_only_this_wf_workspace": self.show_only_this_wf_workspace,
            "show_only_this_output": self.show_only_this_output,
        }

    def output_changed(self):
        self.get_wl_resources()

    def make_draggable(self):
        for button in self.toplevel_buttons.values():
            button.remove_controller(button.drag_source)
            button.set_sensitive(False)

    def restore_drag(self):
        for button in self.toplevel_buttons.values():
            button.add_controller(button.drag_source)
            button.set_sensitive(True)
