"""Property-based tests for v3 posterior assembly and regime classification.

Feature: math-core-v3-engine

Uses Hypothesis to validate correctness properties of the v3 posterior
and regime detection layer.

Validates: Requirements 5.3, 6.2–6.5, 21.5
"""
from __future__ import annotations

import math

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

from services.aggregation.bayesian import V3Posterior, compute_v3_posterior
from services.aggregation.regime import (
    MarketRegime,
    V3RegimeClassification,
    classify_regime_v3,
)
from services.aggregation.scoring import EvidenceUnit
from services.aggregation.worker import EvidenceCluster

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

# Cluster LLR values in [-2.5, 2.5] as per spec
cluster_llrs = st.floats(
    min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False
)

# Lists of cluster LLR values (0 to 10 clusters)
cluster_llr_lists = st.lists(cluster_llrs, min_size=0, max_size=10)

# Regime evidence multiplier gamma from the defined set
gammas = st.sampled_from([0.70, 0.80, 0.90, 1.10])

# Prior probability in [0.40, 0.60]
priors = st.floats(
    min_value=0.40, max_value=0.60, allow_nan=False, allow_infinity=False
)

# Random walk closing prices for regime classification
# Generate 120+ prices as a random walk starting from a base price
_base_price = st.floats(min_value=10.0, max_value=500.0, allow_nan=False, allow_infinity=False)
_price_step = st.floats(min_value=-2.0, max_value=2.0, allow_nan=False, allow_infinity=False)


@st.composite
def closing_prices_strategy(draw: st.DrawFn) -> list[float]:
    """Generate 120+ closing prices as a random walk."""
    base = draw(_base_price)
    n = draw(st.integers(min_value=120, max_value=200))
    steps = draw(st.lists(_price_step, min_size=n, max_size=n))
    prices = [base]
    for step in steps:
        prices.append(max(0.01, prices[-1] + step))  # keep prices positive
    return prices


@st.composite
def daily_returns_strategy(draw: st.DrawFn) -> list[float]:
    """Generate 120+ daily returns (small floats)."""
    n = draw(st.integers(min_value=120, max_value=200))
    returns = draw(
        st.lists(
            st.floats(min_value=-0.10, max_value=0.10, allow_nan=False, allow_infinity=False),
            min_size=n,
            max_size=n,
        )
    )
    return returns


# ATR_20 > 0
atr_20_strategy = st.floats(
    min_value=0.01, max_value=50.0, allow_nan=False, allow_infinity=False
)


# ---------------------------------------------------------------------------
# Helper: build mock EvidenceCluster from a cluster_llr value
# ---------------------------------------------------------------------------

def _make_cluster(cluster_llr: float) -> EvidenceCluster:
    """Create a minimal EvidenceCluster with the given cluster_llr."""
    return EvidenceCluster(
        cluster_id=f"test_cluster_{id(cluster_llr)}",
        units=[],
        llrs=[cluster_llr],
        n_eff=1.0,
        cluster_llr=cluster_llr,
    )


def _make_regime(gamma: float) -> V3RegimeClassification:
    """Create a minimal V3RegimeClassification with the given gamma."""
    return V3RegimeClassification(
        regime=MarketRegime.UNCERTAINTY,
        trend_z=0.0,
        vol_ratio=1.0,
        evidence_multiplier=gamma,
        confidence_multiplier=0.85,
        phi_decay=0.50,
        atr_multiplier=2.0,
    )


# ---------------------------------------------------------------------------
# Property 7: Posterior P_up is in open interval (0, 1)
# ---------------------------------------------------------------------------


