#!/usr/bin/env python3
"""mouse-remap: remap mouse buttons on Wayland via evdev and uinput.

Grabs one physical mouse exclusively and re-emits its events through virtual
devices, applying a profile (TOML) that maps buttons to clicks, keys,
shortcuts, commands and hold-to-shift layers. Works under any compositor,
because it sits below it in the kernel input stack.

    mouse-remap                        run with the default profile
    mouse-remap --profile FILE         run with another profile
    mouse-remap --check                validate the profile, show the matching device
    mouse-remap --list-devices         list input devices that look like mice
    mouse-remap --identify             print button/wheel events (no grab, no remap)

Default profile: ~/.config/cosmic-tools/mouse-remap.toml
"""

import argparse
import os
import subprocess
import sys
import time

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

try:
    import evdev
    from evdev import ecodes as e, UInput
except ImportError:
    sys.exit("mouse-remap: needs python-evdev (sudo apt install python3-evdev)")

from remap_logic import Remapper  # noqa: E402
from remap_profile import ProfileError, load_profile  # noqa: E402

CONFIG_HOME = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
DEFAULT_PROFILE = os.path.join(CONFIG_HOME, "cosmic-tools", "mouse-remap.toml")
VIRTUAL_PREFIX = "mouse-remap"
HINTED = []

PERMISSION_HINT = """\
Permission denied opening {what}.
mouse-remap needs read access to the mouse's /dev/input/event* node and write
access to /dev/uinput. Either install the udev rule (recommended):
    sudo ./install-udev-rule.sh --profile {profile}
or add yourself to the 'input' group and load the uinput module (see README).
"""


def log(msg):
    print(msg, flush=True)


def describe_code(code):
    name = e.BTN.get(code) or e.KEY.get(code) or "?"
    return "/".join(name) if isinstance(name, list) else name


def mouse_like(dev):
    caps = dev.capabilities()
    rel = caps.get(e.EV_REL, [])
    return e.REL_X in rel or e.REL_WHEEL in rel


def list_devices(denied=None):
    found = []
    for path in evdev.list_devices():
        try:
            dev = evdev.InputDevice(path)
        except PermissionError:
            if denied is not None:
                denied.append(path)
            continue
        except OSError:
            continue
        if dev.name.startswith(VIRTUAL_PREFIX):
            dev.close()
            continue
        found.append(dev)
    return found


def find_device(profile):
    """The first matching mouse-like device, or None.

    Raises PermissionError if nothing matched and some devices were unreadable.
    """
    denied = []
    for dev in list_devices(denied):
        info = dev.info
        if mouse_like(dev) and profile.device.matches(dev.name, info.vendor, info.product, dev.path):
            return dev
        dev.close()
    if denied:
        raise PermissionError(13, "cannot read input devices", denied[0])
    return None


def build_pointer_uinput(dev):
    caps = dev.capabilities()
    caps.pop(e.EV_SYN, None)
    caps.pop(e.EV_FF, None)
    buttons = set(caps.get(e.EV_KEY, []))
    buttons.update([e.BTN_LEFT, e.BTN_RIGHT, e.BTN_MIDDLE])
    caps[e.EV_KEY] = sorted(buttons)
    rel = set(caps.get(e.EV_REL, []))
    rel.update([e.REL_WHEEL])
    caps[e.EV_REL] = sorted(rel)
    return UInput(caps, name=f"{VIRTUAL_PREFIX} pointer")


def build_keyboard_uinput():
    # Advertise the FULL key range, not just the keys a profile uses:
    # libinput classifies devices by capability, and a "keyboard" with a
    # handful of keys is not treated as one, so its media keys get dropped.
    keys = sorted(e.KEY.keys() & e.keys.keys())
    return UInput({e.EV_KEY: keys}, name=f"{VIRTUAL_PREFIX} keyboard")


