import datetime
import enum
from decimal import Decimal
from typing import List, Dict, Tuple, Optional, Iterable, Type, Set
import logging

import pandas as pd
from tqdm import tqdm

from eth_defi.abi import get_contract
from eth_defi.event_reader.conversion import decode_data, convert_int256_bytes_to_int, convert_jsonrpc_value_to_int
from eth_defi.event_reader.filter import Filter
from eth_defi.event_reader.logresult import LogResult, LogContext
from eth_defi.event_reader.reader import read_events_concurrent, read_events
from eth_defi.event_reader.web3factory import Web3Factory
from eth_defi.event_reader.web3worker import create_thread_pool_executor
from eth_defi.price_oracle.oracle import PriceOracle, BasePriceOracle
from eth_defi.uniswap_v2.pair import PairDetails
from eth_defi.event_reader.reorganisation_monitor import ReorganisationMonitor

from .timeframe import Timeframe
from .trade_feed import Trade, TradeFeed


logger = logging.getLogger(__name__)


#: List of output columns to pairs.csv
PAIR_FIELD_NAMES = [
    "block_number",
    "timestamp",
    "tx_hash",
    "log_index",
    "factory_contract_address",
    "pair_contract_address",
    "pair_count_index",
    "token0_address",
    "token0_symbol",
    "token1_address",
    "token1_symbol",
]

#: List of fields we need to decode in swaps
SWAP_FIELD_NAMES = [
    "block_number",
    "timestamp",
    "tx_hash",
    "log_index",
    "pair_contract_address",
    "amount0_in",
    "amount1_in",
    "amount0_out",
    "amount1_out",
]

#: List of fields we need to decode in syncs
SYNC_FIELD_NAMES = [
    "block_number",
    "timestamp",
    "tx_hash",
    "log_index",
    "pair_contract_address",
    "reserve0",
    "reserve1",
]


class SwapKind(enum.Enum):
    """What kind of swaps we might have."""

    # token1 -> token0
    buy = "buy"

    # token0 -> token1
    sell = "sell"

    # Traded both ways at the same time
    complex = "complex"

    # Zero traded volumne
    invalid = "invalid"


