#!/usr/bin/env python3
"""lidcreak — make FreeSlapMac's dead "Lid Creaking Sound" switch actually creak.

Reads the MacBook's hidden lid-angle sensor (IOHID usage page 0x20 / usage 0x8A,
feature report 1) and plays FreeSlapMac's Sounds/creaking.mp3 while the lid moves.

v1.0.1 — two fixes over 1.0.0:
  * no longer gated on FreeSlapMac being open (it never was the thing playing the
    creak, so requiring it only ever produced silence with the app quit),
  * real gain (1.0-2.0 via afplay -v) because creaking.mp3 is mastered ~30 dB
    below the other sounds; the old 0.35 volume was inaudible even at full system
    volume, and a muted/zero system output now gets reported instead of ignored.

Gated by FreeSlapMac's own toggle (pref _creakingEnabled in com.devesh.freeslapmac),
so the switch inside the app really does control it.

No root, no kext, no code signature changes: feature-report reads are unprivileged.

Usage:
    lidcreak.py              run the daemon (what launchd does)
    lidcreak.py --self-test  sensor + audio report, play one creak
    lidcreak.py --prove      play three creaks, quiet -> loud, to pick a level
    lidcreak.py --trigger-test  exercise the trigger logic offline (no audio)
    lidcreak.py --watch      print live angle + speed to stdout (tuning mode)

Options:
    --app PATH        FreeSlapMac.app location  (default: auto-detect)
    --trigger DEG_S   lid speed that counts as a creak (default 8.0 deg/s)
    --volume GAIN     fixed gain 1.0-2.0 (default: 1.0 + speed/40, capped 2.0)
    --sound PATH      override the mp3
"""
import ctypes
import ctypes.util
import os
import subprocess
import sys
import time

VERSION = "1.0.1"

BUNDLE = "com.devesh.freeslapmac"
PREF_KEY = "_creakingEnabled"

PAGE, USAGE = 0x20, 0x8A          # lid angle sensor
POLL_HZ = 20
SPEED_TRIGGER = 8.0               # deg/sec of lid movement that counts
RETRIGGER_S = 1.4
SAMPLE_GAP_MAX = 0.5              # ignore deltas across sleep/stall
GAIN_MIN, GAIN_MAX = 1.0, 2.0     # afplay -v gain (creaking.mp3 is quiet)

HOME = os.path.expanduser("~")
LOG = os.path.join(HOME, "Library/Logs/lidcreak.log")

CANDIDATE_APPS = [
    "/Applications/FreeSlapMac.app",
    os.path.join(HOME, "Applications/FreeSlapMac.app"),
    "/Applications/FreeSlapMac (free).app",
]

IOKit = ctypes.cdll.LoadLibrary(ctypes.util.find_library("IOKit"))
CF = ctypes.cdll.LoadLibrary(ctypes.util.find_library("CoreFoundation"))
kCFAllocatorDefault = ctypes.c_void_p.in_dll(CF, "kCFAllocatorDefault")

CF.CFStringCreateWithCString.restype = ctypes.c_void_p
CF.CFStringCreateWithCString.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_uint32]
CF.CFNumberCreate.restype = ctypes.c_void_p
CF.CFNumberCreate.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p]
CF.CFDictionaryCreate.restype = ctypes.c_void_p
CF.CFDictionaryCreate.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p),
                                  ctypes.POINTER(ctypes.c_void_p), ctypes.c_long,
                                  ctypes.c_void_p, ctypes.c_void_p]
IOKit.IOHIDManagerCreate.restype = ctypes.c_void_p
IOKit.IOHIDManagerCreate.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
IOKit.IOHIDManagerSetDeviceMatching.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
IOKit.IOHIDManagerCopyDevices.restype = ctypes.c_void_p
IOKit.IOHIDManagerCopyDevices.argtypes = [ctypes.c_void_p]
CF.CFSetGetCount.restype = ctypes.c_long
CF.CFSetGetCount.argtypes = [ctypes.c_void_p]
CF.CFSetGetValues.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)]
IOKit.IOHIDDeviceOpen.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
IOKit.IOHIDDeviceGetReport.argtypes = [ctypes.c_void_p, ctypes.c_int, ctypes.c_long,
                                       ctypes.c_char_p, ctypes.POINTER(ctypes.c_long)]
IOKit.IOHIDDeviceClose.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
kCFNumberIntType = 9
kCFStringEncodingUTF8 = 0x08000100


def find_app():
    for p in CANDIDATE_APPS:
        if os.path.isdir(p):
            return p
    return None


def opt(flag, default):
    """--flag VALUE  (also --flag=VALUE)"""
    for i, a in enumerate(sys.argv):
        if a == flag and i + 1 < len(sys.argv):
            return sys.argv[i + 1]
        if a.startswith(flag + "="):
            return a.split("=", 1)[1]
    return default


