#!/usr/bin/env python3
"""gts-webchat — unified WebChat CLI for AI agents and humans.

Single approval surface. One pattern (`gts-webchat *`) per harness instead of
N per-endpoint curl rules across Claude Code, Codex, Aider, Cursor, MCP, etc.

Wraps gts-team.dev WebChat relay HTTP API into subcommands. Stdlib only.

Usage:
    gts-webchat register --task <name> [--email <addr>] [--peer <chat_address>]
    gts-webchat verify   --task <name> --code <6digits>
    gts-webchat daemon start --task <name>
    gts-webchat daemon stop  --task <name>
    gts-webchat daemon status --task <name>
    gts-webchat send  --task <name> [--to <peer>] <text...>
    gts-webchat tail  --task <name> [-n N] [-f]
    gts-webchat poll  --task <name>
    gts-webchat presence --task <name> [--offline]
    gts-webchat goodbye  --task <name>

Config dir: ~/.config/gts-webchat/<task>/
    token.json     — session_token, chat_address, display_name, email, peer_address
    daemon.pid     — pid of running poll loop
    daemon.heartbeat — unix ts updated each poll cycle
    inbox.jsonl    — append-only log of received messages
    daemon.log     — daemon stderr / events

Env:
    GTS_WEBCHAT_RELAY     — base URL (default: https://gts-team.dev)
    GTS_WEBCHAT_PEER      — fallback peer chat_address for `send` w/o --to
"""

import argparse
import json
import os
import signal
import subprocess
import sys
import time
import urllib.error
import urllib.request
from pathlib import Path

RELAY = os.environ.get("GTS_WEBCHAT_RELAY", "https://gts-team.dev").rstrip("/")
CONFIG_ROOT = Path(os.environ.get("GTS_WEBCHAT_CONFIG_DIR",
                                  os.path.expanduser("~/.config/gts-webchat")))
POLL_INTERVAL_SEC = 4
PRESENCE_INTERVAL_SEC = 25
DEFAULT_DEVICE = "Claude · agent"
PHONE_ID = "pchat-free"


# ── HTTP helpers ───────────────────────────────────────────────────────────

def _http(method, path, body=None, token=None, timeout=15):
    url = f"{RELAY}{path}"
    data = json.dumps(body).encode() if body is not None else None
    headers = {"Content-Type": "application/json"}
    if token:
        headers["X-PChat-Session"] = token
    req = urllib.request.Request(url, data=data, method=method, headers=headers)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return resp.status, json.loads(resp.read().decode())
    except urllib.error.HTTPError as e:
        return e.code, json.loads(e.read().decode()) if e.read else {"error": str(e)}
    except Exception as e:
        return 0, {"error": str(e)}


# ── Config / persistence ──────────────────────────────────────────────────

def _task_dir(task):
    d = CONFIG_ROOT / task
    d.mkdir(parents=True, exist_ok=True)
    return d

def _token_path(task):
    return _task_dir(task) / "token.json"

def _load_token(task):
    p = _token_path(task)
    if not p.exists():
        die(f"no token for task '{task}'. Run: gts-webchat register --task {task}")
    return json.loads(p.read_text())

def _save_token(task, data):
    _token_path(task).write_text(json.dumps(data, indent=2))
    _token_path(task).chmod(0o600)


# ── output ───────────────────────────────────────────────────────────────

def die(msg, code=1):
    print(f"gts-webchat: {msg}", file=sys.stderr)
    sys.exit(code)

def out(msg):
    print(msg)


# ── subcommands ───────────────────────────────────────────────────────────

def cmd_register(args):
    email = args.email or input(f"email (e.g. avikad76+claude-{args.task}@gmail.com): ").strip()
    if not email:
        die("email required")
    code, resp = _http("POST", "/api/v1/pchat/auth/request-code",
                       {"email": email, "purpose": "login"})
    if code != 200 or not resp.get("success"):
        die(f"request-code failed: HTTP {code} resp={resp}")
    out(f"OK — 6-digit code sent to base inbox (expires in {resp.get('expires_in_sec', '?')}s).")
    out(f"Next: gts-webchat verify --task {args.task} --code <6digits>")
    # Stash partial token state so verify can find email+peer
    partial = {"email": email}
    if args.peer:
        partial["peer_address"] = args.peer
    if args.display_name:
        partial["display_name"] = args.display_name
    _token_path(args.task).write_text(json.dumps(partial, indent=2))


def cmd_verify(args):
    if not _token_path(args.task).exists():
        die(f"no pending registration for task '{args.task}'. Run register first.")
    partial = json.loads(_token_path(args.task).read_text())
    email = partial.get("email")
    if not email:
        die("partial token missing email; re-register")
    display_name = partial.get("display_name") or f"Claude · {args.task}"
    code, resp = _http("POST", "/api/v1/pchat/auth/verify-code", {
        "email": email,
        "code": args.code,
        "display_name": display_name,
        "is_ai": True,
    })
    if code != 200 or not resp.get("success"):
        die(f"verify-code failed: HTTP {code} resp={resp}")
    tok = {
        "session_token": resp["session_token"],
        "chat_address": resp["chat_address"],
        "display_name": resp.get("display_name", display_name),
        "email": email,
        "expires_at": int(time.time()) + int(resp.get("expires_in_sec", 0)),
        # Visibility / AI flags from relay — used by `send` to fail fast
        # before hitting the wire when the relay would 403 us anyway.
        "is_ai": bool(resp.get("is_ai", False)),
        "public_visible": bool(resp.get("public_visible", False)),
        "ai_allow_public": bool(resp.get("ai_allow_public", False)),
    }
    # Auto-provisioned shared-memory identity (a separate chat_address owned by
    # the same root account). All AI personas under this email share it as a
    # common notes pool — accessed via `memory-* --shared`.
    if resp.get("shared_memory_chat_address"):
        tok["shared_memory_chat_address"] = resp["shared_memory_chat_address"]
    if resp.get("shared_memory_session_token"):
        tok["shared_memory_session_token"] = resp["shared_memory_session_token"]
    if partial.get("peer_address"):
        tok["peer_address"] = partial["peer_address"]
    _save_token(args.task, tok)
    out(f"verified. chat_address={tok['chat_address']}")
    out(f"token cached at {_token_path(args.task)} (mode 600)")
    out(f"flags: is_ai={tok['is_ai']} public_visible={tok['public_visible']} ai_allow_public={tok['ai_allow_public']}")
    # Surface shared-memory provisioning explicitly — strict AIs (and the
    # Connect-AI prompt) check for `has_shared_memory: true` in stdout to
    # confirm Option B is live. Without these lines they false-report a
    # relay-deploy issue while the data is actually present in token.json.
    has_shared = bool(tok.get("shared_memory_chat_address") and tok.get("shared_memory_session_token"))
    out(f"has_shared_memory={str(has_shared).lower()}")
    if has_shared:
        out(f"shared_memory_chat_address={tok['shared_memory_chat_address']}")
    if tok["is_ai"] and tok["public_visible"]:
        out("WARNING: persona is AI + public_visible — relay will 403 all sends. Toggle to private to enable sending.")
    if not tok.get("peer_address"):
        out("WARNING: no peer_address. `send` will require --to.")


