from __future__ import annotations

from dataclasses import dataclass

from app.services.recommendation_service import (
    _build_reasoning,
    _choose_recommended_action,
    _prune_floor_pinned_targets,
    _select_primary_target,
    direction_analyzer,
    probability_estimator,
    signal_conflict_detector,
    target_generator,
)

_DEFAULT_CFG = {
    "recommendation_high_confidence_threshold": 70.0,
    "recommendation_moderate_confidence_threshold": 50.0,
    "recommendation_confidence_diff_threshold": 20.0,
}


@dataclass
class _SRLevelStub:
    id: int
    price_level: float
    type: str
    strength: int


def test_high_confidence_long_example():
    dimension_scores = {
        "technical": 75.0,
        "momentum": 68.0,
        "fundamental": 55.0,
    }

    confidence = direction_analyzer.calculate_confidence(
        direction="long",
        dimension_scores=dimension_scores,
        sentiment_classification="bullish",
        conflicts=[],
    )

    assert confidence > 70.0


def test_high_confidence_short_example():
    dimension_scores = {
        "technical": 30.0,
        "momentum": 35.0,
        "fundamental": 45.0,
    }

    confidence = direction_analyzer.calculate_confidence(
        direction="short",
        dimension_scores=dimension_scores,
        sentiment_classification="bearish",
        conflicts=[],
    )

    assert confidence > 70.0


def test_short_confidence_low_on_strongly_bullish_stock():
    """The CNC case: technical 92 / momentum 99 must make SHORT confidence low."""
    dims = {"technical": 92.0, "momentum": 99.0, "fundamental": 96.0}

    short_conf = direction_analyzer.calculate_confidence(
        direction="short", dimension_scores=dims, sentiment_classification="neutral", conflicts=[],
    )
    long_conf = direction_analyzer.calculate_confidence(
        direction="long", dimension_scores=dims, sentiment_classification="neutral", conflicts=[],
    )

    assert short_conf < 20.0   # not 55
    assert long_conf > 90.0


def test_action_neutral_when_bias_direction_has_no_setup():
    """Strong LONG bias but only a SHORT setup is tradeable → NEUTRAL, not LONG_HIGH."""
    action = _choose_recommended_action(
        long_confidence=100.0, short_confidence=5.0, config=_DEFAULT_CFG,
        available_directions={"short"},
    )
    assert action == "NEUTRAL"

    # With the long setup available, the same numbers give LONG_HIGH
    action_ok = _choose_recommended_action(
        long_confidence=100.0, short_confidence=5.0, config=_DEFAULT_CFG,
        available_directions={"long", "short"},
    )
    assert action_ok == "LONG_HIGH"


def test_reasoning_explains_missing_setup():
    reasoning = _build_reasoning(
        action="NEUTRAL", long_confidence=100.0, short_confidence=5.0, conflicts=[],
        dimension_scores={"technical": 92.0, "momentum": 99.0},
        sentiment_classification="neutral", config=_DEFAULT_CFG,
        available_directions={"short"},
    )
    assert "bias is LONG" in reasoning
    assert "no high-conviction long setup" in reasoning.lower()


def test_primary_target_is_most_likely_worthwhile_not_lottery():
    targets = [
        {"price": 110.0, "rr_ratio": 2.0, "probability": 65.0},   # worthwhile, most likely ← primary
        {"price": 120.0, "rr_ratio": 3.5, "probability": 50.0},
        {"price": 140.0, "rr_ratio": 6.0, "probability": 15.0},   # far lottery — not chosen
    ]
    primary = _select_primary_target(targets, min_rr=1.5)
    assert primary is not None
    assert primary["price"] == 110.0


def test_primary_target_skips_sub_threshold_rr():
    targets = [
        {"price": 102.0, "rr_ratio": 1.0, "probability": 95.0},   # high prob but trivial R:R — skipped
        {"price": 115.0, "rr_ratio": 2.5, "probability": 60.0},   # most likely above the R:R floor ← primary
    ]
    primary = _select_primary_target(targets, min_rr=1.5)
    assert primary is not None
    assert primary["price"] == 115.0


def test_primary_target_none_when_empty():
    assert _select_primary_target([], min_rr=1.5) is None


def test_primary_target_never_headlines_a_lottery():
    # After a run-up: the only target clearing the R:R floor is a near-impossible
    # far level. The primary must NOT be that lottery — fall back to the most
    # likely target overall, so the headline R:R is honest (and the activation
    # gate rejects the setup on min_rr instead of being gamed).
    targets = [
        {"price": 101.0, "rr_ratio": 0.9, "probability": 55.0},   # likely, no asymmetry
        {"price": 140.0, "rr_ratio": 5.0, "probability": 3.0},    # asymmetric lottery
    ]
    primary = _select_primary_target(targets, min_rr=1.5)
    assert primary is not None
    assert primary["price"] == 101.0