@given(
    llr_values=cluster_llr_lists,
    gamma=gammas,
    p_prior=priors,
)
@settings(max_examples=100)
def test_property_7_posterior_p_up_in_open_interval(
    llr_values: list[float],
    gamma: float,
    p_prior: float,
) -> None:
    """**Validates: Requirements 5.3**

    Property 7: For any set of cluster LLRs (each in [-2.5, 2.5]), any regime
    evidence multiplier gamma in {0.70, 0.80, 0.90, 1.10}, and any prior
    P_prior in [0.40, 0.60], the computed P_up SHALL be in (1e-10, 1 - 1e-10).
    """
    # Build mock clusters from the LLR values
    clusters = [_make_cluster(llr) for llr in llr_values]

    # Build mock regime with the given gamma
    regime = _make_regime(gamma)

    # Compute posterior
    result = compute_v3_posterior(clusters, regime, p_prior=p_prior)

    # P_up must be in the open interval (1e-10, 1 - 1e-10)
    assert result.p_up >= 1e-10, (
        f"P_up {result.p_up} is below lower bound 1e-10"
    )
    assert result.p_up <= 1 - 1e-10, (
        f"P_up {result.p_up} is above upper bound 1 - 1e-10"
    )

    # p_down should be 1 - p_up
    assert math.isclose(result.p_up + result.p_down, 1.0, abs_tol=1e-12), (
        f"p_up + p_down = {result.p_up + result.p_down}, expected 1.0"
    )

    # Strength should be abs(2 * P_up - 1)
    expected_strength = abs(2.0 * result.p_up - 1.0)
    assert math.isclose(result.strength, expected_strength, abs_tol=1e-12), (
        f"strength {result.strength} != expected {expected_strength}"
    )


# ---------------------------------------------------------------------------
# Property 20: Regime classification is exhaustive and deterministic
# ---------------------------------------------------------------------------

_VALID_REGIMES = {"panic", "trend_following", "mean_reversion", "uncertainty"}


@given(
    closing_prices=closing_prices_strategy(),
    daily_returns=daily_returns_strategy(),
    atr_20=atr_20_strategy,
)
@settings(max_examples=100)
def test_property_20_regime_classification_exhaustive_and_deterministic(
    closing_prices: list[float],
    daily_returns: list[float],
    atr_20: float,
) -> None:
    """**Validates: Requirements 6.2–6.5, 21.5**

    Property 20: For any valid market data inputs (closing_prices of sufficient
    length, daily_returns, ATR_20 > 0), the regime classification SHALL produce
    exactly one of {panic, trend_following, mean_reversion, uncertainty} and the
    same inputs SHALL always produce the same classification.
    """
    # First classification
    result_1 = classify_regime_v3(closing_prices, daily_returns, atr_20)

    # Result must be exactly one of the four valid regimes
    assert result_1.regime.value in _VALID_REGIMES, (
        f"Regime '{result_1.regime.value}' not in valid set {_VALID_REGIMES}"
    )

    # Determinism: second call with same inputs must produce same result
    result_2 = classify_regime_v3(closing_prices, daily_returns, atr_20)

    assert result_1.regime == result_2.regime, (
        f"Non-deterministic: first call gave {result_1.regime.value}, "
        f"second gave {result_2.regime.value}"
    )
    assert result_1.trend_z == result_2.trend_z, (
        f"Non-deterministic trend_z: {result_1.trend_z} vs {result_2.trend_z}"
    )
    assert result_1.vol_ratio == result_2.vol_ratio, (
        f"Non-deterministic vol_ratio: {result_1.vol_ratio} vs {result_2.vol_ratio}"
    )
    assert result_1.evidence_multiplier == result_2.evidence_multiplier, (
        f"Non-deterministic evidence_multiplier: "
        f"{result_1.evidence_multiplier} vs {result_2.evidence_multiplier}"
    )


# ---------------------------------------------------------------------------
# Property 8: Contradiction is zero when evidence is unidirectional
# Feature: math-core-v3-engine, Property 8: Contradiction is zero when evidence is unidirectional
# ---------------------------------------------------------------------------

from services.aggregation.contradiction import compute_v3_contradiction

# Strategies for unidirectional cluster LLRs
all_positive_llrs = st.lists(
    st.floats(min_value=0.001, max_value=2.5, allow_nan=False, allow_infinity=False),
    min_size=1, max_size=10,
)
all_negative_llrs = st.lists(
    st.floats(min_value=-2.5, max_value=-0.001, allow_nan=False, allow_infinity=False),
    min_size=1, max_size=10,
)


@given(llr_values=all_positive_llrs)
@settings(max_examples=100)
def test_property_8_contradiction_zero_all_positive(
    llr_values: list[float],
) -> None:
    """**Validates: Requirements 7.6, 7.7**

    Property 8: For any set of cluster LLRs where all clusters have the same
    sign (all positive), the computed contradiction score SHALL be 0.0.
    """
    clusters = [_make_cluster(llr) for llr in llr_values]
    score = compute_v3_contradiction(clusters)
    assert score == 0.0, (
        f"Expected contradiction 0.0 for all-positive LLRs {llr_values}, got {score}"
    )


