"""
Rig control for OBS, for Skrunk's Dr Dabber Switch Controller.

Adds hotkeys for your Dr Dabber Switch 2 or Switch Go and keeps a text
source on your scene showing the live temp.

Setup
  1. OBS needs Python for scripts. Install Python 3.12 (64 bit) from
     python.org, then Tools > Scripts > Python Settings and point it at
     the install folder.
  2. Add a Text source to your scene.
  3. Tools > Scripts > + and pick this file.
  4. Paste your API key (from the app: Social > Profile > Advanced) and
     pick your text source.
  5. Settings > Hotkeys, search "Rig" and set the keys you want.

The app has to be open and connected to the rig with remote control on.
Every command goes through the same rules as the app's buttons.

A Read only key is enough for the readout. Fire and +10 need a
Settings + Fire key.

Docs: https://switch.skrunk.org/api
"""

import json
import threading
import time
import urllib.error
import urllib.request

import obspython as obs

DEFAULT_ADDRESS = "https://rvhzqvhigfseqgtpkmhl.supabase.co/functions/v1/rig-api"
# The readout reads the database directly instead of going through the API:
# API calls are capped on the free plan, database reads aren't. This is
# Supabase's public key, the same one the web app ships with; your API key
# is what unlocks your rig.
DB = "https://rvhzqvhigfseqgtpkmhl.supabase.co"
ANON = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InJ2aHpxdmhpZ2ZzZXFndHBrbWhsIiwicm9sZSI6ImFub24iLCJpYXQiOjE3ODk0MTU2NzYsImV4cCI6MjEwNDk5MTY3Nn0.2ti9pkZ48gkbDnFranV0R6Kjb8tD90VD4rCMwperqdc"
DEFAULT_FORMAT = "{temp}°F  {status}"

# Hotkeys: (id, label in OBS, action, args)
HOTKEYS = [
    ("rig_fire_toggle", "Rig: Fire / Stop", "toggle", {}),
    ("rig_fire", "Rig: Fire", "start", {}),
    ("rig_stop", "Rig: Stop", "stop", {}),
    ("rig_add_time", "Rig: +10 seconds", "add_time", {}),
] + [
    (f"rig_profile_{p}", f"Rig: Profile {p}", "set_profile", {"profile": p}) for p in range(1, 6)
]

# ---------------------------------------------------------------- settings

cfg = {
    "address": DEFAULT_ADDRESS,
    "key": "",
    "source": "",
    "format": DEFAULT_FORMAT,
    "offline_text": "",
    "poll": 1,
    "stop_on_end": True,
}

# Written by the background threads, read on OBS's main thread.
latest = {"state": None, "online": False, "error": None}
last_text = None
running = False
hotkey_ids = {}


# -------------------------------------------------------------------- http