def test_primary_target_requires_probability_floor():
    # A worthwhile-R:R target below the probability floor loses to one that
    # clears both floors, even at lower R:R.
    targets = [
        {"price": 130.0, "rr_ratio": 4.0, "probability": 12.0},   # asymmetric but unlikely
        {"price": 112.0, "rr_ratio": 1.8, "probability": 38.0},   # clears both floors ← primary
    ]
    primary = _select_primary_target(targets, min_rr=1.5)
    assert primary is not None
    assert primary["price"] == 112.0


def test_primary_target_uses_activation_rr_not_scanner_floor():
    targets = [
        {"price": 108.0, "rr_ratio": 1.6, "probability": 60.0},
        {"price": 112.0, "rr_ratio": 2.2, "probability": 35.0},
    ]
    primary = _select_primary_target(targets, min_rr=2.0)
    assert primary is not None
    assert primary["price"] == 112.0


def test_prune_keeps_only_nearest_floor_pinned_target():
    # Two targets pinned at the 3% clamp floor are indistinguishable to the
    # model — only the nearest survives; farther ones are duplicate noise.
    targets = [
        {"price": 204.0, "rr_ratio": 0.7, "probability": 25.6},
        {"price": 241.0, "rr_ratio": 2.0, "probability": 3.0},
        {"price": 272.0, "rr_ratio": 3.1, "probability": 3.0},
    ]
    pruned = _prune_floor_pinned_targets(targets)
    assert [t["price"] for t in pruned] == [204.0, 241.0]


def test_prune_leaves_targets_above_floor_untouched():
    targets = [
        {"price": 110.0, "rr_ratio": 2.0, "probability": 65.0},
        {"price": 120.0, "rr_ratio": 3.5, "probability": 20.0},
    ]
    assert _prune_floor_pinned_targets(targets) == targets


def test_prune_all_floor_pinned_keeps_nearest_only():
    targets = [
        {"price": 241.0, "rr_ratio": 2.0, "probability": 3.0},
        {"price": 272.0, "rr_ratio": 3.1, "probability": 3.0},
    ]
    pruned = _prune_floor_pinned_targets(targets)
    assert [t["price"] for t in pruned] == [241.0]


def test_detects_sentiment_technical_conflict():
    conflicts = signal_conflict_detector.detect_conflicts(
        dimension_scores={"technical": 72.0, "momentum": 55.0, "fundamental": 50.0},
        sentiment_classification="bearish",
    )

    assert any("sentiment-technical" in conflict for conflict in conflicts)


def test_generate_targets_respects_direction_and_order():
    sr_levels = [
        _SRLevelStub(id=1, price_level=110.0, type="resistance", strength=80),
        _SRLevelStub(id=2, price_level=115.0, type="resistance", strength=70),
        _SRLevelStub(id=3, price_level=120.0, type="resistance", strength=60),
        _SRLevelStub(id=4, price_level=95.0, type="support", strength=75),
    ]

    targets = target_generator.generate_targets(
        direction="long",
        entry_price=100.0,
        stop_loss=96.0,
        sr_levels=sr_levels,  # type: ignore[arg-type]
        atr_value=2.0,
    )

    assert len(targets) >= 1
    assert all(target["price"] > 100.0 for target in targets)
    distances = [target["distance_from_entry"] for target in targets]
    assert distances == sorted(distances)


def test_generate_targets_spreads_across_distance_bands():
    """Near levels must not be crowded out by far high-R:R ones — expect a mix
    of distance bands, including the nearest, not just the 5 farthest."""
    # entry 100, atr 2 → ATR multiples: 5→2.5, 8→4.0, 14→7.0, 24→12, 34→17, 44→22
    sr_levels = [
        _SRLevelStub(id=1, price_level=105.0, type="resistance", strength=60),   # 2.5 ATR (conservative)
        _SRLevelStub(id=2, price_level=108.0, type="resistance", strength=55),   # 4.0 ATR (moderate)
        _SRLevelStub(id=3, price_level=114.0, type="resistance", strength=90),   # 7.0 ATR (aggressive, strong)
        _SRLevelStub(id=4, price_level=124.0, type="resistance", strength=95),   # 12 ATR (aggressive, strong)
        _SRLevelStub(id=5, price_level=134.0, type="resistance", strength=92),   # 17 ATR (aggressive, strong)
        _SRLevelStub(id=6, price_level=144.0, type="resistance", strength=88),   # 22 ATR (aggressive, strong)
    ]

    targets = target_generator.generate_targets(
        direction="long", entry_price=100.0, stop_loss=96.0,
        sr_levels=sr_levels, atr_value=2.0,  # type: ignore[arg-type]
    )

    multiples = [t["distance_atr_multiple"] for t in targets]
    # The nearest (conservative) and a moderate target survive despite the
    # strong far levels that would dominate a pure top-5-by-quality pick.
    assert any(m <= 2.9 for m in multiples), "expected a conservative (near) target"
    assert any(2.9 < m <= 4.6 for m in multiples), "expected a moderate target"
    assert any(m > 4.6 for m in multiples), "expected an aggressive (far) target"


