#!/usr/bin/env python3
"""coord.py - ClaudeTools coordination API helper (messages, todos, locks, status).

Removes the recurring "how do I call coord again?" friction. Auto-derives from
identity.json: the API base (coord_api), this session id (<machine>/claude-main),
and the user/machine for attribution. Bakes in the fleet conventions:
  - broadcast to the whole fleet = to_session "ALL_SESSIONS"
  - session id = "<MACHINE>/claude-main"
  - long message bodies via --body-file (avoids shell-quoting breakage)

Usage:
  coord.py status
  coord.py msg send <to> <subject> [body] [--body-file PATH] [--project KEY]
        <to>: ALL | <machine> | <machine>/claude-main
  coord.py msg inbox [--all]                      # messages to me (unread by default)
  coord.py msg read <id>
  coord.py msg purge --before YYYY-MM-DD [--to <session|ALL|machine>] [--yes]
        # delete dealt-with messages older than the cutoff. DRY-RUN by default;
        # add --yes to actually delete. --to scopes to one recipient session.
  coord.py todo add <text> [--text-file PATH] [--user U] [--project KEY] [--parent ID] [--source TEXT]
  coord.py todo list [--user U] [--project KEY] [--status pending|done|all]
  coord.py todo done <id>
  coord.py lock claim <project> <resource> <desc> [--ttl HOURS]
  coord.py lock release <id>
  coord.py lock list [--project KEY]
"""
import sys, os, json, argparse, subprocess, urllib.request, urllib.error, urllib.parse


def _log_skill_error(skill, msg, context=""):
    """Soft-fail: append a functional-error entry to errorlog.md (never throws)."""
    try:
        root = os.environ.get("CLAUDETOOLS_ROOT") or os.path.abspath(
            os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")
        )
        h = os.path.join(root, ".claude", "scripts", "log-skill-error.sh")
        if not os.path.exists(h):
            return
        a = ["bash", h, skill, msg]
        if context:
            a += ["--context", context]
        subprocess.run(a, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                       timeout=10)
    except Exception:
        pass


def find_identity():
    cands = []
    env = os.environ.get("CLAUDETOOLS_ROOT")
    if env:
        cands.append(os.path.join(env, ".claude", "identity.json"))
    here = os.path.dirname(os.path.abspath(__file__))
    cands.append(os.path.normpath(os.path.join(here, "..", "..", "..", "identity.json")))
    for c in cands:
        if os.path.isfile(c):
            try:
                return json.load(open(c, encoding="utf-8"))
            except Exception:
                pass
    return {}


IDENT = find_identity()
API = (IDENT.get("coord_api") or "http://172.16.3.30:8001").rstrip("/") + "/api/coord"
MACHINE = IDENT.get("machine") or os.environ.get("COMPUTERNAME") or "unknown"
USER = IDENT.get("user") or "unknown"
SESSION = f"{MACHINE}/claude-main"


def call(method, path, body=None, query=None):
    url = API + path
    if query:
        q = {k: v for k, v in query.items() if v is not None}
        if q:
            url += "?" + urllib.parse.urlencode(q)
    data = json.dumps(body).encode() if body is not None else None
    headers = {"Content-Type": "application/json"} if data is not None else {}
    req = urllib.request.Request(url, data=data, method=method, headers=headers)
    try:
        with urllib.request.urlopen(req, timeout=25) as r:
            raw = r.read().decode("utf-8", "replace")
            return r.status, (json.loads(raw) if raw.strip() else None)
    except urllib.error.HTTPError as e:
        raw = e.read().decode("utf-8", "replace")
        try:
            return e.code, json.loads(raw)
        except Exception:
            return e.code, {"error": raw[:400]}
    except Exception as e:
        return 0, {"error": str(e)}


def die(st, resp, ok=(200, 201)):
    if st not in ok:
        print(f"[coord] ERROR HTTP {st}: {json.dumps(resp)[:500]}", file=sys.stderr)
        _log_skill_error(
            "coord", f"coord API call failed (HTTP {st})",
            context=f"http={st} cmd={' '.join(sys.argv[1:3])} resp={json.dumps(resp)[:80]}",
        )
        sys.exit(1)


def resolve_to(to):
    t = (to or "").strip()
    if t.lower() in ("all", "broadcast", "fleet", "all_sessions", "*"):
        return "ALL_SESSIONS"
    return t if "/" in t else f"{t}/claude-main"


def listify(r, key):
    if isinstance(r, dict):
        return r.get(key, []) or []
    return r or []


# --- commands ---
def c_status(a):
    st, r = call("GET", "/status"); die(st, r, ok=(200,))
    print(json.dumps(r, indent=2)[:3000])


