"""
Unit tests for app/services/model_runners.py.

The core property under test: every run_* function must NEVER raise an
exception to its caller, regardless of what goes wrong internally
(missing model, malformed prediction, etc). This is the contract that
makes the aggregator's parallel execution safe — see
app/services/aggregator.py and its docstring for why this matters.
"""

from unittest.mock import MagicMock

import numpy as np
import pytest

from app.services import model_runners


class TestRunClimateModel:
    def test_returns_forecast_for_known_district(self):
        registry = MagicMock()
        mock_model = MagicMock()
        mock_forecast_series = MagicMock()
        mock_forecast_series.iloc = [150.0]
        mock_model.forecast.return_value = mock_forecast_series
        mock_model.data.endog = np.array([100, 110, 120] * 4)
        registry.get.return_value = {"thanjavur": mock_model}

        result = model_runners.run_climate_model(registry, "thanjavur")

        assert result["forecast_rainfall_mm"] == 150.0
        assert result["degraded"] is False

    def test_returns_degraded_for_unknown_district(self):
        registry = MagicMock()
        registry.get.return_value = {}

        result = model_runners.run_climate_model(registry, "atlantis")

        assert result["degraded"] is True
        assert result["forecast_rainfall_mm"] is None

    def test_never_raises_on_model_exception(self):
        registry = MagicMock()
        mock_model = MagicMock()
        mock_model.forecast.side_effect = RuntimeError("model exploded")
        registry.get.return_value = {"thanjavur": mock_model}

        result = model_runners.run_climate_model(registry, "thanjavur")

        assert result["degraded"] is True
        assert result["error"] == "climate_model_failed"


class TestRunFeasibilityModel:
    def test_returns_predicted_label_and_confidence(self, sample_feature_vector):
        registry = MagicMock()
        mock_model = MagicMock()
        mock_model.predict_proba.return_value = np.array([[0.1, 0.7, 0.2]])
        mock_le = MagicMock()
        mock_le.inverse_transform.return_value = ["suitable"]
        mock_le.classes_ = ["not_suitable", "suitable", "risky"]
        registry.get.return_value = {"model": mock_model, "label_encoder": mock_le}

        result = model_runners.run_feasibility_model(registry, sample_feature_vector)

        assert result["label"] == "suitable"
        assert result["confidence"] == 0.7
        assert result["degraded"] is False

    def test_never_raises_on_missing_model(self, sample_feature_vector):
        registry = MagicMock()
        registry.get.side_effect = KeyError("model not found")

        result = model_runners.run_feasibility_model(registry, sample_feature_vector)

        assert result["degraded"] is True
        assert result["label"] == "unknown"


class TestRunYieldModel:
    def test_clamps_negative_predictions_to_zero(self, sample_feature_vector):
        registry = MagicMock()
        mock_model = MagicMock()
        mock_model.predict.return_value = np.array([-1.5])
        registry.get.return_value = {"model": mock_model, "feature_cols": []}

        result = model_runners.run_yield_model(registry, sample_feature_vector)

        assert result["expected_yield_ton_ha"] == 0.0

    def test_returns_positive_prediction_unchanged(self, sample_feature_vector):
        registry = MagicMock()
        mock_model = MagicMock()
        mock_model.predict.return_value = np.array([3.8])
        registry.get.return_value = {"model": mock_model, "feature_cols": []}

        result = model_runners.run_yield_model(registry, sample_feature_vector)

        assert result["expected_yield_ton_ha"] == 3.8


class TestRunRecommendationModel:
    def test_excludes_the_queried_crop_from_alternatives(self):
        registry = MagicMock()
        mock_model = MagicMock()
        mock_model.predict_proba.return_value = np.array([[0.2, 0.8]])
        mock_model.classes_ = ["low", "high"]
        registry.get.return_value = {"model": mock_model, "classes": ["low", "high"]}

        def fake_build_feature_fn(district, crop, month):
            return np.zeros(12)

        result = model_runners.run_recommendation_model(
            registry, "thanjavur", 6, fake_build_feature_fn, exclude_crop="rice"
        )

        crop_names = [alt["crop"] for alt in result["alternatives"]]
        assert "rice" not in crop_names

    def test_continues_when_one_candidate_crop_fails(self):
        registry = MagicMock()
        mock_model = MagicMock()
        mock_model.predict_proba.return_value = np.array([[0.2, 0.8]])
        registry.get.return_value = {"model": mock_model, "classes": ["low", "high"]}

        call_count = {"n": 0}

        def flaky_build_feature_fn(district, crop, month):
            call_count["n"] += 1
            if call_count["n"] == 1:
                raise ValueError("simulated failure for first crop")
            return np.zeros(12)

        result = model_runners.run_recommendation_model(
            registry, "thanjavur", 6, flaky_build_feature_fn, exclude_crop="nothing"
        )

        # Should not raise, and should still produce some alternatives
        # from the candidates that didn't fail.
        assert result["degraded"] is False
        assert isinstance(result["alternatives"], list)
