"""Unit tests for services.signal_engine.signals.fibonacci — Fibonacci retracement evaluator.

Requirements: 2.1, 2.6, 2.7
"""
from __future__ import annotations

from datetime import datetime, timezone

from services.signal_engine.models import OHLCVBar, SignalDirection
from services.signal_engine.signals.fibonacci import (
    DEFAULT_MIN_BARS,
    RETRACEMENT_RATIOS,
    FibonacciEvaluator,
)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _bar(
    close: float,
    high: float | None = None,
    low: float | None = None,
) -> OHLCVBar:
    """Create a minimal OHLCVBar for testing."""
    return OHLCVBar(
        timestamp=datetime(2024, 1, 1, tzinfo=timezone.utc),
        open=close,
        high=high if high is not None else close,
        low=low if low is not None else close,
        close=close,
        volume=1000.0,
    )


def _make_bars(
    n: int,
    close: float = 100.0,
    high: float | None = None,
    low: float | None = None,
) -> list[OHLCVBar]:
    """Create *n* identical bars."""
    return [_bar(close, high=high, low=low) for _ in range(n)]


# ---------------------------------------------------------------------------
# Insufficient data → None
# ---------------------------------------------------------------------------


def test_returns_none_when_insufficient_bars() -> None:
    """Requirement 2.6: return None when fewer bars than lookback."""
    evaluator = FibonacciEvaluator(min_bars=20)
    bars = _make_bars(10, close=100.0, high=110.0, low=90.0)
    assert evaluator.evaluate(bars, "D") is None


def test_returns_none_with_empty_bars() -> None:
    evaluator = FibonacciEvaluator()
    assert evaluator.evaluate([], "D") is None


def test_returns_none_when_flat_market() -> None:
    """When SH == SL there is no valid retracement range."""
    evaluator = FibonacciEvaluator(min_bars=5)
    # All bars have the same high and low
    bars = _make_bars(5, close=100.0, high=100.0, low=100.0)
    assert evaluator.evaluate(bars, "D") is None


# ---------------------------------------------------------------------------
# Basic signal production
# ---------------------------------------------------------------------------


def test_produces_signal_with_sufficient_data() -> None:
    """Requirement 2.7: produces a valid SignalResult."""
    evaluator = FibonacciEvaluator(min_bars=5)
    # Create bars with a clear swing high and swing low
    bars = [
        _bar(100.0, high=100.0, low=95.0),
        _bar(105.0, high=110.0, low=100.0),  # swing high
        _bar(95.0, high=100.0, low=90.0),   # swing low
        _bar(98.0, high=100.0, low=95.0),
        _bar(100.0, high=102.0, low=98.0),  # current price = 100
    ]
    result = evaluator.evaluate(bars, "H4")
    assert result is not None
    assert result.signal_type == "fibonacci"
    assert result.timeframe == "H4"
    assert 0.0 <= result.strength <= 1.0
    assert 0.0 <= result.confidence <= 1.0
    assert result.direction in (SignalDirection.BULLISH, SignalDirection.BEARISH)


def test_signal_metadata_contains_expected_keys() -> None:
    evaluator = FibonacciEvaluator(min_bars=5)
    bars = [
        _bar(100.0, high=100.0, low=95.0),
        _bar(105.0, high=110.0, low=100.0),
        _bar(95.0, high=100.0, low=90.0),
        _bar(98.0, high=100.0, low=95.0),
        _bar(100.0, high=102.0, low=98.0),
    ]
    result = evaluator.evaluate(bars, "D")
    assert result is not None
    meta = result.metadata
    assert "swing_high" in meta
    assert "swing_low" in meta
    assert "retracement_levels" in meta
    assert "nearest_ratio" in meta
    assert "nearest_level" in meta
    assert "distance_to_nearest" in meta
    assert "current_price" in meta


# ---------------------------------------------------------------------------
# Retracement level formula: L(r) = SH - r * (SH - SL)
# ---------------------------------------------------------------------------


def test_retracement_levels_formula() -> None:
    """Requirement 2.1: L(r) = SH - r * (SH - SL) for all standard ratios."""
    evaluator = FibonacciEvaluator(min_bars=5)
    # SH = 200, SL = 100 → range = 100
    bars = [
        _bar(150.0, high=200.0, low=100.0),  # contains both SH and SL
        _bar(150.0, high=180.0, low=120.0),
        _bar(150.0, high=170.0, low=130.0),
        _bar(150.0, high=160.0, low=140.0),
        _bar(150.0, high=155.0, low=145.0),  # current close = 150
    ]
    result = evaluator.evaluate(bars, "D")
    assert result is not None

    levels = result.metadata["retracement_levels"]
    sh = result.metadata["swing_high"]
    sl = result.metadata["swing_low"]
    assert sh == 200.0
    assert sl == 100.0

    for ratio in RETRACEMENT_RATIOS:
        expected = sh - ratio * (sh - sl)
        assert abs(levels[ratio] - expected) < 1e-10, (
            f"Level for ratio {ratio}: expected {expected}, got {levels[ratio]}"
        )


def test_retracement_ratios_constant() -> None:
    """Verify the RETRACEMENT_RATIOS constant matches the spec."""
    assert RETRACEMENT_RATIOS == [0.236, 0.382, 0.5, 0.618, 0.786]


