"""Statistical validation tests for the Bitcoin DCA strategy.

These tests detect:
1. Overfitting to historical data
2. Spurious correlations in features
3. Data snooping (parameters tuned on test data)
4. Unrealistic performance under randomized conditions

If the strategy has no predictive power, it should not outperform
uniform DCA on randomized/shuffled price data.
"""

import sys
import warnings
from pathlib import Path

import numpy as np
import pandas as pd
import pytest

# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))

import stacksats.backtest as backtest
from stacksats.backtest import compute_weights_shared
from stacksats.model_development import (
    DYNAMIC_STRENGTH,
    FEATS,
    compute_weights_fast,
    precompute_features,
)
from stacksats.prelude import compute_cycle_spd

# -----------------------------------------------------------------------------
# Randomized Baseline Tests
# -----------------------------------------------------------------------------


class TestRandomizedBaseline:
    """Test that strategy has no spurious predictive power on random data."""

    def test_shuffled_prices_baseline(self, sample_btc_df, sample_features_df):
        """With randomly shuffled prices, dynamic DCA should not significantly beat uniform.

        This is a critical test: if the strategy works on shuffled prices,
        it's likely overfitting or has look-ahead bias.
        """
        backtest._FEATURES_DF = sample_features_df

        # Store excess percentiles from multiple random shuffles
        excess_percentiles = []

        for seed in range(5):  # 5 random shuffles
            np.random.seed(seed)

            # Shuffle prices while keeping dates
            shuffled_df = sample_btc_df.copy()
            shuffled_df["PriceUSD_coinmetrics"] = np.random.permutation(
                shuffled_df["PriceUSD_coinmetrics"].values
            )

            try:
                # Run backtest with shuffled prices
                spd_table = compute_cycle_spd(
                    shuffled_df, compute_weights_shared, features_df=sample_features_df
                )

                # Get mean excess percentile (filter out NaN from edge cases)
                valid_excess = spd_table["excess_percentile"].dropna()
                if len(valid_excess) > 0:
                    excess_percentiles.append(valid_excess.mean())
            except Exception:
                # Some shuffles may cause issues; skip them
                continue

        if len(excess_percentiles) < 3:
            pytest.skip("Not enough valid shuffles completed")

        # With random prices, mean excess should be close to 0
        # Allow for some variance, but it shouldn't be strongly positive
        mean_excess = np.mean(excess_percentiles)

        # The strategy shouldn't consistently outperform on random data
        # If it does (mean_excess > 10%), something is wrong
        assert mean_excess < 15, (
            f"Strategy shows unexpected outperformance on shuffled prices: "
            f"mean excess = {mean_excess:.2f}%. This suggests look-ahead bias or overfitting."
        )

    def test_reversed_prices_baseline(self, sample_btc_df, sample_features_df):
        """With time-reversed prices, strategy should behave differently.

        This tests whether the strategy is actually using temporal patterns.
        """
        backtest._FEATURES_DF = sample_features_df

        # Reverse prices (time reversal)
        reversed_df = sample_btc_df.copy()
        reversed_df["PriceUSD_coinmetrics"] = sample_btc_df[
            "PriceUSD_coinmetrics"
        ].values[::-1]

        try:
            spd_table = compute_cycle_spd(
                reversed_df, compute_weights_shared, features_df=sample_features_df
            )

            # Just verify it runs - performance may differ
            assert len(spd_table) > 0
        except Exception as e:
            # It's acceptable if reversed data causes issues
            pytest.skip(f"Reversed data test skipped: {e}")

    def test_constant_prices_baseline(self, sample_btc_df, sample_features_df):
        """With constant prices, excess percentile should be undefined or zero.

        When all prices are identical, there's no way to outperform uniform DCA.
        """
        backtest._FEATURES_DF = sample_features_df

        # Set all prices to constant
        constant_df = sample_btc_df.copy()
        constant_df["PriceUSD_coinmetrics"] = 50000.0  # Constant price

        try:
            spd_table = compute_cycle_spd(
                constant_df, compute_weights_shared, features_df=sample_features_df
            )

            # With constant prices, span = 0, so percentiles should be NaN
            # or if not NaN, excess should be ~0
            excess = spd_table["excess_percentile"].dropna()
            if len(excess) > 0:
                # All excess should be ~0 (no way to outperform)
                assert abs(excess.mean()) < 1.0, (
                    f"Non-zero excess on constant prices: {excess.mean():.2f}%"
                )
        except Exception:
            # Constant prices may cause edge cases; this is acceptable
            pass


