# # ⚠ 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 abc
import json
import logging
import requests
import time
import typing


from base64 import b64decode
from dataclasses import dataclass
from datetime import datetime, timedelta
from collections.abc import Mapping
from decimal import Decimal
from solana.blockhash import Blockhash, BlockhashCache
from solana.keypair import Keypair
from solana.publickey import PublicKey
from solana.rpc.api import Client
from solana.rpc.commitment import Commitment, Processed, Finalized
from solana.rpc.providers.http import HTTPProvider
from solana.rpc.types import (
    DataSliceOpts,
    MemcmpOpts,
    RPCMethod,
    RPCResponse,
    TokenAccountOpts,
    TxOpts,
)
from solana.transaction import Transaction

from .constants import SOL_DECIMAL_DIVISOR
from .datetimes import local_now
from .instructionreporter import InstructionReporter
from .logmessages import expand_log_messages
from .text import indent_collection_as_str


_STUB_TRANSACTION_SIGNATURE: str = "stub-for-already-submitted-transaction-signature"


# # 🥭 CompoundException class
#
# A `CompoundException` exception can hold all exceptions that were raised when trying to read or
# write to Solana.
#
# The `Client` can rotate through multiple providers, switching when certain `Exception`s are thrown.
# This exception allows all those exceptions to be gathered and inspected should all providers fail.
#
class CompoundException(Exception):
    def __init__(self, name: str, all_exceptions: typing.Sequence[Exception]) -> None:
        super().__init__(f"[{name}] Multiple errors captured for event")
        self.name: str = name
        self.all_exceptions: typing.Sequence[Exception] = all_exceptions

    def __str__(self) -> str:
        if len(self.all_exceptions) == 1:
            return str(self.all_exceptions[0])

        details: str = indent_collection_as_str(self.all_exceptions)
        return f"""« CompoundException with {len(self.all_exceptions)} inner exceptions:
    {details}
»"""


# # 🥭 ClientException class
#
# A `ClientException` exception base class that allows trapping and handling rate limiting
# independent of other error handling.
#
class ClientException(Exception):
    def __init__(self, message: str, name: str, cluster_rpc_url: str) -> None:
        super().__init__(message)
        self.message: str = message
        self.name: str = name
        self.cluster_rpc_url: str = cluster_rpc_url

    def __str__(self) -> str:
        return f"« {type(self)} '{self.message}' from '{self.name}' on {self.cluster_rpc_url} »"

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


# # 🥭 RateLimitException class
#
# A `RateLimitException` exception base class that allows trapping and handling rate limiting
# independent of other error handling.
#
class RateLimitException(ClientException):
    pass


# # 🥭 TooMuchBandwidthRateLimitException class
#
# A `TooMuchBandwidthRateLimitException` exception that specialises the `RateLimitException`
# for when too much bandwidth has been consumed.
#
class TooMuchBandwidthRateLimitException(RateLimitException):
    pass


# # 🥭 TooManyRequestsRateLimitException class
#
# A `TooManyRequestsRateLimitException` exception that specialises the `RateLimitException`
# for when too many requests have been sent in a short time.
#
class TooManyRequestsRateLimitException(RateLimitException):
    pass


# # 🥭 BlockhashNotFoundException class
#
# A `BlockhashNotFoundException` exception allows trapping and handling exceptions when a blockhash is sent that
# the node doesn't understand. This can happen when the blockhash is too old (and the node no longer
# considers it 'recent') or when it's too new (and hasn't yet made it to the node that is responding).
#
class BlockhashNotFoundException(ClientException):
    def __init__(
        self,
        name: str,
        cluster_rpc_url: str,
        blockhash: typing.Optional[Blockhash] = None,
    ) -> None:
        message: str = f"Blockhash '{blockhash}' not found on {cluster_rpc_url}."
        super().__init__(message, name, cluster_rpc_url)
        self.blockhash: typing.Optional[Blockhash] = blockhash

    def __str__(self) -> str:
        return f"« BlockhashNotFoundException '{self.name}' [{self.blockhash}] on {self.cluster_rpc_url} »"


# # 🥭 NodeIsBehindException class
#
# A `NodeIsBehindException` exception allows trapping and handling exceptions when a node is behind by too
# many slots.
#
class NodeIsBehindException(ClientException):
    def __init__(self, name: str, cluster_rpc_url: str, slots_behind: int) -> None:
        message: str = f"Node is behind by {slots_behind} slots."
        super().__init__(message, name, cluster_rpc_url)
        self.slots_behind: int = slots_behind

    def __str__(self) -> str:
        return f"« NodeIsBehindException '{self.name}' [behind by {self.slots_behind} slots] on {self.cluster_rpc_url} »"


