"""
TDD: Evolution Engine Tests.

Tests the genetic algorithm for behavioral parameter optimization:
- Fitness computation from session outcomes
- Genome mutation within safety bounds
- Exploitation (lock winning params) vs. exploration (mutate losing params)
- Hard safety bounds enforcement
- Qdrant persistence (mocked)
- Block penalty severity
"""

import random
from unittest.mock import patch

import pytest

from GramAddict.core.evolution_engine import SAFETY_BOUNDS, EvolutionEngine, Genome, SessionResult


@pytest.fixture
def engine():
    """Fresh Evolution Engine with mocked Qdrant."""
    EvolutionEngine.reset()
    with (
        patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None),
        patch(
            "GramAddict.core.qdrant_memory.QdrantBase.is_connected", new_callable=lambda: property(lambda self: False)
        ),
    ):
        e = EvolutionEngine("test_user")
        e._qdrant_connected = False  # Force offline mode
        yield e
    EvolutionEngine.reset()


@pytest.fixture
def genome():
    """Default genome for isolated tests."""
    return Genome()


class TestGenome:
    """Genome serialization and construction."""

    def test_default_genome_has_valid_params(self, genome):
        """Default genome parameters must be within safety bounds."""
        for param_name, (low, high) in SAFETY_BOUNDS.items():
            value = getattr(genome, param_name)
            assert low <= value <= high, f"{param_name}: {value} not in [{low}, {high}]"

    def test_genome_to_dict_roundtrip(self, genome):
        """Genome must survive dict serialization roundtrip."""
        d = genome.to_dict()
        restored = Genome.from_dict(d)

        for param_name in SAFETY_BOUNDS:
            assert getattr(genome, param_name) == getattr(restored, param_name)

    def test_genome_from_dict_ignores_unknown_keys(self):
        """Forward-compatibility: unknown keys in dict must be ignored."""
        d = Genome().to_dict()
        d["future_param_2027"] = 42.0

        # Must not raise
        genome = Genome.from_dict(d)
        assert not hasattr(genome, "future_param_2027")


class TestFitnessComputation:
    """Fitness function correctness."""

    def test_perfect_session_high_fitness(self, engine):
        """A session with max follows, zero blocks → high fitness."""
        result = SessionResult(
            follows_gained=20,
            likes_given=50,
            stories_viewed=20,
            blocks_received=0,
            duration_minutes=60,
            prediction_error_rate=0.0,
        )
        fitness = engine.compute_fitness(result)
        assert fitness >= 0.9

    def test_blocked_session_near_zero_fitness(self, engine):
        """A session with a block → severe fitness penalty."""
        result = SessionResult(
            follows_gained=10,
            likes_given=30,
            blocks_received=1,
            duration_minutes=30,
        )
        fitness = engine.compute_fitness(result)
        # Block penalty: 0.5^1 = 0.5 multiplier
        assert fitness <= 0.5

    def test_double_block_catastrophic(self, engine):
        """Two blocks in a session → fitness near zero."""
        result = SessionResult(blocks_received=2)
        fitness = engine.compute_fitness(result)
        # Block penalty: 0.5^2 = 0.25 multiplier on already low base
        assert fitness < 0.1

    def test_empty_session_zero_fitness(self, engine):
        """A session with zero interactions → zero fitness."""
        result = SessionResult()
        fitness = engine.compute_fitness(result)
        # No follows, likes, stories, short duration → near zero
        # But accuracy bonus is 1.0 (no errors), so 0.25 * 1.0 = 0.25
        assert 0.0 <= fitness <= 0.3

    def test_fitness_always_bounded(self, engine):
        """Fitness must always be in [0.0, 1.0]."""
        for _ in range(50):
            result = SessionResult(
                follows_gained=random.randint(0, 50),
                likes_given=random.randint(0, 100),
                blocks_received=random.randint(0, 5),
                duration_minutes=random.uniform(0, 180),
                prediction_error_rate=random.uniform(0, 1),
            )
            fitness = engine.compute_fitness(result)
            assert 0.0 <= fitness <= 1.0

    def test_high_prediction_errors_reduce_fitness(self, engine):
        """High prediction error rate should reduce fitness."""
        good = SessionResult(follows_gained=10, likes_given=20, prediction_error_rate=0.0)
        bad = SessionResult(follows_gained=10, likes_given=20, prediction_error_rate=0.8)

        fitness_good = engine.compute_fitness(good)
        fitness_bad = engine.compute_fitness(bad)

        assert fitness_good > fitness_bad


