"""Tests for the DuckDB cache engine."""

import numpy as np
import pandas as pd

from dyfinance._cache import CacheEngine


class TestStoreAndRetrieve:
    def test_round_trip(self, tmp_cache, sample_ohlcv_df):
        """Store a DataFrame and retrieve it; verify all columns match."""
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)

        start = pd.Timestamp("2024-01-01", tz="UTC")
        end = pd.Timestamp("2024-01-10", tz="UTC")
        cached_df, missing = tmp_cache.get_cached_ohlcv("AAPL", "1d", start, end)

        assert not cached_df.empty
        assert len(cached_df) == len(sample_ohlcv_df)

        expected_cols = {"Open", "High", "Low", "Close", "Adj Close", "Volume",
                         "Dividends", "Stock Splits", "Capital Gains"}
        assert set(cached_df.columns) == expected_cols

    def test_values_preserved(self, tmp_cache, sample_ohlcv_df):
        """Verify stored values are correctly returned."""
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)

        start = pd.Timestamp("2024-01-01", tz="UTC")
        end = pd.Timestamp("2024-01-10", tz="UTC")
        cached_df, _ = tmp_cache.get_cached_ohlcv("AAPL", "1d", start, end)

        pd.testing.assert_series_equal(
            cached_df["Close"].reset_index(drop=True),
            sample_ohlcv_df["Close"].reset_index(drop=True),
            check_names=False,
        )
        pd.testing.assert_series_equal(
            cached_df["Volume"].reset_index(drop=True),
            sample_ohlcv_df["Volume"].reset_index(drop=True),
            check_names=False,
        )

    def test_dividends_preserved(self, tmp_cache, sample_ohlcv_df):
        """Verify dividend values survive round-trip."""
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)

        start = pd.Timestamp("2024-01-01", tz="UTC")
        end = pd.Timestamp("2024-01-10", tz="UTC")
        cached_df, _ = tmp_cache.get_cached_ohlcv("AAPL", "1d", start, end)

        pd.testing.assert_series_equal(
            cached_df["Dividends"].reset_index(drop=True),
            sample_ohlcv_df["Dividends"].reset_index(drop=True),
            check_names=False,
        )

    def test_volume_dtype_int64(self, tmp_cache, sample_ohlcv_df):
        """Volume column should be int64 after retrieval."""
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)

        start = pd.Timestamp("2024-01-01", tz="UTC")
        end = pd.Timestamp("2024-01-10", tz="UTC")
        cached_df, _ = tmp_cache.get_cached_ohlcv("AAPL", "1d", start, end)

        assert cached_df["Volume"].dtype == "int64"


class TestCacheMiss:
    def test_empty_cache_returns_full_missing_range(self, tmp_cache):
        """Query with no cache should return the full range as missing."""
        start = pd.Timestamp("2024-01-01", tz="UTC")
        end = pd.Timestamp("2024-06-01", tz="UTC")

        cached_df, missing = tmp_cache.get_cached_ohlcv("AAPL", "1d", start, end)

        assert cached_df.empty
        assert len(missing) == 1
        assert missing[0] == (start, end)

    def test_different_ticker_not_cached(self, tmp_cache, sample_ohlcv_df):
        """Data for one ticker should not satisfy queries for another."""
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)

        start = pd.Timestamp("2024-01-01", tz="UTC")
        end = pd.Timestamp("2024-01-10", tz="UTC")
        cached_df, missing = tmp_cache.get_cached_ohlcv("MSFT", "1d", start, end)

        assert cached_df.empty
        assert len(missing) == 1

    def test_different_interval_not_cached(self, tmp_cache, sample_ohlcv_df):
        """Data for one interval should not satisfy queries for another."""
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)

        start = pd.Timestamp("2024-01-01", tz="UTC")
        end = pd.Timestamp("2024-01-10", tz="UTC")
        cached_df, missing = tmp_cache.get_cached_ohlcv("AAPL", "1wk", start, end)

        assert cached_df.empty
        assert len(missing) == 1


class TestPartialCacheHit:
    def test_partial_hit_returns_missing_ranges(self, tmp_cache, sample_ohlcv_df):
        """Store Jan data; query Jan-Jun should return cache + missing for Feb-Jun."""
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)

        start = pd.Timestamp("2024-01-01", tz="UTC")
        end = pd.Timestamp("2024-06-01", tz="UTC")
        cached_df, missing = tmp_cache.get_cached_ohlcv("AAPL", "1d", start, end)

        assert not cached_df.empty
        # Should have at least one missing range after the cached data
        assert len(missing) >= 1
        # The last missing range should extend to end
        assert any(m[1] == end for m in missing)

    def test_earlier_range_missing(self, tmp_cache, sample_ohlcv_df):
        """Query for a range that starts before cached data."""
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)

        start = pd.Timestamp("2023-06-01", tz="UTC")
        end = pd.Timestamp("2024-01-05", tz="UTC")
        cached_df, missing = tmp_cache.get_cached_ohlcv("AAPL", "1d", start, end)

        # Should have a missing range before the cached data
        assert any(m[0] == start for m in missing)


