"""Webhook execution infrastructure with retry logic and HMAC signing."""

from __future__ import annotations

import asyncio
import hashlib
import hmac
import json
import random
import time
from dataclasses import dataclass
from typing import TYPE_CHECKING, Final
from uuid import UUID, uuid4

import httpx

from noteflow.config.settings import get_settings
from noteflow.domain.utils.time import utc_now
from noteflow.domain.webhooks import (
    DEFAULT_WEBHOOK_BACKOFF_BASE,
    DEFAULT_WEBHOOK_MAX_RESPONSE_LENGTH,
    DEFAULT_WEBHOOK_MAX_RETRIES,
    DEFAULT_WEBHOOK_TIMEOUT_MS,
    HTTP_CONTENT_TYPE_JSON,
    HTTP_HEADER_CONTENT_TYPE,
    HTTP_HEADER_WEBHOOK_DELIVERY,
    HTTP_HEADER_WEBHOOK_EVENT,
    HTTP_HEADER_WEBHOOK_SIGNATURE,
    HTTP_HEADER_WEBHOOK_TIMESTAMP,
    RETRYABLE_STATUS_CODES,
    WEBHOOK_SIGNATURE_PREFIX,
    DeliveryResult,
    WebhookDelivery,
    WebhookEventType,
    WebhookPayloadDict,
)
from noteflow.infrastructure.logging import get_logger
from noteflow.infrastructure.webhooks.metrics import get_webhook_metrics

if TYPE_CHECKING:
    from noteflow.domain.webhooks import WebhookConfig


@dataclass(frozen=True, slots=True)
class _DeliveryContext:
    """Context for a webhook delivery attempt."""

    delivery_id: UUID
    config: WebhookConfig
    event_type: WebhookEventType
    payload: WebhookPayloadDict

_logger = get_logger(__name__)

# HTTP client connection limits
MAX_CONNECTIONS: Final[int] = 20
MAX_KEEPALIVE_CONNECTIONS: Final[int] = 10


def _get_webhook_settings() -> tuple[float, int, float, int]:
    """Get webhook settings with fallback defaults for testing.

    Returns:
        Tuple of (timeout_seconds, max_retries, backoff_base, max_response_length).
    """
    try:
        settings = get_settings()
        return (
            settings.webhook_timeout_seconds,
            settings.webhook_max_retries,
            settings.webhook_backoff_base,
            settings.webhook_max_response_length,
        )
    # INTENTIONAL BROAD HANDLER: Fallback for testing environments
    # - Settings may fail to load in unit tests without full config
    # - Use domain constants as safe defaults
    except Exception:
        return (
            DEFAULT_WEBHOOK_TIMEOUT_MS / 1000.0,
            DEFAULT_WEBHOOK_MAX_RETRIES,
            DEFAULT_WEBHOOK_BACKOFF_BASE,
            DEFAULT_WEBHOOK_MAX_RESPONSE_LENGTH,
        )