def c_msg_send(a):
    body = a.body
    if a.body_file:
        body = open(a.body_file, encoding="utf-8").read()
    if body is None:
        print("[coord] need a body (positional or --body-file)", file=sys.stderr); sys.exit(2)
    to = resolve_to(a.to)
    payload = {"from_session": SESSION, "to_session": to, "subject": a.subject, "body": body}
    if a.project:
        payload["project_key"] = a.project
    st, r = call("POST", "/messages", payload); die(st, r)
    print(f"[coord] message sent  id={r.get('id')}  to={to}")


def c_msg_inbox(a):
    q = {"to_session": SESSION}
    if not a.all:
        q["unread_only"] = "true"
    st, r = call("GET", "/messages", query=q); die(st, r, ok=(200,))
    msgs = listify(r, "messages")
    print(f"[coord] {len(msgs)} message(s) for {SESSION}{'' if a.all else ' (unread)'}")
    for m in msgs:
        print(f"--- {m.get('id')} | from {m.get('from_session')} | {m.get('created_at','')}")
        print(f"    SUBJECT: {m.get('subject')}")
        for line in (m.get("body") or "").rstrip().splitlines():
            print(f"    {line}")


def c_msg_read(a):
    st, r = call("PUT", f"/messages/{a.id}/read"); die(st, r, ok=(200, 204))
    print(f"[coord] marked read: {a.id}")


def c_msg_purge(a):
    """Delete dealt-with messages older than --before (date cutoff). DRY-RUN unless --yes.

    Refuses to run without --before so it can never wipe the whole store by accident.
    Coordination messages are ephemeral; the durable record is in session logs.
    """
    before = (a.before or "").strip()
    if not (len(before) == 10 and before[4] == "-" and before[7] == "-"
            and before.replace("-", "").isdigit()):
        print("[coord] --before must be YYYY-MM-DD", file=sys.stderr); sys.exit(2)
    to = resolve_to(a.to) if a.to else None
    # Fetch all messages, paginating over the API's 1000-row limit cap.
    msgs, skip = [], 0
    while True:
        q = {"limit": 1000, "skip": skip}
        if to:
            q["to_session"] = to
        st, r = call("GET", "/messages", query=q); die(st, r, ok=(200,))
        batch = listify(r, "messages")
        msgs += batch
        if len(batch) < 1000:
            break
        skip += 1000
    # Strictly before the cutoff DATE (compare the date prefix, so the cutoff day is kept).
    targets = [m for m in msgs if (m.get("created_at") or "")[:10] < before]
    scope = f" to {to}" if to else ""
    print(f"[coord] {len(targets)} message(s){scope} before {before} (of {len(msgs)} fetched)")
    if not targets:
        return
    if not a.yes:
        for m in targets[:20]:
            print(f"  {(m.get('created_at') or '')[:16]} | {m.get('from_session')} -> "
                  f"{m.get('to_session')} | {str(m.get('subject',''))[:50]}")
        if len(targets) > 20:
            print(f"  ... and {len(targets) - 20} more")
        print(f"[coord] DRY RUN - re-run with --yes to delete these {len(targets)} message(s)")
        return
    ok = fail = 0
    for m in targets:
        st, r = call("DELETE", f"/messages/{m.get('id')}")
        if st in (200, 204):
            ok += 1
        else:
            fail += 1
    print(f"[coord] purged {ok} message(s)" + (f", {fail} FAILED" if fail else ""))
    if fail:
        _log_skill_error("coord", f"msg purge: {fail} of {len(targets)} deletes failed",
                         context=f"before={before}")


def c_todo_add(a):
    text = a.text
    if a.text_file:
        text = open(a.text_file, encoding="utf-8").read().strip()
    payload = {"text": text, "created_by_user": USER, "created_by_machine": MACHINE,
               "auto_created": bool(a.auto)}
    if a.user:
        payload["assigned_to_user"] = a.user
    if a.project:
        payload["project_key"] = a.project
    if a.parent:
        payload["parent_id"] = a.parent
    if a.source:
        payload["source_context"] = a.source
    st, r = call("POST", "/todos", payload); die(st, r)
    print(f"[coord] todo created  id={r.get('id')}")


def c_todo_list(a):
    q = {"status_filter": a.status}
    if a.user:
        q["for_user"] = a.user
    if a.project:
        q["project_key"] = a.project
    st, r = call("GET", "/todos", query=q); die(st, r, ok=(200,))
    todos = listify(r, "todos")
    print(f"[coord] {len(todos)} todo(s)")
    for t in todos:
        extra = ""
        if t.get("assigned_to_user"):
            extra += f"  @{t['assigned_to_user']}"
        if t.get("project_key"):
            extra += f"  ({t['project_key']})"
        print(f"  [{t.get('status'):<7}] {str(t.get('id',''))[:8]}  {str(t.get('text',''))[:88]}{extra}")