# -----------------------------------------------------------------------------
# Overfitting Detection Tests
# -----------------------------------------------------------------------------


class TestOverfittingDetection:
    """Tests to detect potential overfitting in the strategy."""

    def test_dynamic_strength_is_fixed(self):
        """Verify that DYNAMIC_STRENGTH doesn't change during runtime.

        Data snooping occurs when parameters are fitted on test data.
        DYNAMIC_STRENGTH should be constant.
        """
        # Store original DYNAMIC_STRENGTH
        original_strength = DYNAMIC_STRENGTH

        # Run some operations that might accidentally modify it
        # (This shouldn't happen, but we verify it)
        for _ in range(3):
            assert DYNAMIC_STRENGTH == original_strength, (
                "DYNAMIC_STRENGTH was modified during runtime"
            )

    def test_weight_distribution_reasonable(self, sample_features_df):
        """Verify that weights have reasonable statistical properties.

        Overfitted strategies often produce extreme weight distributions.
        """
        start_date = pd.Timestamp("2024-01-01")
        end_date = pd.Timestamp("2024-06-30")

        if start_date not in sample_features_df.index:
            pytest.skip("Start date not in features index")
        if end_date not in sample_features_df.index:
            pytest.skip("End date not in features index")

        weights = compute_weights_fast(sample_features_df, start_date, end_date)

        # Check for reasonable distribution properties
        assert weights.max() < 0.5, (
            f"Max weight {weights.max():.4f} too high - suggests overfitting"
        )

        # Weights should have some variance (not all equal)
        assert weights.std() > 0.0001, "Weights have no variance"

        # Effective number of weights (entropy-based)
        # Should be reasonably high for a diversified strategy
        weights_clean = weights[weights > 0]
        entropy = -np.sum(weights_clean * np.log(weights_clean + 1e-10))
        effective_n = np.exp(entropy)

        # At least 30% of dates should have meaningful weight
        assert effective_n > len(weights) * 0.3, (
            f"Effective N = {effective_n:.1f} too low for {len(weights)} dates. "
            "Strategy may be too concentrated."
        )

    def test_performance_varies_across_windows(self, sample_btc_df, sample_features_df):
        """Verify that performance varies across windows (not constant).

        A strategy that performs identically across all market conditions
        is likely overfitted or has a bug.
        """
        backtest._FEATURES_DF = sample_features_df

        spd_table = compute_cycle_spd(
            sample_btc_df, compute_weights_shared, features_df=sample_features_df
        )

        if len(spd_table) < 5:
            pytest.skip("Not enough windows for this test")

        excess = spd_table["excess_percentile"].dropna()

        if len(excess) < 3:
            pytest.skip("Not enough valid excess percentiles")

        # Excess should have variance (not constant)
        assert excess.std() > 0.1, (
            f"Excess percentile std = {excess.std():.4f} too low - "
            "performance is suspiciously constant"
        )


# -----------------------------------------------------------------------------
# Feature Validity Tests
# -----------------------------------------------------------------------------


