"""Unit tests for services.signal_engine.heuristic — Heuristic Pipeline verdict logic.

Tests BUY, WATCH, and SKIP verdict conditions, threshold edge cases,
confidence computation (agreement boosts, contradiction penalties),
S_total computation from multiple signals, and None-valued inputs.

Requirements: 5.4, 5.5, 5.6
"""
from __future__ import annotations

from datetime import datetime, timezone

from services.signal_engine.config import HeuristicConfig
from services.signal_engine.heuristic import (
    _compute_confidence,
    _compute_s_company,
    _compute_s_competitive,
    _compute_s_macro,
    _determine_verdict,
    run_heuristic_pipeline,
)
from services.signal_engine.models import (
    ConfluenceSignal,
    NormalizedInput,
    SignalDirection,
    Verdict,
)

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

_NOW = datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)


def _default_config() -> HeuristicConfig:
    """Return default heuristic config matching design thresholds."""
    return HeuristicConfig()


def _normalized(
    *,
    valuation_score: float | None = 0.8,
    earnings_proximity_days: int | None = 30,
    macro_bias: float = 0.5,
) -> NormalizedInput:
    """Create a NormalizedInput with sensible defaults for testing."""
    return NormalizedInput(
        ticker="AAPL",
        evaluated_at=_NOW,
        bars={},
        valuation_score=valuation_score,
        earnings_proximity_days=earnings_proximity_days,
        macro_bias=macro_bias,
    )


def _bullish_signal(
    signal_type: str = "fibonacci",
    confluence_score: float = 0.8,
    timeframes: list[str] | None = None,
) -> ConfluenceSignal:
    """Create a bullish confluence signal."""
    tfs = timeframes or ["D", "W"]
    return ConfluenceSignal(
        signal_type=signal_type,
        direction=SignalDirection.BULLISH,
        confluence_score=confluence_score,
        active_timeframes=tfs,
        per_timeframe={tf: confluence_score for tf in tfs},
    )


def _bearish_signal(
    signal_type: str = "rsi",
    confluence_score: float = 0.6,
    timeframes: list[str] | None = None,
) -> ConfluenceSignal:
    """Create a bearish confluence signal."""
    tfs = timeframes or ["D", "H4"]
    return ConfluenceSignal(
        signal_type=signal_type,
        direction=SignalDirection.BEARISH,
        confluence_score=confluence_score,
        active_timeframes=tfs,
        per_timeframe={tf: confluence_score for tf in tfs},
    )


def _neutral_signal(
    signal_type: str = "elliott_wave",
    confluence_score: float = 0.5,
) -> ConfluenceSignal:
    """Create a neutral confluence signal."""
    return ConfluenceSignal(
        signal_type=signal_type,
        direction=SignalDirection.NEUTRAL,
        confluence_score=confluence_score,
        active_timeframes=["D", "W"],
        per_timeframe={"D": confluence_score, "W": confluence_score},
    )


# ===========================================================================
# 1. BUY verdict — all conditions met (Requirement 5.4)
# ===========================================================================


