"""Tests for evolutionary strategy optimizer.

Test suite covering:
- 6 selection methods (Tournament, Uniform, Roulette, Rank, UCB1, Epsilon-Greedy)
- Genetic operators (mutation, crossover, population init)
- NSGA-II multi-objective optimization (Pareto fronts, crowding distance)
- Island model (migration, topologies)
- Convergence detection (patience, diversity collapse)
- Adaptive mutation (Rechenberg's 1/5 rule)
- Population diversity
- Generation statistics
- All Rust types (EvolutionConfig, Individual, ParamBound, etc.)
"""

import pytest
from collections import Counter


# ---------------------------------------------------------------------------
# 1. Tournament Selection
# ---------------------------------------------------------------------------


class TestTournamentSelect:
    def test_basic(self):
        from horizon._horizon import tournament_select

        fitnesses = [0.1, 0.5, 0.3, 0.9, 0.2]
        selected = tournament_select(fitnesses, 3, 5, 42)
        assert len(selected) == 5
        for idx in selected:
            assert 0 <= idx < 5

    def test_empty(self):
        from horizon._horizon import tournament_select

        result = tournament_select([], 3, 5, 42)
        assert len(result) == 0

    def test_deterministic(self):
        from horizon._horizon import tournament_select

        fitnesses = [0.1, 0.5, 0.3, 0.9, 0.2]
        s1 = tournament_select(fitnesses, 3, 10, 42)
        s2 = tournament_select(fitnesses, 3, 10, 42)
        assert s1 == s2

    def test_different_seeds(self):
        from horizon._horizon import tournament_select

        fitnesses = [0.1, 0.5, 0.3, 0.9, 0.2]
        s1 = tournament_select(fitnesses, 3, 10, 42)
        s2 = tournament_select(fitnesses, 3, 10, 99)
        assert len(s1) == 10
        assert len(s2) == 10

    def test_single_individual(self):
        from horizon._horizon import tournament_select

        selected = tournament_select([0.5], 1, 5, 42)
        assert len(selected) == 5
        for idx in selected:
            assert idx == 0

    def test_selects_high_fitness(self):
        from horizon._horizon import tournament_select

        fitnesses = [0.0, 0.0, 0.0, 100.0, 0.0]
        selected = tournament_select(fitnesses, 5, 100, 42)
        counts = Counter(selected)
        assert counts[3] == max(counts.values())
        assert counts[3] > 50


# ---------------------------------------------------------------------------
# 2. Uniform Selection
# ---------------------------------------------------------------------------


class TestUniformSelect:
    def test_basic(self):
        from horizon._horizon import uniform_select

        fitnesses = [0.1, 0.5, 0.3, 0.9, 0.2]
        selected = uniform_select(fitnesses, 100, 42)
        assert len(selected) == 100
        unique = set(selected)
        assert len(unique) > 1

    def test_empty(self):
        from horizon._horizon import uniform_select

        assert len(uniform_select([], 5, 42)) == 0

    def test_deterministic(self):
        from horizon._horizon import uniform_select

        s1 = uniform_select([0.1, 0.5, 0.3], 50, 42)
        s2 = uniform_select([0.1, 0.5, 0.3], 50, 42)
        assert s1 == s2

    def test_uniform_distribution(self):
        from horizon._horizon import uniform_select

        fitnesses = [0.0, 0.0, 0.0, 100.0, 0.0]
        selected = uniform_select(fitnesses, 5000, 42)
        counts = Counter(selected)
        for i in range(5):
            assert counts[i] > 700  # roughly 1000 each, allow variance


# ---------------------------------------------------------------------------
# 3. Roulette Wheel Selection
# ---------------------------------------------------------------------------


class TestRouletteSelect:
    def test_biased_toward_high_fitness(self):
        from horizon._horizon import roulette_select

        fitnesses = [0.0, 0.0, 0.0, 100.0, 0.0]
        selected = roulette_select(fitnesses, 100, 42)
        counts = Counter(selected)
        assert counts[3] > 50

    def test_negative_fitnesses(self):
        from horizon._horizon import roulette_select

        fitnesses = [-10.0, -5.0, 0.0, 5.0]
        selected = roulette_select(fitnesses, 100, 42)
        assert len(selected) == 100
        counts = Counter(selected)
        assert counts[3] > 20

    def test_empty(self):
        from horizon._horizon import roulette_select

        assert len(roulette_select([], 5, 42)) == 0

    def test_deterministic(self):
        from horizon._horizon import roulette_select

        s1 = roulette_select([0.1, 0.5, 0.9], 50, 42)
        s2 = roulette_select([0.1, 0.5, 0.9], 50, 42)
        assert s1 == s2

    def test_equal_fitness(self):
        from horizon._horizon import roulette_select

        fitnesses = [1.0, 1.0, 1.0, 1.0]
        selected = roulette_select(fitnesses, 4000, 42)
        counts = Counter(selected)
        for i in range(4):
            assert counts[i] > 700


