#!/usr/bin/env python3
"""
AgentsNet push-IPC hook for OpenClaw hosts (v0.1.9 Option D from ADR-035).

Parallels `deploy/hermes/agentsnet-push-ipc/hook.py` but delivers via the
OpenClaw gateway-native `openclaw message send` CLI primitive instead of
Hermes's in-process `send_message_tool`. Both hooks subscribe to the same
daemon SSE endpoint; the host-layer difference is only the delivery call
at the bottom of the stack.

Architecture:
  sender daemon → relay → recipient daemon (this host)
    → handle_incoming emits NewInboxMessage to PushEventBus
    → SSE `/v1/push/subscribe` broadcasts event
    → this hook receives, matches route via `/v1/push/routes`
    → this hook shells out to `openclaw message send --channel <X>
        --target <Y> --message "[AgentsNet/<contact>] <preview>"`
    → OpenClaw gateway → platform adapter → IM platform → user

P11 compliance: AgentsNet never calls the IM platform API directly. OpenClaw
owns the platform credentials and the adapter invocation. Same trust
boundary as the Hermes `send_message_tool` path.

Target hosts: OpenClaw 2026.3.x+ (confirmed on 2026.4.15 Alice Liuyi and
2026.4.21 openclaw-test local Docker). Relies on `openclaw message send`
being in PATH and `openclaw channels status` showing `healthy` for the
target channel. When the target channel is unhealthy, sends will fail
loud (status=error, logged) — same pattern as Hermes silent-success fix.

Configuration:
  AGENTSNET_OPENCLAW_EXEC_BIN    — path to `openclaw` executable
                                (default: looks up PATH)
  AGENTSNET_OPENCLAW_DOCKER_EXEC — if set to a container name, wraps every
                                call as `docker exec -u testuser
                                <container> sh -lc 'HOME=... openclaw ...'`.
                                Enables running this hook on the DOCKER
                                HOST (outside the container), talking to
                                the AgentsNet daemon's UDS via the same
                                bind-mount the container shares.
  AGENTSNET_OPENCLAW_DOCKER_USER — container user to su to (default: testuser)
  AGENTSNET_OPENCLAW_DOCKER_HOME — HOME to export (default:
                                /home/testuser)

Exit codes of `openclaw message send --json`:
  success → stdout is JSON with {ok: true, messageId: "...", ...}
  failure → stdout (and/or stderr) contains error; non-zero rc common

Per-contact dedup + preview-only delivery semantics match hook.py; the
bulk of the code (fetch_routes / pick_target_for_event / SSE loop) is
identical. This file keeps a separate copy so that OpenClaw-only hosts
don't need to install any Hermes assets.
"""
import http.client
import json
import logging
import os
import re
import shlex
import socket
import subprocess
import sys
import time
from pathlib import Path

# ── Configuration ────────────────────────────────────────────────
_OC_BIN = os.environ.get("AGENTSNET_OPENCLAW_EXEC_BIN", "openclaw")
_OC_DOCKER_EXEC = os.environ.get("AGENTSNET_OPENCLAW_DOCKER_EXEC", "").strip()
_OC_DOCKER_USER = os.environ.get("AGENTSNET_OPENCLAW_DOCKER_USER", "testuser")
_OC_DOCKER_HOME = os.environ.get("AGENTSNET_OPENCLAW_DOCKER_HOME", "/home/testuser")

LOG = logging.getLogger("agentsnet.push.openclaw")


# V0110-WIN-05: unified resolve_data_dir() helper. Same name + semantics
# across hook.js / hook-win.js / hook.py. Unix datadir lives at
# $HOME/.agentsnet; Windows hook-win.js uses %LOCALAPPDATA%\AgentsNet.
def resolve_data_dir() -> Path:
    return Path.home() / ".agentsnet"


DATA_DIR = resolve_data_dir()
IPC_SOCK = DATA_DIR / "ipc.sock"
IPC_TOKEN = DATA_DIR / "ipc-token"
SSE_PATH = "/v1/push/subscribe"
ROUTES_PATH = "/v1/push/routes"
# v0.1.12 PR3 G4 (Bucket 2.6): auto_register endpoint for the proof-code
# capture path. Hook posts {proof_code, host, platform, chat_id} when it
# regex-matches AN-PUSH-XXXXXX in an inbound message.
AUTO_REGISTER_PATH = "/v1/push/auto_register"
PROOF_CODE_RE = re.compile(r"AN-PUSH-[A-F0-9]{6}")

# Bump when wire-protocol / CLI-shape expectations change.
HOOK_VERSION = "v0.1.12-openclaw-p1"

RECONNECT_BACKOFF_SECONDS = [1, 2, 5, 10, 30]