# # 🥭 TransactionAlreadyProcessedException class
#
# A `TransactionAlreadyProcessedException` exception indicates that a transaction with that signature and
# recent blockhash has already been processed.
#
# This can be a normal consequence of the stale slot checking/resubmission (which will mean it is ignored)
# or it can indicate an actual problem.
#
class TransactionAlreadyProcessedException(RateLimitException):
    pass


# # 🥭 StaleSlotException class
#
# A `StaleSlotException` exception allows trapping and handling exceptions when data is received from
#
class StaleSlotException(ClientException):
    def __init__(
        self,
        name: str,
        cluster_rpc_url: str,
        latest_seen_slot: int,
        just_returned_slot: int,
    ) -> None:
        message: str = f"Stale slot received - received data from slot {just_returned_slot} having previously seen slot {latest_seen_slot}."
        super().__init__(message, name, cluster_rpc_url)
        self.latest_seen_slot: int = latest_seen_slot
        self.just_returned_slot: int = just_returned_slot

    def __str__(self) -> str:
        return f"« StaleSlotException '{self.name}' [received data from slot {self.just_returned_slot} having previously seen slot {self.latest_seen_slot}] on {self.cluster_rpc_url} »"


# # 🥭 FailedToFetchBlockhashException class
#
# A `FailedToFetchBlockhashException` exception allows trapping and handling exceptions when we fail
# to fetch a recent or distinct blockhash.
#
class FailedToFetchBlockhashException(ClientException):
    def __init__(
        self,
        message: str,
        name: str,
        cluster_rpc_url: str,
        pauses: typing.Sequence[float],
    ) -> None:
        super().__init__(message, name, cluster_rpc_url)
        self.pauses: typing.Sequence[float] = pauses

    def __str__(self) -> str:
        if len(self.pauses) == 0:
            return f"« FailedToFetchBlockhashException '{self.name}' Failed to get recent blockhash on {self.cluster_rpc_url} »"

        pauses_text = ",".join(f"{pause}" for pause in self.pauses[:-1])
        return f"« FailedToFetchBlockhashException '{self.name}' Failed to get a fresh, recent blockhash after {len(self.pauses)} attempts - paused {pauses_text} seconds between attempts on {self.cluster_rpc_url} »"


# # 🥭 TransactionException class
#
# A `TransactionException` exception that can provide additional error data, or at least better output
# of problems at the right place.
#
class TransactionException(ClientException):
    def __init__(
        self,
        transaction: typing.Optional[Transaction],
        message: str,
        code: int,
        name: str,
        cluster_rpc_url: str,
        rpc_method: str,
        request_text: str,
        response_text: str,
        accounts: typing.Union[str, typing.List[str], None],
        errors: typing.Union[str, typing.List[str], None],
        logs: typing.Union[str, typing.List[str], None],
        instruction_reporter: InstructionReporter = InstructionReporter(),
    ) -> None:
        super().__init__(message, name, cluster_rpc_url)
        self.transaction: typing.Optional[Transaction] = transaction
        self.code: int = code
        self.rpc_method: str = rpc_method
        self.request_text: str = request_text
        self.response_text: str = response_text

        def _ensure_list(
            item: typing.Union[str, typing.List[str], None]
        ) -> typing.List[str]:
            if item is None:
                return []
            if isinstance(item, str):
                return [item]
            if isinstance(item, list):
                return item
            return [f"{item}"]

        self.accounts: typing.Sequence[str] = _ensure_list(accounts)
        self.errors: typing.Sequence[str] = _ensure_list(errors)
        self.logs: typing.Sequence[str] = expand_log_messages(_ensure_list(logs))
        self.instruction_reporter: InstructionReporter = instruction_reporter

    def __str__(self) -> str:
        try:
            transaction_details = ""
            if self.transaction is not None:
                instruction_details = "\n".join(
                    list(
                        map(
                            self.instruction_reporter.report,
                            self.transaction.instructions,
                        )
                    )
                )
                transaction_details = (
                    "\n    Instructions:\n        "
                    + instruction_details.replace("\n", "\n        ")
                )
            accounts = "No Accounts"
            if len(self.accounts) > 0:
                accounts = "\n        ".join(
                    [f"{item}".replace("\n", "\n        ") for item in self.accounts]
                )
            errors = "No Errors"
            if len(self.errors) > 0:
                errors = "\n        ".join(
                    [f"{item}".replace("\n", "\n        ") for item in self.errors]
                )
            logs = "No Logs"
            if len(self.logs) > 0:
                logs = "\n        ".join(
                    [f"{item}".replace("\n", "\n        ") for item in self.logs]
                )
            return f"""« TransactionException in '{self.name}' [{self.rpc_method}]: {self.code}:: {self.message}{transaction_details}
    Accounts:
        {accounts}
    Errors:
        {errors}
    Logs:
        {logs}
»"""
        except Exception as exception:
            return f"TransactionException printing failed with: {exception}"

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


