"""Tests for trend projection module — forward-looking trend estimates.

Tests the pure logic functions (no DB required). Covers momentum
computation, macro decay projection, core projection assembly,
divergence flagging, macro-disabled behavior, and low-confidence marking.
"""
from datetime import datetime, timezone

from services.aggregation.projection import (
    DEFAULT_CONFIDENCE_THRESHOLD,
    MacroEventInfo,
    TrendProjection,
    compute_projection,
    compute_trend_momentum,
    project_macro_decay,
)
from services.shared.schemas import TrendDirection, TrendSummary, TrendWindow

NOW = datetime(2026, 4, 11, 12, 0, 0, tzinfo=timezone.utc)


def _make_summary(
    direction: TrendDirection = TrendDirection.BULLISH,
    strength: float = 0.6,
    confidence: float = 0.7,
    window: TrendWindow = TrendWindow.SEVEN_DAY,
    catalysts: list[str] | None = None,
) -> TrendSummary:
    return TrendSummary(
        entity_type="company",
        entity_id="AAPL",
        window=window,
        trend_direction=direction,
        trend_strength=strength,
        confidence=confidence,
        dominant_catalysts=catalysts or [],
        generated_at=NOW,
    )


def _make_macro_event(
    impact_score: float = 0.6,
    direction: str = "negative",
    estimated_duration: str = "medium_term",
    severity: str = "high",
    age_hours: float = 12.0,
    confidence: float = 0.8,
) -> MacroEventInfo:
    return MacroEventInfo(
        event_id="evt-1",
        macro_impact_score=impact_score,
        impact_direction=direction,
        confidence=confidence,
        estimated_duration=estimated_duration,
        severity=severity,
        event_age_hours=age_hours,
    )


# ---------------------------------------------------------------------------
# compute_trend_momentum
# ---------------------------------------------------------------------------


def test_momentum_no_previous_data_bullish():
    """Without previous data, momentum is a heuristic based on current trend."""
    m = compute_trend_momentum(0.6, "bullish")
    assert m > 0.0
    assert m <= 1.0


def test_momentum_no_previous_data_bearish():
    m = compute_trend_momentum(0.6, "bearish")
    assert m < 0.0
    assert m >= -1.0


def test_momentum_no_previous_data_neutral():
    m = compute_trend_momentum(0.3, "neutral")
    assert m == 0.0


def test_momentum_increasing_bullish():
    """Strength increasing in bullish direction → positive momentum."""
    m = compute_trend_momentum(0.8, "bullish", 0.4, "bullish")
    assert m > 0.0


def test_momentum_decreasing_bullish():
    """Strength decreasing in bullish direction → negative momentum."""
    m = compute_trend_momentum(0.3, "bullish", 0.7, "bullish")
    assert m < 0.0


def test_momentum_direction_reversal():
    """Switching from bullish to bearish → strong negative momentum."""
    m = compute_trend_momentum(0.5, "bearish", 0.5, "bullish")
    assert m < 0.0
    assert m <= -0.5  # significant reversal


def test_momentum_clamped_to_bounds():
    """Momentum should be clamped to [-1, 1]."""
    m = compute_trend_momentum(1.0, "bullish", 1.0, "bearish")
    assert -1.0 <= m <= 1.0


# ---------------------------------------------------------------------------
# project_macro_decay
# ---------------------------------------------------------------------------


def test_macro_decay_empty_events():
    strength, direction = project_macro_decay([], 7.0)
    assert strength == 0.0
    assert direction == "neutral"


def test_macro_decay_short_term_rapid():
    """Short-term events decay rapidly (half-life = 1 day)."""
    event = _make_macro_event(
        impact_score=0.8, direction="negative",
        estimated_duration="short_term", severity="high", age_hours=0.0,
    )
    s_1d, _ = project_macro_decay([event], 1.0)
    s_7d, _ = project_macro_decay([event], 7.0)
    # After 7 days, short-term event should be much weaker
    assert s_7d < s_1d


def test_macro_decay_long_term_slow():
    """Long-term events decay slowly (half-life = 30 days)."""
    event = _make_macro_event(
        impact_score=0.8, direction="negative",
        estimated_duration="long_term", severity="high", age_hours=0.0,
    )
    s_1d, _ = project_macro_decay([event], 1.0)
    s_7d, _ = project_macro_decay([event], 7.0)
    # Long-term event should retain most of its strength after 7 days
    assert s_7d > s_1d * 0.5


def test_macro_decay_direction_negative():
    event = _make_macro_event(direction="negative")
    _, direction = project_macro_decay([event], 7.0)
    assert direction == "bearish"


