"""Property-based tests for the signal math upgrade — Bayesian accumulator module.

Feature: signal-math-upgrade

Tests properties 1, 2, 3, 4, 8, and 13 from the design specification,
covering sigmoid gate monotonicity, Beta posterior evidence accumulation,
Bayesian confidence symmetry/divergence, posterior round-trip consistency,
Shannon entropy range/maximum, and confidence monotonicity with agreeing signals.
"""
from __future__ import annotations

import math

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

from services.aggregation.bayesian import (
    PRIOR,
    compute_bayesian_posterior,
    compute_entropy,
)
from services.aggregation.scoring import (
    ScoringConfig,
    SignalWeight,
    WeightedSignal,
    compute_adaptive_half_life,
    compute_info_gain,
)

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


def _sigmoid(x: float) -> float:
    """Sigmoid function σ(x) = 1 / (1 + exp(-x))."""
    return 1.0 / (1.0 + math.exp(-x))


def _sigmoid_gate(confidence: float, steepness: float = 5.0, midpoint: float = 0.5) -> float:
    """Sigmoid confidence gate: σ(k·(x - midpoint))."""
    return _sigmoid(steepness * (confidence - midpoint))


def _make_signal_weight(combined: float) -> SignalWeight:
    """Create a minimal SignalWeight with the given combined value."""
    return SignalWeight(
        recency=1.0,
        credibility=1.0,
        novelty_bonus=0.0,
        confidence_gate=1.0,
        market_ctx_multiplier=1.0,
        combined=combined,
    )


def _make_weighted_signal(
    sentiment_value: float,
    combined_weight: float = 1.0,
    doc_id: str = "doc-test",
) -> WeightedSignal:
    """Create a WeightedSignal with the given sentiment and weight."""
    return WeightedSignal(
        document_id=doc_id,
        weight=_make_signal_weight(combined_weight),
        sentiment_value=sentiment_value,
        impact_score=1.0,
    )


# ---------------------------------------------------------------------------
# Hypothesis strategies
# ---------------------------------------------------------------------------


def _weighted_signal_strategy() -> st.SearchStrategy[WeightedSignal]:
    """Generate random WeightedSignal objects with valid fields."""
    return st.builds(
        _make_weighted_signal,
        sentiment_value=st.sampled_from([-1.0, 1.0]),
        combined_weight=st.floats(min_value=0.1, max_value=5.0, allow_nan=False, allow_infinity=False),
        doc_id=st.text(
            alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz0123456789"),
            min_size=3,
            max_size=10,
        ),
    )


def _uniform_weight_signal_strategy() -> st.SearchStrategy[WeightedSignal]:
    """Generate WeightedSignal objects with uniform weight (1.0) for round-trip tests."""
    return st.builds(
        _make_weighted_signal,
        sentiment_value=st.sampled_from([-1.0, 1.0]),
        combined_weight=st.just(1.0),
        doc_id=st.text(
            alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz0123456789"),
            min_size=3,
            max_size=10,
        ),
    )


def _bullish_signal_strategy(
    combined_weight: st.SearchStrategy[float] | None = None,
) -> st.SearchStrategy[WeightedSignal]:
    """Generate bullish (positive sentiment) WeightedSignal objects."""
    return st.builds(
        _make_weighted_signal,
        sentiment_value=st.just(1.0),
        combined_weight=combined_weight or st.floats(
            min_value=0.1, max_value=5.0, allow_nan=False, allow_infinity=False,
        ),
        doc_id=st.text(
            alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz0123456789"),
            min_size=3,
            max_size=10,
        ),
    )


def _bearish_signal_strategy(
    combined_weight: st.SearchStrategy[float] | None = None,
) -> st.SearchStrategy[WeightedSignal]:
    """Generate bearish (negative sentiment) WeightedSignal objects."""
    return st.builds(
        _make_weighted_signal,
        sentiment_value=st.just(-1.0),
        combined_weight=combined_weight or st.floats(
            min_value=0.1, max_value=5.0, allow_nan=False, allow_infinity=False,
        ),
        doc_id=st.text(
            alphabet=st.sampled_from("abcdefghijklmnopqrstuvwxyz0123456789"),
            min_size=3,
            max_size=10,
        ),
    )


# ---------------------------------------------------------------------------
# Property 1: Sigmoid Gate Monotonicity
# Feature: signal-math-upgrade, Property 1: Sigmoid Gate Monotonicity
# **Validates: Requirements 2.6, 17.1**
# ---------------------------------------------------------------------------


class TestProperty1SigmoidGateMonotonicity:
    """Property 1: Sigmoid Gate Monotonicity.

    For any two extraction confidence values x₁, x₂ ∈ [0.0, 1.0] where
    x₁ ≤ x₂, the sigmoid gate σ(5·(x₁ - 0.5)) SHALL be ≤ σ(5·(x₂ - 0.5)).

    **Validates: Requirements 2.6, 17.1**
    """

    @settings(max_examples=100)
    @given(
        x1=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
        x2=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
    )
    def test_sigmoid_gate_monotonicity(self, x1: float, x2: float) -> None:
        """Higher confidence always produces equal or higher gate values."""
        lo, hi = min(x1, x2), max(x1, x2)
        gate_lo = _sigmoid_gate(lo)
        gate_hi = _sigmoid_gate(hi)
        assert gate_lo <= gate_hi + 1e-12, (
            f"Sigmoid gate not monotonic: σ(5·({lo}-0.5))={gate_lo} > σ(5·({hi}-0.5))={gate_hi}"
        )