# ---------------------------------------------------------------------------
# 4. Rank Selection
# ---------------------------------------------------------------------------


class TestRankSelect:
    def test_biased_toward_best(self):
        from horizon._horizon import rank_select

        fitnesses = [0.1, 0.5, 0.3, 0.9, 0.2]
        selected = rank_select(fitnesses, 1000, 42)
        counts = Counter(selected)
        assert counts[3] > 200

    def test_empty(self):
        from horizon._horizon import rank_select

        assert len(rank_select([], 5, 42)) == 0

    def test_deterministic(self):
        from horizon._horizon import rank_select

        s1 = rank_select([0.1, 0.5, 0.9], 50, 42)
        s2 = rank_select([0.1, 0.5, 0.9], 50, 42)
        assert s1 == s2

    def test_rank_ignores_magnitude(self):
        from horizon._horizon import rank_select

        # Rank selection should give same probabilities regardless of magnitude
        s1 = rank_select([1.0, 2.0, 3.0], 10000, 42)
        s2 = rank_select([100.0, 200.0, 300.0], 10000, 42)
        # Same proportions (same seed, same ranks)
        c1 = Counter(s1)
        c2 = Counter(s2)
        assert c1 == c2


# ---------------------------------------------------------------------------
# 5. UCB1 Selection
# ---------------------------------------------------------------------------


class TestUcb1Select:
    def test_exploration_bonus(self):
        from horizon._horizon import ucb1_select

        fitnesses = [1.0, 1.0, 1.0, 1.0, 1.0]
        selection_counts = [100, 100, 100, 100, 1]
        selected = ucb1_select(fitnesses, selection_counts, 500, 1)
        assert selected[0] == 4  # least explored

    def test_exploitation(self):
        from horizon._horizon import ucb1_select

        fitnesses = [0.0, 0.0, 0.0, 10.0, 0.0]
        selection_counts = [10, 10, 10, 10, 10]
        selected = ucb1_select(fitnesses, selection_counts, 50, 1)
        assert selected[0] == 3

    def test_empty(self):
        from horizon._horizon import ucb1_select

        assert len(ucb1_select([], [], 0, 5)) == 0

    def test_multiple_selections(self):
        from horizon._horizon import ucb1_select

        fitnesses = [1.0, 2.0, 3.0]
        counts = [5, 5, 5]
        selected = ucb1_select(fitnesses, counts, 15, 3)
        assert len(selected) == 3

    def test_zero_selections(self):
        from horizon._horizon import ucb1_select

        fitnesses = [1.0, 2.0, 3.0]
        counts = [0, 0, 0]
        selected = ucb1_select(fitnesses, counts, 0, 1)
        assert len(selected) == 1


# ---------------------------------------------------------------------------
# 6. Epsilon-Greedy Selection
# ---------------------------------------------------------------------------


class TestEpsilonGreedySelect:
    def test_pure_exploitation(self):
        from horizon._horizon import epsilon_greedy_select

        fitnesses = [0.1, 0.5, 0.3, 0.9, 0.2]
        selected = epsilon_greedy_select(fitnesses, 0.0, 10, 42)
        for idx in selected:
            assert idx == 3

    def test_pure_exploration(self):
        from horizon._horizon import epsilon_greedy_select

        fitnesses = [0.1, 0.5, 0.3, 0.9, 0.2]
        selected = epsilon_greedy_select(fitnesses, 1.0, 100, 42)
        unique = set(selected)
        assert len(unique) > 1

    def test_empty(self):
        from horizon._horizon import epsilon_greedy_select

        assert len(epsilon_greedy_select([], 0.1, 5, 42)) == 0

    def test_deterministic(self):
        from horizon._horizon import epsilon_greedy_select

        s1 = epsilon_greedy_select([0.1, 0.5, 0.9], 0.3, 50, 42)
        s2 = epsilon_greedy_select([0.1, 0.5, 0.9], 0.3, 50, 42)
        assert s1 == s2

    def test_mixed_behavior(self):
        from horizon._horizon import epsilon_greedy_select

        fitnesses = [0.1, 0.5, 0.3, 0.9, 0.2]
        selected = epsilon_greedy_select(fitnesses, 0.5, 200, 42)
        counts = Counter(selected)
        # Best (3) should be selected most often
        assert counts[3] == max(counts.values())
        # But random exploration should give variety
        assert len(counts) > 1


# ---------------------------------------------------------------------------
# 7. Gaussian Mutation
# ---------------------------------------------------------------------------


