"""
Tests for all signalab indicators.
"""

from __future__ import annotations

from typing import cast

import numpy as np
import pandas as pd
import pytest
from conftest import make_market_data

from signalab.data.market_data import MarketData
from signalab.indicator.adx import ADX
from signalab.indicator.bollinger_bands import BollingerBands
from signalab.indicator.mamethod import MAMethod
from signalab.indicator.moving_avg import MACD, MovingAverageCross
from signalab.indicator.rsi import RSI
from signalab.indicator.stochastic import Stochastic

# ─────────────────────────────────────────────
# Shared helpers
# ─────────────────────────────────────────────


def assert_valid_signal_col(df: pd.DataFrame, col: str = "signal") -> None:
    assert col in df.columns
    assert set(df[col].unique()).issubset({-1, 0, 1}), (
        f"Signal column '{col}' contains values outside {{-1, 0, 1}}: {df[col].unique()}"
    )


def assert_no_nans(df: pd.DataFrame) -> None:
    assert not df.isnull().values.any(), (
        f"NaN values found:\n{df[df.isnull().any(axis=1)]}"
    )


def make_trending_up(n: int = 300) -> MarketData:
    """Strong uptrend with small noise — avoids avg_loss=0 edge case in RSI."""
    rng = np.random.default_rng(0)
    index: pd.DatetimeIndex = pd.date_range("2024-01-01", periods=n, freq="1min")
    trend = np.linspace(0, 0.05, n)  # +500 pip drift
    noise = rng.normal(0, 0.00005, size=n)  # tiny noise
    price = 1.10000 + trend + noise
    spread: float = 0.00010
    df = pd.DataFrame({"bid": price, "ask": price + spread}, index=index)
    df.index.name = "timestamp"
    df["spread"] = spread
    return MarketData(df)


def make_trending_down(n: int = 300) -> MarketData:
    """Strong downtrend with small noise — avoids avg_gain=0 edge case in RSI."""
    rng = np.random.default_rng(1)
    index: pd.DatetimeIndex = pd.date_range("2024-01-01", periods=n, freq="1min")
    trend = np.linspace(0, -0.05, n)  # -500 pip drift
    noise = rng.normal(0, 0.00005, size=n)
    price = 1.20000 + trend + noise
    spread: float = 0.00010
    df = pd.DataFrame({"bid": price, "ask": price + spread}, index=index)
    df.index.name = "timestamp"
    df["spread"] = spread
    return MarketData(df)


def make_oscillating(n: int = 300, amplitude: float = 0.01) -> MarketData:
    """Oscillating price — useful for overbought/oversold indicators."""
    index: pd.DatetimeIndex = pd.date_range("2024-01-01", periods=n, freq="1min")
    price: np.ndarray = 1.15000 + amplitude * np.sin(np.linspace(0, 6 * np.pi, n))
    spread: float = 0.00010
    df = pd.DataFrame({"bid": price, "ask": price + spread}, index=index)
    df.index.name = "timestamp"
    df["spread"] = spread
    return MarketData(df)


# ─────────────────────────────────────────────
# RSI
# ─────────────────────────────────────────────