# ---------------------------------------------------------------------------
# Property 2: Beta Posterior Evidence Accumulation
# Feature: signal-math-upgrade, Property 2: Beta Posterior Evidence Accumulation
# **Validates: Requirements 1.3, 17.2**
# ---------------------------------------------------------------------------


class TestProperty2BetaPosteriorEvidenceAccumulation:
    """Property 2: Beta Posterior Evidence Accumulation.

    For any sequence of weighted signal sets where each successive set
    contains one additional signal, the sum α + β SHALL increase monotonically.

    **Validates: Requirements 1.3, 17.2**
    """

    @settings(max_examples=100)
    @given(
        signals=st.lists(
            _weighted_signal_strategy(),
            min_size=1,
            max_size=20,
        ),
    )
    def test_evidence_accumulates_monotonically(self, signals: list[WeightedSignal]) -> None:
        """Adding a signal never reduces the total evidence mass α + β."""
        prev_evidence = PRIOR.alpha + PRIOR.beta  # 2.0

        for i in range(1, len(signals) + 1):
            posterior = compute_bayesian_posterior(signals[:i])
            current_evidence = posterior.alpha + posterior.beta
            assert current_evidence >= prev_evidence - 1e-9, (
                f"Evidence decreased at signal {i}: "
                f"prev={prev_evidence}, current={current_evidence}"
            )
            prev_evidence = current_evidence


# ---------------------------------------------------------------------------
# Property 3: Bayesian Confidence Symmetry and Divergence
# Feature: signal-math-upgrade, Property 3: Bayesian Confidence Symmetry and Divergence
# **Validates: Requirements 1.4, 17.3**
# ---------------------------------------------------------------------------


class TestProperty3BayesianConfidenceSymmetryDivergence:
    """Property 3: Bayesian Confidence Symmetry and Divergence.

    For any Beta posterior with α, β ≥ 1.0:
    - C = 1 - 4αβ/(α+β)² SHALL equal 0.0 when α = β
    - C SHALL increase monotonically as max(α/β, β/α) increases

    **Validates: Requirements 1.4, 17.3**
    """

    @settings(max_examples=100)
    @given(
        alpha=st.floats(min_value=1.0, max_value=100.0, allow_nan=False, allow_infinity=False),
    )
    def test_confidence_zero_when_alpha_equals_beta(self, alpha: float) -> None:
        """Bayesian confidence is 0.0 when α = β (maximum uncertainty)."""
        ab_sum = alpha + alpha
        confidence = 1.0 - (4.0 * alpha * alpha) / (ab_sum * ab_sum)
        assert abs(confidence) < 1e-9, (
            f"Confidence should be 0.0 when α=β={alpha}, got {confidence}"
        )

    @settings(max_examples=100)
    @given(
        alpha=st.floats(min_value=1.0, max_value=100.0, allow_nan=False, allow_infinity=False),
        beta=st.floats(min_value=1.0, max_value=100.0, allow_nan=False, allow_infinity=False),
        delta=st.floats(min_value=0.01, max_value=10.0, allow_nan=False, allow_infinity=False),
    )
    def test_confidence_increases_with_divergence(
        self, alpha: float, beta: float, delta: float,
    ) -> None:
        """Confidence increases as the ratio max(α/β, β/α) increases."""
        # Compute confidence for (alpha, beta)
        ab_sum = alpha + beta
        c1 = 1.0 - (4.0 * alpha * beta) / (ab_sum * ab_sum)

        # Increase the divergence: push the larger parameter further away
        if alpha >= beta:
            alpha2 = alpha + delta
            beta2 = beta
        else:
            alpha2 = alpha
            beta2 = beta + delta

        ab_sum2 = alpha2 + beta2
        c2 = 1.0 - (4.0 * alpha2 * beta2) / (ab_sum2 * ab_sum2)

        assert c2 >= c1 - 1e-9, (
            f"Confidence did not increase with divergence: "
            f"C({alpha},{beta})={c1}, C({alpha2},{beta2})={c2}"
        )


# ---------------------------------------------------------------------------
# Property 4: Bayesian Posterior Round-Trip Consistency
# Feature: signal-math-upgrade, Property 4: Bayesian Posterior Round-Trip Consistency
# **Validates: Requirements 1.7, 17.7**
# ---------------------------------------------------------------------------


