"""Ticker proxy class with DuckDB OHLCV caching."""

from __future__ import annotations

import enum
from datetime import datetime
from typing import Any

import numpy as np
import pandas as pd
import yfinance

from dyfinance._cache import (
    ADJ_CLOSE_RTOL,
    SPOT_CHECK_ROWS,
    STALE_THRESHOLD,
    CacheEngine,
    logger,
)
from dyfinance._constants import (
    ACTION_COLUMNS,
    ADJ_CLOSE,
    CACHEABLE_INTERVALS,
    PRICE_COLUMNS,
    PRICE_COLUMNS_WITH_ADJ,
)
from dyfinance._utils import normalize_index

class ValidationResult(enum.Enum):
    """Outcome of spot-check validation of cached Adj Close data."""

    OK = "ok"
    CORRECTED = "corrected"
    INVALIDATED = "invalidated"


# Period to request from yfinance for spot-check, scaled by interval
_SPOT_CHECK_PERIOD = {
    "1d": "7d",
    "5d": "1mo",
    "1wk": "2mo",
    "1mo": "6mo",
    "3mo": "2y",
}


class Ticker:
    """Drop-in replacement for yfinance.Ticker with DuckDB OHLCV caching."""

    def __init__(self, ticker: str, session: Any = None) -> None:
        self._yf_ticker = yfinance.Ticker(ticker, session=session)
        self._cache = CacheEngine.default()

    def __repr__(self) -> str:
        return f"dyfinance.Ticker object <{self._yf_ticker.ticker}>"

    def __getattr__(self, name: str) -> Any:
        """Delegate everything except history() to the underlying yfinance Ticker."""
        return getattr(self._yf_ticker, name)

    def clear_cache(self, interval: str | None = None) -> None:
        """Clear cached OHLCV data for this ticker.

        Args:
            interval: If specified, only clear cache for that interval.
                      If None, clear all intervals for this ticker.
        """
        self._cache.invalidate(self._yf_ticker.ticker, interval)

    def history(
        self,
        period: str | None = None,
        interval: str = "1d",
        start: str | datetime | None = None,
        end: str | datetime | None = None,
        prepost: bool = False,
        actions: bool = True,
        auto_adjust: bool = True,
        back_adjust: bool = False,
        repair: bool = False,
        keepna: bool = False,
        rounding: bool = False,
        timeout: float = 10,
        raise_errors: bool = False,
    ) -> pd.DataFrame:
        """Cached version of yfinance Ticker.history().

        Only caches daily-or-above interval data.
        All other configurations (intraday, prepost) fall through to yfinance.
        """
        if not self._is_cacheable(interval):
            result: pd.DataFrame = self._yf_ticker.history(
                period=period, interval=interval, start=start, end=end,
                prepost=prepost, actions=actions, auto_adjust=auto_adjust,
                back_adjust=back_adjust, repair=repair, keepna=keepna,
                rounding=rounding, timeout=timeout, raise_errors=raise_errors,
            )
            return result

        ticker_symbol = self._yf_ticker.ticker
        resolved_start, resolved_end = self._resolve_date_range(period, start, end)

        cached_df, missing_ranges = self._load_cached(
            ticker_symbol, interval, resolved_start, resolved_end,
            timeout, raise_errors,
        )

        # Pass original period so yfinance can use server-side resolution
        # (e.g. "5d" = last 5 trading days, not 5 calendar days)
        use_period = period if start is None and end is None else None

        if missing_ranges:
            cached_df = self._fetch_and_store_missing(
                ticker_symbol, interval, missing_ranges,
                resolved_start, resolved_end,
                period=use_period,
                repair=repair, timeout=timeout, raise_errors=raise_errors,
            )

        # ETFs/mutual funds always include Capital Gains in yfinance output
        try:
            qt = self._yf_ticker.fast_info.get("quoteType", "")
            keep_capital_gains = qt in ("ETF", "MUTUALFUND")
        except Exception:
            keep_capital_gains = False

        result = self._apply_post_processing(
            cached_df,
            actions=actions,
            auto_adjust=auto_adjust,
            back_adjust=back_adjust,
            keepna=keepna,
            rounding=rounding,
            keep_capital_gains=keep_capital_gains,
        )

        # Convert UTC index to exchange timezone to match yfinance output
        if not result.empty:
            result = self._localize_index(result)
        elif result.columns.empty:
            # yfinance returns standard columns even for empty results
            result = pd.DataFrame(
                columns=["Open", "High", "Low", "Close", ADJ_CLOSE, "Volume"],
            )

        return result

    def _load_cached(
        self,
        ticker_symbol: str,
        interval: str,
        start: pd.Timestamp,
        end: pd.Timestamp,
        timeout: float,
        raise_errors: bool,
    ) -> tuple[pd.DataFrame, list[tuple[pd.Timestamp, pd.Timestamp]]]:
        """Load data from cache, running spot-check validation on full hits."""
        cached_df, missing_ranges = self._cache.get_cached_ohlcv(
            ticker_symbol, interval, start, end,
        )

        # Spot-check: validate stale cache before returning a full hit
        if not missing_ranges and not cached_df.empty:
            result = self._validate_cached_adj_close(
                ticker_symbol, interval, timeout, raise_errors,
            )
            if result is not ValidationResult.OK:
                cached_df, missing_ranges = self._cache.get_cached_ohlcv(
                    ticker_symbol, interval, start, end,
                )

        return cached_df, missing_ranges

    def _fetch_and_store_missing(
        self,
        ticker_symbol: str,
        interval: str,
        missing_ranges: list[tuple[pd.Timestamp, pd.Timestamp]],
        start: pd.Timestamp,
        end: pd.Timestamp,
        *,
        period: str | None = None,
        repair: bool,
        timeout: float,
        raise_errors: bool,
    ) -> pd.DataFrame:
        """Fetch missing date ranges from yfinance, store in cache, return merged data."""
        query_start = start
        for fetch_start, fetch_end in missing_ranges:
            if period is not None:
                # Use server-side period resolution for accurate trading-day counts
                fresh_df = self._yf_ticker.history(
                    period=period,
                    interval=interval,
                    prepost=False,
                    actions=True,
                    auto_adjust=False,
                    back_adjust=False,
                    repair=repair,
                    keepna=False,
                    rounding=False,
                    timeout=timeout,
                    raise_errors=raise_errors,
                )
                period = None  # only use period for the first range
            else:
                fresh_df = self._yf_ticker.history(
                    start=fetch_start.strftime("%Y-%m-%d"),
                    end=fetch_end.strftime("%Y-%m-%d"),
                    interval=interval,
                    prepost=False,
                    actions=True,
                    auto_adjust=False,
                    back_adjust=False,
                    repair=repair,
                    keepna=False,
                    rounding=False,
                    timeout=timeout,
                    raise_errors=raise_errors,
                )
            if not fresh_df.empty:
                self._cache.store_ohlcv(ticker_symbol, interval, fresh_df)
                # Extend query start to cover all fetched data
                normalized = normalize_index(pd.DatetimeIndex(fresh_df.index), interval)
                query_start = min(query_start, normalized.min())

        cached_df, _ = self._cache.get_cached_ohlcv(
            ticker_symbol, interval, query_start, end,
        )
        return cached_df

    def _validate_cached_adj_close(
        self,
        ticker_symbol: str,
        interval: str,
        timeout: float,
        raise_errors: bool,
    ) -> ValidationResult:
        """Spot-check cached Adj Close against yfinance.

        Returns:
            ValidationResult.OK:          Cache is valid, no action needed.
            ValidationResult.CORRECTED:   Ratio correction applied — caller should re-read.
            ValidationResult.INVALIDATED: Cache invalidated — caller should re-read and fetch.
        """
        fetched_at = self._cache.get_fetched_at(ticker_symbol, interval)
        if fetched_at is None:
            return ValidationResult.OK
        age = pd.Timestamp.now("UTC") - fetched_at
        if age <= STALE_THRESHOLD:
            return ValidationResult.OK

        # Fetch a small window from yfinance for comparison
        cached_adj = self._cache.get_cached_adj_close(ticker_symbol, interval, SPOT_CHECK_ROWS)
        if cached_adj.empty:
            return ValidationResult.OK

        spot_period = _SPOT_CHECK_PERIOD.get(interval, f"{SPOT_CHECK_ROWS + 2}d")
        fresh_df = self._yf_ticker.history(
            period=spot_period,
            interval=interval,
            auto_adjust=False,
            actions=False,
            prepost=False,
            timeout=timeout,
            raise_errors=False,  # Spot check is best-effort; don't fail when cache exists
        )
        if fresh_df.empty or ADJ_CLOSE not in fresh_df.columns:
            # Cannot validate; touch and proceed
            self._cache.touch_fetched_at(ticker_symbol, interval)
            return ValidationResult.OK

        fresh_adj = fresh_df[ADJ_CLOSE]
        fresh_adj.index = normalize_index(pd.DatetimeIndex(fresh_adj.index), interval)

        # Align on common dates
        common = cached_adj.index.intersection(fresh_adj.index)
        if len(common) == 0:
            self._cache.touch_fetched_at(ticker_symbol, interval)
            return ValidationResult.OK

        old_vals = np.asarray(cached_adj.loc[common].values)
        new_vals = np.asarray(fresh_adj.loc[common].values)

        # Check if values match within tolerance
        if np.allclose(old_vals, new_vals, rtol=ADJ_CLOSE_RTOL, atol=0, equal_nan=True):
            self._cache.touch_fetched_at(ticker_symbol, interval)
            return ValidationResult.OK

        # Mismatch detected — compute ratios
        with np.errstate(divide="ignore", invalid="ignore"):
            ratios = np.where(old_vals != 0, new_vals / old_vals, np.nan)
        valid_ratios = ratios[~np.isnan(ratios)]

        if len(valid_ratios) > 0 and np.allclose(
            valid_ratios, valid_ratios[0], rtol=ADJ_CLOSE_RTOL,
        ):
            # Uniform ratio → apply correction to all cached data
            ratio = float(np.median(valid_ratios))
            logger.info(
                "Adj Close ratio correction for %s/%s: %.6f", ticker_symbol, interval, ratio,
            )
            self._cache.apply_adj_ratio(ticker_symbol, interval, ratio)
            return ValidationResult.CORRECTED

        # Non-uniform ratio → full invalidation
        logger.warning(
            "Non-uniform Adj Close change for %s/%s, invalidating cache. "
            "Data older than yfinance's history window may be lost.",
            ticker_symbol, interval,
        )
        self._cache.invalidate(ticker_symbol, interval)
        return ValidationResult.INVALIDATED

    @staticmethod
    def _is_cacheable(interval: str) -> bool:
        return interval.lower() in CACHEABLE_INTERVALS

    def _localize_index(self, df: pd.DataFrame) -> pd.DataFrame:
        """Convert UTC index to the exchange timezone (yfinance compat).

        Also casts to second resolution to match yfinance output.
        """
        try:
            tz = self._yf_ticker.fast_info.get("timezone", "UTC")
            if not isinstance(tz, str):
                return df
            idx = pd.DatetimeIndex(df.index).as_unit("s")
            df.index = idx.tz_localize(None).tz_localize(tz)
        except Exception:
            pass
        return df

    @staticmethod
    def _resolve_date_range(
        period: str | None,
        start: str | datetime | None,
        end: str | datetime | None,
    ) -> tuple[pd.Timestamp, pd.Timestamp]:
        """Convert period/start/end into concrete UTC timestamps."""
        now = pd.Timestamp.now("UTC").normalize() + pd.Timedelta(days=1)

        if start is not None:
            ts_start = pd.Timestamp(start, tz="UTC")
            ts_end = pd.Timestamp(end, tz="UTC") if end is not None else now
            return ts_start, ts_end

        if end is not None:
            p = period or "1mo"
            delta = _period_to_timedelta(p)
            ts_end = pd.Timestamp(end, tz="UTC")
            return ts_end - delta, ts_end

        if period is not None:
            if period.lower() == "max":
                return pd.Timestamp("1900-01-01", tz="UTC"), now
            delta = _period_to_timedelta(period)
            return now - delta, now

        # Default: 1mo
        delta = _period_to_timedelta("1mo")
        return now - delta, now

    @staticmethod
    def _apply_post_processing(
        df: pd.DataFrame,
        *,
        actions: bool,
        auto_adjust: bool,
        back_adjust: bool,
        keepna: bool,
        rounding: bool,
        keep_capital_gains: bool = False,
    ) -> pd.DataFrame:
        """Apply adjustments and filtering on cached raw data."""
        if df.empty:
            return df

        result: pd.DataFrame = df.copy()

        if auto_adjust and ADJ_CLOSE in result.columns:
            result = Ticker._apply_auto_adjust(result)
        elif back_adjust and ADJ_CLOSE in result.columns:
            result = Ticker._apply_back_adjust(result)

        if rounding:
            result = Ticker._apply_rounding(result)

        if not actions:
            result = pd.DataFrame(result.drop(columns=list(ACTION_COLUMNS), errors="ignore"))

        if not keepna:
            result = Ticker._drop_empty_rows(result)

        # yfinance includes Capital Gains only for ETF/MUTUALFUND tickers.
        # Drop the column for other quote types (where it's always zero in cache).
        if (
            not keep_capital_gains
            and "Capital Gains" in result.columns
            and (result["Capital Gains"] == 0).all()
        ):
            result = result.drop(columns=["Capital Gains"])

        return result

    @staticmethod
    def _apply_auto_adjust(df: pd.DataFrame) -> pd.DataFrame:
        """Adjust OHLC prices using Adj Close / Close ratio and drop Adj Close."""
        ratio = df[ADJ_CLOSE] / df["Close"]
        for col in PRICE_COLUMNS:
            if col in df.columns:
                df[col] = df[col] * ratio
        result: pd.DataFrame = df.drop(columns=[ADJ_CLOSE], errors="ignore")
        return result

    @staticmethod
    def _apply_back_adjust(df: pd.DataFrame) -> pd.DataFrame:
        """Back-adjust OHLC prices using cumulative ratio and drop Adj Close."""
        ratio = df[ADJ_CLOSE] / df["Close"]
        cumulative = ratio / ratio.iloc[-1]
        for col in PRICE_COLUMNS:
            if col in df.columns:
                df[col] = df[col] * cumulative
        result: pd.DataFrame = df.drop(columns=[ADJ_CLOSE], errors="ignore")
        return result

    @staticmethod
    def _apply_rounding(df: pd.DataFrame) -> pd.DataFrame:
        """Round price columns to 2 decimal places."""
        price_cols = [c for c in PRICE_COLUMNS_WITH_ADJ if c in df.columns]
        df[price_cols] = np.round(df[price_cols], 2)
        return df

    @staticmethod
    def _drop_empty_rows(df: pd.DataFrame) -> pd.DataFrame:
        """Remove rows where all data columns are 0 or NaN."""
        data_cols = [c for c in df.columns if c not in ("Repaired?",)]
        mask = (df[data_cols].isna() | (df[data_cols] == 0)).all(axis=1)
        result: pd.DataFrame = df[~mask]
        return result


def _period_to_timedelta(period: str) -> pd.Timedelta | pd.DateOffset:
    """Convert a yfinance period string to a pandas Timedelta."""
    period = period.lower()
    mapping: dict[str, pd.Timedelta | pd.DateOffset] = {
        "1d": pd.Timedelta(days=1),
        "5d": pd.Timedelta(days=5),
        "1mo": pd.DateOffset(months=1),
        "3mo": pd.DateOffset(months=3),
        "6mo": pd.DateOffset(months=6),
        "1y": pd.DateOffset(years=1),
        "2y": pd.DateOffset(years=2),
        "5y": pd.DateOffset(years=5),
        "10y": pd.DateOffset(years=10),
        "ytd": pd.Timestamp.now("UTC") - pd.Timestamp(f"{pd.Timestamp.now().year}-01-01", tz="UTC"),
    }
    if period in mapping:
        return mapping[period]
    raise ValueError(f"Unknown period: {period!r}")
