#!/usr/bin/env python3
"""rgb-idled — power-aware RGB for the ASUS keyboard + lightbar.

Lightweight always-on daemon (GNOME / Wayland, session D-Bus, no polling):

  * locked              -> dim the lights to a low glow
  * screen powered off  -> turn the lights fully off
  * unlocked / active   -> restore the user's saved colour + brightness

It listens to two signals the desktop already emits, so it costs nothing while
idle:

  * org.gnome.ScreenSaver  ActiveChanged   -> lock / unlock
  * org.gnome.Mutter.IdleMonitor WatchFired -> the same idle clock GNOME uses
    to blank (power off) the display

Transient dim/off writes use rgb_core's persist=False path, so the user's real
preference in state.json is never overwritten and is exactly what we restore to
on wake. That also makes wake-from-suspend "just work": resuming generates user
activity, which restores the lights without a separate resume hook.

Config (environment, set in the systemd unit):
  RGB_DIM_BRIGHTNESS      brightness level to use while locked   (default 1)
  RGB_SCREEN_OFF_SECONDS  idle seconds before lights-off:
                            "auto" -> follow GNOME's screen-blank delay
                                      (org.gnome.desktop.session idle-delay)
                            <int>  -> explicit seconds
                            "0"/"off"/"never" -> disable lights-off (dim only)
"""

from __future__ import annotations

import os
import sys

sys.path.insert(0, os.path.dirname(os.path.realpath(__file__)))

import gi  # noqa: E402

gi.require_version("Gio", "2.0")
gi.require_version("GLib", "2.0")
from gi.repository import Gio, GLib  # noqa: E402

try:
    gi.require_version("GLibUnix", "2.0")
    from gi.repository import GLibUnix
    _unix_signal_add = GLibUnix.signal_add
except (ValueError, ImportError):  # older PyGObject: fall back to the GLib alias
    _unix_signal_add = GLib.unix_signal_add

import rgb_core as rc  # noqa: E402

# --- states (ordered: deeper override wins) ---
NORMAL, DIM, OFF = "normal", "dim", "off"

DIM_BRIGHTNESS = max(0, int(os.environ.get("RGB_DIM_BRIGHTNESS", "1") or 0))


def _log(msg: str) -> None:
    # Plain stdout; journald captures it for the user service.
    print(f"rgb-idled: {msg}", flush=True)


def _screen_off_ms() -> int | None:
    """Idle milliseconds before turning the lights off, or None to disable."""
    raw = (os.environ.get("RGB_SCREEN_OFF_SECONDS", "auto") or "auto").strip().lower()
    if raw in ("0", "off", "never", "none", "disable", "disabled"):
        return None
    if raw == "auto":
        try:
            secs = Gio.Settings.new("org.gnome.desktop.session").get_uint("idle-delay")
        except Exception:
            secs = 0
        # idle-delay 0 means "never blank" -> we have no screen-off event.
        return secs * 1000 if secs > 0 else None
    try:
        secs = int(raw)
    except ValueError:
        return None
    return secs * 1000 if secs > 0 else None