@given(llr_values=all_negative_llrs)
@settings(max_examples=100)
def test_property_8_contradiction_zero_all_negative(
    llr_values: list[float],
) -> None:
    """**Validates: Requirements 7.6, 7.7**

    Property 8: For any set of cluster LLRs where all clusters have the same
    sign (all negative), the computed contradiction score SHALL be 0.0.
    """
    clusters = [_make_cluster(llr) for llr in llr_values]
    score = compute_v3_contradiction(clusters)
    assert score == 0.0, (
        f"Expected contradiction 0.0 for all-negative LLRs {llr_values}, got {score}"
    )


# ---------------------------------------------------------------------------
# Property 9: Contradiction score is bounded in [0, 1]
# Feature: math-core-v3-engine, Property 9: Contradiction score is bounded in [0, 1]
# ---------------------------------------------------------------------------

# Mix of positive and negative cluster LLRs
mixed_llrs = st.lists(
    st.floats(min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False),
    min_size=0, max_size=15,
)


@given(llr_values=mixed_llrs)
@settings(max_examples=100)
def test_property_9_contradiction_bounded_zero_one(
    llr_values: list[float],
) -> None:
    """**Validates: Requirements 7.6, 7.7, 21.7**

    Property 9: For any set of cluster LLRs (including mixed positive and
    negative), the computed contradiction score SHALL be in [0.0, 1.0].
    """
    clusters = [_make_cluster(llr) for llr in llr_values]
    score = compute_v3_contradiction(clusters)
    assert 0.0 <= score <= 1.0, (
        f"Contradiction score {score} out of bounds [0, 1] for LLRs {llr_values}"
    )


# ---------------------------------------------------------------------------
# Property 10: Multiplicative confidence is bounded in [0, 1] and suppressed
# by weak dimensions
# Feature: math-core-v3-engine
# ---------------------------------------------------------------------------

from services.aggregation.worker import compute_v3_confidence, compute_v3_data_quality

# Strategies for Property 10
n_eff_totals = st.floats(min_value=0.0, max_value=20.0, allow_nan=False, allow_infinity=False)
unit_floats = st.floats(min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False)
q_value_lists = st.lists(unit_floats, min_size=1, max_size=15)
llr_lists_conf = st.lists(
    st.floats(min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False),
    min_size=1, max_size=15,
)
regime_conf_mults = st.floats(min_value=0.01, max_value=1.0, allow_nan=False, allow_infinity=False)


@given(
    n_eff_total=n_eff_totals,
    q_values=q_value_lists,
    llrs=llr_lists_conf,
    strength=unit_floats,
    regime_confidence_mult=regime_conf_mults,
    contradiction=unit_floats,
    data_quality=unit_floats,
)
@settings(max_examples=100)
def test_property_10_confidence_bounded_and_suppressed(
    n_eff_total: float,
    q_values: list[float],
    llrs: list[float],
    strength: float,
    regime_confidence_mult: float,
    contradiction: float,
    data_quality: float,
) -> None:
    """**Validates: Requirements 8.3, 8.5**

    Property 10: For any valid inputs (n_eff_total >= 0, q_values in [0,1],
    strength in [0,1], regime_confidence_mult in (0,1], contradiction in [0,1],
    data_quality in [0,1]), the computed confidence SHALL be in [0, 1].
    Furthermore, if any single dimension (data_quality, 1-contradiction, or
    C_quality) is below 0.01, the resulting confidence SHALL be below 0.10.
    """
    # Align list lengths: q_values and llrs must be parallel
    min_len = min(len(q_values), len(llrs))
    q_values = q_values[:min_len]
    llrs = llrs[:min_len]

    confidence = compute_v3_confidence(
        n_eff_total=n_eff_total,
        q_values=q_values,
        llrs=llrs,
        strength=strength,
        regime_confidence_mult=regime_confidence_mult,
        contradiction=contradiction,
        data_quality=data_quality,
    )

    # Confidence must be in [0, 1]
    assert 0.0 <= confidence <= 1.0, (
        f"Confidence {confidence} out of bounds [0, 1]"
    )

    # Suppression check: if data_quality < 0.01, confidence < 0.10
    if data_quality < 0.01:
        assert confidence < 0.10, (
            f"Confidence {confidence} not suppressed by data_quality={data_quality}"
        )

    # Suppression check: if (1 - contradiction) < 0.01, confidence < 0.10
    if (1.0 - contradiction) < 0.01:
        assert confidence < 0.10, (
            f"Confidence {confidence} not suppressed by contradiction={contradiction}"
        )

    # Suppression check: if C_quality < 0.01, confidence < 0.10
    # C_quality = weighted_mean(q_i, |LLR_i|) = sum(q_i * |LLR_i|) / sum(|LLR_i|)
    abs_llrs = [abs(llr) for llr in llrs]
    sum_abs_llrs = sum(abs_llrs)
    if sum_abs_llrs == 0.0:
        c_quality = 0.0
    else:
        c_quality = sum(q * w for q, w in zip(q_values, abs_llrs)) / sum_abs_llrs
    if c_quality < 0.01:
        assert confidence < 0.10, (
            f"Confidence {confidence} not suppressed by C_quality={c_quality}"
        )


