import logging
from collections import defaultdict
from typing import TYPE_CHECKING, Any, Dict, List, NamedTuple, Optional, Tuple, Union

from eth_utils import to_checksum_address
from typing_extensions import Literal

from rotkehlchen.accounting.structures import Balance, BalanceType
from rotkehlchen.assets.asset import Asset, EthereumToken
from rotkehlchen.chain.ethereum.graph import Graph, get_common_params
from rotkehlchen.chain.ethereum.utils import token_normalized_value
from rotkehlchen.chain.ethereum.zerion import GIVEN_DEFI_BALANCES
from rotkehlchen.constants.ethereum import CTOKEN_ABI, ERC20TOKEN_ABI, EthereumConstants
from rotkehlchen.constants.misc import ZERO
from rotkehlchen.db.dbhandler import DBHandler
from rotkehlchen.errors import BlockchainQueryError, RemoteError, UnknownAsset
from rotkehlchen.fval import FVal
from rotkehlchen.history.price import query_usd_price_zero_if_error
from rotkehlchen.inquirer import Inquirer
from rotkehlchen.typing import ChecksumEthAddress, Timestamp
from rotkehlchen.user_messages import MessagesAggregator
from rotkehlchen.utils.interfaces import EthereumModule
from rotkehlchen.utils.misc import hexstr_to_int

if TYPE_CHECKING:
    from rotkehlchen.chain.ethereum.manager import EthereumManager

ADDRESS_TO_ASSETS = Dict[ChecksumEthAddress, Dict[Asset, Balance]]
BLOCKS_PER_DAY = 4 * 60 * 24
DAYS_PER_YEAR = 365
ETH_MANTISSA = 10**18

COMPTROLLER_PROXY = EthereumConstants().contract('COMPTROLLER_PROXY')
COMP_DEPLOYED_BLOCK = 9601359


LEND_EVENTS_QUERY_PREFIX = """{graph_event_name}
(where: {{blockTime_lte: $end_ts, blockTime_gte: $start_ts, {addr_position}: $address}}) {{
    id
    amount
    to
    from
    blockNumber
    blockTime
    cTokenSymbol
    underlyingAmount
}}}}"""


BORROW_EVENTS_QUERY_PREFIX = """{graph_event_name}
 (where: {{blockTime_lte: $end_ts, blockTime_gte: $start_ts, borrower: $address}}) {{
    id
    amount
    borrower
    blockNumber
    blockTime
    underlyingSymbol
    {payer_or_empty}
}}}}"""


log = logging.getLogger(__name__)


class CompoundBalance(NamedTuple):
    balance_type: BalanceType
    balance: Balance
    apy: Optional[FVal]

    def serialize(self) -> Dict[str, Union[Optional[str], Dict[str, str]]]:
        return {
            'balance': self.balance.serialize(),
            'apy': self.apy.to_percentage(precision=2) if self.apy else None,
        }


class CompoundEvent(NamedTuple):
    event_type: Literal['mint', 'redeem', 'borrow', 'repay', 'liquidation', 'comp']
    address: ChecksumEthAddress
    block_number: int
    timestamp: Timestamp
    asset: Asset
    value: Balance
    to_asset: Optional[Asset]
    to_value: Optional[Balance]
    realized_pnl: Optional[Balance]
    tx_hash: str
    log_index: int  # only used to identify uniqueness

    def serialize(self) -> Dict[str, Any]:
        serialized = self._asdict()  # pylint: disable=no-member
        del serialized['log_index']
        return serialized


def _get_txhash_and_logidx(identifier: str) -> Optional[Tuple[str, int]]:
    result = identifier.split('-')
    if len(result) != 2:
        return None

    if len(result[0]) == 0 or len(result[1]) == 0:
        return None

    try:
        log_index = int(result[1])
    except ValueError:
        return None

    return result[0], log_index


