import dataclasses as dc
import logging
import time
from typing import Callable, Dict, Iterator, List, Optional, Tuple

import cryptocompare
import pandas as pd
import yfinance

from . import utils
from .config import TinytickerConfig

CRYPTO_MAX_LOOKBACK = 1440
CRYPTO_CURRENCY = "USD"
SYMBOL_TYPES = ["crypto", "stock"]

YFINANCE_NON_STANDARD_INTERVALS: Dict[str, pd.Timedelta] = {
    "1wk": pd.to_timedelta("1W"),
    "1mo": pd.to_timedelta("30d"),
    "3mo": pd.to_timedelta("90d"),
}  # type: ignore

INTERVAL_TIMEDELTAS: Dict[str, pd.Timedelta] = {
    interval: YFINANCE_NON_STANDARD_INTERVALS[interval]
    if interval in YFINANCE_NON_STANDARD_INTERVALS
    else pd.to_timedelta(interval)
    for interval in [
        "1m",
        "2m",
        "5m",
        "15m",
        "30m",
        "1h",
        "90m",
        "1d",
        "5d",
        "1wk",
        "1mo",
        "3mo",
    ]
}  # type: ignore

INTERVAL_LOOKBACKS = {
    "1m": 20,  # 20m
    "2m": 15,  # 30m
    "5m": 24,  # 2h
    "15m": 16,  # 8h
    "30m": 24,  # 12h
    "1h": 24,  # 24h
    "90m": 24,  # 36h
    "1d": 30,  # 1mo
    "5d": 30,  # 150d
    "1wk": 26,  # 6mo
    "1mo": 24,  # 2yrs
    "3mo": 24,  # 6 yrs
}

CRYPTO_INTERVAL_TIMEDELTAS: Dict[str, pd.Timedelta] = {
    "minute": pd.to_timedelta("1m"),
    "hour": pd.to_timedelta("1h"),
    "day": pd.to_timedelta("1d"),
}  # type: ignore

LOGGER = logging.getLogger(__name__)


@dc.dataclass
class Response:
    """The api response. Holds the historical and current price data.

    Args:
        historical: DataFrame with columns "Open", "Close", "High", "Low", "Volume"
            and a time index.
        current_price: The current price of the asset.
    """

    historical: pd.DataFrame
    current_price: float


def get_cryptocompare(
    token: str,
    interval_dt: pd.Timedelta,
    lookback: int,
) -> pd.DataFrame:
    """Wraps the crypto data API to have the same interface as the stock API.

    This involves working around `cryptocompare`'s 'limited interval choice by
    requesting more data and resampling ourselves. It also renames the columns and sets
    a time index.

    Args:
        token: token identifier e.g. BTC, ETH, ...
        interval_dt: the desired interval duration.
        lookback: how many intervals to fetch data for.

    Returns:
        A `pd.DataFrame` containing the Open, Close, High, Low and Volume historical
            data, with a time index.
    """

    max_timedelta = pd.Timedelta(0)
    crypto_interval = "minute"
    # get the biggest interval_dt which is smaller than the desired interval
    for interval, timedelta in CRYPTO_INTERVAL_TIMEDELTAS.items():
        if max_timedelta <= timedelta <= interval_dt:
            max_timedelta = timedelta
            crypto_interval = interval
    crypto_interval_dt = CRYPTO_INTERVAL_TIMEDELTAS[crypto_interval]
    # how much to extend the query back in time so that after resampling
    # we get the correct lookback
    scale_factor = int(interval_dt / crypto_interval_dt)
    api_method = getattr(cryptocompare, "get_historical_price_" + crypto_interval)
    crypto_limit = min(
        lookback * scale_factor,
        CRYPTO_MAX_LOOKBACK,
    )
    historical: pd.DataFrame = pd.DataFrame(
        api_method(
            token,
            CRYPTO_CURRENCY,
            toTs=utils.now().timestamp(),
            limit=crypto_limit,
        )
    )
    LOGGER.debug("crypto historical data columns: %s", historical.columns)
    historical.set_index("time", inplace=True)
    historical.index = pd.to_datetime(historical.index.to_numpy(), unit="s", utc=True)
    historical.drop(
        columns=["volumeto", "conversionType", "conversionSymbol"],
        inplace=True,
    )
    historical.rename(
        columns={
            "high": "High",
            "close": "Close",
            "low": "Low",
            "open": "Open",
            "volumefrom": "Volume",
        },
        inplace=True,
    )
    if crypto_interval_dt != interval_dt:
        LOGGER.debug("resampling historical data")
        # resample the crypto data to get the desired interval
        historical_index = historical.index
        historical: pd.DataFrame = historical.resample(interval_dt).agg(
            {
                "Open": "first",
                "High": "max",
                "Low": "min",
                "Close": "last",
                "Volume": "sum",
            }
        )  # type: ignore
        historical.index = historical_index[::scale_factor]
    LOGGER.debug("crypto historical length: %s", len(historical))
    if len(historical) > lookback:
        historical = historical.iloc[-lookback:]
    LOGGER.debug("crypto historical length pruned: %s", len(historical))
    return historical