# ---------------------------------------------------------------------------
# Property 10 suppression sub-test: force one dimension below 0.01
# ---------------------------------------------------------------------------

@given(
    n_eff_total=st.floats(min_value=1.0, max_value=20.0, allow_nan=False, allow_infinity=False),
    q_values=st.lists(
        st.floats(min_value=0.5, max_value=1.0, allow_nan=False, allow_infinity=False),
        min_size=1, max_size=10,
    ),
    llrs=st.lists(
        st.floats(min_value=0.5, max_value=2.5, allow_nan=False, allow_infinity=False),
        min_size=1, max_size=10,
    ),
    strength=st.floats(min_value=0.3, max_value=1.0, allow_nan=False, allow_infinity=False),
    regime_confidence_mult=regime_conf_mults,
    dimension=st.sampled_from(["data_quality", "contradiction", "c_quality"]),
)
@settings(max_examples=100)
def test_property_10_suppression_by_weak_dimension(
    n_eff_total: float,
    q_values: list[float],
    llrs: list[float],
    strength: float,
    regime_confidence_mult: float,
    dimension: str,
) -> None:
    """**Validates: Requirements 8.3, 8.5**

    Property 10 (suppression): If any single dimension (data_quality,
    1-contradiction, or C_quality) is below 0.01, the resulting confidence
    SHALL be below 0.10.
    """
    # Align list lengths
    min_len = min(len(q_values), len(llrs))
    q_values = q_values[:min_len]
    llrs = llrs[:min_len]

    # Force one dimension to be weak
    if dimension == "data_quality":
        data_quality = 0.005  # below 0.01
        contradiction = 0.0
    elif dimension == "contradiction":
        data_quality = 0.9
        contradiction = 0.995  # (1 - contradiction) = 0.005 < 0.01
    else:  # c_quality
        # Force all q_values near zero so C_quality < 0.01
        q_values = [0.001] * min_len
        data_quality = 0.9
        contradiction = 0.0

    confidence = compute_v3_confidence(
        n_eff_total=n_eff_total,
        q_values=q_values,
        llrs=llrs,
        strength=strength,
        regime_confidence_mult=regime_confidence_mult,
        contradiction=contradiction,
        data_quality=data_quality,
    )

    assert 0.0 <= confidence <= 1.0, (
        f"Confidence {confidence} out of bounds [0, 1]"
    )
    assert confidence < 0.10, (
        f"Confidence {confidence} not suppressed when {dimension} is weak"
    )


# ---------------------------------------------------------------------------
# Property 17: Data quality score is bounded in [0, 1]
# Feature: math-core-v3-engine
# ---------------------------------------------------------------------------

from datetime import datetime
from datetime import timezone as _tz

# Strategies for Property 17
extraction_failure_rates = st.floats(
    min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False
)
extraction_confs = st.floats(
    min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False
)
impacts = st.floats(
    min_value=0.0, max_value=1.0, allow_nan=False, allow_infinity=False
)
age_newest_hours_strat = st.floats(
    min_value=0.0, max_value=10000.0, allow_nan=False, allow_infinity=False
)
n_source_types_strat = st.integers(min_value=0, max_value=20)


@st.composite
def evidence_unit_list_strategy(draw: st.DrawFn) -> list[EvidenceUnit]:
    """Generate a list of EvidenceUnit-like objects for data quality testing."""
    n = draw(st.integers(min_value=0, max_value=15))
    units = []
    _ts = datetime(2024, 1, 1, tzinfo=_tz.utc)
    for i in range(n):
        ext_conf = draw(extraction_confs)
        impact = draw(impacts)
        unit = EvidenceUnit(
            symbol="TEST",
            layer="company",
            event_type="earnings",
            source_id=f"src_{i}",
            source_group="news",
            timestamp=_ts,
            horizon="7d",
            direction=1,
            sentiment_strength=0.5,
            impact=impact,
            extraction_conf=ext_conf,
            source_cred=0.7,
            novelty=0.5,
            event_base_rate=0.25,
            cluster_id=f"cluster_{i}",
        )
        units.append(unit)
    return units


