# services/tabdeal_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, Optional

import aiohttp


logger = logging.getLogger(__name__)


# ============================================================
# Exceptions
# ============================================================

class TabdealRateError(Exception):
    """Base error for Tabdeal public market-rate operations."""


class TabdealConnectionError(TabdealRateError):
    """Network/transport failure."""


class TabdealResponseError(TabdealRateError):
    """Invalid or unusable API response."""


class TabdealMarketUnavailableError(TabdealRateError):
    """Requested market has no usable order book."""


# ============================================================
# Models
# ============================================================

@dataclass(slots=True, frozen=True)
class TabdealQuote:
    symbol: str
    best_bid: Decimal
    best_ask: Decimal
    fetched_at: float

    @property
    def mid(self) -> Decimal:
        return (
            self.best_bid
            + self.best_ask
        ) / Decimal("2")


@dataclass(slots=True)
class _CacheItem:
    quote: TabdealQuote
    expires_at: float


# ============================================================
# Public Market Rate Service
# ============================================================

class TabdealRateService:
    """
    Read-only Tabdeal market-rate client.

    Important:
        The endpoints used here are public market endpoints.
        No API key or API secret is required.

    Current use in the Telegram shop:
        USDTIRT -> live USDT/Toman rate
        TONUSDT -> live TON/USDT rate

    Conservative pricing:
        For provider-cost calculations we use the best ASK,
        because it avoids underestimating the cost of acquiring
        the base asset.

    Fallback:
        If TONUSDT is unavailable, TON/USDT can be derived from:
            TONIRT best ask / USDTIRT best bid

        That fallback is intentionally conservative.
    """

    BASE_URL = (
        "https://api1.tabdeal.org"
        "/r/api/v1"
    )

    def __init__(
        self,
        *,
        timeout: float = 10.0,
        cache_ttl: float = 10.0,
        retries: int = 3,
        retry_delay: float = 0.7,
        session: Optional[
            aiohttp.ClientSession
        ] = None,
    ) -> None:
        self.timeout = max(
            1.0,
            float(timeout),
        )

        self.cache_ttl = max(
            0.0,
            float(cache_ttl),
        )

        self.retries = max(
            1,
            int(retries),
        )

        self.retry_delay = max(
            0.0,
            float(retry_delay),
        )

        self._session = session

        self._owns_session = (
            session is None
        )

        self._session_lock = (
            asyncio.Lock()
        )

        self._cache_lock = (
            asyncio.Lock()
        )

        self._cache: dict[
            str,
            _CacheItem,
        ] = {}

        self._market_symbols: set[str] = set()
        self._market_symbols_expires_at: float = 0.0
        self._market_symbols_lock = asyncio.Lock()
        self.market_symbols_ttl = max(
            30.0,
            self.cache_ttl,
        )

    # ========================================================
    # Session
    # ========================================================

    async def _get_session(
        self,
    ) -> aiohttp.ClientSession:
        if (
            self._session is not None
            and not self._session.closed
        ):
            return self._session

        async with self._session_lock:
            if (
                self._session is None
                or self._session.closed
            ):
                timeout = aiohttp.ClientTimeout(
                    total=self.timeout
                )

                self._session = (
                    aiohttp.ClientSession(
                        timeout=timeout,
                        headers={
                            "Accept":
                                "application/json",
                            "User-Agent":
                                (
                                    "MatrixTelegramBot/"
                                    "TabdealRates/1.0"
                                ),
                        },
                    )
                )

                self._owns_session = True

        return self._session

    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

    async def __aenter__(
        self,
    ) -> "TabdealRateService":
        await self._get_session()
        return self

    async def __aexit__(
        self,
        exc_type,
        exc,
        tb,
    ) -> None:
        await self.close()

    # ========================================================
    # Helpers
    # ========================================================

    @staticmethod
    def _normalize_symbol(
        symbol: str,
    ) -> str:
        symbol = (
            str(symbol)
            .strip()
            .upper()
            .replace("_", "")
            .replace("-", "")
            .replace("/", "")
        )

        if (
            not symbol
            or len(symbol) > 30
            or not symbol.isalnum()
        ):
            raise ValueError(
                "Invalid Tabdeal market symbol."
            )

        return symbol

    @staticmethod
    def _decimal(
        value: Any,
    ) -> Decimal:
        try:
            result = Decimal(
                str(value)
            )

        except (
            InvalidOperation,
            TypeError,
            ValueError,
        ) as exc:
            raise TabdealResponseError(
                "Invalid decimal value "
                "in Tabdeal response."
            ) from exc

        if (
            not result.is_finite()
            or result <= 0
        ):
            raise TabdealResponseError(
                "Non-positive market price "
                "in Tabdeal response."
            )

        return result

    @classmethod
    def _first_price(
        cls,
        rows: Any,
        side_name: str,
    ) -> Decimal:
        if (
            not isinstance(
                rows,
                list,
            )
            or not rows
        ):
            raise (
                TabdealMarketUnavailableError(
                    f"Tabdeal {side_name} "
                    "order book is empty."
                )
            )

        first = rows[0]

        if (
            not isinstance(
                first,
                (list, tuple),
            )
            or not first
        ):
            raise TabdealResponseError(
                f"Invalid {side_name} row."
            )

        return cls._decimal(
            first[0]
        )

    # ========================================================
    # Cache
    # ========================================================

    async def _get_cached(
        self,
        symbol: str,
    ) -> Optional[TabdealQuote]:
        if self.cache_ttl <= 0:
            return None

        now = time.monotonic()

        async with self._cache_lock:
            item = self._cache.get(
                symbol
            )

            if (
                item is not None
                and item.expires_at > now
            ):
                return item.quote

            if item is not None:
                self._cache.pop(
                    symbol,
                    None,
                )

        return None

    async def _put_cache(
        self,
        quote: TabdealQuote,
    ) -> None:
        if self.cache_ttl <= 0:
            return

        async with self._cache_lock:
            self._cache[
                quote.symbol
            ] = _CacheItem(
                quote=quote,
                expires_at=(
                    time.monotonic()
                    + self.cache_ttl
                ),
            )

    async def clear_cache(
        self,
    ) -> None:
        async with self._cache_lock:
            self._cache.clear()

    # ========================================================
    # HTTP
    # ========================================================

    async def _get_json(
        self,
        endpoint: str,
        *,
        params: Optional[
            dict[str, Any]
        ] = None,
    ) -> Any:
        session = await self._get_session()

        url = (
            self.BASE_URL
            + "/"
            + endpoint.lstrip("/")
        )

        last_error: Optional[
            Exception
        ] = None

        for attempt in range(
            1,
            self.retries + 1,
        ):
            try:
                async with session.get(
                    url,
                    params=params,
                ) as response:
                    text = await response.text()

                    if response.status != 200:
                        raise TabdealResponseError(
                            "Tabdeal HTTP "
                            f"{response.status}: "
                            f"{text[:300]}"
                        )

                    try:
                        return (
                            await response.json(
                                content_type=None
                            )
                        )

                    except Exception as exc:
                        raise TabdealResponseError(
                            "Tabdeal returned "
                            "invalid JSON."
                        ) from exc

            except (
                asyncio.TimeoutError,
                aiohttp.ClientError,
                TabdealResponseError,
            ) as exc:
                last_error = exc

                if attempt >= self.retries:
                    break

                await asyncio.sleep(
                    self.retry_delay
                    * attempt
                )

        if isinstance(
            last_error,
            TabdealRateError,
        ):
            raise last_error

        raise TabdealConnectionError(
            "Could not connect to "
            "Tabdeal public market API."
        ) from last_error

    # ========================================================
    # Market Discovery
    # ========================================================

    @classmethod
    def _collect_market_symbols(
        cls,
        value: Any,
        result: set[str],
    ) -> None:
        if isinstance(
            value,
            dict,
        ):
            for key in (
                "symbol",
                "tabdealSymbol",
            ):
                raw = value.get(
                    key
                )

                if isinstance(
                    raw,
                    str,
                ):
                    try:
                        result.add(
                            cls._normalize_symbol(
                                raw
                            )
                        )
                    except ValueError:
                        pass

            for child in value.values():
                if isinstance(
                    child,
                    (dict, list, tuple),
                ):
                    cls._collect_market_symbols(
                        child,
                        result,
                    )

            return

        if isinstance(
            value,
            (list, tuple),
        ):
            for child in value:
                cls._collect_market_symbols(
                    child,
                    result,
                )

    async def get_market_symbols(
        self,
        *,
        force_refresh: bool = False,
    ) -> set[str]:
        now = time.time()

        if (
            not force_refresh
            and self._market_symbols
            and now < self._market_symbols_expires_at
        ):
            return set(
                self._market_symbols
            )

        async with self._market_symbols_lock:
            now = time.time()

            if (
                not force_refresh
                and self._market_symbols
                and now < self._market_symbols_expires_at
            ):
                return set(
                    self._market_symbols
                )

            data = await self._get_json(
                "exchangeInfo"
            )

            symbols: set[str] = set()

            self._collect_market_symbols(
                data,
                symbols,
            )

            if not symbols:
                raise TabdealResponseError(
                    "Tabdeal exchangeInfo contained no market symbols."
                )

            self._market_symbols = symbols
            self._market_symbols_expires_at = (
                time.time()
                + self.market_symbols_ttl
            )

            return set(
                symbols
            )

    async def _native_market_candidates(
        self,
        quote_asset: str,
        *,
        force_refresh: bool = False,
    ) -> list[str]:
        """
        Prefer GRAM (current native-currency name) and keep TON as the
        exchange-compatibility fallback.
        """
        quote_asset = self._normalize_symbol(
            quote_asset
        )

        preferred = [
            f"GRAM{quote_asset}",
            f"TON{quote_asset}",
        ]

        try:
            available = await self.get_market_symbols(
                force_refresh=force_refresh
            )

        except TabdealRateError as exc:
            logger.warning(
                "Could not discover Tabdeal market aliases; "
                "probing GRAM/TON directly | quote=%s error=%s",
                quote_asset,
                exc,
            )

            return preferred

        discovered = [
            symbol
            for symbol in preferred
            if symbol in available
        ]

        return (
            discovered
            + [
                symbol
                for symbol in preferred
                if symbol not in discovered
            ]
        )

    async def _native_best_ask(
        self,
        quote_asset: str,
        *,
        force_refresh: bool = False,
    ) -> tuple[Decimal, str]:
        errors: list[str] = []

        for symbol in await self._native_market_candidates(
            quote_asset,
            force_refresh=force_refresh,
        ):
            try:
                price = await self.best_ask(
                    symbol,
                    force_refresh=force_refresh,
                )

                logger.info(
                    "Tabdeal native market selected | symbol=%s",
                    symbol,
                )

                return (
                    price,
                    symbol,
                )

            except TabdealRateError as exc:
                errors.append(
                    f"{symbol}: {exc}"
                )

        raise TabdealMarketUnavailableError(
            "No usable GRAM/TON ask market. "
            + " | ".join(
                errors
            )
        )

    # ========================================================
    # Public Market Data
    # ========================================================

    async def ping(
        self,
    ) -> bool:
        try:
            result = await self._get_json(
                "ping"
            )

            return isinstance(
                result,
                dict,
            )

        except Exception:
            return False

    async def get_quote(
        self,
        symbol: str,
        *,
        force_refresh: bool = False,
    ) -> TabdealQuote:
        symbol = self._normalize_symbol(
            symbol
        )

        if not force_refresh:
            cached = await self._get_cached(
                symbol
            )

            if cached is not None:
                return cached

        data = await self._get_json(
            "depth",
            params={
                "symbol": symbol,
                "limit": 1,
            },
        )

        if not isinstance(
            data,
            dict,
        ):
            raise TabdealResponseError(
                "Invalid Tabdeal depth response."
            )

        bid = self._first_price(
            data.get(
                "bids"
            ),
            "bid",
        )

        ask = self._first_price(
            data.get(
                "asks"
            ),
            "ask",
        )

        if bid > ask:
            logger.warning(
                "Tabdeal crossed book | "
                "symbol=%s bid=%s ask=%s",
                symbol,
                bid,
                ask,
            )

        quote = TabdealQuote(
            symbol=symbol,
            best_bid=bid,
            best_ask=ask,
            fetched_at=time.time(),
        )

        await self._put_cache(
            quote
        )

        return quote

    async def _best_side(
        self,
        symbol: str,
        side: str,
        *,
        force_refresh: bool = False,
    ) -> Decimal:
        """
        Return only the requested side of the order book.

        Important:
            A market may temporarily have an empty bid side while asks
            still exist (or vice versa). The old implementation called
            get_quote(), which required BOTH sides and therefore made
            best_ask() fail only because bids were empty.

        We only require the side the caller actually requested.
        If both sides are available we still populate the full quote cache.
        """
        symbol = self._normalize_symbol(
            symbol
        )

        if side not in {
            "bid",
            "ask",
        }:
            raise ValueError(
                "side must be 'bid' or 'ask'."
            )

        if not force_refresh:
            cached = await self._get_cached(
                symbol
            )

            if cached is not None:
                return (
                    cached.best_bid
                    if side == "bid"
                    else cached.best_ask
                )

        data = await self._get_json(
            "depth",
            params={
                "symbol": symbol,
                "limit": 1,
            },
        )

        if not isinstance(
            data,
            dict,
        ):
            raise TabdealResponseError(
                "Invalid Tabdeal depth response."
            )

        requested_rows = (
            data.get("bids")
            if side == "bid"
            else data.get("asks")
        )

        price = self._first_price(
            requested_rows,
            side,
        )

        # Cache a complete quote only when both sides are actually usable.
        try:
            bid = self._first_price(
                data.get("bids"),
                "bid",
            )

            ask = self._first_price(
                data.get("asks"),
                "ask",
            )

        except TabdealRateError:
            return price

        if bid > ask:
            logger.warning(
                "Tabdeal crossed book | "
                "symbol=%s bid=%s ask=%s",
                symbol,
                bid,
                ask,
            )

        await self._put_cache(
            TabdealQuote(
                symbol=symbol,
                best_bid=bid,
                best_ask=ask,
                fetched_at=time.time(),
            )
        )

        return price

    async def best_bid(
        self,
        symbol: str,
        *,
        force_refresh: bool = False,
    ) -> Decimal:
        return await self._best_side(
            symbol,
            "bid",
            force_refresh=force_refresh,
        )

    async def best_ask(
        self,
        symbol: str,
        *,
        force_refresh: bool = False,
    ) -> Decimal:
        return await self._best_side(
            symbol,
            "ask",
            force_refresh=force_refresh,
        )

    # ========================================================
    # Shop Rates
    # ========================================================

    async def get_usdt_toman_rate(
        self,
        *,
        force_refresh: bool = False,
    ) -> Decimal:
        """
        USDT -> IRT.

        Tabdeal uses the USDTIRT market symbol.
        The bot treats IRT as its Toman-denominated wallet unit.
        """
        return await self.best_ask(
            "USDTIRT",
            force_refresh=force_refresh,
        )

    async def get_native_toman_rate(
        self,
        *,
        force_refresh: bool = False,
    ) -> Decimal:
        """
        Price the TON-network native currency in Toman.

        Direct:
            GRAMIRT / TONIRT

        Fallback:
            (GRAMUSDT / TONUSDT) * USDTIRT
        """
        try:
            native_irt, _ = await self._native_best_ask(
                "IRT",
                force_refresh=force_refresh,
            )

            return native_irt

        except TabdealRateError:
            native_usdt = await self.get_native_usdt_rate(
                force_refresh=force_refresh,
            )

            usdt_irt = await self.get_usdt_toman_rate(
                force_refresh=force_refresh,
            )

            result = (
                native_usdt
                * usdt_irt
            )

            if (
                not result.is_finite()
                or result <= 0
            ):
                raise TabdealResponseError(
                    "Invalid derived native/Toman rate."
                )

            return result

    async def get_native_usdt_rate(
        self,
        *,
        force_refresh: bool = False,
    ) -> Decimal:
        """
        Price the TON-network native currency in USDT.

        Direct:
            GRAMUSDT / TONUSDT

        Fallback:
            GRAMIRT / TONIRT best ask
            divided by
            USDTIRT best bid
        """
        try:
            native_usdt, _ = await self._native_best_ask(
                "USDT",
                force_refresh=force_refresh,
            )

            return native_usdt

        except TabdealRateError:
            native_irt, symbol = await self._native_best_ask(
                "IRT",
                force_refresh=force_refresh,
            )

            usdt_irt_bid = await self.best_bid(
                "USDTIRT",
                force_refresh=force_refresh,
            )

            if usdt_irt_bid <= 0:
                raise TabdealResponseError(
                    "Invalid USDTIRT bid."
                )

            result = (
                native_irt
                / usdt_irt_bid
            )

            if (
                not result.is_finite()
                or result <= 0
            ):
                raise TabdealResponseError(
                    "Invalid derived native/USDT rate."
                )

            logger.info(
                "Tabdeal native/USDT derived | "
                "native_market=%s usdt_market=USDTIRT",
                symbol,
            )

            return result

    async def get_ton_toman_rate(
        self,
        *,
        force_refresh: bool = False,
    ) -> Decimal:
        """
        Backward-compatible alias.
        """
        return await self.get_native_toman_rate(
            force_refresh=force_refresh,
        )

    async def get_ton_usdt_rate(
        self,
        *,
        force_refresh: bool = False,
    ) -> Decimal:
        """
        Backward-compatible alias.
        """
        return await self.get_native_usdt_rate(
            force_refresh=force_refresh,
        )

    async def get_shop_rates(
        self,
        *,
        force_refresh: bool = False,
    ) -> dict[str, Decimal]:
        """
        Fetch rates required for MarketApp price conversion.
        """
        usdt_toman, ton_usdt = (
            await asyncio.gather(
                self.get_usdt_toman_rate(
                    force_refresh=force_refresh,
                ),
                self.get_ton_usdt_rate(
                    force_refresh=force_refresh,
                ),
            )
        )

        return {
            "usdt_toman":
                usdt_toman,

            "native_usdt":
                ton_usdt,

            # Existing project compatibility.
            "ton_usdt":
                ton_usdt,
        }


__all__ = [
    "TabdealRateService",
    "TabdealQuote",
    "TabdealRateError",
    "TabdealConnectionError",
    "TabdealResponseError",
    "TabdealMarketUnavailableError",
]