def request(method, path, body=None, timeout=12):
    """Returns (status, json). Never raises."""
    if not cfg["key"]:
        return 0, {"message": "No API key set"}
    data = json.dumps(body).encode() if body is not None else None
    req = urllib.request.Request(
        cfg["address"].rstrip("/") + path,
        data=data,
        method=method,
        headers={
            "Authorization": "Bearer " + cfg["key"],
            "Content-Type": "application/json",
            "User-Agent": "obs-rig-control/1",
        },
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return r.status, json.loads(r.read().decode() or "{}")
    except urllib.error.HTTPError as e:
        try:
            return e.code, json.loads(e.read().decode() or "{}")
        except ValueError:
            return e.code, {}
    except Exception as e:  # network down, bad address, timeout
        return 0, {"message": str(e)}


def read_state(timeout=8):
    """The same thing GET /v1/state returns, read from the database."""
    if not cfg["key"]:
        return 0, {"message": "No API key set"}
    req = urllib.request.Request(
        DB + "/rest/v1/rpc/remote_read_state",
        data=json.dumps({"p_key": cfg["key"]}).encode(),
        method="POST",
        headers={"apikey": ANON, "Authorization": "Bearer " + ANON,
                 "Content-Type": "application/json", "User-Agent": "obs-rig-control/1"},
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            return 200, json.loads(r.read().decode() or "{}")
    except urllib.error.HTTPError as e:
        try:
            data = json.loads(e.read().decode() or "{}")
        except ValueError:
            data = {}
        if "bad key" in (data.get("message") or ""):
            return 401, {"message": "That API key doesn't exist or was deleted."}
        return e.code, data
    except Exception as e:
        return 0, {"message": str(e)}


def send(action, args=None):
    """Sends a command on its own thread, so OBS never waits on the network."""
    def work():
        if action == "toggle":
            state = latest["state"] or {}
            real = "stop" if state.get("heating") else "start"
        else:
            real = action
        status, data = request("POST", "/v1/command", {"action": real, "args": args or {}})
        if status == 200:
            print(f"[rig] {real}: done")
            if isinstance(data.get("result"), dict):
                latest["state"] = data["result"]
                latest["online"] = True
        else:
            why = data.get("error") or data.get("message") or "no answer"
            print(f"[rig] {real}: {why} ({status})")
    threading.Thread(target=work, daemon=True).start()


def poll_loop():
    while running:
        if cfg["key"]:
            status, data = read_state()
            if status == 200:
                latest["online"] = bool(data.get("online"))
                latest["state"] = data.get("state") if latest["online"] else None
                latest["error"] = None
            else:
                latest["online"] = False
                latest["state"] = None
                latest["error"] = data.get("message") or f"HTTP {status}"
        for _ in range(int(max(1, cfg["poll"]) * 10)):
            if not running:
                return
            time.sleep(0.1)


# ------------------------------------------------------------------ readout

def render():
    s = latest["state"]
    if not latest["online"] or not s or not s.get("connected"):
        return cfg["offline_text"]
    status = f"heating {s.get('countdown', 0)}s" if s.get("heating") else "idle"
    values = {
        "temp": s.get("temperature", ""),
        "target": s.get("target_temp", ""),
        "status": status,
        "countdown": s.get("countdown", 0) if s.get("heating") else "",
        "profile": s.get("profile", ""),
        "mode": s.get("mode", ""),
        "battery": s.get("battery", ""),
        "light": s.get("light_mode", ""),
        "rig": s.get("rig", ""),
    }
    try:
        return cfg["format"].format(**values)
    except (KeyError, IndexError, ValueError):
        return "check the format: " + cfg["format"]


def update_text():
    """Runs on OBS's main thread (a timer), the only place OBS calls are safe."""
    global last_text
    if not cfg["source"]:
        return
    text = render()
    if text == last_text:
        return
    src = obs.obs_get_source_by_name(cfg["source"])
    if src is None:
        return
    settings = obs.obs_data_create()
    obs.obs_data_set_string(settings, "text", text)
    obs.obs_source_update(src, settings)
    obs.obs_data_release(settings)
    obs.obs_source_release(src)
    last_text = text


# --------------------------------------------------------------- obs events

def on_event(event):
    if not cfg["stop_on_end"]:
        return
    if event in (obs.OBS_FRONTEND_EVENT_STREAMING_STOPPED, obs.OBS_FRONTEND_EVENT_RECORDING_STOPPED):
        if (latest["state"] or {}).get("heating"):
            print("[rig] stream or recording ended, stopping the rig")
            send("stop")


def make_hotkey(action, args):
    def cb(pressed):
        if pressed:
            send(action, args)
    return cb


# ------------------------------------------------------------ script hooks

def script_description():
    return (
        "<b>Rig control</b> for Skrunk's Dr Dabber Switch Controller.<br>"
        "Hotkeys for Fire, Stop, +10 and profiles (Settings &gt; Hotkeys, search Rig), "
        "and a live temp readout on a text source.<br>"
        "The app must be open with remote control on. "
        "<a href='https://switch.skrunk.org/api#obs'>Docs</a>"
    )


def script_properties():
    props = obs.obs_properties_create()
    obs.obs_properties_add_text(props, "key", "API key", obs.OBS_TEXT_PASSWORD)

    lst = obs.obs_properties_add_list(
        props, "source", "Text source", obs.OBS_COMBO_TYPE_EDITABLE, obs.OBS_COMBO_FORMAT_STRING
    )
    obs.obs_property_list_add_string(lst, "(none)", "")
    sources = obs.obs_enum_sources()
    if sources:
        for src in sources:
            if obs.obs_source_get_unversioned_id(src) in ("text_gdiplus", "text_ft2_source"):
                name = obs.obs_source_get_name(src)
                obs.obs_property_list_add_string(lst, name, name)
        obs.source_list_release(sources)

    fmt = obs.obs_properties_add_text(props, "format", "Format", obs.OBS_TEXT_DEFAULT)
    obs.obs_property_set_long_description(
        fmt, "Fill-ins: {temp} {target} {status} {countdown} {profile} {mode} {battery} {light} {rig}"
    )
    obs.obs_properties_add_text(props, "offline_text", "Text when offline", obs.OBS_TEXT_DEFAULT)
    obs.obs_properties_add_int(props, "poll", "Refresh every (seconds)", 1, 30, 1)
    obs.obs_properties_add_bool(props, "stop_on_end", "Stop the rig when the stream or recording ends")
    obs.obs_properties_add_text(props, "address", "API address", obs.OBS_TEXT_DEFAULT)
    obs.obs_properties_add_button(props, "test", "Test connection", test_connection)
    return props


def test_connection(props, prop):
    def work():
        status, data = read_state()
        if status == 200:
            s = data.get("state") or {}
            if not data.get("online"):
                print("[rig] key works, but the app isn't open with remote control on")
            elif not s.get("connected"):
                print("[rig] key works, app is online, no rig connected")
            else:
                print(f"[rig] connected: {s.get('rig')} at {s.get('temperature')}°F")
        else:
            print(f"[rig] test failed: {data.get('message') or data.get('error') or 'no answer'} ({status})")
    threading.Thread(target=work, daemon=True).start()
    return False


def script_defaults(settings):
    obs.obs_data_set_default_string(settings, "address", DEFAULT_ADDRESS)
    obs.obs_data_set_default_string(settings, "format", DEFAULT_FORMAT)
    obs.obs_data_set_default_string(settings, "offline_text", "")
    obs.obs_data_set_default_int(settings, "poll", 1)
    obs.obs_data_set_default_bool(settings, "stop_on_end", True)


def script_update(settings):
    global last_text
    cfg["address"] = obs.obs_data_get_string(settings, "address").strip() or DEFAULT_ADDRESS
    cfg["key"] = obs.obs_data_get_string(settings, "key").strip()
    cfg["source"] = obs.obs_data_get_string(settings, "source")
    cfg["format"] = obs.obs_data_get_string(settings, "format") or DEFAULT_FORMAT
    cfg["offline_text"] = obs.obs_data_get_string(settings, "offline_text")
    cfg["poll"] = obs.obs_data_get_int(settings, "poll") or 1
    cfg["stop_on_end"] = obs.obs_data_get_bool(settings, "stop_on_end")
    last_text = None  # redraw with the new settings


def script_load(settings):
    global running
    for hid, label, action, args in HOTKEYS:
        h = obs.obs_hotkey_register_frontend(hid, label, make_hotkey(action, args))
        hotkey_ids[hid] = h
        arr = obs.obs_data_get_array(settings, hid)
        obs.obs_hotkey_load(h, arr)
        obs.obs_data_array_release(arr)
    obs.obs_frontend_add_event_callback(on_event)
    running = True
    threading.Thread(target=poll_loop, daemon=True).start()
    obs.timer_add(update_text, 250)


def script_save(settings):
    for hid, h in hotkey_ids.items():
        arr = obs.obs_hotkey_save(h)
        obs.obs_data_set_array(settings, hid, arr)
        obs.obs_data_array_release(arr)


def script_unload():
    global running
    running = False
    obs.timer_remove(update_text)
