# # ⚠ 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://entropy.trade/) support is available at:
#   [Docs](https://docs.entropy.trade/)
#   [Discord](https://discord.gg/67jySBhxrg)
#   [Twitter](https://twitter.com/entropymarkets)
#   [Github](https://github.com/blockworks-foundation)
#   [Email](mailto:hello@blockworks.foundation)

import logging
import typing

from dataclasses import dataclass
from datetime import datetime
from decimal import Decimal
from solana.publickey import PublicKey

from .accountinfo import AccountInfo
from .addressableaccount import AddressableAccount
from .cache import RootBankCache, Cache
from .context import Context
from .instrumentlookup import InstrumentLookup
from .layouts import layouts
from .metadata import Metadata
from .observables import Disposable
from .tokens import Instrument, Token
from .version import Version
from .websocketsubscription import (
    WebSocketAccountSubscription,
    WebSocketSubscriptionManager,
)


# # 🥭 InterestRates class
#
# A simple way to package borrow and deposit rates together in a single object.
#
@dataclass
class InterestRates:
    deposit: Decimal
    borrow: Decimal

    def __str__(self) -> str:
        return (
            f"« InterestRates Deposit: {self.deposit:,.2%} Borrow: {self.borrow:,.2%} »"
        )

    def __repr__(self) -> str:
        return f"{self}"


# # 🥭 BankBalances class
#
# A simple way to package borrow and deposit balances together in a single object.
#
@dataclass
class BankBalances:
    deposits: Decimal
    borrows: Decimal

    def __str__(self) -> str:
        return f"« BankBalances Deposits: {self.deposits:,.8f} Borrows: {self.borrows:,.8f} »"

    def __repr__(self) -> str:
        return f"{self}"


# # 🥭 NodeBank class
#
# `NodeBank` stores details of deposits/borrows and vault.
#
class NodeBank(AddressableAccount):
    def __init__(
        self,
        account_info: AccountInfo,
        version: Version,
        meta_data: Metadata,
        vault: PublicKey,
        balances: BankBalances,
    ) -> None:
        super().__init__(account_info)
        self.version: Version = version
        self.meta_data: Metadata = meta_data
        self.vault: PublicKey = vault
        self.balances: BankBalances = balances

    @staticmethod
    def from_layout(
        layout: typing.Any, account_info: AccountInfo, version: Version
    ) -> "NodeBank":
        meta_data: Metadata = layout.meta_data
        deposits: Decimal = layout.deposits
        borrows: Decimal = layout.borrows
        balances: BankBalances = BankBalances(deposits=deposits, borrows=borrows)
        vault: PublicKey = layout.vault

        return NodeBank(account_info, version, meta_data, vault, balances)

    @staticmethod
    def parse(account_info: AccountInfo) -> "NodeBank":
        data = account_info.data
        if len(data) != layouts.NODE_BANK.sizeof():
            raise Exception(
                f"NodeBank data length ({len(data)}) does not match expected size ({layouts.NODE_BANK.sizeof()})"
            )

        layout = layouts.NODE_BANK.parse(data)
        return NodeBank.from_layout(layout, account_info, Version.V1)

    @staticmethod
    def load(context: Context, address: PublicKey) -> "NodeBank":
        account_info = AccountInfo.load(context, address)
        if account_info is None:
            raise Exception(f"NodeBank account not found at address '{address}'")
        return NodeBank.parse(account_info)

    def subscribe(
        self,
        context: Context,
        websocketmanager: WebSocketSubscriptionManager,
        callback: typing.Callable[["NodeBank"], None],
    ) -> Disposable:
        subscription = WebSocketAccountSubscription(
            context, self.address, NodeBank.parse
        )
        websocketmanager.add(subscription)
        subscription.publisher.subscribe(on_next=callback)  # type: ignore[call-arg]

        return subscription

    def __str__(self) -> str:
        return f"""« NodeBank [{self.version}] {self.address}
    {self.meta_data}
    Balances: {self.balances}
    Vault: {self.vault}
»"""

    def __repr__(self) -> str:
        return f"{self}"