class TestUpsertAndDedup:
    def test_upsert_no_duplicates(self, tmp_cache, sample_ohlcv_df):
        """Storing overlapping data should not create duplicates."""
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)  # Store same data again

        start = pd.Timestamp("2024-01-01", tz="UTC")
        end = pd.Timestamp("2024-01-10", tz="UTC")
        cached_df, _ = tmp_cache.get_cached_ohlcv("AAPL", "1d", start, end)

        assert len(cached_df) == len(sample_ohlcv_df)

    def test_cache_meta_expansion(self, tmp_cache):
        """Storing non-overlapping ranges should expand cache_meta."""
        dates_jan = pd.date_range("2024-01-02", periods=5, freq="B", tz="America/New_York")
        df_jan = pd.DataFrame(
            {
                "Open": [100.0] * 5, "High": [101.0] * 5, "Low": [99.0] * 5,
                "Close": [100.5] * 5, "Adj Close": [100.5] * 5,
                "Volume": [1000000] * 5, "Dividends": [0.0] * 5,
                "Stock Splits": [0.0] * 5,
            },
            index=dates_jan,
        )

        dates_mar = pd.date_range("2024-03-01", periods=5, freq="B", tz="America/New_York")
        df_mar = pd.DataFrame(
            {
                "Open": [110.0] * 5, "High": [111.0] * 5, "Low": [109.0] * 5,
                "Close": [110.5] * 5, "Adj Close": [110.5] * 5,
                "Volume": [1100000] * 5, "Dividends": [0.0] * 5,
                "Stock Splits": [0.0] * 5,
            },
            index=dates_mar,
        )

        tmp_cache.store_ohlcv("AAPL", "1d", df_jan)
        tmp_cache.store_ohlcv("AAPL", "1d", df_mar)

        meta = tmp_cache._con.execute(
            "SELECT cached_from, cached_to FROM cache_meta WHERE ticker = 'AAPL' AND interval = '1d'"
        ).fetchone()

        cached_from = pd.Timestamp(meta[0]).tz_convert("UTC")
        cached_to = pd.Timestamp(meta[1]).tz_convert("UTC")

        # Meta should span from Jan to Mar (cache stores trading-date as UTC midnight)
        assert cached_from <= pd.Timestamp(dates_jan[0].date(), tz="UTC")
        assert cached_to >= pd.Timestamp(dates_mar[-1].date(), tz="UTC")


class TestEmptyDataFrame:
    def test_store_empty_df_no_crash(self, tmp_cache):
        """Storing an empty DataFrame should not crash."""
        empty_df = pd.DataFrame()
        tmp_cache.store_ohlcv("AAPL", "1d", empty_df)

        start = pd.Timestamp("2024-01-01", tz="UTC")
        end = pd.Timestamp("2024-01-10", tz="UTC")
        cached_df, missing = tmp_cache.get_cached_ohlcv("AAPL", "1d", start, end)

        assert cached_df.empty
        assert len(missing) == 1


class TestDefaultInstance:
    def test_reset_default(self, tmp_path):
        """reset_default should create a new CacheEngine with the given path."""
        db_path = tmp_path / "default_test.duckdb"
        engine = CacheEngine.reset_default(db_path)

        assert CacheEngine.default() is engine
        assert engine._db_path == str(db_path)

        engine.close()
        CacheEngine._default_instance = None


