"""Inbound webhooks: receive POSTs from external services and wake Mugi.

See plan §1 — "Inbound webhooks".

Security properties:
- Off by default via ``webhooks_enabled`` app-pref.
- Two auth paths: primary token in ``X-Mugi-Webhook-Token`` OR per-source
  HMAC signature in ``X-Mugi-Signature`` (GitHub-compatible sha256=...).
- Replay protection via ``X-Mugi-Timestamp`` (±5 min) + LRU of recent
  (source, body_hash) pairs.
- 64 KB body cap.
- Source name must match ``^[a-zA-Z0-9_-]{1,32}$``.
- All secrets live in Keychain, never in TOML / app_prefs.

Payload rendering: each source can be configured with a mode:
- ``summary_template`` (Jinja2-lite {{field}} substitution over top-level JSON)
- ``title_body`` (first non-empty string value becomes title+body)
- ``raw_trimmed`` (first 500 chars of JSON)
"""

from __future__ import annotations

import hashlib
import hmac
import json
import logging
import re
import time
from collections import deque
from dataclasses import dataclass, field
from typing import Any

_log = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------

MAX_BODY_BYTES = 64 * 1024
REPLAY_WINDOW_SECONDS = 300
_REPLAY_CACHE_SIZE = 512
SOURCE_PATTERN = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")

# Mode names.
MODE_SUMMARY_TEMPLATE = "summary_template"
MODE_TITLE_BODY = "title_body"
MODE_RAW_TRIMMED = "raw_trimmed"
_VALID_MODES = frozenset({MODE_SUMMARY_TEMPLATE, MODE_TITLE_BODY, MODE_RAW_TRIMMED})

# Built-in templates.
DEFAULT_TEMPLATES: dict[str, str] = {
    "github": "[github:{{action|event}}] {{repository.full_name}} — {{sender.login}}",
    "generic": "[{{source}}] {{title|summary|subject|message}}",
}


# ---------------------------------------------------------------------------
# Data model
# ---------------------------------------------------------------------------

@dataclass
class WebhookPayload:
    source: str
    body: dict[str, Any]
    body_hash: str
    received_at: float = field(default_factory=time.time)


# ---------------------------------------------------------------------------
# Replay cache
# ---------------------------------------------------------------------------

_replay: deque[tuple[str, str, float]] = deque(maxlen=_REPLAY_CACHE_SIZE)


def _is_replay(source: str, body_hash: str) -> bool:
    now = time.time()
    # Drop stale entries lazily.
    while _replay and (now - _replay[0][2]) > REPLAY_WINDOW_SECONDS:
        _replay.popleft()
    for src, h, _ in _replay:
        if src == source and h == body_hash:
            return True
    _replay.append((source, body_hash, now))
    return False


# ---------------------------------------------------------------------------
# Enable + config
# ---------------------------------------------------------------------------

def is_enabled() -> bool:
    """Webhooks disabled by default. Enable via SwiftUI → writes ``webhooks_enabled``."""
    try:
        from agent_loop_config import _read_pref
        return bool(_read_pref("webhooks_enabled", False))
    except Exception:
        return False


def validate_source_name(source: str) -> bool:
    return bool(SOURCE_PATTERN.match(source or ""))


# ---------------------------------------------------------------------------
# HMAC verification
# ---------------------------------------------------------------------------

def _get_hmac_secret(source: str) -> str:
    try:
        from persistence import keychain_get_webhook_hmac
        return keychain_get_webhook_hmac(source) or ""
    except Exception:
        return ""


def _get_primary_token() -> str:
    try:
        from persistence import keychain_get_webhook_token
        return keychain_get_webhook_token() or ""
    except Exception:
        return ""


def verify_signature(
    source: str,
    body_bytes: bytes,
    timestamp_header: str,
    signature_header: str,
) -> tuple[bool, str]:
    """Verify a GitHub-compatible sha256 HMAC. Returns (ok, reason)."""
    secret = _get_hmac_secret(source)
    if not secret:
        return False, "no_hmac_secret_for_source"
    if not signature_header:
        return False, "missing_signature"
    try:
        ts = int(timestamp_header or "0")
    except (TypeError, ValueError):
        return False, "bad_timestamp"
    now = int(time.time())
    if abs(now - ts) > REPLAY_WINDOW_SECONDS:
        return False, "replay_window_exceeded"
    payload = f"{ts}.".encode("ascii") + body_bytes
    expected = "sha256=" + hmac.new(secret.encode("utf-8"), payload, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, signature_header):
        return False, "signature_mismatch"
    return True, "ok"


