"""
cortex/events/webhook.py — Webhook subscription & delivery system (M20 / #183).

Architecture
------------
  Webhook       — dataclass representing a registered webhook subscription
  WebhookDispatcher — manages registrations and delivers events via HTTP POST

Delivery guarantees:
  - HMAC-SHA256 signature in ``X-EvaOS-Signature`` header (hex digest)
  - 3 delivery attempts with exponential backoff (1s, 2s, 4s)
  - Fire-and-forget async delivery (non-blocking to emitter)
  - Every delivery attempt (success or failure) is logged to webhook_delivery table

Usage
-----
::

    dispatcher = WebhookDispatcher(storage=plugin.storage)

    # Register
    wh = await dispatcher.register(
        owner_id="alice",
        url="https://example.com/hook",
        events=[EventType.MEMORY_CREATED],
        secret="s3cr3t",
    )

    # Dispatch (called automatically from EventEmitter.emit())
    await dispatcher.dispatch(event)
"""
from __future__ import annotations

import asyncio
import hashlib
import hmac
import json
import logging
import os
import re
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional

import ipaddress
import socket
from urllib.parse import urlparse

from cortex.events.emitter import CortexEvent, EventType

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# SSRF protection (#435)
# ---------------------------------------------------------------------------

_BLOCKED_NETWORKS = [
    ipaddress.ip_network("127.0.0.0/8"),
    ipaddress.ip_network("10.0.0.0/8"),
    ipaddress.ip_network("172.16.0.0/12"),
    ipaddress.ip_network("192.168.0.0/16"),
    ipaddress.ip_network("169.254.0.0/16"),
    ipaddress.ip_network("::1/128"),
    ipaddress.ip_network("fd00::/8"),
    ipaddress.ip_network("fe80::/10"),
]


def validate_webhook_url(url: str, *, allow_private: bool = False) -> None:
    """Validate a webhook URL is safe for server-side requests.

    Raises ValueError if the URL targets a private/internal network.
    NOTE: DNS rebinding caveat — hostname could resolve differently at
    delivery time. Full mitigation requires re-validation at delivery.
    """
    parsed = urlparse(url)
    if parsed.scheme not in ("http", "https"):
        raise ValueError(f"Webhook URL must use http or https scheme, got: {parsed.scheme!r}")

    hostname = parsed.hostname
    if not hostname:
        raise ValueError("Webhook URL must include a hostname")

    if allow_private:
        return

    try:
        infos = socket.getaddrinfo(hostname, None, socket.AF_UNSPEC, socket.SOCK_STREAM)
    except socket.gaierror as exc:
        raise ValueError(f"Cannot resolve webhook hostname {hostname!r}: {exc}") from exc

    for family, _, _, _, sockaddr in infos:
        ip = ipaddress.ip_address(sockaddr[0])
        for network in _BLOCKED_NETWORKS:
            if ip in network:
                raise ValueError(
                    f"Webhook URL resolves to private/internal address {ip} "
                    f"(blocked network: {network}). Use webhook_allow_private_urls "
                    f"config to override for development."
                )

# ---------------------------------------------------------------------------
# Webhook dataclass
# ---------------------------------------------------------------------------

@dataclass
class Webhook:
    """Represents a single webhook registration.

    Attributes
    ----------
    id:
        Unique identifier (UUID hex).
    owner_id:
        Owner/user scope.
    url:
        Target URL for HTTP POST delivery.
    events:
        List of EventType values this webhook subscribes to.
        Empty list means *subscribe to all events*.
    secret:
        Optional shared secret for HMAC-SHA256 signature.
    active:
        Whether the webhook is currently enabled.
    created_at:
        ISO-8601 UTC creation timestamp.
    name:
        Optional human-readable label (defaults to url).
    """

    id: str
    owner_id: str
    url: str
    events: List[EventType]
    secret: Optional[str] = None
    active: bool = True
    created_at: str = field(
        default_factory=lambda: datetime.now(timezone.utc).isoformat()
    )
    name: str = ""


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------

_ENV_VAR_PATTERN = re.compile(r"^\$\{?([A-Z_][A-Z0-9_]*)\}?$")


def _resolve_secret(secret: Optional[str]) -> Optional[str]:
    """Resolve a webhook secret value, supporting ``$ENV_VAR`` / ``${ENV_VAR}`` syntax.

    If the secret value matches the pattern ``$VAR`` or ``${VAR}``, the
    corresponding environment variable is looked up at runtime.  This keeps
    plaintext secrets out of config files.

    Examples::

        secret = "$WEBHOOK_SECRET"      # → os.environ["WEBHOOK_SECRET"]
        secret = "${MY_HOOK_TOKEN}"     # → os.environ["MY_HOOK_TOKEN"]
        secret = "literal-value"        # → "literal-value" (unchanged)
        secret = None                   # → None

    Raises ``ValueError`` if the env var pattern is used but the variable is
    not set in the environment.
    """
    if secret is None:
        return None
    match = _ENV_VAR_PATTERN.match(secret.strip())
    if match:
        var_name = match.group(1)
        value = os.environ.get(var_name)
        if value is None:
            raise ValueError(
                f"Webhook secret references env var {var_name!r} which is not set. "
                "Set the environment variable before starting Cortex."
            )
        return value
    return secret


