#!/usr/bin/env python3
"""Evolution API Webhook Listener — structured terminal output for humans & LLMs.

Features:
  - Print every event in a parseable structured format
  - MESSAGES_UPSERT: extract sender phone, profile name, message type, content
  - Save raw payloads to webhook-logs/YYYY-MM-DD/ organized by date
  - Handle all message types (text, image, video, audio, document, etc.)

Usage:
    python3 scripts/webhook-listener.py [port]

Defaults to port 9999. POSTs to /webhook.
"""
import http.server
import json
import os
import re
import sys
import uuid
from datetime import datetime, timezone
from typing import Any, Optional


LOG_DIR = os.path.join(os.path.dirname(__file__), "..", "webhook-logs")

# ── ANSI colors (human terminal) ──────────────────────────────────────────
C = {
    "reset": "\033[0m",
    "bold": "\033[1m",
    "dim": "\033[2m",
    "label": "\033[38;5;245m",   # grey for field labels
    "key": "\033[38;5;39m",      # blue for keys
    "val": "\033[38;5;15m",      # white for values
    "head": "\033[38;5;220m",    # yellow for headers
    "ok": "\033[38;5;76m",       # green for success
    "warn": "\033[38;5;208m",    # orange for warnings
    "err": "\033[38;5;196m",     # red for errors
    "name": "\033[38;5;213m",    # magenta for person name
    "phone": "\033[38;5;81m",    # cyan for phone
    "type": "\033[38;5;119m",    # light green for msg type
    "ts": "\033[38;5;187m",      # cream for timestamp
    "meta": "\033[38;5;244m",    # grey meta
}


def no_colors():
    """Disable all ANSI colors (for LLM-pipe mode or dumb terminals)."""
    for k in C:
        C[k] = ""


# ── Helpers ───────────────────────────────────────────────────────────────

def extract_phone(jid: str) -> str:
    """Extract phone number from JID like 555198228085@s.whatsapp.net"""
    return jid.split("@")[0] if jid else ""


def fmt_ts(unix_ts: Optional[int]) -> str:
    """Format unix timestamp to ISO + relative."""
    if not unix_ts:
        return "?"
    try:
        dt = datetime.fromtimestamp(unix_ts, tz=timezone.utc)
        return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
    except (OSError, ValueError):
        return str(unix_ts)


def short_id(full_id: str) -> str:
    """Shorten a message ID for display."""
    return full_id[:12] + "…" if full_id and len(full_id) > 16 else full_id or "?"