APP = opt("--app", None) or find_app() or "/Applications/FreeSlapMac.app"
CREAK = opt("--sound", os.path.join(APP, "Contents/Resources/Sounds/creaking.mp3"))
SPEED_TRIGGER = float(opt("--trigger", SPEED_TRIGGER))
FIXED_VOLUME = opt("--volume", None)
VERBOSE = "--watch" in sys.argv


def log(msg):
    line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n"
    try:
        if os.path.exists(LOG) and os.path.getsize(LOG) > 512 * 1024:
            os.replace(LOG, LOG + ".1")
        with open(LOG, "a") as fh:
            fh.write(line)
    except OSError:
        pass


def cfstr(s):
    return CF.CFStringCreateWithCString(kCFAllocatorDefault, s.encode(), kCFStringEncodingUTF8)


def cfnum(i):
    v = ctypes.c_int(i)
    return CF.CFNumberCreate(kCFAllocatorDefault, kCFNumberIntType, ctypes.byref(v))


def open_lid_device():
    keys = (ctypes.c_void_p * 2)(cfstr("PrimaryUsagePage"), cfstr("PrimaryUsage"))
    vals = (ctypes.c_void_p * 2)(ctypes.cast(cfnum(PAGE), ctypes.c_void_p),
                                 ctypes.cast(cfnum(USAGE), ctypes.c_void_p))
    matching = CF.CFDictionaryCreate(kCFAllocatorDefault, keys, vals, 2, None, None)
    mgr = IOKit.IOHIDManagerCreate(kCFAllocatorDefault, 0)
    IOKit.IOHIDManagerSetDeviceMatching(mgr, matching)
    devs = IOKit.IOHIDManagerCopyDevices(mgr)
    if not devs:
        return None
    n = CF.CFSetGetCount(devs)
    if n < 1:
        return None
    arr = (ctypes.c_void_p * n)()
    CF.CFSetGetValues(devs, arr)
    dev = arr[0]
    if IOKit.IOHIDDeviceOpen(dev, 0) != 0:
        return None
    return dev


def read_angle(dev):
    """Feature report 1 -> uint16 LE angle in degrees (report byte 0 = report ID)."""
    buf = ctypes.create_string_buffer(8)
    ln = ctypes.c_long(8)
    if IOKit.IOHIDDeviceGetReport(dev, 2, 1, buf, ctypes.byref(ln)) != 0:
        return None
    raw = buf.raw[: ln.value]
    if len(raw) < 3:
        return None
    return float(raw[1] | (raw[2] << 8))


def app_running():
    return subprocess.run(["pgrep", "-x", "FreeSlapMac"],
                          stdout=subprocess.DEVNULL,
                          stderr=subprocess.DEVNULL).returncode == 0


def creak_enabled():
    r = subprocess.run(["defaults", "read", BUNDLE, PREF_KEY],
                       capture_output=True, text=True)
    if r.returncode != 0:
        return True                 # never written: treat as on
    return r.stdout.strip().lower() in ("1", "true", "yes")


def audio_state():
    """(volume, muted) of the default output, or (None, None) if unreadable."""
    try:
        r = subprocess.run(["osascript", "-e", "get volume settings"],
                           capture_output=True, text=True, timeout=5)
    except (OSError, subprocess.TimeoutExpired):
        return None, None
    vol = muted = None
    for part in r.stdout.replace("\n", ",").split(","):
        part = part.strip()
        if part.startswith("output volume:"):
            try:
                vol = int(part.split(":")[1])
            except ValueError:
                pass
        elif part.startswith("output muted:"):
            muted = part.split(":")[1].strip() == "true"
    return vol, muted


def audio_ok():
    """True when the machine can actually be heard. Logs the reason when not."""
    vol, muted = audio_state()
    if muted:
        log(f"WARNING system output is MUTED - creaks play but cannot be heard "
            f"(set volume output muted false)")
        return False
    if vol == 0:
        log("WARNING system output volume is 0 - creaks play but cannot be heard")
        return False
    return True


def gain_for(speed):
    if FIXED_VOLUME:
        return float(FIXED_VOLUME)
    return min(GAIN_MAX, max(GAIN_MIN, GAIN_MIN + speed / 40.0))


_player = None