class Ticker:
    """Price data fetcher.

    Args:
        api_key: CryptoCompare API key, https://min-api.cryptocompare.com/pricing,
            required for fetching crypto prices.
        symbol_type: Either "crypto" or "stock".
        symbol:  Ticker or token identifier, "AAPL", "BTC", "ETH", "DOGE" ...
        interval: Data time interval.
        lookback: How many intervals to look back.
        wait_time: Time to wait in between API calls.
        **kwargs: Extra args are provided to the `Display.plot` method.
    """

    def __init__(
        self,
        api_key: Optional[str] = None,
        symbol_type: str = "crypto",
        symbol: str = "BTC",
        interval: str = "1d",
        lookback: Optional[int] = None,
        wait_time: Optional[int] = None,
        **kwargs,
    ) -> None:
        self._log = logging.getLogger(__name__)
        if symbol_type not in SYMBOL_TYPES:
            raise ValueError(f"'symbol_type' not in {SYMBOL_TYPES}")
        self.symbol_type = symbol_type
        if interval not in INTERVAL_TIMEDELTAS.keys():
            raise ValueError(f"'interval' not in {INTERVAL_TIMEDELTAS.keys()}")
        self.interval = interval
        self._log.debug("interval: %s", self.interval)
        self._interval_dt = INTERVAL_TIMEDELTAS[self.interval]
        if self._interval_dt == pd.NaT:
            raise ValueError("interval Timedelta is NaT.")
        if self.symbol_type == "crypto" and api_key is None:
            raise ValueError("No API key provided.")
        self.api_key = api_key
        if self.api_key is not None:
            cryptocompare.cryptocompare._set_api_key_parameter(self.api_key)
        self.symbol = symbol
        if lookback is None:
            self._log.debug("lookback None")
            self.lookback = INTERVAL_LOOKBACKS[self.interval]
        else:
            self._log.debug("lookback not None")
            self.lookback = lookback  # type: int
        self._log.debug("lookback: %s", self.lookback)
        if wait_time is None:
            self.wait_time = int(self._interval_dt.value * 1e-9)
        else:
            self.wait_time = wait_time  # type: int
        self._log.debug("wait_time: %s", self.wait_time)
        self._symbol_type_map: Dict[str, Callable] = {
            "crypto": self._tick_crypto,
            "stock": self._tick_stock,
        }
        self._display_kwargs = kwargs

    @property
    def single_tick(self) -> Callable[[], Response]:
        """Perform a single tick.

        Returns:
            The response from the API.
        """
        return self._symbol_type_map[self.symbol_type]

    def _current_price_fallback(
        self, historical: pd.DataFrame, current_price: Optional[float]
    ) -> Response:
        return Response(
            historical,
            historical.iloc[-1]["Close"] if current_price is None else current_price,
        )

    def _tick_crypto(self) -> Response:
        """Query the crypto API.

        Returns:
            The response from the API.
        """
        self._log.info("Crypto tick.")
        historical = get_cryptocompare(self.symbol, self._interval_dt, self.lookback)
        current = cryptocompare.get_price(self.symbol, CRYPTO_CURRENCY)
        if current is not None:
            current_price = current[self.symbol][CRYPTO_CURRENCY]
        else:
            current_price = None

        return self._current_price_fallback(historical, current_price)

    def _tick_stock(
        self,
    ) -> Response:
        """Query the stock API.

        Returns:
            The response from the API.
        """
        self._log.info("Stock tick.")
        end = utils.now()
        # We fetch more than desired to kinda compensate for market being closed
        start = end - self._interval_dt * (2 * self.lookback)
        self._log.debug("interval: %s", self.interval)
        self._log.debug("lookback: %s", self.lookback)
        self._log.debug("start: %s", start)
        self._log.debug("end: %s", end)
        current_price_data = yfinance.download(
            self.symbol,
            start=end - pd.to_timedelta("2m"),  # type: ignore
            end=end,
            interval="1m",
        )  # type: pd.DataFrame
        historical = yfinance.download(
            self.symbol, start=start, end=end, interval=self.interval
        )  # type: pd.DataFrame
        if not current_price_data.empty:
            current_price = current_price_data.iloc[-1]["Close"]
        else:
            current_price = None
        # drop the extra data
        if len(historical) > self.lookback:
            historical = historical.iloc[-self.lookback :]
        if historical.index.tzinfo is None:  # type: ignore
            historical.index = historical.index.tz_localize("utc")  # type: ignore
        return self._current_price_fallback(historical, current_price)

    def tick(self) -> Iterator[Response]:
        """Tick forever.

        Returns:
            Iterator over the API responses.
        """
        while True:
            self._log.info("Ticker start.")
            yield self.single_tick()
            self._log.debug("Sleeping %i s", self.wait_time)
            time.sleep(self.wait_time)

    def __str__(self) -> str:
        return "\t".join(
            [
                "Ticker",
                self.symbol_type,
                self.symbol,
                f"{self.lookback}x{self.interval}",
                str(self.wait_time),
            ]
        )