@given(
    units=evidence_unit_list_strategy(),
    extraction_failure_rate=extraction_failure_rates,
    age_newest_hours=age_newest_hours_strat,
    n_source_types=n_source_types_strat,
)
@settings(max_examples=100)
def test_property_17_data_quality_bounded_zero_one(
    units: list[EvidenceUnit],
    extraction_failure_rate: float,
    age_newest_hours: float,
    n_source_types: int,
) -> None:
    """**Validates: Requirements 17.6, 21.6**

    Property 17: For any valid inputs (extraction_failure_rate in [0,1],
    extraction_conf_i in [0,1], impact_i in [0,1], age_newest_hours >= 0,
    N_valid >= 0, N_source_types >= 0), the computed data_quality_score
    SHALL be in [0, 1].
    """
    data_quality = compute_v3_data_quality(
        units=units,
        extraction_failure_rate=extraction_failure_rate,
        age_newest_hours=age_newest_hours,
        n_source_types=n_source_types,
    )

    assert 0.0 <= data_quality <= 1.0, (
        f"Data quality score {data_quality} out of bounds [0, 1] "
        f"for extraction_failure_rate={extraction_failure_rate}, "
        f"age_newest_hours={age_newest_hours}, n_source_types={n_source_types}, "
        f"n_units={len(units)}"
    )


# ---------------------------------------------------------------------------
# Property 12: Posterior state JSON round-trip
# Feature: math-core-v3-engine
# ---------------------------------------------------------------------------

import json
from dataclasses import asdict


@given(
    llr_values=st.lists(
        st.floats(min_value=-2.5, max_value=2.5, allow_nan=False, allow_infinity=False),
        min_size=1, max_size=10,
    ),
    gamma=gammas,
    p_prior=priors,
)
@settings(max_examples=100)
def test_property_12_posterior_json_round_trip(
    llr_values: list[float],
    gamma: float,
    p_prior: float,
) -> None:
    """**Validates: Requirements 20.1, 21.10**

    Property 12: Serialize V3Posterior to JSON via dataclasses.asdict() and
    json.dumps(), then deserialize via json.loads() and V3Posterior(**data).
    All numeric fields SHALL be equivalent within 1e-10, and string fields
    SHALL be exactly equal.
    """
    # Build clusters and regime, compute posterior
    clusters = [_make_cluster(llr) for llr in llr_values]
    regime = _make_regime(gamma)
    posterior = compute_v3_posterior(clusters, regime, p_prior=p_prior)

    # Serialize to JSON
    serialized = json.dumps(asdict(posterior))

    # Deserialize back to V3Posterior
    restored = V3Posterior(**json.loads(serialized))

    # Verify numeric fields within 1e-10
    assert math.isclose(posterior.p_up, restored.p_up, abs_tol=1e-10), (
        f"p_up mismatch: {posterior.p_up} vs {restored.p_up}"
    )
    assert math.isclose(posterior.p_down, restored.p_down, abs_tol=1e-10), (
        f"p_down mismatch: {posterior.p_down} vs {restored.p_down}"
    )
    assert math.isclose(posterior.log_odds, restored.log_odds, abs_tol=1e-10), (
        f"log_odds mismatch: {posterior.log_odds} vs {restored.log_odds}"
    )
    assert math.isclose(posterior.strength, restored.strength, abs_tol=1e-10), (
        f"strength mismatch: {posterior.strength} vs {restored.strength}"
    )
    assert math.isclose(posterior.n_eff_total, restored.n_eff_total, abs_tol=1e-10), (
        f"n_eff_total mismatch: {posterior.n_eff_total} vs {restored.n_eff_total}"
    )

    # Verify string fields are exactly equal
    assert posterior.direction == restored.direction, (
        f"direction mismatch: {posterior.direction!r} vs {restored.direction!r}"
    )
    assert posterior.regime == restored.regime, (
        f"regime mismatch: {posterior.regime!r} vs {restored.regime!r}"
    )
