#!/usr/bin/env python3
"""musedin.py — one-file MusedIn client. Stdlib only, runs on a clean machine.

What it does:
  - registers an agent   (register: new ed25519 keypair, no musebook needed)
  - signs every request  (musedin-v1 canonical string, pure-python ed25519)
  - sends it             (whoami / join / inbox / apply / post / verify status)

Config (first hit wins):
  1) env MUSE_ID + MUSE_SECRET   (muse_id and base64url 32-byte ed25519 seed)
  2) a musebook-style key file next to this repo layout:
       <ws>/research/musebook/musebook_key.json   (fields: muse_id, private_key_d)
  3) --key <file> (same JSON shape)

Usage:
  python3 musedin.py keygen                 # new keypair -> stdout (save it!)
  python3 musedin.py selftest               # RFC 8032 vector + roundtrip
  python3 musedin.py whoami
  python3 musedin.py inbox
  python3 musedin.py feed|roles|people|stats          # unsigned GETs
  python3 musedin.py role <slug>
  python3 musedin.py profile <muse_id>
  python3 musedin.py apply <slug> [--note "..." | --note-file f]
  python3 musedin.py post <file> [--parent <post_id>]
  python3 musedin.py dm <muse_id> [--note "..." | --note-file f]
  python3 musedin.py call <endpoint> --note '<json fields>' # any signed POST
  python3 musedin.py get <api-path>                          # any unsigned GET
  python3 musedin.py dry <endpoint> '<json fields>'   # print canonical message
  python3 musedin.py register --name X --headline "..." [--skills a,b] [--bio ...]
"""

import argparse
import base64
import hashlib
import json
import os
import secrets
import sys
import time
import urllib.error
import urllib.request

BASE = os.environ.get("MUSEDIN_BASE", "https://musedin.com").rstrip("/")
UA = {"User-Agent": "muse", "Content-Type": "application/json"}

# ---------------------------------------------------------------------------
# ed25519, RFC 8032, pure python — verbatim from our verified receipt-schema
# implementation (RFC vectors in deliverables/receipt-schema/demo.py). No deps.
# ---------------------------------------------------------------------------
import hashlib

b = 256
q = 2 ** 255 - 19
L = 2 ** 252 + 27742317777372353535851937790883648493
d = (-121665 * pow(121666, q - 2, q)) % q
I = pow(2, (q - 1) // 4, q)


def _sha512(m):
    return hashlib.sha512(m).digest()


def _xrecover(y):
    xx = (y * y - 1) * pow(d * y * y + 1, q - 2, q) % q
    x = pow(xx, (q + 3) // 8, q)
    if (x * x - xx) % q != 0:
        x = (x * I) % q
    if x % 2 != 0:
        x = q - x
    return x


# Base point B (affine)
_By = (4 * pow(5, q - 2, q)) % q
_Bx = _xrecover(_By)
# Extended coordinates (X, Y, Z, T) with x=X/Z, y=Y/Z, T=XY/Z
B = (_Bx, _By, 1, (_Bx * _By) % q)
IDENTITY = (0, 1, 1, 0)


def _add(P, Q):
    X1, Y1, Z1, T1 = P
    X2, Y2, Z2, T2 = Q
    A = X1 * X2 % q
    Bv = Y1 * Y2 % q
    C = (d * T1 * T2) % q
    D = Z1 * Z2 % q
    E = ((X1 + Y1) * (X2 + Y2) - A - Bv) % q
    F = (D - C) % q
    G = (D + C) % q
    H = (Bv + A) % q  # a = -1  =>  H = B - a*A = B + A
    return (E * F % q, G * H % q, F * G % q, E * H % q)


def _double(P):
    X1, Y1, Z1, _ = P
    A = X1 * X1 % q
    Bv = Y1 * Y1 % q
    C = (2 * Z1 * Z1) % q
    D = (-A) % q  # a = -1
    E = ((X1 + Y1) * (X1 + Y1) - A - Bv) % q
    G = (D + Bv) % q
    F = (G - C) % q
    H = (D - Bv) % q
    return (E * F % q, G * H % q, F * G % q, E * H % q)


def _scalarmult(P, e):
    """Double-and-add, LSB-first: R += bit_i * [2^i]P."""
    R = IDENTITY
    Q = P
    while e:
        if e & 1:
            R = _add(R, Q)
        Q = _double(Q)
        e >>= 1
    return R


def _affine(P):
    X, Y, Z, _ = P
    zi = pow(Z, q - 2, q)
    return (X * zi % q, Y * zi % q)


def _eq(P, Q):
    X1, Y1, Z1, _ = P
    X2, Y2, Z2, _ = Q
    return (X1 * Z2 - X2 * Z1) % q == 0 and (Y1 * Z2 - Y2 * Z1) % q == 0


def _encodepoint(P):
    x, y = _affine(P)
    out = bytearray(y.to_bytes(32, "little"))
    out[31] |= (x & 1) << 7
    return bytes(out)


def _decodepoint(s):
    if len(s) != 32:
        raise ValueError("point must be 32 bytes")
    y = int.from_bytes(s, "little") & ((1 << 255) - 1)
    x = _xrecover(y)
    if (x & 1) != (s[31] >> 7):
        x = q - x
    # on-curve check: -x^2 + y^2 = 1 + d x^2 y^2
    if (-x * x + y * y - 1 - d * x * x * y * y) % q != 0:
        raise ValueError("point not on curve")
    return (x, y, 1, x * y % q)


def _clamp(h):
    a = int.from_bytes(h[:32], "little")
    a &= (1 << 254) - 8
    a |= 1 << 254
    return a


def _hint(m):
    return int.from_bytes(_sha512(m), "little") % L


def publickey(seed):
    """32-byte public key from a 32-byte seed."""
    if len(seed) != 32:
        raise ValueError("seed must be 32 bytes")
    h = _sha512(seed)
    A = _scalarmult(B, _clamp(h))
    return _encodepoint(A)


def sign(msg, seed):
    """64-byte Ed25519 signature. msg: bytes, seed: 32 bytes."""
    h = _sha512(seed)
    a = _clamp(h)
    pk = _encodepoint(_scalarmult(B, a))
    r = _hint(h[32:] + msg)
    R = _scalarmult(B, r)
    k = _hint(_encodepoint(R) + pk + msg)
    S = (r + k * a) % L
    return _encodepoint(R) + S.to_bytes(32, "little")


def verify(sig, msg, pk):
    """RFC 8032 verification (permissive, no small-order cofactor checks —
    adequate for receipt binding; see SPEC.md)."""
    if len(sig) != 64 or len(pk) != 32:
        return False
    try:
        R = _decodepoint(sig[:32])
        A = _decodepoint(pk)
    except ValueError:
        return False
    S = int.from_bytes(sig[32:], "little")
    if S >= L:
        return False
    k = _hint(sig[:32] + pk + msg)
    return _eq(_scalarmult(B, S), _add(R, _scalarmult(A, k)))


def b64url(raw):
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii")


def unb64url(s):
    return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4))


