# # ⚠ Warning
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
# LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN
# NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
# WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
#
# [🥭 Entropy Markets](https://markets/) support is available at:
#   [Docs](https://docs.markets/)
#   [Discord](https://discord.gg/67jySBhxrg)
#   [Twitter](https://twitter.com/entropymarkets)
#   [Github](https://github.com/blockworks-foundation)
#   [Email](mailto:hello@blockworks.foundation)


import re
import rx
import rx.operators
import typing

from decimal import Decimal
from solana.publickey import PublicKey

from ...accountinfo import AccountInfo
from ...context import Context
from ...datetimes import utc_now
from ...loadedmarket import LoadedMarket
from ...observables import observable_pipeline_error_reporter
from ...oracle import (
    Oracle,
    OracleProvider,
    OracleSource,
    Price,
    SupportedOracleFeature,
)

from .layouts import (
    MAGIC,
    MAPPING,
    PRICE,
    PRODUCT,
    PYTH_DEVNET_MAPPING_ROOT,
    PYTH_MAINNET_MAPPING_ROOT,
)


# # 🥭 Pyth
#
# This file contains code specific to the [Pyth Network](https://pyth.network/).
#
# ## Pyth's Confidence Interval
#
# It seems to akin to a spread, and always specified in the quote currency.
#
# Pyth Medium article:
# https://pythnetwork.medium.com/what-is-confidence-uncertainty-in-a-price-649583b598cf
#
# From Discord: https://discord.com/channels/826115122799837205/861627739975319602/861903479331880981
# 6/ What does Pyth's confidence value mean when provided with the price?
# → Confidence is a function of where one expects a trade to occur from the "true" price, i.e. what is the potential dispersion estimation?
#
# From Discord: https://discord.com/channels/826115122799837205/826115122799837208/858091212980092978
# [T]he confidence value here is how far from the aggregate price we believe the true price might be. It
# reflects a combination of the uncertainty of individual quoters and how well individual quoters agree
# with each other
#


# # 🥭 PythOracle class
#
# Implements the `Oracle` abstract base class specialised to the Pyth Network.
#


class PythOracle(Oracle):
    def __init__(
        self, context: Context, market: LoadedMarket, product_data: typing.Any
    ):
        name = f"Pyth Oracle for {market.fully_qualified_symbol}"
        super().__init__(name, market)
        self.context: Context = context
        self.market: LoadedMarket = market
        self.product_data: typing.Any = product_data
        self.address: PublicKey = product_data.address
        features: SupportedOracleFeature = (
            SupportedOracleFeature.MID_PRICE | SupportedOracleFeature.CONFIDENCE
        )
        self.source: OracleSource = OracleSource("Pyth", name, features, market)

    def fetch_price(self, _: Context) -> Price:
        price_account_info = AccountInfo.load(self.context, self.product_data.px_acc)
        if price_account_info is None:
            raise Exception(
                f"[{self.context.name}] Price account {self.product_data.px_acc} not found."
            )

        if len(price_account_info.data) != PRICE.sizeof():
            raise Exception(
                f"[{self.context.name}] Price account data has incorrect size. Expected: {PRICE.sizeof()}, got {len(price_account_info.data)}."
            )

        price_data = PRICE.parse(price_account_info.data)
        if price_data.magic != MAGIC:
            raise Exception(
                f"[{self.context.name}] Price account {price_account_info.address} is not a Pyth account."
            )

        factor = Decimal(10) ** price_data.expo
        price = price_data.agg.price * factor
        confidence = price_data.agg.conf * factor

        # Pyth has no notion of bids, asks, or spreads so just provide the single price.
        return Price(
            self.source,
            utc_now(),
            self.market,
            price,
            price,
            price,
            confidence,
        )

    def to_streaming_observable(
        self, context: Context
    ) -> rx.core.typing.Observable[Price]:
        prices = rx.interval(1).pipe(
            rx.operators.observe_on(context.create_thread_pool_scheduler()),
            rx.operators.start_with(-1),
            rx.operators.map(lambda _: self.fetch_price(context)),
            rx.operators.catch(observable_pipeline_error_reporter),
            rx.operators.retry(),
        )
        return typing.cast(rx.core.typing.Observable[Price], prices)