def test_macro_decay_direction_positive():
    event = _make_macro_event(direction="positive")
    _, direction = project_macro_decay([event], 7.0)
    assert direction == "bullish"


def test_macro_decay_mixed_directions():
    """Mixed positive and negative events → mixed direction."""
    events = [
        _make_macro_event(direction="positive", impact_score=0.5, severity="high"),
        _make_macro_event(direction="negative", impact_score=0.5, severity="high"),
    ]
    _, direction = project_macro_decay(events, 7.0)
    assert direction == "mixed"


# ---------------------------------------------------------------------------
# compute_projection — basic behavior
# ---------------------------------------------------------------------------


def test_projection_basic_bullish():
    """A bullish trend with no macro events produces a bullish projection."""
    summary = _make_summary(TrendDirection.BULLISH, strength=0.6, confidence=0.7)
    proj = compute_projection(summary, macro_events=None, macro_enabled=True)

    assert proj.projected_direction == "bullish"
    assert 0.0 <= proj.projected_strength <= 1.0
    assert 0.0 <= proj.projected_confidence <= 1.0
    assert proj.projection_horizon == "7d"
    assert len(proj.driving_factors) > 0
    assert proj.diverges_from_current is False


def test_projection_basic_bearish():
    summary = _make_summary(TrendDirection.BEARISH, strength=0.5, confidence=0.6)
    proj = compute_projection(summary, macro_events=None, macro_enabled=True)

    assert proj.projected_direction == "bearish"
    assert proj.diverges_from_current is False


def test_projection_neutral_trend():
    summary = _make_summary(TrendDirection.NEUTRAL, strength=0.0, confidence=0.5)
    proj = compute_projection(summary, macro_events=None, macro_enabled=True)

    assert 0.0 <= proj.projected_strength <= 1.0
    assert len(proj.driving_factors) > 0


def test_projection_horizon_from_window():
    """Projection horizon should match the trend window."""
    for window, expected_horizon in [
        (TrendWindow.ONE_DAY, "1d"),
        (TrendWindow.SEVEN_DAY, "7d"),
        (TrendWindow.THIRTY_DAY, "30d"),
        (TrendWindow.NINETY_DAY, "30d"),
        (TrendWindow.INTRADAY, "1d"),
    ]:
        summary = _make_summary(window=window)
        proj = compute_projection(summary)
        assert proj.projection_horizon == expected_horizon


# ---------------------------------------------------------------------------
# compute_projection — divergence flagging
# ---------------------------------------------------------------------------


def test_projection_divergence_flagged():
    """When macro signals push projection opposite to current trend, flag divergence."""
    summary = _make_summary(TrendDirection.BULLISH, strength=0.3, confidence=0.6)
    # Strong negative macro events should push projection bearish
    events = [
        _make_macro_event(impact_score=0.9, direction="negative",
                          severity="critical", age_hours=2.0,
                          estimated_duration="medium_term"),
    ]
    proj = compute_projection(summary, macro_events=events, macro_enabled=True)

    if proj.projected_direction != "bullish":
        assert proj.diverges_from_current is True
        assert any("DIVERGENCE" in f for f in proj.driving_factors)


def test_projection_no_divergence_when_aligned():
    """When macro signals align with current trend, no divergence."""
    summary = _make_summary(TrendDirection.BEARISH, strength=0.5, confidence=0.7)
    events = [
        _make_macro_event(impact_score=0.7, direction="negative",
                          severity="high", age_hours=6.0),
    ]
    proj = compute_projection(summary, macro_events=events, macro_enabled=True)

    assert proj.projected_direction == "bearish"
    assert proj.diverges_from_current is False


# ---------------------------------------------------------------------------
# compute_projection — macro disabled
# ---------------------------------------------------------------------------


def test_projection_macro_disabled_reduced_confidence():
    """With macro disabled, projection confidence should be reduced."""
    summary = _make_summary(TrendDirection.BULLISH, strength=0.6, confidence=0.8)
    events = [_make_macro_event(impact_score=0.5, direction="negative")]

    proj_enabled = compute_projection(
        summary, macro_events=events, macro_enabled=True,
    )
    proj_disabled = compute_projection(
        summary, macro_events=events, macro_enabled=False,
    )

    assert proj_disabled.projected_confidence <= proj_enabled.projected_confidence