class Sequence:
    @classmethod
    def from_tinyticker_config(
        cls, tt_config: TinytickerConfig, **kwargs
    ) -> "Sequence":
        """Create a `Sequence` from a `TinytickerConfig`.

        Args:
            tt_config: `TinytickerConfig` from which to create the `Sequence`.
            **kwargs: Paseed to the `Sequence.__init__` method.

        Returns:
            The `Sequence` instance.
        """
        return Sequence(
            [
                Ticker(
                    api_key=tt_config.api_key,
                    symbol_type=ticker.symbol_type,
                    symbol=ticker.symbol,
                    interval=ticker.interval,
                    lookback=ticker.lookback,
                    wait_time=ticker.wait_time,
                    plot_type=ticker.plot_type,
                    mav=ticker.mav,
                    volume=ticker.volume,
                )
                for ticker in tt_config.tickers
            ],
            **kwargs,
        )

    def __init__(
        self,
        tickers: List[Ticker],
        skip_empty: bool = True,
        skip_outdated: bool = True,
    ):
        """Runs multiple `Ticker` instances in sequence.

        Args:
            tickers: list of `Ticker` instances.
            skip_empty: if the response doesn't contain any data, move on to the
                next ticker.
            skip_outdated: if the last candle of the response is too old, move on to the
                next ticker. This typically happens when the stock market closes.
        """
        if len(tickers) == 0:
            raise ValueError("No tickers provided.")
        self.tickers = tickers
        self.skip_empty = skip_empty
        self.skip_outdated = skip_outdated

    def start(self) -> Iterator[Tuple[Ticker, Response]]:
        """Start iterating through the tickers.

        Returns:
            The `Ticker` instance and the response from the API.
        """
        while True:
            for ticker in self.tickers:
                min_delta: pd.Timedelta = max(pd.to_timedelta("5m"), ticker._interval_dt)  # type: ignore
                response = ticker.single_tick()
                if self.skip_empty and (
                    response.historical is None or response.historical.empty
                ):
                    LOGGER.debug(f"{ticker} response empty, skipping.")
                    continue
                if self.skip_outdated and (
                    (utils.now() - response.historical.index[-1])  # type: ignore
                    > min_delta
                ):
                    LOGGER.debug(f"{ticker} response outdated, skipping.")
                    continue
                yield (ticker, response)
                time.sleep(ticker.wait_time)

    def __str__(self):
        return "Sequence: \n" + "\n".join([ticker.__str__() for ticker in self.tickers])