# # 🥭 PythOracleProvider class
#
# Implements the `OracleProvider` abstract base class specialised to the Pyth Network.
#
# In order to allow it to vary its cluster without affecting other programs, this takes a `Context` in its
# constructor and uses that to access the data. It ignores the context passed as a parameter to its methods.
# This allows the context-fudging to only happen on construction.


class PythOracleProvider(OracleProvider):
    def __init__(self, context: Context) -> None:
        self.address: PublicKey = (
            PYTH_MAINNET_MAPPING_ROOT
            if context.client.cluster_name == "mainnet"
            else PYTH_DEVNET_MAPPING_ROOT
        )
        self.__symbol_prefix = "Crypto."

        super().__init__(f"Pyth Oracle Factory [{self.address}]")
        self.context: Context = context

    def oracle_for_market(
        self, _: Context, market: LoadedMarket
    ) -> typing.Optional[Oracle]:
        pyth_symbol = self._market_symbol_to_pyth_symbol(market.symbol)
        products = self._fetch_all_pyth_products(self.context, self.address)
        for product in products:
            if product.attr["symbol"] == pyth_symbol:
                return PythOracle(self.context, market, product)
        return None

    def all_available_symbols(self, _: Context) -> typing.Sequence[str]:
        products = self._fetch_all_pyth_products(self.context, self.address)
        symbols: typing.List[str] = []
        for product in products:
            symbol = product.attr["symbol"]
            symbols += self._pyth_symbol_to_market_symbols(symbol)
        return symbols

    def _market_symbol_to_pyth_symbol(self, symbol: str) -> str:
        normalised = symbol.upper()
        prefixed = self.__symbol_prefix + normalised
        fixed_usdt = re.sub("USDT$", "USD", prefixed)
        fixed_usdc = re.sub("USDC$", "USD", fixed_usdt)
        fixed_perp = re.sub("\\-PERP$", "/USD", fixed_usdc)
        return fixed_perp

    def _pyth_symbol_to_market_symbols(self, symbol: str) -> typing.Sequence[str]:
        if symbol.endswith("USD"):
            return [f"{symbol}C", f"{symbol}T"]
        return [symbol]

    def _load_pyth_mapping(self, context: Context, address: PublicKey) -> typing.Any:
        account_info = AccountInfo.load(context, address)
        if account_info is None:
            raise Exception(
                f"[{context.name}] Pyth mapping account {address} not found."
            )

        if len(account_info.data) != MAPPING.sizeof():
            raise Exception(
                f"Mapping account data has incorrect size. Expected: {MAPPING.sizeof()}, got {len(account_info.data)}."
            )

        mapping: typing.Any = MAPPING.parse(account_info.data)
        mapping.address = account_info.address
        if mapping.magic != MAGIC:
            raise Exception(
                f"[{context.name}] Mapping account {account_info.address} is not a Pyth account."
            )

        return mapping

    def _fetch_all_pyth_products(
        self, context: Context, address: PublicKey
    ) -> typing.Sequence[typing.Any]:
        mapping = self._load_pyth_mapping(context, address)
        all_product_addresses = mapping.products[0 : int(mapping.num)]
        product_account_infos = AccountInfo.load_multiple(
            context, all_product_addresses
        )
        products: typing.List[typing.Any] = []
        for product_account_info in product_account_infos:
            product: typing.Any = PRODUCT.parse(product_account_info.data)
            product.address = product_account_info.address
            if product.magic != MAGIC:
                raise Exception(
                    f"[{context.name}] Product account {product_account_info.address} is not a Pyth account."
                )
            products += [product]
        return products