# # 🥭 RootBank class
#
# `RootBank` stores details of how to reach `NodeBank`.
#
class RootBank(AddressableAccount):
    def __init__(
        self,
        account_info: AccountInfo,
        version: Version,
        meta_data: Metadata,
        optimal_util: Decimal,
        optimal_rate: Decimal,
        max_rate: Decimal,
        node_banks: typing.Sequence[PublicKey],
        deposit_index: Decimal,
        borrow_index: Decimal,
        last_updated: datetime,
    ) -> None:
        super().__init__(account_info)
        self.version: Version = version

        self.meta_data: Metadata = meta_data

        self.optimal_util: Decimal = optimal_util
        self.optimal_rate: Decimal = optimal_rate
        self.max_rate: Decimal = max_rate

        self.node_banks: typing.Sequence[PublicKey] = node_banks
        self.deposit_index: Decimal = deposit_index
        self.borrow_index: Decimal = borrow_index
        self.last_updated: datetime = last_updated

        self.loaded_node_banks: typing.Optional[typing.Sequence[NodeBank]] = None

    def ensure_node_banks(self, context: Context) -> typing.Sequence[NodeBank]:
        if self.loaded_node_banks is None:
            node_bank_account_infos = AccountInfo.load_multiple(
                context, self.node_banks
            )
            self.loaded_node_banks = list(map(NodeBank.parse, node_bank_account_infos))
        return self.loaded_node_banks

    def pick_node_bank(self, context: Context) -> NodeBank:
        return self.ensure_node_banks(context)[0]

    def fetch_balances(self, context: Context) -> BankBalances:
        node_banks: typing.Sequence[NodeBank] = self.ensure_node_banks(context)

        deposits_in_node_banks: Decimal = Decimal(0)
        borrows_in_node_banks: Decimal = Decimal(0)
        for node_bank in node_banks:
            deposits_in_node_banks += node_bank.balances.deposits
            borrows_in_node_banks += node_bank.balances.borrows

        total_deposits: Decimal = deposits_in_node_banks * self.deposit_index
        total_borrows: Decimal = borrows_in_node_banks * self.borrow_index

        return BankBalances(deposits=total_deposits, borrows=total_borrows)

    @staticmethod
    def from_layout(
        layout: typing.Any, account_info: AccountInfo, version: Version
    ) -> "RootBank":
        meta_data: Metadata = Metadata.from_layout(layout.meta_data)

        optimal_util: Decimal = layout.optimal_util
        optimal_rate: Decimal = layout.optimal_rate
        max_rate: Decimal = layout.max_rate

        num_node_banks: Decimal = layout.num_node_banks
        node_banks: typing.Sequence[PublicKey] = layout.node_banks[
            0 : int(num_node_banks)
        ]
        deposit_index: Decimal = layout.deposit_index
        borrow_index: Decimal = layout.borrow_index
        last_updated: datetime = layout.last_updated

        return RootBank(
            account_info,
            version,
            meta_data,
            optimal_util,
            optimal_rate,
            max_rate,
            node_banks,
            deposit_index,
            borrow_index,
            last_updated,
        )

    @staticmethod
    def parse(account_info: AccountInfo) -> "RootBank":
        data = account_info.data
        if len(data) != layouts.ROOT_BANK.sizeof():
            raise Exception(
                f"RootBank data length ({len(data)}) does not match expected size ({layouts.ROOT_BANK.sizeof()})"
            )

        layout = layouts.ROOT_BANK.parse(data)
        return RootBank.from_layout(layout, account_info, Version.V1)

    @staticmethod
    def load(context: Context, address: PublicKey) -> "RootBank":
        account_info = AccountInfo.load(context, address)
        if account_info is None:
            raise Exception(f"RootBank account not found at address '{address}'")
        return RootBank.parse(account_info)

    @staticmethod
    def load_multiple(
        context: Context, addresses: typing.Sequence[PublicKey]
    ) -> typing.Sequence["RootBank"]:
        account_infos = AccountInfo.load_multiple(context, addresses)
        root_banks = []
        for account_info in account_infos:
            root_bank = RootBank.parse(account_info)
            root_banks += [root_bank]

        return root_banks

    @staticmethod
    def find_by_address(
        values: typing.Sequence["RootBank"], address: PublicKey
    ) -> "RootBank":
        found = [value for value in values if value.address == address]
        if len(found) == 0:
            raise Exception(f"RootBank '{address}' not found in root banks: {values}")

        if len(found) > 1:
            raise Exception(
                f"RootBank '{address}' matched multiple root banks in: {values}"
            )

        return found[0]

    def subscribe(
        self,
        context: Context,
        websocketmanager: WebSocketSubscriptionManager,
        callback: typing.Callable[["RootBank"], None],
    ) -> Disposable:
        subscription = WebSocketAccountSubscription(
            context, self.address, RootBank.parse
        )
        websocketmanager.add(subscription)
        subscription.publisher.subscribe(on_next=callback)  # type: ignore[call-arg]

        return subscription

    def __str__(self) -> str:
        return f"""« RootBank [{self.version}] {self.address}
    {self.meta_data}
    Optimal Util: {self.optimal_util:,.4f}
    Optimal Rate: {self.optimal_rate:,.4f}
    Max Rate: {self.max_rate}
    Node Banks:
        {self.node_banks}
    Deposit Index: {self.deposit_index}
    Borrow Index: {self.borrow_index}
    Last Updated: {self.last_updated}
»"""

    def __repr__(self) -> str:
        return f"{self}"


