"""BitQuery model"""
__docformat__ = "numpy"

import datetime
import json
import logging
import os
from typing import Optional

import numpy as np
import pandas as pd
import requests
from requests.adapters import HTTPAdapter
from requests.exceptions import HTTPError

from openbb_terminal.cryptocurrency.dataframe_helpers import (
    prettify_column_names,
)
from openbb_terminal import config_terminal as cfg
from openbb_terminal.rich_config import console
from openbb_terminal.decorators import check_api_key, log_start_end

logger = logging.getLogger(__name__)


class BitQueryApiKeyException(Exception):
    """Bit Query Api Key Exception object"""

    @log_start_end(log=logger)
    def __init__(self, message: str):
        super().__init__(message)
        self.message = message

    @log_start_end(log=logger)
    def __str__(self) -> str:
        return f"BitQueryApiKeyException: {self.message}"


class BitQueryTimeoutException(Exception):
    """BitQuery Timeout Exception class"""


BQ_URL = "https://graphql.bitquery.io"
CURRENCIES = ["ETH", "USD", "BTC", "USDT"]
LT_FILTERS = ["exchange", "trades", "tradeAmount"]
LT_KIND = ["dex", "time"]
INTERVALS = ["day", "month", "week"]
DVCP_FILTERS = [
    "date",
    "exchange",
    "base",
    "quote",
    "open",
    "high",
    "low",
    "close",
    "tradeAmount",
    "trades",
]
UEAT_FILTERS = [
    "date",
    "uniqueSenders",
    "transactions",
    "averageGasPrice",
    "mediumGasPrice",
    "maximumGasPrice",
]
TTCP_FILTERS = ["base", "quoted", "trades", "tradeAmount"]
BAAS_FILTERS = [
    "date",
    "baseCurrency",
    "quoteCurrency",
    "dailySpread",
    "averageBidPrice",
    "averageAskPrice",
]
DECENTRALIZED_EXCHANGES = [
    "1inch",
    "AfroDex",
    "AirSwap",
    "Amplbitcratic",
    "Balancer",
    "BestSwap",
    "Bitox",
    "CellSwap",
    "Cellswap",
    "Cofix",
    "Coinchangex",
    "Curve",
    "DDEX",
    "DUBIex",
    "DecentrEx",
    "DeversiFi",
    "Dodo",
    "ETHERCExchange",
    "EtherBlockchain",
    "EtherDelta",
    "Ethernext",
    "Ethfinex",
    "FEGex",
    "FFFSwap",
    "Fordex",
    "GUDecks",
    "GUDeks",
    "HiSwap",
    "IDEX",
    "LedgerDex",
    "Matcha",
    "Miniswap",
    "Mooniswap",
    "Oasis",
    "OpenRelay",
    "S.Finance",
    "SakeSwap",
    "SeedDex",
    "SingularX",
    "StarBitEx",
    "SushiSwap",
    "SwapX",
    "SwitchDex",
    "TacoSwap",
    "TokenJar",
    "TokenStore",
    "TokenTrove",
    "Tokenlon",
    "TradexOne",
    "Uniswap",
    "ZeusSwap",
    "dYdX",
    "dex.blue",
]
DECENTRALIZED_EXCHANGES_MAP = {e.lower(): e for e in DECENTRALIZED_EXCHANGES}
NETWORKS = ["bsc", "ethereum", "matic"]


@log_start_end(log=logger)
def _extract_dex_trades(data: dict) -> pd.DataFrame:
    """Helper method that extracts from bitquery api response data from nested dictionary:
    response = {'ethereum' : {'dexTrades' : <data>}}. If 'dexTrades' is None, raises Exception.

    Parameters
    ----------
    data: dict
        response data from bitquery api.

    Returns
    -------
    pd.DataFrame
        normalized pandas data frame with data
    """

    dex_trades = data["ethereum"]["dexTrades"]
    if not dex_trades:
        raise ValueError("No data was returned in request response\n")
    return pd.json_normalize(dex_trades)