class UniswapV2TradeFeed(TradeFeed):
    """Uniswap v2 compatible DXE candle generator.

    Uses multiple threads to speed up blockchain read.
    """

    def __init__(self,
                 pairs: List[PairDetails],
                 web3_factory: Web3Factory,
                 oracles: Dict[str, PriceOracle],
                 reorg_mon: ReorganisationMonitor,
                 timeframe: Timeframe,
                 data_retention_time: Optional[pd.Timedelta] = None,
                 threads=10,
                 chunk_size=100):
        """

        :param pairs:
            List of Uniswap v2 pool addresses

        :param web3_factory:
            Web3 connecion creator (multithread safe)

        :param oracles:
            Price oracles needed for the exchange rate conversion

        :param timeframe:
            Maximum timeframe for produced candles
            when doing candle refreshes

        :param reorg_mon:
            Chain reorganistaion manager

        :param data_retention_time:

        :param threads:
            Number of threads used in the reader pool.
            Set 1 to disable thread pooling.

        :param chunk_size:
            Max block chunk read at a time
        """

        super().__init__(
            pairs=[p.checksum_free_address for p in pairs],
            oracles=oracles,
            reorg_mon=reorg_mon,
            timeframe=timeframe,
            data_retention_time=data_retention_time,
        )

        self.event_reader_context = LogContext()

        #: Pair address -> details mapping
        self.pair_map: Dict[str, PairDetails] = {p.address.lower(): p for p in pairs}
        self.web3_factory = web3_factory
        # A web3 instance used in the main thread
        self.web3 = web3_factory(self.event_reader_context)
        self.chunk_size = chunk_size
        self.max_threads = threads

        # Get data from ABI
        Pair = get_contract(self.web3, "UniswapV2Pair.json")
        self.events_to_read = [Pair.events.Swap, Pair.events.Sync]

    def get_pair_details(self, pair: str) -> PairDetails:
        return self.pair_map[pair.lower()]

    def get_all_pair_details(self) -> List[PairDetails]:
        return list(self.pair_map.values())

    def get_block_number_at_chain_tip(self) -> int:
        return self.web3.eth.block_number

    def fetch_trades(self,
                     start_block: int,
                     end_block: Optional[int],
                     tqdm: Optional[Type[tqdm]] = None) -> Iterable[Trade]:
        """Read data between logs.

        :raise ChainReorganisationDetected:
            In the case we notice chain data has changed during the reading
        """

        logger.debug("Fetching uniswap trades %d - %d", start_block, end_block)

        last_block = None

        max_blocks = end_block - start_block

        if tqdm:
            progress_bar = tqdm(total=max_blocks)
            progress_bar.set_description(f"Loading Uniswap v2 event data {start_block:,} - {end_block:,}, {len(self.pairs)} trading pairs")
        else:
            progress_bar = None

        # Listen only pairs we are interested in
        filter = Filter.create_filter(
            address=list(self.pair_map.keys()),
            event_types=self.events_to_read,
        )

        assert self.max_threads > 0

        if self.max_threads == 1:
            # Read in the current thread
            generator = read_events(
                self.web3,
                start_block,
                end_block,
                notify = None,
                chunk_size=self.chunk_size,
                context=self.event_reader_context,
                filter=filter,

            )
        else:
            # Read using a thread pool
            executor = create_thread_pool_executor(self.web3_factory, self.event_reader_context, max_workers=self.max_threads)
            generator = read_events_concurrent(
                 executor=executor,
                 start_block=start_block,
                 end_block=end_block,
                 notify=None,
                 chunk_size=self.chunk_size,
                 context=self.event_reader_context,
                 extract_timestamps=None,
                 reorg_mon=self.reorg_mon,
                 filter=filter,
             )

        sync = None

        # Read specified events in block range.
        # Sync() event should always come one log_index before Swap()
        events_processed = trades_processed = 0

        # Self sanity check that we don't create duplicates
        processed_swaps: Set[tuple] = set()

        for log_result in generator:
            events_processed += 1

            logger.debug("Reading %s event, block: %s, chunk: %d, log_index: %s, tx: %s",
                         log_result["event"].event_name,
                         convert_jsonrpc_value_to_int(log_result["blockNumber"]),
                         log_result["chunk_id"],
                         log_result["logIndex"],
                         log_result["transactionHash"])

            if log_result["event"].event_name == "Swap":
                swap = decode_swap(log_result)

                # Check that our read did not have duplicates
                swap_id = (swap["tx_hash"], swap["log_index"])
                assert swap_id not in processed_swaps, f"Tried to add swap twice: {swap}"
                processed_swaps.add(swap_id)

                trade = self.construct_trade_from_uniswap_v2_events(sync, swap)
                if trade:
                    trades_processed += 1
                    yield trade
            elif log_result["event"].event_name == "Sync":
                sync = decode_sync(log_result)
            else:
                raise RuntimeError(f"Cannot handle: {log_result}")

            if progress_bar:
                # Update progress bar for any block range we have processed.
                # Usually 1 but can be several if there has blocks without trades
                if last_block != log_result["blockNumber"]:
                    if last_block:
                        diff = log_result["blockNumber"] - last_block
                    else:
                        diff = 0
                    last_block = log_result["blockNumber"]
                    progress_bar.set_postfix({
                        "events": events_processed,
                        "trades": trades_processed,
                    }, refresh=False)
                    progress_bar.update(diff)

        logger.debug("Mapped %d events, %d trades", events_processed, trades_processed)

        if progress_bar:
            progress_bar.close()

    def construct_trade_from_uniswap_v2_events(self, prev_sync: Optional[dict], swap: dict) -> Optional[Trade]:
        """Figure out Uniswap v2 swap and volume.

        This is a stateful mapping: we need to be able
        to access previous Pair events to correctly deduct the price.

        :param prev_sync:
            The previous Sync() event that defines the price for this swap.
        """

        if prev_sync is None:
            logger.debug("Could not match Sync() for Swap(): %s", swap)
            return None

        # Swap
        tx_hash = swap["tx_hash"]
        if prev_sync["tx_hash"] != tx_hash:
            logger.debug("Current sync and swap do not follow Uniswap logic: %s - %s", prev_sync, swap)
            return None

        pair: PairDetails
        swap_pair_address = swap["pair_contract_address"].lower()
        pair = self.pair_map.get(swap_pair_address)
        assert pair is not None, f"Pair {swap_pair_address} not in our pair map {self.pair_map.keys()}"

        oracle: BasePriceOracle = self.oracles.get(pair.checksum_free_address)
        if not oracle:
            raise RuntimeError(f"Exchange rate oracle missing for pair %s", pair)

        exchange_rate = oracle.calculate_price(swap["block_number"])

        reserve0 = pair.token0.convert_to_decimals(prev_sync["reserve0"])
        reserve1 = pair.token1.convert_to_decimals(prev_sync["reserve1"])
        amount0_in = pair.token0.convert_to_decimals(swap["amount0_in"])
        amount1_in = pair.token1.convert_to_decimals(swap["amount1_in"])
        amount0_out = pair.token0.convert_to_decimals(swap["amount0_out"])
        amount1_out = pair.token1.convert_to_decimals(swap["amount1_out"])

        kind, price, amount = calculate_reserve_price_in_quote_token_decimal(
            pair.reverse_token_order,
            reserve0,
            reserve1,
            amount0_in,
            amount1_in,
            amount0_out,
            amount1_out
        )

        if kind == SwapKind.invalid:
            logger.info("Could not determine trade: %s", swap)
            return None

        timestamp = self.reorg_mon.get_block_timestamp(swap["block_number"])

        # Flip for Trade() object
        if kind == SwapKind.sell:
            amount = -amount

        t = Trade(
            pair=pair.address.lower(),
            block_number=swap["block_number"],
            block_hash=swap["block_hash"],
            log_index=swap["log_index"],
            tx_hash=swap["tx_hash"],
            timestamp=pd.Timestamp.utcfromtimestamp(timestamp),
            price=price,
            amount=amount,
            exchange_rate=exchange_rate,
        )
        logger.debug("Uniswap trade processed: %s", t)
        return t


