#!/usr/bin/python3
# SPDX-License-Identifier: GPL-2.0-or-later
#
# /usr/bin/pocketds-fancontrol-indicator
#
# AppIndicator (StatusNotifierItem) for the Pocket DS fan controller.
# Uses Ayatana AppIndicator so it shows up natively in Plasma 6's
# system tray (and in any DE/extension that speaks SNI). The indicator
# polls /run/pocketds-fancontrol/state for the daemon's published
# temp/pwm/profile and renders them in the menu; switching profiles
# rewrites /etc/pocketds-fancontrol/profile via pkexec (the daemon
# notices the mtime change and reapplies within 5s).
#
# Custom curve editing is intentionally not in this v1 -- power users
# can edit /etc/pocketds-fancontrol/custom.conf directly and the
# daemon will reload on next mtime check.

import os
import shutil
import signal
import subprocess
import sys

import gi

gi.require_version("Gtk", "3.0")
try:
    gi.require_version("AyatanaAppIndicator3", "0.1")
    from gi.repository import AyatanaAppIndicator3 as AppIndicator3
except (ValueError, ImportError):
    # Fall back to the legacy AppIndicator3 binding (provided by
    # libappindicator-gtk3 on some images) if Ayatana isn't installed.
    gi.require_version("AppIndicator3", "0.1")
    from gi.repository import AppIndicator3

from gi.repository import GLib, Gtk

APP_ID = "pocketds-fancontrol"
# Themes don't agree on a single fan-icon name -- Breeze ships
# "sensors-fan" but not the symbolic variant, Adwaita has neither, GNOME
# Console / Yaru ship "sensors-fan-symbolic", and various third-party
# themes use just "fan". Probe the live icon theme at startup and pick
# the first one that resolves; only fall back to a guaranteed-present
# system icon if nothing fan-shaped is available.
ICON_CANDIDATES = (
    "sensors-fan-symbolic",
    "sensors-fan",
    "fan-symbolic",
    "fan",
    "org.kde.plasma.systemmonitor.fan",
)
ICON_FALLBACK = "applications-system-symbolic"
STATE_FILE = "/run/pocketds-fancontrol/state"
PROFILE_FILE = "/etc/pocketds-fancontrol/profile"
POLL_MS = 2000


def pick_icon():
    theme = Gtk.IconTheme.get_default()
    for name in ICON_CANDIDATES:
        if theme.has_icon(name):
            return name
    return ICON_FALLBACK

PROFILES = ["auto", "quiet", "moderate", "aggressive", "custom", "off"]
PROFILE_LABEL = {
    "auto":       "Auto (moderate curve)",
    "quiet":      "Quiet — fan stays calm",
    "moderate":   "Moderate — balanced",
    "aggressive": "Aggressive — runs cool",
    "custom":     "Custom curve",
    "off":        "Off (no fan)",
}


def read_state():
    out = {"profile": "?", "temp_c": None, "pwm": None}
    try:
        with open(STATE_FILE) as f:
            for line in f:
                if "=" not in line:
                    continue
                k, v = line.strip().split("=", 1)
                out[k] = v
    except OSError:
        return out
    for key in ("temp_c", "pwm"):
        try:
            out[key] = int(out[key])
        except (TypeError, ValueError):
            pass
    return out


def read_current_profile():
    try:
        with open(PROFILE_FILE) as f:
            v = f.read().strip()
        return v if v in PROFILES else "moderate"
    except OSError:
        return "moderate"