class TestGaussianMutate:
    def test_basic(self):
        from horizon._horizon import gaussian_mutate

        genome = [0.5, 0.5, 0.5]
        mutated = gaussian_mutate(
            genome, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0],
            [False, False, False], 0.1, 1.0, 42,
        )
        assert len(mutated) == 3
        for v in mutated:
            assert 0.0 <= v <= 1.0

    def test_bounds(self):
        from horizon._horizon import gaussian_mutate

        genome = [0.0, 1.0]
        mutated = gaussian_mutate(
            genome, [0.0, 0.0], [1.0, 1.0],
            [False, False], 1.0, 1.0, 42,
        )
        for v in mutated:
            assert 0.0 <= v <= 1.0

    def test_discrete(self):
        from horizon._horizon import gaussian_mutate

        genome = [5.0]
        mutated = gaussian_mutate(
            genome, [0.0], [10.0], [True], 0.5, 1.0, 42,
        )
        assert (mutated[0] - round(mutated[0])) == pytest.approx(0.0, abs=1e-10)

    def test_no_mutation_at_zero_rate(self):
        from horizon._horizon import gaussian_mutate

        genome = [0.5, 0.5, 0.5]
        mutated = gaussian_mutate(
            genome, [0.0, 0.0, 0.0], [1.0, 1.0, 1.0],
            [False, False, False], 0.1, 0.0, 42,
        )
        for i in range(3):
            assert mutated[i] == pytest.approx(genome[i], abs=1e-10)

    def test_deterministic(self):
        from horizon._horizon import gaussian_mutate

        genome = [0.5, 0.5]
        m1 = gaussian_mutate(genome, [0.0, 0.0], [1.0, 1.0], [False, False], 0.1, 1.0, 42)
        m2 = gaussian_mutate(genome, [0.0, 0.0], [1.0, 1.0], [False, False], 0.1, 1.0, 42)
        assert m1 == m2


# ---------------------------------------------------------------------------
# 8. Uniform Crossover
# ---------------------------------------------------------------------------


class TestUniformCrossover:
    def test_basic(self):
        from horizon._horizon import uniform_crossover

        a = [0.0, 0.0, 0.0, 0.0]
        b = [1.0, 1.0, 1.0, 1.0]
        child = uniform_crossover(a, b, 0.5, 42)
        assert len(child) == 4
        for v in child:
            assert v == pytest.approx(0.0, abs=1e-10) or v == pytest.approx(1.0, abs=1e-10)

    def test_deterministic(self):
        from horizon._horizon import uniform_crossover

        a = [0.0, 0.0, 0.0, 0.0]
        b = [1.0, 1.0, 1.0, 1.0]
        c1 = uniform_crossover(a, b, 0.5, 42)
        c2 = uniform_crossover(a, b, 0.5, 42)
        assert c1 == c2

    def test_rate_zero(self):
        from horizon._horizon import uniform_crossover

        a = [0.0, 0.0, 0.0]
        b = [1.0, 1.0, 1.0]
        child = uniform_crossover(a, b, 0.0, 42)
        for v in child:
            assert v == pytest.approx(0.0, abs=1e-10)

    def test_rate_one(self):
        from horizon._horizon import uniform_crossover

        a = [0.0, 0.0, 0.0]
        b = [1.0, 1.0, 1.0]
        child = uniform_crossover(a, b, 1.0, 42)
        for v in child:
            assert v == pytest.approx(1.0, abs=1e-10)

    def test_mismatched_lengths(self):
        from horizon._horizon import uniform_crossover

        a = [0.0, 0.0]
        b = [1.0, 1.0, 1.0]
        child = uniform_crossover(a, b, 0.5, 42)
        assert len(child) == 2


# ---------------------------------------------------------------------------
# 9. Init Population
# ---------------------------------------------------------------------------


class TestInitPopulation:
    def test_basic(self):
        from horizon._horizon import init_population

        pop = init_population(
            [0.0, 0.0], [1.0, 10.0], [False, True], 20, 42,
        )
        assert len(pop) == 20
        for genome in pop:
            assert len(genome) == 2
            assert 0.0 <= genome[0] <= 1.0
            assert 0.0 <= genome[1] <= 10.0

    def test_bounds(self):
        from horizon._horizon import init_population

        pop = init_population(
            [5.0, -10.0], [10.0, 10.0], [False, False], 100, 42,
        )
        for genome in pop:
            assert 5.0 <= genome[0] <= 10.0
            assert -10.0 <= genome[1] <= 10.0

    def test_discrete(self):
        from horizon._horizon import init_population

        pop = init_population(
            [0.0], [10.0], [True], 50, 42,
        )
        for genome in pop:
            assert (genome[0] - round(genome[0])) == pytest.approx(0.0, abs=1e-10)

    def test_deterministic(self):
        from horizon._horizon import init_population

        pop1 = init_population([0.0], [1.0], [False], 10, 42)
        pop2 = init_population([0.0], [1.0], [False], 10, 42)
        assert pop1 == pop2

    def test_different_seeds(self):
        from horizon._horizon import init_population

        pop1 = init_population([0.0], [1.0], [False], 10, 42)
        pop2 = init_population([0.0], [1.0], [False], 10, 99)
        assert pop1 != pop2

    def test_single_individual(self):
        from horizon._horizon import init_population

        pop = init_population([0.0, 0.0], [1.0, 1.0], [False, False], 1, 42)
        assert len(pop) == 1
        assert len(pop[0]) == 2