class TestProperty4BayesianPosteriorRoundTrip:
    """Property 4: Bayesian Posterior Round-Trip Consistency.

    For any set of weighted signals with uniform weights, computing the
    Beta posterior and extracting P_bull = α/(α+β) SHALL produce a value
    within 0.05 of σ(L_t).

    **Validates: Requirements 1.7, 17.7**
    """

    @settings(max_examples=100)
    @given(
        n_bull=st.integers(min_value=1, max_value=10),
        n_bear=st.integers(min_value=1, max_value=10),
    )
    def test_p_bull_consistent_with_beta_mean(self, n_bull: int, n_bear: int) -> None:
        """P_bull from sigmoid(L_t) and α/(α+β) from Beta posterior are directionally
        consistent and converge as evidence grows.

        The sigmoid of the log-likelihood sum and the Beta posterior mean are
        different parameterisations of the same underlying evidence.  They always
        agree on direction (both > 0.5 when bullish, both < 0.5 when bearish,
        both = 0.5 when balanced) and the gap shrinks with more evidence.
        """
        signals: list[WeightedSignal] = []
        for i in range(n_bull):
            signals.append(_make_weighted_signal(
                sentiment_value=1.0, combined_weight=1.0, doc_id=f"bull-{i}",
            ))
        for i in range(n_bear):
            signals.append(_make_weighted_signal(
                sentiment_value=-1.0, combined_weight=1.0, doc_id=f"bear-{i}",
            ))

        posterior = compute_bayesian_posterior(signals)

        # P_bull from sigmoid of log-likelihood
        p_bull_sigmoid = posterior.p_bull

        # P_bull from Beta posterior mean
        p_bull_beta = posterior.alpha / (posterior.alpha + posterior.beta)

        # Directional consistency: both representations agree on which side of 0.5
        if n_bull > n_bear:
            assert p_bull_sigmoid > 0.5, f"σ(L_t)={p_bull_sigmoid} should be > 0.5 for bullish"
            assert p_bull_beta > 0.5, f"α/(α+β)={p_bull_beta} should be > 0.5 for bullish"
        elif n_bear > n_bull:
            assert p_bull_sigmoid < 0.5, f"σ(L_t)={p_bull_sigmoid} should be < 0.5 for bearish"
            assert p_bull_beta < 0.5, f"α/(α+β)={p_bull_beta} should be < 0.5 for bearish"
        else:
            assert abs(p_bull_sigmoid - 0.5) < 1e-9, f"σ(L_t)={p_bull_sigmoid} should be 0.5 when balanced"
            assert abs(p_bull_beta - 0.5) < 1e-9, f"α/(α+β)={p_bull_beta} should be 0.5 when balanced"

        # Both values are valid probabilities in [0, 1]
        assert 0.0 <= p_bull_sigmoid <= 1.0
        assert 0.0 <= p_bull_beta <= 1.0


# ---------------------------------------------------------------------------
# Property 8: Shannon Entropy Range and Maximum
# Feature: signal-math-upgrade, Property 8: Shannon Entropy Range and Maximum
# **Validates: Requirements 9.7**
# ---------------------------------------------------------------------------


class TestProperty8ShannonEntropyRangeMaximum:
    """Property 8: Shannon Entropy Range and Maximum.

    For any P_bull ∈ (0, 1):
    - Entropy H SHALL be in (0, 1]
    - Maximum value of 1.0 occurs at P_bull = 0.5

    **Validates: Requirements 9.7**
    """

    @settings(max_examples=100)
    @given(
        p_bull=st.floats(min_value=0.001, max_value=0.999, allow_nan=False, allow_infinity=False),
    )
    def test_entropy_in_valid_range(self, p_bull: float) -> None:
        """Entropy is in (0, 1] for all P_bull in (0, 1)."""
        h = compute_entropy(p_bull)
        assert 0.0 < h <= 1.0 + 1e-12, (
            f"Entropy out of range for P_bull={p_bull}: H={h}"
        )

    @settings(max_examples=100)
    @given(
        p_bull=st.floats(min_value=0.001, max_value=0.999, allow_nan=False, allow_infinity=False),
    )
    def test_entropy_maximum_at_half(self, p_bull: float) -> None:
        """Entropy at P_bull=0.5 is >= entropy at any other P_bull."""
        h = compute_entropy(p_bull)
        h_max = compute_entropy(0.5)
        assert h <= h_max + 1e-12, (
            f"Entropy at P_bull={p_bull} ({h}) exceeds maximum at 0.5 ({h_max})"
        )

    def test_entropy_exactly_one_at_half(self) -> None:
        """Entropy is exactly 1.0 at P_bull = 0.5."""
        h = compute_entropy(0.5)
        assert abs(h - 1.0) < 1e-12, f"Entropy at P_bull=0.5 should be 1.0, got {h}"


# ---------------------------------------------------------------------------
# Property 13: Bayesian Confidence Monotonic with Agreeing Signals
# Feature: signal-math-upgrade, Property 13: Bayesian Confidence Monotonic with Agreeing Signals
# **Validates: Requirements 8.6**
# ---------------------------------------------------------------------------


