# services/iran_usdt_rate_service.py

from __future__ import annotations

import asyncio
import logging
import time
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import Any, Awaitable, Callable, Optional

import aiohttp


logger = logging.getLogger(__name__)


class IranUsdtRateError(Exception):
    pass


class IranUsdtAllProvidersFailed(IranUsdtRateError):
    pass


class IranUsdtInvalidResponse(IranUsdtRateError):
    pass


@dataclass(slots=True, frozen=True)
class IranUsdtQuote:
    rate_toman: Decimal
    source: str
    fetched_at: float
    stale: bool = False


@dataclass(slots=True)
class _CacheItem:
    quote: IranUsdtQuote
    expires_at: float
    stale_until: float


class IranUsdtRateService:
    """
    Sequential USDT/Toman fallback.

    Priority:
        Tabdeal -> Wallex -> Bitpin -> Nobitex -> Exir -> Sarrafex

    Normal pricing stops at the first valid source to reduce API pressure.
    """

    TABDEAL_DEPTH_URL = (
        "https://api1.tabdeal.org/r/api/v1/depth"
    )

    WALLEX_MARKETS_URL = (
        "https://api.wallex.ir/hector/web/v1/markets"
    )

    BITPIN_MARKETS_URL = (
        "https://api.bitpin.ir/v1/mkt/markets/"
    )

    NOBITEX_STATS_URL = (
        "https://apiv2.nobitex.ir/market/stats"
    )

    EXIR_TICKER_URL = (
        "https://api.exir.io/v2/ticker"
    )

    SARRAFEX_MARKETS_URL = (
        "https://api.sarrafex.com/Exchanger/query/market"
    )

    def __init__(
        self,
        *,
        timeout: float = 4.0,
        cache_ttl: float = 15.0,
        stale_ttl: float = 300.0,
        session: Optional[
            aiohttp.ClientSession
        ] = None,
        min_reasonable_toman: Decimal | int = Decimal(
            "10000"
        ),
        max_reasonable_toman: Decimal | int = Decimal(
            "10000000"
        ),
    ) -> None:

        self.timeout = max(
            1.0,
            float(
                timeout
            ),
        )

        self.cache_ttl = max(
            1.0,
            float(
                cache_ttl
            ),
        )

        self.stale_ttl = max(
            self.cache_ttl,
            float(
                stale_ttl
            ),
        )

        self.min_reasonable_toman = Decimal(
            str(
                min_reasonable_toman
            )
        )

        self.max_reasonable_toman = Decimal(
            str(
                max_reasonable_toman
            )
        )

        self._session = (
            session
        )

        self._owns_session = (
            session is None
        )

        self._cache: Optional[
            _CacheItem
        ] = None

        self._lock = (
            asyncio.Lock()
        )

        self._providers: list[
            tuple[
                str,
                Callable[
                    [],
                    Awaitable[
                        Decimal
                    ],
                ],
            ]
        ] = [
            (
                "tabdeal",
                self._tabdeal,
            ),
            (
                "wallex",
                self._wallex,
            ),
            (
                "bitpin",
                self._bitpin,
            ),
            (
                "nobitex",
                self._nobitex,
            ),
            (
                "exir",
                self._exir,
            ),
            (
                "sarrafex",
                self._sarrafex,
            ),
        ]

    # ========================================================
    # Session
    # ========================================================

    async def _get_session(
        self,
    ) -> aiohttp.ClientSession:

        if (
            self._session is not None
            and not self._session.closed
        ):
            return self._session

        timeout = aiohttp.ClientTimeout(
            total=self.timeout,
            connect=min(
                self.timeout,
                3.0,
            ),
        )

        self._session = (
            aiohttp.ClientSession(
                timeout=timeout,
                headers={
                    "Accept":
                        "application/json",

                    "User-Agent":
                        (
                            "MatrixTelegramBot/1.0 "
                            "USDT-Toman-Rate"
                        ),
                },
            )
        )

        self._owns_session = True

        return self._session

    # ========================================================
    # Close
    # ========================================================

    async def close(
        self,
    ) -> None:

        if (
            self._owns_session
            and self._session is not None
            and not self._session.closed
        ):
            await self._session.close()

        self._session = None

    # ========================================================
    # HTTP JSON
    # ========================================================

    async def _get_json(
        self,
        url: str,
        *,
        params: Optional[
            dict[
                str,
                Any,
            ]
        ] = None,
    ) -> Any:

        session = (
            await self._get_session()
        )

        try:

            async with session.get(
                url,
                params=params,
            ) as response:

                raw = (
                    await response.text()
                )

                if (
                    response.status
                    != 200
                ):

                    raise IranUsdtInvalidResponse(
                        (
                            f"HTTP "
                            f"{response.status}: "
                            f"{raw[:180]}"
                        )
                    )

                try:

                    return await response.json(
                        content_type=None
                    )

                except Exception as exc:

                    raise IranUsdtInvalidResponse(
                        "Invalid JSON response."
                    ) from exc

        except asyncio.TimeoutError as exc:

            raise IranUsdtRateError(
                "Request timeout."
            ) from exc

        except aiohttp.ClientError as exc:

            raise IranUsdtRateError(
                f"Network error: {exc}"
            ) from exc

    # ========================================================
    # Decimal
    # ========================================================

    @staticmethod
    def _decimal(
        value: Any,
    ) -> Decimal:

        try:

            result = Decimal(
                str(
                    value
                )
            )

        except (
            InvalidOperation,
            TypeError,
            ValueError,
        ) as exc:

            raise IranUsdtInvalidResponse(
                (
                    "Invalid numeric rate: "
                    f"{value!r}"
                )
            ) from exc

        if (
            not result.is_finite()
            or result <= 0
        ):

            raise IranUsdtInvalidResponse(
                (
                    "Non-positive rate: "
                    f"{value!r}"
                )
            )

        return result

    # ========================================================
    # Sanity Check
    # ========================================================

    def _validate_toman(
        self,
        value: Any,
    ) -> Decimal:

        result = self._decimal(
            value
        )

        if (
            result
            < self.min_reasonable_toman

            or result
            > self.max_reasonable_toman
        ):

            raise IranUsdtInvalidResponse(
                (
                    "USDT/Toman outside "
                    "sanity range: "
                    f"{result}"
                )
            )

        return result

    # ========================================================
    # Tabdeal
    # ========================================================

    async def _tabdeal(
        self,
    ) -> Decimal:

        data = (
            await self._get_json(
                self.TABDEAL_DEPTH_URL,
                params={
                    "symbol":
                        "USDTIRT",

                    "limit":
                        1,
                },
            )
        )

        if not isinstance(
            data,
            dict,
        ):

            raise IranUsdtInvalidResponse(
                "Tabdeal response is invalid."
            )

        asks = data.get(
            "asks"
        )

        if (
            not isinstance(
                asks,
                list,
            )
            or not asks
        ):

            raise IranUsdtInvalidResponse(
                (
                    "Tabdeal USDTIRT "
                    "ask book is empty."
                )
            )

        first = asks[0]

        if (
            not isinstance(
                first,
                (
                    list,
                    tuple,
                ),
            )
            or not first
        ):

            raise IranUsdtInvalidResponse(
                "Invalid Tabdeal ask row."
            )

        return self._validate_toman(
            first[0]
        )

    # ========================================================
    # Wallex
    # ========================================================

    async def _wallex(
        self,
    ) -> Decimal:

        data = (
            await self._get_json(
                self.WALLEX_MARKETS_URL
            )
        )

        if not isinstance(
            data,
            dict,
        ):

            raise IranUsdtInvalidResponse(
                "Wallex response is invalid."
            )

        result = data.get(
            "result"
        )

        markets = (
            result.get(
                "markets"
            )
            if isinstance(
                result,
                dict,
            )
            else None
        )

        if not isinstance(
            markets,
            list,
        ):

            raise IranUsdtInvalidResponse(
                (
                    "Wallex markets "
                    "list missing."
                )
            )

        for market in markets:

            if not isinstance(
                market,
                dict,
            ):
                continue

            symbol = str(
                market.get(
                    "symbol"
                )
                or ""
            ).upper()

            if symbol not in {
                "USDTTMN",
                "USDTIRT",
            }:
                continue

            value = (
                market.get(
                    "price"
                )
                or market.get(
                    "last_price"
                )
                or market.get(
                    "lastPrice"
                )
            )

            return self._validate_toman(
                value
            )

        raise IranUsdtInvalidResponse(
            (
                "Wallex USDTTMN "
                "market not found."
            )
        )

    # ========================================================
    # Bitpin
    # ========================================================

    async def _bitpin(
        self,
    ) -> Decimal:

        """
        Best-effort v1 adapter.

        Any Bitpin deprecation/shape change
        automatically falls through to
        later providers.
        """

        next_url: Optional[
            str
        ] = (
            self.BITPIN_MARKETS_URL
        )

        params: Optional[
            dict[
                str,
                Any,
            ]
        ] = {
            "page":
                1
        }

        for _ in range(
            5
        ):

            if not next_url:
                break

            data = (
                await self._get_json(
                    next_url,
                    params=params,
                )
            )

            params = None

            if not isinstance(
                data,
                dict,
            ):

                raise IranUsdtInvalidResponse(
                    "Bitpin response is invalid."
                )

            results = data.get(
                "results"
            )

            if not isinstance(
                results,
                list,
            ):

                raise IranUsdtInvalidResponse(
                    (
                        "Bitpin markets "
                        "list missing."
                    )
                )

            for market in results:

                if not isinstance(
                    market,
                    dict,
                ):
                    continue

                code = str(
                    market.get(
                        "code"
                    )
                    or ""
                ).upper()

                c1 = market.get(
                    "currency1"
                )

                c2 = market.get(
                    "currency2"
                )

                c1_code = (
                    str(
                        c1.get(
                            "code"
                        )
                        or ""
                    ).upper()

                    if isinstance(
                        c1,
                        dict,
                    )

                    else ""
                )

                c2_code = (
                    str(
                        c2.get(
                            "code"
                        )
                        or ""
                    ).upper()

                    if isinstance(
                        c2,
                        dict,
                    )

                    else ""
                )

                matched = (
                    code
                    in {
                        "USDT_IRT",
                        "USDTIRT",
                    }

                    or (
                        c1_code
                        == "USDT"

                        and c2_code
                        in {
                            "IRT",
                            "TMN",
                        }
                    )
                )

                if not matched:
                    continue

                value = market.get(
                    "price"
                )

                if (
                    value is None
                    and isinstance(
                        market.get(
                            "price_info"
                        ),
                        dict,
                    )
                ):

                    value = (
                        market[
                            "price_info"
                        ].get(
                            "price"
                        )
                    )

                if (
                    value is None
                    and isinstance(
                        market.get(
                            "order_book_info"
                        ),
                        dict,
                    )
                ):

                    value = (
                        market[
                            "order_book_info"
                        ].get(
                            "price"
                        )
                    )

                return self._validate_toman(
                    value
                )

            raw_next = data.get(
                "next"
            )

            next_url = (
                str(
                    raw_next
                )
                if raw_next
                else None
            )

        raise IranUsdtInvalidResponse(
            (
                "Bitpin USDT/IRT "
                "market not found."
            )
        )

    # ========================================================
    # Nobitex
    # ========================================================

    async def _nobitex(
        self,
    ) -> Decimal:

        data = (
            await self._get_json(
                self.NOBITEX_STATS_URL,
                params={
                    "srcCurrency":
                        "usdt",

                    "dstCurrency":
                        "rls",
                },
            )
        )

        if not isinstance(
            data,
            dict,
        ):

            raise IranUsdtInvalidResponse(
                "Nobitex response is invalid."
            )

        stats = data.get(
            "stats"
        )

        if not isinstance(
            stats,
            dict,
        ):

            raise IranUsdtInvalidResponse(
                "Nobitex stats missing."
            )

        market = None

        for (
            key,
            value,
        ) in stats.items():

            if str(
                key
            ).lower() in {
                "usdt-rls",
                "usdt_rls",
                "usdtrls",
            }:

                market = value
                break

        if not isinstance(
            market,
            dict,
        ):

            raise IranUsdtInvalidResponse(
                (
                    "Nobitex USDT-RLS "
                    "market missing."
                )
            )

        rial = (
            market.get(
                "bestSell"
            )
            or market.get(
                "latest"
            )
            or market.get(
                "mark"
            )
        )

        toman = (
            self._decimal(
                rial
            )
            / Decimal(
                "10"
            )
        )

        return self._validate_toman(
            toman
        )

    # ========================================================
    # Exir
    # ========================================================

    async def _exir(
        self,
    ) -> Decimal:

        data = (
            await self._get_json(
                self.EXIR_TICKER_URL,
                params={
                    "symbol":
                        "usdt-irt"
                },
            )
        )

        if not isinstance(
            data,
            dict,
        ):

            raise IranUsdtInvalidResponse(
                "Exir response is invalid."
            )

        value = (
            data.get(
                "last"
            )
            or data.get(
                "close"
            )
            or data.get(
                "price"
            )
        )

        return self._validate_toman(
            value
        )

    # ========================================================
    # Sarrafex
    # ========================================================

    async def _sarrafex(
        self,
    ) -> Decimal:

        data = (
            await self._get_json(
                self.SARRAFEX_MARKETS_URL
            )
        )

        if not isinstance(
            data,
            dict,
        ):

            raise IranUsdtInvalidResponse(
                (
                    "Sarrafex response "
                    "is invalid."
                )
            )

        markets = data.get(
            "value"
        )

        if not isinstance(
            markets,
            list,
        ):

            raise IranUsdtInvalidResponse(
                (
                    "Sarrafex markets "
                    "list missing."
                )
            )

        for market in markets:

            if not isinstance(
                market,
                dict,
            ):
                continue

            symbol = str(
                market.get(
                    "symbol"
                )
                or ""
            ).upper()

            pair = str(
                market.get(
                    "pair"
                )
                or ""
            ).upper()

            if not (
                symbol in {
                    "USDTIRT",
                    "USDTTMN",
                }

                or pair in {
                    "USDT/IRT",
                    "USDT/TMN",
                }
            ):

                continue

            value = (
                market.get(
                    "close"
                )
                or market.get(
                    "latestRate"
                )
                or market.get(
                    "price"
                )
            )

            return self._validate_toman(
                value
            )

        raise IranUsdtInvalidResponse(
            (
                "Sarrafex USDT/IRT "
                "market not found."
            )
        )

    # ========================================================
    # Get Quote
    # ========================================================

    async def get_quote(
        self,
        *,
        force_refresh: bool = False,
        allow_stale: bool = True,
    ) -> IranUsdtQuote:

        now = time.time()

        # ----------------------------------------------------
        # Fast cache
        # ----------------------------------------------------

        if (
            not force_refresh

            and self._cache is not None

            and now
            < self._cache.expires_at
        ):

            return (
                self._cache.quote
            )

        # ----------------------------------------------------
        # Prevent duplicate refreshes
        # ----------------------------------------------------

        async with self._lock:

            now = time.time()

            if (
                not force_refresh

                and self._cache is not None

                and now
                < self._cache.expires_at
            ):

                return (
                    self._cache.quote
                )

            errors: list[
                str
            ] = []

            # =================================================
            # Sequential Fallback
            # =================================================

            for (
                source,
                provider,
            ) in self._providers:

                started = (
                    time.perf_counter()
                )

                try:

                    rate = (
                        await provider()
                    )

                    quote = (
                        IranUsdtQuote(

                            rate_toman=(
                                rate
                            ),

                            source=(
                                source
                            ),

                            fetched_at=(
                                time.time()
                            ),

                            stale=False,
                        )
                    )

                    now = time.time()

                    self._cache = (
                        _CacheItem(

                            quote=(
                                quote
                            ),

                            expires_at=(
                                now
                                + self.cache_ttl
                            ),

                            stale_until=(
                                now
                                + self.stale_ttl
                            ),
                        )
                    )

                    logger.info(
                        (
                            "USDT/Toman source selected | "
                            "source=%s rate=%s latency_ms=%.0f"
                        ),
                        source,
                        rate,
                        (
                            time.perf_counter()
                            - started
                        )
                        * 1000,
                    )

                    return quote

                except Exception as exc:

                    errors.append(
                        (
                            f"{source}: "
                            f"{type(exc).__name__}: "
                            f"{exc}"
                        )
                    )

                    logger.warning(
                        (
                            "USDT/Toman provider failed | "
                            "source=%s error=%s"
                        ),
                        source,
                        exc,
                    )

            # =================================================
            # Stale Fallback
            # =================================================

            if (
                allow_stale

                and self._cache
                is not None

                and time.time()
                < self._cache.stale_until
            ):

                old = (
                    self._cache.quote
                )

                logger.warning(
                    (
                        "All USDT/Toman providers failed; "
                        "using recent stale quote | "
                        "source=%s rate=%s"
                    ),
                    old.source,
                    old.rate_toman,
                )

                return IranUsdtQuote(

                    rate_toman=(
                        old.rate_toman
                    ),

                    source=(
                        f"{old.source}:stale"
                    ),

                    fetched_at=(
                        old.fetched_at
                    ),

                    stale=True,
                )

            # =================================================
            # Complete Failure
            # =================================================

            raise IranUsdtAllProvidersFailed(
                (
                    "All Iranian "
                    "USDT/Toman providers failed. "
                    + " | ".join(
                        errors
                    )
                )
            )

    # ========================================================
    # Simple Rate API
    # ========================================================

    async def get_usdt_toman_rate(
        self,
        *,
        force_refresh: bool = False,
        allow_stale: bool = True,
    ) -> Decimal:

        quote = (
            await self.get_quote(

                force_refresh=(
                    force_refresh
                ),

                allow_stale=(
                    allow_stale
                ),
            )
        )

        return (
            quote.rate_toman
        )

    # ========================================================
    # Diagnostics
    # ========================================================

    async def diagnostics(
        self,
    ) -> dict[
        str,
        dict[
            str,
            Any,
        ],
    ]:

        async def run_one(
            source: str,
            provider: Callable[
                [],
                Awaitable[
                    Decimal
                ],
            ],
        ) -> tuple[
            str,
            dict[
                str,
                Any,
            ],
        ]:

            started = (
                time.perf_counter()
            )

            try:

                rate = (
                    await provider()
                )

                return (
                    source,
                    {
                        "ok":
                            True,

                        "rate_toman":
                            rate,

                        "latency_ms":
                            int(
                                (
                                    time.perf_counter()
                                    - started
                                )
                                * 1000
                            ),
                    },
                )

            except Exception as exc:

                return (
                    source,
                    {
                        "ok":
                            False,

                        "error":
                            (
                                f"{type(exc).__name__}: "
                                f"{str(exc)[:180]}"
                            ),

                        "latency_ms":
                            int(
                                (
                                    time.perf_counter()
                                    - started
                                )
                                * 1000
                            ),
                    },
                )

        results = (
            await asyncio.gather(
                *[
                    run_one(
                        source,
                        provider,
                    )
                    for (
                        source,
                        provider,
                    )
                    in self._providers
                ]
            )
        )

        return dict(
            results
        )


# ============================================================
# Public Exports
# ============================================================

__all__ = [
    "IranUsdtRateService",
    "IranUsdtQuote",
    "IranUsdtRateError",
    "IranUsdtAllProvidersFailed",
    "IranUsdtInvalidResponse",
]