class TestBuyVerdict:
    """BUY requires confidence >= 0.70, S_total >= 1.2, valuation >= 0.5,
    macro_bias > 0, earnings_proximity_days > 5."""

    def test_buy_all_conditions_met(self) -> None:
        """Strong bullish signals + favorable fundamentals → BUY."""
        signals = [
            _bullish_signal("fibonacci", 0.9),
            _bullish_signal("ma_stack", 0.85),
            _bullish_signal("rsi", 0.8),
        ]
        normalized = _normalized(
            valuation_score=0.7,
            earnings_proximity_days=30,
            macro_bias=0.5,
        )
        config = _default_config()
        result = run_heuristic_pipeline(normalized, signals, config)

        assert result.verdict == Verdict.BUY
        assert result.confidence >= config.buy_confidence
        assert result.s_total >= config.buy_s_total
        assert len(result.reasoning) > 0
        assert "BUY" in result.reasoning[0]

    def test_buy_reasoning_includes_all_values(self) -> None:
        """BUY reasoning should mention confidence, S_total, valuation, macro, earnings."""
        signals = [
            _bullish_signal("fibonacci", 0.9),
            _bullish_signal("ma_stack", 0.85),
            _bullish_signal("rsi", 0.8),
        ]
        normalized = _normalized(valuation_score=0.7, macro_bias=0.5, earnings_proximity_days=30)
        result = run_heuristic_pipeline(normalized, signals, _default_config())

        assert result.verdict == Verdict.BUY
        reason = result.reasoning[0]
        assert "confidence" in reason.lower()
        assert "s_total" in reason.lower()

    def test_buy_s_total_components_populated(self) -> None:
        """BUY result should have non-zero s_company and s_macro."""
        signals = [
            _bullish_signal("fibonacci", 0.9),
            _bullish_signal("ma_stack", 0.85),
            _bullish_signal("rsi", 0.8),
        ]
        normalized = _normalized(macro_bias=0.5)
        result = run_heuristic_pipeline(normalized, signals, _default_config())

        assert result.s_company > 0
        assert result.s_macro > 0  # macro_bias=0.5 * 0.5 weight = 0.25
        assert result.s_total == result.s_company + result.s_macro + result.s_competitive


# ===========================================================================
# 2. WATCH verdict — confidence sufficient but BUY conditions not fully met
#    (Requirement 5.5)
# ===========================================================================


class TestWatchVerdict:
    """WATCH: confidence >= 0.55 but at least one BUY condition fails."""

    def test_watch_low_valuation(self) -> None:
        """Confidence OK but valuation below BUY threshold → WATCH."""
        signals = [
            _bullish_signal("fibonacci", 0.85),
            _bullish_signal("ma_stack", 0.80),
        ]
        normalized = _normalized(
            valuation_score=0.3,  # below 0.5 BUY threshold
            macro_bias=0.5,
            earnings_proximity_days=30,
        )
        result = run_heuristic_pipeline(normalized, signals, _default_config())

        # Confidence should be >= watch threshold (0.55) with 2 strong bullish signals
        if result.confidence >= 0.55:
            assert result.verdict == Verdict.WATCH
            assert any("WATCH" in r for r in result.reasoning)

    def test_watch_negative_macro_bias(self) -> None:
        """Confidence OK but macro_bias <= 0 → WATCH (not BUY)."""
        signals = [
            _bullish_signal("fibonacci", 0.85),
            _bullish_signal("ma_stack", 0.80),
        ]
        normalized = _normalized(
            valuation_score=0.8,
            macro_bias=-0.1,  # negative, fails macro_bias > 0
            earnings_proximity_days=30,
        )
        result = run_heuristic_pipeline(normalized, signals, _default_config())

        if result.confidence >= 0.55:
            assert result.verdict == Verdict.WATCH

    def test_watch_earnings_too_close(self) -> None:
        """Confidence OK but earnings within 5 days → WATCH."""
        signals = [
            _bullish_signal("fibonacci", 0.85),
            _bullish_signal("ma_stack", 0.80),
        ]
        normalized = _normalized(
            valuation_score=0.8,
            macro_bias=0.5,
            earnings_proximity_days=3,  # <= 5, fails earnings condition
        )
        result = run_heuristic_pipeline(normalized, signals, _default_config())

        if result.confidence >= 0.55:
            assert result.verdict == Verdict.WATCH

    def test_watch_macro_bias_exactly_zero(self) -> None:
        """macro_bias == 0 fails the > 0 condition → WATCH if confidence OK."""
        signals = [
            _bullish_signal("fibonacci", 0.85),
            _bullish_signal("ma_stack", 0.80),
        ]
        normalized = _normalized(macro_bias=0.0)
        result = run_heuristic_pipeline(normalized, signals, _default_config())

        if result.confidence >= 0.55:
            assert result.verdict == Verdict.WATCH

    def test_watch_reasoning_lists_failed_conditions(self) -> None:
        """WATCH reasoning should identify which BUY conditions failed."""
        signals = [
            _bullish_signal("fibonacci", 0.85),
            _bullish_signal("ma_stack", 0.80),
        ]
        normalized = _normalized(valuation_score=0.3, macro_bias=0.0)
        result = run_heuristic_pipeline(normalized, signals, _default_config())

        if result.verdict == Verdict.WATCH:
            full_reasoning = " ".join(result.reasoning)
            assert "valuation" in full_reasoning.lower() or "macro" in full_reasoning.lower()


