"""GraphQL client for the AiVibe universal platform API (api.aivibe.cloud).

This client replaces the former REST client and interacts with the AppSync
GraphQL endpoint for all platform-related operations, including tenant
lookup, plan validation, and credit management.
"""

from __future__ import annotations

import logging
import threading
from typing import Any, ClassVar, TypeVar

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

from aicippy.auth.keychain import KeychainStorage
from aicippy.config import get_settings
from aicippy.exceptions import (
    AiCippyError,
    AuthenticationRequiredError,
    BillingError,
    ErrorCode,
    ErrorContext,
    PlanInactiveError,
    PlatformApiError,
)

# Module-level logger
logger = logging.getLogger(__name__)

T = TypeVar("T", bound="GraphQLClient")

# --- GraphQL Queries and Mutations ---

# Fetches the user's active subscription for a specific product.
# Replaces the old `validate_plan` REST endpoint.
GET_ACTIVE_SUBSCRIPTION_QUERY = """
query GetActiveSubscription($productId: ID!) {
  activeSubscription(productId: $productId) {
    id
    status
    plan {
      id
      planCode
      planName
      features
    }
  }
}
"""

# Fetches the user's credit wallet for a specific product.
# Replaces the old `get_credits` REST endpoint.
GET_CREDIT_WALLET_QUERY = """
query GetCreditWallet($productId: ID!) {
  creditWallet(productId: $productId) {
    id
    balance
    currency
  }
}
"""

# Deducts credits from the user's wallet.
# Replaces the old `deduct_credits` REST endpoint.
USE_CREDITS_MUTATION = """
mutation UseCredits($input: UseCreditsInput!) {
  useCredits(input: $input) {
    id
    balance
  }
}
"""

# Tracks a generic analytics event.
# Replaces the old `report_usage` REST endpoint.
TRACK_ANALYTICS_EVENT_MUTATION = """
mutation TrackAnalyticsEvent($input: TrackEventInput!) {
  trackAnalyticsEvent(input: $input) {
    id
  }
}
"""

# Fetches an encrypted tenant secret by name.
GET_TENANT_SECRET_QUERY = """
query GetTenantSecret($tenantId: ID!, $secretName: String!) {
  tenantSecret(tenantId: $tenantId, secretName: $secretName) {
    value
  }
}
"""

# Stores (upserts) an encrypted tenant secret.
STORE_TENANT_SECRET_MUTATION = """
mutation StoreTenantSecret($input: StoreTenantSecretInput!) {
  storeTenantSecret(input: $input) {
    success
    error
  }
}
"""