# ---------------------------------------------------------------------------
# 10. NSGA-II: Non-Dominated Sort
# ---------------------------------------------------------------------------


class TestNonDominatedSort:
    def test_basic(self):
        from horizon._horizon import non_dominated_sort

        objectives = [
            [3.0, 1.0],
            [1.0, 3.0],
            [2.0, 2.0],
            [1.0, 1.0],
        ]
        fronts = non_dominated_sort(objectives)
        assert len(fronts) == 2
        assert len(fronts[0]) == 3
        assert len(fronts[1]) == 1
        assert 3 in fronts[1]

    def test_empty(self):
        from horizon._horizon import non_dominated_sort

        fronts = non_dominated_sort([])
        assert len(fronts) == 0

    def test_single(self):
        from horizon._horizon import non_dominated_sort

        fronts = non_dominated_sort([[1.0, 2.0]])
        assert len(fronts) == 1
        assert fronts[0] == [0]

    def test_all_dominated(self):
        from horizon._horizon import non_dominated_sort

        objectives = [
            [3.0, 3.0],
            [2.0, 2.0],
            [1.0, 1.0],
        ]
        fronts = non_dominated_sort(objectives)
        assert len(fronts) == 3
        assert fronts[0] == [0]
        assert fronts[1] == [1]
        assert fronts[2] == [2]

    def test_all_non_dominated(self):
        from horizon._horizon import non_dominated_sort

        objectives = [
            [3.0, 1.0],
            [1.0, 3.0],
            [2.0, 2.0],
        ]
        fronts = non_dominated_sort(objectives)
        assert len(fronts) == 1
        assert len(fronts[0]) == 3

    def test_three_objectives(self):
        from horizon._horizon import non_dominated_sort

        objectives = [
            [3.0, 1.0, 2.0],
            [1.0, 3.0, 2.0],
            [1.0, 1.0, 1.0],
        ]
        fronts = non_dominated_sort(objectives)
        assert len(fronts) == 2
        assert 2 in fronts[1]


# ---------------------------------------------------------------------------
# 11. NSGA-II: Crowding Distance
# ---------------------------------------------------------------------------


class TestCrowdingDistance:
    def test_basic(self):
        from horizon._horizon import crowding_distance_assign

        objectives = [
            [1.0, 3.0],
            [2.0, 2.0],
            [3.0, 1.0],
        ]
        distances = crowding_distance_assign(objectives, [0, 1, 2])
        assert len(distances) == 3
        assert distances[0] == float("inf")
        assert distances[2] == float("inf")
        assert distances[1] > 0.0
        assert distances[1] < float("inf")

    def test_two_points(self):
        from horizon._horizon import crowding_distance_assign

        distances = crowding_distance_assign([[1.0, 2.0], [3.0, 4.0]], [0, 1])
        assert distances[0] == float("inf")
        assert distances[1] == float("inf")

    def test_empty(self):
        from horizon._horizon import crowding_distance_assign

        distances = crowding_distance_assign([], [])
        assert len(distances) == 0

    def test_single(self):
        from horizon._horizon import crowding_distance_assign

        distances = crowding_distance_assign([[1.0, 2.0]], [0])
        assert len(distances) == 1
        assert distances[0] == float("inf")

    def test_same_objectives(self):
        from horizon._horizon import crowding_distance_assign

        objectives = [[1.0, 1.0], [1.0, 1.0], [1.0, 1.0]]
        distances = crowding_distance_assign(objectives, [0, 1, 2])
        # Boundary get infinity, interior gets 0 since range is 0
        assert distances[0] == float("inf")
        assert distances[2] == float("inf")


# ---------------------------------------------------------------------------
# 12. NSGA-II: Selection
# ---------------------------------------------------------------------------