# ===========================================================================
# 3. SKIP verdict — confidence below watch threshold (Requirement 5.6)
# ===========================================================================


class TestSkipVerdict:
    """SKIP: confidence < 0.55 (watch threshold)."""

    def test_skip_empty_signals(self) -> None:
        """No confluence signals → confidence = 0.0 → SKIP."""
        normalized = _normalized()
        result = run_heuristic_pipeline(normalized, [], _default_config())

        assert result.verdict == Verdict.SKIP
        assert result.confidence == 0.0
        assert result.s_total == result.s_macro  # only macro contributes

    def test_skip_single_weak_signal(self) -> None:
        """Single weak signal → low confidence → SKIP."""
        signals = [_bullish_signal("fibonacci", 0.3)]
        normalized = _normalized()
        result = run_heuristic_pipeline(normalized, signals, _default_config())

        # Single signal with score 0.3 → base_confidence=0.3, source_factor=0.6
        # confidence = 0.3 * 0.6 * 1.0 = 0.18 → well below 0.55
        assert result.verdict == Verdict.SKIP
        assert result.confidence < 0.55

    def test_skip_reasoning_mentions_threshold(self) -> None:
        """SKIP reasoning should reference the watch threshold."""
        result = run_heuristic_pipeline(_normalized(), [], _default_config())

        assert result.verdict == Verdict.SKIP
        assert any("SKIP" in r for r in result.reasoning)


# ===========================================================================
# 4. Edge cases at threshold boundaries
# ===========================================================================


