"""Asynchronous analytics client for FastAPI, Starlette, and other
async Python frameworks.

Uses :class:`httpx.AsyncClient` internally.  Never blocks the event
loop.
"""

from __future__ import annotations

import asyncio
import datetime
import logging
import uuid
from typing import Any, Callable, Dict, List, Optional

import httpx
from tenacity import (
    retry,
    retry_if_exception_type,
    stop_after_attempt,
    wait_exponential_jitter,
)

from .batch import AsyncBatchQueue
from .context import build_server_context
from .errors import (
    AuthenticationError,
    NetworkError,
    RateLimitError,
    TimeoutError,
    ValidationError,
)
from .middleware import run_middleware_async
from .types import FlushResult, SDKOptions, ServerContext, ServerEvent

logger = logging.getLogger("kanban_analytics")

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

DEFAULT_ENDPOINT = "/api/ingest"
DEFAULT_TIMEOUT = 5.0
DEFAULT_BATCH_SIZE = 50
DEFAULT_FLUSH_INTERVAL = 2.0
DEFAULT_MAX_RETRIES = 3
DEFAULT_RETRY_DELAY = 0.5


# ---------------------------------------------------------------------------
# AsyncAnalyticsClient
# ---------------------------------------------------------------------------


class AsyncAnalyticsClient:
    """Async analytics client.

    All public methods are coroutines.  Use :class:`AnalyticsClient`
    for synchronous code paths.
    """

    def __init__(self) -> None:
        self._api_key: str | None = None
        self._options: SDKOptions = {}  # type: ignore[typeddict-item]
        self._initialized = False
        self._disabled = False
        self._middleware: List[Callable[..., Any]] = []
        self._batch_queue: AsyncBatchQueue | None = None
        self._context: ServerContext | None = None
        self._client: httpx.AsyncClient | None = None
        self._current_user_id: str | None = None
        self._current_group_id: str | None = None
        self._api_key_validated = False

        # Resolved option values
        self._endpoint: str = DEFAULT_ENDPOINT
        self._timeout: float = DEFAULT_TIMEOUT
        self._max_retries: int = DEFAULT_MAX_RETRIES
        self._retry_delay: float = DEFAULT_RETRY_DELAY
        self._batch_mode: bool = False
        self._batch_size: int = DEFAULT_BATCH_SIZE
        self._flush_interval: float = DEFAULT_FLUSH_INTERVAL
        self._default_properties: Dict[str, Any] = {}
        self._debug: bool = False

    # -- lifecycle ----------------------------------------------------------

    async def init(
        self,
        api_key: str,
        *,
        endpoint: str | None = None,
        timeout: float = DEFAULT_TIMEOUT,
        max_retries: int = DEFAULT_MAX_RETRIES,
        retry_delay: float = DEFAULT_RETRY_DELAY,
        batch_mode: bool = False,
        batch_size: int = DEFAULT_BATCH_SIZE,
        flush_interval: float = DEFAULT_FLUSH_INTERVAL,
        default_properties: Dict[str, Any] | None = None,
        service_name: str | None = None,
        service_version: str | None = None,
        debug: bool = False,
        disabled: bool = False,
        validate_on_init: bool = True,
        middleware: List[Callable[..., Any]] | None = None,
    ) -> None:
        """Initialise the async SDK.  Call once at application startup."""
        if self._initialized:
            return

        self._api_key = api_key
        self._endpoint = endpoint or DEFAULT_ENDPOINT
        self._timeout = timeout
        self._max_retries = max_retries
        self._retry_delay = retry_delay
        self._batch_mode = batch_mode
        self._batch_size = batch_size
        self._flush_interval = flush_interval
        self._default_properties = default_properties or {}
        self._debug = debug
        self._disabled = disabled

        opts: SDKOptions = {}  # type: ignore[typeddict-item]
        if service_name is not None:
            opts["service_name"] = service_name
        if service_version is not None:
            opts["service_version"] = service_version
        self._options = opts

        if middleware:
            self._middleware = list(middleware)

        self._context = build_server_context(opts)
        self._client = httpx.AsyncClient(timeout=self._timeout)

        if self._batch_mode:
            self._batch_queue = AsyncBatchQueue(
                transport=self._transport_batch,
                batch_size=self._batch_size,
                flush_interval=self._flush_interval,
                debug=self._debug,
            )
            await self._batch_queue.start()

        if validate_on_init:
            await self._validate_api_key()
            self._api_key_validated = True

        self._initialized = True
        self._debug_log("Initialized (async)")

    # -- core tracking methods ----------------------------------------------

    async def track(
        self,
        event: str,
        properties: Dict[str, Any] | None = None,
        *,
        user_id: str | None = None,
        anonymous_id: str | None = None,
        group_id: str | None = None,
        timestamp: datetime.datetime | None = None,
        await_response: bool = False,
    ) -> None:
        """Track a custom event."""
        self._ensure_initialized()
        if self._disabled:
            return

        resolved_user = user_id or self._current_user_id
        resolved_group = group_id or self._current_group_id
        self._validate_identity(resolved_user, anonymous_id, resolved_group)

        server_event = self._build_event(
            event_type="track",
            event_name=event,
            properties={**self._default_properties, **(properties or {})},
            user_id=resolved_user,
            anonymous_id=anonymous_id,
            group_id=resolved_group,
            timestamp=timestamp,
        )

        if await_response:
            await self._process_event(server_event)
        else:
            asyncio.create_task(self._send_fire_and_forget(server_event))

    async def identify(
        self,
        user_id: str,
        traits: Dict[str, Any] | None = None,
        *,
        await_response: bool = False,
    ) -> None:
        """Identify a user and set traits."""
        self._ensure_initialized()
        if self._disabled:
            return

        self._current_user_id = user_id

        server_event = self._build_event(
            event_type="identify",
            user_id=user_id,
            traits=traits,
        )

        if await_response:
            await self._process_event(server_event)
        else:
            asyncio.create_task(self._send_fire_and_forget(server_event))

    async def group(
        self,
        group_id: str,
        traits: Dict[str, Any] | None = None,
        *,
        user_id: str | None = None,
        await_response: bool = False,
    ) -> None:
        """Set group context for all subsequent calls."""
        self._ensure_initialized()
        if self._disabled:
            return

        self._current_group_id = group_id

        server_event = self._build_event(
            event_type="group",
            user_id=user_id or self._current_user_id,
            group_id=group_id,
            traits=traits,
        )

        if await_response:
            await self._process_event(server_event)
        else:
            asyncio.create_task(self._send_fire_and_forget(server_event))

    async def screen(
        self,
        name: str,
        properties: Dict[str, Any] | None = None,
        *,
        user_id: str | None = None,
        await_response: bool = False,
    ) -> None:
        """Track a screen or page view."""
        self._ensure_initialized()
        if self._disabled:
            return

        resolved_user = user_id or self._current_user_id
        self._validate_identity(resolved_user, None, self._current_group_id)

        server_event = self._build_event(
            event_type="screen",
            event_name=name,
            properties=properties,
            user_id=resolved_user,
        )

        if await_response:
            await self._process_event(server_event)
        else:
            asyncio.create_task(self._send_fire_and_forget(server_event))

    # -- identity helpers ---------------------------------------------------

    def reset(self) -> None:
        """Clear current user and group identity."""
        self._current_user_id = None
        self._current_group_id = None

    # -- middleware ---------------------------------------------------------

    def use(self, middleware: Callable[..., Any]) -> None:
        """Add middleware to the pipeline."""
        self._middleware.append(middleware)

    # -- flush / shutdown ---------------------------------------------------

    async def flush(self) -> FlushResult:
        """Manually flush the batch queue."""
        if self._batch_queue:
            return await self._batch_queue.flush()
        return {"sent": 0, "failed": 0}

    async def shutdown(self) -> None:
        """Flush remaining events and close HTTP connections."""
        self._disabled = True
        if self._batch_queue:
            await self._batch_queue.destroy()
        if self._client:
            await self._client.aclose()
            self._client = None
        self._debug_log("Shutdown complete (async)")

    # -- scoped context -----------------------------------------------------

    def with_context(self, **context: Any) -> "AsyncScopedAnalyticsClient":
        """Create a scoped client with pre-set context."""
        return AsyncScopedAnalyticsClient(self, context)

    # -- async context manager ----------------------------------------------

    async def __aenter__(self) -> "AsyncAnalyticsClient":
        return self

    async def __aexit__(self, *args: Any) -> None:
        await self.shutdown()

    # ======================================================================
    # Private helpers
    # ======================================================================

    def _ensure_initialized(self) -> None:
        if not self._initialized:
            raise RuntimeError("SDK not initialized. Call init() first.")

    @staticmethod
    def _validate_identity(
        user_id: str | None,
        anonymous_id: str | None,
        group_id: str | None,
    ) -> None:
        if not user_id and not anonymous_id and not group_id:
            raise ValidationError(
                "At least one of user_id, anonymous_id, or group_id is required."
            )

    def _build_event(
        self,
        event_type: str,
        event_name: str | None = None,
        properties: Dict[str, Any] | None = None,
        traits: Dict[str, Any] | None = None,
        user_id: str | None = None,
        anonymous_id: str | None = None,
        group_id: str | None = None,
        timestamp: datetime.datetime | None = None,
    ) -> ServerEvent:
        ts = timestamp or datetime.datetime.now(datetime.timezone.utc)
        if anonymous_id is None and (user_id or group_id):
            anonymous_id = self._options.get("service_name") or "server"  # type: ignore[call-overload]

        evt: ServerEvent = {
            "messageId": str(uuid.uuid4()),
            "type": event_type,  # type: ignore[typeddict-item]
            "timestamp": ts.isoformat(),
            "userId": user_id,
            "anonymousId": anonymous_id,
            "groupId": group_id,
            "context": self._context,  # type: ignore[typeddict-item]
        }
        if event_name is not None:
            evt["event"] = event_name
        if properties is not None:
            evt["properties"] = properties
        if traits is not None:
            evt["traits"] = traits
        return evt

    async def _process_event(self, event: ServerEvent) -> None:
        """Process event through middleware, then send."""
        # Lazy API-key validation
        if not self._api_key_validated:
            await self._validate_api_key()
            self._api_key_validated = True

        # Middleware
        final_event: ServerEvent | None = event
        if self._middleware:
            final_event = await run_middleware_async(event, self._middleware)
            if final_event is None:
                self._debug_log(
                    f"Event dropped by middleware: {event.get('event') or event.get('type')}"
                )
                return

        # Batch mode
        if self._batch_mode and self._batch_queue:
            await self._batch_queue.enqueue(dict(final_event))  # type: ignore[arg-type]
            return

        # Direct send with retry
        await self._send_with_retry(final_event)

    async def _send_fire_and_forget(self, event: ServerEvent) -> None:
        """Fire-and-forget wrapper — catches all errors internally."""
        try:
            await self._process_event(event)
        except Exception:
            if self._debug:
                logger.exception("[Analytics SDK] Fire-and-forget send failed")

    # -- transport ----------------------------------------------------------

    async def _send_with_retry(self, event: ServerEvent) -> None:
        """Send a single event with retry."""
        retryer = retry(
            stop=stop_after_attempt(self._max_retries + 1),
            wait=wait_exponential_jitter(initial=self._retry_delay, max=30),
            retry=retry_if_exception_type((NetworkError, TimeoutError)),
            reraise=True,
        )
        await retryer(self._send)(event)

    async def _send(self, event: ServerEvent) -> None:
        assert self._client is not None
        assert self._api_key is not None

        try:
            response = await self._client.post(
                self._endpoint,
                json={
                    "batch": [event],
                    "sentAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
                },
                headers={
                    "Content-Type": "application/json",
                    "x-api-key": self._api_key,
                },
                timeout=self._timeout,
            )
        except httpx.TimeoutException:
            raise TimeoutError(self._timeout)
        except httpx.HTTPError as exc:
            raise NetworkError(str(exc))

        self._handle_response(response)

    async def _transport_batch(self, batch: List[Dict[str, Any]]) -> None:
        """Transport callback used by :class:`AsyncBatchQueue`."""
        assert self._client is not None
        assert self._api_key is not None

        try:
            response = await self._client.post(
                self._endpoint,
                json={
                    "batch": batch,
                    "sentAt": datetime.datetime.now(datetime.timezone.utc).isoformat(),
                },
                headers={
                    "Content-Type": "application/json",
                    "x-api-key": self._api_key,
                },
                timeout=self._timeout,
            )
        except httpx.TimeoutException:
            raise TimeoutError(self._timeout)
        except httpx.HTTPError as exc:
            raise NetworkError(str(exc))

        self._handle_response(response)

    @staticmethod
    def _handle_response(response: httpx.Response) -> None:
        status = response.status_code
        if 200 <= status < 300:
            return

        if status == 401:
            raise AuthenticationError()
        if status == 429:
            retry_after = response.headers.get("Retry-After")
            retry_ms = int(float(retry_after) * 1000) if retry_after else 1000
            raise RateLimitError(retry_ms)
        if 400 <= status < 500:
            raise ValidationError(f"Server returned {status}")
        raise NetworkError(f"Server returned {status}", status_code=status)

    async def _validate_api_key(self) -> None:
        assert self._client is not None
        assert self._api_key is not None

        base_url = self._endpoint.replace("/ingest", "")
        validate_url = f"{base_url}/validate-key"

        try:
            response = await self._client.get(
                validate_url,
                headers={"x-api-key": self._api_key},
                timeout=self._timeout,
            )
        except httpx.TimeoutException:
            raise TimeoutError(self._timeout)
        except httpx.HTTPError as exc:
            raise NetworkError(str(exc))

        if response.status_code == 401:
            raise AuthenticationError()
        if not (200 <= response.status_code < 300):
            raise NetworkError(
                f"API key validation failed with status {response.status_code}",
                status_code=response.status_code,
            )

    def _debug_log(self, message: str) -> None:
        if self._debug:
            logger.debug("[Analytics SDK] %s", message)


