"""
Panorama background: desktop wallpaper using Wayland layer-shell
protocol.
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 os
import faulthandler
import pathlib
import sys
import locale

import ruamel.yaml as yaml
from pywayland.client import Display, EventQueue
from pywayland.protocol.wayland import WlOutput
from pathlib import Path

locale.bindtextdomain("panorama-bg", "locale")
locale.textdomain("panorama-bg")
locale.setlocale(locale.LC_ALL)
_ = locale.gettext

faulthandler.enable()

os.environ["GI_TYPELIB_PATH"] = "/usr/local/lib/x86_64-linux-gnu/girepository-1.0"

from ctypes import CDLL
CDLL("libgtk4-layer-shell.so")

import gi

gi.require_version("Gtk", "4.0")
gi.require_version("Gtk4LayerShell", "1.0")

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

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")


def get_config_file():
    config_home = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config"))

    return config_home / "panorama-bg" / "config.yaml"


FITTING_MODES = {
    "cover": Gtk.ContentFit.COVER,
    "contain": Gtk.ContentFit.CONTAIN,
    "fill": Gtk.ContentFit.FILL,
    "scale_down": Gtk.ContentFit.SCALE_DOWN,
}

FITTING_MODES_REVERSE = {
    Gtk.ContentFit.COVER: "cover",
    Gtk.ContentFit.CONTAIN: "contain",
    Gtk.ContentFit.FILL: "fill",
    Gtk.ContentFit.SCALE_DOWN: "scale_down",
}

TRANSITION_MODES = {
    "fade": Gtk.StackTransitionType.CROSSFADE,
    "stack_down": Gtk.StackTransitionType.OVER_DOWN,
    "stack_up": Gtk.StackTransitionType.OVER_UP,
    "stack_left": Gtk.StackTransitionType.OVER_LEFT,
    "stack_right": Gtk.StackTransitionType.OVER_RIGHT,
    "take_down": Gtk.StackTransitionType.UNDER_DOWN,
    "take_up": Gtk.StackTransitionType.UNDER_UP,
    "take_left": Gtk.StackTransitionType.UNDER_LEFT,
    "take_right": Gtk.StackTransitionType.UNDER_RIGHT,
    "push_down": Gtk.StackTransitionType.SLIDE_DOWN,
    "push_up": Gtk.StackTransitionType.SLIDE_UP,
    "push_left": Gtk.StackTransitionType.SLIDE_LEFT,
    "push_right": Gtk.StackTransitionType.SLIDE_RIGHT,
    "cube_left": Gtk.StackTransitionType.ROTATE_LEFT,
    "cube_right": Gtk.StackTransitionType.ROTATE_RIGHT,
}

TRANSITION_MODES_REVERSE = {
    Gtk.StackTransitionType.CROSSFADE: "fade",
    Gtk.StackTransitionType.OVER_DOWN: "stack_down",
    Gtk.StackTransitionType.OVER_UP: "stack_up",
    Gtk.StackTransitionType.OVER_LEFT: "stack_left",
    Gtk.StackTransitionType.OVER_RIGHT: "stack_right",
    Gtk.StackTransitionType.UNDER_DOWN: "take_down",
    Gtk.StackTransitionType.UNDER_UP: "take_up",
    Gtk.StackTransitionType.UNDER_LEFT: "take_left",
    Gtk.StackTransitionType.UNDER_RIGHT: "take_right",
    Gtk.StackTransitionType.SLIDE_DOWN: "push_down",
    Gtk.StackTransitionType.SLIDE_UP: "push_up",
    Gtk.StackTransitionType.SLIDE_LEFT: "push_left",
    Gtk.StackTransitionType.SLIDE_RIGHT: "push_right",
    Gtk.StackTransitionType.ROTATE_LEFT: "cube_left",
    Gtk.StackTransitionType.ROTATE_RIGHT: "cube_right",
}


class PanoramaBG(Gtk.Application):
    def __init__(self):
        super().__init__(
                application_id="com.roundabout_host.roundabout.PanoramaBG",
                flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE
        )
        self.wl_output_ids: dict[int, WlOutput] = {}
        self.wl_display = None
        self.display = None
        self.settings_window = None

        self.add_main_option(
            "open-settings",
            ord("s"),
            GLib.OptionFlags.NONE,
            GLib.OptionArg.NONE,
            _("Open the settings window")
        )

    def make_backgrounds(self):
        for window in self.get_windows():
            window.destroy()

        with open(get_config_file(), "r") as config_file:
            yaml_loader = yaml.YAML(typ="rt")
            yaml_file = yaml_loader.load(config_file)

            monitors = self.display.get_monitors()
            for monitor in monitors:
                background_window = BackgroundWindow(self, monitor)
                monitor.background_window = background_window
                if monitor.get_connector() in yaml_file["monitors"]:
                    background_window.transition_ms = yaml_file["monitors"][monitor.get_connector()]["transition_ms"]
                    background_window.time_per_image = yaml_file["monitors"][monitor.get_connector()]["time_per_image"]
                    background_window.put_sources(yaml_file["monitors"][monitor.get_connector()]["sources"])
                    background_window.picture1.set_content_fit(FITTING_MODES[yaml_file["monitors"][monitor.get_connector()]["fitting_mode"]])
                    background_window.picture2.set_content_fit(FITTING_MODES[yaml_file["monitors"][monitor.get_connector()]["fitting_mode"]])
                    background_window.stack.set_transition_type(TRANSITION_MODES[yaml_file["monitors"][monitor.get_connector()]["transition_mode"]])

                background_window.present()

    def do_startup(self):
        Gtk.Application.do_startup(self)
        self.hold()

        ctypes.pythonapi.PyCapsule_GetPointer.restype = ctypes.c_void_p
        ctypes.pythonapi.PyCapsule_GetPointer.argtypes = (ctypes.py_object,)

        self.display = Gdk.Display.get_default()

        self.wl_display = Display()
        wl_display_ptr = gtk.gdk_wayland_display_get_wl_display(
                ffi.cast("void *", ctypes.pythonapi.PyCapsule_GetPointer(self.display.__gpointer__, None)))
        self.wl_display._ptr = wl_display_ptr
        self.registry = self.wl_display.get_registry()
        self.registry.dispatcher["global"] = self.on_global
        self.registry.dispatcher["global_remove"] = self.on_global_remove
        self.wl_display.roundtrip()

    def receive_output_name(self, output: WlOutput, name: str):
        output.name = name

    def on_global(self, registry, name, interface, version):
        if interface == "wl_output":
            output = registry.bind(name, WlOutput, version)
            output.id = name
            output.name = None
            output.dispatcher["name"] = self.receive_output_name
            while not output.name:
                self.wl_display.dispatch(block=True)
            if not output.name.startswith("live-preview"):
                self.make_backgrounds()
                self.wl_output_ids[name] = output

    def on_global_remove(self, registry, name):
        if name in self.wl_output_ids:
            self.wl_output_ids[name].destroy()
            del self.wl_output_ids[name]

    def do_activate(self):
        Gio.Application.do_activate(self)

    def open_settings(self):
        if self.settings_window:
            self.settings_window.present()
            return

        self.settings_window = SettingsWindow(self)
        self.settings_window.connect("close-request", self.reset_settings_window)
        self.settings_window.present()

    def reset_settings_window(self, *args):
        self.settings_window = None

    def do_command_line(self, command_line: Gio.ApplicationCommandLine):
        options = command_line.get_options_dict()
        args = command_line.get_arguments()[1:]

        open_settings = options.contains("open-settings")
        if open_settings:
            self.open_settings()

        return 0

    def write_config(self):
        with open(get_config_file(), "w") as config_file:
            yaml_writer = yaml.YAML(typ="rt")
            data = {"monitors": {}}
            for window in self.get_windows():
                if isinstance(window, BackgroundWindow):
                    monitor = Gtk4LayerShell.get_monitor(window)
                    data["monitors"][monitor.get_connector()] = {
                        "sources": window.sources,
                        "time_per_image": window.time_per_image,
                        "fitting_mode": FITTING_MODES_REVERSE[window.picture1.get_content_fit()],
                        "transition_ms": window.stack.get_transition_duration(),
                        "transition_mode": TRANSITION_MODES_REVERSE[window.stack.get_transition_type()],
                    }

            yaml_writer.dump(data, config_file)


class BackgroundWindow(Gtk.Window):
    def __init__(self, application: Gtk.Application, monitor: Gdk.Monitor):
        super().__init__(application=application)
        self.set_decorated(False)

        self.all_sources = []
        self.sources = []
        self.time_per_image = 0
        self.transition_ms = 0
        self.current_timeout = None
        self.pic1 = True

        Gtk4LayerShell.init_for_window(self)
        Gtk4LayerShell.set_namespace(self, "com.roundabout_host.panorama.background")
        Gtk4LayerShell.set_monitor(self, monitor)
        Gtk4LayerShell.set_layer(self, Gtk4LayerShell.Layer.BACKGROUND)

        Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.TOP, True)
        Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.LEFT, True)
        Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.BOTTOM, True)
        Gtk4LayerShell.set_anchor(self, Gtk4LayerShell.Edge.RIGHT, True)

        self.picture1 = Gtk.Picture()
        self.picture2 = Gtk.Picture()
        self.stack = Gtk.Stack()

        self.stack.add_child(self.picture1)
        self.stack.add_child(self.picture2)

        self.stack.set_visible_child(self.picture1)
        self.set_child(self.stack)


    def put_sources(self, sources):
        self.all_sources = []
        self.sources = sources
        for source in sources:
            path = pathlib.Path(source)
            if path.is_dir():
                self.all_sources.extend(f for f in path.iterdir() if f.is_file())
            else:
                self.all_sources.append(path)
        self.all_sources = self.all_sources
        if self.time_per_image <= 0:
            self.time_per_image = 120
        if self.transition_ms <= 0:
            self.transition_ms = 1000
        if len(self.all_sources) <= 1:
            self.transition_ms = 0
        self.stack.set_transition_duration(self.transition_ms)
        if self.time_per_image * 1000 < self.transition_ms:
            self.time_per_image = self.transition_ms / 500
        self.change_image()

    def change_image(self):
        now = GLib.DateTime.new_now_local()
        num_images = len(self.all_sources)
        # Ping pong between images is required for transition
        if self.pic1:
            self.picture1.set_filename(str(self.all_sources[now.to_unix() // self.time_per_image % num_images]))
            self.stack.set_visible_child(self.picture1)
        else:
            self.picture2.set_filename(str(self.all_sources[now.to_unix() // self.time_per_image % num_images]))
            self.stack.set_visible_child(self.picture2)
        self.pic1 = not self.pic1
        time_until_next = self.time_per_image - now.to_unix() % self.time_per_image
        if num_images > 1:
            self.current_timeout = GLib.timeout_add_seconds(time_until_next, self.change_image)
        return False


@Gtk.Template(filename="settings-window.ui")
class SettingsWindow(Gtk.Window):
    __gtype_name__ = "SettingsWindow"

    monitor_list: Gtk.ListBox = Gtk.Template.Child()

    def __init__(self, application: Gtk.Application, **kwargs):
        super().__init__(application=application, **kwargs)

        monitors = application.display.get_monitors()
        for monitor in monitors:
            monitor_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4)
            monitor_box.append(Gtk.Label.new(monitor.get_description()))

            monitor_time_adjustment = Gtk.Adjustment(lower=1, upper=86400, value=monitor.background_window.time_per_image, step_increment=5)
            monitor_seconds_spinbutton = Gtk.SpinButton(adjustment=monitor_time_adjustment, digits=0)
            monitor_time_adjustment.connect("value-changed", self.display_time_changed, monitor)
            monitor_box.append(monitor_seconds_spinbutton)

            monitor_row = Gtk.ListBoxRow(activatable=False, child=monitor_box)

            formats = Gdk.ContentFormats.new_for_gtype(Gdk.FileList)
            drop_target = Gtk.DropTarget(formats=formats, actions=Gdk.DragAction.COPY)
            drop_target.connect("drop", self.on_drop, monitor)
            monitor_row.add_controller(drop_target)

            self.monitor_list.append(monitor_row)

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

    def display_time_changed(self, adjustment: Gtk.Adjustment, monitor: Gdk.Monitor):
        if hasattr(monitor, "background_window"):
            if monitor.background_window.current_timeout is not None:
                GLib.source_remove(monitor.background_window.current_timeout)

            monitor.background_window.time_per_image = int(adjustment.get_value())

            monitor.background_window.change_image()

        self.get_application().write_config()

    def on_drop(self, drop_target: Gtk.DropTarget, value: Gdk.FileList, x, y, monitor: Gdk.Monitor):
        if hasattr(monitor, "background_window"):
            files = value.get_files()
            if not files:
                return False

            paths = []
            for file in files:
                paths.append(file.get_path())

            if monitor.background_window.current_timeout is not None:
                GLib.source_remove(monitor.background_window.current_timeout)

            monitor.background_window.put_sources(paths)

            monitor.background_window.change_image()

        self.get_application().write_config()

        return False


if __name__ == "__main__":
    app = PanoramaBG()
    app.run(sys.argv)