class TestThresholdEdgeCases:
    """Test behavior at exact threshold values."""

    def test_confidence_exactly_at_buy_threshold(self) -> None:
        """Verify _determine_verdict with confidence exactly at 0.70."""
        config = _default_config()
        normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)

        verdict, reasoning = _determine_verdict(
            confidence=0.70,
            s_total=1.5,
            normalized=normalized,
            config=config,
        )
        assert verdict == Verdict.BUY

    def test_confidence_just_below_buy_threshold(self) -> None:
        """confidence = 0.699 → not BUY, should be WATCH."""
        config = _default_config()
        normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)

        verdict, _ = _determine_verdict(
            confidence=0.699,
            s_total=1.5,
            normalized=normalized,
            config=config,
        )
        assert verdict == Verdict.WATCH

    def test_confidence_exactly_at_watch_threshold(self) -> None:
        """confidence = 0.55 → WATCH (not SKIP)."""
        config = _default_config()
        normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)

        verdict, _ = _determine_verdict(
            confidence=0.55,
            s_total=0.5,  # below BUY s_total threshold
            normalized=normalized,
            config=config,
        )
        assert verdict == Verdict.WATCH

    def test_confidence_just_below_watch_threshold(self) -> None:
        """confidence = 0.549 → SKIP."""
        config = _default_config()
        normalized = _normalized()

        verdict, _ = _determine_verdict(
            confidence=0.549,
            s_total=2.0,
            normalized=normalized,
            config=config,
        )
        assert verdict == Verdict.SKIP

    def test_s_total_exactly_at_buy_threshold(self) -> None:
        """S_total = 1.2 exactly → BUY if all other conditions met."""
        config = _default_config()
        normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)

        verdict, _ = _determine_verdict(
            confidence=0.80,
            s_total=1.2,
            normalized=normalized,
            config=config,
        )
        assert verdict == Verdict.BUY

    def test_s_total_just_below_buy_threshold(self) -> None:
        """S_total = 1.199 → not BUY, should be WATCH."""
        config = _default_config()
        normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)

        verdict, _ = _determine_verdict(
            confidence=0.80,
            s_total=1.199,
            normalized=normalized,
            config=config,
        )
        assert verdict == Verdict.WATCH

    def test_valuation_exactly_at_buy_threshold(self) -> None:
        """valuation_score = 0.5 exactly → BUY if all other conditions met."""
        config = _default_config()
        normalized = _normalized(valuation_score=0.5, macro_bias=0.5, earnings_proximity_days=30)

        verdict, _ = _determine_verdict(
            confidence=0.80,
            s_total=1.5,
            normalized=normalized,
            config=config,
        )
        assert verdict == Verdict.BUY

    def test_valuation_just_below_buy_threshold(self) -> None:
        """valuation_score = 0.499 → not BUY."""
        config = _default_config()
        normalized = _normalized(valuation_score=0.499, macro_bias=0.5, earnings_proximity_days=30)

        verdict, _ = _determine_verdict(
            confidence=0.80,
            s_total=1.5,
            normalized=normalized,
            config=config,
        )
        assert verdict == Verdict.WATCH

    def test_earnings_exactly_at_threshold(self) -> None:
        """earnings_proximity_days = 5 → fails > 5 condition → WATCH."""
        config = _default_config()
        normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=5)

        verdict, _ = _determine_verdict(
            confidence=0.80,
            s_total=1.5,
            normalized=normalized,
            config=config,
        )
        assert verdict == Verdict.WATCH

    def test_earnings_just_above_threshold(self) -> None:
        """earnings_proximity_days = 6 → passes > 5 condition → BUY."""
        config = _default_config()
        normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=6)

        verdict, _ = _determine_verdict(
            confidence=0.80,
            s_total=1.5,
            normalized=normalized,
            config=config,
        )
        assert verdict == Verdict.BUY

    def test_none_valuation_score_treated_as_zero(self) -> None:
        """None valuation_score defaults to 0.0 → fails BUY valuation check."""
        config = _default_config()
        normalized = _normalized(valuation_score=None, macro_bias=0.5, earnings_proximity_days=30)

        verdict, _ = _determine_verdict(
            confidence=0.80,
            s_total=1.5,
            normalized=normalized,
            config=config,
        )
        assert verdict == Verdict.WATCH

    def test_none_earnings_proximity_treated_as_zero(self) -> None:
        """None earnings_proximity_days defaults to 0 → fails BUY earnings check."""
        config = _default_config()
        normalized = _normalized(
            valuation_score=0.8,
            macro_bias=0.5,
            earnings_proximity_days=None,
        )

        verdict, _ = _determine_verdict(
            confidence=0.80,
            s_total=1.5,
            normalized=normalized,
            config=config,
        )
        assert verdict == Verdict.WATCH


# ===========================================================================
# 5. Signal agreement boosts confidence
# ===========================================================================


class TestConfidenceAgreement:
    """All signals in the same direction → agreement boost (factor 1.15)."""

    def test_all_bullish_signals_boost_confidence(self) -> None:
        """All bullish signals → agreement_factor = 1.15."""
        signals = [
            _bullish_signal("fibonacci", 0.7),
            _bullish_signal("ma_stack", 0.7),
            _bullish_signal("rsi", 0.7),
        ]
        confidence = _compute_confidence(signals)

        # base = 0.7, source_factor = 1 - 0.4/3 ≈ 0.867, agreement = 1.15
        # confidence = 0.7 * 0.867 * 1.15 ≈ 0.698
        assert confidence > 0.0
        # Compare with a mixed-direction set to verify boost
        mixed_signals = [
            _bullish_signal("fibonacci", 0.7),
            _bearish_signal("rsi", 0.7),
            _bullish_signal("ma_stack", 0.7),
        ]
        mixed_confidence = _compute_confidence(mixed_signals)
        assert confidence > mixed_confidence

    def test_all_bearish_signals_boost_confidence(self) -> None:
        """All bearish signals → agreement_factor = 1.15 (same boost)."""
        signals = [
            _bearish_signal("fibonacci", 0.7),
            _bearish_signal("ma_stack", 0.7),
        ]
        confidence = _compute_confidence(signals)

        # base = 0.7, source_factor = 1 - 0.4/2 = 0.8, agreement = 1.15
        # confidence = 0.7 * 0.8 * 1.15 = 0.644
        assert confidence > 0.6

    def test_single_signal_no_agreement_factor(self) -> None:
        """Single signal → agreement_factor = 1.0 (no boost or penalty)."""
        signals = [_bullish_signal("fibonacci", 0.8)]
        confidence = _compute_confidence(signals)

        # base = 0.8, source_factor = 1 - 0.4/1 = 0.6, agreement = 1.0
        # confidence = 0.8 * 0.6 * 1.0 = 0.48
        assert abs(confidence - 0.48) < 0.001

    def test_directional_plus_neutral_mild_boost(self) -> None:
        """Mix of directional and neutral signals → agreement_factor = 1.05."""
        signals = [
            _bullish_signal("fibonacci", 0.7),
            _neutral_signal("elliott_wave", 0.7),
        ]
        confidence = _compute_confidence(signals)

        # base = 0.7, source_factor = 1 - 0.4/2 = 0.8, agreement = 1.05
        # confidence = 0.7 * 0.8 * 1.05 = 0.588
        assert abs(confidence - 0.588) < 0.001