class GraphQLClient:
    """An asynchronous GraphQL client for the AiVibe Platform API.

    This client handles communication with the AppSync endpoint, including
    authentication, request execution, and error handling with retries.
    """

    _DEFAULT_HEADERS: ClassVar[dict[str, str]] = {
        "Content-Type": "application/json",
        "Accept": "application/json",
    }
    _TIMEOUT: ClassVar[float] = 30.0
    _MAX_RETRIES: ClassVar[int] = 3
    _RETRY_MIN_WAIT: ClassVar[int] = 2
    _RETRY_MAX_WAIT: ClassVar[int] = 10
    _RETRY_MULTIPLIER: ClassVar[int] = 1

    _instance: ClassVar[GraphQLClient | None] = None
    _lock: ClassVar[threading.Lock] = threading.Lock()

    def __init__(self, token: str | None = None, api_url: str | None = None) -> None:
        """Initialize the GraphQL client.

        Args:
            token: The JWT authentication token. If not provided, it will be
                   loaded from the keychain.
            api_url: The platform API URL. If not provided, it's loaded from
                     settings.

        """
        settings = get_settings()
        self._api_url = api_url or settings.platform_api_url
        self._product_id = "aicippy"  # Assuming 'aicippy' is the product ID

        self._keychain = KeychainStorage()
        self._explicit_token = token

        # Verify that a token is available at init time.
        if not self._get_current_token():
            raise AuthenticationRequiredError(
                context=ErrorContext(operation="GraphQLClient.__init__"),
            )

        self._http_client = httpx.AsyncClient(
            timeout=self._TIMEOUT,
        )
        logger.debug("GraphQLClient initialized for %s", self._api_url)

    def _get_current_token(self) -> str | None:
        """Return the best available token, preferring an explicit override."""
        if self._explicit_token:
            return self._explicit_token
        tokens = self._keychain.get_tokens()
        return tokens.access_token if tokens else None

    def _get_fresh_headers(self) -> dict[str, str]:
        """Build request headers with a fresh token from the keychain."""
        token = self._get_current_token()
        if not token:
            raise AuthenticationRequiredError(
                context=ErrorContext(operation="_get_fresh_headers"),
            )
        headers = self._DEFAULT_HEADERS.copy()
        headers["Authorization"] = f"Bearer {token}"
        return headers

    @retry(
        # tenacity decorators are evaluated at definition so constants aren't ideal here
        stop=stop_after_attempt(3),
        wait=wait_exponential(multiplier=1, min=2, max=10),
        retry=retry_if_exception_type((httpx.NetworkError, httpx.TimeoutException)),
        reraise=True,
    )
    async def _execute_graphql(
        self,
        query: str,
        variables: dict[str, Any] | None = None,
    ) -> dict[str, Any]:
        """Execute a GraphQL query or mutation.

        Args:
            query: The GraphQL query or mutation string.
            variables: A dictionary of variables for the operation.

        Returns:
            The JSON response data from the API.

        Raises:
            PlatformApiError: If the API returns an error or a non-200 status code.

        """
        payload = {"query": query, "variables": variables or {}}
        try:
            headers = self._get_fresh_headers()
            response = await self._http_client.post(self._api_url, json=payload, headers=headers)
            response.raise_for_status()

            response_json = response.json()
            if "errors" in response_json:
                error_details = response_json["errors"][0]
                msg = f"GraphQL API error: {error_details.get('message', 'Unknown error')}"
                logger.error(msg, extra={"error": error_details})
                raise PlatformApiError(  # noqa: TRY301
                    msg,
                    context=ErrorContext(operation="_execute_graphql", details=error_details),
                )

            return response_json.get("data", {})

        except httpx.HTTPStatusError as e:
            msg = f"Platform API request failed: {e.response.status_code} {e.response.reason_phrase}"
            logger.exception(msg, extra={"response_text": e.response.text})
            raise PlatformApiError(
                msg,
                context=ErrorContext(
                    operation="_execute_graphql",
                    details={"status_code": e.response.status_code},
                ),
            ) from e
        except (httpx.NetworkError, httpx.TimeoutException) as e:
            msg = f"Platform API network error: {e}"
            logger.exception(msg)
            raise PlatformApiError(
                msg,
                code=ErrorCode.TIMEOUT,
                context=ErrorContext(operation="_execute_graphql"),
            ) from e
        except Exception as e:
            msg = f"An unexpected error occurred during GraphQL execution: {e}"
            logger.exception(msg)
            raise AiCippyError(
                msg,
                context=ErrorContext(operation="_execute_graphql"),
            ) from e

    async def validate_plan(self) -> dict[str, Any]:
        """Validate the user's current subscription plan.

        Returns:
            A dictionary containing the active subscription details.

        Raises:
            PlanInactiveError: If the user has no active subscription.
            PlatformApiError: If the API fails.

        """
        logger.debug("Validating plan for product ID: %s", self._product_id)
        variables = {"productId": self._product_id}
        data = await self._execute_graphql(GET_ACTIVE_SUBSCRIPTION_QUERY, variables)

        subscription = data.get("activeSubscription")
        if not subscription or subscription.get("status") != "ACTIVE":
            raise PlanInactiveError(
                context=ErrorContext(operation="validate_plan"),
            )

        return subscription

    async def get_credits(self) -> dict[str, Any]:
        """Fetch the user's current credit balance.

        Returns:
            A dictionary containing the credit wallet details.

        Raises:
            BillingError: If the credit wallet is not found.

        """
        logger.debug("Fetching credit wallet for product ID: %s", self._product_id)
        variables = {"productId": self._product_id}
        data = await self._execute_graphql(GET_CREDIT_WALLET_QUERY, variables)

        wallet = data.get("creditWallet")
        if not wallet:
            # Depending on business logic, we might want to create a wallet
            # or raise an error. For now, we raise.
            raise BillingError(  # noqa: TRY003
                "Credit wallet not found for this user.",
                code=ErrorCode.PLAN_NOT_FOUND,
                context=ErrorContext(operation="get_credits"),
            )

        return wallet

    async def deduct_credits(self, amount: int = 1) -> dict[str, Any]:
        """Deducts a specified number of credits from the user's wallet.

        Args:
            amount: The number of credits to deduct.

        Returns:
            The updated credit wallet information.

        """
        logger.debug("Deducting %d credits for product ID: %s", amount, self._product_id)
        variables = {
            "input": {
                "productId": self._product_id,
                "amount": amount,
                "description": "Usage from AiCippy CLI",
            },
        }
        data = await self._execute_graphql(USE_CREDITS_MUTATION, variables)
        return data.get("useCredits", {})

    async def report_usage(self, usage_data: dict[str, Any]) -> None:
        """Report token usage and other analytics events.

        Args:
            usage_data: A dictionary of properties to include in the event.

        """
        logger.debug("Reporting usage event with data: %s", usage_data)
        variables = {
            "input": {
                "eventName": "cli_usage",
                "eventCategory": "ai_operation",
                "properties": usage_data,
            },
        }
        await self._execute_graphql(TRACK_ANALYTICS_EVENT_MUTATION, variables)

    # ── Tenant Secrets ─────────────────────────────────────────────────

    async def get_tenant_secret(
        self,
        tenant_id: str,
        secret_name: str,
    ) -> str | None:
        """Retrieve an encrypted tenant secret by name.

        Args:
            tenant_id: Tenant identifier from JWT.
            secret_name: The name/key of the secret to retrieve.

        Returns:
            The decrypted secret value, or None if not found.

        """
        logger.debug("Fetching tenant secret: %s for tenant: %s", secret_name, tenant_id)
        variables = {"tenantId": tenant_id, "secretName": secret_name}
        data = await self._execute_graphql(GET_TENANT_SECRET_QUERY, variables)
        secret = data.get("tenantSecret")
        if not secret:
            return None
        return secret.get("value")

    async def store_tenant_secret(
        self,
        tenant_id: str,
        secret_name: str,
        secret_value: str,
    ) -> dict[str, Any]:
        """Store (upsert) an encrypted tenant secret.

        The secret is encrypted server-side via KMS and stored in RDS
        with tenant-level RLS. Only this tenant can read it back.

        Args:
            tenant_id: Tenant identifier from JWT.
            secret_name: The name/key of the secret to store.
            secret_value: The secret value to encrypt and store.

        Returns:
            A dict with ``success`` (bool) and optional ``error`` (str).

        """
        logger.debug("Storing tenant secret: %s for tenant: %s", secret_name, tenant_id)
        variables = {
            "input": {
                "tenantId": tenant_id,
                "secretName": secret_name,
                "secretValue": secret_value,
            },
        }
        data = await self._execute_graphql(STORE_TENANT_SECRET_MUTATION, variables)
        return data.get("storeTenantSecret", {"success": False, "error": "No response"})

    async def close(self) -> None:
        """Close the underlying HTTP client."""
        await self._http_client.aclose()
        logger.debug("GraphQLClient closed.")

    async def __aenter__(self: T) -> T:
        """Async context manager entry."""
        return self

    async def __aexit__(
        self,
        exc_type: type[BaseException] | None,
        exc_val: BaseException | None,
        exc_tb: Any | None,  # noqa: ANN401
    ) -> None:
        """Async context manager exit."""
        await self.close()

    @classmethod
    def get_instance(cls: type[T]) -> T:
        """Get or create the module-level GraphQLClient singleton.

        This function acts as a factory and provides a single instance of the
        client throughout the application's lifecycle.

        Returns:
            The singleton GraphQLClient instance.

        """
        if cls._instance is None:
            with cls._lock:
                if cls._instance is None:
                    try:
                        cls._instance = cls()
                    except AiCippyError:
                        logger.exception("Failed to initialize GraphQLClient")
                        raise
        return cls._instance  # type: ignore[return-value]  # narrowing after creation not tracked by mypy

    @classmethod
    def reset_instance(cls) -> None:
        """Reset the module-level GraphQLClient singleton.

        This is useful for re-authentication or in testing scenarios.
        """
        with cls._lock:
            cls._instance = None
            logger.debug("GraphQLClient instance has been reset.")


def get_platform_client() -> GraphQLClient:
    """Get the GraphQLClient singleton instance.

    Returns:
        The singleton GraphQLClient instance.

    """
    return GraphQLClient.get_instance()


def reset_platform_client() -> None:
    """Reset the GraphQLClient singleton instance."""
    GraphQLClient.reset_instance()
