"""Unit tests for ferro_ta.indicators.momentum"""

import numpy as np

from ferro_ta.indicators.momentum import (
    ADX,
    ADXR,
    APO,
    AROON,
    AROONOSC,
    BOP,
    CCI,
    CMO,
    DX,
    MFI,
    MINUS_DI,
    MINUS_DM,
    MOM,
    PLUS_DI,
    PLUS_DM,
    PPO,
    ROC,
    ROCP,
    ROCR,
    ROCR100,
    RSI,
    STOCH,
    STOCHF,
    STOCHRSI,
    TRIX,
    ULTOSC,
    WILLR,
)

# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------

RNG = np.random.default_rng(7)
N = 100
_CLOSE = 100 + np.cumsum(RNG.normal(0, 0.5, N))
_HIGH = _CLOSE + np.abs(RNG.normal(0, 0.3, N))
_LOW = _CLOSE - np.abs(RNG.normal(0, 0.3, N))
_OPEN = _CLOSE + RNG.normal(0, 0.1, N)
_VOL = RNG.uniform(1000, 5000, N)

SMALL5 = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
SMALL5_H = np.array([12.0, 13.0, 14.0, 15.0, 16.0])
SMALL5_L = np.array([9.0, 10.0, 11.0, 12.0, 13.0])
SMALL5_O = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
SMALL5_V = np.array([1000.0, 2000.0, 3000.0, 4000.0, 5000.0])


# ---------------------------------------------------------------------------
# RSI
# ---------------------------------------------------------------------------


class TestRSI:
    def test_nan_warmup(self):
        result = RSI(_CLOSE, timeperiod=14)
        assert np.all(np.isnan(result[:14]))

    def test_range(self):
        result = RSI(_CLOSE, timeperiod=14)
        valid = result[~np.isnan(result)]
        assert np.all(valid >= 0) and np.all(valid <= 100)

    def test_length(self):
        assert len(RSI(_CLOSE, 14)) == N


# ---------------------------------------------------------------------------
# STOCH
# ---------------------------------------------------------------------------


class TestSTOCH:
    def test_returns_two_arrays(self):
        result = STOCH(_HIGH, _LOW, _CLOSE)
        assert isinstance(result, tuple) and len(result) == 2

    def test_range(self):
        slowk, slowd = STOCH(_HIGH, _LOW, _CLOSE)
        for arr in [slowk, slowd]:
            valid = arr[~np.isnan(arr)]
            assert np.all(valid >= 0) and np.all(valid <= 100)

    def test_length(self):
        slowk, slowd = STOCH(_HIGH, _LOW, _CLOSE)
        assert len(slowk) == len(slowd) == N


# ---------------------------------------------------------------------------
# STOCHF
# ---------------------------------------------------------------------------


class TestSTOCHF:
    def test_returns_two_arrays(self):
        result = STOCHF(_HIGH, _LOW, _CLOSE)
        assert isinstance(result, tuple) and len(result) == 2

    def test_fastk_range(self):
        fastk, fastd = STOCHF(_HIGH, _LOW, _CLOSE, fastk_period=5, fastd_period=3)
        valid = fastk[~np.isnan(fastk)]
        assert np.all(valid >= 0) and np.all(valid <= 100)

    def test_known_values(self):
        # With identical OHLC, fast %K = 100 * (C - min_low) / (max_high - min_low)
        # On our SMALL5 data the range is constant so all = 2/6 * 100 ≈ 66.67
        h5 = np.array([12.0, 13.0, 14.0, 15.0, 16.0])
        l5 = np.array([9.0, 10.0, 11.0, 12.0, 13.0])
        c5 = np.array([11.0, 12.0, 13.0, 14.0, 15.0])
        fastk, fastd = STOCHF(h5, l5, c5, fastk_period=3, fastd_period=2)
        valid_k = fastk[~np.isnan(fastk)]
        assert np.all(valid_k >= 0) and np.all(valid_k <= 100)

    def test_length(self):
        fastk, fastd = STOCHF(_HIGH, _LOW, _CLOSE)
        assert len(fastk) == len(fastd) == N