def get_msg_content(msg: dict) -> tuple[str, str, str]:
    """Extract (content_preview, message_type, subtype_detail) from message body.

    Handles: conversation, extendedTextMessage, imageMessage, videoMessage,
    audioMessage, documentMessage, stickerMessage, contactMessage, locationMessage,
    etc.
    """
    if not msg:
        return ("[empty]", "unknown", "")

    type_map = {
        "conversation": ("text/plain", "text"),
        "extendedTextMessage": ("text/extended", "text"),
        "imageMessage": ("image", "media"),
        "videoMessage": ("video", "media"),
        "audioMessage": ("audio", "media"),
        "documentMessage": ("document", "media"),
        "documentWithCaptionMessage": ("document", "media"),
        "stickerMessage": ("sticker", "media"),
        "contactMessage": ("contact", "vcard"),
        "locationMessage": ("location", "geo"),
        "liveLocationMessage": ("location/live", "geo"),
        "buttonsMessage": ("buttons", "interactive"),
        "buttonsResponseMessage": ("buttons/response", "interactive"),
        "listMessage": ("list", "interactive"),
        "listResponseMessage": ("list/response", "interactive"),
        "orderMessage": ("order", "commerce"),
        "paymentMessage": ("payment", "commerce"),
        "reactionMessage": ("reaction", "reaction"),
        "protocolMessage": ("protocol", "system"),
        "pollCreationMessage": ("poll", "poll"),
        "pollUpdateMessage": ("poll/update", "poll"),
        "groupInviteMessage": ("group/invite", "system"),
        "groupChangeMessage": ("group/change", "system"),
    }

    for msg_type, (label, category) in type_map.items():
        sub = msg.get(msg_type, msg.get(msg_type.lower()))
        if sub:
            if category == "text":
                if isinstance(sub, str):
                    text = sub
                else:
                    text = sub.get("text", sub.get("conversation", str(sub)))
                return (text[:500].replace("\n", "\\n"), label, category)
            elif msg_type == "imageMessage":
                caption = sub.get("caption", "")
                return (f"[image] {caption[:200]}" if caption else "[image]", label, category)
            elif msg_type == "videoMessage":
                caption = sub.get("caption", "")
                dur = sub.get("seconds", "?")
                return (f"[video {dur}s] {caption[:200]}" if caption else f"[video {dur}s]", label, category)
            elif msg_type == "audioMessage":
                dur = sub.get("seconds", "?")
                ptt = " (voice)" if sub.get("ptt") else ""
                return (f"[audio{dur}s{ptt}]", label, category)
            elif msg_type == "documentMessage":
                name = sub.get("title", sub.get("fileName", "?"))
                return (f"[document: {name}]", label, category)
            elif msg_type == "stickerMessage":
                return ("[sticker]", label, category)
            elif msg_type == "reactionMessage":
                key = sub.get("key", {})
                text = sub.get("text", "?")
                reacted_to = key.get("id", "?")[:8]
                return (f"reaction '{text}' to {reacted_to}", label, category)
            elif msg_type == "pollCreationMessage":
                q = sub.get("name", "?")
                return (f"[poll: {q}]", label, category)
            elif msg_type == "locationMessage":
                lat = sub.get("degreesLatitude", "?")
                lng = sub.get("degreesLongitude", "?")
                return (f"[location {lat},{lng}]", label, category)
            elif msg_type == "contactMessage":
                display = sub.get("displayName", "?")
                return (f"[contact: {display}]", label, category)
            elif msg_type == "listResponseMessage":
                selected = sub.get("singleSelectReply", {}).get("selectedRowId", sub.get("title", "?"))
                return (f"[list/select: {selected[:100]}]", label, category)
            elif msg_type == "buttonsResponseMessage":
                selected = sub.get("selectedButtonId", sub.get("selectedDisplayText", "?"))
                return (f"[button: {selected[:100]}]", label, category)
            elif msg_type == "orderMessage":
                total = sub.get("totalAmount1000", "?")
                currency = sub.get("currencyCode", "?")
                return (f"[order {total}{currency}]", label, category)
            elif msg_type == "protocolMessage":
                return ("[protocol]", label, category)
            else:
                return (f"[{label}]", label, category)

    # Fallback: detect message keys
    for k in msg:
        if k.endswith("Message"):
            return (f"[{k}]", k, "unknown")

    return (str(msg)[:200], "unknown", "")


def label_v(v: Any, maxlen: int = 400) -> str:
    """Safely stringify a value."""
    if v is None:
        return "∅"
    s = str(v)
    return s[:maxlen] + "…" if len(s) > maxlen else s


# ── Structured Output ─────────────────────────────────────────────────────

HEADER_BAR = "─" * 60
FOOTER_BAR = "─" * 60


def cval(label: str, value: Any, color_key: str = "val") -> str:
    """Colored key: value line."""
    return f"  {C['label']}{label}:{C['reset']} {C[color_key]}{label_v(value)}{C['reset']}"


def print_event(data: dict):
    """Route webhook payload to appropriate structured display."""
    event = data.get("event", "?")
    instance = data.get("instance", "?")

    # Normalize: outer data may have event directly, or nested
    event_data = data.get("data") or data.get("payload") or {}

    print()  # spacing

    if event and "messages.upsert" in str(event).lower():
        _print_messages_upsert(event, instance, event_data)
    elif event and "messages.set" in str(event).lower():
        _print_messages_set(event, instance, event_data)
    elif event and "messages.delete" in str(event).lower():
        _print_messages_delete(event, instance, event_data)
    elif event and "send.message" in str(event).lower():
        _print_send_message(event, instance, event_data)
    elif event and "connection.update" in str(event).lower():
        _print_connection_update(event, instance, event_data)
    elif event and "qrcode" in str(event).lower():
        _print_qrcode(event, instance, event_data)
    elif event and "call" in str(event).lower():
        _print_call(event, instance, event_data)
    elif event == "APPLICATION_STARTUP":
        print(f"  {C['ok']}EVENT{reset}   {event} | {instance} | Evolution API started{N}")
    else:
        # Generic fallback
        _print_generic(event, instance, data)