def decode_swap(log: LogResult) -> dict:
    """Process swap event.

    This function does manually optimised high speed decoding of the event.

    The event signature is:

    .. code-block::

        event Swap(
          address indexed sender,
          uint amount0In,
          uint amount1In,
          uint amount0Out,
          uint amount1Out,
          address indexed to
        );
    """

    # Raw example event
    # {'address': '0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc', 'blockHash': '0x4ba33a650f9e3d8430f94b61a382e60490ec7a06c2f4441ecf225858ec748b78', 'blockNumber': '0x98b7f6', 'data': '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000046ec814a2e900000000000000000000000000000000000000000000000000000000000003e80000000000000000000000000000000000000000000000000000000000000000', 'logIndex': '0x4', 'removed': False, 'topics': ['0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822', '0x000000000000000000000000f164fc0ec4e93095b804a4795bbe1e041497b92a', '0x0000000000000000000000008688a84fcfd84d8f78020d0fc0b35987cc58911f'], 'transactionHash': '0x932cb88306450d481a0e43365a3ed832625b68f036e9887684ef6da594891366', 'transactionIndex': '0x1', 'context': <__main__.TokenCache object at 0x104ab7e20>, 'event': <class 'web3._utils.datatypes.Swap'>, 'timestamp': 1588712972}

    pair_contract_address = log["address"]

    # Chop data blob to byte32 entries
    data_entries = decode_data(log["data"])

    amount0_in, amount1_in, amount0_out, amount1_out = data_entries

    data = {
        "type": "swap",
        "block_number": convert_jsonrpc_value_to_int(log["blockNumber"]),
        "block_hash": log["blockHash"],
        "timestamp": log["timestamp"],
        "tx_hash": log["transactionHash"],
        "log_index": convert_jsonrpc_value_to_int(log["logIndex"]),
        "pair_contract_address": pair_contract_address,
        "amount0_in": convert_int256_bytes_to_int(amount0_in),
        "amount1_in": convert_int256_bytes_to_int(amount1_in),
        "amount0_out": convert_int256_bytes_to_int(amount0_out),
        "amount1_out": convert_int256_bytes_to_int(amount1_out),
    }
    return data