# ---------------------------------------------------------------------------
# STOCHRSI
# ---------------------------------------------------------------------------


class TestSTOCHRSI:
    def test_returns_two_arrays(self):
        result = STOCHRSI(_CLOSE)
        assert isinstance(result, tuple) and len(result) == 2

    def test_range(self):
        fastk, fastd = STOCHRSI(_CLOSE, timeperiod=14)
        for arr in [fastk, fastd]:
            valid = arr[~np.isnan(arr)]
            assert np.all(valid >= -1e-10) and np.all(valid <= 100 + 1e-10)

    def test_length(self):
        fastk, fastd = STOCHRSI(_CLOSE)
        assert len(fastk) == N


# ---------------------------------------------------------------------------
# ADX
# ---------------------------------------------------------------------------


class TestADX:
    def test_nan_warmup(self):
        result = ADX(_HIGH, _LOW, _CLOSE, timeperiod=14)
        assert np.all(np.isnan(result[:27]))

    def test_range(self):
        result = ADX(_HIGH, _LOW, _CLOSE, timeperiod=14)
        valid = result[~np.isnan(result)]
        assert np.all(valid >= 0) and np.all(valid <= 100)

    def test_length(self):
        assert len(ADX(_HIGH, _LOW, _CLOSE, 14)) == N


# ---------------------------------------------------------------------------
# ADXR
# ---------------------------------------------------------------------------


class TestADXR:
    def test_length(self):
        assert len(ADXR(_HIGH, _LOW, _CLOSE, 14)) == N

    def test_range(self):
        result = ADXR(_HIGH, _LOW, _CLOSE, 14)
        valid = result[~np.isnan(result)]
        assert np.all(valid >= 0) and np.all(valid <= 100)


# ---------------------------------------------------------------------------
# CCI
# ---------------------------------------------------------------------------


class TestCCI:
    def test_known_constant_mean_dev(self):
        # Constant typical price → CCI = 0 after warmup
        c5 = np.full(10, 12.0)
        h5 = np.full(10, 13.0)
        l5 = np.full(10, 11.0)
        result = CCI(h5, l5, c5, timeperiod=5)
        valid = result[~np.isnan(result)]
        np.testing.assert_allclose(valid, 0.0, atol=1e-10)

    def test_length(self):
        assert len(CCI(_HIGH, _LOW, _CLOSE, 14)) == N

    def test_nan_warmup(self):
        result = CCI(_HIGH, _LOW, _CLOSE, timeperiod=14)
        assert np.all(np.isnan(result[:13]))

    def test_simple_rising(self):
        h = np.array([12.0, 13.0, 14.0, 15.0, 16.0])
        l = np.array([9.0, 10.0, 11.0, 12.0, 13.0])
        c = np.array([11.0, 12.0, 13.0, 14.0, 15.0])
        result = CCI(h, l, c, 3)
        valid = result[~np.isnan(result)]
        np.testing.assert_allclose(valid, 100.0, atol=1e-8)


# ---------------------------------------------------------------------------
# WILLR
# ---------------------------------------------------------------------------


class TestWILLR:
    def test_range(self):
        result = WILLR(_HIGH, _LOW, _CLOSE, 14)
        valid = result[~np.isnan(result)]
        assert np.all(valid >= -100) and np.all(valid <= 0)

    def test_length(self):
        assert len(WILLR(_HIGH, _LOW, _CLOSE, 14)) == N


# ---------------------------------------------------------------------------
# AROON
# ---------------------------------------------------------------------------