# ── Message type formatters ───────────────────────────────────────────────

_LABEL = f"{C['label']}"
_VAL = f"{C['val']}"
_HEAD = f"{C['head']}{C['bold']}"
_RESET = f"{C['reset']}"
_NAME = f"{C['name']}"
_PHONE = f"{C['phone']}"
_TYPE = f"{C['type']}"
_TS = f"{C['ts']}"
_DIM = f"{C['dim']}"
_KEY = f"{C['key']}"
_OK = f"{C['ok']}"
_WARN = f"{C['warn']}"
_ERR = f"{C['err']}"
_META = f"{C['meta']}"
_BOLD = f"{C['bold']}"
_N = f"{C['reset']}"


def _print_messages_upsert(event: str, instance: str, event_data: dict):
    """Structured display for MESSAGES_UPSERT — the main event."""
    # event_data may be a list (multiple messages) or a dict (single)
    msgs = event_data if isinstance(event_data, list) else [event_data]

    for msg_data in msgs:
        if not isinstance(msg_data, dict):
            continue

        key = msg_data.get("key", {})
        remote_jid = key.get("remoteJid", "")
        from_me = key.get("fromMe", False)
        msg_id = key.get("id", "")
        participant = key.get("participant", "") or msg_data.get("participant", "")

        phone = extract_phone(participant or remote_jid)
        push_name = msg_data.get("pushName", "")
        msg_timestamp = msg_data.get("messageTimestamp")
        msg_type = msg_data.get("messageType", "?")
        source = msg_data.get("source", "?")
        status = msg_data.get("status", "")
        msg_body = msg_data.get("message", {})
        is_group = "@g.us" in remote_jid

        # Message content
        content, rich_type, category = get_msg_content(msg_body)
        formatted_ts = fmt_ts(msg_timestamp)

        # ponytail: skip own messages + groups — they pollute the LLM pipeline
        if from_me or is_group:
            continue

        # Direction indicator
        direction = f"{_WARN}INCOMING{_N}"
        direction_icon = "◀"

        # ── print ──
        print(f"  {_HEAD}╔══ {direction_icon}  MESSAGE {direction} ═══════════════════════════════{_N}")
        print(f"  {_HEAD}║{_N}")

        # Sender block
        print(f"  {_HEAD}║{_N}  {_BOLD}SENDER{_N}")
        print(f"  {_HEAD}║{_N}  {_LABEL}Phone:{_N}        {_PHONE}{phone}{_N}")
        what = f" ({extract_phone(remote_jid)})" if participant and not from_me else ""
        if participant:
            print(f"  {_HEAD}║{_N}  {_LABEL}In Group As:{_N}   {_PHONE}{extract_phone(participant)}{_N}")
        if is_group:
            group_phone = extract_phone(remote_jid)
            print(f"  {_HEAD}║{_N}  {_LABEL}Group JID:{_N}    {_DIM}{group_phone}{_N}")
        if from_me:
            print(f"  {_HEAD}║{_N}  {_LABEL}Sent To:{_N}      {_PHONE}{extract_phone(remote_jid)}{_N}")
        print(f"  {_HEAD}║{_N}  {_LABEL}Profile Name:{_N}  {_NAME}{push_name or '∅'}{_N}")
        # ponytail: username field — not in current payload, placeholder for when Evolution adds it
        username = msg_data.get("username", msg_data.get("verifiedName", ""))
        if username:
            print(f"  {_HEAD}║{_N}  {_LABEL}Username:{_N}      {_NAME}{username}{_N}")
        else:
            print(f"  {_HEAD}║{_N}  {_LABEL}Username:{_N}      {_DIM}(not available in payload yet){_N}")

        # Message block
        print(f"  {_HEAD}║{_N}")
        print(f"  {_HEAD}║{_N}  {_BOLD}MESSAGE{_N}")
        print(f"  {_HEAD}║{_N}  {_LABEL}Type:{_N}         {_TYPE}{rich_type}{_N}")
        print(f"  {_HEAD}║{_N}  {_LABEL}Category:{_N}     {_TYPE}{category}{_N}")
        print(f"  {_HEAD}║{_N}  {_LABEL}Content:{_N}      {_VAL}{content}{_N}")
        print(f"  {_HEAD}║{_N}  {_LABEL}Message ID:{_N}   {_DIM}{short_id(msg_id)}{_N}")

        # Timeline
        print(f"  {_HEAD}║{_N}")
        print(f"  {_HEAD}║{_N}  {_BOLD}TIMELINE{_N}")
        print(f"  {_HEAD}║{_N}  {_LABEL}Timestamp:{_N}    {_TS}{formatted_ts}{_N}")
        print(f"  {_HEAD}║{_N}  {_LABEL}Unix:{_N}         {_DIM}{msg_timestamp or '?'}{_N}")

        # Meta
        print(f"  {_HEAD}║{_N}")
        print(f"  {_HEAD}║{_N}  {_BOLD}META{_N}")
        print(f"  {_HEAD}║{_N}  {_LABEL}Event:{_N}        {_DIM}{event}{_N}")
        print(f"  {_HEAD}║{_N}  {_LABEL}Instance:{_N}     {_DIM}{instance}{_N}")
        print(f"  {_HEAD}║{_N}  {_LABEL}Source:{_N}       {_DIM}{source}{_N}")
        print(f"  {_HEAD}║{_N}  {_LABEL}Status:{_N}       {_DIM}{status or '?'}{_N}")
        print(f"  {_HEAD}║{_N}  {_LABEL}Chat:{_N}         {_DIM}{phone}{_N}")
        print(f"  {_HEAD}║{_N}")
        print(f"  {_HEAD}╚══ MESSAGE END ════════════════════════════════════════{_N}")
        print()

        # ── LLM-friendly block (no ANSI, pure structured) ──
        _print_llm_block({
            "_event": event,
            "_instance": instance,
            "sender": {
                "phone": phone,
                "profileName": push_name or None,
                "username": username or None,
                "fromMe": from_me,
                "remoteJid": remote_jid,
            },
            "message": {
                "type": rich_type,
                "category": category,
                "content": content,
                "id": msg_id,
            },
            "timeline": {
                "timestamp": formatted_ts,
                "unix": msg_timestamp,
            },
            "meta": {
                "source": source,
                "status": status,
                "isGroup": is_group,
            },
        })


