#!/usr/bin/env python3
"""THE FORTEANA DESK, hands. The one small thing for a box that is not the home PC: no Claude on it, no keys of the house,
only this. Run it and the box shows up on the desk by name; the desk's writer (which lives on the home PC) can then be told,
from the desk page in a browser, to do things on this box: run a command, read or write a file, list a folder, open a page.
Nothing runs unless the box's lock on the desk is lifted (locked / ask / open), and every job is logged on the desk.

Pairing, one of: (a) the file was downloaded from the desk with the box's key filled in below; (b) the file name carries a
six-digit pair code from the desk page, hands-123456.exe or hands-123456.py; (c) the code is the first argument. The box
then pairs itself once and keeps desk, key and name in hands.json beside this file. Delete the folder when you are done
with the box and nothing is left behind. Python 3.8+, standard library only; or the hands.exe build, which needs nothing.
"""
import io, json, os, platform, re, subprocess, sys, threading, time, urllib.request, urllib.parse, webbrowser
from http.server import BaseHTTPRequestHandler, HTTPServer

DESK = "https://forteana.ai-ministries.com"; KEY = "__KEY__"; NAME = "__NAME__"; POLL = 3
DEFAULT_DESK = os.environ.get("FORTEANA_DESK") or "https://forteana.ai-ministries.com"
HOME = os.path.expanduser("~")
ME = sys.executable if getattr(sys, "frozen", False) else os.path.abspath(__file__)
CONF = os.path.join(os.path.dirname(ME), "hands.json")