def test_projection_macro_disabled_zero_macro_contribution():
    """With macro disabled, macro_contribution_pct should be 0."""
    summary = _make_summary(TrendDirection.BULLISH, strength=0.6, confidence=0.7)
    events = [_make_macro_event()]

    proj = compute_projection(summary, macro_events=events, macro_enabled=False)
    assert proj.macro_contribution_pct == 0.0


def test_projection_macro_disabled_still_produces_projection():
    """Even with macro disabled, a projection is always produced."""
    summary = _make_summary(TrendDirection.BULLISH, strength=0.5, confidence=0.6)
    proj = compute_projection(summary, macro_events=None, macro_enabled=False)

    assert proj.projected_direction in {"bullish", "bearish", "mixed", "neutral"}
    assert 0.0 <= proj.projected_strength <= 1.0
    assert 0.0 <= proj.projected_confidence <= 1.0
    assert len(proj.driving_factors) > 0


# ---------------------------------------------------------------------------
# compute_projection — low confidence marking
# ---------------------------------------------------------------------------


def test_projection_low_confidence_marked():
    """Projections below confidence threshold are marked low_confidence."""
    summary = _make_summary(
        TrendDirection.NEUTRAL, strength=0.0, confidence=0.1,
    )
    proj = compute_projection(
        summary, macro_events=None, macro_enabled=False,
        confidence_threshold=DEFAULT_CONFIDENCE_THRESHOLD,
    )
    # Very low base confidence → projected confidence should be below threshold
    assert proj.low_confidence is True


def test_projection_above_threshold_not_low_confidence():
    """Projections above confidence threshold are NOT marked low_confidence."""
    summary = _make_summary(
        TrendDirection.BULLISH, strength=0.7, confidence=0.9,
    )
    proj = compute_projection(
        summary, macro_events=None, macro_enabled=True,
        confidence_threshold=DEFAULT_CONFIDENCE_THRESHOLD,
    )
    assert proj.low_confidence is False


# ---------------------------------------------------------------------------
# compute_projection — macro contribution
# ---------------------------------------------------------------------------


def test_projection_macro_contribution_nonzero_with_events():
    """When macro events are present and enabled, macro_contribution_pct > 0."""
    summary = _make_summary(TrendDirection.BULLISH, strength=0.5, confidence=0.7)
    events = [
        _make_macro_event(impact_score=0.7, direction="negative",
                          severity="high", age_hours=6.0),
    ]
    proj = compute_projection(summary, macro_events=events, macro_enabled=True)
    assert proj.macro_contribution_pct > 0.0


def test_projection_macro_contribution_zero_without_events():
    """Without macro events, macro_contribution_pct should be 0."""
    summary = _make_summary(TrendDirection.BULLISH, strength=0.5, confidence=0.7)
    proj = compute_projection(summary, macro_events=None, macro_enabled=True)
    assert proj.macro_contribution_pct == 0.0


# ---------------------------------------------------------------------------
# compute_projection — catalysts
# ---------------------------------------------------------------------------


def test_projection_with_upcoming_catalysts():
    """Upcoming catalysts should appear in driving_factors."""
    summary = _make_summary(TrendDirection.BULLISH, strength=0.5, confidence=0.7)
    proj = compute_projection(
        summary, macro_events=None, macro_enabled=True,
        upcoming_catalysts=["Q4 earnings report", "FDA approval decision"],
    )
    factor_text = " ".join(proj.driving_factors)
    assert "Q4 earnings report" in factor_text
    assert "FDA approval decision" in factor_text


# ---------------------------------------------------------------------------
# TrendProjection dataclass
# ---------------------------------------------------------------------------


def test_trend_projection_defaults():
    """TrendProjection should have sensible defaults."""
    proj = TrendProjection()
    assert proj.projected_direction == "neutral"
    assert proj.projected_strength == 0.5
    assert proj.projected_confidence == 0.5
    assert proj.projection_horizon == "7d"
    assert proj.driving_factors == []
    assert proj.macro_contribution_pct == 0.0
    assert proj.diverges_from_current is False
    assert proj.low_confidence is False


def test_projection_strength_bounds():
    """Projected strength should always be in [0, 1]."""
    # Test with extreme inputs
    summary = _make_summary(TrendDirection.BULLISH, strength=1.0, confidence=1.0)
    events = [
        _make_macro_event(impact_score=1.0, direction="positive",
                          severity="critical", age_hours=0.0),
    ]
    proj = compute_projection(
        summary, macro_events=events, macro_enabled=True,
        previous_strength=0.0, previous_direction="bearish",
    )
    assert 0.0 <= proj.projected_strength <= 1.0
    assert 0.0 <= proj.projected_confidence <= 1.0