@log_start_end(log=logger)
@check_api_key(["API_BITQUERY_KEY"])
def query_graph(url: str, query: str) -> dict:
    """Helper methods for querying graphql api. [Source: https://bitquery.io/]

    Parameters
    ----------
    url: str
        Endpoint url
    query: str
        Graphql query

    Returns
    -------
    dict
        Dictionary with response data
    """

    session = requests.Session()
    session.mount("https://", HTTPAdapter(max_retries=5))
    headers = {"x-api-key": cfg.API_BITQUERY_KEY}
    timeout = 30

    try:
        response = session.post(
            url, json={"query": query}, headers=headers, timeout=timeout
        )
    except requests.Timeout as e:
        logger.exception("BitQuery timeout")
        raise BitQueryTimeoutException(
            f"BitQuery API didn't respond within {timeout} seconds.\n"
        ) from e

    if response.status_code == 500:
        raise HTTPError(f"Internal sever error {response.reason}")

    if not 200 <= response.status_code < 300:
        raise BitQueryApiKeyException(
            f"Invalid Authentication: {response.status_code}. "
            f"Please visit https://bitquery.io/pricing and generate you free api key\n"
        )
    try:
        data = response.json()
        if "error" in data:
            raise ValueError(f"Invalid Response: {data['error']}\n")
    except Exception as e:
        logger.exception("Invalid Response: %s", str(e))
        raise ValueError(f"Invalid Response: {response.text}\n") from e
    return data["data"]


@log_start_end(log=logger)
def get_erc20_tokens() -> pd.DataFrame:
    """Helper method that loads ~1500 most traded erc20 token.
    [Source: json file]

    Returns
    -------
    pd.DataFrame
        ERC20 tokens with address, symbol and name
    """
    file_path = os.path.join(
        os.path.dirname(os.path.abspath(__file__)), "..", "data", "erc20_coins.json"
    )
    with open(file_path) as f:
        data = json.load(f)
    df = pd.json_normalize(data)
    df.columns = ["count", "address", "symbol", "name"]
    df = df[~df["symbol"].isin(["", None, np.NaN])]
    return df[["name", "symbol", "address", "count"]]


@log_start_end(log=logger)
def find_token_address(symbol: str) -> Optional[str]:
    """Helper methods that search for ERC20 coin base on provided symbol or token address.
    If erc20 token address is provided, then checks if it's proper address and returns it back.
    In other case mapping data is loaded from file, and lookup for belonging token address.

    Parameters
    ----------
    symbol: str
        ERC20 token symbol e.g. UNI, SUSHI, ETH, WBTC or token address e.g.
        0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48

    Returns
    -------
    str or None
        ERC20 token address, or None if nothing found.
    """

    if symbol.startswith("0x") and len(symbol) >= 38:
        return symbol

    if symbol == "ETH":
        return symbol

    token = "WBTC" if symbol == "BTC" else symbol
    tokens_map: pd.DataFrame = get_erc20_tokens()

    found_token = tokens_map.loc[tokens_map["symbol"] == token]
    if found_token.empty:
        return None
    if len(found_token) > 1:
        return found_token.sort_values(by="count", ascending=False).iloc[0]["address"]
    return found_token.iloc[0]["address"]


@log_start_end(log=logger)
def get_dex_trades_by_exchange(
    trade_amount_currency: str = "USD",
    limit: int = 90,
    sortby: str = "tradeAmount",
    ascend: bool = True,
) -> pd.DataFrame:
    """Get trades on Decentralized Exchanges aggregated by DEX [Source: https://graphql.bitquery.io/]

    Parameters
    ----------
    trade_amount_currency: str
        Currency of displayed trade amount. Default: USD
    limit:  int
        Last n days to query data. Maximum 365 (bigger numbers can cause timeouts
        on server side)
    sortby: str
        Key by which to sort data
    ascend: bool
        Flag to sort data ascending

    Returns
    -------
    pd.DataFrame
        Trades on Decentralized Exchanges aggregated by DEX
    """

    dt = (datetime.date.today() - datetime.timedelta(min(limit, 365))).strftime(
        "%Y-%m-%d"
    )

    if trade_amount_currency not in CURRENCIES:
        trade_amount_currency = "USD"

    query = f"""
        {{
        ethereum {{
        dexTrades(options: {{limit: 40, desc: ["count"]}}
            date: {{since: "{dt}"}}
        ) {{
            exchange {{
            name
        }}
            count
            tradeAmount(in: {trade_amount_currency})
        }}
        }}
    }}
    """

    try:
        data = query_graph(BQ_URL, query)
    except BitQueryApiKeyException:
        logger.exception("Invalid API Key")
        console.print("[red]Invalid API Key[/red]\n")

    df = _extract_dex_trades(data)
    df.columns = ["trades", "tradeAmount", "exchange"]
    df = df[~df["exchange"].isin([None, np.NaN, ""])]
    df = df[["exchange", "trades", "tradeAmount"]].sort_values(
        by="tradeAmount", ascending=True
    )
    df = df.sort_values(by=sortby, ascending=ascend)
    df.columns = prettify_column_names(df.columns)
    return df