# ── `openclaw message send` adapter ────────────────────────────────

def _build_openclaw_cmd(channel: str, target: str, message: str) -> list:
    """Build the argv for the send call.

    When AGENTSNET_OPENCLAW_DOCKER_EXEC is set, wrap the call as
    `docker exec -i -u <USER> <CONTAINER> sh -lc 'HOME=<HOME> openclaw
    message send --channel <C> --target <T> --message <M> --json'`.
    Otherwise invoke `openclaw` directly.
    """
    inner = [
        _OC_BIN, "message", "send",
        "--channel", channel,
        "--target", target,
        "--message", message,
        "--json",
    ]
    if not _OC_DOCKER_EXEC:
        return inner
    # Container-wrap: we rebuild the inner argv as a single shell-quoted
    # string so we can su+export HOME via sh -lc.
    inner_quoted = " ".join(shlex.quote(x) for x in inner)
    shell_cmd = f"HOME={shlex.quote(_OC_DOCKER_HOME)} {inner_quoted}"
    return [
        "docker", "exec", "-i", _OC_DOCKER_EXEC,
        "sh", "-lc",
        f"su {shlex.quote(_OC_DOCKER_USER)} -c {shlex.quote(shell_cmd)}",
    ]


def send_message(channel: str, target: str, text: str) -> dict:
    """Invoke `openclaw message send --json` and parse result.

    Returns a dict with one of:
      {"success": True, "platform": <channel>, "message_id": <id>, ...}
      {"error": "<str>"}  — non-zero rc or non-JSON output
    """
    cmd = _build_openclaw_cmd(channel, target, text)
    try:
        proc = subprocess.run(
            cmd, capture_output=True, text=True, timeout=30,
        )
    except subprocess.TimeoutExpired:
        return {"error": f"openclaw message send timeout after 30s"}
    except FileNotFoundError as exc:
        return {"error": f"openclaw binary not found: {exc}"}

    stdout = (proc.stdout or "").strip()
    stderr = (proc.stderr or "").strip()

    if proc.returncode != 0 and not stdout:
        return {"error": f"openclaw rc={proc.returncode}: {stderr[-400:]}"}

    # OpenClaw --json emits a plain JSON blob on success (possibly preceded
    # by plugin-loader warnings on stderr). Parse the last JSON object we
    # see on stdout.
    parsed = None
    # Fast path: whole stdout is JSON.
    try:
        parsed = json.loads(stdout)
    except json.JSONDecodeError:
        # Fallback: look for the last `{`-starting line.
        for line in reversed(stdout.splitlines()):
            line = line.strip()
            if line.startswith("{"):
                try:
                    parsed = json.loads(line)
                    break
                except json.JSONDecodeError:
                    continue

    if not isinstance(parsed, dict):
        # OpenClaw's text-mode success looks like:
        #   "✅ Sent via gateway (whatsapp). Message ID: 3EB07C8A..."
        # Accept it as a soft-success when --json output wasn't emitted.
        if stdout.startswith("✅") or "Message ID" in stdout:
            return {"success": True, "platform": channel, "raw": stdout[:200]}
        return {"error": f"openclaw returned non-json: {stdout[:400]}"}

    # OpenClaw 2026.4.x `message send --json` shape:
    #   {action, channel, dryRun, handledBy, payload:
    #     {channel, to, via, mediaUrl, result: {runId, messageId, channel, toJid}}}
    # Success is indicated by payload.result.messageId. There is no
    # top-level `ok` field. Accept dry-run + legacy ok/success too for
    # forward-compat.
    result_block = (parsed.get("payload") or {}).get("result") or {}
    message_id = (
        result_block.get("messageId")
        or parsed.get("messageId")
        or parsed.get("message_id")
    )
    is_dry_run = parsed.get("dryRun") is True
    if message_id or is_dry_run or parsed.get("ok") is True or parsed.get("success") is True:
        return {
            "success": True,
            "platform": channel,
            "message_id": message_id or ("dry-run" if is_dry_run else None),
            "raw": parsed,
        }
    return {"error": parsed.get("error") or f"openclaw non-ok: {parsed}"}


# ── IPC client (identical shape to hook.py) ──────────────────────

def load_token() -> str:
    try:
        return IPC_TOKEN.read_text().strip()
    except (OSError, IOError):
        return ""


def unix_http_connect(sock_path: Path) -> http.client.HTTPConnection:
    class UnixHTTPConnection(http.client.HTTPConnection):
        def connect(self):
            self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
            self.sock.connect(str(sock_path))

    return UnixHTTPConnection("localhost")


# ── v0.1.12 PR3 G4 (Bucket 2.6) — proof-code capture path ─────────