class Compound(EthereumModule):
    """Compound integration module

    https://compound.finance/docs#guides
    """

    def __init__(
            self,
            ethereum_manager: 'EthereumManager',
            database: DBHandler,
            msg_aggregator: MessagesAggregator,
    ):
        self.ethereum = ethereum_manager
        self.database = database
        self.msg_aggregator = msg_aggregator
        try:
            self.graph: Optional[Graph] = Graph(
                'https://api.thegraph.com/subgraphs/name/graphprotocol/compound-v2',
            )
        except RemoteError as e:
            self.graph = None
            self.msg_aggregator.add_error(
                f'Could not initialize the Compound subgraph due to {str(e)}. '
                f' All compound historical queries are not functioning until this is fixed. '
                f'Probably will get fixed with time. If not report it to Rotkis support channel ',
            )

        self.comptroller_address = to_checksum_address(COMPTROLLER_PROXY.call(
            ethereum=self.ethereum,
            method_name='comptrollerImplementation',
        ))

    def _get_apy(self, address: ChecksumEthAddress, supply: bool) -> Optional[FVal]:
        method_name = 'supplyRatePerBlock' if supply else 'borrowRatePerBlock'

        try:
            rate = self.ethereum.call_contract(
                contract_address=address,
                abi=CTOKEN_ABI,
                method_name=method_name,
            )
        except (RemoteError, BlockchainQueryError) as e:
            log.error(f'Could not query cToken {address} for supply/borrow rate: {str(e)}')
            return None

        apy = ((FVal(rate) / ETH_MANTISSA * BLOCKS_PER_DAY) + 1) ** (DAYS_PER_YEAR - 1) - 1  # noqa: E501
        return apy

    def get_balances(
            self,
            given_defi_balances: GIVEN_DEFI_BALANCES,
    ) -> Dict[ChecksumEthAddress, Dict]:
        compound_balances = {}
        if isinstance(given_defi_balances, dict):
            defi_balances = given_defi_balances
        else:
            defi_balances = given_defi_balances()

        for account, balance_entries in defi_balances.items():
            lending_map = {}
            borrowing_map = {}
            rewards_map = {}
            for balance_entry in balance_entries:
                if balance_entry.protocol.name != 'Compound':
                    continue

                entry = balance_entry.base_balance
                try:
                    asset = Asset(entry.token_symbol)
                except UnknownAsset:
                    log.error(
                        f'Encountered unknown asset {entry.token_symbol} in compound. Skipping',
                    )
                    continue

                if entry.token_address == EthereumToken('COMP').ethereum_address:
                    rewards_map[EthereumToken('COMP')] = CompoundBalance(
                        balance_type=BalanceType.ASSET,
                        balance=entry.balance,
                        apy=None,
                    )
                    continue

                if balance_entry.balance_type == 'Asset':
                    # Get the underlying balance
                    underlying_symbol = balance_entry.underlying_balances[0].token_symbol
                    try:
                        underlying_asset = Asset(underlying_symbol)
                    except UnknownAsset:
                        log.error(
                            f'Encountered unknown asset {underlying_symbol} in compound. Skipping',
                        )
                        continue

                    lending_map[underlying_asset.identifier] = CompoundBalance(
                        balance_type=BalanceType.ASSET,
                        balance=balance_entry.underlying_balances[0].balance,
                        apy=self._get_apy(entry.token_address, supply=True),
                    )
                else:  # 'Debt'
                    try:
                        ctoken = EthereumToken('c' + entry.token_symbol)
                    except UnknownAsset:
                        log.error(
                            f'Encountered unknown asset {entry.token_symbol} in '
                            f'compound while figuring out cToken. Skipping',
                        )
                        continue

                    borrowing_map[asset.identifier] = CompoundBalance(
                        balance_type=BalanceType.LIABILITY,
                        balance=entry.balance,
                        apy=self._get_apy(ctoken.ethereum_address, supply=False),
                    )

            if lending_map == {} and borrowing_map == {} and rewards_map == {}:
                # no balances for the account
                continue
            compound_balances[account] = {
                'rewards': rewards_map,
                'lending': lending_map,
                'borrowing': borrowing_map,
            }

        return compound_balances

    def _get_borrow_events(
            self,
            event_type: Literal['borrow', 'repay'],
            address: ChecksumEthAddress,
            from_ts: Timestamp,
            to_ts: Timestamp,
    ) -> List[CompoundEvent]:
        param_types, param_values = get_common_params(from_ts, to_ts, address)
        if event_type == 'borrow':
            graph_event_name = 'borrowEvents'
            payer_or_empty = ''
        elif event_type == 'repay':
            graph_event_name = 'repayEvents'
            payer_or_empty = 'payer'

        result = self.graph.query(  # type: ignore
            querystr=BORROW_EVENTS_QUERY_PREFIX.format(
                graph_event_name=graph_event_name,
                payer_or_empty=payer_or_empty,
            ),
            param_types=param_types,
            param_values=param_values,
        )

        events = []
        for entry in result[graph_event_name]:
            underlying_symbol = entry['underlyingSymbol']
            try:
                underlying_asset = Asset(underlying_symbol)
            except UnknownAsset:
                log.error(
                    f'Found unexpected token symbol {underlying_symbol} during '
                    f'graph query. Skipping.',
                )
                continue
            timestamp = entry['blockTime']
            usd_price = query_usd_price_zero_if_error(
                asset=underlying_asset,
                time=timestamp,
                location=f'compound {event_type}',
                msg_aggregator=self.msg_aggregator,
            )
            amount = FVal(entry['amount'])
            parse_result = _get_txhash_and_logidx(entry['id'])
            if parse_result is None:
                log.error(
                    f'Found unprocessable borrow/repay id from the graph {entry["id"]}. Skipping',
                )
                continue

            events.append(CompoundEvent(
                event_type=event_type,
                address=address,
                block_number=entry['blockNumber'],
                timestamp=timestamp,
                asset=underlying_asset,
                value=Balance(amount=amount, usd_value=amount * usd_price),
                to_asset=None,
                to_value=None,
                realized_pnl=None,
                tx_hash=parse_result[0],
                log_index=parse_result[1],
            ))

        return events

    def _get_liquidation_events(
            self,
            address: ChecksumEthAddress,
            from_ts: Timestamp,
            to_ts: Timestamp,
    ) -> List[CompoundEvent]:
        """https://compound.finance/docs/ctokens#liquidate-borrow"""
        param_types, param_values = get_common_params(from_ts, to_ts, address)
        result = self.graph.query(  # type: ignore
            querystr="""liquidationEvents (where: {blockTime_lte: $end_ts, blockTime_gte: $start_ts, from: $address}) {
    id
    amount
    from
    blockNumber
    blockTime
    cTokenSymbol
    underlyingSymbol
    underlyingRepayAmount
}}""",
            param_types=param_types,
            param_values=param_values,
        )

        events = []
        for entry in result['liquidationEvents']:
            ctoken_symbol = entry['cTokenSymbol']
            try:
                ctoken_asset = Asset(ctoken_symbol)
            except UnknownAsset:
                log.error(
                    f'Found unexpected cTokenSymbol {ctoken_symbol} during graph query. Skipping.')
                continue
            underlying_symbol = entry['underlyingSymbol']
            try:
                underlying_asset = Asset(underlying_symbol)
            except UnknownAsset:
                log.error(
                    f'Found unexpected token symbol {underlying_symbol} during '
                    f'graph query. Skipping.',
                )
                continue
            timestamp = entry['blockTime']
            # Amount/value of underlying asset paid by liquidator
            # Essentially liquidator covers part of the debt of the user
            debt_amount = FVal(entry['underlyingRepayAmount'])
            underlying_usd_price = query_usd_price_zero_if_error(
                asset=underlying_asset,
                time=timestamp,
                location='compound liquidation underlying asset',
                msg_aggregator=self.msg_aggregator,
            )
            debt_usd_value = debt_amount * underlying_usd_price
            # Amount/value of ctoken_asset lost to the liquidator
            # This is what the liquidator gains at a discount
            liquidated_amount = FVal(entry['amount'])
            liquidated_usd_price = query_usd_price_zero_if_error(
                asset=ctoken_asset,
                time=timestamp,
                location='compound liquidation ctoken asset',
                msg_aggregator=self.msg_aggregator,
            )
            liquidated_usd_value = liquidated_amount * liquidated_usd_price
            parse_result = _get_txhash_and_logidx(entry['id'])
            if parse_result is None:
                log.error(
                    f'Found unprocessable liquidation id from the graph {entry["id"]}. Skipping',
                )
                continue

            gained_value = Balance(amount=debt_amount, usd_value=debt_usd_value)
            lost_value = Balance(amount=liquidated_amount, usd_value=liquidated_usd_value)
            events.append(CompoundEvent(
                event_type='liquidation',
                address=address,
                block_number=entry['blockNumber'],
                timestamp=timestamp,
                asset=underlying_asset,
                value=gained_value,
                to_asset=ctoken_asset,
                to_value=lost_value,
                realized_pnl=None,
                tx_hash=parse_result[0],
                log_index=parse_result[1],
            ))

        return events

    def _get_lend_events(
            self,
            event_type: Literal['mint', 'redeem'],
            address: ChecksumEthAddress,
            from_ts: Timestamp,
            to_ts: Timestamp,
    ) -> List[CompoundEvent]:
        param_types, param_values = get_common_params(from_ts, to_ts, address)
        if event_type == 'mint':
            graph_event_name = 'mintEvents'
            addr_position = 'to'
        elif event_type == 'redeem':
            graph_event_name = 'redeemEvents'
            addr_position = 'from'

        result = self.graph.query(  # type: ignore
            querystr=LEND_EVENTS_QUERY_PREFIX.format(
                graph_event_name=graph_event_name,
                addr_position=addr_position,
            ),
            param_types=param_types,
            param_values=param_values,
        )

        events = []
        for entry in result[graph_event_name]:
            ctoken_symbol = entry['cTokenSymbol']
            try:
                ctoken_asset = Asset(ctoken_symbol)
            except UnknownAsset:
                log.error(
                    f'Found unexpected cTokenSymbol {ctoken_symbol} during graph query. Skipping.')
                continue

            underlying_symbol = ctoken_symbol[1:]
            try:
                underlying_asset = Asset(underlying_symbol)
            except UnknownAsset:
                log.error(
                    f'Found unexpected token symbol {underlying_symbol} during '
                    f'graph query. Skipping.',
                )
                continue
            timestamp = entry['blockTime']
            usd_price = query_usd_price_zero_if_error(
                asset=underlying_asset,
                time=timestamp,
                location=f'compound {event_type}',
                msg_aggregator=self.msg_aggregator,
            )
            underlying_amount = FVal(entry['underlyingAmount'])
            usd_value = underlying_amount * usd_price
            parse_result = _get_txhash_and_logidx(entry['id'])
            if parse_result is None:
                log.error(f'Found unprocessable mint id from the graph {entry["id"]}. Skipping')
                continue
            amount = FVal(entry['amount'])

            if event_type == 'mint':
                from_value = Balance(amount=underlying_amount, usd_value=usd_value)
                to_value = Balance(amount=amount, usd_value=usd_value)
                from_asset = underlying_asset
                to_asset = ctoken_asset
            else:  # redeem
                from_value = Balance(amount=amount, usd_value=usd_value)
                to_value = Balance(amount=underlying_amount, usd_value=usd_value)
                from_asset = ctoken_asset
                to_asset = underlying_asset

            events.append(CompoundEvent(
                event_type=event_type,
                address=address,
                block_number=entry['blockNumber'],
                timestamp=timestamp,
                asset=from_asset,
                value=from_value,
                to_asset=to_asset,
                to_value=to_value,
                realized_pnl=None,
                tx_hash=parse_result[0],
                log_index=parse_result[1],
            ))

        return events

    def _get_comp_events(
            self,
            address: ChecksumEthAddress,
            from_ts: Timestamp,
            to_ts: Timestamp,
    ) -> List[CompoundEvent]:
        self.ethereum.etherscan.get_blocknumber_by_time(from_ts)
        from_block = max(
            COMP_DEPLOYED_BLOCK,
            self.ethereum.etherscan.get_blocknumber_by_time(from_ts),
        )
        argument_filters = {
            'from': COMPTROLLER_PROXY.address,
            'to': address,
        }
        comp_events = self.ethereum.get_logs(
            contract_address=EthereumToken('COMP').ethereum_address,
            abi=ERC20TOKEN_ABI,
            event_name='Transfer',
            argument_filters=argument_filters,
            from_block=from_block,
            to_block=self.ethereum.etherscan.get_blocknumber_by_time(to_ts),
        )

        events = []
        for event in comp_events:
            timestamp = self.ethereum.get_event_timestamp(event)
            amount = token_normalized_value(hexstr_to_int(event['data']), EthereumToken('COMP'))
            usd_price = query_usd_price_zero_if_error(
                asset=EthereumToken('COMP'),
                time=timestamp,
                location='comp_claim',
                msg_aggregator=self.msg_aggregator,
            )
            value = Balance(amount, amount * usd_price)
            events.append(CompoundEvent(
                event_type='comp',
                address=address,
                block_number=event['blockNumber'],
                timestamp=timestamp,
                asset=EthereumToken('COMP'),
                value=value,
                to_asset=None,
                to_value=None,
                realized_pnl=value,
                tx_hash=event['transactionHash'],
                log_index=event['logIndex'],
            ))

        return events

    def _process_events(
            self,
            events: List[CompoundEvent],
            given_defi_balances: GIVEN_DEFI_BALANCES,
    ) -> Tuple[ADDRESS_TO_ASSETS, ADDRESS_TO_ASSETS, ADDRESS_TO_ASSETS, ADDRESS_TO_ASSETS]:
        """Processes all events and returns a dictionary of earned balances totals"""
        assets: ADDRESS_TO_ASSETS = defaultdict(lambda: defaultdict(Balance))
        loss_assets: ADDRESS_TO_ASSETS = defaultdict(lambda: defaultdict(Balance))
        rewards_assets: ADDRESS_TO_ASSETS = defaultdict(lambda: defaultdict(Balance))

        profit_so_far: ADDRESS_TO_ASSETS = defaultdict(lambda: defaultdict(Balance))
        loss_so_far: ADDRESS_TO_ASSETS = defaultdict(lambda: defaultdict(Balance))
        liquidation_profit: ADDRESS_TO_ASSETS = defaultdict(lambda: defaultdict(Balance))

        balances = self.get_balances(given_defi_balances)

        for idx, event in enumerate(events):
            if event.event_type == 'mint':
                assets[event.address][event.asset] -= event.value
            elif event.event_type == 'redeem':
                assert event.to_asset, 'redeem events should have a to_asset'
                assert event.to_value, 'redeem events should have a to_value'
                profit_amount = (
                    assets[event.address][event.to_asset].amount +
                    event.to_value.amount -
                    profit_so_far[event.address][event.to_asset].amount
                )
                profit: Optional[Balance]
                if profit_amount >= 0:
                    usd_price = query_usd_price_zero_if_error(
                        asset=event.to_asset,
                        time=event.timestamp,
                        location='comp redeem event processing',
                        msg_aggregator=self.msg_aggregator,
                    )
                    profit = Balance(profit_amount, profit_amount * usd_price)
                    profit_so_far[event.address][event.to_asset] += profit
                else:
                    profit = None

                assets[event.address][event.to_asset] += event.to_value
                events[idx] = event._replace(realized_pnl=profit)  # TODO: maybe not named tuple?

            elif event.event_type == 'borrow':
                loss_assets[event.address][event.asset] -= event.value
            elif event.event_type == 'repay':
                loss_amount = (
                    loss_assets[event.address][event.asset].amount +
                    event.value.amount -
                    loss_so_far[event.address][event.asset].amount
                )
                loss: Optional[Balance]
                if loss_amount >= 0:
                    usd_price = query_usd_price_zero_if_error(
                        asset=event.asset,
                        time=event.timestamp,
                        location='comp repay event processing',
                        msg_aggregator=self.msg_aggregator,
                    )
                    loss = Balance(loss_amount, loss_amount * usd_price)
                    loss_so_far[event.address][event.asset] += loss
                else:
                    loss = None

                loss_assets[event.address][event.asset] += event.value
                events[idx] = event._replace(realized_pnl=loss)  # TODO: maybe not named tuple?
            elif event.event_type == 'liquidation':
                assert event.to_asset, 'liquidation events should have a to_asset'
                # Liquidator covers part of the borrowed amount
                loss_assets[event.address][event.asset] += event.value
                liquidation_profit[event.address][event.asset] += event.value
                # Liquidator receives discounted to_asset
                loss_assets[event.address][event.to_asset] += event.to_value
                loss_so_far[event.address][event.to_asset] += event.to_value
            elif event.event_type == 'comp':
                rewards_assets[event.address][EthereumToken('COMP')] += event.value

        for address, bentry in balances.items():
            for asset, entry in bentry['lending'].items():
                profit_amount = (
                    profit_so_far[address][asset].amount +
                    entry.balance.amount +
                    assets[address][asset].amount
                )
                if profit_amount < 0:
                    log.error(
                        f'In compound we calculated negative profit. Should not happen. '
                        f'address: {address} asset: {asset} ',
                    )
                else:
                    usd_price = Inquirer().find_usd_price(Asset(asset))
                    profit_so_far[address][asset] = Balance(
                        amount=profit_amount,
                        usd_value=profit_amount * usd_price,
                    )

            for asset, entry in bentry['borrowing'].items():
                remaining = entry.balance + loss_assets[address][asset]
                if remaining.amount < ZERO:
                    continue
                loss_so_far[address][asset] += remaining
                if loss_so_far[address][asset].usd_value < ZERO:
                    amount = loss_so_far[address][asset].amount
                    loss_so_far[address][asset] = Balance(
                        amount=amount, usd_value=amount * Inquirer().find_usd_price(Asset(asset)),
                    )

            for asset, entry in bentry['rewards'].items():
                rewards_assets[address][asset] += entry.balance

        return profit_so_far, loss_so_far, liquidation_profit, rewards_assets

    def get_history(
            self,
            given_defi_balances: GIVEN_DEFI_BALANCES,
            addresses: List[ChecksumEthAddress],
            reset_db_data: bool,  # pylint: disable=unused-argument
            from_timestamp: Timestamp,
            to_timestamp: Timestamp,
    ) -> Dict[str, Any]:
        """May raise:
        - RemoteError due to the graph query failure or etherscan
        """
        history: Dict[str, Any] = {}
        events: List[CompoundEvent] = []

        if self.graph is None:  # could not initialize graph
            return {}

        for address in addresses:
            user_events = self._get_lend_events('mint', address, from_timestamp, to_timestamp)
            user_events.extend(self._get_lend_events('redeem', address, from_timestamp, to_timestamp))  # noqa: E501
            user_events.extend(self._get_borrow_events('borrow', address, from_timestamp, to_timestamp))  # noqa: E501
            repay_events = self._get_borrow_events('repay', address, from_timestamp, to_timestamp)
            liquidation_events = self._get_liquidation_events(address, from_timestamp, to_timestamp)  # noqa: E501
            indices_to_remove = []
            for levent in liquidation_events:
                for ridx, revent in enumerate(repay_events):
                    if levent.tx_hash == revent.tx_hash:
                        indices_to_remove.append(ridx)

            for i in sorted(indices_to_remove, reverse=True):
                del repay_events[i]

            user_events.extend(repay_events)
            user_events.extend(liquidation_events)
            if len(user_events) != 0:
                # query comp events only if any other event has happened
                user_events.extend(self._get_comp_events(address, from_timestamp, to_timestamp))
                events.extend(user_events)

        events.sort(key=lambda x: x.timestamp)
        history['events'] = events
        profit, loss, liquidation, rewards = self._process_events(events, given_defi_balances)
        history['interest_profit'] = profit
        history['liquidation_profit'] = liquidation
        history['debt_loss'] = loss
        history['rewards'] = rewards

        return history

    # -- Methods following the EthereumModule interface -- #
    def on_startup(self) -> None:
        pass

    def on_account_addition(self, address: ChecksumEthAddress) -> None:
        pass

    def on_account_removal(self, address: ChecksumEthAddress) -> None:
        pass
