# Feature: dual-pipeline-signal-engine, Property: Hard filter determinism
"""Property-based tests for the Hard Filter Engine.

Feature: dual-pipeline-signal-engine

Tests the hard filter determinism property from the design specification:
certain input conditions SHALL always produce a filtered (SKIP) result
regardless of all other field values in the NormalizedInput.

Requirements: 4.1, 4.2, 4.3, 17.7
"""
from __future__ import annotations

from datetime import datetime, timezone

from hypothesis import given, settings
from hypothesis import strategies as st

from services.signal_engine.config import HardFilterConfig
from services.signal_engine.hard_filter import HardFilterResult, evaluate_hard_filters
from services.signal_engine.models import NormalizedInput, OHLCVBar, OpenPositionState

# ---------------------------------------------------------------------------
# Property: Hard Filter Determinism
# Validates: Requirements 4.1, 4.2, 4.3, 17.7
# ---------------------------------------------------------------------------

# ---------------------------------------------------------------------------
# Hypothesis strategies — building blocks
# ---------------------------------------------------------------------------

_finite_float = st.floats(allow_nan=False, allow_infinity=False)

_unit_float = st.floats(
    min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False,
)

_positive_float = st.floats(
    min_value=0.01, max_value=1e6, allow_nan=False, allow_infinity=False,
)

_aware_datetime = st.datetimes(
    min_value=datetime(2020, 1, 1),
    max_value=datetime(2030, 12, 31),
    timezones=st.just(timezone.utc),
)

_ticker = st.text(
    alphabet=st.characters(whitelist_categories=("Lu",)),
    min_size=1,
    max_size=5,
)

# --- OHLCVBar strategy ---

_ohlcv_bar = st.builds(
    OHLCVBar,
    timestamp=_aware_datetime,
    open=_positive_float,
    high=_positive_float,
    low=_positive_float,
    close=_positive_float,
    volume=_positive_float,
)

# --- Bars dict strategy (0-3 bars per timeframe, 0-2 timeframes) ---

_bars_strategy = st.fixed_dictionaries(
    {},
    optional={
        tf: st.lists(_ohlcv_bar, min_size=0, max_size=3)
        for tf in ["M30", "H1", "H4", "D", "W", "M"]
    },
)

# --- OpenPositionState strategy ---

_open_position = st.builds(
    OpenPositionState,
    position_id=st.uuids().map(str),
    ticker=_ticker,
    entry_price=_positive_float,
    current_price=_positive_float,
    stop_loss=_positive_float,
    target_1=_positive_float,
    target_2=_positive_float,
    trailing_stop=st.one_of(st.none(), _positive_float),
    partial_exit_done=st.booleans(),
    atr=st.one_of(st.none(), _positive_float),
)

# --- Base NormalizedInput strategy (all fields arbitrary) ---


def _normalized_input_strategy(
    *,
    macro_bias: st.SearchStrategy[float] | None = None,
    valuation_score: st.SearchStrategy[float | None] | None = None,
    earnings_proximity_days: st.SearchStrategy[int | None] | None = None,
) -> st.SearchStrategy[NormalizedInput]:
    """Build a NormalizedInput strategy with optional field overrides.

    Fields not overridden are generated with full arbitrary ranges so that
    the property tests prove the filter holds *regardless* of other values.
    """
    return st.builds(
        NormalizedInput,
        ticker=_ticker,
        evaluated_at=_aware_datetime,
        bars=_bars_strategy,
        valuation_score=(
            valuation_score
            if valuation_score is not None
            else st.one_of(st.none(), _unit_float)
        ),
        earnings_proximity_days=(
            earnings_proximity_days
            if earnings_proximity_days is not None
            else st.one_of(st.none(), st.integers(min_value=0, max_value=365))
        ),
        macro_bias=(
            macro_bias
            if macro_bias is not None
            else st.floats(min_value=-1.0, max_value=1.0, allow_nan=False)
        ),
        open_positions=st.lists(_open_position, min_size=0, max_size=2),
        closing_prices=st.lists(_positive_float, min_size=0, max_size=5),
        returns=st.lists(_finite_float, min_size=0, max_size=5),
        current_price=st.one_of(st.none(), _positive_float),
    )


