"""Tests for Ticker.history() caching behavior and option handling.

Uses mocking to avoid network calls. Verifies that:
- Cacheable intervals go through the cache path
- Non-cacheable intervals fall through to yfinance
- All options produce correct output structure
"""

from unittest.mock import patch

import pandas as pd
import pytest

from helpers import FIXED_END, FIXED_START, make_raw_df
from dyfinance._ticker import Ticker


def _make_intraday_df(n: int = 10) -> pd.DataFrame:
    """Create an intraday yfinance-style DataFrame."""
    dates = pd.date_range("2024-06-03 09:30", periods=n, freq="5min", tz="America/New_York")
    return pd.DataFrame(
        {
            "Open": [180.0 + i * 0.1 for i in range(n)],
            "High": [180.5 + i * 0.1 for i in range(n)],
            "Low": [179.5 + i * 0.1 for i in range(n)],
            "Close": [180.2 + i * 0.1 for i in range(n)],
            "Adj Close": [180.2 + i * 0.1 for i in range(n)],
            "Volume": [100000 + i * 1000 for i in range(n)],
            "Dividends": [0.0] * n,
            "Stock Splits": [0.0] * n,
        },
        index=dates,
    )


# ---------------------------------------------------------------------------
# Cache vs Passthrough routing
# ---------------------------------------------------------------------------

class TestCacheRouting:
    @pytest.mark.parametrize("interval", ["1d", "5d", "1wk", "1mo", "3mo"])
    def test_cacheable_intervals_use_cache(self, mock_yf_ticker, interval):
        """Daily and above intervals should go through the cache path."""
        mock_yf_ticker.history.return_value = make_raw_df()

        t = Ticker("AAPL")
        df = t.history(start=FIXED_START, end=FIXED_END, interval=interval)

        assert mock_yf_ticker.history.called
        assert not df.empty

    @pytest.mark.parametrize("interval", ["1m", "2m", "5m", "15m", "30m", "60m", "90m", "1h"])
    def test_intraday_intervals_passthrough(self, mock_yf_ticker, interval):
        """Intraday intervals should pass through directly to yfinance."""
        mock_yf_ticker.history.return_value = _make_intraday_df()

        t = Ticker("AAPL")
        t.history(period="1d", interval=interval)

        mock_yf_ticker.history.assert_called_once()
        call_kwargs = mock_yf_ticker.history.call_args[1]
        assert call_kwargs["interval"] == interval


# ---------------------------------------------------------------------------
# period parameter
# ---------------------------------------------------------------------------

class TestPeriodOption:
    @pytest.mark.parametrize("period", ["1d", "5d", "1mo", "3mo", "6mo", "1y", "2y", "5y", "10y", "ytd", "max"])
    def test_all_periods_accepted(self, mock_yf_ticker, period):
        """All valid period values should be accepted without error."""
        mock_yf_ticker.history.return_value = make_raw_df(n=20)
        fixed_start = pd.Timestamp(FIXED_START, tz="UTC")
        fixed_end = pd.Timestamp(FIXED_END, tz="UTC")

        with patch.object(Ticker, "_resolve_date_range", return_value=(fixed_start, fixed_end)):
            t = Ticker("AAPL")
            df = t.history(period=period, interval="1d")
            assert isinstance(df, pd.DataFrame)
            assert not df.empty


# ---------------------------------------------------------------------------
# actions option
# ---------------------------------------------------------------------------

class TestActionsOption:
    @pytest.mark.parametrize("actions", [True, False])
    def test_actions_column_presence(self, mock_yf_ticker, actions):
        """actions=True should include Dividends/Stock Splits; False should exclude them."""
        mock_yf_ticker.history.return_value = make_raw_df()

        t = Ticker("AAPL")
        df = t.history(start=FIXED_START, end=FIXED_END, interval="1d", actions=actions)

        if actions:
            assert "Dividends" in df.columns
            assert "Stock Splits" in df.columns
        else:
            assert "Dividends" not in df.columns
            assert "Stock Splits" not in df.columns


# ---------------------------------------------------------------------------
# auto_adjust option
# ---------------------------------------------------------------------------