def _do_presence(token_data, visible=True):
    return _http("POST", "/api/v1/webchat/presence", {
        "chat_address": token_data["chat_address"],
        "display_name": token_data["display_name"],
        "visible": visible,
        "device_name": DEFAULT_DEVICE,
        "session_name": token_data.get("task", token_data["display_name"]),
    }, token=token_data["session_token"])

def cmd_presence(args):
    tok = _load_token(args.task)
    code, resp = _do_presence(tok, visible=not args.offline)
    out(f"HTTP {code} success={resp.get('success')}")


def cmd_update_profile(args):
    tok = _load_token(args.task)
    body = {}
    if args.display_name:
        body["display_name"] = args.display_name
    if not body:
        die("nothing to update — pass --display-name")
    code, resp = _http("PUT", "/api/v1/pchat/auth/me", body, token=tok["session_token"])
    if code != 200 or not resp.get("success"):
        die(f"HTTP {code}: {resp}")
    if args.display_name:
        tok["display_name"] = resp.get("display_name") or args.display_name
        _save_token(args.task, tok)
    out(f"OK — display_name={tok['display_name']}")


def _sha256_file(path):
    """Return hex SHA-256 of a file, streaming so it works on big files too."""
    import hashlib
    h = hashlib.sha256()
    with open(path, "rb") as fh:
        for chunk in iter(lambda: fh.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()


def _multipart_post(url, token, file_path, field_name="file"):
    """Build a multipart/form-data POST with stdlib only. Returns (http_code, json_resp)."""
    import mimetypes
    import urllib.request
    import urllib.error

    boundary = f"----gtswebchat{os.urandom(16).hex()}"
    filename = os.path.basename(file_path)
    mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"

    with open(file_path, "rb") as fh:
        file_bytes = fh.read()

    body = (
        f"--{boundary}\r\n"
        f'Content-Disposition: form-data; name="{field_name}"; filename="{filename}"\r\n'
        f"Content-Type: {mime}\r\n\r\n"
    ).encode() + file_bytes + f"\r\n--{boundary}--\r\n".encode()

    headers = {
        "Content-Type": f"multipart/form-data; boundary={boundary}",
        "Content-Length": str(len(body)),
        "X-PChat-Session": token,
    }
    req = urllib.request.Request(url, data=body, method="POST", headers=headers)
    try:
        with urllib.request.urlopen(req, timeout=120) as resp:
            return resp.status, json.loads(resp.read().decode())
    except urllib.error.HTTPError as e:
        return e.code, json.loads(e.read().decode()) if e.fp else {"error": str(e)}
    except Exception as e:
        return 0, {"error": str(e)}


def cmd_send_file(args):
    tok = _load_token(args.task)
    if tok.get("is_ai") and tok.get("public_visible") and not args.force:
        die("persona is AI + public_visible — relay blocks sends. Use --force to bypass.")
    peer = args.to or tok.get("peer_address") or os.environ.get("GTS_WEBCHAT_PEER")
    if not peer:
        die("--to required (no peer_address in token, no GTS_WEBCHAT_PEER env)")
    if not os.path.isfile(args.path):
        die(f"file not found: {args.path}")
    size = os.path.getsize(args.path)
    if size == 0:
        die("file is empty")

    out(f"[1/3] sha256 of {args.path} ({size:,} bytes)...")
    sha = _sha256_file(args.path)
    out(f"      sha256={sha}")

    out(f"[2/3] uploading to relay...")
    upload_url = f"{RELAY}/api/v1/webchat/upload"
    code, resp = _multipart_post(upload_url, tok["session_token"], args.path)
    if code not in (200, 201) or not resp.get("success"):
        die(f"upload failed: HTTP {code} resp={resp}")
    file_id = resp["file_id"]
    file_token = resp["file_token"]
    file_url = resp.get("url") or f"/api/v1/webchat/dl?id={file_id}&token={file_token}"
    out(f"      uploaded: file_id={file_id} size={resp['size']:,} mime={resp.get('content_type')}")

    out(f"[3/3] sending carrier message to {peer}...")
    name = os.path.basename(args.path)
    text = (args.text or
            f"\U0001f4ce {name} ({size:,} bytes)\n"
            f"Get with: gts-webchat get-file --task {args.task} {file_id} --token {file_token} --sha256 {sha}")
    body = {
        "phone_id": PHONE_ID,
        "channel": "pchat",
        "to": peer,
        "source": tok["chat_address"],
        "source_session_id": args.task,
        "target_session_id": "",
        "text": text,
        "attachment": {
            "file_id": file_id,
            "file_token": file_token,
            "name": name,
            "size": size,
            "mime": resp.get("content_type", "application/octet-stream"),
            "sha256": sha,
            "url": file_url,
        },
        "idempotency_key": f"msg-file-{int(time.time())}-{os.urandom(4).hex()}",
    }
    mcode, mresp = _http("POST", "/api/v1/messages", body, token=tok["session_token"])
    if mcode not in (200, 202) or not mresp.get("success"):
        die(f"carrier message failed: HTTP {mcode} resp={mresp}")
    out(f"OK. attachment sent (carrier msg_id={mresp.get('message_id')}).")


def cmd_get_file(args):
    import urllib.request
    import urllib.error
    tok = _load_token(args.task)
    # Build URL — prefer the /dl?id=...&token=... form which is nginx-safe.
    if args.token:
        url = f"{RELAY}/api/v1/webchat/dl?id={args.file_id}&token={args.token}"
    else:
        url = f"{RELAY}/api/v1/webchat/files/{args.file_id}"

    out_path = args.output or os.path.basename(args.file_id) or args.file_id
    if os.path.isdir(out_path):
        out_path = os.path.join(out_path, args.file_id)

    out(f"[1/3] downloading {args.file_id} → {out_path}...")
    req = urllib.request.Request(url, method="GET",
                                 headers={"X-PChat-Session": tok["session_token"]})
    try:
        with urllib.request.urlopen(req, timeout=120) as resp:
            total = 0
            with open(out_path, "wb") as fh:
                while True:
                    chunk = resp.read(65536)
                    if not chunk:
                        break
                    fh.write(chunk)
                    total += len(chunk)
            out(f"      wrote {total:,} bytes")
    except urllib.error.HTTPError as e:
        die(f"download failed: HTTP {e.code} {e.read().decode()[:200]}")
    except Exception as e:
        die(f"download error: {e}")

    if args.sha256:
        out(f"[2/3] verifying sha256...")
        got = _sha256_file(out_path)
        if got != args.sha256:
            os.unlink(out_path)  # corrupt — don't leave it on disk
            die(f"sha256 mismatch (deleted corrupt file)\n"
                f"  expected: {args.sha256}\n"
                f"  got:      {got}")
        out(f"      OK ({got})")
    else:
        out(f"[2/3] no sha256 provided, skipping verify")

    out(f"[3/3] saved: {out_path}")


def cmd_send(args):
    tok = _load_token(args.task)
    # Client-side guardrail: AI persona toggled public → relay forces read-only.
    # Fail fast with a clear message instead of letting the user see HTTP 403.
    if tok.get("is_ai") and tok.get("public_visible") and not args.force:
        die("persona is AI + public_visible — relay blocks sends (403). "
            "Either toggle persona to private, or rerun with --force to attempt the send anyway.")
    peer = args.to or tok.get("peer_address") or os.environ.get("GTS_WEBCHAT_PEER")
    if not peer:
        die("--to required (no peer_address in token, no GTS_WEBCHAT_PEER env)")
    text = " ".join(args.text) if args.text else sys.stdin.read().strip()
    if not text:
        die("empty message")
    body = {
        "phone_id": PHONE_ID,
        "channel": "pchat",
        "to": peer,
        "source": tok["chat_address"],
        "source_session_id": args.task,
        "target_session_id": "",
        "text": text,
        "idempotency_key": f"msg-{int(time.time())}-{os.urandom(4).hex()}",
    }
    code, resp = _http("POST", "/api/v1/messages", body, token=tok["session_token"])
    if code not in (200, 202) or not resp.get("success"):
        die(f"send failed: HTTP {code} resp={resp}")
    out(f"sent. message_id={resp.get('message_id')} status={resp.get('status')}")


def cmd_poll(args):
    tok = _load_token(args.task)
    code, resp = _http("POST", "/api/v1/webchat/poll", {
        "chat_address": tok["chat_address"],
        "phone_id": PHONE_ID,
        "limit": 20,
    }, token=tok["session_token"])
    if code != 200:
        die(f"poll failed: HTTP {code} resp={resp}")
    msgs = resp.get("messages", [])
    out(f"received {len(msgs)} msg(s)")
    # Append to inbox
    inbox = _task_dir(args.task) / "inbox.jsonl"
    with inbox.open("a") as f:
        for m in msgs:
            f.write(json.dumps(m) + "\n")
    for m in msgs:
        out(f"  [{m.get('created_at','?')}] {m.get('sender_display_name','?')}: {m.get('text','')[:100]}")


def cmd_tail(args):
    inbox = _task_dir(args.task) / "inbox.jsonl"
    if not inbox.exists():
        out("(no messages yet)")
        return
    lines = inbox.read_text().splitlines()
    for line in lines[-args.n:]:
        try:
            m = json.loads(line)
            print(f"[{m.get('created_at','?')}] {m.get('sender_display_name','?')}: {m.get('text','')}")
        except Exception:
            print(line)
    if args.follow:
        proc = subprocess.Popen(["tail", "-F", str(inbox)])
        try:
            proc.wait()
        except KeyboardInterrupt:
            proc.terminate()


# ── Daemon ─────────────────────────────────────────────────────────────────

def _daemon_loop(task, on_message=None):
    """The polling loop. Runs in foreground in the spawned subprocess.

    on_message: optional path to executable that gets the message JSON on stdin
                for each new message received.
    """
    tok_path = _token_path(task)
    tdir = _task_dir(task)
    pid_file = tdir / "daemon.pid"
    hb_file = tdir / "daemon.heartbeat"
    inbox = tdir / "inbox.jsonl"
    log = tdir / "daemon.log"
    seen_file = tdir / "seen_ids.txt"

    pid_file.write_text(str(os.getpid()))
    inbox.touch()
    seen_file.touch()
    log_fh = log.open("a", buffering=1)

    # Load already-seen message IDs (for dedup across daemon restarts).
    seen = set()
    try:
        with seen_file.open() as f:
            for line in f:
                line = line.strip()
                if line:
                    seen.add(line)
    except Exception:
        pass
    log_fh.write(f"[{time.strftime('%H:%M:%S')}] started; seen={len(seen)} on_message={on_message or '(none)'}\n")

    def shutdown(signum, _frame):
        log_fh.write(f"[{time.strftime('%H:%M:%S')}] SIGNAL {signum}, shutting down\n")
        try:
            tok = json.loads(tok_path.read_text())
            _do_presence({**tok, "task": task}, visible=False)
        except Exception:
            pass
        try:
            pid_file.unlink()
        except Exception:
            pass
        log_fh.close()
        sys.exit(0)

    signal.signal(signal.SIGTERM, shutdown)
    signal.signal(signal.SIGINT, shutdown)

    last_presence = 0
    while True:
        try:
            tok = json.loads(tok_path.read_text())
            tok_with_task = {**tok, "task": task}
            now = time.time()
            if now - last_presence >= PRESENCE_INTERVAL_SEC:
                _do_presence(tok_with_task, visible=True)
                last_presence = now
            code, resp = _http("POST", "/api/v1/webchat/poll", {
                "chat_address": tok["chat_address"],
                "phone_id": PHONE_ID,
                "limit": 20,
            }, token=tok["session_token"], timeout=10)
            hb_file.write_text(str(int(time.time())))
            if code == 200:
                msgs = resp.get("messages", [])
                if msgs:
                    new_msgs = []
                    with inbox.open("a") as f, seen_file.open("a") as sf:
                        for m in msgs:
                            mid = m.get("message_id")
                            if mid and mid in seen:
                                continue  # dedup across daemon restarts
                            f.write(json.dumps(m) + "\n")
                            if mid:
                                seen.add(mid)
                                sf.write(mid + "\n")
                            new_msgs.append(m)
                    if new_msgs:
                        log_fh.write(f"[{time.strftime('%H:%M:%S')}] received {len(new_msgs)} new msg(s) ({len(msgs)-len(new_msgs)} dups skipped)\n")
                        # Fire on_message callback for each new message.
                        if on_message:
                            for m in new_msgs:
                                try:
                                    proc = subprocess.Popen(
                                        [on_message],
                                        stdin=subprocess.PIPE,
                                        stdout=subprocess.PIPE,
                                        stderr=subprocess.PIPE,
                                    )
                                    proc.communicate(input=json.dumps(m).encode(), timeout=30)
                                    log_fh.write(f"[{time.strftime('%H:%M:%S')}] on_message fired for {m.get('message_id','?')} rc={proc.returncode}\n")
                                except Exception as cb_exc:
                                    log_fh.write(f"[{time.strftime('%H:%M:%S')}] on_message error: {cb_exc}\n")
            elif code == 429:
                log_fh.write(f"[{time.strftime('%H:%M:%S')}] 429 rate limit, backing off 10s\n")
                time.sleep(10)
                continue
            else:
                log_fh.write(f"[{time.strftime('%H:%M:%S')}] poll HTTP {code} {resp}\n")
        except Exception as e:
            log_fh.write(f"[{time.strftime('%H:%M:%S')}] error: {e}\n")
        time.sleep(POLL_INTERVAL_SEC)


def cmd_daemon(args):
    tdir = _task_dir(args.task)
    pid_file = tdir / "daemon.pid"

    if args.action == "start":
        # Reap stale pid
        if pid_file.exists():
            try:
                old_pid = int(pid_file.read_text().strip())
                os.kill(old_pid, 0)
                die(f"daemon already running pid={old_pid}")
            except (ProcessLookupError, ValueError):
                pid_file.unlink()
        # Validate on_message script if provided
        if args.on_message:
            if not os.path.isfile(args.on_message):
                die(f"--on-message script not found: {args.on_message}")
            if not os.access(args.on_message, os.X_OK):
                die(f"--on-message script not executable: {args.on_message}")
        # Spawn detached
        log = tdir / "daemon.stdout.log"
        cmd = [sys.executable, __file__, "_daemon-loop", "--task", args.task]
        if args.on_message:
            cmd += ["--on-message", args.on_message]
        with open(log, "a") as logf:
            proc = subprocess.Popen(
                cmd,
                stdout=logf, stderr=logf, stdin=subprocess.DEVNULL,
                start_new_session=True,
            )
        time.sleep(0.5)
        out(f"daemon started pid={proc.pid}, polling every {POLL_INTERVAL_SEC}s")
        if args.on_message:
            out(f"on_message hook: {args.on_message}")
        out(f"logs: {tdir / 'daemon.log'}")
        out(f"inbox: {tdir / 'inbox.jsonl'}")

    elif args.action == "stop":
        if not pid_file.exists():
            die("daemon not running (no pid file)")
        pid = int(pid_file.read_text().strip())
        try:
            os.kill(pid, signal.SIGTERM)
            for _ in range(20):
                time.sleep(0.5)
                try:
                    os.kill(pid, 0)
                except ProcessLookupError:
                    out(f"daemon stopped (pid {pid})")
                    return
            os.kill(pid, signal.SIGKILL)
            out(f"daemon force-killed (pid {pid})")
        except ProcessLookupError:
            pid_file.unlink()
            out("daemon already gone, removed stale pidfile")

    elif args.action == "status":
        if not pid_file.exists():
            out("not running")
            return
        pid = int(pid_file.read_text().strip())
        try:
            os.kill(pid, 0)
            hb = tdir / "daemon.heartbeat"
            hb_age = int(time.time()) - int(hb.read_text().strip()) if hb.exists() else "?"
            inbox = tdir / "inbox.jsonl"
            count = sum(1 for _ in inbox.open()) if inbox.exists() else 0
            out(f"running pid={pid} heartbeat_age={hb_age}s inbox_count={count}")
        except ProcessLookupError:
            out(f"STALE: pid file says {pid} but process is dead")


_WEBCHAT_WAKE_SCRIPT = r"""#!/bin/bash
# webchat-wake.sh — Claude Code Stop-hook script (auto-installed by gts-webchat bootstrap-hook).
# Two phases: (1) catch-up — emit any messages that arrived between Stop events; (2) watch —
# wait up to MAX_WAIT for new arrivals. Exit 2 wakes the session, exit 0 = nothing new.
# Uses a persisted cursor (.wake_cursor) so messages can never be silently dropped between turns.
TASK="${WEBCHAT_TASK:-${1:-}}"
if [ -z "$TASK" ]; then
    TASKS=$(ls -d "$HOME/.config/gts-webchat/"*/ 2>/dev/null | xargs -n1 basename 2>/dev/null)
    COUNT=$(echo "$TASKS" | grep -c .)
    if [ "$COUNT" -eq 1 ]; then
        TASK="$TASKS"
    else
        exit 0
    fi
fi

INBOX="$HOME/.config/gts-webchat/$TASK/inbox.jsonl"
CURSOR="$HOME/.config/gts-webchat/$TASK/.wake_cursor"
MAX_WAIT="${WEBCHAT_WAKE_MAX_WAIT:-600}"
POLL_INTERVAL=4

[ -f "$INBOX" ] || exit 0

emit_and_wake() {
    local from="$1" to="$2"
    local n=$((to - from))
    echo "📨 ${n} new WebChat message(s) since last turn:"
    tail -n "$n" "$INBOX" | python3 -c "
import sys, json
for line in sys.stdin:
    try:
        m = json.loads(line.strip())
        print(f\"  [{m.get('created_at','?')}] {m.get('sender_display_name','?')}: {m.get('text','')}\")
    except Exception: pass"
    echo
    echo "Reply via: gts-webchat send --task $TASK '<reply>'"
    echo "$to" > "$CURSOR"
    exit 2
}

LAST_READ=$(cat "$CURSOR" 2>/dev/null || echo 0)
CURRENT=$(wc -l < "$INBOX" 2>/dev/null || echo 0)

# Phase 1 — catch up on anything that arrived while no watcher was active.
if [ "$CURRENT" -gt "$LAST_READ" ]; then
    emit_and_wake "$LAST_READ" "$CURRENT"
fi

# Phase 2 — watch for new arrivals (push mode while user is idle).
WAITED=0
while [ "$WAITED" -lt "$MAX_WAIT" ]; do
    sleep "$POLL_INTERVAL"
    WAITED=$((WAITED + POLL_INTERVAL))
    CURRENT=$(wc -l < "$INBOX" 2>/dev/null || echo 0)
    if [ "$CURRENT" -gt "$LAST_READ" ]; then
        emit_and_wake "$LAST_READ" "$CURRENT"
    fi
done
exit 0
"""


def cmd_bootstrap_hook(args):
    """Install everything needed for auto-wake in Claude Code:
    - drop ~/.local/bin/webchat-wake.sh (executable)
    - merge Bash(gts-webchat *) allow rule into .claude/settings.local.json
    - merge Stop hook with asyncRewake into the same file
    - start the daemon if not running
    Prints next steps (run /hooks once to register).
    """
    import os, json
    # 1. Write the wake script
    wake_dir = Path.home() / ".local" / "bin"
    wake_dir.mkdir(parents=True, exist_ok=True)
    wake_path = wake_dir / "webchat-wake.sh"
    wake_path.write_text(_WEBCHAT_WAKE_SCRIPT)
    wake_path.chmod(0o755)
    out(f"[1/4] wrote {wake_path}")

    # 2. Locate .claude/settings.local.json (project-local). Create if missing.
    project_root = Path.cwd()
    while project_root != project_root.parent:
        if (project_root / ".claude").is_dir():
            break
        project_root = project_root.parent
    else:
        project_root = Path.cwd()
    claude_dir = project_root / ".claude"
    claude_dir.mkdir(exist_ok=True)
    settings = claude_dir / "settings.local.json"

    if settings.exists():
        cfg = json.loads(settings.read_text())
    else:
        cfg = {}

    # 3. Merge allow rule
    perms = cfg.setdefault("permissions", {})
    allow = perms.setdefault("allow", [])
    added_rules = []
    for r in ["Bash(gts-webchat *)"]:
        if r not in allow:
            allow.append(r)
            added_rules.append(r)
    if added_rules:
        out(f"[2/4] added allow rule(s) to {settings}: {added_rules}")
    else:
        out(f"[2/4] allow rule already present in {settings}")

    # 4. Merge Stop hook (only if no webchat-wake hook present yet)
    hooks = cfg.setdefault("hooks", {})
    stop_hooks = hooks.setdefault("Stop", [])
    have_wake = any(
        any(h.get("command", "").endswith("webchat-wake.sh") for h in entry.get("hooks", []))
        for entry in stop_hooks
    )
    if not have_wake:
        stop_hooks.append({
            "hooks": [{
                "type": "command",
                "command": str(wake_path),
                "asyncRewake": True,
                "timeout": 600,
                "rewakeSummary": "WebChat inbound",
            }]
        })
        out(f"[3/4] added Stop hook with asyncRewake → {wake_path}")
    else:
        out(f"[3/4] Stop hook already present (no change)")

    settings.write_text(json.dumps(cfg, indent=2) + "\n")

    # 5. Start daemon if not running
    pid_file = _task_dir(args.task) / "daemon.pid"
    daemon_running = False
    if pid_file.exists():
        try:
            os.kill(int(pid_file.read_text().strip()), 0)
            daemon_running = True
        except (ProcessLookupError, ValueError):
            pid_file.unlink()
    if daemon_running:
        out(f"[4/4] daemon already running for task '{args.task}'")
    else:
        # Reuse the daemon-start machinery
        class _A: pass
        a = _A()
        a.action = "start"
        a.task = args.task
        a.on_message = None
        cmd_daemon(a)

    out("")
    out("✓ Bootstrap complete. Next steps:")
    out("  1. In your Claude Code terminal, type `/hooks` once (any key closes the menu).")
    out("     This reloads the hook watcher so the new Stop hook is registered for THIS session.")
    out("  2. Test: send a webchat message to your chat_address — Claude should auto-wake.")
    out("")
    out("If the wake doesn't fire on first test, restart the Claude Code session — the hook")
    out("watcher will then register on session start automatically.")


# ── Memory subcommands (v0.2) ─────────────────────────────────────────
# Memory entries are messages sent from user's chat_address to itself,
# carrying a structured `memory` field in the body text (since the relay
# only forwards text fields, we JSON-encode + prefix the memory metadata
# inside the text payload).
#
# Wire format (in the `text` field of /api/v1/messages):
#   📝MEM:v1:<json>
# where <json> is {key, value, tags?, written_by?, ts?}
# This format is human-readable enough for the Saved Messages UI to render
# OR filter out, and is unambiguous for the memory_read parser.

MEMORY_PREFIX = "📝MEM:v1:"


def _shared_creds(task):
    """Return (chat_address, session_token, source_session_id) for this account's
    auto-provisioned shared-memory identity, or None if the token doesn't yet
    carry shared creds (older relay or pre-Option-B registration)."""
    tok = _load_token(task)
    addr = (tok.get("shared_memory_chat_address") or "").strip()
    token = (tok.get("shared_memory_session_token") or "").strip()
    if not addr or not token:
        return None
    return (addr, token, f"{task}:shared")


def _fetch_shared_inbox(task, limit=200):
    """Poll the shared-memory inbox synchronously and return the message list.
    Used by --shared memory reads since the daemon only mirrors the persona's
    own inbox, not the shared pool."""
    sc = _shared_creds(task)
    if not sc:
        return []
    addr, token, src_sess = sc
    code, resp = _http("POST", "/api/v1/webchat/poll", {
        "phone_id": PHONE_ID,
        "chat_address": addr,
        "session_client_id": src_sess,
        "limit": int(limit),
    }, token=token)
    if code != 200 or not resp.get("success"):
        return []
    return resp.get("messages", []) or []


def _filter_memory_from_messages(messages, owner_addr, filter_key=None, filter_tag=None, query=None, limit=None):
    """Extract memory entries from a raw message list (same logic as the inbox.jsonl
    path, but operating on in-memory dicts so callers can supply either source)."""
    entries = []
    for m in messages:
        mem = _parse_memory_entry(m.get("text", ""))
        if not mem:
            continue
        # Memory writes have source == owner of the chat_address being read.
        if m.get("source") != owner_addr:
            continue
        mem.setdefault("ts", m.get("created_at"))
        mem.setdefault("message_id", m.get("message_id"))
        if filter_key and mem.get("key") != filter_key:
            continue
        if filter_tag:
            tags = mem.get("tags") or []
            if filter_tag not in tags:
                continue
        if query:
            hay = (mem.get("key", "") + " " + mem.get("value", "") + " " +
                   " ".join(mem.get("tags") or [])).lower()
            if query.lower() not in hay:
                continue
        entries.append(mem)
    entries.reverse()
    if limit:
        entries = entries[:limit]
    return entries


def _parse_memory_entry(text):
    """Return parsed memory dict if `text` is a memory envelope, else None."""
    if not text or not text.startswith(MEMORY_PREFIX):
        return None
    try:
        return json.loads(text[len(MEMORY_PREFIX):])
    except Exception:
        return None


def _gather_memory_entries(task, filter_key=None, filter_tag=None, query=None, limit=None):
    """Read inbox.jsonl, extract memory entries, optionally filter."""
    inbox = _task_dir(task) / "inbox.jsonl"
    if not inbox.exists():
        return []
    entries = []
    with inbox.open() as fh:
        for line in fh:
            line = line.strip()
            if not line:
                continue
            try:
                m = json.loads(line)
            except Exception:
                continue
            mem = _parse_memory_entry(m.get("text", ""))
            if not mem:
                continue
            # Only include memory entries SENT BY US (to == own chat_address
            # AND source == our chat_address). This filters out anyone else's
            # messages that happen to start with the prefix.
            tok = _load_token(task)
            if m.get("source") != tok.get("chat_address"):
                continue
            mem.setdefault("ts", m.get("created_at"))
            mem.setdefault("message_id", m.get("message_id"))
            # Apply filters
            if filter_key and mem.get("key") != filter_key:
                continue
            if filter_tag:
                tags = mem.get("tags") or []
                if filter_tag not in tags:
                    continue
            if query:
                hay = (mem.get("key", "") + " " + mem.get("value", "") + " " +
                       " ".join(mem.get("tags") or [])).lower()
                if query.lower() not in hay:
                    continue
            entries.append(mem)
    # Latest-first
    entries.reverse()
    if limit:
        entries = entries[:limit]
    return entries


def cmd_memory_write(args):
    tok = _load_token(args.task)
    # When --shared, swap to the auto-provisioned shared-memory identity so
    # the write lands in the cross-persona pool (visible to every AI of mine
    # plus me, via the WebChat UI's Shared Memory thread).
    if getattr(args, "shared", False):
        sc = _shared_creds(args.task)
        if not sc:
            tok_email = (_load_token(args.task) or {}).get("email", "<your-email>")
            die(
                "no shared-memory creds in token.json — your token predates the shared-memory "
                "auto-provision feature. Refresh it (no email-flow needed if relay deploy >= 2026-05-22 "
                "and token is still valid; otherwise re-register):\n"
                f"  gts-webchat register --task {args.task} --email {tok_email}\n"
                f"  # ask the human for the emailed 6-digit code, then:\n"
                f"  gts-webchat verify   --task {args.task} --code <6digits>\n"
                "After verify, token.json will contain shared_memory_chat_address + "
                "shared_memory_session_token and --shared will work."
            )
        chat_addr, sess_token, src_sess = sc
    else:
        chat_addr = tok["chat_address"]
        sess_token = tok["session_token"]
        src_sess = args.task
    # --delete writes a tombstone; empty value is fine, skip stdin read.
    if args.delete:
        value = ""
    elif args.value is None or args.value == "":
        # Read from stdin (only when not piped from interactive terminal)
        if sys.stdin.isatty():
            die("memory value is empty (pass --value or pipe via stdin)")
        value = sys.stdin.read().strip()
        if not value:
            die("memory value is empty (pass --value or pipe via stdin)")
    else:
        value = args.value
    tags = [t.strip() for t in (args.tags or "").split(",") if t.strip()] if args.tags else []
    entry = {
        "key": args.key,
        "value": value,
        "tags": tags,
        "written_by": args.task,
        "ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
    }
    if args.delete:
        entry["deleted"] = True
        entry["value"] = ""
    payload_text = MEMORY_PREFIX + json.dumps(entry, separators=(",", ":"))
    body = {
        "phone_id": PHONE_ID,
        "channel": "pchat",
        "to": chat_addr,
        "source": chat_addr,
        "source_session_id": src_sess,
        "target_session_id": "",
        "text": payload_text,
        "idempotency_key": f"mem-{int(time.time())}-{os.urandom(4).hex()}",
    }
    code, resp = _http("POST", "/api/v1/messages", body, token=sess_token)
    if code not in (200, 202) or not resp.get("success"):
        die(f"memory write failed: HTTP {code} resp={resp}")
    pool = "shared" if getattr(args, "shared", False) else "persona"
    out(f"OK ({pool}). key={args.key} message_id={resp.get('message_id')}")


def cmd_memory_read(args):
    if getattr(args, "shared", False):
        sc = _shared_creds(args.task)
        if not sc:
            tok_email = (_load_token(args.task) or {}).get("email", "<your-email>")
            die(
                "no shared-memory creds in token.json — your token predates the shared-memory "
                "auto-provision feature. Refresh it (no email-flow needed if relay deploy >= 2026-05-22 "
                "and token is still valid; otherwise re-register):\n"
                f"  gts-webchat register --task {args.task} --email {tok_email}\n"
                f"  # ask the human for the emailed 6-digit code, then:\n"
                f"  gts-webchat verify   --task {args.task} --code <6digits>\n"
                "After verify, token.json will contain shared_memory_chat_address + "
                "shared_memory_session_token and --shared will work."
            )
        chat_addr, _, _ = sc
        msgs = _fetch_shared_inbox(args.task, limit=max(200, args.limit or 200))
        entries = _filter_memory_from_messages(msgs, chat_addr,
                                               filter_key=args.key,
                                               filter_tag=args.tag,
                                               limit=args.limit)
    else:
        entries = _gather_memory_entries(args.task, filter_key=args.key,
                                         filter_tag=args.tag, limit=args.limit)
    if args.json:
        out(json.dumps(entries, indent=2))
        return
    if not entries:
        out("(no memory entries match)")
        return
    for e in entries:
        deleted = " [DELETED]" if e.get("deleted") else ""
        tags = ",".join(e.get("tags") or [])
        tags_str = f" #{tags}" if tags else ""
        out(f"[{e.get('ts','?')}] {e.get('key','?')}{deleted}{tags_str}")
        if e.get("value"):
            for ln in e["value"].splitlines():
                out(f"    {ln}")


def cmd_memory_list(args):
    args.key = None
    args.tag = args.tag
    args.json = args.json
    args.limit = args.limit
    cmd_memory_read(args)


def cmd_memory_search(args):
    if getattr(args, "shared", False):
        sc = _shared_creds(args.task)
        if not sc:
            tok_email = (_load_token(args.task) or {}).get("email", "<your-email>")
            die(
                "no shared-memory creds in token.json — your token predates the shared-memory "
                "auto-provision feature. Refresh it (no email-flow needed if relay deploy >= 2026-05-22 "
                "and token is still valid; otherwise re-register):\n"
                f"  gts-webchat register --task {args.task} --email {tok_email}\n"
                f"  # ask the human for the emailed 6-digit code, then:\n"
                f"  gts-webchat verify   --task {args.task} --code <6digits>\n"
                "After verify, token.json will contain shared_memory_chat_address + "
                "shared_memory_session_token and --shared will work."
            )
        chat_addr, _, _ = sc
        msgs = _fetch_shared_inbox(args.task, limit=max(200, args.limit or 200))
        entries = _filter_memory_from_messages(msgs, chat_addr,
                                               query=args.query, limit=args.limit)
    else:
        entries = _gather_memory_entries(args.task, query=args.query, limit=args.limit)
    if args.json:
        out(json.dumps(entries, indent=2))
        return
    if not entries:
        out(f"(no memory entries matched '{args.query}')")
        return
    for e in entries:
        deleted = " [DELETED]" if e.get("deleted") else ""
        tags = ",".join(e.get("tags") or [])
        tags_str = f" #{tags}" if tags else ""
        out(f"[{e.get('ts','?')}] {e.get('key','?')}{deleted}{tags_str}")
        if e.get("value"):
            for ln in e["value"].splitlines():
                out(f"    {ln}")


def cmd_memory_forget(args):
    """Tombstone an entry by writing a deleted=True record with the same key."""
    args.value = ""
    args.delete = True
    args.tags = None
    cmd_memory_write(args)


def cmd_goodbye(args):
    """Graceful shutdown: send 'going offline' message + presence=false + stop daemon."""
    try:
        tok = _load_token(args.task)
        peer = tok.get("peer_address") or os.environ.get("GTS_WEBCHAT_PEER")
        if peer:
            _http("POST", "/api/v1/messages", {
                "phone_id": PHONE_ID,
                "channel": "pchat",
                "to": peer,
                "source": tok["chat_address"],
                "source_session_id": args.task,
                "target_session_id": "",
                "text": f"🛑 Claude · {args.task} going offline.",
                "idempotency_key": f"msg-bye-{int(time.time())}-{os.urandom(4).hex()}",
            }, token=tok["session_token"])
        _do_presence({**tok, "task": args.task}, visible=False)
        out("goodbye sent")
    except SystemExit:
        raise
    except Exception as e:
        out(f"goodbye soft-fail: {e}")
    # Then stop daemon
    args.action = "stop"
    try:
        cmd_daemon(args)
    except SystemExit:
        pass


# ── arg parsing ───────────────────────────────────────────────────────────

def main():
    p = argparse.ArgumentParser(prog="gts-webchat", description=__doc__,
                                formatter_class=argparse.RawDescriptionHelpFormatter)
    sub = p.add_subparsers(dest="cmd", required=True)

    def add_task(parser):
        parser.add_argument("--task", required=True, help="persona task name")

    r = sub.add_parser("register", help="request 6-digit login code")
    add_task(r)
    r.add_argument("--email", help="login email (default: prompt)")
    r.add_argument("--peer", help="default peer chat_address (saved for `send`)")
    r.add_argument("--display-name", help="display name (default: 'Claude · <task>')")

    v = sub.add_parser("verify", help="verify 6-digit code, save token")
    add_task(v)
    v.add_argument("--code", required=True)

    pr = sub.add_parser("presence", help="ping presence (visible/offline)")
    add_task(pr)
    pr.add_argument("--offline", action="store_true")

    up = sub.add_parser("update-profile", help="update display_name without re-registering (no email code needed)")
    add_task(up)
    up.add_argument("--display-name", help="new display name, e.g. \"Avi Kadosh's AI — Codex — agent\"")

    s = sub.add_parser("send", help="send a message")
    add_task(s)
    s.add_argument("--to", help="peer chat_address (default: token's peer)")
    s.add_argument("--force", action="store_true",
                   help="bypass client-side visibility guardrail (AI-public block)")
    s.add_argument("text", nargs="*", help="message body; or stdin if empty")

    sf = sub.add_parser("send-file", help="upload a file + send carrier message with attachment metadata + sha256")
    add_task(sf)
    sf.add_argument("--to", help="peer chat_address (default: token's peer)")
    sf.add_argument("--text", help="custom message text (default: auto-generated with get-file command)")
    sf.add_argument("--force", action="store_true",
                    help="bypass client-side visibility guardrail (AI-public block)")
    sf.add_argument("path", help="local file path to upload")

    gf = sub.add_parser("get-file", help="download a file by id; optionally verify sha256")
    add_task(gf)
    gf.add_argument("file_id", help="file_id returned by sender (or from inbox attachment)")
    gf.add_argument("--token", help="file_token (required for /dl auth on most relay configs)")
    gf.add_argument("--sha256", help="expected sha256 hex; aborts + deletes corrupt file if mismatch")
    gf.add_argument("-o", "--output", help="local path to save to (default: file_id basename)")

    po = sub.add_parser("poll", help="one-shot poll (no daemon)")
    add_task(po)

    t = sub.add_parser("tail", help="show inbox messages")
    add_task(t)
    t.add_argument("-n", type=int, default=10, help="last N (default 10)")
    t.add_argument("-f", "--follow", action="store_true", help="follow new messages")

    d = sub.add_parser("daemon", help="manage polling daemon")
    add_task(d)
    d.add_argument("action", choices=["start", "stop", "status"])
    d.add_argument("--on-message", help="executable to run for each NEW message; gets message JSON on stdin")

    bye = sub.add_parser("goodbye", help="send going-offline + stop daemon (call from SessionEnd hook)")
    add_task(bye)

    bs = sub.add_parser("bootstrap-hook",
                        help="install Claude Code asyncRewake setup in one go (script + allow rule + Stop hook + daemon)")
    add_task(bs)

    # ── Memory subcommands (v0.2) ──────────────────────────────────────
    # --shared on any memory-* command routes to the auto-provisioned
    # cross-persona pool (the WebChat "Shared Memory" thread) instead of
    # this persona's own self-chat. Same primitive, different identity.
    SHARED_HELP = "use the cross-persona shared memory pool (visible to every AI of mine + me)"
    mw = sub.add_parser("memory-write",
                        help="write a memory entry (sent to self; persists across sessions)")
    add_task(mw)
    mw.add_argument("--key", required=True, help="memory key, e.g. 'customer-pref:avi'")
    mw.add_argument("--value", help="memory value text (or read from stdin if omitted)")
    mw.add_argument("--tags", help="comma-separated tags, e.g. 'customer,format-pref'")
    mw.add_argument("--delete", action="store_true",
                    help="write a tombstone entry (marks key as deleted)")
    mw.add_argument("--shared", action="store_true", help=SHARED_HELP)

    mr = sub.add_parser("memory-read",
                        help="read memory entries, optionally filtered by key or tag")
    add_task(mr)
    mr.add_argument("--key", help="filter by exact key")
    mr.add_argument("--tag", help="filter by tag")
    mr.add_argument("--limit", type=int, default=50, help="max entries to return (default 50)")
    mr.add_argument("--json", action="store_true", help="output as JSON instead of pretty text")
    mr.add_argument("--shared", action="store_true", help=SHARED_HELP)

    ml = sub.add_parser("memory-list", help="list recent memory entries (alias of memory-read)")
    add_task(ml)
    ml.add_argument("--tag", help="filter by tag")
    ml.add_argument("--limit", type=int, default=50, help="max entries (default 50)")
    ml.add_argument("--json", action="store_true")
    ml.add_argument("--shared", action="store_true", help=SHARED_HELP)

    ms = sub.add_parser("memory-search",
                        help="full-text search across memory entries (key + value + tags)")
    add_task(ms)
    ms.add_argument("query", help="text to search for (case-insensitive)")
    ms.add_argument("--limit", type=int, default=50, help="max matches (default 50)")
    ms.add_argument("--json", action="store_true")
    ms.add_argument("--shared", action="store_true", help=SHARED_HELP)

    mf = sub.add_parser("memory-forget",
                        help="tombstone a memory key (writes a deleted=True entry)")
    add_task(mf)
    mf.add_argument("--key", required=True, help="key to forget")
    mf.add_argument("--shared", action="store_true", help=SHARED_HELP)

    # internal entry point for daemon subprocess
    loop = sub.add_parser("_daemon-loop", help=argparse.SUPPRESS)
    add_task(loop)
    loop.add_argument("--on-message", default=None)

    args = p.parse_args()
    {
        "register": cmd_register,
        "verify": cmd_verify,
        "presence": cmd_presence,
        "update-profile": cmd_update_profile,
        "send": cmd_send,
        "send-file": cmd_send_file,
        "get-file": cmd_get_file,
        "poll": cmd_poll,
        "tail": cmd_tail,
        "daemon": cmd_daemon,
        "goodbye": cmd_goodbye,
        "bootstrap-hook": cmd_bootstrap_hook,
        "memory-write": cmd_memory_write,
        "memory-read": cmd_memory_read,
        "memory-list": cmd_memory_list,
        "memory-search": cmd_memory_search,
        "memory-forget": cmd_memory_forget,
        "_daemon-loop": lambda a: _daemon_loop(a.task, a.on_message),
    }[args.cmd](args)


if __name__ == "__main__":
    main()
