#!/usr/bin/python3
"""mood-touch — session daemon for touchscreens, pens and 2-in-1 laptops.

Inputs:  /run/mood/input.json (mood-inputd, root)   ~/.config/mood/settings.json "touch"
         iio-sensor-proxy (accelerometer, system bus)
Does:    tablet mode (hinge switch / keyboard detached / forced), the on-screen keyboard
         (Squeekboard, shown automatically in text fields while you're using touch),
         auto-rotation of the built-in screen, and publishes the state to
         $XDG_RUNTIME_DIR/mood-touch.json for the shell, Settings and Mood apps.
`mood-touch --status` prints that state, `--osk show|hide|toggle` drives the keyboard.
"""
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path

sys.path.insert(0, "/usr/lib/mood/runtime")

RUN = Path(os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}"))
STATE = RUN / "mood-touch.json"
INPUT = Path("/run/mood/input.json")
SETTINGS = Path.home() / ".config/mood/settings.json"
DEFAULTS = {"tablet": "auto", "osk": "auto", "rotate": True, "rotateLock": False, "rotation": "normal", "gestures": True}
ORIENT = {"normal": "normal", "bottom-up": "180", "left-up": "90", "right-up": "270"}
TRANSFORMS = ("normal", "90", "180", "270")
OSK_BUS = ("sm.puri.OSK0", "/sm/puri/OSK0", "sm.puri.OSK0")


def read_json(p, default):
    try:
        return json.loads(Path(p).read_text())
    except (OSError, ValueError):
        return default


def touch_settings():
    s = read_json(SETTINGS, {}).get("touch") or {}
    return {**DEFAULTS, **{k: v for k, v in s.items() if k in DEFAULTS}}


def internal_output():
    """Connector name of the built-in panel as Wayfire/wlroots name it (eDP-1, LVDS-1, DSI-1)."""
    for p in sorted(Path("/sys/class/drm").glob("card*-*")):
        conn = p.name.split("-", 1)[1]
        if conn.split("-")[0] in ("eDP", "LVDS", "DSI"):
            try:
                if (p / "status").read_text().strip() != "connected":
                    continue
            except OSError:
                continue
            return conn
    return None


def osk_call(method, *args):
    cmd = ["busctl", "--user", "call", *OSK_BUS, method]
    if args:
        cmd += ["b", "true" if args[0] else "false"]
    try:
        r = subprocess.run(cmd, capture_output=True, text=True, timeout=3)
        return r.returncode == 0
    except (OSError, subprocess.SubprocessError):
        return False


def osk_visible():
    try:
        r = subprocess.run(["busctl", "--user", "get-property", *OSK_BUS, "Visible"], capture_output=True, text=True, timeout=3)
        return r.stdout.strip().endswith("true")
    except (OSError, subprocess.SubprocessError):
        return False


def gs_set(schema, key, val):
    try:
        subprocess.run(["gsettings", "set", schema, key, val], capture_output=True, timeout=5)
    except (OSError, subprocess.SubprocessError):
        pass


