import json
from typing import Any, Optional
import warnings
import numpy as np
from requests import Session, Response, exceptions
import pandas as pd
from datetime import datetime, date, timedelta

from .const import _QUERY1_URL_
from .utils import log_indent_decorator, get_yf_logger, _parse_user_dt
from .screener import screen
from .data import YfData
from .exceptions import YFException


class CalendarQuery:
    """
    Simple CalendarQuery class for calendar queries, similar to yf.screener.query.QueryBase.

    Simple operand accepted by YF is of the form:
        `{ "operator": operator, "operands": [field, ...values] }`

    Nested operand accepted by YF:
        `{ "operator": operator, "operands": [ ...CalendarQuery ] }`

    ### Simple example:
    ```python
    op = CalendarQuery('eq', ['ticker', 'AAPL'])
    print(op.to_dict())
    ```
    """

    def __init__(self, operator: str, operand: list[Any] | list["CalendarQuery"]):
        """
        :param operator: Operator string, e.g., 'eq', 'gte', 'and', 'or'.
        :param operand: List of operands: can be values (str, int), or other Operands instances (nested).
        """
        operator = operator.upper()
        self.operator = operator
        self.operands = operand

    def append(self, operand: Any) -> None:
        """
        Append an operand to the operands list.

        :param operand: CalendarQuery to append (can be value or CalendarQuery instance).
        """
        self.operands.append(operand)

    @property
    def is_empty(self) -> bool:
        """
        Check if the operands list is empty.

        :return: True if operands list is empty, False otherwise.
        """
        return len(self.operands) == 0

    def to_dict(self) -> dict:
        """
        Query-ready dict for YF.

        Simple operand accepted by YF is of the form:
            `{ "operator": operator, "operands": [field, ...values] }`

        Nested operand accepted by YF:
            `{ "operator": operator, "operands": [ ...CalendarQuery ] }`
        """
        op = self.operator
        ops = self.operands
        return {
            "operator": op,
            "operands": [o.to_dict() if isinstance(o, CalendarQuery) else o for o in ops],
        }


_CALENDAR_URL_ = f"{_QUERY1_URL_}/v1/finance/visualization"
DATE_STR_FORMAT = "%Y-%m-%d"

PREDEFINED_CALENDARS = {
    "sp_earnings": {
        "sortField": "intradaymarketcap",
        "includeFields": [
            "ticker",
            "companyshortname",
            "intradaymarketcap",
            "eventname",
            "startdatetime",
            "startdatetimetype",
            "epsestimate",
            "epsactual",
            "epssurprisepct",
        ],
        "nan_cols": ["Surprise (%)", "EPS Estimate", "Reported EPS"],
        "datetime_cols": ["Event Start Date"],
        "df_index": "Symbol",
        "renames": {
            "Surprise (%)": "Surprise(%)",
            "Company Name": "Company",
            "Market Cap (Intraday)": "Marketcap",
        },
    },
    "ipo_info": {
        "sortField": "startdatetime",
        "includeFields": [
            "ticker",
            "companyshortname",
            "exchange_short_name",
            "filingdate",
            "startdatetime",
            "amendeddate",
            "pricefrom",
            "priceto",
            "offerprice",
            "currencyname",
            "shares",
            "dealtype",
        ],
        "nan_cols": ["Price From", "Price To", "Price", "Shares"],
        "datetime_cols": ["Filing Date", "Date", "Amended Date"],
        "df_index": "Symbol",
        "renames": {
            "Exchange Short Name": "Exchange",
        },
    },
    "economic_event": {
        "sortField": "startdatetime",
        "includeFields": [
            "econ_release",
            "country_code",
            "startdatetime",
            "period",
            "after_release_actual",
            "consensus_estimate",
            "prior_release_actual",
            "originally_reported_actual",
        ],
        "nan_cols": ["Actual", "Market Expectation", "Prior to This", "Revised from"],
        "datetime_cols": ["Event Time"],
        "df_index": "Event",
        "renames": {
            "Country Code": "Region",
            "Market Expectation": "Expected",
            "Prior to This": "Last",
            "Revised from": "Revised",
        },
    },
    "splits": {
        "sortField": "startdatetime",
        "includeFields": [
            "ticker",
            "companyshortname",
            "startdatetime",
            "optionable",
            "old_share_worth",
            "share_worth",
        ],
        "nan_cols": [],
        "datetime_cols": ["Payable On"],
        "df_index": "Symbol",
        "renames": {
            "Optionable?": "Optionable",
        },
    },
}