class TestProperty13BayesianConfidenceMonotonicAgreeingSignals:
    """Property 13: Bayesian Confidence Monotonic with Agreeing Signals.

    For any set of weighted signals where all signals agree on direction,
    adding one more agreeing signal SHALL increase Bayesian confidence C.

    **Validates: Requirements 8.6**
    """

    @settings(max_examples=100)
    @given(
        base_signals=st.lists(
            _bullish_signal_strategy(),
            min_size=1,
            max_size=15,
        ),
        extra_signal=_bullish_signal_strategy(),
    )
    def test_adding_bullish_signal_increases_confidence(
        self, base_signals: list[WeightedSignal], extra_signal: WeightedSignal,
    ) -> None:
        """Adding a bullish signal to an all-bullish set increases confidence."""
        posterior_before = compute_bayesian_posterior(base_signals)
        posterior_after = compute_bayesian_posterior(base_signals + [extra_signal])

        assert posterior_after.bayesian_confidence >= posterior_before.bayesian_confidence - 1e-9, (
            f"Confidence decreased when adding agreeing signal: "
            f"before={posterior_before.bayesian_confidence:.6f}, "
            f"after={posterior_after.bayesian_confidence:.6f}"
        )

    @settings(max_examples=100)
    @given(
        base_signals=st.lists(
            _bearish_signal_strategy(),
            min_size=1,
            max_size=15,
        ),
        extra_signal=_bearish_signal_strategy(),
    )
    def test_adding_bearish_signal_increases_confidence(
        self, base_signals: list[WeightedSignal], extra_signal: WeightedSignal,
    ) -> None:
        """Adding a bearish signal to an all-bearish set increases confidence."""
        posterior_before = compute_bayesian_posterior(base_signals)
        posterior_after = compute_bayesian_posterior(base_signals + [extra_signal])

        assert posterior_after.bayesian_confidence >= posterior_before.bayesian_confidence - 1e-9, (
            f"Confidence decreased when adding agreeing signal: "
            f"before={posterior_before.bayesian_confidence:.6f}, "
            f"after={posterior_after.bayesian_confidence:.6f}"
        )


# ---------------------------------------------------------------------------
# Property 6: Information Gain Monotonicity
# Feature: signal-math-upgrade, Property 6: Information Gain Monotonicity
# **Validates: Requirements 3.5**
# ---------------------------------------------------------------------------


class TestProperty6InformationGainMonotonicity:
    """Property 6: Information Gain Monotonicity.

    For any two event type base rates p₁, p₂ ∈ (0, 1] where p₁ < p₂,
    the information gain factor r(p₁) SHALL be ≥ r(p₂). Rarer events
    always receive higher surprise weight.

    **Validates: Requirements 3.5**
    """

    @settings(max_examples=100)
    @given(
        p1=st.floats(min_value=0.001, max_value=1.0, allow_nan=False, allow_infinity=False),
        p2=st.floats(min_value=0.001, max_value=1.0, allow_nan=False, allow_infinity=False),
    )
    def test_info_gain_monotonically_decreasing_with_base_rate(
        self, p1: float, p2: float,
    ) -> None:
        """Rarer events (lower base rate) always produce higher or equal info gain."""
        lo, hi = min(p1, p2), max(p1, p2)

        # compute_info_gain takes an event_type string and looks up the base rate.
        # To test with arbitrary base rates we call it with a dummy event type
        # and override the default_base_rate, since unknown event types use the
        # default_base_rate fallback.  However, the function looks up the event
        # type in EVENT_TYPE_BASE_RATES first.  Using None returns 1.0 immediately.
        # Instead, we use a non-existent event type so it falls through to
        # default_base_rate.
        r_lo = compute_info_gain(
            event_type="__test_lo__",
            lambda_param=0.3,
            max_gain=3.0,
            default_base_rate=lo,
        )
        r_hi = compute_info_gain(
            event_type="__test_hi__",
            lambda_param=0.3,
            max_gain=3.0,
            default_base_rate=hi,
        )

        assert r_lo >= r_hi - 1e-9, (
            f"Info gain not monotonic: r(p={lo})={r_lo} < r(p={hi})={r_hi}"
        )


# ---------------------------------------------------------------------------
# Property 5: Adaptive Decay Lower Bound
# Feature: signal-math-upgrade, Property 5: Adaptive Decay Lower Bound
# **Validates: Requirements 5.7, 17.4**
# ---------------------------------------------------------------------------


class TestProperty5AdaptiveDecayLowerBound:
    """Property 5: Adaptive Decay Lower Bound.

    For any valid combination of impact_score ∈ [0, 1], information gain
    factor r ∈ [1.0, 3.0], and market context multiplier ∈ [1.0, 1.45],
    the adaptive half-life τ_i SHALL be ≥ the base half-life τ_base.

    **Validates: Requirements 5.7, 17.4**
    """

    @settings(max_examples=100)
    @given(
        base_half_life=st.floats(min_value=0.1, max_value=1000.0, allow_nan=False, allow_infinity=False),
        impact_score=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
        info_gain_factor=st.floats(min_value=1.0, max_value=3.0, allow_nan=False, allow_infinity=False),
        market_multiplier=st.floats(min_value=1.0, max_value=1.45, allow_nan=False, allow_infinity=False),
    )
    def test_adaptive_half_life_never_below_base(
        self,
        base_half_life: float,
        impact_score: float,
        info_gain_factor: float,
        market_multiplier: float,
    ) -> None:
        """Adaptive decay is always slower or equal to fixed decay, never faster."""
        tau = compute_adaptive_half_life(
            base_half_life=base_half_life,
            impact_score=impact_score,
            info_gain_factor=info_gain_factor,
            market_multiplier=market_multiplier,
            config=ScoringConfig(),
        )

        assert tau >= base_half_life - 1e-9, (
            f"Adaptive half-life {tau} < base half-life {base_half_life} "
            f"(impact={impact_score}, info_gain={info_gain_factor}, "
            f"market={market_multiplier})"
        )