class TestNsga2Select:
    def test_basic(self):
        from horizon._horizon import nsga2_select

        objectives = [
            [3.0, 1.0],
            [1.0, 3.0],
            [2.0, 2.0],
            [1.0, 1.0],
            [0.5, 0.5],
        ]
        result = nsga2_select(objectives, 3)
        assert len(result.selected_indices) == 3
        assert 4 not in result.selected_indices

    def test_pareto_front(self):
        from horizon._horizon import nsga2_select

        objectives = [
            [3.0, 1.0],
            [1.0, 3.0],
            [2.0, 2.0],
            [1.0, 1.0],
        ]
        result = nsga2_select(objectives, 4)
        assert len(result.pareto_front_indices) == 3
        assert 3 not in result.pareto_front_indices

    def test_fronts_returned(self):
        from horizon._horizon import nsga2_select

        objectives = [[3.0, 3.0], [2.0, 2.0], [1.0, 1.0]]
        result = nsga2_select(objectives, 2)
        assert len(result.fronts) == 3

    def test_empty(self):
        from horizon._horizon import nsga2_select

        result = nsga2_select([], 3)
        assert len(result.selected_indices) == 0

    def test_repr(self):
        from horizon._horizon import nsga2_select

        result = nsga2_select([[1.0, 2.0]], 1)
        r = repr(result)
        assert "NsgaResult" in r


# ---------------------------------------------------------------------------
# 13. Pareto Front
# ---------------------------------------------------------------------------


class TestParetoFront:
    def test_basic(self):
        from horizon._horizon import pareto_front

        objectives = [
            [3.0, 1.0],
            [1.0, 3.0],
            [2.0, 2.0],
            [1.0, 1.0],
        ]
        front = pareto_front(objectives)
        assert len(front) == 3
        assert 3 not in front

    def test_empty(self):
        from horizon._horizon import pareto_front

        front = pareto_front([])
        assert len(front) == 0

    def test_single(self):
        from horizon._horizon import pareto_front

        front = pareto_front([[1.0, 2.0]])
        assert front == [0]


# ---------------------------------------------------------------------------
# 14. Island Model
# ---------------------------------------------------------------------------


class TestIslandMigrate:
    def test_ring(self):
        from horizon._horizon import island_migrate, IslandConfig, MigrationTopology

        islands = [
            [[1.0], [2.0]],
            [[3.0], [4.0]],
        ]
        fitnesses = [[0.5, 1.0], [0.3, 0.8]]
        config = IslandConfig(2, 5, 1, MigrationTopology.Ring)
        result = island_migrate(islands, fitnesses, config)
        assert len(result[0]) == 3
        assert len(result[1]) == 3

    def test_fully_connected(self):
        from horizon._horizon import island_migrate, IslandConfig, MigrationTopology

        islands = [[[1.0]], [[2.0]], [[3.0]]]
        fitnesses = [[1.0], [1.0], [1.0]]
        config = IslandConfig(3, 5, 1, MigrationTopology.FullyConnected)
        result = island_migrate(islands, fitnesses, config)
        for island in result:
            assert len(island) == 3

    def test_single_island(self):
        from horizon._horizon import island_migrate, IslandConfig, MigrationTopology

        islands = [[[1.0], [2.0]]]
        fitnesses = [[0.5, 1.0]]
        config = IslandConfig(1, 5, 1, MigrationTopology.Ring)
        result = island_migrate(islands, fitnesses, config)
        assert len(result[0]) == 2

    def test_migration_count(self):
        from horizon._horizon import island_migrate, IslandConfig, MigrationTopology

        islands = [
            [[1.0], [2.0], [3.0]],
            [[4.0], [5.0], [6.0]],
        ]
        fitnesses = [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]]
        config = IslandConfig(2, 5, 2, MigrationTopology.Ring)
        result = island_migrate(islands, fitnesses, config)
        assert len(result[0]) == 5  # 3 + 2 migrants
        assert len(result[1]) == 5


class TestIslandSelectionMethods:
    def test_basic(self):
        from horizon._horizon import island_selection_methods, SelectionMethod

        methods = island_selection_methods(4)
        assert len(methods) == 4
        assert methods[0] == SelectionMethod.RouletteWheel
        assert methods[1] == SelectionMethod.Rank
        assert methods[2] == SelectionMethod.Ucb1
        assert methods[3] == SelectionMethod.EpsilonGreedy

    def test_wraps(self):
        from horizon._horizon import island_selection_methods, SelectionMethod

        methods = island_selection_methods(6)
        assert methods[4] == SelectionMethod.RouletteWheel
        assert methods[5] == SelectionMethod.Rank

    def test_single(self):
        from horizon._horizon import island_selection_methods

        methods = island_selection_methods(1)
        assert len(methods) == 1