class IdleDaemon:
    def __init__(self) -> None:
        self.bus = Gio.bus_get_sync(Gio.BusType.SESSION, None)
        self.lock_active = False
        self.idle_off = False
        self.current = NORMAL
        self.screen_off_ms = _screen_off_ms()
        self.idle_watch = 0
        self.active_watch = 0
        self.loop = GLib.MainLoop()

        # Lock / unlock (also fires when the lock-screen shield blanks).
        self.bus.signal_subscribe(
            "org.gnome.ScreenSaver", "org.gnome.ScreenSaver", "ActiveChanged",
            "/org/gnome/ScreenSaver", None, Gio.DBusSignalFlags.NONE,
            self._on_screensaver, None,
        )
        # Screen-off / activity, via the same idle clock the shell uses to blank.
        self.bus.signal_subscribe(
            "org.gnome.Mutter.IdleMonitor", "org.gnome.Mutter.IdleMonitor", "WatchFired",
            "/org/gnome/Mutter/IdleMonitor/Core", None, Gio.DBusSignalFlags.NONE,
            self._on_watch_fired, None,
        )
        # Re-arm idle watches whenever Mutter (re)appears — survives a shell
        # restart without leaving us deaf to screen-off.
        Gio.bus_watch_name_on_connection(
            self.bus, "org.gnome.Mutter.IdleMonitor",
            Gio.BusNameWatcherFlags.NONE, self._on_mutter_up, self._on_mutter_down,
        )

        # Seed initial lock state so we don't start out of sync.
        try:
            res = self.bus.call_sync(
                "org.gnome.ScreenSaver", "/org/gnome/ScreenSaver",
                "org.gnome.ScreenSaver", "GetActive", None,
                GLib.VariantType("(b)"), Gio.DBusCallFlags.NONE, 2000, None,
            )
            self.lock_active = bool(res.unpack()[0])
        except GLib.Error:
            pass

        # Restore lights (don't leave them dimmed) if we're stopped.
        _unix_signal_add(GLib.PRIORITY_DEFAULT, 15, self._on_term)  # SIGTERM
        _unix_signal_add(GLib.PRIORITY_DEFAULT, 2, self._on_term)   # SIGINT

        off = "disabled" if self.screen_off_ms is None else f"{self.screen_off_ms // 1000}s"
        _log(f"started (dim={DIM_BRIGHTNESS}, screen-off={off}, locked={self.lock_active})")
        self._update()

    # ---- idle-watch arming ----

    def _mutter_call(self, method: str, params, ret: str):
        return self.bus.call_sync(
            "org.gnome.Mutter.IdleMonitor", "/org/gnome/Mutter/IdleMonitor/Core",
            "org.gnome.Mutter.IdleMonitor", method, params,
            GLib.VariantType(ret), Gio.DBusCallFlags.NONE, 2000, None,
        ).unpack()[0]

    def _arm_idle(self) -> None:
        self.idle_watch = 0
        self.active_watch = 0
        if self.screen_off_ms is None:
            return
        try:
            self.idle_watch = self._mutter_call(
                "AddIdleWatch", GLib.Variant("(t)", (self.screen_off_ms,)), "(u)")
        except GLib.Error as e:
            _log(f"could not arm idle watch (will retry when Mutter is up): {e.message}")

    def _on_mutter_up(self, *_a) -> None:
        # Fresh idle clock; clear any stale idle state and re-arm.
        if self.idle_off:
            self.idle_off = False
        self._arm_idle()
        self._update()

    def _on_mutter_down(self, *_a) -> None:
        self.idle_watch = 0
        self.active_watch = 0

    # ---- signal handlers ----

    def _on_screensaver(self, _c, _s, _p, _i, _sig, params, _u) -> None:
        self.lock_active = bool(params.unpack()[0])
        self._update()

    def _on_watch_fired(self, _c, _s, _p, _i, _sig, params, _u) -> None:
        wid = params.unpack()[0]
        if self.idle_watch and wid == self.idle_watch:
            # User went idle past the screen-blank threshold -> screen powering
            # off. Watch for the return to activity.
            self.idle_off = True
            try:
                self.active_watch = self._mutter_call("AddUserActiveWatch", None, "(u)")
            except GLib.Error:
                self.active_watch = 0
            self._update()
        elif self.active_watch and wid == self.active_watch:
            # Activity resumed -> re-arm the idle watch for next time.
            self.idle_off = False
            self.active_watch = 0
            self._arm_idle()
            self._update()

    def _on_term(self) -> bool:
        _log("stopping; restoring lights")
        self.idle_off = False
        self.lock_active = False
        self._apply(NORMAL)
        self.loop.quit()
        return False

    # ---- state machine ----

    def _target(self) -> str:
        if self.idle_off:
            return OFF
        if self.lock_active:
            return DIM
        return NORMAL

    def _update(self) -> None:
        target = self._target()
        if target != self.current:
            self._apply(target)

    def _apply(self, target: str) -> None:
        try:
            zones = rc.discover_zones()
        except Exception as e:  # discovery touches sysfs/hidraw; never crash on it
            _log(f"discovery failed: {e}")
            return
        if not zones:
            self.current = target
            return
        for z in zones:
            try:
                if target == OFF:
                    rc.set_brightness(z, 0, persist=False)
                elif target == DIM:
                    rc.set_brightness(z, min(DIM_BRIGHTNESS, z.max_brightness), persist=False)
                else:  # NORMAL -> reapply the user's saved look
                    self._restore_zone(z)
            except (rc.RGBError, ValueError) as e:
                _log(f"{z.id}: {target} failed: {e}")
        self.current = target
        _log(f"-> {target}")

    def _restore_zone(self, z: rc.Zone) -> None:
        """Reapply the persisted colour + brightness for one zone.

        state.json is authoritative because dim/off never wrote to it, so this
        also picks up any change the user made (CLI/GUI/web) while locked.
        """
        saved = rc.load_state().get(z.id)
        if saved:
            try:
                color = tuple(int(c) for c in saved.get("color", z.color))[:3]
                bright = int(saved.get("brightness", z.brightness))
                if len(color) == 3:
                    z.color = color  # type: ignore[assignment]
                    rc.set_brightness(z, max(0, min(bright, z.max_brightness)), persist=False)
                    return
            except (TypeError, ValueError):
                pass
        # No saved state: fall back to whatever the zone reports.
        rc.set_brightness(z, z.brightness, persist=False)

    def run(self) -> None:
        self._arm_idle()
        self.loop.run()


def main() -> None:
    try:
        IdleDaemon().run()
    except GLib.Error as e:
        _log(f"fatal: could not reach the session bus: {e.message}")
        sys.exit(1)


if __name__ == "__main__":
    main()