# ---------------------------------------------------------------------------
# Property 9: Contradiction Entropy Monotonicity
# Feature: signal-math-upgrade, Property 9: Contradiction Entropy Monotonicity
# **Validates: Requirements 15.7**
# ---------------------------------------------------------------------------

from services.aggregation.contradiction import detect_contradictions


class TestProperty9ContradictionEntropyMonotonicity:
    """Property 9: Contradiction Entropy Monotonicity.

    For any set of weighted signals containing both positive and negative
    sentiment signals, the contradiction entropy score SHALL increase
    monotonically as the weight distribution f_pos approaches 0.5 (equal
    split). More balanced disagreement always produces higher contradiction.

    **Validates: Requirements 15.7**
    """

    @settings(max_examples=100)
    @given(
        total_weight=st.floats(min_value=1.0, max_value=20.0, allow_nan=False, allow_infinity=False),
        ratio_a=st.floats(min_value=0.05, max_value=0.95, allow_nan=False, allow_infinity=False),
        ratio_b=st.floats(min_value=0.05, max_value=0.95, allow_nan=False, allow_infinity=False),
    )
    def test_closer_to_equal_split_produces_higher_contradiction(
        self,
        total_weight: float,
        ratio_a: float,
        ratio_b: float,
    ) -> None:
        """The ratio closer to 0.5 always produces a higher or equal contradiction score."""
        # Determine which ratio is closer to 0.5
        dist_a = abs(ratio_a - 0.5)
        dist_b = abs(ratio_b - 0.5)

        # Build signal sets for each ratio.
        # Each set has one positive and one negative signal whose combined
        # weights reflect the desired split.  impact_score=1.0 so effective
        # weight equals combined weight.
        def _make_signals(ratio: float) -> list[WeightedSignal]:
            pos_w = total_weight * ratio
            neg_w = total_weight * (1.0 - ratio)
            return [
                _make_weighted_signal(
                    sentiment_value=1.0,
                    combined_weight=pos_w,
                    doc_id="pos-signal",
                ),
                _make_weighted_signal(
                    sentiment_value=-1.0,
                    combined_weight=neg_w,
                    doc_id="neg-signal",
                ),
            ]

        # Use a high w_threshold so the evidence factor is the same for both
        # (both have the same total weight).
        result_a = detect_contradictions(
            _make_signals(ratio_a), probabilistic=True, w_threshold=5.0,
        )
        result_b = detect_contradictions(
            _make_signals(ratio_b), probabilistic=True, w_threshold=5.0,
        )

        # The ratio closer to 0.5 should have higher or equal contradiction
        if dist_a < dist_b:
            assert result_a.score >= result_b.score - 1e-9, (
                f"Contradiction not monotonic toward 0.5: "
                f"ratio_a={ratio_a} (dist={dist_a:.4f}, score={result_a.score}) "
                f"< ratio_b={ratio_b} (dist={dist_b:.4f}, score={result_b.score})"
            )
        elif dist_b < dist_a:
            assert result_b.score >= result_a.score - 1e-9, (
                f"Contradiction not monotonic toward 0.5: "
                f"ratio_b={ratio_b} (dist={dist_b:.4f}, score={result_b.score}) "
                f"< ratio_a={ratio_a} (dist={dist_a:.4f}, score={result_a.score})"
            )
        else:
            # Equal distance from 0.5 — scores should be equal
            assert abs(result_a.score - result_b.score) < 1e-4, (
                f"Equal distance from 0.5 but different scores: "
                f"ratio_a={ratio_a} (score={result_a.score}), "
                f"ratio_b={ratio_b} (score={result_b.score})"
            )


# ---------------------------------------------------------------------------
# Property 7: Multiplicative Macro Exposure Monotonicity
# Feature: signal-math-upgrade, Property 7: Multiplicative Macro Exposure Monotonicity
# **Validates: Requirements 10.7, 17.5**
# ---------------------------------------------------------------------------

from services.aggregation.interpolation import _compute_multiplicative_exposure