# Default config matches the production defaults
_default_config = HardFilterConfig()


# ---------------------------------------------------------------------------
# Property tests
# ---------------------------------------------------------------------------


@given(normalized=_normalized_input_strategy(macro_bias=st.just(-1.0)))
@settings(max_examples=100)
def test_macro_bias_negative_always_filters(normalized: NormalizedInput) -> None:
    """**Validates: Requirements 4.1, 17.7**

    For any NormalizedInput where macro_bias == -1.0, the hard filter
    SHALL always produce filtered=True with "macro_bias_negative" in
    reasons, regardless of all other field values.
    """
    result: HardFilterResult = evaluate_hard_filters(normalized, _default_config)

    assert result.filtered is True, (
        f"Expected filtered=True for macro_bias=-1.0 but got filtered=False.\n"
        f"  ticker={normalized.ticker}, valuation_score={normalized.valuation_score}, "
        f"earnings_proximity_days={normalized.earnings_proximity_days}"
    )
    assert "macro_bias_negative" in result.reasons, (
        f"Expected 'macro_bias_negative' in reasons but got {result.reasons}.\n"
        f"  ticker={normalized.ticker}, macro_bias={normalized.macro_bias}"
    )


@given(
    normalized=_normalized_input_strategy(
        valuation_score=st.floats(
            min_value=0.0,
            max_value=0.3,
            exclude_max=True,
            allow_nan=False,
        ),
    )
)
@settings(max_examples=100)
def test_valuation_below_threshold_always_filters(normalized: NormalizedInput) -> None:
    """**Validates: Requirements 4.2, 17.7**

    For any NormalizedInput where valuation_score is not None and < 0.3,
    the hard filter SHALL always produce filtered=True with
    "valuation_below_threshold" in reasons, regardless of all other
    field values.
    """
    result: HardFilterResult = evaluate_hard_filters(normalized, _default_config)

    assert result.filtered is True, (
        f"Expected filtered=True for valuation_score={normalized.valuation_score} "
        f"but got filtered=False.\n"
        f"  ticker={normalized.ticker}, macro_bias={normalized.macro_bias}, "
        f"earnings_proximity_days={normalized.earnings_proximity_days}"
    )
    assert "valuation_below_threshold" in result.reasons, (
        f"Expected 'valuation_below_threshold' in reasons but got {result.reasons}.\n"
        f"  ticker={normalized.ticker}, valuation_score={normalized.valuation_score}"
    )


@given(
    normalized=_normalized_input_strategy(
        earnings_proximity_days=st.integers(min_value=0, max_value=5),
    )
)
@settings(max_examples=100)
def test_earnings_proximity_always_filters(normalized: NormalizedInput) -> None:
    """**Validates: Requirements 4.3, 17.7**

    For any NormalizedInput where earnings_proximity_days is not None
    and <= 5, the hard filter SHALL always produce filtered=True with
    "earnings_block" in reasons, regardless of all other field values.
    """
    result: HardFilterResult = evaluate_hard_filters(normalized, _default_config)

    assert result.filtered is True, (
        f"Expected filtered=True for earnings_proximity_days="
        f"{normalized.earnings_proximity_days} but got filtered=False.\n"
        f"  ticker={normalized.ticker}, macro_bias={normalized.macro_bias}, "
        f"valuation_score={normalized.valuation_score}"
    )
    assert "earnings_block" in result.reasons, (
        f"Expected 'earnings_block' in reasons but got {result.reasons}.\n"
        f"  ticker={normalized.ticker}, "
        f"earnings_proximity_days={normalized.earnings_proximity_days}"
    )
