import asyncio
import datetime
import itertools
from decimal import Decimal
from typing import List, Dict, Any, cast, Set, Callable, Optional
from zoneinfo import ZoneInfo

import httpx
from async_lru import alru_cache
from pydantic import ConfigDict, SerializeAsAny

from sirius import common
from sirius.common import Currency, DataClass
from sirius.constants import SiriusEnvironmentSecretKey
from sirius.exceptions import SiriusException
from sirius.http_requests import HTTPResponse, ServerSideException, AsyncHTTPSession
from sirius.market_data import Stock, Option, StockMarketData, Exchange, OptionType, OptionMarketData

OPTIONS_DATE_FORMAT: str = "%b%y"
_session: AsyncHTTPSession | None = None


async def get_base_url() -> str:
    return await common.get_environmental_secret(SiriusEnvironmentSecretKey.IBKR_SERVICE_BASE_URL, "http://ibkr-gateway/")


async def get_session() -> AsyncHTTPSession:
    global _session
    if not _session:
        base_url: str = await get_base_url()
        _session = AsyncHTTPSession(base_url)
        _session.client = httpx.AsyncClient(verify=False, timeout=60)

    return _session


class IBKRException(SiriusException):
    pass


class IBKRContract(DataClass):
    contract_id: int

    @staticmethod
    @alru_cache(maxsize=50, ttl=86_400)  # 24 hour cache
    async def find_by_contract_id(contract_id: int) -> Optional["IBKRContract"]:
        session: AsyncHTTPSession = await get_session()
        response: HTTPResponse = await session.get(f"{await get_base_url()}iserver/contract/{contract_id}/info")
        instrument_type: str = response.data["instrument_type"]
        symbol: str = response.data["local_symbol"].replace(" ", "")

        if instrument_type == "STK":
            ibkr_contract: IBKRContract | None = cast(IBKRContract, await IBKRStock._find(symbol))
        elif instrument_type == "OPT":
            ibkr_contract = cast(IBKRContract, await IBKROption._find(symbol))
        else:
            return None

        assert ibkr_contract.contract_id == contract_id if ibkr_contract else True
        return ibkr_contract


class IBKRStock(Stock, IBKRContract):
    model_config = ConfigDict(frozen=True)

    @staticmethod
    @alru_cache(maxsize=50, ttl=86_400)  # 24 hour cache
    async def _find(ticker: str) -> Optional[Stock]:
        base_url: str = await get_base_url()
        session: AsyncHTTPSession = await get_session()
        response: HTTPResponse = await session.get(f"{base_url}iserver/secdef/search?symbol={ticker}&secType=STK")
        filtered_list: List[Dict[str, Any]] = list(filter(lambda d: d["description"] in Exchange, response.data))
        contract_id_list: List[int] = [int(data["conid"]) for data in filtered_list]

        if not contract_id_list:
            return None

        if len(contract_id_list) > 1:
            raise IBKRException(f"More than one stock found for the ticker: {ticker}")

        contract_id: int = contract_id_list[0]
        response = await session.get(f"{base_url}iserver/contract/{contract_id}/info")
        return IBKRStock(
            name=response.data["company_name"],
            currency=Currency(response.data["currency"]),
            ticker=response.data["symbol"],
            contract_id=contract_id
        )