def verify_primary_token(token_header: str) -> bool:
    expected = _get_primary_token()
    if not expected or not token_header:
        return False
    return hmac.compare_digest(expected, token_header)


# ---------------------------------------------------------------------------
# Payload rendering
# ---------------------------------------------------------------------------

_TEMPLATE_FIELD_RE = re.compile(r"\{\{\s*([a-zA-Z0-9_.|]+)\s*\}\}")


def _resolve_field(body: dict[str, Any], path: str) -> str:
    """Walk dotted path; pipes form a fallback chain: ``a.b|c``."""
    for candidate in path.split("|"):
        cur: Any = body
        ok = True
        for part in candidate.split("."):
            if isinstance(cur, dict) and part in cur:
                cur = cur[part]
            else:
                ok = False
                break
        if ok and cur is not None and str(cur):
            return str(cur)
    return ""


def render_summary(source: str, body: dict[str, Any], mode: str, template: str | None = None) -> str:
    """Return a compact one-line human-readable summary for the proactive queue."""
    if mode == MODE_RAW_TRIMMED:
        try:
            return json.dumps(body, ensure_ascii=True)[:500]
        except Exception:
            return str(body)[:500]

    if mode == MODE_TITLE_BODY:
        for key in ("title", "subject", "summary", "message", "text"):
            val = body.get(key)
            if isinstance(val, str) and val.strip():
                return f"[{source}] {val.strip()[:400]}"
        return f"[{source}] webhook received"

    # summary_template (default)
    tmpl = template or DEFAULT_TEMPLATES.get(source) or DEFAULT_TEMPLATES["generic"]
    body_with_source = {**body, "source": source}
    def _sub(m: re.Match[str]) -> str:
        return _resolve_field(body_with_source, m.group(1))
    out = _TEMPLATE_FIELD_RE.sub(_sub, tmpl)
    return out.strip()[:500] or f"[{source}] webhook received"


def _source_mode(source: str) -> tuple[str, str | None]:
    """Resolve (mode, template) from config. Defaults: summary_template + built-in."""
    try:
        from core.config import get_webhooks_config
        cfg = get_webhooks_config() or {}
    except Exception:
        cfg = {}
    per_source = (cfg.get("per_source") or {}).get(source) or {}
    mode = str(per_source.get("mode") or cfg.get("default_mode") or MODE_SUMMARY_TEMPLATE)
    if mode not in _VALID_MODES:
        mode = MODE_SUMMARY_TEMPLATE
    template = per_source.get("template")
    return mode, template


# ---------------------------------------------------------------------------
# Enqueue
# ---------------------------------------------------------------------------

def enqueue_webhook(payload: WebhookPayload, *, redact: bool = False) -> str:
    """Write the rendered summary to the proactive queue and return dedupe key.

    When ``redact`` is True (``notify_only`` capability), the summary is passed
    through ``utils.redaction.redact_text`` before enqueue so sensitive tokens
    never reach the queue or macOS notification path.
    """
    from persistence import PROACTIVE_QUEUE_PATH
    from utils.proactive_queue import append_proactive_queue

    mode, template = _source_mode(payload.source)
    summary = render_summary(payload.source, payload.body, mode, template)
    if redact:
        try:
            from utils.redaction import redact_text
            summary = redact_text(summary)
        except Exception:
            _log.debug("redact_text failed; enqueuing raw summary", exc_info=True)
    append_proactive_queue(
        PROACTIVE_QUEUE_PATH,
        summary,
        source=f"webhook:{payload.source}"[:50],
        priority="normal",
        dedupe_key=payload.body_hash,
        kind="webhook",
    )
    return payload.body_hash


# ---------------------------------------------------------------------------
# Wake runner (UDS ping)
# ---------------------------------------------------------------------------