class TestEvolution:
    """Exploitation vs. exploration behavior."""

    def test_improved_fitness_locks_genome(self, engine):
        """Fitness improvement should preserve (lock) current parameters."""
        original_params = engine.genome.to_dict()

        result = SessionResult(
            follows_gained=15,
            likes_given=40,
            duration_minutes=45,
            blocks_received=0,
            prediction_error_rate=0.1,
        )
        engine.evolve(result)

        # Parameters should be unchanged (locked)
        for param_name in SAFETY_BOUNDS:
            assert getattr(engine.genome, param_name) == original_params[param_name]

        # Fitness should be stored
        assert engine.genome.best_fitness > 0

    def test_regressed_fitness_triggers_mutation(self, engine):
        """Fitness regression should trigger parameter mutation."""
        # First, set a high best_fitness
        engine.genome.best_fitness = 0.95

        # Now evolve with a bad session
        result = SessionResult(follows_gained=0, blocks_received=1, duration_minutes=5)

        # Force mutation to be deterministic
        random.seed(42)
        engine.evolve(result)

        # At least one parameter should have changed (with high probability)
        # Note: with mutation_rate=0.15 and 8 params, ~1-2 params change on average
        # With seed 42, this is deterministic
        assert engine.genome.generation == 1

    def test_generation_increments_on_evolve(self, engine):
        """Generation counter must increment on every evolve() call."""
        assert engine.genome.generation == 0

        engine.evolve(SessionResult())
        assert engine.genome.generation == 1

        engine.evolve(SessionResult())
        assert engine.genome.generation == 2


class TestMutation:
    """Mutation respects safety bounds."""

    def test_mutation_stays_within_bounds(self, engine):
        """All mutations must respect hard safety bounds."""
        for _ in range(100):
            engine._mutate(mutation_rate=1.0)  # Force all params to mutate

            for param_name, (low, high) in SAFETY_BOUNDS.items():
                value = getattr(engine.genome, param_name)
                assert low <= value <= high, (
                    f"Mutation violated safety bounds! " f"{param_name}: {value} not in [{low}, {high}]"
                )

    def test_mutation_changes_at_least_one_param(self, engine):
        """With mutation_rate=1.0, at least one param must change."""
        original = engine.genome.to_dict()
        engine._mutate(mutation_rate=1.0)
        current = engine.genome.to_dict()

        changed = any(original[p] != current[p] for p in SAFETY_BOUNDS)
        assert changed, "100% mutation rate should change at least one parameter"

    def test_zero_mutation_rate_changes_nothing(self, engine):
        """With mutation_rate=0.0, no params should change."""
        original = engine.genome.to_dict()
        engine._mutate(mutation_rate=0.0)
        current = engine.genome.to_dict()

        for param_name in SAFETY_BOUNDS:
            assert original[param_name] == current[param_name]

    def test_integer_params_stay_integer(self, engine):
        """Integer parameters must remain integers after mutation."""
        for _ in range(50):
            engine._mutate(mutation_rate=1.0)

            assert isinstance(engine.genome.max_follows_per_session, int)
            assert isinstance(engine.genome.max_likes_per_session, int)


class TestParameterAccess:
    """External parameter access API."""

    def test_get_param_returns_current_value(self, engine):
        """get_param must return the current genome value."""
        assert engine.get_param("scroll_correction_probability") == 0.15
        assert engine.get_param("resonance_threshold") == 0.7

    def test_get_param_default_for_unknown(self, engine):
        """Unknown params must return the default value."""
        assert engine.get_param("nonexistent_param", 42) == 42
        assert engine.get_param("nonexistent_param") is None


class TestSingleton:
    """Singleton lifecycle management."""

    def test_get_instance_creates_singleton(self):
        """get_instance should return the same object."""
        EvolutionEngine.reset()
        with (
            patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None),
            patch(
                "GramAddict.core.qdrant_memory.QdrantBase.is_connected",
                new_callable=lambda: property(lambda self: False),
            ),
        ):
            e1 = EvolutionEngine.get_instance("test")
            e2 = EvolutionEngine.get_instance("test")
            assert e1 is e2
        EvolutionEngine.reset()

    def test_reset_clears_singleton(self):
        """reset() must clear the singleton."""
        EvolutionEngine.reset()
        with (
            patch("GramAddict.core.qdrant_memory.QdrantBase.__init__", return_value=None),
            patch(
                "GramAddict.core.qdrant_memory.QdrantBase.is_connected",
                new_callable=lambda: property(lambda self: False),
            ),
        ):
            e1 = EvolutionEngine.get_instance("test")
            EvolutionEngine.reset()
            e2 = EvolutionEngine.get_instance("test")
            assert e1 is not e2
        EvolutionEngine.reset()