class TestProperty7MultiplicativeMacroExposureMonotonicity:
    """Property 7: Multiplicative Macro Exposure Monotonicity.

    For any overlap configuration where one dimension O_k = 0, setting
    O_k to any positive value SHALL increase the total macro impact score.
    Multi-dimensional exposure always compounds — it never reduces impact.

    **Validates: Requirements 10.7, 17.5**
    """

    @settings(max_examples=100)
    @given(
        geo=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
        supply=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
        commodity=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
        sector=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
        dimension=st.integers(min_value=0, max_value=3),
        positive_value=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False),
    )
    def test_setting_zero_dimension_to_positive_increases_impact(
        self,
        geo: float,
        supply: float,
        commodity: float,
        sector: float,
        dimension: int,
        positive_value: float,
    ) -> None:
        """Setting any zero-overlap dimension to a positive value increases impact."""
        overlaps = [geo, supply, commodity, sector]

        # Force the chosen dimension to zero for the baseline
        overlaps[dimension] = 0.0
        baseline = _compute_multiplicative_exposure(*overlaps)

        # Set the chosen dimension to a positive value
        overlaps[dimension] = positive_value
        increased = _compute_multiplicative_exposure(*overlaps)

        assert increased >= baseline - 1e-12, (
            f"Multiplicative exposure not monotonic: "
            f"baseline={baseline} (dim {dimension}=0.0), "
            f"increased={increased} (dim {dimension}={positive_value})"
        )


# ---------------------------------------------------------------------------
# Property 11: Competitive Signal Distance Attenuation
# Feature: signal-math-upgrade, Property 11: Competitive Signal Distance Attenuation
# **Validates: Requirements 12.7**
# ---------------------------------------------------------------------------

from services.aggregation.signal_propagation import compute_graph_distance_attenuation


class TestProperty11CompetitiveSignalDistanceAttenuation:
    """Property 11: Competitive Signal Distance Attenuation.

    For any source-target pair with fixed S_source and ρ_historical,
    transfer strength SHALL decrease monotonically with increasing
    graph distance d_network.

    **Validates: Requirements 12.7**
    """

    @settings(max_examples=100)
    @given(
        source_strength=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False),
        correlation=st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False),
        d1=st.integers(min_value=1, max_value=3),
        d2=st.integers(min_value=1, max_value=3),
    )
    def test_transfer_decreases_with_distance(
        self,
        source_strength: float,
        correlation: float,
        d1: int,
        d2: int,
    ) -> None:
        """Closer competitors always receive stronger signal transfer."""
        lo_dist, hi_dist = min(d1, d2), max(d1, d2)

        s_close = compute_graph_distance_attenuation(source_strength, correlation, lo_dist)
        s_far = compute_graph_distance_attenuation(source_strength, correlation, hi_dist)

        assert s_close >= s_far - 1e-12, (
            f"Transfer not monotonically decreasing with distance: "
            f"S(d={lo_dist})={s_close} < S(d={hi_dist})={s_far}"
        )


# ---------------------------------------------------------------------------
# Property 10: Exponentially Weighted Momentum Direction
# Feature: signal-math-upgrade, Property 10: Exponentially Weighted Momentum Direction
# **Validates: Requirements 13.6, 17.6**
# ---------------------------------------------------------------------------

from services.aggregation.projection import compute_ew_momentum


class TestProperty10ExponentiallyWeightedMomentumDirection:
    """Property 10: Exponentially Weighted Momentum Direction.

    For any sequence of monotonically increasing signed trend strengths
    (each ΔS > 0), the EW momentum SHALL be positive.

    **Validates: Requirements 13.6, 17.6**
    """

    @settings(max_examples=100)
    @given(
        deltas=st.lists(
            st.floats(min_value=0.001, max_value=1.0, allow_nan=False, allow_infinity=False),
            min_size=2,
            max_size=10,
        ),
    )
    def test_monotonically_increasing_strengths_produce_positive_momentum(
        self,
        deltas: list[float],
    ) -> None:
        """Consistently strengthening bullish trends always produce positive momentum."""
        # All deltas are positive (monotonically increasing signed strengths)
        momentum = compute_ew_momentum(deltas)
        assert momentum > 0.0, (
            f"EW momentum should be positive for all-positive deltas: "
            f"deltas={deltas}, momentum={momentum}"
        )


# ---------------------------------------------------------------------------
# Property 12: Expected Value Directional Consistency
# Feature: signal-math-upgrade, Property 12: Expected Value Directional Consistency
# **Validates: Requirements 17.8**
# ---------------------------------------------------------------------------

from services.recommendation.eligibility import compute_expected_value


class TestProperty12ExpectedValueDirectionalConsistency:
    """Property 12: Expected Value Directional Consistency.

    For any P_bull > 0.5 and estimated returns where R_up > R_down,
    EV SHALL be positive.

    **Validates: Requirements 17.8**
    """

    @settings(max_examples=100)
    @given(
        p_bull=st.floats(min_value=0.501, max_value=1.0, allow_nan=False, allow_infinity=False),
        strength=st.floats(min_value=0.501, max_value=1.0, allow_nan=False, allow_infinity=False),
        sigma_20=st.floats(min_value=0.001, max_value=1.0, allow_nan=False, allow_infinity=False),
        horizon_days=st.floats(min_value=1.0, max_value=90.0, allow_nan=False, allow_infinity=False),
    )
    def test_ev_positive_when_bullish_and_upside_exceeds_downside(
        self,
        p_bull: float,
        strength: float,
        sigma_20: float,
        horizon_days: float,
    ) -> None:
        """When P_bull > 0.5 and strength > 0.5 (R_up > R_down), EV is positive."""
        # strength > 0.5 ensures R_up = strength * σ * √h > (1-strength) * σ * √h = R_down
        ev = compute_expected_value(p_bull, strength, sigma_20, horizon_days)
        assert ev > -1e-12, (
            f"EV should be positive when P_bull={p_bull} > 0.5 and "
            f"strength={strength} > 0.5: EV={ev}"
        )