UnspecifiedCommitment = Commitment("unspecified")
UnspecifiedEncoding = "unspecified"


# # 🥭 AbstractSlotHolder class
#
# A `SlotHolder` shares the latest slot across multiple `RPCCaller`s.
#
class AbstractSlotHolder(metaclass=abc.ABCMeta):
    def __init__(self) -> None:
        self._logger: logging.Logger = logging.getLogger(self.__class__.__name__)

    @abc.abstractproperty
    def latest_slot(self) -> int:
        raise NotImplementedError(
            "AbstractSlotHolder.latest_slot() is not implemented on the base type."
        )

    def require_data_from_fresh_slot(
        self, latest_slot: typing.Optional[int] = None
    ) -> None:
        raise NotImplementedError(
            "AbstractSlotHolder.require_data_from_fresh_slot() is not implemented on the base type."
        )

    def is_acceptable(self, slot_to_check: int) -> bool:
        raise NotImplementedError(
            "AbstractSlotHolder.is_acceptable() is not implemented on the base type."
        )


# # 🥭 NullSlotHolder class
#
# A `NullSlotHolder` takes no slot-checking action.
#
class NullSlotHolder(AbstractSlotHolder):
    def __init__(self) -> None:
        super().__init__()

    @property
    def latest_slot(self) -> int:
        return 0

    def require_data_from_fresh_slot(
        self, latest_slot: typing.Optional[int] = None
    ) -> None:
        pass

    def is_acceptable(self, slot_to_check: int) -> bool:
        return True


# # 🥭 CheckingSlotHolder class
#
# A `CheckingSlotHolder` allows checking if a given slot is newer or equal to the last slot seen.
#
class CheckingSlotHolder(AbstractSlotHolder):
    def __init__(self) -> None:
        super().__init__()
        self.__latest_slot: int = 0

    @property
    def latest_slot(self) -> int:
        return self.__latest_slot

    def require_data_from_fresh_slot(
        self, latest_slot: typing.Optional[int] = None
    ) -> None:
        latest: int = latest_slot or self.latest_slot
        if latest >= self.latest_slot:
            self.__latest_slot = latest + 1
            self._logger.debug(
                f"Requiring data from slot {self.latest_slot} onwards now."
            )

    def is_acceptable(self, slot_to_check: int) -> bool:
        if slot_to_check < self.__latest_slot:
            return False

        if slot_to_check > self.__latest_slot:
            self.__latest_slot = slot_to_check
            self._logger.debug(
                f"Only accepting data from slot {self.latest_slot} onwards now."
            )
        return True


class TransactionMonitor(metaclass=abc.ABCMeta):
    def __init__(
        self,
        commitment: Commitment = Finalized,
        transaction_timeout: float = 90.0,
        slot_holder: AbstractSlotHolder = NullSlotHolder(),
    ) -> None:
        self._logger: logging.Logger = logging.getLogger(self.__class__.__name__)
        self.commitment: Commitment = commitment
        self.transaction_timeout: float = transaction_timeout

        self.slot_holder: AbstractSlotHolder = slot_holder

    @abc.abstractmethod
    def monitor(self, signature: str) -> None:
        raise NotImplementedError(
            "TransactionMonitor.monitor() is not implemented on the base type."
        )

    def dispose(self) -> None:
        pass


class NullTransactionMonitor(TransactionMonitor):
    def __init__(
        self,
        commitment: Commitment = Finalized,
        transaction_timeout: float = 90.0,
        slot_holder: AbstractSlotHolder = NullSlotHolder(),
    ) -> None:
        super().__init__(
            commitment=commitment,
            transaction_timeout=transaction_timeout,
            slot_holder=slot_holder,
        )

    def monitor(self, signature: str) -> None:
        pass