class TestAROON:
    def test_returns_two_arrays(self):
        result = AROON(_HIGH, _LOW, 14)
        assert isinstance(result, tuple) and len(result) == 2

    def test_range(self):
        aroon_down, aroon_up = AROON(_HIGH, _LOW, 14)
        for arr in [aroon_down, aroon_up]:
            valid = arr[~np.isnan(arr)]
            assert np.all(valid >= 0) and np.all(valid <= 100)

    def test_length(self):
        aroon_down, aroon_up = AROON(_HIGH, _LOW, 14)
        assert len(aroon_down) == N


# ---------------------------------------------------------------------------
# AROONOSC
# ---------------------------------------------------------------------------


class TestAROONOSC:
    def test_known_values(self):
        h = np.array([12.0, 13.0, 14.0, 15.0, 16.0])
        l = np.array([9.0, 10.0, 11.0, 12.0, 13.0])
        result = AROONOSC(h, l, timeperiod=2)
        valid = result[~np.isnan(result)]
        # Monotone rising high/low → aroon_up = 100, aroon_down = 0 → osc = 100
        np.testing.assert_allclose(valid, 100.0, atol=1e-10)

    def test_equals_aroon_diff(self):
        aroon_down, aroon_up = AROON(_HIGH, _LOW, 14)
        aroonosc = AROONOSC(_HIGH, _LOW, 14)
        valid = ~np.isnan(aroon_up) & ~np.isnan(aroon_down) & ~np.isnan(aroonosc)
        np.testing.assert_allclose(
            aroonosc[valid],
            aroon_up[valid] - aroon_down[valid],
            atol=1e-10,
        )

    def test_length(self):
        assert len(AROONOSC(_HIGH, _LOW, 14)) == N


# ---------------------------------------------------------------------------
# MFI
# ---------------------------------------------------------------------------


class TestMFI:
    def test_range(self):
        result = MFI(_HIGH, _LOW, _CLOSE, _VOL, 14)
        valid = result[~np.isnan(result)]
        assert np.all(valid >= 0) and np.all(valid <= 100)

    def test_length(self):
        assert len(MFI(_HIGH, _LOW, _CLOSE, _VOL, 14)) == N

    def test_nan_warmup(self):
        result = MFI(_HIGH, _LOW, _CLOSE, _VOL, 14)
        assert np.all(np.isnan(result[:14]))

    def test_constant_price_is_50(self):
        # When money flow is neither positive nor negative → MFI should be near 50
        # Use alternating tiny moves around constant so no clear direction
        c = np.full(20, 100.0)
        h = np.full(20, 101.0)
        l = np.full(20, 99.0)
        v = np.full(20, 1000.0)
        result = MFI(h, l, c, v, 5)
        valid = result[~np.isnan(result)]
        assert len(valid) > 0  # just ensure it runs


# ---------------------------------------------------------------------------
# MOM
# ---------------------------------------------------------------------------


class TestMOM:
    def test_known_values(self):
        result = MOM(SMALL5, timeperiod=2)
        assert np.isnan(result[0]) and np.isnan(result[1])
        np.testing.assert_allclose(result[2], 2.0, rtol=1e-10)
        np.testing.assert_allclose(result[3], 2.0, rtol=1e-10)

    def test_length(self):
        assert len(MOM(_CLOSE, 10)) == N


# ---------------------------------------------------------------------------
# ROC
# ---------------------------------------------------------------------------


class TestROC:
    def test_known_values(self):
        arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
        result = ROC(arr, 2)
        # ROC = ((close - close[n]) / close[n]) * 100
        np.testing.assert_allclose(result[2], (12 - 10) / 10 * 100, rtol=1e-10)

    def test_length(self):
        assert len(ROC(_CLOSE, 10)) == N


# ---------------------------------------------------------------------------
# ROCP
# ---------------------------------------------------------------------------


class TestROCP:
    def test_known_values(self):
        arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
        result = ROCP(arr, 2)
        # ROCP = (close - close[n]) / close[n]
        np.testing.assert_allclose(result[2], (12 - 10) / 10, rtol=1e-10)

    def test_length(self):
        assert len(ROCP(_CLOSE, 10)) == N