class TestAutoAdjustOption:
    @pytest.mark.parametrize("auto_adjust, expect_adj_close", [(True, False), (False, True)])
    def test_auto_adjust_adj_close_presence(self, mock_yf_ticker, auto_adjust, expect_adj_close):
        """auto_adjust controls Adj Close column presence."""
        mock_yf_ticker.history.return_value = make_raw_df()

        t = Ticker("AAPL")
        df = t.history(start=FIXED_START, end=FIXED_END, interval="1d", auto_adjust=auto_adjust)

        assert ("Adj Close" in df.columns) == expect_adj_close
        assert "Close" in df.columns

    def test_auto_adjust_applies_ratio(self, mock_yf_ticker):
        """auto_adjust=True should adjust OHLC prices using Adj Close / Close ratio."""
        dates = pd.date_range(FIXED_START, periods=3, freq="B", tz="America/New_York")
        mock_yf_ticker.history.return_value = pd.DataFrame(
            {
                "Open": [100.0, 102.0, 104.0],
                "High": [101.0, 103.0, 105.0],
                "Low": [99.0, 101.0, 103.0],
                "Close": [100.0, 102.0, 104.0],
                "Adj Close": [50.0, 51.0, 52.0],  # 2:1 split scenario
                "Volume": [1000000, 1100000, 1200000],
                "Dividends": [0.0, 0.0, 0.0],
                "Stock Splits": [0.0, 0.0, 0.0],
            },
            index=dates,
        )

        t = Ticker("AAPL")
        df = t.history(start=FIXED_START, end=FIXED_END, interval="1d", auto_adjust=True)

        # Close should be adjusted: Close * (AdjClose / Close) = AdjClose
        assert abs(df["Close"].iloc[0] - 50.0) < 0.01
        assert abs(df["Close"].iloc[1] - 51.0) < 0.01


# ---------------------------------------------------------------------------
# back_adjust option
# ---------------------------------------------------------------------------

class TestBackAdjustOption:
    def test_back_adjust_removes_adj_close(self, mock_yf_ticker):
        """back_adjust=True (with auto_adjust=False) should remove Adj Close."""
        mock_yf_ticker.history.return_value = make_raw_df()

        t = Ticker("AAPL")
        df = t.history(
            start=FIXED_START, end=FIXED_END, interval="1d",
            auto_adjust=False, back_adjust=True,
        )

        assert "Adj Close" not in df.columns


# ---------------------------------------------------------------------------
# rounding option
# ---------------------------------------------------------------------------

class TestRoundingOption:
    @pytest.mark.parametrize("rounding", [True, False])
    def test_rounding_option(self, mock_yf_ticker, rounding):
        """rounding=True rounds prices to 2 decimals; False preserves precision."""
        dates = pd.date_range(FIXED_START, periods=3, freq="B", tz="America/New_York")
        mock_yf_ticker.history.return_value = pd.DataFrame(
            {
                "Open": [100.123, 101.456, 102.789],
                "High": [101.111, 102.222, 103.333],
                "Low": [99.444, 100.555, 101.666],
                "Close": [100.777, 101.888, 102.999],
                "Adj Close": [100.777, 101.888, 102.999],
                "Volume": [1000000, 1100000, 1200000],
                "Dividends": [0.0, 0.0, 0.0],
                "Stock Splits": [0.0, 0.0, 0.0],
            },
            index=dates,
        )

        t = Ticker("AAPL")
        df = t.history(
            start=FIXED_START, end=FIXED_END, interval="1d",
            rounding=rounding, auto_adjust=False,
        )

        if rounding:
            for col in ("Open", "High", "Low", "Close", "Adj Close"):
                for val in df[col]:
                    assert round(val, 2) == val, f"{col} value {val} not rounded to 2 decimals"
        else:
            assert abs(df["Open"].iloc[0] - 100.123) < 1e-6


# ---------------------------------------------------------------------------
# keepna option
# ---------------------------------------------------------------------------

class TestKeepnaOption:
    @pytest.mark.parametrize("keepna, expected_len", [(False, 2), (True, 3)])
    def test_keepna_option(self, mock_yf_ticker, keepna, expected_len):
        """keepna=False drops all-zero rows; True preserves them."""
        dates = pd.date_range(FIXED_START, periods=3, freq="B", tz="America/New_York")
        mock_yf_ticker.history.return_value = pd.DataFrame(
            {
                "Open": [100.0, 0.0, 102.0],
                "High": [101.0, 0.0, 103.0],
                "Low": [99.0, 0.0, 101.0],
                "Close": [100.5, 0.0, 102.5],
                "Adj Close": [100.5, 0.0, 102.5],
                "Volume": [1000000, 0, 1200000],
                "Dividends": [0.0, 0.0, 0.0],
                "Stock Splits": [0.0, 0.0, 0.0],
            },
            index=dates,
        )

        t = Ticker("AAPL")
        df = t.history(
            start=FIXED_START, end=FIXED_END, interval="1d",
            keepna=keepna, auto_adjust=False,
        )

        assert len(df) == expected_len


# ---------------------------------------------------------------------------
# start / end parameter
# ---------------------------------------------------------------------------

class TestStartEndOption:
    def test_start_and_end_strings(self, mock_yf_ticker):
        """start/end as date strings should work."""
        mock_yf_ticker.history.return_value = make_raw_df()

        t = Ticker("AAPL")
        df = t.history(start=FIXED_START, end=FIXED_END, interval="1d")

        assert isinstance(df, pd.DataFrame)
        assert not df.empty

    def test_start_only(self, mock_yf_ticker):
        """start without end should default end to now."""
        mock_yf_ticker.history.return_value = make_raw_df()

        t = Ticker("AAPL")
        df = t.history(start=FIXED_START, interval="1d")

        assert isinstance(df, pd.DataFrame)

    def test_start_as_datetime(self, mock_yf_ticker):
        """start as a datetime object should work."""
        from datetime import datetime

        mock_yf_ticker.history.return_value = make_raw_df()

        t = Ticker("AAPL")
        df = t.history(start=datetime(2024, 6, 1), end=datetime(2024, 6, 30), interval="1d")

        assert isinstance(df, pd.DataFrame)