def _generate_signature(payload: bytes, secret: str) -> str:
    """Return HMAC-SHA256 hex digest of *payload* using *secret*."""
    return hmac.new(
        secret.encode("utf-8"),
        payload,
        hashlib.sha256,
    ).hexdigest()


def _make_payload_bytes(event: CortexEvent) -> bytes:
    """Serialise *event* to a stable JSON bytes representation."""
    data = {
        "event_type": event.event_type.value if isinstance(event.event_type, EventType) else str(event.event_type),
        "owner_id": event.owner_id,
        "timestamp": event.timestamp,
        "payload": event.payload,
        "session_id": event.session_id,
    }
    return json.dumps(data, sort_keys=True, separators=(",", ":")).encode("utf-8")


# ---------------------------------------------------------------------------
# WebhookDispatcher
# ---------------------------------------------------------------------------

class WebhookDispatcher:
    """Manages webhook registrations and dispatches events via HTTP POST.

    Parameters
    ----------
    storage:
        A StorageAdapter instance. Required for persistence.
        If ``None``, registrations are memory-only and delivery is not logged.
    http_timeout:
        Seconds to wait for each HTTP delivery attempt (default 10).
    """

    RETRY_DELAYS = (1, 2, 4)  # exponential backoff seconds

    def __init__(self, storage: Any = None, http_timeout: float = 10.0, config: Any = None) -> None:
        self._storage = storage
        self._http_timeout = http_timeout
        self._config = config

    # ------------------------------------------------------------------ #
    # Registration
    # ------------------------------------------------------------------ #

    async def register(
        self,
        owner_id: str,
        url: str,
        events: List[EventType],
        secret: Optional[str] = None,
        name: str = "",
    ) -> Webhook:
        """Create and persist a new webhook subscription.

        Parameters
        ----------
        owner_id:
            The owner scope.
        url:
            Delivery target URL.
        events:
            List of EventType values to subscribe to.  Pass an empty list
            to subscribe to *all* events.
        secret:
            Optional shared secret used to sign payloads.
        name:
            Optional human-readable label.

        Returns
        -------
        Webhook
            The newly created Webhook dataclass.
        """
        allow_private = getattr(self._config, 'webhook_allow_private_urls', False) if self._config else False
        validate_webhook_url(url, allow_private=allow_private)

        webhook_id = uuid.uuid4().hex
        created_at = datetime.now(timezone.utc).isoformat()
        name = name or url

        webhook = Webhook(
            id=webhook_id,
            owner_id=owner_id,
            url=url,
            events=events,
            secret=secret,
            active=True,
            created_at=created_at,
            name=name,
        )

        if self._storage is not None:
            await self._storage.create_webhook(
                webhook_id=webhook_id,
                owner_id=owner_id,
                url=url,
                events=[e.value if isinstance(e, EventType) else str(e) for e in events],
                secret=secret,
                name=name,
            )

        return webhook

    # ------------------------------------------------------------------ #
    # Dispatch
    # ------------------------------------------------------------------ #

    async def dispatch(self, event: CortexEvent) -> None:
        """Dispatch *event* to all matching active webhooks.

        Delivery is fire-and-forget — the coroutine schedules background tasks
        for each matching webhook and returns immediately.

        Parameters
        ----------
        event:
            The CortexEvent to deliver.
        """
        if self._storage is None:
            logger.debug("WebhookDispatcher: no storage — skipping dispatch for %s", event.event_type)
            return

        try:
            webhooks_raw = await self._storage.list_webhooks(
                owner_id=event.owner_id,
                active_only=True,
            )
        except Exception as exc:
            logger.warning("WebhookDispatcher: failed to list webhooks: %s", exc)
            return

        event_type_str = event.event_type.value if isinstance(event.event_type, EventType) else str(event.event_type)

        for wh_data in webhooks_raw:
            wh = _row_to_webhook(wh_data)

            # Filter: only deliver if webhook subscribes to this event type
            # (empty events list = subscribe to all)
            if wh.events and event_type_str not in [
                e.value if isinstance(e, EventType) else str(e) for e in wh.events
            ]:
                continue

            # Fire and forget
            asyncio.ensure_future(
                self._deliver_with_retry(wh, event, event_type_str)
            )

    # ------------------------------------------------------------------ #
    # Internal delivery
    # ------------------------------------------------------------------ #

    async def _deliver_with_retry(
        self,
        webhook: Webhook,
        event: CortexEvent,
        event_type_str: str,
    ) -> None:
        """Attempt delivery up to 3 times with exponential backoff.

        Logs each attempt result to the webhook_delivery table.
        """
        payload_bytes = _make_payload_bytes(event)
        headers: Dict[str, str] = {
            "Content-Type": "application/json",
            "X-EvaOS-Event": event_type_str,
            "X-EvaOS-Webhook-ID": webhook.id,
        }
        if webhook.secret:
            try:
                resolved_secret = _resolve_secret(webhook.secret)
            except ValueError as exc:
                logger.error(
                    "WebhookDispatcher: cannot resolve secret for webhook %s: %s",
                    webhook.id, exc,
                )
                resolved_secret = None
            if resolved_secret:
                headers["X-EvaOS-Signature"] = _generate_signature(payload_bytes, resolved_secret)

        last_exc: Optional[Exception] = None
        last_status: Optional[int] = None

        for attempt, delay in enumerate(self.RETRY_DELAYS):
            try:
                status_code = await self._http_post(webhook.url, payload_bytes, headers)
                last_status = status_code
                success = 200 <= status_code < 300

                await self._log_delivery(
                    webhook_id=webhook.id,
                    event_type=event_type_str,
                    status_code=status_code,
                    success=success,
                    error_msg=None,
                )

                if success:
                    logger.debug(
                        "WebhookDispatcher: delivered %s to %s (attempt %d, status %d)",
                        event_type_str,
                        webhook.url,
                        attempt + 1,
                        status_code,
                    )
                    return

                logger.warning(
                    "WebhookDispatcher: non-2xx %d for %s attempt %d — retrying in %ds",
                    status_code,
                    webhook.url,
                    attempt + 1,
                    delay,
                )

            except Exception as exc:
                last_exc = exc
                await self._log_delivery(
                    webhook_id=webhook.id,
                    event_type=event_type_str,
                    status_code=None,
                    success=False,
                    error_msg=str(exc),
                )
                logger.warning(
                    "WebhookDispatcher: delivery error for %s attempt %d: %s — retrying in %ds",
                    webhook.url,
                    attempt + 1,
                    exc,
                    delay,
                )

            if attempt < len(self.RETRY_DELAYS) - 1:
                await asyncio.sleep(delay)

        logger.error(
            "WebhookDispatcher: all %d attempts failed for %s (webhook=%s, last_status=%s, last_exc=%s)",
            len(self.RETRY_DELAYS),
            webhook.url,
            webhook.id,
            last_status,
            last_exc,
        )

    async def _http_post(
        self,
        url: str,
        payload_bytes: bytes,
        headers: Dict[str, str],
    ) -> int:
        """Send HTTP POST and return the status code.

        Uses ``aiohttp`` if available, falls back to ``urllib`` in a thread.
        """
        try:
            import aiohttp  # type: ignore

            timeout = aiohttp.ClientTimeout(total=self._http_timeout)
            async with aiohttp.ClientSession(timeout=timeout) as session:
                async with session.post(
                    url,
                    data=payload_bytes,
                    headers=headers,
                ) as resp:
                    return resp.status

        except ImportError:
            # Fallback: urllib in executor (blocking but functional)
            import urllib.request as _req
            import urllib.error as _err

            def _blocking_post() -> int:
                request = _req.Request(
                    url,
                    data=payload_bytes,
                    headers=headers,
                    method="POST",
                )
                try:
                    with _req.urlopen(request, timeout=self._http_timeout) as resp:
                        return resp.status
                except _err.HTTPError as e:
                    return e.code

            loop = asyncio.get_event_loop()
            return await loop.run_in_executor(None, _blocking_post)

    async def _log_delivery(
        self,
        webhook_id: str,
        event_type: str,
        status_code: Optional[int],
        success: bool,
        error_msg: Optional[str],
    ) -> None:
        """Persist a delivery attempt record; errors are swallowed to avoid
        infinite loops if storage is the failing component."""
        if self._storage is None:
            return
        try:
            await self._storage.log_webhook_delivery(
                webhook_id=webhook_id,
                event_type=event_type,
                status_code=status_code,
                success=success,
                error_msg=error_msg,
            )
        except Exception as exc:
            logger.warning("WebhookDispatcher: failed to log delivery: %s", exc)


# ---------------------------------------------------------------------------
# Row → Webhook helper
# ---------------------------------------------------------------------------

def _row_to_webhook(row: Dict[str, Any]) -> Webhook:
    """Convert a dict row (from storage) to a Webhook dataclass."""
    events_raw = row.get("events", "[]") or "[]"
    if isinstance(events_raw, str):
        try:
            events_list = json.loads(events_raw)
        except (json.JSONDecodeError, ValueError):
            events_list = [events_raw] if events_raw else []
    else:
        events_list = list(events_raw)

    events: List[EventType] = []
    for e in events_list:
        try:
            events.append(EventType(e))
        except ValueError:
            logger.warning("WebhookDispatcher: unknown event type %r — skipping", e)

    return Webhook(
        id=row["id"],
        owner_id=row.get("owner_id", "default"),
        url=row["url"],
        events=events,
        secret=row.get("secret"),
        active=bool(row.get("active", 1)),
        created_at=row.get("created_at", ""),
        name=row.get("name", row["url"]),
    )