class TestIslandConfig:
    def test_creation(self):
        from horizon._horizon import IslandConfig, MigrationTopology

        config = IslandConfig(4, 5, 2, MigrationTopology.Ring)
        assert config.n_islands == 4
        assert config.migration_interval == 5
        assert config.migration_count == 2
        assert config.topology == MigrationTopology.Ring

    def test_defaults(self):
        from horizon._horizon import IslandConfig, MigrationTopology

        config = IslandConfig()
        assert config.n_islands == 4
        assert config.migration_interval == 5
        assert config.migration_count == 2
        assert config.topology == MigrationTopology.Ring

    def test_repr(self):
        from horizon._horizon import IslandConfig

        config = IslandConfig()
        r = repr(config)
        assert "IslandConfig" in r


# ---------------------------------------------------------------------------
# 15. Convergence Detection
# ---------------------------------------------------------------------------


class TestConvergence:
    def test_patience_triggered(self):
        from horizon._horizon import check_convergence

        state = check_convergence(1.0, 1.0, 1.0, 19, 20, 1e-4, 0.01)
        assert state.converged
        assert "No improvement" in state.reason

    def test_improvement_resets(self):
        from horizon._horizon import check_convergence

        state = check_convergence(2.0, 1.0, 1.0, 19, 20, 1e-4, 0.01)
        assert not state.converged
        assert state.generations_without_improvement == 0

    def test_diversity_collapse(self):
        from horizon._horizon import check_convergence

        state = check_convergence(1.0, 0.005, 1.0, 0, 20, 1e-4, 0.01)
        assert state.converged
        assert "Diversity" in state.reason

    def test_not_converged(self):
        from horizon._horizon import check_convergence

        state = check_convergence(1.0, 1.0, 1.0, 5, 20, 1e-4, 0.01)
        assert not state.converged
        assert state.generations_without_improvement == 6

    def test_repr(self):
        from horizon._horizon import check_convergence

        state = check_convergence(1.0, 1.0, 1.0, 0, 20, 1e-4, 0.01)
        r = repr(state)
        assert "ConvergenceState" in r

    def test_small_improvement_ignored(self):
        from horizon._horizon import check_convergence

        # Improvement of 1e-5 < min_improvement of 1e-4
        state = check_convergence(1.00001, 1.0, 1.0, 5, 20, 1e-4, 0.01)
        assert state.generations_without_improvement == 6


# ---------------------------------------------------------------------------
# 16. Adaptive Mutation
# ---------------------------------------------------------------------------


class TestAdaptiveMutation:
    def test_increase_on_high_success(self):
        from horizon._horizon import adapt_mutation_sigma

        state = adapt_mutation_sigma(0.1, 30, 100, 0.2, 1.5, 0.001, 2.0)
        assert state.sigma > 0.1
        assert state.sigma == pytest.approx(0.15, abs=1e-10)

    def test_decrease_on_low_success(self):
        from horizon._horizon import adapt_mutation_sigma

        state = adapt_mutation_sigma(0.1, 10, 100, 0.2, 1.5, 0.001, 2.0)
        assert state.sigma < 0.1

    def test_bounds(self):
        from horizon._horizon import adapt_mutation_sigma

        state = adapt_mutation_sigma(1.9, 100, 100, 0.2, 1.5, 0.001, 2.0)
        assert state.sigma <= 2.0

        state2 = adapt_mutation_sigma(0.002, 0, 100, 0.2, 1.5, 0.001, 2.0)
        assert state2.sigma >= 0.001

    def test_zero_total(self):
        from horizon._horizon import adapt_mutation_sigma

        state = adapt_mutation_sigma(0.1, 0, 0, 0.2, 1.5, 0.001, 2.0)
        assert state.sigma == pytest.approx(0.1, abs=1e-10)

    def test_exact_target(self):
        from horizon._horizon import adapt_mutation_sigma

        state = adapt_mutation_sigma(0.1, 20, 100, 0.2, 1.5, 0.001, 2.0)
        assert state.sigma == pytest.approx(0.1, abs=1e-10)

    def test_success_rate(self):
        from horizon._horizon import adapt_mutation_sigma

        state = adapt_mutation_sigma(0.1, 30, 100)
        assert state.success_rate == pytest.approx(0.3, abs=1e-10)

    def test_repr(self):
        from horizon._horizon import adapt_mutation_sigma

        state = adapt_mutation_sigma(0.1, 10, 50)
        r = repr(state)
        assert "AdaptiveMutationState" in r


# ---------------------------------------------------------------------------
# 17. Population Diversity
# ---------------------------------------------------------------------------