# # 🥭 TokenBank class
#
# `TokenBank` defines additional information for a `Token`.
#
class TokenBank:
    def __init__(self, token: Token, root_bank_address: PublicKey) -> None:
        self._logger: logging.Logger = logging.getLogger(self.__class__.__name__)
        self.token: Token = token
        self.root_bank_address: PublicKey = root_bank_address

        self.loaded_root_bank: typing.Optional[RootBank] = None

    @staticmethod
    def from_layout_or_none(
        layout: typing.Any, instrument_lookup: InstrumentLookup
    ) -> typing.Optional["TokenBank"]:
        if layout.mint is None:
            return None

        instrument: typing.Optional[Instrument] = instrument_lookup.find_by_mint(
            layout.mint
        )
        if instrument is None:
            raise Exception(f"Token with mint {layout.mint} could not be found.")
        token: Token = Token.ensure(instrument)
        root_bank_address: PublicKey = layout.root_bank
        decimals: Decimal = layout.decimals

        if decimals != token.decimals:
            raise Exception(
                f"Conflict between number of decimals in token static data {token.decimals} and group {decimals} for token {token.symbol}."
            )

        return TokenBank(token, root_bank_address)

    @staticmethod
    def find_by_symbol(
        values: typing.Sequence[typing.Optional["TokenBank"]], symbol: str
    ) -> "TokenBank":
        found = [
            value
            for value in values
            if value is not None
            and value.token is not None
            and value.token.symbol_matches(symbol)
        ]
        if len(found) == 0:
            raise Exception(f"Token '{symbol}' not found in token infos: {values}")

        if len(found) > 1:
            raise Exception(
                f"Token '{symbol}' matched multiple tokens in infos: {values}"
            )

        return found[0]

    def root_bank_cache_from_cache(
        self, cache: Cache, index: int
    ) -> typing.Optional[RootBankCache]:
        return cache.root_bank_cache[index]

    def ensure_root_bank(self, context: Context) -> RootBank:
        if self.loaded_root_bank is None:
            self.loaded_root_bank = RootBank.load(context, self.root_bank_address)
        return self.loaded_root_bank

    def pick_node_bank(self, context: Context) -> NodeBank:
        root_bank: RootBank = self.ensure_root_bank(context)
        return root_bank.pick_node_bank(context)

    def fetch_interest_rates(self, context: Context) -> InterestRates:
        root_bank: RootBank = self.ensure_root_bank(context)
        balances: BankBalances = root_bank.fetch_balances(context)

        borrow_rate: Decimal = Decimal(0)
        deposit_rate: Decimal = Decimal(0)
        utilization: Decimal
        if balances.deposits != 0 and balances.borrows != 0:
            if balances.deposits <= balances.borrows:
                borrow_rate = root_bank.max_rate
            else:
                utilization = balances.borrows / balances.deposits
                slope: Decimal
                if utilization < root_bank.optimal_util:
                    slope = root_bank.optimal_rate / root_bank.optimal_util
                    borrow_rate = slope * utilization
                else:
                    extra_utilization = utilization - root_bank.optimal_util
                    slope = (root_bank.max_rate - root_bank.optimal_rate) / (
                        1 - root_bank.optimal_util
                    )
                    borrow_rate = root_bank.optimal_rate + (slope * extra_utilization)

            if balances.deposits == 0:
                deposit_rate = root_bank.max_rate
            else:
                utilization = balances.borrows / balances.deposits
                deposit_rate = utilization * borrow_rate

        return InterestRates(deposit=deposit_rate, borrow=borrow_rate)

    def __str__(self) -> str:
        return f"""« TokenBank {self.token}
    Root Bank Address: {self.root_bank_address}
»"""

    def __repr__(self) -> str:
        return f"{self}"