def test_probability_decreases_with_distance():
    """A far target must be far less likely than a near one — no 90% at +39%."""
    config = {
        "recommendation_signal_alignment_weight": 0.15,
        "recommendation_sr_strength_weight": 0.20,
    }
    dims = {"technical": 50.0, "momentum": 50.0}  # neutral, isolate the distance term

    def prob(atr_multiple: float, strength: float = 50.0) -> float:
        return probability_estimator.estimate_probability(
            {"sr_strength": strength, "distance_atr_multiple": atr_multiple},
            dims, None, "long", config,
        )

    near = prob(1.5)
    mid = prob(4.0)
    far = prob(10.0)

    # Monotonic decay with distance
    assert near > mid > far
    # Backtest-calibrated: even a near target with no R:R context (even race) is
    # only a moderate probability, and a 10-ATR target is a long shot.
    assert near > 30
    assert far < 15


def test_far_target_not_high_probability_even_with_strong_level():
    """The AJG case: a far target stays low-probability even at max strength."""
    config = {"recommendation_sr_strength_weight": 0.20, "recommendation_signal_alignment_weight": 0.15}
    # ~10 ATR away, strongest possible level, fully aligned bullish
    p = probability_estimator.estimate_probability(
        {"sr_strength": 100, "distance_atr_multiple": 10.0},
        {"technical": 80.0, "momentum": 80.0}, "bullish", "long", config,
    )
    assert p < 40   # nowhere near 90


def test_classify_by_probability_thresholds():
    from app.services.recommendation_service import _classify_by_probability
    assert _classify_by_probability(75) == "Conservative"
    assert _classify_by_probability(50) == "Moderate"
    assert _classify_by_probability(20) == "Aggressive"


def test_zone_representative_levels_merges_wall():
    """Near-duplicate resistances collapse to one zone with combined strength."""
    from types import SimpleNamespace
    from app.services.recommendation_service import _zone_representative_levels

    levels = [
        SimpleNamespace(id=1, price_level=183.0, type="resistance", strength=60),
        SimpleNamespace(id=2, price_level=185.0, type="resistance", strength=60),
        SimpleNamespace(id=3, price_level=200.0, type="resistance", strength=50),
    ]
    reps = _zone_representative_levels(levels, entry_price=180.0)

    # 183/185 merge into one zone; 200 stays separate → 2 representatives
    assert len(reps) == 2
    wall = min(reps, key=lambda r: r.price_level)
    assert wall.price_level == 183.0          # near edge of the wall
    assert wall.strength == 100               # 60 + 60, capped at 100
    far = max(reps, key=lambda r: r.price_level)
    assert far.price_level == 200.0
    assert far.strength == 50


def test_zone_representative_levels_singletons_unchanged():
    from types import SimpleNamespace
    from app.services.recommendation_service import _zone_representative_levels

    levels = [
        SimpleNamespace(id=1, price_level=120.0, type="resistance", strength=70),
        SimpleNamespace(id=2, price_level=150.0, type="resistance", strength=40),
    ]
    reps = _zone_representative_levels(levels, entry_price=100.0)
    assert len(reps) == 2
    assert {round(r.price_level) for r in reps} == {120, 150}


def test_zone_representative_levels_soft_strength_avoids_resaturation():
    from types import SimpleNamespace
    from app.services.recommendation_service import _zone_representative_levels

    levels = [
        SimpleNamespace(
            id=1, price_level=183.0, type="resistance", strength=60,
            detection_method="pivot_point", sources=["pivot_point"],
            rejection_count=3, last_rejection_age=5,
        ),
        SimpleNamespace(
            id=2, price_level=185.0, type="resistance", strength=60,
            detection_method="round_number", sources=["round_number"],
            rejection_count=1, last_rejection_age=10,
        ),
    ]
    reps = _zone_representative_levels(
        levels, entry_price=180.0, strength_mode="soft"
    )
    assert len(reps) == 1
    assert reps[0].strength == 65
    assert set(reps[0].sources) == {"pivot_point", "round_number"}
    assert reps[0].rejection_count == 3