def post_auto_register(token: str, proof_code: str, platform: str,
                      chat_id: str) -> dict:
    """POST {proof_code, host, platform, chat_id} to the daemon.

    Returns the parsed JSON body or {"error": "..."}. Caller decides what
    user-facing reply to send based on the `status` field.
    """
    try:
        conn = unix_http_connect(IPC_SOCK)
        body = json.dumps({
            "proof_code": proof_code,
            "host": "openclaw",
            "platform": platform,
            "chat_id": chat_id,
        })
        conn.request(
            "POST", AUTO_REGISTER_PATH,
            body=body,
            headers={
                "Content-Type": "application/json",
                "Authorization": f"Bearer {token}",
                "User-Agent": f"agentsnet-openclaw-hook/{HOOK_VERSION}",
            },
        )
        resp = conn.getresponse()
        raw = resp.read().decode("utf-8", errors="replace")
        conn.close()
        if resp.status != 200:
            return {"error": f"daemon status={resp.status} body={raw[:200]}"}
        try:
            return json.loads(raw)
        except json.JSONDecodeError:
            return {"error": f"non-json: {raw[:200]}"}
    except Exception as exc:
        return {"error": f"transport: {exc}"}


def auto_register_from_inbound(platform: str, chat_id: str, body_text: str) -> None:
    """Inspect an inbound IM message body for `AN-PUSH-XXXXXX`.

    On match: POST to /v1/push/auto_register with the captured chat_id
    and reply via `openclaw message send` summarising the outcome.
    Returns None; runs best-effort and never raises.

    Caller integrates this into whatever inbound-event source the host
    surfaces. The function deliberately does NOT subscribe to inbound
    messages itself — that's host-specific (OpenClaw webhook /
    polling / etc.). Keeping this helper isolated lets us drop in the
    capture source without touching the SSE consumer below.
    """
    match = PROOF_CODE_RE.search(body_text or "")
    if not match:
        return
    code = match.group(0)
    LOG.info("proof-code captured: %s on %s:%s", code, platform, chat_id)
    token = load_token()
    if not token:
        LOG.warning("auto_register skipped: no IPC token")
        return
    result = post_auto_register(token, code, platform, chat_id)
    status = (result.get("status") or "").strip()
    if status == "registered":
        reply = "AgentsNet push is configured. New messages will be delivered to this chat."
    elif status == "already_registered":
        reply = "AgentsNet push is already configured for this chat."
    elif status == "expired_proof":
        reply = "AgentsNet push setup failed: verification code expired. Generate a fresh code and try again."
    elif status == "invalid_proof":
        reply = "AgentsNet push setup failed: verification code is invalid or already used. Generate a fresh code and try again."
    else:
        reply = f"AgentsNet push setup failed: {result.get('error') or status or 'unknown'}"
    try:
        send_message(platform, chat_id, reply)
    except Exception:
        LOG.exception("auto_register reply send failed")


def fetch_routes(token: str) -> list:
    try:
        conn = unix_http_connect(IPC_SOCK)
        conn.request(
            "GET",
            ROUTES_PATH,
            headers={
                "Accept": "application/json",
                "Authorization": f"Bearer {token}",
                "User-Agent": f"agentsnet-openclaw-hook/{HOOK_VERSION}",
            },
        )
        resp = conn.getresponse()
        body = resp.read().decode("utf-8", errors="replace")
        conn.close()
        if resp.status != 200:
            LOG.warning("fetch_routes status=%d body=%s", resp.status, body[:200])
            return []
        data = json.loads(body)
        routes = data.get("routes", [])
        LOG.info("fetch_routes: loaded %d active route(s)", len(routes))
        return routes
    except Exception:
        LOG.exception("fetch_routes failed")
        return []


def pick_target_for_event(routes: list, contact_id: str, milestone: bool):
    """Scope-match a NewInboxMessage event against active routes.

    Returns `(platform, target)` tuple or `None` when no route applies.
    Mirrors hook.py's scope semantics.
    """
    for r in routes:
        if r.get("host") != "openclaw":
            continue
        platform = r.get("platform")
        target = r.get("chat_id")
        if not platform or not target:
            continue
        scope = r.get("scope", "all")
        # scope can be "all" | "milestones_only" | JSON array of contact_ids
        if scope == "all":
            return platform, target
        if scope == "milestones_only":
            if milestone:
                return platform, target
            continue
        # Try parsing scope as JSON array
        try:
            scope_list = json.loads(scope) if isinstance(scope, str) else scope
            if isinstance(scope_list, list) and contact_id in scope_list:
                return platform, target
        except (json.JSONDecodeError, TypeError):
            pass
    return None