class TestRSI:
    def test_output_columns(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = RSI().compute(md)
        assert "rsi" in df.columns
        assert "signal" in df.columns

    def test_rsi_bounds(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = RSI().compute(md)
        assert (df["rsi"] >= 0).all() and (df["rsi"] <= 100).all()

    def test_valid_signal_values(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = RSI().compute(md)
        assert_valid_signal_col(df)

    def test_no_nans_after_compute(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = RSI().compute(md)
        assert_no_nans(df)

    def test_uptrend_rsi_high(self) -> None:
        """In a strong uptrend RSI should consistently stay above 50."""
        md: MarketData = make_trending_up(300)
        df: pd.DataFrame = RSI(period=9, candles_type="1m").compute(md)
        assert df["rsi"].iloc[20:].mean() > 50

    def test_downtrend_rsi_low(self) -> None:
        """In a strong downtrend RSI should consistently stay below 50."""
        md: MarketData = make_trending_down(300)
        df: pd.DataFrame = RSI(period=9, candles_type="1m").compute(md)
        assert df["rsi"].iloc[20:].mean() < 50

    def test_buy_signal_only_on_crossover(self) -> None:
        """buy signal=1 only fires once per oversold crossing, not persistently."""
        md: MarketData = make_oscillating(300)
        df: pd.DataFrame = RSI(
            period=9, buy_level=40, sell_level=60, candles_type="1m"
        ).compute(md)
        buys: pd.DataFrame = df[df["signal"] == 1]
        if len(buys) > 1:
            assert not (df["signal"].shift(1) == 1).loc[buys.index].any()

    def test_parametrize_period(self) -> None:
        md: MarketData = make_market_data(n=300)
        for period in [5, 9, 14, 21]:
            df: pd.DataFrame = RSI(period=period).compute(md)
            assert len(df) > 0
            assert_valid_signal_col(df)


# ─────────────────────────────────────────────
# Moving Average Cross
# ─────────────────────────────────────────────


class TestMovingAverageCross:
    def test_output_columns(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = MovingAverageCross().compute(md)
        assert "fast" in df.columns
        assert "slow" in df.columns
        assert "signal" in df.columns

    def test_valid_signal_values(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = MovingAverageCross().compute(md)
        assert_valid_signal_col(df)

    def test_no_nans_after_compute(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = MovingAverageCross().compute(md)
        assert_no_nans(df)

    def test_uptrend_fast_above_slow(self) -> None:
        md: MarketData = make_trending_up(300)
        df: pd.DataFrame = MovingAverageCross(
            fast_period=8, slow_period=21, crossover_only=False
        ).compute(md)
        assert (df["fast"].iloc[30:] > df["slow"].iloc[30:]).all()

    def test_crossover_only_fires_once(self) -> None:
        """In crossover_only mode, signal must not be persistent."""
        md: MarketData = make_trending_up(300)
        df: pd.DataFrame = MovingAverageCross(crossover_only=True).compute(md)
        assert df[df["signal"] == 1].shape[0] <= 2

    @pytest.mark.parametrize("method", list(MAMethod))
    def test_all_ma_methods(self, method: MAMethod) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = MovingAverageCross(method=method).compute(md)
        assert len(df) > 0
        assert_valid_signal_col(df)

    def test_fast_period_must_be_less_than_slow(self) -> None:
        """fast >= slow is a logical misconfiguration — results should still be finite."""
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = MovingAverageCross(fast_period=20, slow_period=10).compute(
            md
        )
        assert_no_nans(df)


# ─────────────────────────────────────────────
# MACD
# ─────────────────────────────────────────────


class TestMACD:
    def test_output_columns(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = MACD().compute(md)
        for col in ("macd", "signal_line", "histogram", "signal"):
            assert col in df.columns

    def test_valid_signal_values(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = MACD().compute(md)
        assert_valid_signal_col(df)

    def test_no_nans_after_compute(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = MACD().compute(md)
        assert_no_nans(df)

    def test_histogram_is_macd_minus_signal(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = MACD().compute(md)
        np.testing.assert_allclose(
            cast(np.ndarray, df["histogram"].values),
            cast(np.ndarray, (df["macd"] - df["signal_line"]).values),
            atol=1e-10,
        )

    def test_crossover_only_fires_on_sign_change(self) -> None:
        """Signal must only fire when histogram changes sign."""
        md: MarketData = make_market_data(n=300)
        df: pd.DataFrame = MACD(crossover_only=True).compute(md)
        buy_rows: pd.DataFrame = df[df["signal"] == 1]
        for ts in buy_rows.index:
            loc: int = cast(int, df.index.get_loc(ts))
            if loc > 0:
                assert df["histogram"].iloc[loc] > 0
                assert df["histogram"].iloc[loc - 1] <= 0

    def test_level_mode_persistent(self) -> None:
        """In crossover_only=False mode, signal matches histogram sign."""
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = MACD(crossover_only=False).compute(md)
        pos_hist: pd.Series = df["histogram"] > 0
        neg_hist: pd.Series = df["histogram"] < 0
        assert (df.loc[pos_hist, "signal"] == 1).all()
        assert (df.loc[neg_hist, "signal"] == -1).all()


# ─────────────────────────────────────────────
# Bollinger Bands
# ─────────────────────────────────────────────


class TestBollingerBands:
    def test_output_columns(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = BollingerBands().compute(md)
        for col in ("avg", "upper", "lower", "signal"):
            assert col in df.columns

    def test_upper_gt_avg_gt_lower(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = BollingerBands().compute(md)
        assert (df["upper"] > df["avg"]).all()
        assert (df["avg"] > df["lower"]).all()

    def test_valid_signal_values(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = BollingerBands().compute(md)
        assert_valid_signal_col(df)

    def test_no_nans_after_compute(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = BollingerBands().compute(md)
        assert_no_nans(df)

    def test_wider_multiplier_wider_bands(self) -> None:
        md: MarketData = make_market_data(n=300)
        df1: pd.DataFrame = BollingerBands(
            std_multiplier=1.0, candles_type="1m"
        ).compute(md)
        df2: pd.DataFrame = BollingerBands(
            std_multiplier=3.0, candles_type="1m"
        ).compute(md)
        width1: float = (df1["upper"] - df1["lower"]).mean()
        width2: float = (df2["upper"] - df2["lower"]).mean()
        assert width2 > width1


# ─────────────────────────────────────────────
# ADX
# ─────────────────────────────────────────────


class TestADX:
    def test_output_columns(self) -> None:
        md: MarketData = make_market_data(n=300)
        df: pd.DataFrame = ADX().compute(md)
        for col in ("adx", "plus_di", "minus_di", "signal"):
            assert col in df.columns

    def test_adx_non_negative(self) -> None:
        md: MarketData = make_market_data(n=300)
        df: pd.DataFrame = ADX().compute(md)
        assert (df["adx"] >= 0).all()

    def test_valid_signal_values(self) -> None:
        md: MarketData = make_market_data(n=300)
        df: pd.DataFrame = ADX().compute(md)
        assert_valid_signal_col(df)

    def test_no_nans_after_compute(self) -> None:
        md: MarketData = make_market_data(n=300)
        df: pd.DataFrame = ADX().compute(md)
        assert_no_nans(df)

    def test_uptrend_plus_di_above_minus_di(self) -> None:
        md: MarketData = make_trending_up(300)
        df: pd.DataFrame = ADX(period=10).compute(md)
        assert (df["plus_di"].iloc[30:] > df["minus_di"].iloc[30:]).mean() > 0.8

    def test_downtrend_minus_di_above_plus_di(self) -> None:
        md: MarketData = make_trending_down(300)
        df: pd.DataFrame = ADX(period=10).compute(md)
        assert (df["minus_di"].iloc[30:] > df["plus_di"].iloc[30:]).mean() > 0.8

    def test_signal_zero_below_threshold(self) -> None:
        """When ADX < threshold, signal must be 0 regardless of DI."""
        md: MarketData = make_market_data(n=300)
        df: pd.DataFrame = ADX(threshold=100.0).compute(md)
        assert (df["signal"] == 0).all()


# ─────────────────────────────────────────────
# Stochastic
# ─────────────────────────────────────────────


class TestStochastic:
    def test_output_columns(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = Stochastic().compute(md)
        for col in ("k", "d", "signal"):
            assert col in df.columns

    def test_k_d_in_0_100(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = Stochastic().compute(md)
        assert (df["k"] >= 0).all() and (df["k"] <= 100).all()
        assert (df["d"] >= 0).all() and (df["d"] <= 100).all()

    def test_valid_signal_values(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = Stochastic().compute(md)
        assert_valid_signal_col(df)

    def test_no_nans_after_compute(self) -> None:
        md: MarketData = make_market_data(n=200)
        df: pd.DataFrame = Stochastic().compute(md)
        assert_no_nans(df)

    def test_flat_candles_no_crash(self) -> None:
        """Flat candles produce high==low, which previously caused division by zero."""
        index: pd.DatetimeIndex = pd.date_range("2024-01-01", periods=100, freq="1min")
        price: np.ndarray = np.full(100, 1.10000)
        df = pd.DataFrame({"bid": price, "ask": price + 0.0001}, index=index)
        df.index.name = "timestamp"
        df["spread"] = 0.0001
        md = MarketData(df)
        result: pd.DataFrame = Stochastic().compute(md)
        assert len(result) >= 0