# ===========================================================================
# 6. Contradicting signals reduce confidence
# ===========================================================================


class TestConfidenceContradiction:
    """Mixed bullish/bearish signals → contradiction penalty."""

    def test_contradiction_reduces_confidence(self) -> None:
        """Bullish + bearish signals → penalty proportional to minority fraction."""
        contradicting = [
            _bullish_signal("fibonacci", 0.7),
            _bearish_signal("rsi", 0.7),
        ]
        agreeing = [
            _bullish_signal("fibonacci", 0.7),
            _bullish_signal("rsi", 0.7),
        ]
        conf_contradicting = _compute_confidence(contradicting)
        conf_agreeing = _compute_confidence(agreeing)

        assert conf_contradicting < conf_agreeing

    def test_more_contradiction_more_penalty(self) -> None:
        """Higher minority fraction → larger penalty."""
        # 1 bearish out of 3 → minority = 1/3
        mild_contradiction = [
            _bullish_signal("fibonacci", 0.7),
            _bullish_signal("ma_stack", 0.7),
            _bearish_signal("rsi", 0.7),
        ]
        # 2 bearish out of 4 → minority = 2/4 = 0.5
        strong_contradiction = [
            _bullish_signal("fibonacci", 0.7),
            _bullish_signal("ma_stack", 0.7),
            _bearish_signal("rsi", 0.7),
            _bearish_signal("elliott_wave", 0.7),
        ]
        conf_mild = _compute_confidence(mild_contradiction)
        conf_strong = _compute_confidence(strong_contradiction)

        # Strong contradiction should have lower confidence per-signal
        # (accounting for source count factor difference)
        # mild: agreement = 1 - 0.3*(1/3) = 0.9
        # strong: agreement = 1 - 0.3*(2/4) = 0.85
        # The agreement factor is lower for strong contradiction
        mild_agreement = 1.0 - 0.3 * (1 / 3)
        strong_agreement = 1.0 - 0.3 * (2 / 4)
        assert strong_agreement < mild_agreement

    def test_equal_split_maximum_penalty(self) -> None:
        """50/50 split → maximum contradiction penalty (0.3 * 0.5 = 0.15)."""
        signals = [
            _bullish_signal("fibonacci", 0.7),
            _bearish_signal("rsi", 0.7),
        ]
        confidence = _compute_confidence(signals)

        # base = 0.7, source_factor = 0.8, agreement = 1 - 0.3*0.5 = 0.85
        # confidence = 0.7 * 0.8 * 0.85 = 0.476
        assert abs(confidence - 0.476) < 0.001


# ===========================================================================
# 7. Empty confluence signals → SKIP
# ===========================================================================