def play_creak(volume):
    global _player
    if _player is not None and _player.poll() is None:
        _player.kill()
    try:
        _player = subprocess.Popen(
            ["afplay", "-t", "1.8", "-v", f"{volume:.2f}", CREAK],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        log(f"creak vol={volume:.2f}")
        return True
    except OSError as exc:
        log(f"afplay failed: {exc}")
        return False


def should_play(speed, now, last_play, enabled):
    """Pure trigger decision -> (play, reason). Kept separate so it can be tested."""
    if speed < SPEED_TRIGGER:
        return False, "slow"
    if not enabled:
        return False, "toggle off"
    if (now - last_play) < RETRIGGER_S:
        return False, "retrigger"
    return True, "creak"


def trigger_test():
    """Offline proof the trigger fires on a real lid close and stays quiet otherwise."""
    cases = [
        ("slow drift 2 deg/s", should_play(2.0, 100.0, 0.0, True), False),
        ("normal move 20 deg/s", should_play(20.0, 100.0, 0.0, True), True),
        ("fast close 136 deg/s", should_play(136.0, 100.0, 0.0, True), True),
        ("toggle off", should_play(136.0, 100.0, 0.0, False), False),
        ("0.5s after last creak", should_play(136.0, 100.5, 100.0, True), False),
        ("2s after last creak", should_play(136.0, 102.0, 100.0, True), True),
    ]
    bad = 0
    for name, (play, reason), want in cases:
        ok = play == want
        bad += 0 if ok else 1
        print(f"{'PASS' if ok else 'FAIL'}  {name:26s} play={play!s:5s} reason={reason}")
    print(f"\n{len(cases) - bad}/{len(cases)} trigger cases pass")
    return 0 if bad == 0 else 1


def self_test():
    print(f"lidcreak {VERSION}")
    print(f"app: {APP}")
    print(f"sound: {CREAK} exists={os.path.exists(CREAK)}")
    dev = open_lid_device()
    if dev is None:
        print("LID DEVICE: not found")
        return 1
    angle = read_angle(dev)
    print(f"LID ANGLE: {angle}")
    print(f"APP RUNNING: {app_running()}  TOGGLE ON: {creak_enabled()}")
    vol, muted = audio_state()
    print(f"SYSTEM OUTPUT: volume={vol} muted={muted}"
          f"{'   <-- SILENT, nothing can be heard' if (muted or vol == 0) else ''}")
    print(f"GAIN: {gain_for(20.0):.2f}  (afplay -v)")
    play_creak(gain_for(20.0))
    time.sleep(2.2)
    print("played creaking.mp3")
    return 0 if angle is not None else 1


def prove():
    """Play the creak at three levels so the user can pick a loudness."""
    vol, muted = audio_state()
    print(f"system output: volume={vol} muted={muted}")
    if muted or vol == 0:
        print("!! output is muted / at 0 — fixing that first")
        subprocess.run(["osascript", "-e", "set volume output muted false",
                        "-e", "set volume output volume 60"])
    if not os.path.exists(CREAK):
        print(f"missing sound file: {CREAK}")
        return 1
    for gain in (1.0, 1.5, 2.0):
        print(f"gain {gain:.1f}", flush=True)
        subprocess.run(["afplay", "-t", "2.2", "-v", f"{gain:.2f}", CREAK])
        time.sleep(0.4)
    print("done — peak of the sample is -6.2 dBFS, so 2.0 is the loudest clean setting")
    return 0


def watch():
    dev = None
    prev = prev_t = None
    while True:
        if dev is None:
            dev = open_lid_device()
            if dev is None:
                print("sensor unavailable (lid closed?) — retrying", flush=True)
                time.sleep(2)
                continue
        angle = read_angle(dev)
        now = time.time()
        if angle is None:
            dev = None
            continue
        speed = 0.0 if prev is None else abs(angle - prev) / max(now - prev_t, 1e-3)
        print(f"angle={angle:6.1f}  speed={speed:6.1f} deg/s  "
              f"{'TRIGGER' if speed >= SPEED_TRIGGER else ''}", flush=True)
        prev, prev_t = angle, now
        time.sleep(1.0 / POLL_HZ)


def main():
    if "--self-test" in sys.argv:
        return self_test()
    if "--prove" in sys.argv:
        return prove()
    if "--trigger-test" in sys.argv:
        return trigger_test()
    if VERBOSE:
        return watch()

    log(f"agent start v{VERSION} app={APP} trigger={SPEED_TRIGGER} deg/s")
    warned_silent = False
    dev = None
    prev = None
    prev_t = None
    last_play = 0.0
    closed_logged = False
    while True:
        if dev is None:
            dev = open_lid_device()
            if dev is None:
                if not closed_logged:
                    log("lid sensor unavailable (lid closed?) - retrying")
                    closed_logged = True
                time.sleep(2)
                continue
            log("lid sensor opened")
            closed_logged = False
            prev, prev_t = None, None
        angle = read_angle(dev)
        now = time.time()
        if angle is None:
            try:
                IOKit.IOHIDDeviceClose(dev, 0)
            except Exception:
                pass
            dev = None
            continue
        if prev is not None and prev_t is not None:
            dt = now - prev_t
            if dt <= SAMPLE_GAP_MAX:
                speed = abs(angle - prev) / dt
                play, reason = should_play(speed, now, last_play, creak_enabled())
                if play:
                    if not audio_ok() and not warned_silent:
                        warned_silent = True
                    vol = gain_for(speed)
                    play_creak(vol)
                    last_play = now
                if speed >= 1.5:
                    log(f"move {prev:.0f}->{angle:.0f} deg speed={speed:.1f} deg/s "
                        f"{'CREAK' if play else 'skip:' + reason}")
        prev, prev_t = angle, now
        time.sleep(1.0 / POLL_HZ)


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