def _print_llm_block(obj: dict):
    """Print a YAML-like structured block for LLMs/SLMs.

    No ANSI codes — pure parseable format. Every LLM block starts and
    ends with a marker line for easy extraction.
    """
    indent = "  "
    print(f"{indent}{_META}<llm>{_N}")
    print(f"{indent}{_META}event:{_N} {_DIM}{obj.get('_event', '?')}{_N}")
    print(f"{indent}{_META}instance:{_N} {_DIM}{obj.get('_instance', '?')}{_N}")

    def _dump(sub: dict, depth: int = 0):
        pfx = indent + "  " * depth
        for k, v in sub.items():
            if k.startswith("_"):
                continue
            if isinstance(v, dict):
                print(f"{pfx}{_KEY}{k}:{_N}")
                _dump(v, depth + 1)
            elif v is None:
                print(f"{pfx}{_KEY}{k}:{_N} {_DIM}null{_N}")
            else:
                print(f"{pfx}{_KEY}{k}:{_N} {_VAL}{v}{_N}")

    _dump(obj.get("sender", {}), 1)
    _dump(obj.get("message", {}), 1)
    _dump(obj.get("timeline", {}), 1)
    _dump(obj.get("meta", {}), 1)
    print(f"{indent}{_META}</llm>{_N}")