def write_profile(profile):
    # The daemon's config dir is root-owned (mode 0755) and the profile
    # file is mode 0644 -- regular users can't write it directly. We
    # shell out via pkexec to a tiny helper so the only thing the user
    # is authorising is "set fan profile to one of these N strings",
    # not arbitrary file writes. The polkit policy ships in
    # org.pocketds.fancontrol.policy.
    if profile not in PROFILES:
        return False
    pkexec = shutil.which("pkexec")
    helper = "/usr/libexec/pocketds-fancontrol-set-profile"
    if pkexec and os.path.exists(helper):
        try:
            subprocess.run([pkexec, helper, profile], check=True)
            return True
        except subprocess.CalledProcessError:
            return False
    # Fallback: try sudo (relies on wheel-nopasswd which Pocket DS
    # ships in /etc/sudoers.d/10-wheel-nopasswd). Useful when running
    # under a session that doesn't have a polkit agent.
    sudo = shutil.which("sudo")
    if sudo:
        try:
            subprocess.run(
                [sudo, "-n", "tee", PROFILE_FILE],
                input=profile.encode() + b"\n",
                stdout=subprocess.DEVNULL,
                check=True,
            )
            return True
        except subprocess.CalledProcessError:
            return False
    return False


class FanIndicator:
    def __init__(self):
        self.indicator = AppIndicator3.Indicator.new(
            APP_ID,
            pick_icon(),
            AppIndicator3.IndicatorCategory.HARDWARE,
        )
        self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE)

        self.menu = Gtk.Menu()

        self.status_item = Gtk.MenuItem(label="…")
        self.status_item.set_sensitive(False)
        self.menu.append(self.status_item)

        self.menu.append(Gtk.SeparatorMenuItem())

        # Radio group for profiles.
        self.profile_items = {}
        group = None
        current = read_current_profile()
        for name in PROFILES:
            item = Gtk.RadioMenuItem.new_with_label_from_widget(
                group, PROFILE_LABEL[name],
            )
            if group is None:
                group = item
            if name == current:
                item.set_active(True)
            item.connect("toggled", self._on_profile_toggled, name)
            self.menu.append(item)
            self.profile_items[name] = item

        self.menu.append(Gtk.SeparatorMenuItem())

        edit_item = Gtk.MenuItem(label="Edit custom curve…")
        edit_item.connect("activate", self._on_edit_custom)
        self.menu.append(edit_item)

        quit_item = Gtk.MenuItem(label="Quit")
        quit_item.connect("activate", lambda *_: Gtk.main_quit())
        self.menu.append(quit_item)

        self.menu.show_all()
        self.indicator.set_menu(self.menu)

        self._suppress_toggle = False
        self._refresh()
        GLib.timeout_add(POLL_MS, self._refresh)

    def _refresh(self):
        st = read_state()
        prof = st.get("profile", "?")
        temp = st.get("temp_c")
        pwm = st.get("pwm")

        if isinstance(temp, int) and isinstance(pwm, int):
            pct = round(pwm * 100 / 255)
            label = f"  {temp}°C   fan {pct}%   ({prof})"
            self.indicator.set_label(f"{temp}°C", APP_ID)
        else:
            label = "Daemon not running"
            self.indicator.set_label("", APP_ID)

        self.status_item.set_label(label)

        # Sync the radio with the on-disk profile so external edits
        # (or pkexec failures) don't desync the UI.
        if prof in self.profile_items:
            self._suppress_toggle = True
            self.profile_items[prof].set_active(True)
            self._suppress_toggle = False
        return True

    def _on_profile_toggled(self, item, name):
        if self._suppress_toggle or not item.get_active():
            return
        if not write_profile(name):
            # Re-sync from disk on failure.
            self._refresh()

    def _on_edit_custom(self, _item):
        # Pop the file open in the user's default editor. We avoid
        # taking a hard dependency on a specific text editor; xdg-open
        # routes to whatever the desktop registered for text/plain.
        for opener in ("xdg-open", "kate", "kwrite", "gnome-text-editor", "gedit"):
            path = shutil.which(opener)
            if path:
                subprocess.Popen([path, "/etc/pocketds-fancontrol/custom.conf"])
                return


def main():
    # Allow Ctrl-C to quit when launched from a terminal.
    signal.signal(signal.SIGINT, signal.SIG_DFL)
    FanIndicator()
    Gtk.main()
    return 0


if __name__ == "__main__":
    sys.exit(main())