# ── SSE subscriber loop ─────────────────────────────────────────

def _subscribe_once(token: str, routes: list) -> None:
    conn = unix_http_connect(IPC_SOCK)
    try:
        conn.request(
            "GET",
            SSE_PATH,
            headers={
                "Accept": "text/event-stream",
                "Authorization": f"Bearer {token}",
                "User-Agent": f"agentsnet-openclaw-hook/{HOOK_VERSION}",
            },
        )
        resp = conn.getresponse()
        if resp.status != 200:
            body = resp.read().decode("utf-8", errors="replace")[:200]
            LOG.warning("SSE subscribe failed status=%d body=%s", resp.status, body)
            return
        LOG.info("SSE subscribed (token len=%d, routes=%d)", len(token), len(routes))
        buf = ""
        while True:
            chunk = resp.read1(4096) if hasattr(resp, "read1") else resp.read(4096)
            if not chunk:
                LOG.info("SSE stream closed cleanly")
                return
            buf += chunk.decode("utf-8", errors="replace")
            while "\n\n" in buf:
                raw, buf = buf.split("\n\n", 1)
                _handle_sse_event(raw, routes)
    finally:
        conn.close()


def _handle_sse_event(raw: str, routes: list) -> None:
    lines = raw.splitlines()
    data_lines = [l[5:].lstrip() for l in lines if l.startswith("data:")]
    if not data_lines:
        return
    try:
        event = json.loads("\n".join(data_lines))
    except json.JSONDecodeError:
        LOG.warning("SSE event not JSON: %r", raw[:200])
        return
    # Rust serde tagged-enum shape: {"kind":{"NewInboxMessage": {...}}}.
    # Match hook.py (Hermes) pattern — extract the inner object before
    # dispatch. Prior draft used `event.get("kind") == "NewInboxMessage"`
    # which always failed because kind is an object, not a string.
    kind = event.get("kind")
    if not isinstance(kind, dict) or "NewInboxMessage" not in kind:
        return
    _dispatch_new_inbox_message(kind["NewInboxMessage"], routes)


def _dispatch_new_inbox_message(msg: dict, routes: list) -> None:
    contact_id = msg.get("contact_id") or ""
    contact_alias = msg.get("contact_alias") or contact_id
    preview = msg.get("preview_text") or ""
    content_type = msg.get("content_type") or "Text"
    milestone = bool(msg.get("milestone"))

    picked = pick_target_for_event(routes, contact_id, milestone)
    if not picked:
        LOG.info(
            "push hook: no route matched for contact=%s (ctype=%s milestone=%s)",
            contact_id, content_type, milestone,
        )
        return

    platform, target = picked
    LOG.info(
        "push hook: matched target=%s:%s for contact=%s alias=%s (ctype=%s milestone=%s)",
        platform, target, contact_id, contact_alias, content_type, milestone,
    )

    body_text = preview or f"[{content_type} from {contact_alias}]"
    full = f"[AgentsNet/{contact_id}] {body_text}"

    try:
        result = send_message(platform, target, full)
    except Exception:
        LOG.exception("send_message raised")
        return

    if result.get("success"):
        LOG.info(
            "delivered push: %s → %s:%s (content_type=%s, message_id=%s)",
            contact_id, platform, target, content_type, result.get("message_id"),
        )
    else:
        LOG.error(
            "send_message failed: %s → %s:%s: %s",
            contact_id, platform, target, result.get("error"),
        )


def run() -> None:
    logging.basicConfig(
        level=os.environ.get("AGENTSNET_PUSH_LOG_LEVEL", "INFO"),
        format="%(asctime)s %(levelname)s %(name)s %(message)s",
    )
    LOG.info(
        "agentsnet-openclaw-hook starting %s (docker-exec=%r bin=%s)",
        HOOK_VERSION, _OC_DOCKER_EXEC, _OC_BIN,
    )
    backoff_idx = 0
    while True:
        token = load_token()
        if not token:
            LOG.warning("IPC token missing; waiting for daemon")
            time.sleep(RECONNECT_BACKOFF_SECONDS[backoff_idx])
            backoff_idx = min(backoff_idx + 1, len(RECONNECT_BACKOFF_SECONDS) - 1)
            continue

        routes = fetch_routes(token)
        try:
            _subscribe_once(token, routes)
        except Exception:
            LOG.exception("SSE subscriber errored")
        sleep_s = RECONNECT_BACKOFF_SECONDS[backoff_idx]
        LOG.info("reconnecting in %ds", sleep_s)
        time.sleep(sleep_s)
        backoff_idx = min(backoff_idx + 1, len(RECONNECT_BACKOFF_SECONDS) - 1)


if __name__ == "__main__":
    run()