class TestFeatureValidity:
    """Test that features are statistically valid and not spurious."""

    def test_features_not_all_zero(self, sample_features_df):
        """Verify that features are not trivially zero."""
        for feat in FEATS:
            non_zero_count = (sample_features_df[feat] != 0).sum()
            total_count = len(sample_features_df)

            # At least 10% of values should be non-zero
            assert non_zero_count > total_count * 0.1, (
                f"Feature {feat} has too many zeros: {non_zero_count}/{total_count}"
            )

    def test_features_not_constant(self, sample_features_df):
        """Verify that features have variance (not constant)."""
        for feat in FEATS:
            std = sample_features_df[feat].std()
            assert std > 0.01, f"Feature {feat} has std = {std:.6f}, appears constant"

    def test_features_have_reasonable_correlation(self, sample_features_df):
        """Verify that features are not perfectly correlated (redundant).

        Perfectly correlated features suggest a bug in feature computation.
        """
        feature_df = sample_features_df[FEATS]
        corr_matrix = feature_df.corr()

        # Check off-diagonal correlations
        for i, feat1 in enumerate(FEATS):
            for j, feat2 in enumerate(FEATS):
                if i >= j:
                    continue

                corr = corr_matrix.loc[feat1, feat2]

                # Features shouldn't be perfectly correlated
                assert abs(corr) < 0.999, (
                    f"Features {feat1} and {feat2} have correlation {corr:.4f} - "
                    "appears redundant"
                )

    def test_feature_autocorrelation(self, sample_features_df):
        """Test that features have reasonable autocorrelation (not white noise).

        Features with no autocorrelation are likely random/useless.
        """
        for feat in FEATS:
            series = sample_features_df[feat].dropna()

            if len(series) < 100:
                continue

            # Compute lag-1 autocorrelation
            autocorr = series.autocorr(lag=1)

            # Features should have some temporal structure (not random noise)
            # But this may not always be the case depending on the feature
            # Just verify it's not NaN
            assert not np.isnan(autocorr), f"Feature {feat} autocorrelation is NaN"


# -----------------------------------------------------------------------------
# Out-of-Sample Tests
# -----------------------------------------------------------------------------


class TestOutOfSample:
    """Tests for out-of-sample performance consistency."""

    def test_train_test_split_consistency(self, sample_btc_df, sample_features_df):
        """Compare performance on first half vs second half of data.

        Large discrepancy suggests overfitting to one period.
        """
        backtest._FEATURES_DF = sample_features_df

        # Split data in half
        mid_point = len(sample_btc_df) // 2
        mid_date = sample_btc_df.index[mid_point]

        first_half = sample_btc_df.loc[:mid_date]
        second_half = sample_btc_df.loc[mid_date:]

        first_half_features = sample_features_df.loc[:mid_date]
        second_half_features = sample_features_df.loc[mid_date:]

        # Run backtests on each half
        try:
            backtest._FEATURES_DF = first_half_features
            spd_first = compute_cycle_spd(
                first_half, compute_weights_shared, features_df=first_half_features
            )

            backtest._FEATURES_DF = second_half_features
            spd_second = compute_cycle_spd(
                second_half, compute_weights_shared, features_df=second_half_features
            )

            # Compare mean excess percentiles
            excess_first = spd_first["excess_percentile"].dropna().mean()
            excess_second = spd_second["excess_percentile"].dropna().mean()

            # Difference shouldn't be too extreme
            # (allows for some variation due to market conditions)
            diff = abs(excess_first - excess_second)
            assert diff < 30, (
                f"Large performance gap between halves: "
                f"first={excess_first:.2f}%, second={excess_second:.2f}%"
            )
        except Exception as e:
            pytest.skip(f"Train/test split test skipped: {e}")
        finally:
            # Restore original features
            backtest._FEATURES_DF = sample_features_df


# -----------------------------------------------------------------------------
# Numerical Stability Tests
# -----------------------------------------------------------------------------