def _print_messages_set(event: str, instance: str, event_data: dict):
    print(f"  {_HEAD}══ MESSAGES SET ══════════════════════════════════════{_N}")
    print(f"  {_LABEL}Event:{_N}    {_VAL}{event}{_N}")
    print(f"  {_LABEL}Instance:{_N} {_VAL}{instance}{_N}")
    # Messages set usually has status updates — just show summary
    count = len(event_data) if isinstance(event_data, list) else 1
    print(f"  {_LABEL}Messages:{_N} {_VAL}{count}{_N}")


def _print_messages_delete(event: str, instance: str, event_data: dict):
    msgs = event_data if isinstance(event_data, list) else [event_data]
    for msg in msgs:
        key = msg.get("key", {}) if isinstance(msg, dict) else {}
        jid = extract_phone(key.get("remoteJid", ""))
        print(f"  {_HEAD}══ MESSAGE DELETED ════════════════════════════════════{_N}")
        print(f"  {_LABEL}Event:{_N}    {_VAL}{event}{_N}  |  {_LABEL}Instance:{_N} {_VAL}{instance}{_N}")
        print(f"  {_LABEL}Chat:{_N}     {_PHONE}{jid or '?'}{_N}")
        print(f"  {_LABEL}From Me:{_N}  {_VAL}{key.get('fromMe', '?')}{_N}")
        print(f"  {_LABEL}Msg ID:{_N}   {_DIM}{short_id(key.get('id', ''))}{_N}")


def _print_send_message(event: str, instance: str, event_data: dict):
    key = event_data.get("key", {}) if isinstance(event_data, dict) else {}
    phone = extract_phone(key.get("remoteJid", ""))
    msg_type = event_data.get("messageType", "?")
    content, _, _ = get_msg_content(event_data.get("message", {}) if isinstance(event_data, dict) else {})
    print(f"  {_HEAD}══ SENT MESSAGE ═══════════════════════════════════════{_N}")
    print(f"  {_LABEL}Event:{_N}    {_VAL}{event}{_N}  |  {_LABEL}Instance:{_N} {_VAL}{instance}{_N}")
    print(f"  {_LABEL}To:{_N}       {_PHONE}{phone}{_N}")
    print(f"  {_LABEL}Type:{_N}     {_TYPE}{msg_type}{_N}")
    print(f"  {_LABEL}Content:{_N}  {_VAL}{content}{_N}")


def _print_connection_update(event: str, instance: str, event_data: dict):
    state = event_data.get("state", "?")
    status = event_data.get("status", "")
    print(f"  {_HEAD}══ CONNECTION UPDATE ═══════════════════════════════════{_N}")
    icon = {"open": "🟢", "close": "🔴", "connecting": "🟡", "disconnecting": "🟠"}.get(str(state).lower(), "⚪")
    print(f"  {_LABEL}Event:{_N}    {_VAL}{event}{_N}  |  {_LABEL}Instance:{_N} {_VAL}{instance}{_N}")
    print(f"  {_LABEL}State:{_N}    {icon} {_VAL}{state}{_N}")
    if status:
        print(f"  {_LABEL}Status:{_N}   {_DIM}{status}{_N}")


def _print_qrcode(event: str, instance: str, event_data: dict):
    code = event_data.get("qrcode", event_data.get("code", "?"))
    pairing = event_data.get("pairingCode", "")
    print(f"  {_HEAD}══ QR CODE ════════════════════════════════════════════{_N}")
    print(f"  {_LABEL}Event:{_N}    {_VAL}{event}{_N}  |  {_LABEL}Instance:{_N} {_VAL}{instance}{_N}")
    if isinstance(code, str) and len(code) > 40:
        print(f"  {_VAL}  [QR code base64, {len(code)} chars]{_N}")
    else:
        print(f"  {_LABEL}Code:{_N}     {_VAL}{label_v(code)}{_N}")
    if pairing:
        print(f"  {_LABEL}Pairing:{_N}  {_WARN}{pairing}{_N}")