# # 🥭 RPCCaller class
#
# A `RPCCaller` extends the HTTPProvider with better error handling.
#
class RPCCaller(HTTPProvider):
    def __init__(
        self,
        name: str,
        cluster_rpc_url: str,
        cluster_ws_url: str,
        http_request_timeout: float,
        stale_data_pauses_before_retry: typing.Sequence[float],
        slot_holder: AbstractSlotHolder,
        instruction_reporter: InstructionReporter,
    ):
        super().__init__(cluster_rpc_url)
        self._logger: logging.Logger = logging.getLogger(self.__class__.__name__)
        self.name: str = name
        self.cluster_rpc_url: str = cluster_rpc_url
        self.cluster_ws_url: str = cluster_ws_url
        self.http_request_timeout: float = http_request_timeout
        self.stale_data_pauses_before_retry: typing.Sequence[
            float
        ] = stale_data_pauses_before_retry
        self.slot_holder: AbstractSlotHolder = slot_holder
        self.instruction_reporter: InstructionReporter = instruction_reporter

    def require_data_from_fresh_slot(
        self, latest_slot: typing.Optional[int] = None
    ) -> None:
        self.slot_holder.require_data_from_fresh_slot(latest_slot)

    def make_request(self, method: RPCMethod, *params: typing.Any) -> RPCResponse:
        # No pauses specified means this funcitonality is turned off.
        if len(self.stale_data_pauses_before_retry) == 0:
            return self.__make_request(method, *params)

        at_least_one_submission: bool = False
        last_stale_slot_exception: StaleSlotException
        for pause in [*self.stale_data_pauses_before_retry, 0]:
            try:
                return self.__make_request(method, *params)
            except TransactionAlreadyProcessedException as transaction_already_processed_exception:
                if not at_least_one_submission:
                    raise transaction_already_processed_exception

                # The transaction has already been processed (even though we previously got a stale slot
                # response). So, yay? It's good that it was successfully handled (and presumably processed)
                # and it means we can safely ignore this exception, but it does leave us with no proper
                # response to return.
                #
                # Return fake data in the expected structure for now, and try to figure out a better way.
                return {
                    "jsonrpc": "2.0",
                    "id": 0,
                    "result": _STUB_TRANSACTION_SIGNATURE,
                }
            except StaleSlotException as exception:
                last_stale_slot_exception = exception
                self._logger.debug(
                    f"Will retry after pause of {pause} seconds after getting stale slot: {exception}"
                )
                time.sleep(pause)
            at_least_one_submission = True

        # They've all failed.
        raise last_stale_slot_exception

    def __make_request(self, method: RPCMethod, *params: typing.Any) -> RPCResponse:
        # This is the entire method in HTTPProvider that we're overriding here:
        #
        # """Make an HTTP request to an http rpc endpoint."""
        # request_kwargs = self._before_request(method=method, params=params, is_async=False)
        # raw_response = requests.post(**request_kwargs)
        # return self._after_request(raw_response=raw_response, method=method)

        request_kwargs = self._before_request(
            method=method, params=params, is_async=False
        )
        http_post_timeout: typing.Union[float, None] = (
            self.http_request_timeout if self.http_request_timeout >= 0 else None
        )
        raw_response = requests.post(**request_kwargs, timeout=http_post_timeout)

        # Some custom exceptions specifically for rate-limiting. This allows calling code to handle this
        # specific case if they so choose.
        #
        # "You will see HTTP respose codes 429 for too many requests or 413 for too much bandwidth."
        if raw_response.status_code == 413:
            raise TooMuchBandwidthRateLimitException(
                f"Rate limited (too much bandwidth) calling method '{method}' on {self.cluster_rpc_url}",
                self.name,
                self.cluster_rpc_url,
            )
        elif raw_response.status_code == 429:
            raise TooManyRequestsRateLimitException(
                f"Rate limited (too many requests) calling method '{method}' on {self.cluster_rpc_url}",
                self.name,
                self.cluster_rpc_url,
            )

        # Not a rate-limit problem, but maybe there was some other error?
        raw_response.raise_for_status()

        # All seems OK, but maybe the server returned an error? If so, try to pass on as much
        # information as we can.
        response_text: str = raw_response.text
        response: typing.Dict[str, typing.Any] = json.loads(response_text)

        # Did we get sufficiently up-to-date information? It must be from the last slot we saw or a
        # newer slot.
        #
        # Only do this check if we're using a commitment level of 'processed'.
        if (
            isinstance(params, Mapping)
            and len(params) > 1
            and "commitment" in params[1]
            and params[1]["commitment"] == Processed
        ):
            if (
                "result" in response
                and isinstance(response["result"], Mapping)
                and "context" in response["result"]
                and isinstance(response["result"]["context"], Mapping)
                and "slot" in response["result"]["context"]
            ):
                slot: int = response["result"]["context"]["slot"]
                if not self.slot_holder.is_acceptable(slot):
                    self._logger.warning(
                        f"Result is from stale slot: {slot} - latest slot is: {self.slot_holder.latest_slot}"
                    )
                    raise StaleSlotException(
                        self.name,
                        self.cluster_rpc_url,
                        self.slot_holder.latest_slot,
                        slot,
                    )

        if "error" in response:
            if response["error"] is str:
                message: str = typing.cast(str, response["error"])
                raise ClientException(
                    f"Transaction failed: '{message}'", self.name, self.cluster_rpc_url
                )
            else:
                error = response["error"]
                error_message: str = (
                    error["message"] if "message" in error else "No message"
                )
                error_data: typing.Dict[str, typing.Any] = (
                    error["data"] if "data" in error else {}
                )
                error_accounts = (
                    error_data["accounts"]
                    if "accounts" in error_data
                    else "No accounts"
                )
                error_code: int = error["code"] if "code" in error else -1
                error_err = (
                    error_data["err"]
                    if "err" in error_data
                    else "No error text returned"
                )
                error_logs = error_data["logs"] if "logs" in error_data else "No logs"
                parameters = json.dumps(
                    {"jsonrpc": "2.0", "method": method, "params": params}
                )

                transaction: typing.Optional[Transaction] = None
                blockhash: typing.Optional[Blockhash] = None
                if method == "sendTransaction":
                    transaction = Transaction.deserialize(b64decode(params[0]))
                    blockhash = transaction.recent_blockhash

                if error_code == -32005:
                    slots_behind: int = (
                        error["data"]["numSlotsBehind"]
                        if "numSlotsBehind" in error["data"]
                        else -1
                    )
                    raise NodeIsBehindException(
                        self.name, self.cluster_rpc_url, slots_behind
                    )

                if error_err == "BlockhashNotFound":
                    raise BlockhashNotFoundException(
                        self.name, self.cluster_rpc_url, blockhash
                    )

                if error_err == "AlreadyProcessed":
                    raise TransactionAlreadyProcessedException(
                        error_message, self.name, self.cluster_rpc_url
                    )

                exception_message: str = f"Transaction failed with: '{error_message}'"
                raise TransactionException(
                    transaction,
                    exception_message,
                    error_code,
                    self.name,
                    self.cluster_rpc_url,
                    method,
                    parameters,
                    response_text,
                    error_accounts,
                    error_err,
                    error_logs,
                    self.instruction_reporter,
                )

        if method == "getRecentBlockhash":
            if (
                "result" in response
                and "value" in response["result"]
                and "blockhash" in response["result"]["value"]
                and "context" in response["result"]
                and "slot" in response["result"]["context"]
            ):
                fresh_blockhash = Blockhash(response["result"]["value"]["blockhash"])
                fresh_blockhash_slot = Blockhash(response["result"]["context"]["slot"])
                self._logger.debug(
                    f"Recent blockhash [slot: {fresh_blockhash_slot}]: {fresh_blockhash}"
                )

        # The call succeeded.
        return typing.cast(RPCResponse, response)

    def __str__(self) -> str:
        return f"« RPCCaller [{self.cluster_rpc_url}] »"

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