class TestEmptySignals:
    """Empty confluence signals produce confidence = 0 → SKIP."""

    def test_empty_signals_confidence_zero(self) -> None:
        """No signals → confidence = 0.0."""
        assert _compute_confidence([]) == 0.0

    def test_empty_signals_skip_verdict(self) -> None:
        """No signals → SKIP regardless of fundamentals."""
        normalized = _normalized(valuation_score=1.0, macro_bias=1.0, earnings_proximity_days=100)
        result = run_heuristic_pipeline(normalized, [], _default_config())

        assert result.verdict == Verdict.SKIP
        assert result.confidence == 0.0

    def test_empty_signals_s_total_only_macro(self) -> None:
        """No signals → S_company = 0, S_competitive = 0, S_total = S_macro only."""
        normalized = _normalized(macro_bias=0.6)
        result = run_heuristic_pipeline(normalized, [], _default_config())

        assert result.s_company == 0.0
        assert result.s_competitive == 0.0
        assert result.s_macro == 0.6 * 0.5  # macro_bias * _MACRO_WEIGHT
        assert result.s_total == result.s_macro


# ===========================================================================
# 8. S_total computation from multiple signals
# ===========================================================================


class TestSTotalComputation:
    """S_total = S_company + S_macro + S_competitive."""

    def test_s_company_sums_company_signals(self) -> None:
        """Company-level signals (fibonacci, ma_stack, rsi) contribute to S_company."""
        signals = [
            _bullish_signal("fibonacci", 0.5),
            _bullish_signal("ma_stack", 0.3),
        ]
        s_company, weights = _compute_s_company(signals)

        # Both bullish → positive contributions
        assert s_company == 0.5 + 0.3
        assert len(weights) == 2
        assert all(w["layer"] == "company" for w in weights)

    def test_s_company_bearish_signals_subtract(self) -> None:
        """Bearish company signals contribute negatively to S_company."""
        signals = [
            _bullish_signal("fibonacci", 0.5),
            _bearish_signal("rsi", 0.3),
        ]
        s_company, weights = _compute_s_company(signals)

        # fibonacci: +0.5, rsi: -0.3
        assert abs(s_company - 0.2) < 0.001

    def test_s_company_neutral_signals_zero_contribution(self) -> None:
        """Neutral company signals contribute 0 to S_company."""
        signals = [
            ConfluenceSignal(
                signal_type="fibonacci",
                direction=SignalDirection.NEUTRAL,
                confluence_score=0.8,
                active_timeframes=["D", "W"],
                per_timeframe={"D": 0.8, "W": 0.8},
            ),
        ]
        s_company, _ = _compute_s_company(signals)
        assert s_company == 0.0

    def test_s_company_ignores_non_company_signals(self) -> None:
        """Signals not in COMPANY_SIGNAL_TYPES are ignored for S_company."""
        signals = [
            _bullish_signal("unknown_signal_type", 0.9),
        ]
        s_company, weights = _compute_s_company(signals)

        assert s_company == 0.0
        assert len(weights) == 0

    def test_s_macro_positive_bias(self) -> None:
        """Positive macro_bias → positive S_macro."""
        normalized = _normalized(macro_bias=0.8)
        s_macro = _compute_s_macro(normalized)
        assert s_macro == 0.8 * 0.5  # macro_bias * _MACRO_WEIGHT

    def test_s_macro_negative_bias(self) -> None:
        """Negative macro_bias → negative S_macro."""
        normalized = _normalized(macro_bias=-0.6)
        s_macro = _compute_s_macro(normalized)
        assert s_macro == -0.6 * 0.5

    def test_s_macro_zero_bias(self) -> None:
        """Zero macro_bias → zero S_macro."""
        normalized = _normalized(macro_bias=0.0)
        s_macro = _compute_s_macro(normalized)
        assert s_macro == 0.0

    def test_s_competitive_currently_zero(self) -> None:
        """No competitive signal types defined → S_competitive = 0."""
        signals = [_bullish_signal("fibonacci", 0.9)]
        s_competitive = _compute_s_competitive(signals)
        assert s_competitive == 0.0

    def test_s_total_is_sum_of_components(self) -> None:
        """S_total = S_company + S_macro + S_competitive."""
        signals = [
            _bullish_signal("fibonacci", 0.5),
            _bullish_signal("ma_stack", 0.4),
        ]
        normalized = _normalized(macro_bias=0.6)
        result = run_heuristic_pipeline(normalized, signals, _default_config())

        expected_s_company = 0.5 + 0.4
        expected_s_macro = 0.6 * 0.5
        expected_s_competitive = 0.0
        expected_s_total = expected_s_company + expected_s_macro + expected_s_competitive

        assert abs(result.s_company - expected_s_company) < 0.001
        assert abs(result.s_macro - expected_s_macro) < 0.001
        assert abs(result.s_competitive - expected_s_competitive) < 0.001
        assert abs(result.s_total - expected_s_total) < 0.001

    def test_signal_weights_audit_trail(self) -> None:
        """signal_weights list contains per-signal audit info."""
        signals = [
            _bullish_signal("fibonacci", 0.5),
            _bullish_signal("rsi", 0.3),
        ]
        result = run_heuristic_pipeline(_normalized(), signals, _default_config())

        assert len(result.signal_weights) == 2
        types = {w["signal_type"] for w in result.signal_weights}
        assert "fibonacci" in types
        assert "rsi" in types
        for w in result.signal_weights:
            assert "contribution" in w
            assert "direction" in w
            assert "active_timeframes" in w


