"""Tests for GEPA genetic evolution"""

import pytest

from agentkit.evolution.genetic import (
    CrossoverOperator,
    FitnessScore,
    GEPAPopulation,
    MutationOperator,
    PromptChromosome,
)
from agentkit.evolution.prompt_optimizer import Module, Signature


class TestFitnessScore:
    """FitnessScore unit tests"""

    def test_dominates(self):
        a = FitnessScore(accuracy=0.9, latency_ms=100, cost_tokens=500)
        b = FitnessScore(accuracy=0.7, latency_ms=200, cost_tokens=1000)
        assert a.dominates(b)
        assert not b.dominates(a)

    def test_no_dominance_equal(self):
        a = FitnessScore(accuracy=0.8, latency_ms=100)
        b = FitnessScore(accuracy=0.8, latency_ms=100)
        assert not a.dominates(b)
        assert not b.dominates(a)

    def test_partial_dominance(self):
        a = FitnessScore(accuracy=0.9, latency_ms=200)  # Higher accuracy but slower
        b = FitnessScore(accuracy=0.7, latency_ms=100)  # Faster but lower accuracy
        assert not a.dominates(b)  # a is not >= b in all dimensions
        assert not b.dominates(a)  # b is not >= a in all dimensions

    def test_normalized_values(self):
        score = FitnessScore(accuracy=0.8, latency_ms=1000, cost_tokens=2000)
        n = score.normalized
        assert n["accuracy"] == 0.8
        assert 0 < n["latency"] < 1
        assert 0 < n["cost"] < 1

    def test_zero_fitness(self):
        score = FitnessScore()
        assert score.accuracy == 0.0
        n = score.normalized
        assert n["accuracy"] == 0.0


class TestPromptChromosome:
    """PromptChromosome unit tests"""

    def test_from_module(self):
        module = Module(
            name="test",
            signature=Signature(
                input_fields={"query": "user query"},
                output_fields={"answer": "response"},
                instruction="Answer the question.\n- Must be accurate\n- Never hallucinate",
            ),
            demos=[{"input": "test", "output": "result"}],
        )
        chromosome = PromptChromosome.from_module(module)
        assert "Answer the question" in chromosome.instructions
        assert len(chromosome.constraints) >= 1
        assert len(chromosome.demos) == 1

    def test_to_module(self):
        chromosome = PromptChromosome(
            instructions="Test instruction",
            demos=[{"input": "q", "output": "a"}],
            constraints=["Be accurate"],
        )
        module = chromosome.to_module("test_module")
        assert module.name == "test_module"
        assert "Test instruction" in module.signature.instruction
        assert len(module.demos) == 1

    def test_default_values(self):
        c = PromptChromosome()
        assert c.instructions == ""
        assert c.demos == []
        assert c.constraints == []
        assert c.generation == 0
        assert c.fitness.accuracy == 0.0


class TestCrossoverOperator:
    """CrossoverOperator unit tests"""

    def setup_method(self):
        self.crossover = CrossoverOperator()

    def test_crossover_produces_child(self):
        parent_a = PromptChromosome(
            instructions="Instruction A paragraph 1\n\nInstruction A paragraph 2",
            demos=[{"input": "a1", "output": "r1"}],
            constraints=["Constraint A"],
        )
        parent_b = PromptChromosome(
            instructions="Instruction B paragraph 1\n\nInstruction B paragraph 2",
            demos=[{"input": "b1", "output": "r2"}],
            constraints=["Constraint B"],
        )

        child = self.crossover.crossover(parent_a, parent_b)
        assert child.generation == 1
        assert len(child.parent_ids) == 2
        assert parent_a.id in child.parent_ids
        assert parent_b.id in child.parent_ids

    def test_crossover_preserves_content(self):
        parent_a = PromptChromosome(instructions="A", demos=[], constraints=["C1"])
        parent_b = PromptChromosome(instructions="B", demos=[], constraints=["C2"])

        child = self.crossover.crossover(parent_a, parent_b, crossover_rate=0.0)
        # With rate=0, should take from parent_a
        assert child.instructions == "A"

    def test_crossover_demos(self):
        parent_a = PromptChromosome(
            demos=[{"input": "a1", "output": "r1"}, {"input": "a2", "output": "r2"}],
        )
        parent_b = PromptChromosome(
            demos=[{"input": "b1", "output": "r3"}],
        )

        child = self.crossover.crossover(parent_a, parent_b)
        # Child should have demos from both parents
        assert len(child.demos) >= 0  # May be empty due to rate filtering

    def test_crossover_constraints(self):
        parent_a = PromptChromosome(constraints=["C1", "C2"])
        parent_b = PromptChromosome(constraints=["C3", "C4"])

        child = self.crossover.crossover(parent_a, parent_b)
        # Child should have some constraints from parents
        assert isinstance(child.constraints, list)