class TouchDaemon:
    def __init__(self):
        from gi.repository import Gio, GLib
        self.Gio, self.GLib = Gio, GLib
        self.state = {}
        self.osk_proc = None
        self.osk_starts = []
        self.orientation = None
        self.has_accel = False
        self.applied_transform = "normal"   # panels boot unrotated; only call wlr-randr on a real change
        self.applied_osk = None
        self.last_tablet = None
        self.sensor = None

    # ------------------------------------------------------------ accelerometer
    def setup_sensor(self):
        Gio = self.Gio
        try:
            bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None)
            self.sensor = Gio.DBusProxy.new_sync(bus, Gio.DBusProxyFlags.NONE, None, "net.hadess.SensorProxy",
                                                 "/net/hadess/SensorProxy", "net.hadess.SensorProxy", None)
            has = self.sensor.get_cached_property("HasAccelerometer")
            self.has_accel = bool(has and has.unpack())
            if self.has_accel:
                self.sensor.call_sync("ClaimAccelerometer", None, Gio.DBusCallFlags.NONE, 3000, None)
                o = self.sensor.get_cached_property("AccelerometerOrientation")
                self.orientation = o.unpack() if o else None
                self.sensor.connect("g-properties-changed", self.on_sensor)
        except Exception as e:  # no iio-sensor-proxy / no sensor: rotation stays manual
            print(f"mood-touch: no accelerometer ({e})", file=sys.stderr)
            self.has_accel = False

    def on_sensor(self, proxy, changed, _inv):
        d = changed.unpack()
        if "HasAccelerometer" in d:
            self.has_accel = bool(d["HasAccelerometer"])
        if "AccelerometerOrientation" in d:
            self.orientation = d["AccelerometerOrientation"]
        self.schedule()

    # ------------------------------------------------------------ keyboard process
    def ensure_squeekboard(self, want):
        alive = self.osk_proc and self.osk_proc.poll() is None
        if want and not alive and shutil.which("squeekboard"):
            now = time.monotonic()
            self.osk_starts = [t for t in self.osk_starts if now - t < 60] + [now]
            if len(self.osk_starts) > 4:   # crash loop: give up until settings change
                return
            env = {**os.environ, "SQUEEKBOARD_DEBUG": ""}
            self.osk_proc = subprocess.Popen(["squeekboard"], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
        elif not want and alive:
            self.osk_proc.terminate()
            self.osk_proc = None

    # ------------------------------------------------------------ main computation
    def compute(self):
        inp = read_json(INPUT, {})
        ts = touch_settings()
        has_touch, has_pen = bool(inp.get("touch")), bool(inp.get("pen"))
        has_switch = bool(inp.get("switch"))
        keyboards = inp.get("keyboards") or []
        if ts["tablet"] == "on":
            tablet = True
        elif ts["tablet"] == "off":
            tablet = False
        elif has_switch:
            tablet = bool(inp.get("tablet"))
        else:  # detachables (Surface-style): keyboard unplugged + touchscreen = tablet
            tablet = (has_touch or has_pen) and "keyboards" in inp and not keyboards
        last = inp.get("last", "key")
        mode = ts["osk"]
        osk_auto = {"off": False, "always": True, "tablet": tablet, "auto": tablet or last in ("touch", "pen")}.get(mode, False)
        run_osk = mode != "off" and (has_touch or has_pen or mode == "always")
        out = internal_output()
        if self.has_accel and ts["rotate"] and not ts["rotateLock"] and tablet:
            transform = ORIENT.get(self.orientation or "normal", "normal")
            rot_auto = True
        else:
            transform = ts["rotation"] if ts["rotation"] in TRANSFORMS else "normal"
            rot_auto = False
        return {
            "hasTouch": has_touch, "hasPen": has_pen, "hasSwitch": has_switch, "hasAccel": self.has_accel,
            "touchDevices": inp.get("touch") or [], "penDevices": inp.get("pen") or [], "keyboards": keyboards,
            "tablet": tablet, "tabletMode": ts["tablet"], "switchTablet": bool(inp.get("tablet")),
            "last": last, "osk": mode, "oskAuto": osk_auto, "oskRunning": run_osk,
            "rotate": ts["rotate"], "rotateLock": ts["rotateLock"], "rotateAuto": rot_auto,
            "orientation": self.orientation, "transform": transform, "output": out,
            "watcher": INPUT.exists(),
        }

    def apply(self):
        self._pending = None
        st = self.compute()
        self.ensure_squeekboard(st["oskRunning"])
        if st["oskAuto"] != self.applied_osk:
            gs_set("org.gnome.desktop.a11y.applications", "screen-keyboard-enabled", "true" if st["oskAuto"] else "false")
            if not st["oskAuto"] and self.applied_osk is not None:
                osk_call("SetVisible", False)
            self.applied_osk = st["oskAuto"]
        if st["output"] and st["transform"] != self.applied_transform:
            try:
                r = subprocess.run(["wlr-randr", "--output", st["output"], "--transform", st["transform"]], capture_output=True, timeout=8)
                if r.returncode == 0:
                    self.applied_transform = st["transform"]
            except (OSError, subprocess.SubprocessError):
                pass
        if self.last_tablet is not None and st["tablet"] != self.last_tablet:
            subprocess.Popen(["notify-send", "-a", "Moodtop", "-i", "tablet" if st["tablet"] else "computer",
                              "Tablet mode" if st["tablet"] else "Laptop mode",
                              "Bigger buttons and the touch keyboard are on." if st["tablet"] else "Back to keyboard and touchpad."],
                             stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        tablet_changed = st["tablet"] != self.last_tablet
        self.last_tablet = st["tablet"]
        if st != self.state:
            self.state = st
            tmp = STATE.with_suffix(".tmp")
            tmp.write_text(json.dumps(st))
            tmp.replace(STATE)
        if tablet_changed:   # title bars / gesture bindings follow tablet mode
            subprocess.Popen(["/usr/lib/mood/runtime/moodtop-wm"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
        return False

    def schedule(self, *_):
        if not getattr(self, "_pending", None):
            self._pending = self.GLib.timeout_add(150, self.apply)

    def run(self):
        Gio, GLib = self.Gio, self.GLib
        self._pending = None
        self.setup_sensor()
        mons = []
        for p in (INPUT, SETTINGS):
            m = Gio.File.new_for_path(str(p)).monitor_file(Gio.FileMonitorFlags.WATCH_MOVES, None)
            m.connect("changed", self.schedule)
            mons.append(m)
        self._mons = mons
        GLib.timeout_add_seconds(5, lambda: (self.schedule(), True)[1])   # hotplug of panels / squeekboard restarts
        self.apply()
        GLib.MainLoop().run()


def main():
    a = sys.argv[1:]
    if a[:1] == ["--status"]:
        print(json.dumps(read_json(STATE, {}), indent=1))
    elif a[:1] == ["--osk"]:
        act = a[1] if len(a) > 1 else "toggle"
        on = {"show": True, "hide": False}.get(act, not osk_visible())
        if on and not osk_call("SetVisible", True):
            # Squeekboard not running (no touchscreen found): fall back to wvkbd
            if shutil.which("squeekboard"):
                subprocess.Popen(["squeekboard"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
                for _ in range(20):
                    time.sleep(0.15)
                    if osk_call("SetVisible", True):
                        return
            subprocess.Popen(["wvkbd-mobintl", "-L", "280", "--fn", "Inter 18"], start_new_session=True)
        elif not on:
            osk_call("SetVisible", False)
            subprocess.run(["pkill", "-u", str(os.getuid()), "-x", "wvkbd-mobintl"], capture_output=True)
    else:
        import fcntl
        lock = open(RUN / "mood-touch.lock", "w")
        try:
            fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
        except OSError:
            return  # already running for this session
        TouchDaemon().run()


if __name__ == "__main__":
    main()