def c_todo_done(a):
    st, r = call("PUT", f"/todos/{a.id}", {"status": "done", "completed_by": USER})
    die(st, r, ok=(200,))
    print(f"[coord] todo done: {a.id}")


def c_lock_claim(a):
    st, r = call("POST", "/locks", {"project_key": a.project, "session_id": SESSION,
                                     "resource": a.resource, "description": a.desc,
                                     "ttl_hours": a.ttl}); die(st, r)
    print(f"[coord] lock id={r.get('id')}  {a.project}:{a.resource}")


def c_lock_release(a):
    st, r = call("DELETE", f"/locks/{a.id}", query={"session_id": SESSION})
    die(st, r, ok=(200, 204))
    print(f"[coord] lock released: {a.id}")


def c_lock_list(a):
    st, r = call("GET", "/locks", query={"project_key": a.project} if a.project else None)
    die(st, r, ok=(200,))
    locks = listify(r, "locks")
    print(f"[coord] {len(locks)} lock(s)")
    for l in locks:
        print(f"  {str(l.get('id',''))[:8]}  {l.get('project_key')}:{l.get('resource')}  by {l.get('session_id')}  exp {l.get('expires_at')}")


def c_comp_set(a):
    payload = {"state": a.state, "updated_by": SESSION}
    if a.version:
        payload["version"] = a.version
    if a.notes:
        payload["notes"] = a.notes
    st, r = call("PUT", f"/components/{a.project}/{a.component}", payload)
    die(st, r, ok=(200, 201))
    print(f"[coord] component {a.project}/{a.component} -> state={a.state}" + (f"  v{a.version}" if a.version else ""))


def main():
    p = argparse.ArgumentParser(prog="coord.py", description="ClaudeTools coordination API helper")
    sub = p.add_subparsers(dest="cmd", required=True)

    sub.add_parser("status").set_defaults(fn=c_status)

    m = sub.add_parser("msg").add_subparsers(dest="sub", required=True)
    s = m.add_parser("send"); s.add_argument("to"); s.add_argument("subject"); s.add_argument("body", nargs="?")
    s.add_argument("--body-file"); s.add_argument("--project"); s.set_defaults(fn=c_msg_send)
    ib = m.add_parser("inbox"); ib.add_argument("--all", action="store_true"); ib.set_defaults(fn=c_msg_inbox)
    rd = m.add_parser("read"); rd.add_argument("id"); rd.set_defaults(fn=c_msg_read)
    pg = m.add_parser("purge"); pg.add_argument("--before", required=True); pg.add_argument("--to")
    pg.add_argument("--yes", action="store_true"); pg.set_defaults(fn=c_msg_purge)

    t = sub.add_parser("todo").add_subparsers(dest="sub", required=True)
    ta = t.add_parser("add"); ta.add_argument("text", nargs="?"); ta.add_argument("--text-file")
    ta.add_argument("--user"); ta.add_argument("--project"); ta.add_argument("--parent")
    ta.add_argument("--source"); ta.add_argument("--auto", action="store_true"); ta.set_defaults(fn=c_todo_add)
    tl = t.add_parser("list"); tl.add_argument("--user"); tl.add_argument("--project")
    tl.add_argument("--status", default="pending"); tl.set_defaults(fn=c_todo_list)
    td = t.add_parser("done"); td.add_argument("id"); td.set_defaults(fn=c_todo_done)

    cp = sub.add_parser("component").add_subparsers(dest="sub", required=True)
    cs = cp.add_parser("set"); cs.add_argument("project"); cs.add_argument("component"); cs.add_argument("state")
    cs.add_argument("--version"); cs.add_argument("--notes"); cs.set_defaults(fn=c_comp_set)

    lk = sub.add_parser("lock").add_subparsers(dest="sub", required=True)
    lc = lk.add_parser("claim"); lc.add_argument("project"); lc.add_argument("resource")
    lc.add_argument("desc"); lc.add_argument("--ttl", type=int, default=2); lc.set_defaults(fn=c_lock_claim)
    lr = lk.add_parser("release"); lr.add_argument("id"); lr.set_defaults(fn=c_lock_release)
    ll = lk.add_parser("list"); ll.add_argument("--project"); ll.set_defaults(fn=c_lock_list)

    a = p.parse_args()
    a.fn(a)


if __name__ == "__main__":
    main()