class TestPopulationDiversity:
    def test_identical(self):
        from horizon._horizon import population_diversity

        pop = [[0.5, 0.5], [0.5, 0.5], [0.5, 0.5]]
        assert population_diversity(pop) == pytest.approx(0.0, abs=1e-10)

    def test_spread(self):
        from horizon._horizon import population_diversity

        pop = [[0.0, 0.0], [1.0, 1.0]]
        d = population_diversity(pop)
        assert d > 0.0

    def test_single(self):
        from horizon._horizon import population_diversity

        assert population_diversity([[0.5, 0.5]]) == pytest.approx(0.0, abs=1e-10)

    def test_empty(self):
        from horizon._horizon import population_diversity

        assert population_diversity([]) == pytest.approx(0.0, abs=1e-10)

    def test_more_diverse(self):
        from horizon._horizon import population_diversity

        tight = [[0.5, 0.5], [0.51, 0.51]]
        spread = [[0.0, 0.0], [1.0, 1.0]]
        assert population_diversity(spread) > population_diversity(tight)


# ---------------------------------------------------------------------------
# 18. Generation Statistics
# ---------------------------------------------------------------------------


class TestGenerationStats:
    def test_basic(self):
        from horizon._horizon import compute_generation_stats

        stats = compute_generation_stats(5, [0.1, 0.5, 0.3, 0.8, 0.2])
        assert stats.generation == 5
        assert stats.population_size == 5
        assert stats.best_fitness == pytest.approx(0.8, abs=1e-10)
        assert stats.worst_fitness == pytest.approx(0.1, abs=1e-10)
        assert stats.mean_fitness > 0.0
        assert stats.std_fitness > 0.0

    def test_empty(self):
        from horizon._horizon import compute_generation_stats

        stats = compute_generation_stats(0, [])
        assert stats.population_size == 0
        assert stats.best_fitness == pytest.approx(0.0, abs=1e-10)

    def test_identical(self):
        from horizon._horizon import compute_generation_stats

        stats = compute_generation_stats(0, [1.0, 1.0, 1.0])
        assert stats.std_fitness == pytest.approx(0.0, abs=1e-10)
        assert stats.diversity == pytest.approx(0.0, abs=1e-10)

    def test_repr(self):
        from horizon._horizon import compute_generation_stats

        stats = compute_generation_stats(3, [0.1, 0.5])
        r = repr(stats)
        assert "GenerationStats" in r

    def test_diversity_nonzero(self):
        from horizon._horizon import compute_generation_stats

        stats = compute_generation_stats(0, [0.1, 0.5, 0.9])
        assert stats.diversity > 0.0


# ---------------------------------------------------------------------------
# 19. Rust Types
# ---------------------------------------------------------------------------


class TestEvolutionConfig:
    def test_creation(self):
        from horizon._horizon import EvolutionConfig

        config = EvolutionConfig(
            pop_size=50, generations=100,
            mutation_rate=0.1, crossover_rate=0.7,
            tournament_size=3, elite_count=2, sigma=0.1,
        )
        assert config.pop_size == 50
        assert config.generations == 100
        assert config.mutation_rate == pytest.approx(0.1, abs=1e-10)

    def test_defaults(self):
        from horizon._horizon import EvolutionConfig

        config = EvolutionConfig()
        assert config.pop_size == 50
        assert config.generations == 100

    def test_repr(self):
        from horizon._horizon import EvolutionConfig

        config = EvolutionConfig()
        r = repr(config)
        assert "EvolutionConfig" in r


class TestIndividual:
    def test_creation(self):
        from horizon._horizon import Individual

        ind = Individual(genome=[0.5, 0.3, 0.8], fitness=1.5)
        assert ind.genome == [0.5, 0.3, 0.8]
        assert ind.fitness == pytest.approx(1.5, abs=1e-10)

    def test_default_fitness(self):
        from horizon._horizon import Individual

        ind = Individual(genome=[0.5])
        assert ind.fitness == float("-inf")

    def test_repr(self):
        from horizon._horizon import Individual

        ind = Individual(genome=[0.5, 0.3], fitness=1.0)
        r = repr(ind)
        assert "Individual" in r
        assert "dim=2" in r


class TestParamBound:
    def test_creation(self):
        from horizon._horizon import ParamBound

        pb = ParamBound(name="spread", min_val=0.01, max_val=0.10, is_discrete=False)
        assert pb.name == "spread"
        assert pb.min_val == pytest.approx(0.01, abs=1e-10)
        assert pb.max_val == pytest.approx(0.10, abs=1e-10)
        assert pb.is_discrete is False

    def test_discrete(self):
        from horizon._horizon import ParamBound

        pb = ParamBound(name="window", min_val=5.0, max_val=100.0, is_discrete=True)
        assert pb.is_discrete is True

    def test_default_discrete(self):
        from horizon._horizon import ParamBound

        pb = ParamBound(name="test", min_val=0.0, max_val=1.0)
        assert pb.is_discrete is False

    def test_repr(self):
        from horizon._horizon import ParamBound

        pb = ParamBound(name="sigma", min_val=0.0, max_val=1.0)
        r = repr(pb)
        assert "ParamBound" in r
        assert "sigma" in r