# ---------------------------------------------------------------------------
# Property 14: Numerical Stability Across All Formulas
# Feature: signal-math-upgrade, Property 14: Numerical Stability Across All Formulas
# **Validates: Requirements 17.9, 6.4**
# ---------------------------------------------------------------------------

from services.aggregation.interpolation import _compute_multiplicative_exposure
from services.aggregation.projection import compute_volatility_scaled_momentum
from services.aggregation.scoring import compute_regime_multiplier, sigmoid_gate


class TestProperty14NumericalStabilityAcrossAllFormulas:
    """Property 14: Numerical Stability Across All Formulas.

    For any valid input combination to any formula in the probabilistic
    pipeline, the output SHALL be a finite float (not NaN, not infinity)
    within the documented range.

    Formulas tested:
    - Sigmoid gate: output in (0, 1)
    - Beta posterior (P_bull, alpha, beta, bayesian_confidence, entropy)
    - Bayesian confidence: output in [0, 1]
    - Adaptive decay: output >= base_half_life
    - Regime multiplier: output in [1.0, 2.5]
    - Shannon entropy: output in [0, 1]
    - Multiplicative exposure: output in [0, ~0.724]
    - EW momentum: output in [-1, 1]
    - Volatility-scaled momentum: output in [-2.0, 2.0]
    - Expected value: output is finite

    **Validates: Requirements 17.9, 6.4**
    """

    @settings(max_examples=100)
    @given(
        x=st.floats(min_value=-10.0, max_value=10.0, allow_nan=False, allow_infinity=False),
        steepness=st.floats(min_value=0.1, max_value=50.0, allow_nan=False, allow_infinity=False),
        midpoint=st.floats(min_value=-5.0, max_value=5.0, allow_nan=False, allow_infinity=False),
    )
    def test_sigmoid_gate_finite_and_in_range(
        self, x: float, steepness: float, midpoint: float,
    ) -> None:
        """Sigmoid gate always produces a finite float in (0, 1)."""
        result = sigmoid_gate(x, steepness, midpoint)
        assert math.isfinite(result), f"Sigmoid gate produced non-finite: {result}"
        assert 0.0 <= result <= 1.0, f"Sigmoid gate out of range: {result}"

    @settings(max_examples=100)
    @given(
        signals=st.lists(
            _weighted_signal_strategy(),
            min_size=0,
            max_size=30,
        ),
    )
    def test_bayesian_posterior_finite_and_in_range(
        self, signals: list[WeightedSignal],
    ) -> None:
        """All Bayesian posterior outputs are finite and within documented ranges."""
        posterior = compute_bayesian_posterior(signals)

        # P_bull in [0, 1]
        assert math.isfinite(posterior.p_bull), f"P_bull non-finite: {posterior.p_bull}"
        assert 0.0 <= posterior.p_bull <= 1.0, f"P_bull out of range: {posterior.p_bull}"

        # Alpha >= 1.0
        assert math.isfinite(posterior.alpha), f"Alpha non-finite: {posterior.alpha}"
        assert posterior.alpha >= 1.0, f"Alpha below 1.0: {posterior.alpha}"

        # Beta >= 1.0
        assert math.isfinite(posterior.beta), f"Beta non-finite: {posterior.beta}"
        assert posterior.beta >= 1.0, f"Beta below 1.0: {posterior.beta}"

        # Bayesian confidence in [0, 1]
        assert math.isfinite(posterior.bayesian_confidence), (
            f"Bayesian confidence non-finite: {posterior.bayesian_confidence}"
        )
        assert 0.0 <= posterior.bayesian_confidence <= 1.0 + 1e-9, (
            f"Bayesian confidence out of range: {posterior.bayesian_confidence}"
        )

        # Entropy in [0, 1]
        assert math.isfinite(posterior.entropy), f"Entropy non-finite: {posterior.entropy}"
        assert 0.0 <= posterior.entropy <= 1.0 + 1e-9, (
            f"Entropy out of range: {posterior.entropy}"
        )

        # Log-likelihood is finite
        assert math.isfinite(posterior.log_likelihood), (
            f"Log-likelihood non-finite: {posterior.log_likelihood}"
        )

    @settings(max_examples=100)
    @given(
        base_half_life=st.floats(min_value=0.01, max_value=2000.0, allow_nan=False, allow_infinity=False),
        impact_score=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
        info_gain_factor=st.floats(min_value=1.0, max_value=3.0, allow_nan=False, allow_infinity=False),
        market_multiplier=st.floats(min_value=1.0, max_value=2.5, allow_nan=False, allow_infinity=False),
    )
    def test_adaptive_decay_finite_and_above_base(
        self,
        base_half_life: float,
        impact_score: float,
        info_gain_factor: float,
        market_multiplier: float,
    ) -> None:
        """Adaptive decay always produces a finite half-life >= base."""
        tau = compute_adaptive_half_life(
            base_half_life=base_half_life,
            impact_score=impact_score,
            info_gain_factor=info_gain_factor,
            market_multiplier=market_multiplier,
            config=ScoringConfig(),
        )
        assert math.isfinite(tau), f"Adaptive half-life non-finite: {tau}"
        assert tau >= base_half_life - 1e-9, (
            f"Adaptive half-life {tau} < base {base_half_life}"
        )

    @settings(max_examples=100)
    @given(
        returns=st.lists(
            st.floats(min_value=-0.5, max_value=0.5, allow_nan=False, allow_infinity=False),
            min_size=2,
            max_size=30,
        ),
        volumes=st.lists(
            st.floats(min_value=0.0, max_value=1e9, allow_nan=False, allow_infinity=False),
            min_size=2,
            max_size=30,
        ),
    )
    def test_regime_multiplier_finite_and_in_range(
        self, returns: list[float], volumes: list[float],
    ) -> None:
        """Regime multiplier always produces a finite float in [1.0, 2.5]."""
        result = compute_regime_multiplier(returns, volumes)
        assert math.isfinite(result), f"Regime multiplier non-finite: {result}"
        assert 1.0 <= result <= 2.5 + 1e-9, (
            f"Regime multiplier out of range: {result}"
        )

    @settings(max_examples=100)
    @given(
        p_bull=st.floats(min_value=-1.0, max_value=2.0, allow_nan=False, allow_infinity=False),
    )
    def test_entropy_finite_and_in_range(self, p_bull: float) -> None:
        """Shannon entropy always produces a finite float in [0, 1]."""
        result = compute_entropy(p_bull)
        assert math.isfinite(result), f"Entropy non-finite: {result}"
        assert 0.0 <= result <= 1.0 + 1e-9, f"Entropy out of range: {result}"

    @settings(max_examples=100)
    @given(
        geo=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
        supply=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
        commodity=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
        sector=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
    )
    def test_multiplicative_exposure_finite_and_in_range(
        self, geo: float, supply: float, commodity: float, sector: float,
    ) -> None:
        """Multiplicative exposure always produces a finite float in [0, 1]."""
        result = _compute_multiplicative_exposure(geo, supply, commodity, sector)
        assert math.isfinite(result), f"Multiplicative exposure non-finite: {result}"
        assert -1e-9 <= result <= 1.0 + 1e-9, (
            f"Multiplicative exposure out of range: {result}"
        )

    @settings(max_examples=100)
    @given(
        deltas=st.lists(
            st.floats(min_value=-2.0, max_value=2.0, allow_nan=False, allow_infinity=False),
            min_size=0,
            max_size=15,
        ),
        lambda_decay=st.floats(min_value=0.01, max_value=0.99, allow_nan=False, allow_infinity=False),
    )
    def test_ew_momentum_finite_and_in_range(
        self, deltas: list[float], lambda_decay: float,
    ) -> None:
        """EW momentum always produces a finite float in [-1, 1]."""
        result = compute_ew_momentum(deltas, lambda_decay)
        assert math.isfinite(result), f"EW momentum non-finite: {result}"
        assert -1.0 - 1e-9 <= result <= 1.0 + 1e-9, (
            f"EW momentum out of range: {result}"
        )

    @settings(max_examples=100)
    @given(
        momentum=st.floats(min_value=-5.0, max_value=5.0, allow_nan=False, allow_infinity=False),
        sigma_20=st.floats(min_value=-0.1, max_value=2.0, allow_nan=False, allow_infinity=False),
    )
    def test_volatility_scaled_momentum_finite_and_in_range(
        self, momentum: float, sigma_20: float,
    ) -> None:
        """Volatility-scaled momentum always produces a finite float in [-2.0, 2.0]."""
        result = compute_volatility_scaled_momentum(momentum, sigma_20)
        assert math.isfinite(result), (
            f"Volatility-scaled momentum non-finite: {result}"
        )
        assert -2.0 - 1e-9 <= result <= 2.0 + 1e-9, (
            f"Volatility-scaled momentum out of range: {result}"
        )

    @settings(max_examples=100)
    @given(
        p_bull=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
        strength=st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False),
        sigma_20=st.floats(min_value=0.0, max_value=2.0, allow_nan=False, allow_infinity=False),
        horizon_days=st.floats(min_value=0.0, max_value=365.0, allow_nan=False, allow_infinity=False),
    )
    def test_expected_value_finite(
        self,
        p_bull: float,
        strength: float,
        sigma_20: float,
        horizon_days: float,
    ) -> None:
        """Expected value always produces a finite float."""
        result = compute_expected_value(p_bull, strength, sigma_20, horizon_days)
        assert math.isfinite(result), f"Expected value non-finite: {result}"