# ---------------------------------------------------------------------------
# Signal strength — proximity to nearest level
# ---------------------------------------------------------------------------


def test_strength_is_high_when_price_at_level() -> None:
    """When current price is exactly at a retracement level, strength ≈ 1.0."""
    evaluator = FibonacciEvaluator(min_bars=5)
    # SH = 200, SL = 100, range = 100
    # 0.5 level = 200 - 0.5 * 100 = 150
    # Set current close exactly at the 0.5 level
    bars = [
        _bar(150.0, high=200.0, low=100.0),
        _bar(160.0, high=180.0, low=120.0),
        _bar(140.0, high=170.0, low=110.0),
        _bar(155.0, high=165.0, low=130.0),
        _bar(150.0, high=155.0, low=145.0),  # close = 150 = 0.5 level
    ]
    result = evaluator.evaluate(bars, "D")
    assert result is not None
    assert result.strength == 1.0


def test_strength_decreases_with_distance() -> None:
    """Strength should be lower when price is far from any retracement level."""
    evaluator = FibonacciEvaluator(min_bars=5)
    # SH = 200, SL = 100
    # Levels: 176.4, 161.8, 150.0, 138.2, 121.4
    # Price at 110 → nearest is 121.4, distance = 11.4, strength = 1 - 11.4/100 = 0.886
    bars = [
        _bar(150.0, high=200.0, low=100.0),
        _bar(160.0, high=180.0, low=120.0),
        _bar(140.0, high=170.0, low=110.0),
        _bar(130.0, high=165.0, low=105.0),
        _bar(110.0, high=115.0, low=105.0),  # close = 110
    ]
    result = evaluator.evaluate(bars, "D")
    assert result is not None
    assert result.strength < 1.0
    assert result.strength > 0.0


# ---------------------------------------------------------------------------
# Direction logic
# ---------------------------------------------------------------------------


def test_direction_bullish_when_above_swing_low() -> None:
    """Price above SL → BULLISH (potential bounce)."""
    evaluator = FibonacciEvaluator(min_bars=5)
    # SL = 100, current close = 150 (above SL)
    bars = [
        _bar(150.0, high=200.0, low=100.0),
        _bar(160.0, high=180.0, low=120.0),
        _bar(140.0, high=170.0, low=110.0),
        _bar(155.0, high=165.0, low=130.0),
        _bar(150.0, high=155.0, low=145.0),
    ]
    result = evaluator.evaluate(bars, "D")
    assert result is not None
    assert result.direction == SignalDirection.BULLISH


def test_direction_bearish_when_below_swing_low() -> None:
    """Price below SL → BEARISH."""
    evaluator = FibonacciEvaluator(min_bars=3)
    # SH = 200 (bar 0 high), SL = 100 (bar 1 low)
    # Current close = 95 (below SL of 100)
    # The last bar's low must not be lower than SL, otherwise SL shifts down
    bars = [
        _bar(150.0, high=200.0, low=110.0),
        _bar(120.0, high=150.0, low=100.0),
        _bar(95.0, high=100.0, low=100.0),  # close = 95 < SL=100, but low stays at 100
    ]
    result = evaluator.evaluate(bars, "D")
    assert result is not None
    assert result.direction == SignalDirection.BEARISH


# ---------------------------------------------------------------------------
# Confidence — key ratios boost confidence
# ---------------------------------------------------------------------------


def test_confidence_higher_at_key_ratio() -> None:
    """Confidence should be boosted when nearest level is 0.5 or 0.618."""
    evaluator = FibonacciEvaluator(min_bars=5)
    # SH = 200, SL = 100
    # 0.5 level = 150, 0.618 level = 138.2
    # Price at 150 → nearest is 0.5 (key ratio)
    bars_at_key = [
        _bar(150.0, high=200.0, low=100.0),
        _bar(160.0, high=180.0, low=120.0),
        _bar(140.0, high=170.0, low=110.0),
        _bar(155.0, high=165.0, low=130.0),
        _bar(150.0, high=155.0, low=145.0),  # at 0.5 level
    ]
    result_key = evaluator.evaluate(bars_at_key, "D")

    # Price at 176.4 → nearest is 0.236 (non-key ratio)
    bars_at_nonkey = [
        _bar(150.0, high=200.0, low=100.0),
        _bar(160.0, high=180.0, low=120.0),
        _bar(170.0, high=175.0, low=165.0),
        _bar(175.0, high=178.0, low=172.0),
        _bar(176.4, high=178.0, low=174.0),  # at 0.236 level
    ]
    result_nonkey = evaluator.evaluate(bars_at_nonkey, "D")

    assert result_key is not None
    assert result_nonkey is not None
    # Both at their respective levels (distance ≈ 0), but key ratio gets higher confidence
    assert result_key.confidence > result_nonkey.confidence


# ---------------------------------------------------------------------------
# Configurable min_bars
# ---------------------------------------------------------------------------


def test_custom_min_bars() -> None:
    """The lookback is configurable via constructor."""
    evaluator = FibonacciEvaluator(min_bars=3)
    bars = [
        _bar(100.0, high=120.0, low=80.0),
        _bar(110.0, high=115.0, low=90.0),
        _bar(105.0, high=110.0, low=95.0),
    ]
    result = evaluator.evaluate(bars, "M30")
    assert result is not None


def test_default_min_bars_value() -> None:
    assert DEFAULT_MIN_BARS == 20