class TestNsgaIndividual:
    def test_creation(self):
        from horizon._horizon import NsgaIndividual

        ind = NsgaIndividual(index=0, objectives=[1.0, 2.0])
        assert ind.index == 0
        assert ind.objectives == [1.0, 2.0]
        assert ind.rank == 0
        assert ind.crowding_distance == pytest.approx(0.0, abs=1e-10)

    def test_repr(self):
        from horizon._horizon import NsgaIndividual

        ind = NsgaIndividual(index=5, objectives=[1.0, 2.0])
        r = repr(ind)
        assert "NsgaIndividual" in r


# ---------------------------------------------------------------------------
# 20. Enum Types
# ---------------------------------------------------------------------------


class TestEnumTypes:
    def test_selection_method(self):
        from horizon._horizon import SelectionMethod

        assert SelectionMethod.Tournament != SelectionMethod.Ucb1
        assert repr(SelectionMethod.Ucb1) == "SelectionMethod.Ucb1"

    def test_migration_topology(self):
        from horizon._horizon import MigrationTopology

        assert MigrationTopology.Ring != MigrationTopology.FullyConnected
        assert repr(MigrationTopology.Ring) == "MigrationTopology.Ring"

    def test_objective_direction(self):
        from horizon._horizon import ObjectiveDirection

        assert ObjectiveDirection.Maximize != ObjectiveDirection.Minimize
        assert repr(ObjectiveDirection.Maximize) == "ObjectiveDirection.Maximize"

    def test_selection_method_hashable(self):
        from horizon._horizon import SelectionMethod

        s = {SelectionMethod.Tournament, SelectionMethod.Ucb1}
        assert SelectionMethod.Tournament in s

    def test_all_selection_methods_exist(self):
        from horizon._horizon import SelectionMethod

        methods = [
            SelectionMethod.Tournament,
            SelectionMethod.Uniform,
            SelectionMethod.RouletteWheel,
            SelectionMethod.Rank,
            SelectionMethod.Ucb1,
            SelectionMethod.EpsilonGreedy,
        ]
        assert len(methods) == 6


# ---------------------------------------------------------------------------
# 21. Python API
# ---------------------------------------------------------------------------


class TestPythonSelectParents:
    def test_dispatch_tournament(self):
        from horizon.evolve import _select_parents
        from horizon._horizon import SelectionMethod

        result = _select_parents([0.1, 0.5, 0.9], SelectionMethod.Tournament, 5, 42)
        assert len(result) == 5

    def test_dispatch_ucb1(self):
        from horizon.evolve import _select_parents
        from horizon._horizon import SelectionMethod

        result = _select_parents(
            [0.1, 0.5, 0.9], SelectionMethod.Ucb1, 3, 42,
            selection_counts=[5, 5, 1], total_selections=11,
        )
        assert len(result) == 3

    def test_dispatch_all_methods(self):
        from horizon.evolve import _select_parents
        from horizon._horizon import SelectionMethod

        fitnesses = [0.1, 0.5, 0.3, 0.9, 0.2]
        for method in [
            SelectionMethod.Tournament, SelectionMethod.Uniform,
            SelectionMethod.RouletteWheel, SelectionMethod.Rank,
            SelectionMethod.Ucb1, SelectionMethod.EpsilonGreedy,
        ]:
            result = _select_parents(fitnesses, method, 5, 42)
            assert len(result) == 5


class TestResolveSelectionMethod:
    def test_string_mapping(self):
        from horizon.evolve import _resolve_selection_method
        from horizon._horizon import SelectionMethod

        assert _resolve_selection_method("tournament") == SelectionMethod.Tournament
        assert _resolve_selection_method("ucb1") == SelectionMethod.Ucb1
        assert _resolve_selection_method("roulette") == SelectionMethod.RouletteWheel
        assert _resolve_selection_method("roulette_wheel") == SelectionMethod.RouletteWheel
        assert _resolve_selection_method("rank") == SelectionMethod.Rank
        assert _resolve_selection_method("uniform") == SelectionMethod.Uniform
        assert _resolve_selection_method("epsilon_greedy") == SelectionMethod.EpsilonGreedy
        assert _resolve_selection_method("epsilon-greedy") == SelectionMethod.EpsilonGreedy

    def test_passthrough(self):
        from horizon.evolve import _resolve_selection_method
        from horizon._horizon import SelectionMethod

        assert _resolve_selection_method(SelectionMethod.Ucb1) == SelectionMethod.Ucb1

    def test_unknown_defaults_tournament(self):
        from horizon.evolve import _resolve_selection_method
        from horizon._horizon import SelectionMethod

        assert _resolve_selection_method("invalid") == SelectionMethod.Tournament