# # 🥭 CompoundRPCCaller class
#
# A `CompoundRPCCaller` will try multiple providers until it succeeds (or the all fail). Should only trap
# and switch provider on exceptions that show that provider is no longer at the tip of the chain.
#
class CompoundRPCCaller(HTTPProvider):
    def __init__(self, name: str, providers: typing.Sequence[RPCCaller]):
        self._logger: logging.Logger = logging.getLogger(self.__class__.__name__)
        self.__providers: typing.Sequence[RPCCaller] = providers
        self.name: str = name
        self.on_provider_change: typing.Callable[[], None] = lambda: None

        # Some libraries (like the SPL Token Library) depend on `endpoint_uri` existing on
        # a `Provider`, and static typing complains if we use a property instead of a
        # variable. We add it here and keep it up to date when we switch inner providers.
        self.endpoint_uri = providers[0].endpoint_uri

    @property
    def current(self) -> RPCCaller:
        return self.__providers[0]

    @property
    def all_providers(self) -> typing.Sequence[RPCCaller]:
        return self.__providers

    def shift_to_next_provider(self) -> None:
        # This is called when the current provider is raising errors, when the next provider might not.
        # Typical RPC host errors are trapped and managed via make_request(), but some errors can't be
        # handled properly there. For example, BlockhashNotFound exceptions can be trapped there, but
        # the right answer is to switch to the next provider AND THEN fetch a fresh blockhash and retry
        # the transaction. That's not possible to do atomically (without a lot of nasty, fragile work) so
        # it's better to handle it at the higher level. That's what this method allows - the higher level
        # can call this to switch to the next provider, and it can then fetch the fresh blockhash and
        # resubmit the transaction.
        if len(self.__providers) > 1:
            self.__providers = [*self.__providers[1:], *self.__providers[:1]]
            self.endpoint_uri = self.__providers[0].endpoint_uri
            self.on_provider_change()
        self._logger.debug(f"Told to shift provider - now using: {self.__providers[0]}")

    def make_request(self, method: RPCMethod, *params: typing.Any) -> RPCResponse:
        all_exceptions: typing.List[Exception] = []
        for provider in self.__providers:
            try:
                result = provider.make_request(method, *params)
                successful_index: int = self.__providers.index(provider)
                if successful_index != 0:
                    # Rebase the providers' list so we continue to use this successful one (until it fails)
                    self.__providers = [
                        *self.__providers[successful_index:],
                        *self.__providers[:successful_index],
                    ]
                    self.endpoint_uri = self.__providers[0].endpoint_uri
                    self.on_provider_change()
                    self._logger.debug(
                        f"Shifted provider - now using: {self.__providers[0]}"
                    )
                return result
            except (
                requests.exceptions.HTTPError,
                requests.exceptions.ConnectionError,
                requests.exceptions.Timeout,
                RateLimitException,
                NodeIsBehindException,
                StaleSlotException,
                FailedToFetchBlockhashException,
            ) as exception:
                all_exceptions += [exception]
                self._logger.info(
                    f"Moving to next provider - {provider} gave {exception}"
                )

        if len(all_exceptions) == 1:
            raise all_exceptions[0]

        raise CompoundException(self.name, all_exceptions)

    def is_connected(self) -> bool:
        # All we need for this to be true is for one of our providers to be connected.
        for provider in self.__providers:
            if provider.is_connected():
                return True
        return False

    def __str__(self) -> str:
        return f"« CompoundRPCCaller with {len(self.__providers)} providers - current head is: {self.__providers[0]} »"

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