# ---------------------------------------------------------------------------
# ROCR
# ---------------------------------------------------------------------------


class TestROCR:
    def test_known_values(self):
        arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
        result = ROCR(arr, 2)
        # ROCR = close / close[n]
        np.testing.assert_allclose(result[2], 12 / 10, rtol=1e-10)
        np.testing.assert_allclose(result[4], 14 / 12, rtol=1e-10)

    def test_nan_warmup(self):
        result = ROCR(_CLOSE, 10)
        assert np.all(np.isnan(result[:10]))

    def test_length(self):
        assert len(ROCR(_CLOSE, 10)) == N


# ---------------------------------------------------------------------------
# ROCR100
# ---------------------------------------------------------------------------


class TestROCR100:
    def test_known_values(self):
        arr = np.array([10.0, 11.0, 12.0, 13.0, 14.0])
        result = ROCR100(arr, 2)
        # ROCR100 = (close / close[n]) * 100
        np.testing.assert_allclose(result[2], 12 / 10 * 100, rtol=1e-10)

    def test_relation_to_rocr(self):
        rocr = ROCR(_CLOSE, 5)
        rocr100 = ROCR100(_CLOSE, 5)
        valid = ~np.isnan(rocr)
        np.testing.assert_allclose(rocr100[valid], rocr[valid] * 100, rtol=1e-10)

    def test_length(self):
        assert len(ROCR100(_CLOSE, 10)) == N


# ---------------------------------------------------------------------------
# CMO
# ---------------------------------------------------------------------------


class TestCMO:
    def test_range(self):
        result = CMO(_CLOSE, 14)
        valid = result[~np.isnan(result)]
        assert np.all(valid >= -100) and np.all(valid <= 100)

    def test_length(self):
        assert len(CMO(_CLOSE, 14)) == N


# ---------------------------------------------------------------------------
# DX
# ---------------------------------------------------------------------------


class TestDX:
    def test_range(self):
        result = DX(_HIGH, _LOW, _CLOSE, 14)
        valid = result[~np.isnan(result)]
        assert np.all(valid >= 0) and np.all(valid <= 100)

    def test_length(self):
        assert len(DX(_HIGH, _LOW, _CLOSE, 14)) == N


# ---------------------------------------------------------------------------
# MINUS_DI / MINUS_DM
# ---------------------------------------------------------------------------


class TestMINUS:
    def test_minus_di_range(self):
        result = MINUS_DI(_HIGH, _LOW, _CLOSE, 14)
        valid = result[~np.isnan(result)]
        assert np.all(valid >= 0)

    def test_minus_dm_range(self):
        result = MINUS_DM(_HIGH, _LOW, 14)
        valid = result[~np.isnan(result)]
        assert np.all(valid >= 0)

    def test_lengths(self):
        assert len(MINUS_DI(_HIGH, _LOW, _CLOSE, 14)) == N
        assert len(MINUS_DM(_HIGH, _LOW, 14)) == N


# ---------------------------------------------------------------------------
# PLUS_DI / PLUS_DM
# ---------------------------------------------------------------------------


class TestPLUS:
    def test_plus_di_range(self):
        result = PLUS_DI(_HIGH, _LOW, _CLOSE, 14)
        valid = result[~np.isnan(result)]
        assert np.all(valid >= 0)

    def test_plus_dm_range(self):
        result = PLUS_DM(_HIGH, _LOW, 14)
        valid = result[~np.isnan(result)]
        assert np.all(valid >= 0)

    def test_lengths(self):
        assert len(PLUS_DI(_HIGH, _LOW, _CLOSE, 14)) == N
        assert len(PLUS_DM(_HIGH, _LOW, 14)) == N


# ---------------------------------------------------------------------------
# PPO
# ---------------------------------------------------------------------------