@log_start_end(log=logger)
def get_dex_trades_monthly(
    trade_amount_currency: str = "USD", limit: int = 90, ascend: bool = True
) -> pd.DataFrame:
    """Get list of trades on Decentralized Exchanges monthly aggregated.
    [Source: https://graphql.bitquery.io/]

    Parameters
    ----------
    trade_amount_currency: str
        Currency of displayed trade amount. Default: USD
    limit:  int
        Last n days to query data. Maximum 365 (bigger numbers can cause timeouts
        on server side)
    ascend: bool
        Flag to sort data ascending

    Returns
    -------
    pd.DataFrame
        Trades on Decentralized Exchanges monthly aggregated
    """

    if trade_amount_currency not in CURRENCIES:
        trade_amount_currency = "USD"

    dt = (datetime.date.today() - datetime.timedelta(min(limit, 365))).strftime(
        "%Y-%m-%d"
    )

    query = f"""
    {{
        ethereum {{
        dexTrades(
            options: {{desc: ["date.year", "date.month", "count"]}}
            date: {{since: "{dt}"}}
        ) {{
            count
            date {{
            month
            year
            }}
            tradeAmount(in: {trade_amount_currency})
        }}
        }}
    }}
    """
    try:
        data = query_graph(BQ_URL, query)
    except BitQueryApiKeyException:
        logger.exception("Invalid API Key")
        console.print("[red]Invalid API Key[/red]\n")
        return pd.DataFrame()
    if not data:
        return pd.DataFrame()

    df = _extract_dex_trades(data)
    df["date"] = df.apply(
        lambda x: datetime.date(int(x["date.year"]), int(x["date.month"]), 1), axis=1
    )
    df.rename(columns={"count": "trades"}, inplace=True)
    df = df[["date", "trades", "tradeAmount"]]
    df = df.sort_values(by="date", ascending=ascend)
    df.columns = prettify_column_names(df.columns)
    return df


@log_start_end(log=logger)
def get_daily_dex_volume_for_given_pair(
    limit: int = 100,
    symbol: str = "UNI",
    to_symbol: str = "USDT",
    sortby: str = "date",
    ascend: bool = True,
) -> pd.DataFrame:
    """Get daily volume for given pair [Source: https://graphql.bitquery.io/]

    Parameters
    ----------
    limit:  int
        Last n days to query data
    symbol: str
        ERC20 token symbol
    to_symbol: str
        Quote currency.
    sortby: str
        Key by which to sort data
    ascend: bool
        Flag to sort data ascending

    Returns
    -------
    pd.DataFrame
        Daily volume for given pair
    """

    dt = (datetime.date.today() - datetime.timedelta(min(limit, 365))).strftime(
        "%Y-%m-%d"
    )

    base, quote = find_token_address(symbol), find_token_address(to_symbol)
    if not base or not quote:
        raise ValueError("Provided coin or quote currency doesn't exist\n")

    query = f"""
        {{
        ethereum(network: ethereum) {{
        dexTrades(
            options: {{desc: ["timeInterval.day", "trades"]}}
            baseCurrency: {{is: "{base}"}}
            quoteCurrency: {{is: "{quote}"}}
            date: {{since: "{dt}" }}
        ) {{
            timeInterval {{
            day(count: 1)
            }}
            baseCurrency {{
            symbol
            }}
            quoteCurrency {{
            symbol
            }}
            exchange {{
            fullName
            }}
            trades: count
            tradeAmount(in: USD)
            quotePrice
            maximum_price: quotePrice(calculate: maximum)
            minimum_price: quotePrice(calculate: minimum)
            open_price: minimum(of: block, get: quote_price)
            close_price: maximum(of: block, get: quote_price)
        }}
        }}
    }}
    """

    try:
        data = query_graph(BQ_URL, query)
    except BitQueryApiKeyException:
        logger.exception("Invalid API Key")
        console.print("[red]Invalid API Key[/red]\n")
        return pd.DataFrame()

    if not data:
        return pd.DataFrame()

    df = _extract_dex_trades(data)
    df.columns = [
        "trades",
        "tradeAmount",
        "price",
        "high",
        "low",
        "open",
        "close",
        "date",
        "base",
        "quote",
        "exchange",
    ]
    df = df[
        [
            "date",
            "exchange",
            "base",
            "quote",
            "open",
            "high",
            "low",
            "close",
            "tradeAmount",
            "trades",
        ]
    ]
    df = df.sort_values(by=sortby, ascending=ascend)
    df.columns = prettify_column_names(df.columns)
    return df


