"""
Shared integration base utilities.

Provides a BaseIntegration class with consistent HTTP behavior:
- Automatic retries with exponential backoff and jitter
- Respect for Retry-After on 429s
- Basic error classification (auth, rate limit, transient)
- Structured logging for observability
"""

from __future__ import annotations

import contextlib
import logging
import time
from dataclasses import dataclass
from email.utils import parsedate_to_datetime
from typing import Callable, Iterable, Optional

import requests

from g_gremlin.credentials.provider import CredentialProvider

logger = logging.getLogger(__name__)


@dataclass
class RetryConfig:
    max_attempts: int = 4
    base_delay: float = 1.0
    max_delay: float = 30.0
    jitter: bool = True
    retry_on: tuple[int, ...] = (429, 500, 502, 503, 504)


class IntegrationError(Exception):
    """Base exception for all integration errors."""


class AuthenticationError(IntegrationError):
    """Raised for authentication/authorization failures."""


class RateLimitError(IntegrationError):
    """Raised for 429 responses."""

    def __init__(self, message: str, retry_after: float | None = None):
        super().__init__(message)
        self.retry_after = retry_after


class TransientError(IntegrationError):
    """Raised for retryable server-side failures."""


class BaseIntegration:
    """
    Shared base class for all g-gremlin integrations.
    Provides consistent auth, retries, rate-limiting, and observability.
    """

    SERVICE_NAME: str = "integration"

    def __init__(
        self,
        credential_provider: CredentialProvider | None = None,
        default_retry_config: RetryConfig | None = None,
    ) -> None:
        self.credential_provider = credential_provider or CredentialProvider()
        self.default_retry_config = default_retry_config or RetryConfig()
        self._session = requests.Session()
        self.correlation_id: Optional[str] = None

    # ------------------------------------------------------------------
    # HTTP helpers
    # ------------------------------------------------------------------
    def _request(
        self,
        method: str,
        url: str,
        *,
        params: dict | None = None,
        json: dict | None = None,
        data: dict | str | None = None,
        headers: dict | None = None,
        timeout: float = 30.0,
        retry_config: RetryConfig | None = None,
        auth: tuple | None = None,
        allow_statuses: tuple[int, ...] = (),
    ) -> requests.Response:
        cfg = retry_config or self.default_retry_config
        # Sanitize headers to prevent UnicodeEncodeError from copy-pasted smart quotes
        safe_headers = self._sanitize_headers(headers)
        attempt = 1
        while True:
            with self._timed_request(method, url) as timer:
                try:
                    response = self._session.request(
                        method=method,
                        url=url,
                        params=params,
                        json=json,
                        data=data,
                        headers=safe_headers,
                        auth=auth,
                        timeout=timeout,
                    )
                except requests.RequestException as exc:
                    if attempt >= cfg.max_attempts:
                        raise TransientError(f"{self.SERVICE_NAME} request failed: {exc}") from exc
                    delay = self._compute_delay(cfg, attempt)
                    logger.warning(
                        "%s.retry transport_error attempt=%s delay=%.2fs error=%s correlation_id=%s",
                        self.SERVICE_NAME,
                        attempt,
                        delay,
                        exc,
                        self.correlation_id,
                    )
                    self._sleep(delay)
                    attempt += 1
                    continue

            self._log_request(method, url, response.status_code, timer())

            status = response.status_code
            if status in allow_statuses:
                return response

            if status in (401, 403):
                message = (
                    f"{self.SERVICE_NAME} auth failed ({status}). "
                    f"Run g-gremlin {self.SERVICE_NAME} doctor to check credentials."
                )
                if self.SERVICE_NAME == "hubspot":
                    message = (
                        f"{message} "
                        "For scopes, run g-gremlin hubspot setup --pack <dir> or --full-beta."
                    )
                raise AuthenticationError(message)

            if status == 429:
                retry_after = self._retry_after_seconds(response)
                error = RateLimitError(
                    f"{self.SERVICE_NAME} rate limited (429)",
                    retry_after=retry_after,
                )
                if attempt >= cfg.max_attempts:
                    raise RateLimitError(
                        f"{self.SERVICE_NAME} rate limited (429). "
                        "Try --safe mode for conservative rate limiting.",
                        retry_after=retry_after,
                    )
                delay = retry_after if retry_after is not None else self._compute_delay(cfg, attempt)
                logger.warning(
                    "%s.retry rate_limit attempt=%s delay=%.2fs correlation_id=%s",
                    self.SERVICE_NAME,
                    attempt,
                    delay,
                    self.correlation_id,
                )
                self._sleep(delay)
                attempt += 1
                continue

            if status in cfg.retry_on:
                if attempt >= cfg.max_attempts:
                    raise TransientError(
                        f"{self.SERVICE_NAME} retryable error {status}: {self._response_snippet(response)}"
                    )
                delay = self._compute_delay(cfg, attempt)
                logger.warning(
                    "%s.retry status=%s attempt=%s delay=%.2fs correlation_id=%s",
                    self.SERVICE_NAME,
                    status,
                    attempt,
                    delay,
                    self.correlation_id,
                )
                self._sleep(delay)
                attempt += 1
                continue

            if not response.ok:
                raise IntegrationError(
                    f"{self.SERVICE_NAME} error {status}: {self._response_snippet(response)}"
                )

            return response

    def _get(self, url: str, **kwargs) -> requests.Response:
        return self._request("GET", url, **kwargs)

    def _post(self, url: str, **kwargs) -> requests.Response:
        return self._request("POST", url, **kwargs)

    def _put(self, url: str, **kwargs) -> requests.Response:
        return self._request("PUT", url, **kwargs)

    def _patch(self, url: str, **kwargs) -> requests.Response:
        return self._request("PATCH", url, **kwargs)

    def _delete(self, url: str, **kwargs) -> requests.Response:
        return self._request("DELETE", url, **kwargs)

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------
    def _compute_delay(self, cfg: RetryConfig, attempt: int) -> float:
        delay = cfg.base_delay * (2 ** (attempt - 1))
        if delay > cfg.max_delay:
            delay = cfg.max_delay
        if cfg.jitter:
            import random

            delay *= random.uniform(0.5, 1.5)
        return delay

    def _retry_after_seconds(self, response: requests.Response) -> float | None:
        header = response.headers.get("Retry-After")
        if not header:
            return None
        try:
            return float(header)
        except ValueError:
            try:
                dt = parsedate_to_datetime(header)
                return max(dt.timestamp() - time.time(), 0.0)
            except Exception:
                return None

    def _response_snippet(self, response: requests.Response, limit: int = 200) -> str:
        try:
            text = response.text or ""
        except Exception:
            text = ""
        return text[:limit]

    def _log_request(self, method: str, url: str, status: int, duration_ms: float) -> None:
        logger.info(
            "%s.request method=%s url=%s status=%s duration_ms=%.1f correlation_id=%s",
            self.SERVICE_NAME,
            method,
            self._sanitize_url(url),
            status,
            duration_ms,
            self.correlation_id,
        )

    def _sanitize_url(self, url: str) -> str:
        return url.split("?")[0]

    def _sanitize_headers(self, headers: dict | None) -> dict | None:
        """
        Sanitize header values to ensure ASCII-encodable content.

        This handles common issues like smart quotes from copy-paste:
        - \u2018 \u2019 (left/right single quotes) -> '
        - \u201c \u201d (left/right double quotes) -> "
        - \u2013 \u2014 (en/em dashes) -> -
        - Other non-ASCII characters are stripped
        """
        if not headers:
            return headers

        # Common Unicode replacements (smart quotes, fancy dashes)
        replacements = {
            "\u2018": "'",  # Left single quote
            "\u2019": "'",  # Right single quote
            "\u201c": '"',  # Left double quote
            "\u201d": '"',  # Right double quote
            "\u2013": "-",  # En dash
            "\u2014": "-",  # Em dash
            "\u00a0": " ",  # Non-breaking space
        }

        sanitized = {}
        for key, value in headers.items():
            if isinstance(value, str):
                # Apply common replacements
                for char, replacement in replacements.items():
                    value = value.replace(char, replacement)
                # Strip any remaining non-ASCII characters
                try:
                    value.encode("ascii")
                except UnicodeEncodeError:
                    # Remove non-ASCII characters that weren't in our replacement map
                    value = value.encode("ascii", errors="ignore").decode("ascii")
                    logger.warning(
                        "%s.sanitize_headers stripped non-ASCII from header %s",
                        self.SERVICE_NAME,
                        key,
                    )
            sanitized[key] = value
        return sanitized

    def _sleep(self, delay: float) -> None:
        time.sleep(delay)

    @contextlib.contextmanager
    def _timed_request(self, method: str, url: str) -> Iterable[Callable[[], float]]:
        start = time.time()

        def elapsed_ms() -> float:
            return (time.time() - start) * 1000

        yield elapsed_ms