class TestCacheInvalidation:
    def test_invalidate_specific_interval(self, tmp_cache, sample_ohlcv_df):
        """invalidate(ticker, interval) should only remove that interval."""
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)
        tmp_cache.store_ohlcv("AAPL", "1wk", sample_ohlcv_df)

        tmp_cache.invalidate("AAPL", "1d")

        start = pd.Timestamp("2024-01-01", tz="UTC")
        end = pd.Timestamp("2024-01-10", tz="UTC")

        df_1d, missing_1d = tmp_cache.get_cached_ohlcv("AAPL", "1d", start, end)
        df_1wk, missing_1wk = tmp_cache.get_cached_ohlcv("AAPL", "1wk", start, end)

        assert df_1d.empty
        assert len(missing_1d) == 1
        assert not df_1wk.empty

    def test_invalidate_all_intervals(self, tmp_cache, sample_ohlcv_df):
        """invalidate(ticker) with no interval should remove all intervals."""
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)
        tmp_cache.store_ohlcv("AAPL", "1wk", sample_ohlcv_df)

        tmp_cache.invalidate("AAPL")

        start = pd.Timestamp("2024-01-01", tz="UTC")
        end = pd.Timestamp("2024-01-10", tz="UTC")

        df_1d, _ = tmp_cache.get_cached_ohlcv("AAPL", "1d", start, end)
        df_1wk, _ = tmp_cache.get_cached_ohlcv("AAPL", "1wk", start, end)

        assert df_1d.empty
        assert df_1wk.empty

    def test_invalidate_does_not_affect_other_tickers(self, tmp_cache, sample_ohlcv_df):
        """invalidate should not touch other tickers' data."""
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)
        tmp_cache.store_ohlcv("MSFT", "1d", sample_ohlcv_df)

        tmp_cache.invalidate("AAPL")

        start = pd.Timestamp("2024-01-01", tz="UTC")
        end = pd.Timestamp("2024-01-10", tz="UTC")
        df, _ = tmp_cache.get_cached_ohlcv("MSFT", "1d", start, end)

        assert not df.empty


class TestAdjRatioCorrection:
    def test_apply_adj_ratio_updates_all_rows(self, tmp_cache, sample_ohlcv_df):
        """apply_adj_ratio should multiply all cached Adj Close by the ratio."""
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)

        original_adj = sample_ohlcv_df["Adj Close"].values.copy()
        ratio = 0.5  # Simulate a 2:1 stock split

        tmp_cache.apply_adj_ratio("AAPL", "1d", ratio)

        start = pd.Timestamp("2024-01-01", tz="UTC")
        end = pd.Timestamp("2024-01-10", tz="UTC")
        cached_df, _ = tmp_cache.get_cached_ohlcv("AAPL", "1d", start, end)

        np.testing.assert_allclose(
            cached_df["Adj Close"].values, original_adj * ratio, rtol=1e-6,
        )

    def test_ratio_correction_preserves_other_columns(self, tmp_cache, sample_ohlcv_df):
        """apply_adj_ratio should not change OHLC, Volume, or action columns."""
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)

        tmp_cache.apply_adj_ratio("AAPL", "1d", 0.5)

        start = pd.Timestamp("2024-01-01", tz="UTC")
        end = pd.Timestamp("2024-01-10", tz="UTC")
        cached_df, _ = tmp_cache.get_cached_ohlcv("AAPL", "1d", start, end)

        pd.testing.assert_series_equal(
            cached_df["Close"].reset_index(drop=True),
            sample_ohlcv_df["Close"].reset_index(drop=True),
            check_names=False,
        )
        pd.testing.assert_series_equal(
            cached_df["Volume"].reset_index(drop=True),
            sample_ohlcv_df["Volume"].reset_index(drop=True),
            check_names=False,
        )


class TestFetchedAt:
    def test_get_fetched_at_returns_none_for_missing(self, tmp_cache):
        """get_fetched_at should return None when no data is cached."""
        assert tmp_cache.get_fetched_at("AAPL", "1d") is None

    def test_get_fetched_at_returns_timestamp(self, tmp_cache, sample_ohlcv_df):
        """get_fetched_at should return a UTC timestamp after storing."""
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)
        ts = tmp_cache.get_fetched_at("AAPL", "1d")
        assert ts is not None
        assert str(ts.tz) == "UTC"

    def test_touch_fetched_at_updates_timestamp(self, tmp_cache, sample_ohlcv_df):
        """touch_fetched_at should update fetched_at without changing data."""
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)

        # Simulate passage of time by manually setting fetched_at to the past
        tmp_cache._con.execute(
            "UPDATE cache_meta SET fetched_at = '2020-01-01 00:00:00+00' "
            "WHERE ticker = 'AAPL' AND interval = '1d'",
        )
        ts_old = tmp_cache.get_fetched_at("AAPL", "1d")
        assert ts_old.year == 2020

        tmp_cache.touch_fetched_at("AAPL", "1d")
        ts2 = tmp_cache.get_fetched_at("AAPL", "1d")
        assert ts2 > ts_old


class TestGetCachedAdjClose:
    def test_returns_last_n_adj_close(self, tmp_cache, sample_ohlcv_df):
        """get_cached_adj_close should return the last N Adj Close values."""
        tmp_cache.store_ohlcv("AAPL", "1d", sample_ohlcv_df)

        adj = tmp_cache.get_cached_adj_close("AAPL", "1d", n=3)
        assert len(adj) == 3
        assert adj.index.is_monotonic_increasing

    def test_returns_empty_for_missing_ticker(self, tmp_cache):
        """get_cached_adj_close should return empty Series for unknown ticker."""
        adj = tmp_cache.get_cached_adj_close("UNKNOWN", "1d")
        assert adj.empty