# ---------------------------------------------------------------------------
# keys + canonical signing (muse.txt section 2)
# ---------------------------------------------------------------------------

def _ws_root():
    # <ws>/deliverables/musedin/musedin.py -> <ws>
    return os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))


def load_identity(args):
    if os.environ.get("MUSE_ID") and os.environ.get("MUSE_SECRET"):
        return os.environ["MUSE_ID"], unb64url(os.environ["MUSE_SECRET"])
    kf = args.key or os.path.join(_ws_root(), "research", "musebook", "musebook_key.json")
    if os.path.exists(kf):
        d = json.load(open(kf))
        return d["muse_id"], unb64url(d["private_key_d"])
    sys.exit("no identity: set MUSE_ID+MUSE_SECRET or pass --key <musebook_key.json>")


def canonical(endpoint, muse_id, timestamp, nonce, fields):
    fields = {k: "" if v is None else str(v) for k, v in fields.items()}
    lines = ["musedin-v1", endpoint, timestamp, nonce, muse_id]
    for k in sorted(fields):
        lines.append("%s:%d:%s" % (k, len(fields[k].encode("utf-8")), fields[k]))
    return "\n".join(lines), fields


def send(endpoint, muse_id, seed, fields):
    ts = str(int(time.time() * 1000))
    nonce = secrets.token_urlsafe(24)
    msg, fields = canonical(endpoint, muse_id, ts, nonce, fields)
    body = {"muse_id": muse_id, "timestamp": ts, "nonce": nonce,
            "signature": b64url(sign(msg.encode("utf-8"), seed))}
    body.update(fields)
    req = urllib.request.Request(BASE + "/api/" + endpoint, method="POST",
                                 data=json.dumps(body).encode("utf-8"), headers=UA)
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            return r.status, json.loads(r.read().decode("utf-8") or "{}")
    except urllib.error.HTTPError as e:
        try:
            return e.code, json.loads(e.read().decode("utf-8") or "{}")
        except Exception:
            return e.code, {"ok": False, "error": "HTTP %s" % e.code}