class IBKROption(Option, IBKRContract):

    @staticmethod
    async def _find(option_id: str) -> Optional["Option"]:
        ticker: str = "".join(itertools.takewhile(str.isalpha, option_id))
        date_string: str = option_id.replace(ticker, "")[:6]
        expiry_date: datetime.date = datetime.datetime.strptime(date_string, "%y%m%d").date()
        strike_price: Decimal = Decimal(option_id[-8:]) / Decimal("1000")
        stock: Optional[IBKRStock] = cast(Optional[IBKRStock], await IBKRStock._find(ticker))
        option_type: OptionType = OptionType.CALL if option_id[option_id.find(date_string) + len(date_string)] == "C" else OptionType.PUT

        if not stock:
            raise IBKRException(f"Could not find the underlying stock for the option with ticker: {option_id}")

        all_option_list: List[IBKROption] = await IBKROption.__get_for_strike_and_expiry(stock, expiry_date, strike_price)
        try:
            return next(filter(lambda o: o.type == option_type and o.expiry_date == expiry_date, all_option_list))
        except StopIteration:
            raise IBKRException(f"Could not find option with ID: {option_id}")

    @staticmethod
    async def __get_all_expiry_month_list(stock: IBKRStock, number_of_days_to_expiry: int) -> List[datetime.date]:
        base_url: str = await get_base_url()
        session: AsyncHTTPSession = await get_session()
        response: HTTPResponse = await session.get(f"{base_url}iserver/secdef/search?symbol={stock.ticker}&secType=STK")
        data: Dict[str, Any] = next(filter(lambda c: int(c["conid"]) == stock.contract_id, response.data))
        option_data: Dict[str, Any] = next(filter(lambda o: o["secType"] == "OPT", data["sections"]))
        all_expiry_month_str_list: List[str] = option_data["months"].split(";")
        all_expiry_month_list: List[datetime.date] = [datetime.datetime.strptime(expiry_month, OPTIONS_DATE_FORMAT).date() for expiry_month in all_expiry_month_str_list]

        return [date for date in all_expiry_month_list if (date - datetime.datetime.now().date()).days <= number_of_days_to_expiry]

    @staticmethod
    async def __get_for_strike_and_expiry(stock: IBKRStock, expiry_month: datetime.date, strike_price: Decimal) -> List["IBKROption"]:
        option_list: List[IBKROption] = []
        base_url: str = await get_base_url()
        session: AsyncHTTPSession = await get_session()
        expiry_month_str: str = expiry_month.strftime(OPTIONS_DATE_FORMAT).upper()
        response: HTTPResponse = await session.get(
            f"{base_url}iserver/secdef/info",
            query_params={
                "conid": stock.contract_id,
                "sectype": "OPT",
                "month": expiry_month_str,
                "strike": float(strike_price)}
        )

        for data in response.data:
            expiry_date: datetime.date = datetime.datetime.strptime(data["maturityDate"], '%Y%m%d').date()
            option_type: OptionType = OptionType.CALL if data["right"] == "C" else OptionType.PUT
            option_id: str = Option._get_id(stock.ticker, expiry_date, strike_price, option_type)

            option_list.append(IBKROption(
                id=option_id,
                contract_id=data["conid"],
                strike_price=strike_price,
                expiry_date=expiry_date,
                type=option_type,
                underlying_stock=stock,
                name=option_id,
                currency=stock.currency
            ))

        return option_list

    @staticmethod
    @alru_cache(maxsize=50, ttl=43_200)  # 12 hour cache
    async def _find_all_options(ticker: str, number_of_days_to_expiry: int) -> List[Option]:
        base_url: str = await get_base_url()
        session: AsyncHTTPSession = await get_session()
        abstract_stock: Stock = await Stock.get(ticker)
        stock: IBKRStock = cast(IBKRStock, await IBKRStock._get_local_object(abstract_stock))
        option_contract_list: List[Option] = []
        expiry_month_list: List[datetime.date] = await IBKROption.__get_all_expiry_month_list(stock, number_of_days_to_expiry)
        expiry_month_str_list: List[str] = [expiry_month.strftime(OPTIONS_DATE_FORMAT).upper() for expiry_month in expiry_month_list]

        responses: List[HTTPResponse] = await asyncio.gather(*[
            session.get(f"{base_url}iserver/secdef/strikes", query_params={"conid": stock.contract_id, "sectype": "OPT", "month": expiry_month_str})
            for expiry_month_str in expiry_month_str_list
        ])
        for expiry_month, response in zip(expiry_month_list, responses):
            all_strike_price_set: Set[Decimal] = set([Decimal(str(strike_price)) for strike_price in response.data.get("call", [])])
            all_strike_price_set.update([Decimal(str(strike_price)) for strike_price in response.data.get("put", [])])
            all_option_contract_list: List[IBKROption] = list(itertools.chain.from_iterable(await asyncio.gather(*[
                IBKROption.__get_for_strike_and_expiry(stock, expiry_month, strike_price)
                for strike_price in all_strike_price_set
            ])))

            option_contract_list.extend(
                [option
                 for option in all_option_contract_list
                 if (option.expiry_date - datetime.datetime.now().date()).days == number_of_days_to_expiry]
            )

        return option_contract_list