def settle():
    """desk, key, name: baked in, or hands.json beside me, or pair with a code from my file name or the command line"""
    global DESK, KEY, NAME
    if not KEY.startswith("__") and KEY: NAME = NAME if not NAME.startswith("__") and NAME else platform.node(); return
    try:
        c = json.load(io.open(CONF, encoding="utf-8")); DESK, KEY, NAME = c["desk"], c["key"], c["name"]; return
    except Exception: pass
    m = re.search(r"(\d{6})", os.path.basename(ME)); code = (sys.argv[1] if len(sys.argv) > 1 and re.fullmatch(r"\d{6}", sys.argv[1]) else None) or (m.group(1) if m else None)
    desk = DESK if not DESK.startswith("__") and DESK else DEFAULT_DESK
    body = {"os": platform.platform()[:60], "name": platform.node()[:40]}
    if code: body["code"] = code
    elif sys.stdin and not sys.stdin.isatty() and os.environ.get("HANDS_PAIR") != "browser":
        body["password"] = sys.stdin.readline().strip()
        if not body["password"]: sys.exit("no password given")
    else:
        # pair through the browser on this box: the desk page, already logged in, finds this program on loopback and hands it a code
        DESK = desk; threading.Thread(target=name_port, daemon=True).start()
        print("First run on this box. Opening the desk in your browser; once you are logged in there, it pairs this box as", platform.node(), "by itself.", flush=True)
        if not os.environ.get("HANDS_NO_BROWSER"):
            try: webbrowser.open(desk)
            except Exception: pass
        while not PAIR.get("code"): time.sleep(1)
        body["code"] = PAIR["code"]
    while True:
        try:
            req = urllib.request.Request(desk.rstrip("/") + "/api/hands/pair", data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
            with urllib.request.urlopen(req, timeout=30) as r: c = json.loads(r.read().decode("utf-8")); break
        except urllib.error.HTTPError as e:
            msg = e.read().decode("utf-8", "replace")[:160]; print(time.strftime("%H:%M:%S"), "the desk refused the pairing (%d): %s" % (e.code, msg), flush=True)
            if "code" in body and os.environ.get("HANDS_PAIR") != "stdin" and not body.get("password"):
                PAIR.pop("code", None); print("waiting for the desk page on this box to send a fresh code...", flush=True)
                while not PAIR.get("code"): time.sleep(1)
                body["code"] = PAIR["code"]; continue
            sys.exit("pairing failed")
        except Exception as e: print(time.strftime("%H:%M:%S"), "desk unreachable while pairing:", str(e)[:120], flush=True); time.sleep(5)
    if c.get("error"): sys.exit("the desk refused the code: " + c["error"])
    DESK, KEY, NAME = c["desk"], c["key"], c["name"]
    io.open(CONF, "w", encoding="utf-8").write(json.dumps({"desk": DESK, "key": KEY, "name": NAME}))
    print("paired as", NAME, "-> settings kept in", CONF, flush=True)

def api(path, data=None):
    body = json.dumps(data).encode() if data is not None else None
    req = urllib.request.Request(DESK.rstrip("/") + path, data=body, headers={"Authorization": "Bearer " + KEY, "Content-Type": "application/json", "X-Hands": "1"}, method="POST" if body else "GET")
    with urllib.request.urlopen(req, timeout=40) as r:
        if r.status == 204: return None
        return json.loads(r.read().decode("utf-8") or "null")

def do(job):
    k, a = job["kind"], job.get("args") or {}
    cwd = a.get("cwd") or HOME
    if k == "run":
        cmd = a.get("cmd", "")
        if not cmd: return {"error": "no command"}
        try:
            r = subprocess.run(cmd, shell=True, cwd=cwd if os.path.isdir(cwd) else HOME, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=int(a.get("timeout") or 600))
            out = (r.stdout or "") + (("\n[stderr]\n" + r.stderr) if r.stderr else "")
            return {"out": out[-120000:], "rc": r.returncode}
        except subprocess.TimeoutExpired: return {"error": "timed out after %s s" % a.get("timeout", 600)}
        except Exception as e: return {"error": str(e)[:400]}
    if k == "read":
        p = os.path.expanduser(a.get("path", ""))
        try: return {"out": io.open(p, encoding="utf-8", errors="replace").read()[:200000], "rc": 0}
        except Exception as e: return {"error": str(e)[:400]}
    if k == "write":
        p = os.path.expanduser(a.get("path", ""))
        try:
            os.makedirs(os.path.dirname(p) or ".", exist_ok=True)
            if os.path.exists(p) and a.get("backup", True):
                b = p + ".bak-" + time.strftime("%Y%m%d-%H%M%S"); io.open(b, "wb").write(io.open(p, "rb").read())
            io.open(p, "w", encoding="utf-8", newline="\n").write(a.get("text", "")); return {"out": "wrote %d chars to %s" % (len(a.get("text", "")), p), "rc": 0}
        except Exception as e: return {"error": str(e)[:400]}
    if k == "ls":
        p = os.path.expanduser(a.get("path", "") or HOME)
        try:
            rows = []
            for n in sorted(os.listdir(p))[:400]:
                f = os.path.join(p, n); rows.append(("%s/" % n) if os.path.isdir(f) else "%s  %d" % (n, os.path.getsize(f)))
            return {"out": "\n".join(rows), "rc": 0}
        except Exception as e: return {"error": str(e)[:400]}
    if k == "open":
        u = a.get("url", "")
        try:
            if u.startswith("http"): webbrowser.open(u)
            elif hasattr(os, "startfile"): os.startfile(os.path.expanduser(u))
            else: subprocess.Popen(["xdg-open", os.path.expanduser(u)])
            return {"out": "opened " + u, "rc": 0}
        except Exception as e: return {"error": str(e)[:400]}
    return {"error": "unknown kind " + k}

PAIR = {}
class NameHd(BaseHTTPRequestHandler):
    """a desk page opened in a browser on this very box asks 127.0.0.1:5617/name and learns which box it is on;
    while unpaired it answers unpaired, and /pair?code=123456 from that page completes the pairing"""
    def log_message(self, *a): pass
    def do_GET(self):
        unpaired = KEY.startswith("__") or not KEY
        if self.path.startswith("/pair?") and unpaired:
            q = urllib.parse.parse_qs(self.path.split("?", 1)[1]); c = (q.get("code") or [""])[0]
            if re.fullmatch(r"\d{6}", c): PAIR["code"] = c
        b = json.dumps({"name": NAME if not NAME.startswith("__") and NAME else platform.node()[:40], "desk": DESK, "os": platform.platform()[:60], "unpaired": unpaired}).encode()
        self.send_response(200); self.send_header("Content-Type", "application/json"); self.send_header("Access-Control-Allow-Origin", "*"); self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
def name_port():
    try: HTTPServer(("127.0.0.1", 5617), NameHd).serve_forever()
    except Exception as e: print("name port not open (another hands running here?):", str(e)[:80], flush=True)

def main():
    settle()
    if not PAIR: threading.Thread(target=name_port, daemon=True).start()
    print("The Forteana desk, hands on", NAME, "(", platform.platform(), ") ->", DESK, flush=True)
    print("This box does nothing until its lock on the desk page is lifted. Ctrl+C stops it; delete this folder when done.", flush=True)
    while True:
        try:
            job = api("/api/hands/next?" + urllib.parse.urlencode({"name": NAME, "os": platform.platform()[:60], "home": HOME}))
            if job:
                print(time.strftime("%H:%M:%S"), "job", job["id"], job["kind"], json.dumps(job.get("args"))[:160], flush=True)
                res = do(job); api("/api/hands/%s/result" % job["id"], res)
                print(time.strftime("%H:%M:%S"), "done" if not res.get("error") else "failed: " + res["error"][:120], flush=True)
                continue
        except Exception as e:
            print(time.strftime("%H:%M:%S"), "desk unreachable:", str(e)[:120], flush=True); time.sleep(10)
        time.sleep(POLL)

if __name__ == "__main__":
    try: main()
    except KeyboardInterrupt: print("stopped")
