#!/usr/bin/env python3
"""keyholder — the self-hosted execution half of a compute.wick.pics limit order.

Runs on YOUR machine. Holds YOUR provider API key (env var, never sent anywhere
except the fill call, which the station passes through to the provider without
storing). Long-polls the station for a fill ticket; when one is cut, verifies
its HMAC signature with the order secret and executes the fill.

Environment:
  ORDER_ID        (required) from create order
  ORDER_SECRET    (required) shown once at create
  PROVIDER_KEY    (required) your own Vast.ai or RunPod API key
  COMPUTE_URL     default https://compute.wick.pics
  CONFIRM         "1" places the rental FOR REAL; anything else dry-runs
  ACCOUNT_TOKEN   optional usage-ledger token
  IMAGE, DISK_GB  optional runtime knobs passed to the fill

Standing orders (compute that survives): after a live fill the sidecar keeps
running — every HEARTBEAT_SECONDS (default 45) it checks its own instance via
the station's pass-through status call (your key rides that one call, never
stored) and reports what it sees. Two consecutive dead observations re-arm the
order; the sidecar then long-polls for the refill ticket and fills again. If
this process dies, nothing refills — it is both the witness and the executor —
so keep it under a restart policy (docker --restart=always, or systemd).

One command:
  docker build -t keyholder . && docker run -e ORDER_ID=... -e ORDER_SECRET=... \
    -e PROVIDER_KEY=... -e CONFIRM=1 keyholder
Or just: python3 keyholder.py  (stdlib only, no installs)
"""
import hashlib
import hmac
import json
import os
import sys
import time
import urllib.error
import urllib.request

BASE = os.environ.get("COMPUTE_URL", "https://compute.wick.pics").rstrip("/")
ORDER_ID = os.environ.get("ORDER_ID", "")
SECRET = os.environ.get("ORDER_SECRET", "")
KEY = os.environ.get("PROVIDER_KEY", "")
CONFIRM = os.environ.get("CONFIRM", "") == "1"
HB_SECONDS = max(20, int(os.environ.get("HEARTBEAT_SECONDS", "45")))

def die(msg):
    print("keyholder:", msg, file=sys.stderr)
    sys.exit(1)

def post(path, body, timeout=70):
    req = urllib.request.Request(
        BASE + path, data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json",
                 "User-Agent": "keyholder-sidecar/1.0"})
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read())

def verify(ticket):
    payload = {k: v for k, v in ticket.items() if k != "sig"}
    body = json.dumps(payload, sort_keys=True, separators=(",", ":"))
    want = hmac.new(SECRET.encode(), body.encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(want, ticket.get("sig", ""))

def instance_state(provider, instance_id):
    """One pass-through status call with OUR key; never raises."""
    try:
        r = post(f"/api/rentals/{provider}/{instance_id}/status",
                 {"api_key": KEY}, timeout=30)
        return r.get("state") or "unknown", r.get("price_hr")
    except urllib.error.HTTPError as e:
        print(f"keyholder: status refused: {e.read().decode()[:120]}")
    except Exception as e:
        print(f"keyholder: status unreachable ({type(e).__name__})")
    return "unknown", None

def watch(receipt):
    """Heartbeat loop for a standing order's live machine. Returns the order
    state the station moved to when the watch ended."""
    provider = receipt.get("provider")
    iid = receipt.get("provider_instance_id") or receipt.get("instance_id")
    print(f"keyholder: WATCHING {provider} instance {iid}"
          f" (heartbeat every {HB_SECONDS}s)")
    while True:
        time.sleep(HB_SECONDS)
        state, price = instance_state(provider, iid)
        try:
            r = post(f"/api/orders/{ORDER_ID}/heartbeat",
                     {"order_secret": SECRET, "state": state,
                      "provider": provider, "instance_id": str(iid),
                      "price_hr": price}, timeout=30)
        except urllib.error.HTTPError as e:
            die(f"station refused the heartbeat: {e.read().decode()[:200]}")
        except Exception as e:
            print(f"keyholder: heartbeat undelivered ({type(e).__name__});"
                  " retrying next beat")
            continue
        ostate = r.get("order_state")
        if ostate == "watching":
            continue
        if ostate == "armed":
            print(f"keyholder: machine is GONE — order re-armed"
                  f" (refill #{r.get('refill_no')}); waiting for the ticket")
        else:
            print(f"keyholder: watch ended — order is {ostate}")
        return ostate

def main():
    # our prints ARE the interface — never let a pipe buffer them
    sys.stdout.reconfigure(line_buffering=True)
    if not (ORDER_ID and SECRET and KEY):
        die("ORDER_ID, ORDER_SECRET and PROVIDER_KEY are required")
    print(f"keyholder: watching order {ORDER_ID} on {BASE}"
          f" ({'LIVE fills' if CONFIRM else 'dry-run only — set CONFIRM=1'})")
    backoff = 5
    while True:
        try:
            r = post(f"/api/orders/{ORDER_ID}/ticket",
                     {"order_secret": SECRET, "wait": 55})
            backoff = 5
        except urllib.error.HTTPError as e:
            die(f"station refused: {e.read().decode()[:200]}")
        except Exception as e:
            print(f"keyholder: station unreachable ({type(e).__name__}),"
                  f" retrying in {backoff}s")
            time.sleep(backoff)
            backoff = min(backoff * 2, 300)
            continue
        state = r.get("state")
        if state in ("filled", "cancelled", "expired", "retired", "gone"):
            print(f"keyholder: order is {state}; exiting")
            return
        if state == "watching" and CONFIRM and r.get("live", {}).get("instance_id"):
            # restarted mid-watch: resume supervising the live machine
            ended = watch(r["live"])
            if ended == "armed":
                continue
            return
        if state == "watching":
            # dry-run sidecar on a live watch: nothing to execute, don't spin
            time.sleep(HB_SECONDS)
            continue
        t = r.get("ticket")
        if not t:
            continue
        if not verify(t):
            die("ticket signature FAILED verification — refusing to act")
        offer = t["offer"]
        print(f"keyholder: ticket — {offer['gpu_model']} on {offer['provider']}"
              f" at ${offer['price_final_per_gpu_hr']:.4f}/GPU/hr"
              f" (line ${t['line']:.4f}, expires {t['expires_at']})")
        fill = {"order_secret": SECRET, "api_key": KEY,
                "confirm": CONFIRM, "dry_run": not CONFIRM}
        if os.environ.get("ACCOUNT_TOKEN"):
            fill["account_token"] = os.environ["ACCOUNT_TOKEN"]
        if os.environ.get("IMAGE"):
            fill["image"] = os.environ["IMAGE"]
        if os.environ.get("DISK_GB"):
            fill["disk_gb"] = float(os.environ["DISK_GB"])
        try:
            out = post(f"/api/orders/{ORDER_ID}/fill", fill, timeout=120)
        except urllib.error.HTTPError as e:
            print(f"keyholder: fill refused: {e.read().decode()[:300]}")
            time.sleep(10)
            continue
        print("keyholder: receipt:", json.dumps(out.get("receipt", out), indent=2))
        if CONFIRM and out.get("order_state") == "watching":
            ended = watch(out.get("receipt", {}))
            if ended == "armed":
                continue           # refill: back to the ticket long-poll
            return
        if CONFIRM and out.get("order_state") == "filled":
            print("keyholder: order FILLED; exiting")
            return
        if not CONFIRM:
            print("keyholder: dry-run complete (order stays armed);"
                  " set CONFIRM=1 for real fills. Watching on.")
            time.sleep(30)

if __name__ == "__main__":
    main()