def run_session(profile, verbose, wait=True):
    """One session with the device. Returns False if it never appeared."""
    dev = None
    for _ in range(60 if wait else 1):
        try:
            dev = find_device(profile)
        except PermissionError as exc:
            # Unreadable devices are normal when the udev rule grants access
            # to this mouse only, so this is just a hint, logged once.
            dev = None
            if not HINTED:
                HINTED.append(1)
                log("mouse not found among readable devices; if it is plugged in, "
                    "check permissions.\n" + PERMISSION_HINT.format(
                        what=exc.filename, profile=profile.source))
        if dev is not None:
            break
        time.sleep(1)
    if dev is None:
        return False

    ui = build_pointer_uinput(dev)
    kbd = build_keyboard_uinput()
    has_hi_res = e.REL_WHEEL_HI_RES in dev.capabilities().get(e.EV_REL, [])
    remap = Remapper(profile, has_hi_res=has_hi_res)

    def click(n):
        for i in range(n):
            ui.write(e.EV_KEY, e.BTN_LEFT, 1)
            ui.syn()
            time.sleep(profile.click_hold)
            ui.write(e.EV_KEY, e.BTN_LEFT, 0)
            ui.syn()
            if i < n - 1:
                time.sleep(profile.click_gap)

    def key_state(code, value):
        # Mouse buttons requested as keys go out on the pointer device.
        target = ui if code in e.BTN.keys() else kbd
        target.write(e.EV_KEY, code, value)
        target.syn()

    def tap(code):
        key_state(code, 1)
        time.sleep(profile.key_gap)
        key_state(code, 0)

    def combo(codes):
        for c in codes:
            key_state(c, 1)
            time.sleep(profile.key_gap)
        for c in reversed(codes):
            key_state(c, 0)
            time.sleep(profile.key_gap)

    def spawn(argv):
        try:
            subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
                             stderr=subprocess.DEVNULL, start_new_session=True)
        except OSError as exc:
            log(f"exec failed for {argv}: {exc}")

    dev.grab()
    log(f"grabbed {dev.name} ({dev.path})")
    try:
        for ev in dev.read_loop():
            actions = remap.handle(ev.type, ev.code, ev.value)
            if verbose and ev.type in (e.EV_KEY, e.EV_REL) and not (
                    ev.type == e.EV_REL and ev.code in (e.REL_X, e.REL_Y)):
                what = (f"button {ev.code} ({describe_code(ev.code)}) {ev.value}"
                        if ev.type == e.EV_KEY else f"rel {ev.code} {ev.value:+d}")
                log(f"{what:<36} -> {actions}")
            for action in actions:
                kind = action[0]
                if kind == "pass":
                    ui.write_event(ev)
                    if ev.type != e.EV_SYN:
                        ui.syn()
                elif kind == "swallow":
                    pass
                elif kind == "click":
                    click(action[1])
                elif kind == "rel":
                    ui.write(e.EV_REL, action[1], action[2])
                    ui.syn()
                elif kind == "btn":
                    ui.write(e.EV_KEY, action[1], action[2])
                    ui.syn()
                elif kind == "keystate":
                    key_state(action[1], action[2])
                elif kind == "key":
                    tap(action[1])
                elif kind == "combo":
                    combo(action[1])
                elif kind == "exec":
                    spawn(action[1])
    except OSError as exc:
        log(f"device went away ({exc}); waiting for it to come back")
    finally:
        remap.reset()
        try:
            dev.ungrab()
        except OSError:
            pass
        ui.close()
        kbd.close()
    return True


def cmd_list():
    devs = list_devices()
    if not devs:
        print("no readable input devices (see README: permissions)")
    for dev in devs:
        kind = "mouse" if mouse_like(dev) else "other"
        print(f"{dev.path:20s} {dev.info.vendor:04x}:{dev.info.product:04x}  {kind:5s}  {dev.name}")
        dev.close()
    return 0


def cmd_identify(profile):
    try:
        dev = find_device(profile)
    except PermissionError as exc:
        print(PERMISSION_HINT.format(what=exc.filename, profile="PROFILE"), file=sys.stderr)
        return 1
    if dev is None:
        print("no matching device found (try --list-devices)")
        return 1
    print(f"reading {dev.name} ({dev.path}) without grabbing; Ctrl+C to stop")
    try:
        for ev in dev.read_loop():
            if ev.type == e.EV_KEY:
                state = {0: "up", 1: "down", 2: "repeat"}.get(ev.value, ev.value)
                print(f"button {ev.code} {describe_code(ev.code):14s} {state}")
            elif ev.type == e.EV_REL and ev.code not in (e.REL_X, e.REL_Y):
                print(f"wheel  {e.REL.get(ev.code, ev.code)} {ev.value:+d}")
    except KeyboardInterrupt:
        pass
    return 0


def main(argv=None):
    ap = argparse.ArgumentParser(prog="mouse-remap", description=__doc__.split("\n\n")[1],
                                 formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("--profile", default=DEFAULT_PROFILE, help="profile TOML file")
    ap.add_argument("--check", action="store_true", help="validate the profile and exit")
    ap.add_argument("--list-devices", action="store_true", help="list input devices")
    ap.add_argument("--identify", action="store_true",
                    help="print button codes from the matched mouse")
    ap.add_argument("--no-probe", action="store_true",
                    help="with --check: validate the file only, do not look for the device")
    ap.add_argument("-v", "--verbose", action="store_true", help="log every button decision")
    args = ap.parse_args(argv)

    if args.list_devices:
        return cmd_list()
    try:
        profile = load_profile(args.profile)
    except ProfileError as exc:
        print(f"mouse-remap: {exc}", file=sys.stderr)
        return 2
    if args.identify:
        return cmd_identify(profile)
    if args.check:
        print(f"profile ok: {args.profile}")
        print(f"  {len(profile.buttons)} button mapping(s), {len(profile.layers)} layer(s), "
              f"scroll divisor {profile.scroll_divisor}")
        if args.no_probe:
            return 0
        try:
            dev = find_device(profile)
        except PermissionError:
            dev = None
        print(f"  device: {dev.name} ({dev.path})" if dev else
              "  device: not found or not readable right now")
        if not os.access("/dev/uinput", os.W_OK):
            print("  /dev/uinput: not writable (see README: permissions)")
        return 0

    if not os.access("/dev/uinput", os.W_OK):
        print(PERMISSION_HINT.format(what="/dev/uinput", profile=args.profile), file=sys.stderr)
        return 1
    missing_logged = False
    while True:
        try:
            ok = run_session(profile, args.verbose)
        except PermissionError as exc:
            # grab() or uinput creation refused
            print(PERMISSION_HINT.format(what=exc.filename or "an input device",
                                         profile=args.profile), file=sys.stderr)
            return 1
        if not ok and not missing_logged:
            log("mouse not found yet; waiting for it to be plugged in")
            missing_logged = True
        elif ok:
            missing_logged = False
        if not ok:
            time.sleep(5)


if __name__ == "__main__":
    try:
        sys.exit(main())
    except KeyboardInterrupt:
        log("bye")