class TestMutationOperator:
    """MutationOperator unit tests"""

    def setup_method(self):
        self.mutation = MutationOperator()

    @pytest.mark.asyncio
    async def test_mutate_returns_new_chromosome(self):
        original = PromptChromosome(
            instructions="Test instruction",
            demos=[{"input": "q", "output": "a"}],
            constraints=["Be accurate"],
        )
        mutated = await self.mutation.mutate(original, mutation_rate=1.0)
        assert mutated.parent_ids == [original.id]
        assert mutated.generation == original.generation

    @pytest.mark.asyncio
    async def test_mutate_with_zero_rate(self):
        original = PromptChromosome(
            instructions="Test instruction",
            demos=[{"input": "q", "output": "a"}],
            constraints=["Be accurate"],
        )
        mutated = await self.mutation.mutate(original, mutation_rate=0.0)
        # With rate=0, should be identical
        assert mutated.instructions == original.instructions
        assert mutated.demos == original.demos
        assert mutated.constraints == original.constraints

    @pytest.mark.asyncio
    async def test_demo_mutation(self):
        original = PromptChromosome(
            demos=[
                {"input": "q1", "output": "a1"},
                {"input": "q2", "output": "a2"},
                {"input": "q3", "output": "a3"},
            ],
        )
        mutated_demos = self.mutation._mutate_demos(original.demos)
        assert isinstance(mutated_demos, list)

    @pytest.mark.asyncio
    async def test_constraint_mutation_add(self):
        constraints = ["Be accurate"]
        mutated = self.mutation._mutate_constraints(constraints)
        assert isinstance(mutated, list)

    @pytest.mark.asyncio
    async def test_constraint_mutation_remove(self):
        constraints = ["C1", "C2", "C3"]
        mutated = self.mutation._mutate_constraints(constraints)
        assert isinstance(mutated, list)


class TestGEPAPopulation:
    """GEPAPopulation unit tests"""

    def setup_method(self):
        self.population = GEPAPopulation(population_size=6, elite_size=2, tournament_size=3)

    def test_initialize_with_seed(self):
        seed = PromptChromosome(instructions="You are a helpful assistant.")
        self.population.initialize(seed)
        assert self.population.size == 6
        assert self.population.generation == 0

    def test_initialize_without_seed(self):
        self.population.initialize()
        assert self.population.size == 6

    def test_get_elite(self):
        self.population.initialize()
        # Set fitness scores
        for i, ind in enumerate(self.population.individuals):
            ind.fitness = FitnessScore(accuracy=i * 0.1)

        elite = self.population.get_elite()
        assert len(elite) == 2
        assert elite[0].fitness.accuracy >= elite[1].fitness.accuracy

    def test_tournament_select(self):
        self.population.initialize()
        for i, ind in enumerate(self.population.individuals):
            ind.fitness = FitnessScore(accuracy=i * 0.1)

        selected = self.population.tournament_select()
        assert isinstance(selected, PromptChromosome)

    def test_tournament_select_empty_population(self):
        with pytest.raises(ValueError, match="Population is empty"):
            self.population.tournament_select()

    def test_get_best(self):
        self.population.initialize()
        for i, ind in enumerate(self.population.individuals):
            ind.fitness = FitnessScore(accuracy=i * 0.1)

        best = self.population.get_best()
        assert best.fitness.accuracy == 0.5  # Last individual (index 5 * 0.1)

    def test_evolve(self):
        self.population.initialize()
        for i, ind in enumerate(self.population.individuals):
            ind.fitness = FitnessScore(accuracy=i * 0.1)

        crossover = CrossoverOperator()
        mutation = MutationOperator()

        new_gen = self.population.evolve(crossover, mutation)
        assert self.population.generation == 1
        assert len(new_gen) == 6

    def test_multiple_generations(self):
        self.population.initialize()
        for i, ind in enumerate(self.population.individuals):
            ind.fitness = FitnessScore(accuracy=i * 0.1)

        crossover = CrossoverOperator()
        mutation = MutationOperator()

        for _ in range(5):
            self.population.evolve(crossover, mutation)
            # Re-evaluate fitness (simulated)
            for i, ind in enumerate(self.population.individuals):
                ind.fitness = FitnessScore(accuracy=min(1.0, i * 0.1 + 0.3))

        assert self.population.generation == 5

    def test_get_pareto_front(self):
        self.population.initialize()
        # Set diverse fitness
        self.population.individuals[0].fitness = FitnessScore(accuracy=0.9, latency_ms=500)
        self.population.individuals[1].fitness = FitnessScore(accuracy=0.7, latency_ms=100)
        self.population.individuals[2].fitness = FitnessScore(accuracy=0.5, latency_ms=50)
        self.population.individuals[3].fitness = FitnessScore(accuracy=0.3, latency_ms=30)
        self.population.individuals[4].fitness = FitnessScore(accuracy=0.8, latency_ms=200)
        self.population.individuals[5].fitness = FitnessScore(accuracy=0.6, latency_ms=150)

        front = self.population.get_pareto_front()
        assert len(front) >= 1
        # The front should contain non-dominated individuals

    def test_get_statistics(self):
        self.population.initialize()
        for i, ind in enumerate(self.population.individuals):
            ind.fitness = FitnessScore(accuracy=i * 0.1 + 0.3)

        stats = self.population.get_statistics()
        assert stats["generation"] == 0
        assert stats["size"] == 6
        assert "best_accuracy" in stats
        assert "avg_accuracy" in stats

    def test_get_statistics_empty(self):
        stats = self.population.get_statistics()
        assert stats["size"] == 0

    def test_add_individual(self):
        self.population.initialize()
        initial_size = self.population.size
        new_individual = PromptChromosome(instructions="New individual")
        self.population.add(new_individual)
        assert self.population.size == initial_size + 1