# ---------------------------------------------------------------------------
# timeout and raise_errors passthrough
# ---------------------------------------------------------------------------

class TestTimeoutAndRaiseErrors:
    def test_timeout_passed_to_yfinance(self, mock_yf_ticker):
        """timeout should be forwarded to the underlying yfinance call."""
        mock_yf_ticker.history.return_value = make_raw_df()

        t = Ticker("AAPL")
        t.history(start=FIXED_START, end=FIXED_END, interval="1d", timeout=30)

        for call in mock_yf_ticker.history.call_args_list:
            if call[1].get("timeout") == 30:
                break
        else:
            pytest.fail("timeout=30 not found in any yfinance.Ticker.history() call")

    def test_raise_errors_passed_to_yfinance(self, mock_yf_ticker):
        """raise_errors should be forwarded to the underlying yfinance call."""
        mock_yf_ticker.history.return_value = make_raw_df()

        t = Ticker("AAPL")
        t.history(start=FIXED_START, end=FIXED_END, interval="1d", raise_errors=True)

        for call in mock_yf_ticker.history.call_args_list:
            if call[1].get("raise_errors") is True:
                break
        else:
            pytest.fail("raise_errors=True not found in any yfinance.Ticker.history() call")


# ---------------------------------------------------------------------------
# prepost passthrough
# ---------------------------------------------------------------------------

class TestPrepostOption:
    def test_prepost_intraday_passthrough(self, mock_yf_ticker):
        """prepost with intraday interval should pass through to yfinance."""
        mock_yf_ticker.history.return_value = _make_intraday_df()

        t = Ticker("AAPL")
        t.history(period="1d", interval="5m", prepost=True)

        call_kwargs = mock_yf_ticker.history.call_args[1]
        assert call_kwargs.get("prepost") is True


# ---------------------------------------------------------------------------
# repair passthrough
# ---------------------------------------------------------------------------

class TestRepairOption:
    def test_repair_passed_to_yfinance(self, mock_yf_ticker):
        """repair should be forwarded to the underlying yfinance call."""
        mock_yf_ticker.history.return_value = make_raw_df()

        t = Ticker("AAPL")
        t.history(start=FIXED_START, end=FIXED_END, interval="1d", repair=True)

        for call in mock_yf_ticker.history.call_args_list:
            if call[1].get("repair") is True:
                break
        else:
            pytest.fail("repair=True not found in any yfinance.Ticker.history() call")


# ---------------------------------------------------------------------------
# Combined options
# ---------------------------------------------------------------------------

class TestCombinedOptions:
    @pytest.mark.parametrize("auto_adjust", [True, False])
    @pytest.mark.parametrize("actions", [True, False])
    @pytest.mark.parametrize("rounding", [True, False])
    def test_option_combinations(self, mock_yf_ticker, auto_adjust, actions, rounding):
        """Various option combinations should produce valid DataFrames."""
        mock_yf_ticker.history.return_value = make_raw_df()

        t = Ticker("AAPL")
        df = t.history(
            start=FIXED_START, end=FIXED_END, interval="1d",
            auto_adjust=auto_adjust, actions=actions, rounding=rounding,
        )

        assert isinstance(df, pd.DataFrame)
        assert not df.empty
        assert "Open" in df.columns
        assert "Close" in df.columns
        assert "Volume" in df.columns

        if auto_adjust:
            assert "Adj Close" not in df.columns
        else:
            assert "Adj Close" in df.columns

        if actions:
            assert "Dividends" in df.columns
        else:
            assert "Dividends" not in df.columns


# ---------------------------------------------------------------------------
# Cache hit on second call
# ---------------------------------------------------------------------------

class TestCacheHit:
    def test_second_call_uses_cache(self, mock_yf_ticker):
        """Second call with same parameters should not fetch from yfinance again."""
        mock_yf_ticker.history.return_value = make_raw_df()

        t = Ticker("AAPL")

        # First call
        df1 = t.history(start=FIXED_START, end="2024-06-08", interval="1d")
        first_call_count = mock_yf_ticker.history.call_count

        # Second call - same range, should reuse most data from cache
        df2 = t.history(start=FIXED_START, end="2024-06-08", interval="1d")
        second_call_count = mock_yf_ticker.history.call_count

        # Cache may still need to fetch edges, but should not re-fetch the entire range
        assert second_call_count < first_call_count * 2 + 2
        assert not df1.empty
        assert not df2.empty
        assert len(df1) == len(df2)