@log_start_end(log=logger)
def get_token_volume_on_dexes(
    symbol: str = "UNI",
    trade_amount_currency: str = "USD",
    sortby: str = "tradeAmount",
    ascend: bool = True,
) -> pd.DataFrame:
    """Get token volume on different Decentralized Exchanges. [Source: https://graphql.bitquery.io/]

    Parameters
    ----------
    symbol: str
        ERC20 token symbol.
    trade_amount_currency: str
        Currency to display trade amount in.
    sortby: str
        Key by which to sort data
    ascend: bool
        Flag to sort data ascending

    Returns
    -------
    pd.DataFrame
        Token volume on Decentralized Exchanges
    """

    if trade_amount_currency not in CURRENCIES:
        trade_amount_currency = "USD"

    token_address = find_token_address(symbol)
    if token_address is None:
        raise ValueError(f"Couldn't find token with symbol {symbol}\n")
    query = f"""
    {{
        ethereum {{
        dexTrades(
            baseCurrency: {{is:"{token_address}"}}
        ) {{
                baseCurrency{{
    symbol
    }}
            exchange {{
            name
            fullName
            }}
            count
            tradeAmount(in: {trade_amount_currency})

        }}
        }}
        }}

    """
    try:
        data = query_graph(BQ_URL, query)
    except BitQueryApiKeyException:
        logger.exception("Invalid API Key")
        console.print("[red]Invalid API Key[/red]\n")
        return pd.DataFrame()

    if not data:
        return pd.DataFrame()

    df = _extract_dex_trades(data)[["exchange.fullName", "tradeAmount", "count"]]
    df.columns = LT_FILTERS
    df = df[~df["exchange"].str.startswith("<")]
    df = df.sort_values(by=sortby, ascending=ascend)
    df.columns = prettify_column_names(df.columns)
    return df


@log_start_end(log=logger)
def get_ethereum_unique_senders(
    interval: str = "day",
    limit: int = 90,
    sortby: str = "tradeAmount",
    ascend: bool = True,
) -> pd.DataFrame:
    """Get number of unique ethereum addresses which made a transaction in given time interval.

    Parameters
    ----------
    interval: str
        Time interval in which count unique ethereum addresses which made transaction. day,
        month or week.
    limit: int
        Number of records for data query.
    sortby: str
        Key by which to sort data
    ascend: bool
        Flag to sort data ascending

    Returns
    -------
    pd.DataFrame
        Unique ethereum addresses which made a transaction
    """

    intervals = {
        "day": 1,
        "month": 30,
        "week": 7,
    }

    if interval not in intervals:
        interval = "day"

    days = min(limit * intervals[interval], 90)

    dt = (datetime.date.today() - datetime.timedelta(days)).strftime("%Y-%m-%d")

    query = f"""
    {{
        ethereum(network: ethereum) {{
            transactions(options: {{desc: "date.date"}}, date: {{since: "{dt}"}}) {{
                uniqueSenders: count(uniq: senders)
                date {{
                    date:startOfInterval(unit: {interval})
                }}
                averageGasPrice: gasPrice(calculate: average)
                mediumGasPrice: gasPrice(calculate: median)
                maximumGasPrice: gasPrice(calculate: maximum)
                transactions: count
            }}
        }}
    }}
    """

    try:
        data = query_graph(BQ_URL, query)
    except BitQueryApiKeyException:
        logger.exception("Invalid API Key")
        console.print("[red]Invalid API Key[/red]\n")
        return pd.DataFrame()
    if not data:
        return pd.DataFrame()

    df = pd.DataFrame(data["ethereum"]["transactions"])
    df["date"] = df["date"].apply(lambda x: x["date"])
    df = df[UEAT_FILTERS]
    df = df.sort_values(by=sortby, ascending=ascend)
    df.columns = prettify_column_names(df.columns)
    return df