# ===========================================================================
# 9. Full pipeline integration — HeuristicResult structure
# ===========================================================================


class TestHeuristicResultStructure:
    """Verify the HeuristicResult has all required fields."""

    def test_result_has_all_fields(self) -> None:
        """HeuristicResult contains verdict, confidence, scores, weights, reasoning."""
        signals = [_bullish_signal("fibonacci", 0.7)]
        result = run_heuristic_pipeline(_normalized(), signals, _default_config())

        assert result.verdict in (Verdict.BUY, Verdict.WATCH, Verdict.SKIP)
        assert 0.0 <= result.confidence <= 1.0
        assert isinstance(result.s_total, float)
        assert isinstance(result.s_company, float)
        assert isinstance(result.s_macro, float)
        assert isinstance(result.s_competitive, float)
        assert isinstance(result.signal_weights, list)
        assert isinstance(result.reasoning, list)
        assert len(result.reasoning) > 0

    def test_confidence_clamped_to_unit_interval(self) -> None:
        """Confidence is always in [0.0, 1.0] even with strong agreement boost."""
        # Very high confluence scores with perfect agreement
        signals = [
            _bullish_signal("fibonacci", 1.0),
            _bullish_signal("ma_stack", 1.0),
            _bullish_signal("rsi", 1.0),
            _bullish_signal("cup_handle", 1.0),
            _bullish_signal("elliott_wave", 1.0),
        ]
        confidence = _compute_confidence(signals)
        assert 0.0 <= confidence <= 1.0


# ===========================================================================
# 10. Custom config thresholds
# ===========================================================================


class TestCustomConfig:
    """Verify that custom config thresholds are respected."""

    def test_custom_buy_confidence_threshold(self) -> None:
        """Lowering buy_confidence makes BUY easier to achieve."""
        config = HeuristicConfig(buy_confidence=0.50, buy_s_total=0.5)
        normalized = _normalized(valuation_score=0.8, macro_bias=0.5, earnings_proximity_days=30)

        verdict, _ = _determine_verdict(
            confidence=0.55,
            s_total=0.8,
            normalized=normalized,
            config=config,
        )
        assert verdict == Verdict.BUY

    def test_custom_watch_confidence_threshold(self) -> None:
        """Raising watch_confidence makes WATCH harder to achieve."""
        config = HeuristicConfig(watch_confidence=0.80)
        normalized = _normalized()

        verdict, _ = _determine_verdict(
            confidence=0.75,
            s_total=0.5,
            normalized=normalized,
            config=config,
        )
        assert verdict == Verdict.SKIP  # 0.75 < 0.80 watch threshold

    def test_custom_earnings_threshold(self) -> None:
        """Custom earnings_days_threshold changes BUY gating."""
        config = HeuristicConfig(earnings_days_threshold=10)
        normalized = _normalized(
            valuation_score=0.8,
            macro_bias=0.5,
            earnings_proximity_days=8,  # > 5 default but <= 10 custom
        )

        verdict, _ = _determine_verdict(
            confidence=0.80,
            s_total=1.5,
            normalized=normalized,
            config=config,
        )
        assert verdict == Verdict.WATCH  # 8 is not > 10