class IBKRStockMarketData(StockMarketData):

    @staticmethod
    @alru_cache(maxsize=50, ttl=43_200)  # 12 hour cache
    async def _get_raw_ohlc_data(contract_id: int, from_timestamp: datetime.datetime, to_timestamp: datetime.datetime) -> List[Dict[str, Any]]:
        base_url: str = await get_base_url()
        session: AsyncHTTPSession = await get_session()
        DATE_FORMAT: str = "%Y%m%d-%H:%M:%S"
        try:
            response = await session.get(
                f"{base_url}iserver/marketdata/history",
                query_params={
                    "conid": contract_id,
                    "period": "999d",
                    "bar": "1d",
                    "startTime": (to_timestamp + datetime.timedelta(days=1)).strftime(DATE_FORMAT),  # IBKR sends data 1 day earlier, no idea why.
                    "direction": "-1"
                }
            )
        except ServerSideException as e:
            raise ServerSideException("Did not retrieve any historical market data due to: " + str(e))

        data = response.data.get("data", [])
        if not data:
            return []

        earliest_data_timestamp = min([datetime.datetime.fromtimestamp(d["t"] / 1000) for d in data])

        if earliest_data_timestamp.timestamp() > from_timestamp.timestamp():
            more_data = await IBKRStockMarketData._get_raw_ohlc_data(contract_id, from_timestamp, earliest_data_timestamp)
            return list(itertools.chain(data, more_data))

        return data

    @staticmethod
    @alru_cache(maxsize=50, ttl=60)  # 60 second cache
    async def _get_raw_latest_ohlc_data(contract_id: int) -> Dict[str, Any]:
        base_url: str = await get_base_url()
        session: AsyncHTTPSession = await get_session()
        is_response_valid: Callable = lambda r: "31" in r.data[0].keys() and r.data[0]["31"] != ""
        number_of_tries: int = 1
        response: HTTPResponse = await session.get(f"{base_url}iserver/marketdata/snapshot", query_params={"conids": contract_id, "fields": "7295,70,71,31,87"})
        while number_of_tries < 5 and not is_response_valid(response):
            await asyncio.sleep(0.1)
            response = await session.get(f"{base_url}iserver/marketdata/snapshot", query_params={"conids": contract_id, "fields": "7295,70,71,31,87"})
            number_of_tries = number_of_tries + 1

        if not is_response_valid(response):
            raise ServerSideException("Did not retrieve any market data.")

        return response.data[0]

    @staticmethod
    async def _get(abstract_stock: Stock, from_datestamp: datetime.date, to_datestamp: datetime.date) -> List["StockMarketData"]:
        from_datestamp = common.get_next_business_day_adjusted_date(from_datestamp)
        to_datestamp = common.get_previous_business_day_adjusted_date(to_datestamp)
        from_timestamp: datetime.datetime = datetime.datetime.combine(from_datestamp, datetime.time.min, tzinfo=ZoneInfo("America/New_York"))
        to_timestamp: datetime.datetime = datetime.datetime.combine(to_datestamp, datetime.time.min, tzinfo=ZoneInfo("America/New_York"))
        stock = cast(IBKRStock, await IBKRStock._get_local_object(abstract_stock))
        raw_ohlc_data_list: List[Dict[str, float]] = await IBKRStockMarketData._get_raw_ohlc_data(stock.contract_id, from_timestamp, to_timestamp)

        return [IBKRStockMarketData(
            open=Decimal(str(ohlc_data["o"])),
            high=Decimal(str(ohlc_data["h"])),
            low=Decimal(str(ohlc_data["l"])),
            close=Decimal(str(ohlc_data["c"])),
            timestamp=datetime.datetime.fromtimestamp(ohlc_data["t"] / 1000),
            stock=stock)
            for ohlc_data in raw_ohlc_data_list
            if from_timestamp.timestamp() <= ohlc_data["t"] / 1000 <= to_timestamp.timestamp()]

    @staticmethod
    async def get_latest(abstract_stock: Stock) -> "IBKRStockMarketData":
        ibkr_stock: IBKRStock = cast(IBKRStock, await IBKRStock._get_local_object(abstract_stock))
        raw_data: Dict[str, Any] = await IBKRStockMarketData._get_raw_latest_ohlc_data(ibkr_stock.contract_id)
        open: Decimal = Decimal(raw_data["7295"].replace("H", "").replace("C", ""))
        high: Decimal = Decimal(raw_data["70"].replace("H", "").replace("C", ""))
        low: Decimal = Decimal(raw_data["71"].replace("H", "").replace("C", ""))
        close: Decimal = Decimal(raw_data["31"].replace("H", "").replace("C", ""))
        return IBKRStockMarketData(
            open=open,
            high=high,
            low=low,
            close=close,
            timestamp=datetime.datetime.now(),
            stock=ibkr_stock
        )