# # Data transfer class for cluster url
#
@dataclass
class ClusterUrlData:
    rpc: str  # http/https connection to contact RPC nodes
    ws: str = ""  # ws/wss connection to register to RPC websocket

    def __post_init__(self) -> None:
        if not self.ws:
            self.ws = self.rpc.replace("https:", "wss:", 1)
            self.ws = self.ws.replace("http:", "ws:", 1)

    def __str__(self) -> str:
        return f"« ClusterUrlData RPC: {self.rpc} WS: {self.ws} »"

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


class BetterClient:
    def __init__(
        self,
        client: Client,
        name: str,
        cluster_name: str,
        commitment: Commitment,
        skip_preflight: bool,
        tpu_retransmissions: int,
        encoding: str,
        blockhash_cache_duration: int,
        rpc_caller: CompoundRPCCaller,
        transaction_monitor: TransactionMonitor = NullTransactionMonitor(),
    ) -> None:
        self._logger: logging.Logger = logging.getLogger(self.__class__.__name__)
        self.compatible_client: Client = client
        self.name: str = name
        self.cluster_name: str = cluster_name
        self.commitment: Commitment = commitment
        self.skip_preflight: bool = skip_preflight
        self.tpu_retransmissions: int = tpu_retransmissions
        self.encoding: str = encoding
        self.blockhash_cache_duration: int = blockhash_cache_duration
        self.rpc_caller: CompoundRPCCaller = rpc_caller
        self.transaction_monitor: TransactionMonitor = transaction_monitor

    @staticmethod
    def from_configuration(
        name: str,
        cluster_name: str,
        cluster_urls: typing.Sequence[ClusterUrlData],
        commitment: Commitment,
        skip_preflight: bool,
        tpu_retransmissions: int,
        encoding: str,
        blockhash_cache_duration: int,
        http_request_timeout: float,
        stale_data_pauses_before_retry: typing.Sequence[float],
        instruction_reporter: InstructionReporter,
        transaction_monitor: TransactionMonitor = NullTransactionMonitor(),
    ) -> "BetterClient":
        rpc_callers: typing.List[RPCCaller] = []
        for cluster_url in cluster_urls:
            rpc_caller: RPCCaller = RPCCaller(
                name,
                cluster_url.rpc,
                cluster_url.ws,
                http_request_timeout,
                stale_data_pauses_before_retry,
                transaction_monitor.slot_holder,
                instruction_reporter,
            )
            rpc_callers += [rpc_caller]

        provider: CompoundRPCCaller = CompoundRPCCaller(name, rpc_callers)
        blockhash_cache: typing.Union[BlockhashCache, bool] = False
        if blockhash_cache_duration > 0:
            blockhash_cache = BlockhashCache(blockhash_cache_duration)
        client: Client = Client(
            endpoint=cluster_urls[0].rpc,
            commitment=commitment,
            blockhash_cache=blockhash_cache,
        )
        client._provider = provider

        def __on_provider_change() -> None:
            if client.blockhash_cache:
                # Clear out the blockhash cache on retrying
                logging.debug("Replacing client blockhash cache.")
                client.blockhash_cache = BlockhashCache(blockhash_cache_duration)
                blockhash_resp = client.get_recent_blockhash(Finalized)
                client._process_blockhash_resp(blockhash_resp, used_immediately=False)

        provider.on_provider_change = __on_provider_change

        return BetterClient(
            client,
            name,
            cluster_name,
            commitment,
            skip_preflight,
            tpu_retransmissions,
            encoding,
            blockhash_cache_duration,
            provider,
            transaction_monitor,
        )

    @property
    def cluster_rpc_url(self) -> str:
        return self.rpc_caller.current.cluster_rpc_url

    @property
    def cluster_rpc_urls(self) -> typing.Sequence[str]:
        return [
            rpc_caller.cluster_rpc_url for rpc_caller in self.rpc_caller.all_providers
        ]

    @property
    def cluster_ws_url(self) -> str:
        return self.rpc_caller.current.cluster_ws_url

    @property
    def cluster_ws_urls(self) -> typing.Sequence[str]:
        return [
            rpc_caller.cluster_ws_url for rpc_caller in self.rpc_caller.all_providers
        ]

    @property
    def cluster_urls(self) -> typing.Sequence[ClusterUrlData]:
        return [
            ClusterUrlData(rpc=rpc_caller.cluster_rpc_url, ws=rpc_caller.cluster_ws_url)
            for rpc_caller in self.rpc_caller.all_providers
        ]

    @property
    def instruction_reporter(self) -> InstructionReporter:
        return self.rpc_caller.current.instruction_reporter

    @property
    def stale_data_pauses_before_retry(self) -> typing.Sequence[float]:
        return self.rpc_caller.current.stale_data_pauses_before_retry

    def dispose(self) -> None:
        self.transaction_monitor.dispose()

    def require_data_from_fresh_slot(self) -> None:
        self.rpc_caller.current.require_data_from_fresh_slot()

    def get_balance(
        self,
        pubkey: typing.Union[PublicKey, str],
        commitment: Commitment = UnspecifiedCommitment,
    ) -> Decimal:
        resolved_commitment, _ = self.__resolve_defaults(commitment)
        response = self.compatible_client.get_balance(pubkey, resolved_commitment)
        value = Decimal(response["result"]["value"])
        return value / SOL_DECIMAL_DIVISOR

    def get_account_info(
        self,
        pubkey: typing.Union[PublicKey, str],
        commitment: Commitment = UnspecifiedCommitment,
        encoding: str = UnspecifiedEncoding,
        data_slice: typing.Optional[DataSliceOpts] = None,
    ) -> typing.Any:
        resolved_commitment, resolved_encoding = self.__resolve_defaults(
            commitment, encoding
        )
        response = self.compatible_client.get_account_info(
            pubkey, resolved_commitment, resolved_encoding, data_slice
        )
        return response["result"]

    def get_confirmed_signatures_for_address2(
        self,
        account: typing.Union[str, Keypair, PublicKey],
        before: typing.Optional[str] = None,
        until: typing.Optional[str] = None,
        limit: typing.Optional[int] = None,
    ) -> typing.Sequence[str]:
        response = self.compatible_client.get_confirmed_signature_for_address2(
            account, before, until, limit
        )
        return [result["signature"] for result in response["result"]]

    def get_confirmed_transaction(
        self, signature: str, encoding: str = "json"
    ) -> typing.Any:
        _, resolved_encoding = self.__resolve_defaults(None, encoding)
        response = self.compatible_client.get_confirmed_transaction(
            signature, resolved_encoding
        )
        return response["result"]

    def get_minimum_balance_for_rent_exemption(
        self, size: int, commitment: Commitment = UnspecifiedCommitment
    ) -> int:
        resolved_commitment, _ = self.__resolve_defaults(commitment)
        response = self.compatible_client.get_minimum_balance_for_rent_exemption(
            size, resolved_commitment
        )
        return int(response["result"])

    def get_program_accounts(
        self,
        pubkey: typing.Union[str, PublicKey],
        commitment: Commitment = UnspecifiedCommitment,
        encoding: typing.Optional[str] = UnspecifiedEncoding,
        data_slice: typing.Optional[DataSliceOpts] = None,
        data_size: typing.Optional[int] = None,
        memcmp_opts: typing.Optional[typing.List[MemcmpOpts]] = None,
    ) -> typing.Any:
        resolved_commitment, resolved_encoding = self.__resolve_defaults(
            commitment, encoding
        )
        response = self.compatible_client.get_program_accounts(
            pubkey,
            resolved_commitment,
            resolved_encoding,
            data_slice,
            data_size,
            memcmp_opts,
        )

        return response["result"]

    def get_recent_blockhash(
        self, commitment: Commitment = UnspecifiedCommitment
    ) -> Blockhash:
        resolved_commitment, _ = self.__resolve_defaults(commitment)
        response = self.compatible_client.get_recent_blockhash(resolved_commitment)
        return Blockhash(response["result"]["value"]["blockhash"])

    def get_token_account_balance(
        self,
        pubkey: typing.Union[str, PublicKey],
        commitment: Commitment = UnspecifiedCommitment,
    ) -> Decimal:
        resolved_commitment, _ = self.__resolve_defaults(commitment)
        response = self.compatible_client.get_token_account_balance(
            pubkey, resolved_commitment
        )
        value = Decimal(response["result"]["value"]["amount"])
        decimal_places = response["result"]["value"]["decimals"]
        divisor = Decimal(10**decimal_places)
        return value / divisor

    def get_token_accounts_by_owner(
        self,
        owner: PublicKey,
        token_account_options: TokenAccountOpts,
        commitment: Commitment = UnspecifiedCommitment,
    ) -> typing.Any:
        resolved_commitment, _ = self.__resolve_defaults(commitment)
        response = self.compatible_client.get_token_accounts_by_owner(
            owner, token_account_options, resolved_commitment
        )
        return response["result"]["value"]

    def get_multiple_accounts(
        self,
        pubkeys: typing.List[typing.Union[PublicKey, str]],
        commitment: Commitment = UnspecifiedCommitment,
        encoding: str = UnspecifiedEncoding,
        data_slice: typing.Optional[DataSliceOpts] = None,
    ) -> typing.Any:
        resolved_commitment, resolved_encoding = self.__resolve_defaults(
            commitment, encoding
        )
        response = self.compatible_client.get_multiple_accounts(
            pubkeys, resolved_commitment, resolved_encoding, data_slice
        )
        return response["result"]["value"]

    def send_transaction(
        self,
        transaction: Transaction,
        *signers: Keypair,
        opts: TxOpts = TxOpts(preflight_commitment=UnspecifiedCommitment),
        recent_blockhash: typing.Optional[Blockhash] = None,
    ) -> str:
        # This method is an exception to the normal exception-handling to fail over to the next RPC provider.
        #
        # Normal RPC exceptions just move on to the next RPC provider and try again. That won't work with the
        # BlockhashNotFoundException, since a stale blockhash will be stale for all providers and it probably
        # indicates a problem with the current node returning the stale blockhash anyway.
        #
        # What we want to do in this situation is: retry the same transaction (which we know for certain failed)
        # but retry it with the next provider in the list, with a fresh recent_blockhash. (Setting the transaction's
        # recent_blockhash to None makes the client fetch a fresh one.)
        last_exception: BlockhashNotFoundException
        for provider in self.rpc_caller.all_providers:
            try:
                proper_commitment: Commitment = opts.preflight_commitment
                proper_skip_preflight = opts.skip_preflight
                proper_tpu_retransmissions = opts.max_retries
                if proper_commitment == UnspecifiedCommitment:
                    proper_commitment = self.commitment
                    proper_skip_preflight = self.skip_preflight
                    proper_tpu_retransmissions = (
                        self.tpu_retransmissions
                        if self.tpu_retransmissions >= 0
                        else None
                    )

                proper_opts = TxOpts(
                    preflight_commitment=proper_commitment,
                    skip_confirmation=opts.skip_confirmation,
                    skip_preflight=proper_skip_preflight,
                    max_retries=proper_tpu_retransmissions,
                )

                response = self.compatible_client.send_transaction(
                    transaction,
                    *signers,
                    opts=proper_opts,
                    recent_blockhash=recent_blockhash,
                )
                signature: str = str(response["result"])
                self._logger.debug(f"Transaction signature: {signature}")

                if signature != _STUB_TRANSACTION_SIGNATURE:
                    self.transaction_monitor.monitor(signature)
                else:
                    self._logger.error("Could not get status for stub signature")

                return signature
            except BlockhashNotFoundException as blockhash_not_found_exception:
                self._logger.debug(
                    f"Trying next provider after intercepting blockhash exception on provider {provider}: {blockhash_not_found_exception}"
                )
                last_exception = blockhash_not_found_exception
                transaction.recent_blockhash = None
                self.rpc_caller.shift_to_next_provider()

        raise last_exception

    def wait_for_confirmation(
        self, transaction_ids: typing.Sequence[str], max_wait_in_seconds: int = 60
    ) -> typing.Sequence[str]:
        self._logger.info(
            f"Waiting up to {max_wait_in_seconds} seconds for {transaction_ids}."
        )
        all_confirmed: typing.List[str] = []
        start_time: datetime = local_now()
        cutoff: datetime = start_time + timedelta(seconds=max_wait_in_seconds)
        for transaction_id in transaction_ids:
            while local_now() < cutoff:
                time.sleep(1)
                confirmed = self.get_confirmed_transaction(transaction_id)
                if confirmed is not None:
                    self._logger.info(
                        f"Confirmed {transaction_id} after {local_now() - start_time} seconds."
                    )
                    all_confirmed += [transaction_id]
                    break

        if len(all_confirmed) != len(transaction_ids):
            self._logger.info(
                f"Timed out after {max_wait_in_seconds} seconds waiting on transaction {transaction_id}."
            )
        return all_confirmed

    def __resolve_defaults(
        self,
        commitment: typing.Optional[Commitment],
        encoding: typing.Optional[str] = None,
    ) -> typing.Tuple[Commitment, str]:
        if commitment is None or commitment == UnspecifiedCommitment:
            commitment = self.commitment

        if encoding is None or encoding == UnspecifiedEncoding:
            encoding = self.encoding

        return commitment, encoding

    def __str__(self) -> str:
        return f"« BetterClient [{self.cluster_name}]: {self.cluster_rpc_urls}, {self.cluster_ws_urls} »"

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