class TestPPO:
    def test_returns_three_arrays(self):
        result = PPO(_CLOSE, fastperiod=12, slowperiod=26)
        assert isinstance(result, tuple) and len(result) == 3

    def test_histogram_is_diff(self):
        ppo, signal, hist = PPO(_CLOSE, fastperiod=12, slowperiod=26)
        valid = ~np.isnan(ppo) & ~np.isnan(signal)
        np.testing.assert_allclose(hist[valid], ppo[valid] - signal[valid], atol=1e-10)

    def test_length(self):
        ppo, signal, hist = PPO(_CLOSE)
        assert len(ppo) == len(signal) == len(hist) == N

    def test_nan_warmup(self):
        ppo, signal, hist = PPO(_CLOSE, fastperiod=12, slowperiod=26)
        assert np.any(np.isnan(ppo))


# ---------------------------------------------------------------------------
# APO
# ---------------------------------------------------------------------------


class TestAPO:
    def test_known_direction(self):
        # Rising close → fast EMA > slow EMA → APO > 0 after warmup
        rising = np.linspace(1.0, 100.0, 60)
        result = APO(rising, fastperiod=5, slowperiod=10)
        valid = result[~np.isnan(result)]
        assert np.all(valid > 0)

    def test_length(self):
        assert len(APO(_CLOSE, 12, 26)) == N

    def test_nan_warmup(self):
        result = APO(_CLOSE, 12, 26)
        assert np.any(np.isnan(result))


# ---------------------------------------------------------------------------
# TRIX
# ---------------------------------------------------------------------------


class TestTRIX:
    def test_length(self):
        assert len(TRIX(_CLOSE, 10)) == N

    def test_nan_warmup(self):
        result = TRIX(_CLOSE, timeperiod=5)
        # TRIX warmup = 3*(tp-1) for triple EMA + 1 for diff
        assert np.all(np.isnan(result[:12]))

    def test_finite_after_warmup(self):
        result = TRIX(_CLOSE, timeperiod=5)
        valid = result[~np.isnan(result)]
        assert np.all(np.isfinite(valid))

    def test_rising_series_positive(self):
        rising = np.linspace(1.0, 200.0, 100)
        result = TRIX(rising, timeperiod=5)
        valid = result[~np.isnan(result)]
        # On monotone rise, rate of change of triple EMA is positive
        assert np.all(valid > 0)


# ---------------------------------------------------------------------------
# BOP
# ---------------------------------------------------------------------------


class TestBOP:
    def test_known_values(self):
        o = np.array([10.0, 11.0])
        h = np.array([14.0, 15.0])
        l = np.array([8.0, 9.0])
        c = np.array([12.0, 13.0])
        # BOP = (close - open) / (high - low)
        result = BOP(o, h, l, c)
        np.testing.assert_allclose(result[0], (12 - 10) / (14 - 8), rtol=1e-10)
        np.testing.assert_allclose(result[1], (13 - 11) / (15 - 9), rtol=1e-10)

    def test_bearish_is_negative(self):
        o = np.array([14.0, 14.0])
        h = np.array([15.0, 15.0])
        l = np.array([8.0, 8.0])
        c = np.array([10.0, 10.0])
        result = BOP(o, h, l, c)
        assert np.all(result < 0)

    def test_range(self):
        # BOP = (close - open) / (high - low); can exceed [-1,1] with noisy data
        result = BOP(_OPEN, _HIGH, _LOW, _CLOSE)
        assert np.all(np.isfinite(result))

    def test_length(self):
        assert len(BOP(_OPEN, _HIGH, _LOW, _CLOSE)) == N


# ---------------------------------------------------------------------------
# ULTOSC
# ---------------------------------------------------------------------------


class TestULTOSC:
    def test_range(self):
        result = ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28)
        valid = result[~np.isnan(result)]
        assert np.all(valid >= 0) and np.all(valid <= 100)

    def test_length(self):
        assert len(ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28)) == N

    def test_nan_warmup(self):
        result = ULTOSC(_HIGH, _LOW, _CLOSE, 7, 14, 28)
        assert np.any(np.isnan(result))