class TestNumericalStability:
    """Test for numerical stability issues that could cause spurious results."""

    def test_extreme_price_values(self, sample_btc_df, sample_features_df):
        """Test with extreme price values to check for numerical issues."""
        # Test with very small prices
        small_df = sample_btc_df.copy()
        small_df["PriceUSD_coinmetrics"] = sample_btc_df["PriceUSD_coinmetrics"] / 1000

        features_small = precompute_features(small_df)

        # Should still produce valid weights
        start_date = pd.Timestamp("2024-01-01")
        end_date = pd.Timestamp("2024-06-30")

        if start_date in features_small.index and end_date in features_small.index:
            weights = compute_weights_fast(features_small, start_date, end_date)
            assert weights.notna().all(), "NaN weights with small prices"
            assert np.all(np.isfinite(weights)), "Inf weights with small prices"

        # Test with very large prices
        large_df = sample_btc_df.copy()
        large_df["PriceUSD_coinmetrics"] = sample_btc_df["PriceUSD_coinmetrics"] * 1000

        features_large = precompute_features(large_df)

        if start_date in features_large.index and end_date in features_large.index:
            weights = compute_weights_fast(features_large, start_date, end_date)
            assert weights.notna().all(), "NaN weights with large prices"
            assert np.all(np.isfinite(weights)), "Inf weights with large prices"

    def test_price_with_zeros(self, sample_btc_df):
        """Test that zero prices are handled gracefully."""
        # Create data with some zero prices
        zero_df = sample_btc_df.copy()
        zero_df.iloc[100:110, zero_df.columns.get_loc("PriceUSD_coinmetrics")] = 0

        # Suppress expected RuntimeWarning for divide by zero in log(0)
        with warnings.catch_warnings():
            warnings.filterwarnings(
                "ignore",
                category=RuntimeWarning,
                message="divide by zero encountered in log",
            )
            # This should handle zeros gracefully (log(0) issues)
            try:
                features = precompute_features(zero_df)
                # Check for any inf or NaN values in features
                for feat in FEATS:
                    assert not features[feat].isna().all(), f"Feature {feat} is all NaN"
            except Exception as e:
                # It's acceptable to raise an error for invalid data
                assert "zero" in str(e).lower() or "invalid" in str(e).lower() or True

    def test_weights_bounded_with_extreme_features(self, sample_features_df):
        """Test that weights remain bounded even with extreme feature values."""
        # Create features with some extreme values
        extreme_features = sample_features_df.copy()

        for feat in FEATS:
            # Set some values to the clipping limits
            extreme_features.iloc[50:60, extreme_features.columns.get_loc(feat)] = 4.0
            extreme_features.iloc[70:80, extreme_features.columns.get_loc(feat)] = -4.0

        start_date = pd.Timestamp("2024-01-01")
        end_date = pd.Timestamp("2024-06-30")

        if start_date in extreme_features.index and end_date in extreme_features.index:
            weights = compute_weights_fast(extreme_features, start_date, end_date)

            # Weights should still be valid
            assert weights.notna().all(), "NaN weights with extreme features"
            assert np.all(np.isfinite(weights)), "Inf weights with extreme features"
            assert np.isclose(weights.sum(), 1.0, rtol=1e-6), "Weights don't sum to 1"


# -----------------------------------------------------------------------------
# Sanity Check Tests
# -----------------------------------------------------------------------------


class TestSanityChecks:
    """Basic sanity checks to catch obvious errors."""

    def test_weights_are_positive(self, sample_features_df):
        """All weights must be non-negative."""
        start_date = pd.Timestamp("2024-01-01")
        end_date = pd.Timestamp("2024-06-30")

        if start_date not in sample_features_df.index:
            pytest.skip("Start date not in features index")
        if end_date not in sample_features_df.index:
            pytest.skip("End date not in features index")

        weights = compute_weights_fast(sample_features_df, start_date, end_date)

        assert (weights >= 0).all(), f"Found negative weights: min={weights.min()}"

    def test_feats_has_expected_count(self):
        """Verify FEATS has expected count of feature names."""
        # FEATS should have 8 feature names
        assert len(FEATS) == 8, f"FEATS has {len(FEATS)} elements, expected 8"

    def test_features_have_expected_count(self, sample_features_df):
        """Verify all expected features are present."""
        for feat in FEATS:
            assert feat in sample_features_df.columns, f"Missing feature: {feat}"

    def test_no_future_dates_in_weights(self, sample_features_df):
        """Verify weight index doesn't include dates beyond the end_date."""
        start_date = pd.Timestamp("2024-01-01")
        end_date = pd.Timestamp("2024-06-30")

        if start_date not in sample_features_df.index:
            pytest.skip("Start date not in features index")
        if end_date not in sample_features_df.index:
            pytest.skip("End date not in features index")

        weights = compute_weights_fast(sample_features_df, start_date, end_date)

        # No weight dates should be beyond end_date
        assert weights.index.max() <= end_date, (
            f"Weight dates extend beyond end_date: {weights.index.max()} > {end_date}"
        )

        # No weight dates should be before start_date
        assert weights.index.min() >= start_date, (
            f"Weight dates before start_date: {weights.index.min()} < {start_date}"
        )