def ping_runner_wake() -> bool:
    """Best-effort UDS ping telling the runner to force-active for next tick.

    Arms the one-shot ``activeHours`` bypass. Returns True if the ping
    reached the socket (ephemeral: does NOT block for a response).
    """
    # Arm the one-shot in-process flag; covers tests + same-process runners.
    try:
        from utils.active_hours import arm_force_active
        arm_force_active()
    except Exception:
        _log.debug("arm_force_active failed", exc_info=True)

    try:
        import socket
        from persistence import SOCKET_PATH
        if not SOCKET_PATH:
            return False
        try:
            import os
            if not os.path.exists(SOCKET_PATH):
                return False
        except Exception:
            return False
        s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        s.settimeout(1.0)
        try:
            s.connect(SOCKET_PATH)
            msg = json.dumps({"cmd": "webhook_wake"}) + "\n"
            s.sendall(msg.encode("utf-8"))
        finally:
            try:
                s.close()
            except Exception:
                pass
        return True
    except Exception as e:
        _log.debug("webhook ping_runner_wake failed: %s", e)
        return False


# ---------------------------------------------------------------------------
# Processing
# ---------------------------------------------------------------------------

def process_request(
    source: str,
    body_bytes: bytes,
    *,
    token_header: str | None,
    timestamp_header: str | None,
    signature_header: str | None,
) -> tuple[int, dict[str, Any]]:
    """Verify + enqueue a webhook. Returns ``(http_status, response_body)``.

    ``http_status`` codes:
    - 202: accepted and enqueued
    - 400: bad source name / body / timestamp
    - 401: failed auth (both paths)
    - 404: webhooks disabled
    - 413: body too large
    - 429: duplicate within replay window (soft; still acknowledges)
    """
    if not is_enabled():
        return 404, {"error": "not_found"}

    if not validate_source_name(source):
        return 400, {"error": "invalid_source_name"}

    if body_bytes is None:
        body_bytes = b""
    if len(body_bytes) > MAX_BODY_BYTES:
        return 413, {"error": "body_too_large", "limit": MAX_BODY_BYTES}

    body_hash = hashlib.sha256(body_bytes).hexdigest()[:24]

    auth_ok = False
    auth_reason = ""
    if token_header and verify_primary_token(str(token_header)):
        auth_ok = True
    if not auth_ok and signature_header:
        ok, reason = verify_signature(source, body_bytes, str(timestamp_header or ""), str(signature_header))
        auth_ok = ok
        auth_reason = reason
    if not auth_ok:
        _log.warning("webhook auth failed source=%s reason=%s", source, auth_reason or "missing")
        return 401, {"error": "unauthorized"}

    if _is_replay(source, body_hash):
        return 429, {"error": "replay", "body_hash": body_hash}

    try:
        body = json.loads(body_bytes.decode("utf-8")) if body_bytes else {}
    except Exception:
        body = {"_raw": body_bytes[:500].decode("utf-8", errors="replace")}
    if not isinstance(body, dict):
        body = {"_value": body}

    payload = WebhookPayload(source=source, body=body, body_hash=body_hash)

    # Phase 2b §3: capability gates whether we wake the runner (full_turn) or
    # merely drop a redacted notification + queue row (notify_only).
    capability = "full_turn"
    try:
        from persistence import webhook_source_capability
        capability = webhook_source_capability(source)
    except Exception:
        _log.debug("webhook_source_capability lookup failed", exc_info=True)

    is_notify_only = capability == "notify_only"
    enqueue_webhook(payload, redact=is_notify_only)
    if is_notify_only:
        try:
            from utils.notifications import send_mac_notification
            from utils.redaction import redact_text
            mode, template = _source_mode(source)
            preview = redact_text(render_summary(source, body, mode, template))[:240]
            send_mac_notification(
                title=f"Mugi: {source}",
                subtitle="webhook received",
                body=preview or "(no content)",
            )
        except Exception:
            _log.debug("notify_only notification failed", exc_info=True)
    else:
        ping_runner_wake()

    run_id = body_hash
    try:
        from utils import task_ledger as _tl
        _tl.record_run(
            _tl.KIND_WEBHOOK,
            f"webhook_{source}",
            status=_tl.STATUS_OK,
            duration_ms=int((time.time() - payload.received_at) * 1000),
            meta={
                "body_hash": body_hash,
                "size": len(body_bytes),
                "capability": capability,
            },
        )
    except Exception:
        _log.debug("ledger record (webhook) failed", exc_info=True)

    _log.info(
        "webhook accepted source=%s size=%d capability=%s run_id=%s",
        source, len(body_bytes), capability, run_id,
    )
    return 202, {"ok": True, "run_id": run_id, "capability": capability}