@log_start_end(log=logger)
def get_most_traded_pairs(
    network: str = "ethereum",
    exchange: str = "Uniswap",
    limit: int = 90,
    sortby: str = "tradeAmount",
    ascend: bool = True,
) -> pd.DataFrame:
    """Get most traded crypto pairs on given decentralized exchange in chosen time period.
    [Source: https://graphql.bitquery.io/]

    Parameters
    ----------
    network: str
        EVM network. One from list: bsc (binance smart chain), ethereum or matic
    exchange: st
        Decentralized exchange name
    limit: int
        Number of days taken into calculation account.
    sortby: str
        Key by which to sort data
    ascend: bool
        Flag to sort data ascending

    Returns
    -------
    pd.DataFrame
        Most traded crypto pairs on given decentralized exchange in chosen time period.
    """

    dt = (datetime.date.today() - datetime.timedelta(limit)).strftime("%Y-%m-%d")
    exchange = DECENTRALIZED_EXCHANGES_MAP.get(exchange, "Uniswap")
    query = f"""
    {{
        ethereum(network: {network}){{
            dexTrades(options: {{limit: 100, desc: "tradeAmount"}},
            exchangeName: {{is: "{exchange}"}}
            date: {{since: "{dt}"}}) {{
                buyCurrency {{
                    symbol
                }}
                sellCurrency{{
                    symbol
                }}
                trades: count
                tradeAmount(in: USD)
            }}
        }}
    }}
    """
    try:
        data = query_graph(BQ_URL, query)
    except BitQueryApiKeyException:
        logger.exception("Invalid API Key")
        console.print("[red]Invalid API Key[/red]\n")
        return pd.DataFrame()
    if not data:
        return pd.DataFrame()

    df = _extract_dex_trades(data)
    df.columns = ["trades", "tradeAmount", "base", "quoted"]
    df = df[TTCP_FILTERS]
    df = df.sort_values(by=sortby, ascending=ascend)
    df.columns = prettify_column_names(df.columns)
    return df


@log_start_end(log=logger)
def get_spread_for_crypto_pair(
    symbol: str = "WETH",
    to_symbol: str = "USDT",
    limit: int = 10,
    sortby: str = "date",
    ascend: bool = True,
) -> pd.DataFrame:
    """Get an average bid and ask prices, average spread for given crypto pair for chosen time period.
        [Source: https://graphql.bitquery.io/]

    Parameters
    ----------
    symbol: str
        ERC20 token symbol
    to_symbol: str
        Quoted currency.
    limit:  int
        Last n days to query data
    sortby: str
        Key by which to sort data
    ascend: bool
        Flag to sort data ascending

    Returns
    -------
    pd.DataFrame
        Average bid and ask prices, spread for given crypto pair for chosen time period
    """

    dt = (datetime.date.today() - datetime.timedelta(limit)).strftime("%Y-%m-%d")
    base, quote = find_token_address(symbol), find_token_address(to_symbol)

    if not base or not quote:
        raise ValueError("Provided coin or quote currency doesn't exist\n")

    query = f"""
    {{
        ethereum(network: ethereum){{
            dexTrades(
                date: {{since:"{dt}"}}
                baseCurrency: {{is: "{base}"}},
                quoteCurrency: {{is: "{quote}"}}) {{
                    date {{date}}
                    baseCurrency {{symbol}}
                    baseAmount
                    quoteCurrency {{
                        symbol
                    }}
                    quoteAmount
                    trades: count
                    quotePrice
                    side
                }}
        }}
    }}
    """
    try:
        data = query_graph(BQ_URL, query)
    except BitQueryApiKeyException:
        logger.exception("Invalid API Key")
        console.print("[red]Invalid API Key[/red]\n")
        return pd.DataFrame()
    if not data:
        return pd.DataFrame()

    df = _extract_dex_trades(data)
    columns = ["quotePrice", "date.date", "baseCurrency.symbol", "quoteCurrency.symbol"]
    bids = df.query("side == 'SELL'")[columns]
    asks = df.query("side == 'BUY'")[columns]

    bids.columns = ["averageBidPrice", "date", "baseCurrency", "quoteCurrency"]
    asks.columns = ["averageAskPrice", "date", "baseCurrency", "quoteCurrency"]

    daily_spread = pd.merge(asks, bids, on=["date", "baseCurrency", "quoteCurrency"])
    daily_spread["dailySpread"] = abs(
        daily_spread["averageBidPrice"] - daily_spread["averageAskPrice"]
    )
    df = daily_spread[
        [
            "date",
            "baseCurrency",
            "quoteCurrency",
            "dailySpread",
            "averageBidPrice",
            "averageAskPrice",
        ]
    ]
    df = df.sort_values(by=sortby, ascending=ascend)
    df.columns = prettify_column_names(df.columns)
    return df


POSSIBLE_CRYPTOS = list(get_erc20_tokens()["symbol"].unique())