class WebhookExecutor:
    """Execute webhooks with retry logic and HMAC signing.

    Implements exponential backoff for failed deliveries and
    supports optional HMAC-SHA256 signature verification.
    """

    def __init__(
        self,
        max_retries: int | None = None,
        timeout_seconds: float | None = None,
        backoff_base: float | None = None,
        max_response_length: int | None = None,
    ) -> None:
        """Initialize webhook executor.

        Args:
            max_retries: Maximum delivery attempts (uses settings if None).
            timeout_seconds: HTTP request timeout (uses settings if None).
            backoff_base: Exponential backoff multiplier (uses settings if None).
            max_response_length: Max response body to log (uses settings if None).
        """
        defaults = _get_webhook_settings()
        self._timeout = timeout_seconds if timeout_seconds is not None else defaults[0]
        self._max_retries = max_retries if max_retries is not None else defaults[1]
        self._backoff_base = backoff_base if backoff_base is not None else defaults[2]
        self._max_response_length = max_response_length if max_response_length is not None else defaults[3]
        self._client: httpx.AsyncClient | None = None

    async def _ensure_client(self) -> httpx.AsyncClient:
        """Lazy-initialize HTTP client with connection limits.

        Returns:
            Configured async HTTP client.
        """
        if self._client is None:
            self._client = httpx.AsyncClient(
                timeout=self._timeout,
                limits=httpx.Limits(
                    max_connections=MAX_CONNECTIONS,
                    max_keepalive_connections=MAX_KEEPALIVE_CONNECTIONS,
                ),
            )
        return self._client

    def _check_delivery_preconditions(
        self,
        ctx: _DeliveryContext,
    ) -> WebhookDelivery | None:
        """Check if delivery should proceed.

        Args:
            ctx: Delivery context with config and event info.

        Returns:
            WebhookDelivery if preconditions fail, None if delivery should proceed.
        """
        if not ctx.config.enabled:
            result = DeliveryResult(error_message="Webhook disabled", attempt_count=0)
            return self._create_delivery(ctx, result)

        if not ctx.config.subscribes_to(ctx.event_type):
            result = DeliveryResult(
                error_message=f"Event {ctx.event_type.value} not subscribed",
                attempt_count=0,
            )
            return self._create_delivery(ctx, result)

        return None

    async def _attempt_delivery(
        self,
        client: httpx.AsyncClient,
        config: WebhookConfig,
        headers: dict[str, str],
        payload: WebhookPayloadDict,
        attempt: int,
        max_retries: int,
    ) -> tuple[httpx.Response | None, str | None]:
        """Execute a single delivery attempt.

        Args:
            client: HTTP client.
            config: Webhook configuration.
            headers: Request headers.
            payload: Event payload.
            attempt: Current attempt number.
            max_retries: Maximum retry count.

        Returns:
            Tuple of (response, error_message). Response is None on error.
        """
        try:
            _logger.debug("Webhook delivery attempt %d/%d to %s", attempt, max_retries, config.url)
            response = await client.post(
                config.url,
                json=payload,
                headers=headers,
                timeout=config.timeout_ms / 1000.0,
            )
            return response, None
        except httpx.TimeoutException:
            _logger.warning("Webhook timeout (attempt %d/%d): %s", attempt, max_retries, config.url)
            return None, "Request timed out"
        except httpx.RequestError as e:
            _logger.warning(
                "Webhook request error (attempt %d/%d): %s - %s", attempt, max_retries, config.url, e
            )
            return None, str(e)

    def _handle_response(
        self,
        response: httpx.Response,
        ctx: _DeliveryContext,
        attempt: int,
        max_retries: int,
        duration_ms: int,
    ) -> tuple[WebhookDelivery | None, str | None]:
        """Handle HTTP response and determine if retry is needed.

        Args:
            response: HTTP response from webhook endpoint.
            ctx: Delivery context with config and event info.
            attempt: Current attempt number.
            max_retries: Maximum retry count.
            duration_ms: Time taken for this attempt.

        Returns:
            Tuple of (delivery, error). Delivery is set if final, error is set if retry needed.
        """
        response_body = response.text[: self._max_response_length]

        if response.status_code in RETRYABLE_STATUS_CODES:
            _logger.warning(
                "Webhook received retryable status %d (attempt %d/%d): %s",
                response.status_code, attempt, max_retries, ctx.config.url,
            )
            return None, f"Retryable status code: {response.status_code}"

        result = DeliveryResult(
            status_code=response.status_code,
            response_body=None if response.is_success else response_body,
            error_message=None if response.is_success else response_body,
            attempt_count=attempt,
            duration_ms=duration_ms,
        )
        return self._create_delivery(ctx, result), None

    async def deliver(
        self,
        config: WebhookConfig,
        event_type: WebhookEventType,
        payload: WebhookPayloadDict,
    ) -> WebhookDelivery:
        """Deliver webhook with retries.

        Args:
            config: Webhook configuration.
            event_type: Type of event being delivered.
            payload: Event payload data.

        Returns:
            Delivery record with status information.
        """
        ctx = _DeliveryContext(
            delivery_id=uuid4(),
            config=config,
            event_type=event_type,
            payload=payload,
        )
        _logger.info(
            "webhook_delivery_started",
            delivery_id=str(ctx.delivery_id),
            event_type=event_type.value,
            url=config.url,
        )

        if early_return := self._check_delivery_preconditions(ctx):
            return early_return

        headers = self._build_headers(ctx)
        client = await self._ensure_client()
        max_retries = min(config.max_retries, self._max_retries)
        last_error: str | None = None
        attempt = 0
        max_delay, jitter_max = 60.0, 1.0

        for attempt in range(1, max_retries + 1):
            start_time = time.monotonic()
            response, error = await self._attempt_delivery(
                client, config, headers, payload, attempt, max_retries
            )

            if response is not None:
                duration_ms = int((time.monotonic() - start_time) * 1000)
                delivery, retry_error = self._handle_response(
                    response, ctx, attempt, max_retries, duration_ms
                )
                if delivery is not None:
                    return delivery
                last_error = retry_error
            else:
                last_error = error

            if attempt < max_retries:
                delay = min(max_delay, self._backoff_base ** (attempt - 1)) + random.uniform(0, jitter_max)
                await asyncio.sleep(delay)

        result = DeliveryResult(
            error_message=f"Max retries exceeded: {last_error}",
            attempt_count=attempt,
        )
        return self._create_delivery(ctx, result)

    def _build_headers(self, ctx: _DeliveryContext) -> dict[str, str]:
        """Build HTTP headers for webhook request.

        Args:
            ctx: Delivery context with config and event info.

        Returns:
            Headers dictionary including signature if secret configured.
        """
        delivery_id_str = str(ctx.delivery_id)
        timestamp = str(int(utc_now().timestamp()))

        headers = {
            HTTP_HEADER_CONTENT_TYPE: HTTP_CONTENT_TYPE_JSON,
            HTTP_HEADER_WEBHOOK_EVENT: ctx.event_type.value,
            HTTP_HEADER_WEBHOOK_DELIVERY: delivery_id_str,
            HTTP_HEADER_WEBHOOK_TIMESTAMP: timestamp,
        }

        if ctx.config.secret:
            # Canonical JSON with sorted keys for cross-platform verification
            body = json.dumps(ctx.payload, separators=(",", ":"), sort_keys=True)
            # Include timestamp and delivery ID in signature for replay protection
            # Signature format: timestamp.delivery_id.body
            signature_base = f"{timestamp}.{delivery_id_str}.{body}"
            signature = hmac.new(
                ctx.config.secret.encode(),
                signature_base.encode(),
                hashlib.sha256,
            ).hexdigest()
            headers[HTTP_HEADER_WEBHOOK_SIGNATURE] = f"{WEBHOOK_SIGNATURE_PREFIX}{signature}"

        return headers

    def _create_delivery(self, ctx: _DeliveryContext, result: DeliveryResult) -> WebhookDelivery:
        """Create a delivery record.

        Args:
            ctx: Delivery context with config and event info.
            result: Delivery outcome (status, error, attempts, duration).

        Returns:
            WebhookDelivery record with correlated delivery ID.
        """
        delivery = WebhookDelivery(
            id=ctx.delivery_id,
            webhook_id=ctx.config.id,
            event_type=ctx.event_type,
            payload=ctx.payload,
            status_code=result.status_code,
            response_body=result.response_body,
            error_message=result.error_message,
            attempt_count=result.attempt_count,
            duration_ms=result.duration_ms,
            delivered_at=utc_now(),
        )
        status = "success" if delivery.succeeded else "failed"
        _logger.info(
            "webhook_delivery_completed",
            delivery_id=str(ctx.delivery_id),
            event_type=ctx.event_type.value,
            status=status,
            status_code=result.status_code,
            attempt_count=result.attempt_count,
            duration_ms=result.duration_ms,
        )

        # Record metrics for observability (Sprint GAP-003)
        get_webhook_metrics().record_delivery(
            event_type=ctx.event_type,
            success=delivery.succeeded,
            duration_ms=result.duration_ms or 0,
            attempts=result.attempt_count,
        )

        return delivery

    async def close(self) -> None:
        """Close HTTP client and release resources.

        Uses a timeout to prevent hanging on slow connections during shutdown.
        """
        if self._client is not None:
            try:
                await asyncio.wait_for(self._client.aclose(), timeout=5.0)
            except TimeoutError:
                _logger.warning("Webhook HTTP client close timed out after 5s")
            except (httpx.CloseError, RuntimeError) as e:
                _logger.warning("Error closing webhook HTTP client: %s", e)
            finally:
                self._client = None
