import numpy as np
import pytest

from pwml.utilities.classificationhelpers import BinaryClassifierHelper, MulticlassClassifierOptimizer


# ---------------------------------------------------------------------------
# BinaryClassifierHelper metric edge cases
# ---------------------------------------------------------------------------

class TestRecall:
    def test_normal(self):
        assert BinaryClassifierHelper.recall(tn=50, fp=10, fn=5, tp=35) == pytest.approx(35 / 40)

    def test_no_positives_returns_zero(self):
        """When there are no positive ground-truth instances recall is 0, not 1."""
        assert BinaryClassifierHelper.recall(tn=50, fp=0, fn=0, tp=0) == 0.0

    def test_all_tp(self):
        assert BinaryClassifierHelper.recall(tn=0, fp=0, fn=0, tp=10) == 1.0


class TestPrecision:
    def test_normal(self):
        assert BinaryClassifierHelper.precision(tn=50, fp=5, fn=10, tp=35) == pytest.approx(35 / 40)

    def test_no_predictions(self):
        """No positive predictions: precision is 1 (vacuously true)."""
        assert BinaryClassifierHelper.precision(tn=50, fp=0, fn=5, tp=0) == 1.0

    def test_all_fp(self):
        assert BinaryClassifierHelper.precision(tn=0, fp=10, fn=0, tp=0) == 0.0


class TestF1:
    def test_normal(self):
        tp, fp, fn = 35, 5, 10
        expected = tp / (tp + 0.5 * (fp + fn))
        assert BinaryClassifierHelper.f1(tn=50, fp=fp, fn=fn, tp=tp) == pytest.approx(expected)

    def test_all_zero_returns_zero_with_warning(self):
        """Edge case: all zero confusion matrix cells - F1 is undefined, returns 0.0."""
        import warnings
        with warnings.catch_warnings(record=True) as caught:
            warnings.simplefilter('always')
            result = BinaryClassifierHelper.f1(tn=0, fp=0, fn=0, tp=0)
        assert result == 0.0
        assert any(issubclass(w.category, UserWarning) for w in caught)


class TestAccuracy:
    def test_normal(self):
        assert BinaryClassifierHelper.accuracy(tn=50, fp=5, fn=10, tp=35) == pytest.approx(85 / 100)

    def test_all_zero_returns_zero(self):
        assert BinaryClassifierHelper.accuracy(tn=0, fp=0, fn=0, tp=0) == 0.0


# ---------------------------------------------------------------------------
# MulticlassClassifierOptimizer.predict_from_score
# ---------------------------------------------------------------------------

class TestPredictFromScore:
    def _make_thresholds(self, n, value=0.5):
        return np.full(n, value)

    def test_single_clear_winner(self):
        """One class clearly above threshold."""
        thresholds = self._make_thresholds(3)
        y_score = np.array([[0.1, 0.8, 0.1]])
        result = MulticlassClassifierOptimizer.predict_from_score(thresholds, y_score)
        assert result.shape == (1, 3)
        assert result[0, 1] == 1.0
        assert result[0, 0] == 0.0
        assert result[0, 2] == 0.0

    def test_no_winner_picks_highest_ratio(self):
        """All scores below threshold: class with highest score/threshold ratio wins."""
        thresholds = self._make_thresholds(3, value=0.9)
        y_score = np.array([[0.3, 0.5, 0.2]])
        result = MulticlassClassifierOptimizer.predict_from_score(thresholds, y_score)
        assert result[0, 1] == 1.0  # class 1 has highest score

    def test_no_division_by_zero_with_small_threshold(self):
        """predict_from_score must not raise with near-zero thresholds (clip guard)."""
        thresholds = np.array([1e-12, 0.5, 0.5])
        y_score = np.array([[0.0, 0.6, 0.3]])
        result = MulticlassClassifierOptimizer.predict_from_score(thresholds, y_score)
        assert not np.any(np.isnan(result))
        assert not np.any(np.isinf(result))

    def test_output_is_valid_one_hot(self):
        """Each row of the output should sum to exactly 1."""
        rng = np.random.default_rng(42)
        thresholds = rng.uniform(0.3, 0.7, size=4)
        y_score = rng.dirichlet(np.ones(4), size=20)
        result = MulticlassClassifierOptimizer.predict_from_score(thresholds, y_score)
        row_sums = result.sum(axis=1)
        np.testing.assert_array_equal(row_sums, np.ones(20))


# ---------------------------------------------------------------------------
# InputFeature validation
# ---------------------------------------------------------------------------

class TestInputFeatureValidation:
    def test_valid_types_accepted(self):
        from pwml.classifiers.features import InputFeature
        for ft in ('category', 'numeric', 'text', 'longtext'):
            f = InputFeature(feature_name='x', feature_type=ft)
            assert f.feature_type == ft

    def test_invalid_type_raises(self):
        from pwml.classifiers.features import InputFeature
        with pytest.raises(ValueError, match='feature_type'):
            InputFeature(feature_name='x', feature_type='categorical')

    def test_typo_raises(self):
        from pwml.classifiers.features import InputFeature
        with pytest.raises(ValueError):
            InputFeature(feature_name='x', feature_type='Text')