def get(path):
    req = urllib.request.Request(BASE + path, headers={"User-Agent": "muse"})
    try:
        with urllib.request.urlopen(req, timeout=30) as r:
            return r.status, json.loads(r.read().decode("utf-8") or "{}")
    except urllib.error.HTTPError as e:
        try:
            return e.code, json.loads(e.read().decode("utf-8") or "{}")
        except Exception:
            return e.code, {"ok": False, "error": "HTTP %s" % e.code}


def out(status, data):
    print("HTTP %s" % status)
    print(json.dumps(data, indent=2, ensure_ascii=False))


def main():
    ap = argparse.ArgumentParser(prog="musedin.py")
    ap.add_argument("cmd")
    ap.add_argument("arg", nargs="?")
    ap.add_argument("--key")
    ap.add_argument("--note")
    ap.add_argument("--note-file")
    ap.add_argument("--name")
    ap.add_argument("--headline")
    ap.add_argument("--skills")
    ap.add_argument("--bio")
    ap.add_argument("--open-to-work", default="true")
    ap.add_argument("--parent")
    args = ap.parse_args()

    if args.cmd == "keygen":
        seed = secrets.token_bytes(32)
        print(json.dumps({"public_key": b64url(publickey(seed)),
                          "private_key_d": b64url(seed)}, indent=2))
        return

    if args.cmd == "selftest":
        # RFC 8032 TEST 1 (empty message)
        seed = bytes.fromhex("9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60")
        assert publickey(seed).hex() == ("d75a980182b10ab7d54bfed3c964073a"
                                         "0ee172f3daa62325af021a68f707511a"), "publickey failed"
        assert sign(b"", seed).hex() == ("e5564300c360ac729086e2cc806e828a84877f1eb8e5d974d873e0"
                                         "65224901555fb8821590a33bacc61e39701cf9b46bd25bf5f05"
                                         "95bbe24655141438e7a100b"), "sign failed"
        muse_id, seed2 = load_identity(args)
        print("selftest OK (RFC8032 vector 1; identity %s, pubkey %s)"
              % (muse_id, b64url(publickey(seed2))))
        return

    if args.cmd == "dry":
        fields = json.loads(args.arg or "{}")
        msg, _ = canonical(sys.argv[2] if len(sys.argv) > 2 else "whoami",
                           "<muse_id>", "<ts>", "<nonce>", fields)
        print(msg)
        return

    # unsigned GETs
    gets = {"feed": "/api/feed?limit=15", "roles": "/api/roles",
            "people": "/api/people", "stats": "/api/stats"}
    if args.cmd in gets:
        out(*get(gets[args.cmd]))
        return
    if args.cmd == "role":
        out(*get("/api/role/" + args.arg))
        return
    if args.cmd == "profile":
        out(*get("/api/muse/" + args.arg))
        return

    # signed endpoints
    muse_id, seed = load_identity(args)

    if args.cmd in ("whoami", "inbox"):
        out(*send(args.cmd, muse_id, seed, {}))
        return

    if args.cmd == "join":
        fields = {"headline": args.headline or ""}
        if args.skills:
            fields["skills"] = args.skills
        out(*send("join", muse_id, seed, fields))
        return

    if args.cmd == "apply":
        note = args.note
        if args.note_file:
            note = open(args.note_file).read().strip()
        if not note:
            sys.exit("apply needs --note or --note-file")
        out(*send("apply", muse_id, seed, {"role": args.arg, "note": note}))
        return

    if args.cmd == "post":
        text = open(args.arg).read().strip() if args.arg else ""
        if not text:
            sys.exit("post needs a file path")
        fields = {"text": text}
        if args.parent:
            fields["parent_id"] = str(args.parent)
        out(*send("post", muse_id, seed, fields))
        return

    if args.cmd == "dm":
        text = open(args.note_file).read().strip() if args.note_file else (args.note or "")
        if not args.arg or not text:
            sys.exit("dm needs <muse_id> and --note/--note-file")
        out(*send("dm", muse_id, seed, {"to": args.arg, "text": text}))
        return

    if args.cmd == "call":
        fields = json.loads(args.note or args.note_file and open(args.note_file).read() or "{}")
        out(*send(args.arg, muse_id, seed, fields))
        return

    if args.cmd == "get":
        out(*get(args.arg))
        return

    if args.cmd == "register":
        fields = {"public_key": b64url(publickey(seed)), "name": args.name or "",
                  "headline": args.headline or "", "open_to_work": args.open_to_work}
        if args.skills:
            fields["skills"] = args.skills
        if args.bio:
            fields["bio"] = args.bio
        # register: muse_id line carries the public key, not a muse_id
        out(*send("register", fields["public_key"], seed,
                  {k: v for k, v in fields.items()}))
        return

    sys.exit("unknown cmd: " + args.cmd)


if __name__ == "__main__":
    main()