class IBKRPosition(DataClass):
    quantity: Decimal
    contract: SerializeAsAny[IBKRContract]
    average_cost: Decimal
    market_value: Decimal
    profit: Decimal

    @staticmethod
    @alru_cache(maxsize=50, ttl=60)  # 60 second cache
    async def _get_raw_all_position_data(account_id: str) -> List[Dict[str, Any]]:
        session: AsyncHTTPSession = await get_session()
        response: HTTPResponse = await session.get(f"{await get_base_url()}portfolio/{account_id}/positions/0")

        return list(filter(lambda d: float(d["position"]) != 0.0, response.data))

    @staticmethod
    async def get_all(account_id: str) -> List["IBKRPosition"]:
        positon_list: List[IBKRPosition] = []

        for data in await IBKRPosition._get_raw_all_position_data(account_id):
            quantity: Decimal = Decimal(data["position"])
            average_cost: Decimal = Decimal(data["avgCost"]) * quantity
            market_value: Decimal = Decimal("0")
            contract: IBKRContract = await IBKRContract.find_by_contract_id(data["conid"])

            if isinstance(contract, Stock):  # type: ignore[unreachable]
                stock_market_data: StockMarketData = await StockMarketData.get_latest(contract)  # type: ignore[unreachable]
                market_value = stock_market_data.close * quantity
            elif isinstance(contract, Option):  # type: ignore[unreachable]
                option_market_data: OptionMarketData = await OptionMarketData.get_latest(contract)  # type: ignore[unreachable]
                market_value = option_market_data.last * quantity * Decimal(100)

            positon_list.append(IBKRPosition(
                contract=contract,
                quantity=quantity,
                market_value=market_value,
                average_cost=average_cost,
                profit=market_value - average_cost
            ))

        return positon_list


class IBKRAccount(DataClass):
    id: str
    name: str
    position_list: List[IBKRPosition]
    cash_value: Decimal
    total_value: Decimal

    @staticmethod
    @alru_cache(maxsize=50, ttl=86_400)  # 24 hour cache
    async def _get_raw_all_account_list() -> List[Dict[str, Any]]:
        base_url: str = await get_base_url()
        session: AsyncHTTPSession = await get_session()
        response: HTTPResponse = await session.get(f"{base_url}portfolio/accounts")

        return list(filter(lambda d: d["id"][-1].isdigit(), response.data))

    @staticmethod
    @alru_cache(maxsize=50, ttl=60)  # 60 second cache
    async def _get_raw_all_account_summary_data(account_id: str) -> Dict[str, Any]:
        base_url: str = await get_base_url()
        session: AsyncHTTPSession = await get_session()
        response: HTTPResponse = await session.get(f"{base_url}iserver/account/{account_id}/summary")

        return response.data

    @staticmethod
    async def get_all() -> List["IBKRAccount"]:
        ibkr_account_list: List[IBKRAccount] = []

        for data in await IBKRAccount._get_raw_all_account_list():
            account_id: str = data["id"]
            raw_account_data: Dict[str, Any] = await IBKRAccount._get_raw_all_account_summary_data(account_id)
            cash_value: Decimal = Decimal(raw_account_data["totalCashValue"])
            position_list: List[IBKRPosition] = await IBKRPosition.get_all(account_id)
            # total_value: Decimal = sum(position.market_value for position in position_list) + cash_value
            total_value: Decimal = Decimal(raw_account_data["netLiquidationValue"])

            ibkr_account_list.append(
                IBKRAccount(
                    id=account_id,
                    name=data["accountAlias"] if data["accountAlias"] else "",
                    position_list=position_list,
                    cash_value=cash_value,
                    total_value=total_value
                )
            )

        return ibkr_account_list
