from __future__ import annotations

from datetime import datetime
from enum import Enum
from typing import Any, Sequence

import aiohttp

from .enums import Currency, LinkStatus, LinkType, TransactionStatus
from .exceptions import (
    ValidationItem,
    WataAPIError,
    WataAuthError,
    WataForbiddenError,
    WataRateLimitError,
    WataServerError,
    WataValidationError,
)
from .models import (
    PaymentLink,
    PaymentLinkList,
    RefundResult,
    Transaction,
    TransactionList,
)

PRODUCTION_URL = "https://api.wata.pro/api/h2h"
SANDBOX_URL = "https://api-sandbox.wata.pro/api/h2h"

_DEFAULT_TIMEOUT = aiohttp.ClientTimeout(total=60)


class WataClient:
    """Async client for the WATA H2H payment API.

    :param token: Bearer access token from the WATA merchant dashboard.
    :param sandbox: Use the sandbox environment instead of production.
    :param base_url: Override the API base URL (takes precedence over *sandbox*).
    :param session: An existing :class:`aiohttp.ClientSession` to reuse.
        When omitted, the client creates and owns a new session.
    :param timeout: Request timeout (default 60 s).

    The client should be used as an async context manager so the underlying
    HTTP session is closed automatically::

        async with WataClient(token="...") as client:
            link = await client.create_link(amount=500, currency=Currency.RUB)
    """

    __slots__ = ("_token", "_base_url", "_session", "_owns_session", "_timeout")

    def __init__(
        self,
        token: str,
        *,
        sandbox: bool = False,
        base_url: str | None = None,
        session: aiohttp.ClientSession | None = None,
        timeout: aiohttp.ClientTimeout | None = None,
    ) -> None:
        self._token = token
        if base_url:
            self._base_url = base_url.rstrip("/")
        else:
            self._base_url = SANDBOX_URL if sandbox else PRODUCTION_URL
        self._session = session
        self._owns_session = session is None
        self._timeout = timeout or _DEFAULT_TIMEOUT

    # ------------------------------------------------------------------
    # Lifecycle
    # ------------------------------------------------------------------

    async def _ensure_session(self) -> aiohttp.ClientSession:
        if self._session is None or self._session.closed:
            self._session = aiohttp.ClientSession(timeout=self._timeout)
            self._owns_session = True
        return self._session

    async def close(self) -> None:
        """Close the HTTP session if it was created by the client."""
        if self._owns_session and self._session is not None and not self._session.closed:
            await self._session.close()
            self._session = None

    async def __aenter__(self) -> WataClient:
        return self

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

    # ------------------------------------------------------------------
    # Low-level request handling
    # ------------------------------------------------------------------

    def _headers(self) -> dict[str, str]:
        return {
            "Authorization": f"Bearer {self._token}",
            "Content-Type": "application/json",
        }

    async def _request(
        self,
        method: str,
        path: str,
        *,
        json_body: dict[str, Any] | None = None,
        params: list[tuple[str, str]] | None = None,
    ) -> Any:
        session = await self._ensure_session()
        url = f"{self._base_url}{path}"
        async with session.request(
            method,
            url,
            headers=self._headers(),
            json=json_body,
            params=params,
        ) as resp:
            return await self._handle_response(resp)

    async def _handle_response(self, resp: aiohttp.ClientResponse) -> Any:
        if resp.status == 200:
            return await resp.json()

        body: Any = None
        try:
            body = await resp.json()
        except Exception:
            body = await resp.text()

        if resp.status == 400:
            self._raise_validation(body)
        if resp.status == 401:
            raise WataAuthError(
                401,
                "Unauthorized — check your access token",
                response_body=body,
            )
        if resp.status == 403:
            raise WataForbiddenError(
                403,
                "Forbidden — insufficient permissions",
                response_body=body,
            )
        if resp.status == 429:
            retry_after: float | None = None
            if raw := resp.headers.get("Retry-After"):
                try:
                    retry_after = float(raw)
                except ValueError:
                    pass
            raise WataRateLimitError(
                retry_after=retry_after,
                message="Rate limit exceeded",
                response_body=body,
            )
        if resp.status >= 500:
            raise WataServerError(
                resp.status,
                "Internal server error",
                response_body=body,
            )
        raise WataAPIError(
            resp.status,
            f"Unexpected HTTP {resp.status}",
            response_body=body,
        )

    @staticmethod
    def _raise_validation(body: Any) -> None:
        if not isinstance(body, dict):
            raise WataValidationError(
                message=str(body) if body else "Bad request",
                response_body=body,
            )

        err = body.get("error", body) if "error" in body else body

        items = [
            ValidationItem(
                message=ve.get("message", ""),
                members=ve.get("members", []),
            )
            for ve in err.get("validationErrors") or []
        ]
        raise WataValidationError(
            message=err.get("message", ""),
            code=err.get("code"),
            details=err.get("details"),
            data=err.get("data"),
            validation_errors=items,
            response_body=body,
        )

    # ------------------------------------------------------------------
    # Helpers
    # ------------------------------------------------------------------

    @staticmethod
    def _dt_param(value: datetime | str | None) -> str | None:
        if value is None:
            return None
        if isinstance(value, datetime):
            return value.isoformat()
        return value

    @staticmethod
    def _enum_param(value: Enum | str) -> str:
        if isinstance(value, Enum):
            return str(value.value)
        return str(value)

    # ------------------------------------------------------------------
    # Payment Links
    # ------------------------------------------------------------------

    async def create_link(
        self,
        amount: float,
        currency: Currency | str,
        *,
        type: LinkType | str | None = None,
        description: str | None = None,
        order_id: str | None = None,
        success_redirect_url: str | None = None,
        fail_redirect_url: str | None = None,
        expiration_date_time: datetime | str | None = None,
        is_arbitrary_amount_allowed: bool | None = None,
        arbitrary_amount_prompts: Sequence[float] | None = None,
    ) -> PaymentLink:
        """Create a payment link.

        :param amount: Payment amount (min 10 RUB / 1 USD / 1 EUR, max 999 999.99).
        :param currency: Payment currency.
        :param type: Link type — ``OneTime`` (default) or ``ManyTime``.
        :param description: Order description displayed on the payment form.
        :param order_id: Your internal order identifier.
        :param success_redirect_url: Customer redirect URL after a successful payment.
        :param fail_redirect_url: Customer redirect URL after a failed payment.
        :param expiration_date_time: Link expiry (10 min – 30 days; default 3 days).
        :param is_arbitrary_amount_allowed: Allow the customer to change the amount.
        :param arbitrary_amount_prompts: Suggested amounts when arbitrary amount
            is enabled (3–6 values, each ≥ *amount*).
        :returns: The created :class:`PaymentLink`.
        """
        payload: dict[str, Any] = {
            "amount": amount,
            "currency": self._enum_param(currency),
        }
        if type is not None:
            payload["type"] = self._enum_param(type)
        if description is not None:
            payload["description"] = description
        if order_id is not None:
            payload["orderId"] = order_id
        if success_redirect_url is not None:
            payload["successRedirectUrl"] = success_redirect_url
        if fail_redirect_url is not None:
            payload["failRedirectUrl"] = fail_redirect_url
        if expiration_date_time is not None:
            payload["expirationDateTime"] = self._dt_param(expiration_date_time)
        if is_arbitrary_amount_allowed is not None:
            payload["isArbitraryAmountAllowed"] = is_arbitrary_amount_allowed
        if arbitrary_amount_prompts is not None:
            payload["arbitraryAmountPrompts"] = list(arbitrary_amount_prompts)

        data = await self._request("POST", "/links", json_body=payload)
        return PaymentLink.from_dict(data)

    async def get_link(self, link_id: str) -> PaymentLink:
        """Retrieve a payment link by its UUID.

        :param link_id: WATA payment link identifier.
        :returns: The :class:`PaymentLink`.
        """
        data = await self._request("GET", f"/links/{link_id}")
        return PaymentLink.from_dict(data)

    async def search_links(
        self,
        *,
        amount_from: float | None = None,
        amount_to: float | None = None,
        creation_time_from: datetime | str | None = None,
        creation_time_to: datetime | str | None = None,
        order_id: str | None = None,
        currencies: Sequence[Currency | str] | None = None,
        statuses: Sequence[LinkStatus | str] | None = None,
        sorting: str | None = None,
        skip_count: int | None = None,
        max_result_count: int | None = None,
    ) -> PaymentLinkList:
        """Search payment links matching the given filters.

        :param amount_from: Minimum payment amount.
        :param amount_to: Maximum payment amount.
        :param creation_time_from: Earliest creation date (ISO-8601 or ``datetime``).
        :param creation_time_to: Latest creation date.
        :param order_id: Filter by your internal order identifier.
        :param currencies: Filter by one or more currencies.
        :param statuses: Filter by link status (``Opened`` / ``Closed``).
        :param sorting: Sort field — ``orderId``, ``creationTime``, or ``amount``.
            Append ``desc`` for descending order (e.g. ``"amountdesc"``).
        :param skip_count: Records to skip (pagination offset).
        :param max_result_count: Records to return (default 10, max 1000).
        :returns: :class:`PaymentLinkList` with matching links and *total_count*.
        """
        params = self._search_link_params(
            amount_from=amount_from,
            amount_to=amount_to,
            creation_time_from=creation_time_from,
            creation_time_to=creation_time_to,
            order_id=order_id,
            currencies=currencies,
            statuses=statuses,
            sorting=sorting,
            skip_count=skip_count,
            max_result_count=max_result_count,
        )
        data = await self._request("GET", "/links/", params=params or None)
        return PaymentLinkList.from_dict(data)

    def _search_link_params(self, **kw: Any) -> list[tuple[str, str]]:
        params: list[tuple[str, str]] = []
        if kw["amount_from"] is not None:
            params.append(("amountFrom", str(kw["amount_from"])))
        if kw["amount_to"] is not None:
            params.append(("amountTo", str(kw["amount_to"])))
        if (v := self._dt_param(kw["creation_time_from"])) is not None:
            params.append(("creationTimeFrom", v))
        if (v := self._dt_param(kw["creation_time_to"])) is not None:
            params.append(("creationTimeTo", v))
        if kw["order_id"] is not None:
            params.append(("orderId", kw["order_id"]))
        if kw["currencies"]:
            for c in kw["currencies"]:
                params.append(("currencies", self._enum_param(c)))
        if kw["statuses"]:
            for s in kw["statuses"]:
                params.append(("statuses", self._enum_param(s)))
        if kw["sorting"] is not None:
            params.append(("sorting", kw["sorting"]))
        if kw["skip_count"] is not None:
            params.append(("skipCount", str(kw["skip_count"])))
        if kw["max_result_count"] is not None:
            params.append(("maxResultCount", str(kw["max_result_count"])))
        return params

    # ------------------------------------------------------------------
    # Transactions
    # ------------------------------------------------------------------

    async def get_transaction(self, transaction_id: str) -> Transaction:
        """Retrieve a transaction by its UUID.

        :param transaction_id: WATA transaction identifier.
        :returns: The :class:`Transaction`.
        """
        data = await self._request("GET", f"/transactions/{transaction_id}")
        return Transaction.from_dict(data)

    async def search_transactions(
        self,
        *,
        order_id: str | None = None,
        creation_time_from: datetime | str | None = None,
        creation_time_to: datetime | str | None = None,
        amount_from: float | None = None,
        amount_to: float | None = None,
        currencies: Sequence[Currency | str] | None = None,
        payment_link_ids: Sequence[str] | None = None,
        statuses: Sequence[TransactionStatus | str] | None = None,
        sorting: str | None = None,
        skip_count: int | None = None,
        max_result_count: int | None = None,
    ) -> TransactionList:
        """Search transactions matching the given filters.

        :param order_id: Filter by your internal order identifier.
        :param creation_time_from: Earliest creation date.
        :param creation_time_to: Latest creation date.
        :param amount_from: Minimum payment amount.
        :param amount_to: Maximum payment amount.
        :param currencies: Filter by one or more currencies.
        :param payment_link_ids: Filter by payment link UUIDs.
        :param statuses: Filter by transaction status.
        :param sorting: Sort field — ``amount`` or ``creationTime``.
            Append ``desc`` for descending order.
        :param skip_count: Records to skip (pagination offset).
        :param max_result_count: Records to return (default 10, max 1000).
        :returns: :class:`TransactionList` with matching transactions and *total_count*.
        """
        params = self._search_tx_params(
            order_id=order_id,
            creation_time_from=creation_time_from,
            creation_time_to=creation_time_to,
            amount_from=amount_from,
            amount_to=amount_to,
            currencies=currencies,
            payment_link_ids=payment_link_ids,
            statuses=statuses,
            sorting=sorting,
            skip_count=skip_count,
            max_result_count=max_result_count,
        )
        data = await self._request("GET", "/transactions/", params=params or None)
        return TransactionList.from_dict(data)

    def _search_tx_params(self, **kw: Any) -> list[tuple[str, str]]:
        params: list[tuple[str, str]] = []
        if kw["order_id"] is not None:
            params.append(("orderId", kw["order_id"]))
        if (v := self._dt_param(kw["creation_time_from"])) is not None:
            params.append(("creationTimeFrom", v))
        if (v := self._dt_param(kw["creation_time_to"])) is not None:
            params.append(("creationTimeTo", v))
        if kw["amount_from"] is not None:
            params.append(("amountFrom", str(kw["amount_from"])))
        if kw["amount_to"] is not None:
            params.append(("amountTo", str(kw["amount_to"])))
        if kw["currencies"]:
            for c in kw["currencies"]:
                params.append(("currencies", self._enum_param(c)))
        if kw["payment_link_ids"]:
            for pid in kw["payment_link_ids"]:
                params.append(("PaymentLinkIds", pid))
        if kw["statuses"]:
            for s in kw["statuses"]:
                params.append(("statuses", self._enum_param(s)))
        if kw["sorting"] is not None:
            params.append(("sorting", kw["sorting"]))
        if kw["skip_count"] is not None:
            params.append(("skipCount", str(kw["skip_count"])))
        if kw["max_result_count"] is not None:
            params.append(("maxResultCount", str(kw["max_result_count"])))
        return params

    # ------------------------------------------------------------------
    # Refunds
    # ------------------------------------------------------------------

    async def create_refund(
        self,
        original_transaction_id: str,
        amount: float,
    ) -> RefundResult:
        """Initiate a full or partial refund for a paid transaction.

        :param original_transaction_id: UUID of the original ``Paid`` transaction.
        :param amount: Refund amount in the original currency (must be > 0 and
            not exceed the remaining refundable balance).
        :returns: :class:`RefundResult` with the refund transaction details.
            Track the final status via webhooks.
        """
        payload = {
            "originalTransactionId": original_transaction_id,
            "amount": amount,
        }
        data = await self._request("POST", "/transactions/refunds", json_body=payload)
        return RefundResult.from_dict(data)

    # ------------------------------------------------------------------
    # Public Key
    # ------------------------------------------------------------------

    async def get_public_key(self) -> str:
        """Fetch the RSA public key for webhook signature verification.

        :returns: PEM-encoded public key string.
        """
        data = await self._request("GET", "/public-key")
        return data["value"]