# ---------------------------------------------------------------------------
# AsyncScopedAnalyticsClient
# ---------------------------------------------------------------------------


class AsyncScopedAnalyticsClient:
    """Lightweight async wrapper that delegates to a parent with pre-set context."""

    def __init__(self, parent: AsyncAnalyticsClient, context: Dict[str, Any]) -> None:
        self._parent = parent
        self._context = context

    async def track(
        self,
        event: str,
        properties: Dict[str, Any] | None = None,
        *,
        await_response: bool = False,
        **overrides: Any,
    ) -> None:
        merged = {**self._context, **overrides}
        await self._parent.track(
            event,
            properties=properties,
            user_id=merged.get("user_id"),
            anonymous_id=merged.get("anonymous_id"),
            group_id=merged.get("group_id"),
            await_response=await_response,
        )

    async def identify(
        self,
        user_id: str,
        traits: Dict[str, Any] | None = None,
        *,
        await_response: bool = False,
    ) -> None:
        await self._parent.identify(user_id, traits, await_response=await_response)

    async def group(
        self,
        group_id: str,
        traits: Dict[str, Any] | None = None,
        *,
        user_id: str | None = None,
        await_response: bool = False,
    ) -> None:
        resolved_user = user_id or self._context.get("user_id")
        await self._parent.group(
            group_id,
            traits,
            user_id=resolved_user,
            await_response=await_response,
        )

    async def screen(
        self,
        name: str,
        properties: Dict[str, Any] | None = None,
        *,
        user_id: str | None = None,
        await_response: bool = False,
    ) -> None:
        resolved_user = user_id or self._context.get("user_id")
        await self._parent.screen(
            name,
            properties,
            user_id=resolved_user,
            await_response=await_response,
        )