def decode_sync(log: LogResult) -> dict:
    """Process sync event.

    This function does manually optimised high speed decoding of the event.

    The event signature is:

    .. code-block::

        event Sync(uint112 reserve0, uint112 reserve1);
    """

    # Raw example event
    # {'address': '0xb4e16d0168e52d35cacd2c6185b44281ec28c9dc', 'blockHash': '0x4ba33a650f9e3d8430f94b61a382e60490ec7a06c2f4441ecf225858ec748b78', 'blockNumber': '0x98b7f6', 'data': '0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000046ec814a2e900000000000000000000000000000000000000000000000000000000000003e80000000000000000000000000000000000000000000000000000000000000000', 'logIndex': '0x4', 'removed': False, 'topics': ['0xd78ad95fa46c994b6551d0da85fc275fe613ce37657fb8d5e3d130840159d822', '0x000000000000000000000000f164fc0ec4e93095b804a4795bbe1e041497b92a', '0x0000000000000000000000008688a84fcfd84d8f78020d0fc0b35987cc58911f'], 'transactionHash': '0x932cb88306450d481a0e43365a3ed832625b68f036e9887684ef6da594891366', 'transactionIndex': '0x1', 'context': <__main__.TokenCache object at 0x104ab7e20>, 'event': <class 'web3._utils.datatypes.Swap'>, 'timestamp': 1588712972}

    pair_contract_address = log["address"]

    # Chop data blob to byte32 entries
    data_entries = decode_data(log["data"])

    reserve0, reserve1 = data_entries

    data = {
        "type": "sync",
        "block_number": convert_jsonrpc_value_to_int(log["blockNumber"]),
        "block_hash": log["blockHash"],
        "timestamp": log["timestamp"],
        "tx_hash": log["transactionHash"],
        "log_index": convert_jsonrpc_value_to_int(log["logIndex"]),
        "pair_contract_address": pair_contract_address,
        "reserve0": convert_int256_bytes_to_int(reserve0),
        "reserve1": convert_int256_bytes_to_int(reserve1),
    }
    return data


def calculate_reserve_price_in_quote_token_decimal(
        reversed: bool,
        reserve0: Decimal,
        reserve1: Decimal,
        amount0_in: Decimal,
        amount1_in: Decimal,
        amount0_out: Decimal,
        amount1_out: Decimal,
) -> Tuple[SwapKind, Decimal, Decimal]:
    """Calculate the market price based on Uniswap pool reserve0 an reserve1.

    All inputs are converted from fixed point numbers
    to natural decimal point placed numbers.

    :param reversed:
        Determine base, quote token order relative to token0, token1.
        If reversed, quote token is token0, else quote token is token0.

    :return:
        Price in quote token, amount in quote token
    """

    assert reserve0 > 0, f"Bad reserves {reserve0}, {reserve1}"
    assert reserve1 > 0, f"Bad reserves {reserve0}, {reserve1}"

    # One of those funny txs...
    if amount0_in == amount0_out:
        return SwapKind.invalid, Decimal(0), Decimal(0)

    if reversed:
        reserve0, reserve1 = reserve1, reserve0

    if reversed:
        quote_amount = (amount0_out - amount0_in)
        base_amount = (amount1_out - amount1_in)
    else:
        base_amount = (amount0_out - amount0_in)
        quote_amount = (amount1_out - amount1_in)

    if quote_amount == 0 or base_amount == 0:
        return SwapKind.invalid, Decimal(0), Decimal(0)

    price = reserve1 /reserve0

    # filter out broken swap event like: https://bscscan.com/tx/0x3156a93cd96ac0a1f5c6bfc99850ce6e78fc25a7c756f27a47d02114d8348c47
    if price <= 0:
        return SwapKind.invalid, Decimal(0), Decimal(0)

    # Quote token (fiat currency) increases
    if quote_amount > 0:
        kind = SwapKind.sell
        volume = quote_amount
    else:
        kind = SwapKind.buy
        volume = abs(quote_amount)

    return kind, price, volume