def _print_call(event: str, instance: str, event_data: dict):
    from_jid = event_data.get("from", "")
    phone = extract_phone(from_jid)
    status = event_data.get("status", "?")
    duration = event_data.get("duration", event_data.get("time", "?"))
    print(f"  {_HEAD}══ CALL ═══════════════════════════════════════════════{_N}")
    print(f"  {_LABEL}Event:{_N}    {_VAL}{event}{_N}  |  {_LABEL}Instance:{_N} {_VAL}{instance}{_N}")
    print(f"  {_LABEL}From:{_N}     {_PHONE}{phone}{_N}")
    print(f"  {_LABEL}Status:{_N}   {_VAL}{status}{_N}")
    print(f"  {_LABEL}Duration:{_N} {_VAL}{duration}{_N}")


def _print_generic(event: str, instance: str, data: dict):
    """Fallback for unknown events — dump first 3 keys."""
    truncated = {k: str(v)[:120] for k, v in data.items() if not k.startswith("_")}
    if not truncated:
        truncated = {"_note": "empty payload"}
    print(f"  {_HEAD}══ EVENT ({event}) ═════════════════════════════════════{_N}")
    print(f"  {_LABEL}Event:{_N}    {_VAL}{event}{_N}  |  {_LABEL}Instance:{_N} {_VAL}{instance}{_N}")
    for k, v in list(truncated.items())[:3]:
        print(f"  {_LABEL}{k}:{_N} {_DIM}{v}{_N}")
    print(f"  {_META}  (full payload saved to file){_N}")


# ── HTTP Server ───────────────────────────────────────────────────────────

class Handler(http.server.BaseHTTPRequestHandler):
    def do_POST(self):
        length = int(self.headers.get("Content-Length", 0))
        body = self.rfile.read(length) if length else b"{}"
        try:
            data = json.loads(body)
        except json.JSONDecodeError:
            data = {"_raw": body.decode(errors="replace")}

        path = save_payload(data, self.client_address[0])
        print(f"  {_DIM}├─ saved → {path}{_N}")

        print_event(data)

        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(json.dumps({"ok": True}).encode())

    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.end_headers()
        self.wfile.write(b"Evolution API webhook listener running.\n")

    def log_message(self, fmt, *args):
        pass  # silence default logging, we print manually


def save_payload(data: dict, remote: str) -> str:
    now = datetime.now()
    date_dir = os.path.join(LOG_DIR, now.strftime("%Y-%m-%d"))
    os.makedirs(date_dir, exist_ok=True)
    fname = now.strftime("%H-%M-%S") + f"-{uuid.uuid4().hex[:8]}.json"
    path = os.path.join(date_dir, fname)
    payload = {
        "_meta": {"received_at": now.isoformat(), "remote": remote},
        "data": data,
    }
    with open(path, "w") as f:
        json.dump(payload, f, indent=2, ensure_ascii=False)
    return path


def main():
    port = int(sys.argv[1]) if len(sys.argv) > 1 else 9999

    # Disable colors if piped or /dev/null
    if not sys.stdout.isatty():
        no_colors()

    print(f"  {_HEAD}══ EVOLUTION API WEBHOOK LISTENER ═══════════════════════════{_N}")
    print(f"  {_LABEL}Listen:{_N}      {_VAL}http://0.0.0.0:{port}/webhook{_N}")
    print(f"  {_LABEL}Save logs to:{_N} {_DIM}{os.path.abspath(LOG_DIR)}/{_N}")
    print(f"  {_LABEL}Format:{_N}      {_VAL}Human color + LLM yaml block{_N}")
    print(f"  {_HEAD}══════════════════════════════════════════════════════════════{_N}")

    server = http.server.HTTPServer(("0.0.0.0", port), Handler)
    try:
        server.serve_forever()
    except KeyboardInterrupt:
        print(f"\n  {_WARN}Stopped.{_N}")
        server.server_close()


if __name__ == "__main__":
    main()