class Calendars:
    """
    Get economic calendars, for example, Earnings, IPO, Economic Events, Splits

    ### Simple example default params:
    ```python
    import yfinance as yf
    calendars = yf.Calendars()
    earnings_calendar = calendars.get_earnings_calendar(limit=50)
    print(earnings_calendar)
    ```"""

    def __init__(
        self,
        start: Optional[str | datetime | date] = None,
        end: Optional[str | datetime | date] = None,
        session: Optional[Session] = None,
    ):
        """
        :param str | datetime | date start: start date (default today) \
            eg. start="2025-11-08"
        :param str | datetime | date end: end date (default `start + 7 days`) \
            eg. end="2025-11-08"
        :param session: requests.Session object, optional
        """

        self._logger = get_yf_logger()
        self.session = session or Session()
        self._data: YfData = YfData(session=session)

        _start = self._parse_date_param(start)
        _end = self._parse_date_param(end)
        self._start = _start or datetime.now().strftime(DATE_STR_FORMAT)
        self._end = _end or (datetime.strptime(self._start, DATE_STR_FORMAT) + timedelta(days=7)).strftime(DATE_STR_FORMAT)

        if not start and end:
            self._logger.debug(f"Incomplete boundary: did not provide `start`, using today {self._start=} to {self._end=}")
        elif start and not end:
            self._logger.debug(f"Incomplete boundary: did not provide `end`, using {self._start=} to {self._end=}: +7 days from self._start")

        self._most_active_qy: CalendarQuery = CalendarQuery("or", [])

        self._cache_request_body = {}
        self.calendars: dict[str, pd.DataFrame] = {}

    def _parse_date_param(self, _date: Optional[str | datetime | date | int]) -> str:
        if not _date:
            return ""
        else:
            return _parse_user_dt(_date).strftime(DATE_STR_FORMAT)

    def _get_data(
        self, calendar_type: str, query: CalendarQuery, limit=12, offset=0, force=False
    ) -> pd.DataFrame:
        if calendar_type not in PREDEFINED_CALENDARS:
            raise YFException(f"Unknown calendar type: {calendar_type}")

        params = {"lang": "en-US", "region": "US"}
        body = {
            "sortType": "DESC",
            "entityIdType": calendar_type,
            "sortField": PREDEFINED_CALENDARS[calendar_type]["sortField"],
            "includeFields": PREDEFINED_CALENDARS[calendar_type]["includeFields"],
            "size": min(limit, 100),  # YF caps at 100, don't go higher
            "offset": offset,
            "query": query.to_dict(),
        }

        if self._cache_request_body.get(calendar_type, None) and not force:
            cache_body = self._cache_request_body[calendar_type]
            if cache_body == body and calendar_type in self.calendars:
                # Uses cache if force=False and new request has same body as previous
                self._logger.debug(f"Getting {calendar_type=} from local cache")
                return self.calendars[calendar_type]
        self._cache_request_body[calendar_type] = body

        self._logger.debug(f"Fetching {calendar_type=} with {limit=}")
        response: Response = self._data.post(_CALENDAR_URL_, params=params, body=body)

        try:
            json_data = response.json()
        except json.JSONDecodeError:
            self._logger.error(f"{calendar_type}: Failed to retrieve calendar.")
            json_data = {}

        # Error returned
        if json_data.get("finance", {}).get("error", {}):
            raise YFException(json_data.get("finance", {}).get("error", {}))

        self.calendars[calendar_type] = self._create_df(json_data)
        return self._cleanup_df(calendar_type)

    def _create_df(self, json_data: dict) -> pd.DataFrame:
        columns = []
        for col in json_data["finance"]["result"][0]["documents"][0]["columns"]:
            columns.append(col["label"])

            if col["label"] == "Event Start Date" and col["type"] == "STRING":
                # Rename duplicate columns Event Start Date
                columns[-1] = "Timing"

        rows = json_data["finance"]["result"][0]["documents"][0]["rows"]
        return pd.DataFrame(rows, columns=columns)

    def _cleanup_df(self, calendar_type: str) -> pd.DataFrame:
        predef_cal: dict = PREDEFINED_CALENDARS[calendar_type]
        df: pd.DataFrame = self.calendars[calendar_type]
        if df.empty:
            return df

        # Convert types
        nan_cols: list = predef_cal["nan_cols"]
        if nan_cols:
            df[nan_cols] = df[nan_cols].astype("float64").replace(0.0, np.nan)

        # Format the dataframe
        df.set_index(predef_cal["df_index"], inplace=True)
        for rename_from, rename_to in predef_cal["renames"].items():
            df.rename(columns={rename_from: rename_to}, inplace=True)

        for datetime_col in predef_cal["datetime_cols"]:
            df[datetime_col] = pd.to_datetime(df[datetime_col])

        return df

    @log_indent_decorator
    def _get_most_active_operands(
        self, _market_cap: Optional[float], force=False
    ) -> CalendarQuery:
        """
        Retrieve tickers from YF, converts them into operands accepted by YF.
        Saves the operands in self._most_active_qy.
        Will not re-query if already populated.

        Used for earnings calendar optional filter.

        :param force: if True, will re-query even if operands already exist
        :return: list of operands for active traded stocks
        """
        if not self._most_active_qy.is_empty and not force:
            return self._most_active_qy

        self._logger.debug("Fetching 200 most_active for earnings calendar")

        try:
            json_raw: dict = screen(query="MOST_ACTIVES", count=200)
        except exceptions.HTTPError:
            self._logger.error("Failed to retrieve most active stocks.")
            return self._most_active_qy

        raw = json_raw.get("quotes", [{}])

        self._most_active_qy = CalendarQuery("or", [])
        for stock in raw:
            if type(stock) is not dict:
                continue

            ticker = stock.get("symbol", "")
            t_market_cap = stock.get("marketCap", 0)
            # We filter market_cap here because we want to keep self._most_active_qy consistent
            if ticker and (_market_cap is None or t_market_cap >= _market_cap):
                self._most_active_qy.append(CalendarQuery("eq", ["ticker", ticker]))

        return self._most_active_qy

    def _get_startdatetime_operators(self, start=None, end=None) -> CalendarQuery:
        """
        Get startdatetime operands for start/end dates.
        If no dates passed, defaults to internal date set on initialization.
        """
        _start = self._parse_date_param(start)
        _end = self._parse_date_param(end)
        if (start and not end) or (end and not start):
            warnings.warn(
                "When prividing custom `start` and `end` parameters, you may want to specify both, to avoid unexpected behaviour.",
                UserWarning,
                stacklevel=2,
            )

        return CalendarQuery(
            "and",
            [
                CalendarQuery("gte", ["startdatetime", _start or self._start]),
                CalendarQuery("lte", ["startdatetime", _end or self._end]),
            ],
        )

    ### Manual getter functions:

    @log_indent_decorator
    def get_earnings_calendar(
        self,
        market_cap: Optional[float] = None,
        filter_most_active: bool = True,
        start=None,
        end=None,
        limit=12,
        offset=0,
        force=False,
    ) -> pd.DataFrame:
        """
        Retrieve earnings calendar from YF as a DataFrame.
        Will re-query every time it is called, overwriting previous data.

        :param market_cap: market cap cutoff in USD, default None
        :param filter_most_active: will filter for actively traded stocks (default True)
        :param str | datetime | date start: overwrite start date (default set by __init__) \
            eg. start="2025-11-08"
        :param str | datetime | date end: overwrite end date (default set by __init__) \
            eg. end="2025-11-08"
        :param limit: maximum number of results to return (YF caps at 100)
        :param offset: offsets the results for pagination. YF default 0
        :param force: if True, will re-query even if cache already exists
        :return: DataFrame with earnings calendar
        """
        _start = self._parse_date_param(start)
        _end = self._parse_date_param(end)
        if (start and not end) or (end and not start):
            warnings.warn(
                "When prividing custom `start` and `end` parameters, you may want to specify both, to avoid unexpected behaviour.",
                UserWarning,
                stacklevel=2,
            )

        query = CalendarQuery(
            "and",
            [
                CalendarQuery("eq", ["region", "us"]),
                CalendarQuery(
                    "or",
                    [
                        CalendarQuery("eq", ["eventtype", "EAD"]),
                        CalendarQuery("eq", ["eventtype", "ERA"]),
                    ],
                ),
                CalendarQuery("gte", ["startdatetime", _start or self._start]),
                CalendarQuery("lte", ["startdatetime", _end or self._end]),
            ],
        )

        if market_cap is not None:
            if market_cap < 10_000_000:
                warnings.warn(
                    f"market_cap {market_cap} is very low, did you mean to set it higher?",
                    UserWarning,
                    stacklevel=2,
                )
            query.append(CalendarQuery("gte", ["intradaymarketcap", market_cap]))
        if filter_most_active and not offset:
            # YF does not like filter most active while offsetting
            query.append(self._get_most_active_operands(market_cap))

        return self._get_data(
            calendar_type="sp_earnings",
            query=query,
            limit=limit,
            offset=offset,
            force=force,
        )

    @log_indent_decorator
    def get_ipo_info_calendar(
        self, start=None, end=None, limit=12, offset=0, force=False
    ) -> pd.DataFrame:
        """
        Retrieve IPOs calendar from YF as a Dataframe.

        :param str | datetime | date start: overwrite start date (default set by __init__) \
            eg. start="2025-11-08"
        :param str | datetime | date end: overwrite end date (default set by __init__) \
            eg. end="2025-11-08"
        :param limit: maximum number of results to return (YF caps at 100)
        :param offset: offsets the results for pagination. YF default 0
        :param force: if True, will re-query even if cache already exists
        :return: DataFrame with IPOs calendar
        """
        _start = self._parse_date_param(start)
        _end = self._parse_date_param(end)
        if (start and not end) or (end and not start):
            warnings.warn(
                "When prividing custom `start` and `end` parameters, you may want to specify both, to avoid unexpected behaviour.",
                UserWarning,
                stacklevel=2,
            )

        query = CalendarQuery(
            "or",
            [
                CalendarQuery("gtelt", ["startdatetime", _start or self._start, _end or self._end]),
                CalendarQuery("gtelt", ["filingdate", _start or self._start, _end or self._end]),
                CalendarQuery("gtelt", ["amendeddate", _start or self._start, _end or self._end]),
            ],
        )

        return self._get_data(
            calendar_type="ipo_info",
            query=query,
            limit=limit,
            offset=offset,
            force=force,
        )

    @log_indent_decorator
    def get_economic_events_calendar(
        self, start=None, end=None, limit=12, offset=0, force=False
    ) -> pd.DataFrame:
        """
        Retrieve Economic Events calendar from YF as a DataFrame.

        :param str | datetime | date start: overwrite start date (default set by __init__) \
            eg. start="2025-11-08"
        :param str | datetime | date end: overwrite end date (default set by __init__) \
            eg. end="2025-11-08"
        :param limit: maximum number of results to return (YF caps at 100)
        :param offset: offsets the results for pagination. YF default 0
        :param force: if True, will re-query even if cache already exists
        :return: DataFrame with Economic Events calendar
        """
        return self._get_data(
            calendar_type="economic_event",
            query=self._get_startdatetime_operators(start, end),
            limit=limit,
            offset=offset,
            force=force,
        )

    @log_indent_decorator
    def get_splits_calendar(
        self, start=None, end=None, limit=12, offset=0, force=False
    ) -> pd.DataFrame:
        """
        Retrieve Splits calendar from YF as a DataFrame.

        :param str | datetime | date start: overwrite start date (default set by __init__) \
            eg. start="2025-11-08"
        :param str | datetime | date end: overwrite end date (default set by __init__) \
            eg. end="2025-11-08"
        :param limit: maximum number of results to return (YF caps at 100)
        :param offset: offsets the results for pagination. YF default 0
        :param force: if True, will re-query even if cache already exists
        :return: DataFrame with Splits calendar
        """
        return self._get_data(
            calendar_type="splits",
            query=self._get_startdatetime_operators(start, end),
            limit=limit,
            offset=offset,
            force=force,
        )

    ### Easy / Default getter functions:

    @property
    def earnings_calendar(self) -> pd.DataFrame:
        """Earnings calendar with default settings."""
        if "sp_earnings" in self.calendars:
            return self.calendars["sp_earnings"]
        return self.get_earnings_calendar()

    @property
    def ipo_info_calendar(self) -> pd.DataFrame:
        """IPOs calendar with default settings."""
        if "ipo_info" in self.calendars:
            return self.calendars["ipo_info"]
        return self.get_ipo_info_calendar()

    @property
    def economic_events_calendar(self) -> pd.DataFrame:
        """Economic events calendar with default settings."""
        if "economic_event" in self.calendars:
            return self.calendars["economic_event"]
        return self.get_economic_events_calendar()

    @property
    def splits_calendar(self) -> pd.DataFrame:
        """Splits calendar with default settings."""
        if "splits" in self.calendars:
            return self.calendars["splits"]
        return self.get_splits_calendar()
