"""Minimal WordAuth client.

This client implements basic retry/backoff behavior for rate limits (HTTP 429)
and simple generate/verify helpers used by your backend authentication API.

The backend is expected to handle global rate-limits; the client will retry
locally on 429 according to a small exponential backoff and respect a
Retry-After header when present.
"""
from typing import Optional, Dict, Any
from dataclasses import dataclass
from datetime import datetime
import os
import time
import requests


class AuthError(Exception):
    """Base error for WordAuth client failures.

    Raised for non-network and non-rate-limit errors returned by the client
    when the backend returns an unexpected response or when a non-retryable
    network error occurs.
    """


class RateLimitError(AuthError):
    """Raised when the client exhausts retries due to rate limiting (HTTP 429).

    The client will attempt a small number of retries and respect a
    Retry-After header when present. If retries are exhausted, this error is
    raised so callers can handle rate-limit conditions explicitly.
    """


class AuthClient:
    def __init__(
        self,
        api_key: Optional[str] = None,
        base_url: str = "https://api.wordauth.com",
        timeout: float = 5.0,
        max_retries: int = 3,
        backoff_factor: float = 0.5,
    ) -> None:
        """Create a new AuthClient.

        Parameters
        - api_key: Optional API key. If omitted, the client will read
          the `WORDAUTH_KEY` environment variable.
        - base_url: Base URL of the WordAuth backend (no trailing slash
          required).
        - timeout: Request timeout in seconds for network calls.
        - max_retries: Max number of attempts for transient errors
          (network errors and 429 rate-limits).
        - backoff_factor: Base backoff seconds used for exponential backoff.

        Raises
        - ValueError: if no API key is provided and `WORDAUTH_KEY` is unset.
        """

        if not api_key:
            api_key = os.getenv("WORDAUTH_KEY")
        if not api_key:
            raise ValueError(
                "api_key is required (or set WORDAUTH_KEY env var)"
            )
        self.api_key = api_key
        self.base_url = base_url.rstrip("/")
        self.timeout = timeout
        self.max_retries = max_retries
        self.backoff_factor = backoff_factor
        self._headers = {
            "Authorization": f"Bearer {self.api_key}",
            "Content-Type": "application/json",
        }

    def _request(
        self, method: str, path: str, json: Optional[Dict[str, Any]] = None
    ) -> Any:
        """Perform an HTTP request against the backend with retries.

        This helper performs a synchronous HTTP request using `requests`, and
        implements conservative retry logic for network errors and HTTP 429
        responses. It returns the parsed JSON body on success, or the raw text
        body when JSON decoding fails.

        Returns
        - Parsed JSON object or raw text on success.

        Raises
        - AuthError: for HTTP errors (>=400) or network failures after retries.
        - RateLimitError: when retries are exhausted due to repeated 429s.
        """

        url = f"{self.base_url}{path}"
        for attempt in range(1, self.max_retries + 1):
            try:
                resp = requests.request(
                    method,
                    url,
                    headers=self._headers,
                    json=json,
                    timeout=self.timeout,
                )
            except requests.RequestException as exc:
                # network-level error: either retry or raise on final attempt
                if attempt == self.max_retries:
                    raise AuthError(f"network error: {exc}") from exc
                sleep = self.backoff_factor * (2 ** (attempt - 1))
                time.sleep(sleep)
                continue

            if resp.status_code == 429:
                # Respect Retry-After if present, otherwise exponential backoff
                ra = resp.headers.get("Retry-After")
                try:
                    # Try to interpret Retry-After as seconds first
                    if ra and ra.isdigit():
                        sleep = float(ra)
                    else:
                        sleep = self.backoff_factor * (2 ** (attempt - 1))
                except Exception:
                    sleep = self.backoff_factor * (2 ** (attempt - 1))
                if attempt == self.max_retries:
                    raise RateLimitError(
                        f"rate limited after {attempt} attempts"
                    )
                time.sleep(sleep)
                continue

            if resp.status_code >= 400:
                # Return error info for debugging; callers can raise if desired
                raise AuthError(
                    f"request failed: {resp.status_code} {resp.text}"
                )

            # Success
            try:
                return resp.json()
            except ValueError:
                return resp.text

        # Should not reach here
        raise AuthError("unhandled request error")

    def generate(
        self,
        session_id: Optional[str] = None,
        ttl_seconds: Optional[int] = None,
    ) -> "GenerateResponse":
        """Request generation of an OTP from `/v1/generate`.

        Parameters:
        session_id: Optional session identifier that associates the OTP with
            a particular user session.
        ttl_seconds: Optional time-to-live for the generated OTP in seconds.

        Returns:
        A `GenerateResponse` instance containing `otp_id`, `code` and
        `expires_at`.

        Raises:
        AuthError: on unexpected response shapes or backend errors.
        RateLimitError: if rate limited and retries are exhausted.
        """

        payload: Dict[str, Any] = {}
        if session_id is not None:
            payload["session_id"] = session_id
        if ttl_seconds is not None:
            payload["ttl_seconds"] = int(ttl_seconds)

        data = self._request("POST", "/v1/generate", json=payload)
        if not isinstance(data, dict):
            raise AuthError("unexpected generate response")
        return GenerateResponse.from_dict(data)

    def validate(
        self,
        code: str,
        otp_id: Optional[str] = None,
        session_id: Optional[str] = None,
    ) -> "ValidateResponse":
        """Validate a supplied code against `/v1/validate`.

        Parameters:
        code: The user-supplied code to validate (required).
        otp_id: Optional OTP ID returned by `/v1/generate`.
        session_id: Optional session ID to validate by session.

        Returns:
        A `ValidateResponse` instance with `valid` and optional `message`.

        Raises:
        ValueError: if `code` is empty.
        AuthError: on unexpected response shapes or backend errors.
        """
        if not code:
            raise ValueError("code is required")
        payload: Dict[str, Any] = {"code": code}
        if otp_id is not None:
            payload["otp_id"] = otp_id
        if session_id is not None:
            payload["session_id"] = session_id

        data = self._request("POST", "/v1/validate", json=payload)
        if not isinstance(data, dict):
            raise AuthError("unexpected validate response")
        return ValidateResponse.from_dict(data)


@dataclass
class GenerateResponse:
    otp_id: str
    code: str
    session_id: Optional[str]
    expires_at: datetime

    @classmethod
    def from_dict(cls, d: Dict[str, Any]) -> "GenerateResponse":
        """Create a GenerateResponse from a parsed JSON dict.

        Expects `expires_at` to be an ISO8601 string. Raises AuthError on
        missing or malformed timestamps.
        """

        expires = d.get("expires_at")
        if isinstance(expires, str):
            # Support ISO8601 strings, including a trailing Z
            try:
                if expires.endswith("Z"):
                    expires = expires.replace("Z", "+00:00")
                expires_dt = datetime.fromisoformat(expires)
            except Exception:
                raise AuthError("invalid expires_at in response")
        else:
            raise AuthError("missing expires_at in response")

        return cls(
            otp_id=str(d.get("otp_id")),
            code=str(d.get("code")),
            session_id=d.get("session_id"),
            expires_at=expires_dt,
        )


@dataclass
class ValidateResponse:
    valid: bool
    message: Optional[str]

    @classmethod
    def from_dict(cls, d: Dict[str, Any]) -> "ValidateResponse":
        """Create a ValidateResponse from a parsed JSON dict.

        The backend should return a `valid` boolean and optional `message`.
        This method normalizes missing fields to sensible defaults.
        """

        return cls(valid=bool(d.get("valid", False)),
                   message=d.get("message"))
