"""Unit tests for evolution algorithms module.

This module tests the genetic algorithm, genetic programming,
NSGA-II, and fitness evaluation components.
"""

import numpy as np
import pytest
from datetime import datetime

from openclaw.evolution.engine import (
    EvolutionConfig,
    EvolutionEngine,
    EvolutionAlgorithm,
    EvolutionStatus,
)
from openclaw.evolution.genetic_algorithm import (
    GeneticAlgorithm,
    GAConfig,
    Chromosome,
    SelectionOperator,
    CrossoverOperator,
    MutationOperator,
)
from openclaw.evolution.genetic_programming import (
    GeneticProgramming,
    GPConfig,
    Node,
    NodeType,
    TreeChromosome,
)
from openclaw.evolution.nsga2 import (
    NSGA2,
    NSGA2Config,
    Individual,
    ObjectiveValue,
    ParetoFront,
)
from openclaw.evolution.fitness import (
    FitnessEvaluator,
    FitnessMetrics,
)


class TestEvolutionConfig:
    """Test EvolutionConfig dataclass."""

    def test_default_config(self):
        """Test default configuration values."""
        config = EvolutionConfig()

        assert config.population_size == 100
        assert config.max_generations == 500
        assert config.crossover_rate == 0.8
        assert config.mutation_rate == 0.1
        assert config.elite_size == 5

    def test_custom_config(self):
        """Test custom configuration."""
        config = EvolutionConfig(
            population_size=50,
            max_generations=200,
            crossover_rate=0.7,
            mutation_rate=0.2,
        )

        assert config.population_size == 50
        assert config.max_generations == 200
        assert config.crossover_rate == 0.7
        assert config.mutation_rate == 0.2

    def test_invalid_population_size(self):
        """Test that small population size raises error."""
        with pytest.raises(ValueError, match="Population size must be at least"):
            EvolutionConfig(population_size=5)

    def test_invalid_crossover_rate(self):
        """Test that invalid crossover rate raises error."""
        with pytest.raises(ValueError, match="Crossover rate must be between"):
            EvolutionConfig(crossover_rate=1.5)

    def test_invalid_mutation_rate(self):
        """Test that invalid mutation rate raises error."""
        with pytest.raises(ValueError, match="Mutation rate must be between"):
            EvolutionConfig(mutation_rate=-0.1)

    def test_invalid_elite_size(self):
        """Test that elite size >= population raises error."""
        with pytest.raises(ValueError, match="Elite size must be less than"):
            EvolutionConfig(population_size=50, elite_size=50)


class TestEvolutionEngine:
    """Test EvolutionEngine class."""

    def test_initialization(self):
        """Test engine initialization."""
        config = EvolutionConfig(population_size=20)

        def fitness_func(genes):
            return np.sum(genes ** 2)

        engine = EvolutionEngine(
            config=config,
            algorithm=EvolutionAlgorithm.GA,
            fitness_func=fitness_func,
        )

        assert engine.config == config
        assert engine.algorithm == EvolutionAlgorithm.GA
        assert engine.status == EvolutionStatus.IDLE
        assert len(engine.population) == 0

    def test_population_initialization(self):
        """Test population initialization."""
        config = EvolutionConfig(population_size=20)

        def fitness_func(genes):
            return -np.sum((genes - 5) ** 2)

        def init_func():
            return Chromosome(genes=np.random.randn(5))

        engine = EvolutionEngine(
            config=config,
            algorithm=EvolutionAlgorithm.GA,
            fitness_func=fitness_func,
        )

        engine.initialize_population(init_func)

        assert len(engine.population) == 20
        assert engine.best_individual is not None

    def test_run_without_initialization_raises(self):
        """Test that running without initialization raises error."""
        config = EvolutionConfig(population_size=20)

        engine = EvolutionEngine(
            config=config,
            algorithm=EvolutionAlgorithm.GA,
            fitness_func=lambda x: 1.0,
        )

        with pytest.raises(ValueError, match="Population not initialized"):
            engine.run()

    def test_simple_optimization(self):
        """Test simple optimization problem."""
        config = EvolutionConfig(
            population_size=30,
            max_generations=50,
            elite_size=2,
        )

        # Optimize f(x) = -(x - 3)^2, max at x = 3
        def fitness_func(genes):
            x = genes[0]
            return -(x - 3) ** 2

        def init_func():
            return Chromosome(genes=np.random.uniform(-10, 10, 1))

        engine = EvolutionEngine(
            config=config,
            algorithm=EvolutionAlgorithm.GA,
            fitness_func=fitness_func,
        )

        engine.initialize_population(init_func)
        monitor = engine.run()

        # Should find solution close to 3
        best = engine.get_best_individual()
        assert best is not None
        # best is Chromosome, genes attribute is the numpy array
        assert abs(best.genes[0] - 3) < 2.0
        assert len(monitor.best_fitness_history) > 0

    def test_callback_registration(self):
        """Test callback registration."""
        config = EvolutionConfig(population_size=20, max_generations=10)

        engine = EvolutionEngine(
            config=config,
            algorithm=EvolutionAlgorithm.GA,
            fitness_func=lambda x: 1.0,
        )

        calls = []
        def callback(generation, population, best_fitness, avg_fitness):
            calls.append(generation)

        engine.register_callback(callback)

        # Initialize and run for a few generations
        def init_func():
            return Chromosome(genes=np.random.randn(3))

        engine.initialize_population(init_func)
        engine.run()

        # Callback should have been called for each generation
        assert len(calls) == config.max_generations

    def test_population_stats(self):
        """Test getting population statistics."""
        config = EvolutionConfig(population_size=20)

        # Fitness function receives genes directly (numpy array)
        def fitness_func(genes):
            return float(np.sum(genes))

        engine = EvolutionEngine(
            config=config,
            algorithm=EvolutionAlgorithm.GA,
            fitness_func=fitness_func,
        )

        def init_func():
            return Chromosome(genes=np.random.randn(5))

        engine.initialize_population(init_func)
        stats = engine.get_population_stats()

        assert "best" in stats
        assert "worst" in stats
        assert "mean" in stats
        assert "std" in stats

    def test_reset(self):
        """Test engine reset."""
        config = EvolutionConfig(population_size=20)

        engine = EvolutionEngine(
            config=config,
            algorithm=EvolutionAlgorithm.GA,
            fitness_func=lambda x: 1.0,
        )

        def init_func():
            return Chromosome(genes=np.random.randn(3))

        engine.initialize_population(init_func)
        engine.run()
        engine.reset()

        assert engine.status == EvolutionStatus.IDLE
        assert len(engine.population) == 0
        assert engine.best_individual is None


class TestGeneticAlgorithm:
    """Test GeneticAlgorithm class."""

    def test_ga_initialization(self):
        """Test GA initialization."""
        config = GAConfig(population_size=20)

        def fitness_func(genes):
            return np.sum(genes)

        def gene_init_func():
            return np.random.randn(5)

        ga = GeneticAlgorithm(config, fitness_func, gene_init_func)

        assert ga.config == config
        assert ga.fitness_func == fitness_func

    def test_ga_population_init(self):
        """Test GA population initialization."""
        config = GAConfig(population_size=20)

        ga = GeneticAlgorithm(
            config=config,
            fitness_func=lambda g: np.sum(g),
            gene_init_func=lambda: np.random.randn(5),
        )

        ga.initialize()

        assert len(ga.population) == 20
        assert all(isinstance(c, Chromosome) for c in ga.population)

    def test_roulette_selection(self):
        """Test roulette wheel selection."""
        config = GAConfig(population_size=20, selection=SelectionOperator.ROULETTE)

        ga = GeneticAlgorithm(
            config=config,
            fitness_func=lambda g: np.sum(g),
            gene_init_func=lambda: np.random.randn(5),
        )

        ga.initialize()
        selected = ga._select_roulette()

        # Check selected is a valid chromosome by comparing genes
        assert any(np.array_equal(selected.genes, c.genes) for c in ga.population)

    def test_tournament_selection(self):
        """Test tournament selection."""
        config = GAConfig(population_size=20, selection=SelectionOperator.TOURNAMENT)

        ga = GeneticAlgorithm(
            config=config,
            fitness_func=lambda g: np.sum(g),
            gene_init_func=lambda: np.random.randn(5),
        )

        ga.initialize()
        selected = ga._select_tournament(tournament_size=3)

        # Check selected is a valid chromosome by comparing genes
        assert any(np.array_equal(selected.genes, c.genes) for c in ga.population)

    def test_crossover_operators(self):
        """Test different crossover operators."""
        parent1 = Chromosome(genes=np.array([1.0, 2.0, 3.0, 4.0]))
        parent2 = Chromosome(genes=np.array([5.0, 6.0, 7.0, 8.0]))

        config = GAConfig(crossover_rate=1.0)
        ga = GeneticAlgorithm(config, lambda g: 1.0, lambda: np.zeros(4))

        # Single point crossover
        config.crossover = CrossoverOperator.SINGLE_POINT
        c1, c2 = ga._crossover_single_point(parent1, parent2)
        assert len(c1.genes) == len(parent1.genes)

        # Two point crossover
        c1, c2 = ga._crossover_two_point(parent1, parent2)
        assert len(c1.genes) == len(parent1.genes)

        # Uniform crossover
        c1, c2 = ga._crossover_uniform(parent1, parent2)
        assert len(c1.genes) == len(parent1.genes)

        # Arithmetic crossover
        c1, c2 = ga._crossover_arithmetic(parent1, parent2)
        assert len(c1.genes) == len(parent1.genes)

    def test_mutation_operators(self):
        """Test different mutation operators."""
        config = GAConfig(mutation_rate=1.0, bounds=(-10, 10))
        ga = GeneticAlgorithm(config, lambda g: 1.0, lambda: np.zeros(5))
        ga.generation = 1

        chromosome = Chromosome(genes=np.zeros(5))

        # Gaussian mutation
        config.mutation = MutationOperator.GAUSSIAN
        mutated = ga._mutate_gaussian(chromosome)
        assert len(mutated.genes) == len(chromosome.genes)
        assert np.any(mutated.genes != chromosome.genes)

        # Uniform mutation
        config.mutation = MutationOperator.UNIFORM
        mutated = ga._mutate_uniform(chromosome)
        assert len(mutated.genes) == len(chromosome.genes)

        # Boundary mutation
        config.mutation = MutationOperator.BOUNDARY
        mutated = ga._mutate_boundary(chromosome)
        assert len(mutated.genes) == len(chromosome.genes)

    def test_ga_optimization(self):
        """Test GA on a simple optimization problem."""
        config = GAConfig(
            population_size=30,
            max_generations=50,
            elite_size=2,
            bounds=(-10, 10),
        )

        # Minimize (x - 5)^2 + (y + 3)^2
        def fitness_func(genes):
            x, y = genes[0], genes[1]
            return -((x - 5) ** 2 + (y + 3) ** 2)

        def gene_init_func():
            return np.random.uniform(-10, 10, 2)

        ga = GeneticAlgorithm(config, fitness_func, gene_init_func)
        best = ga.run()

        # Should be close to [5, -3]
        assert abs(best.genes[0] - 5) < 1.0
        assert abs(best.genes[1] + 3) < 1.0

    def test_ga_statistics(self):
        """Test GA statistics."""
        config = GAConfig(population_size=20)

        ga = GeneticAlgorithm(
            config=config,
            fitness_func=lambda g: np.sum(g),
            gene_init_func=lambda: np.random.randn(5),
        )

        ga.initialize()
        stats = ga.get_statistics()

        assert "generation" in stats
        assert "best" in stats
        assert "mean" in stats
        assert "std" in stats


class TestGeneticProgramming:
    """Test GeneticProgramming class."""

    def test_gp_initialization(self):
        """Test GP initialization."""
        config = GPConfig(population_size=20)

        def fitness_func(chromosome):
            return 1.0

        gp = GeneticProgramming(config, fitness_func)

        assert gp.config == config
        assert len(gp.population) == 0

    def test_node_creation(self):
        """Test creating nodes."""
        # Terminal node
        terminal = Node(node_type=NodeType.CONSTANT, value=5.0)
        assert terminal.is_terminal()
        assert terminal.get_size() == 1
        assert terminal.get_depth() == 0

        # Function node
        func = Node(
            node_type=NodeType.ADD,
            children=[
                Node(node_type=NodeType.CONSTANT, value=1.0),
                Node(node_type=NodeType.CONSTANT, value=2.0),
            ],
        )
        assert func.is_function()
        assert func.get_size() == 3
        assert func.get_depth() == 1

    def test_node_evaluation(self):
        """Test node evaluation."""
        # Simple expression: 2 + 3
        tree = Node(
            node_type=NodeType.ADD,
            children=[
                Node(node_type=NodeType.CONSTANT, value=2.0),
                Node(node_type=NodeType.CONSTANT, value=3.0),
            ],
        )

        result = tree.evaluate({})
        assert result == 5.0

    def test_tree_copy(self):
        """Test tree copying."""
        original = Node(
            node_type=NodeType.MUL,
            children=[
                Node(node_type=NodeType.CONSTANT, value=2.0),
                Node(node_type=NodeType.ADD,
                     children=[
                         Node(node_type=NodeType.CONSTANT, value=1.0),
                         Node(node_type=NodeType.CONSTANT, value=3.0),
                     ]),
            ],
        )

        copy_node = original.copy()

        assert copy_node.node_type == original.node_type
        assert len(copy_node.children) == len(original.children)

        # Modify copy should not affect original
        copy_node.children[0].value = 10.0
        assert original.children[0].value == 2.0

    def test_gp_population_init(self):
        """Test GP population initialization."""
        config = GPConfig(population_size=20, max_depth=5)

        def fitness_func(chromosome):
            return 1.0

        gp = GeneticProgramming(config, fitness_func)
        gp.initialize()

        assert len(gp.population) == 20
        assert all(isinstance(c, TreeChromosome) for c in gp.population)
        assert all(c.get_depth() <= config.max_depth for c in gp.population)

    def test_subtree_crossover(self):
        """Test subtree crossover."""
        config = GPConfig(crossover_rate=1.0)

        gp = GeneticProgramming(config, lambda c: 1.0)

        parent1 = TreeChromosome(
            root=Node(
                node_type=NodeType.ADD,
                children=[
                    Node(node_type=NodeType.CONSTANT, value=1.0),
                    Node(node_type=NodeType.CONSTANT, value=2.0),
                ],
            ),
        )

        parent2 = TreeChromosome(
            root=Node(
                node_type=NodeType.MUL,
                children=[
                    Node(node_type=NodeType.CONSTANT, value=3.0),
                    Node(node_type=NodeType.CONSTANT, value=4.0),
                ],
            ),
        )

        child1, child2 = gp._subtree_crossover(parent1, parent2)

        assert child1.get_size() > 0
        assert child2.get_size() > 0

    def test_point_mutation(self):
        """Test point mutation."""
        config = GPConfig(mutation_rate=1.0)

        gp = GeneticProgramming(config, lambda c: 1.0)
        gp.initialize()

        chromosome = TreeChromosome(
            root=Node(node_type=NodeType.CONSTANT, value=5.0),
        )

        mutated = gp._point_mutation(chromosome)

        assert mutated is not None
        assert mutated.get_size() == chromosome.get_size()

    def test_tree_simplification(self):
        """Test tree simplification."""
        config = GPConfig()
        gp = GeneticProgramming(config, lambda c: 1.0)

        # Expression: 2 + 3 (both constants, should simplify to 5)
        tree = TreeChromosome(
            root=Node(
                node_type=NodeType.ADD,
                children=[
                    Node(node_type=NodeType.CONSTANT, value=2.0),
                    Node(node_type=NodeType.CONSTANT, value=3.0),
                ],
            ),
        )

        simplified = gp.simplify_tree(tree)

        assert simplified.get_size() <= tree.get_size()

    def test_gp_optimization(self):
        """Test GP on a simple symbolic regression problem."""
        config = GPConfig(
            population_size=30,
            max_generations=50,
            max_depth=5,
        )

        # Target: f(x) = 2x + 1
        target_func = lambda x: 2 * x + 1
        x_vals = np.linspace(-5, 5, 20)
        y_vals = target_func(x_vals)

        def fitness_func(chromosome):
            total_error = 0
            for x, y in zip(x_vals, y_vals):
                context = {"price_close": x, "param_x": x}
                try:
                    pred = chromosome.evaluate(context)
                    total_error += (pred - y) ** 2
                except:
                    return -10000
            return -total_error

        gp = GeneticProgramming(
            config,
            fitness_func,
            terminal_set=[NodeType.CONSTANT, NodeType.PARAMETER],
            function_set=[NodeType.ADD, NodeType.MUL, NodeType.SUB],
        )

        best = gp.run()

        assert best is not None
        # Just verify we got a result, exact fitness depends on random evolution
        assert isinstance(best.fitness, (int, float))


class TestNSGA2:
    """Test NSGA2 class."""

    def test_nsga2_initialization(self):
        """Test NSGA2 initialization."""
        config = NSGA2Config(population_size=20)

        objectives = {
            "profit": lambda g: np.sum(g),
            "risk": lambda g: np.std(g),
        }

        nsga2 = NSGA2(
            config=config,
            objective_funcs=objectives,
            gene_init_func=lambda: np.random.randn(5),
        )

        assert nsga2.config == config
        assert len(nsga2.population) == 0

    def test_nsga2_population_init(self):
        """Test NSGA2 population initialization."""
        config = NSGA2Config(population_size=20)

        nsga2 = NSGA2(
            config=config,
            objective_funcs={"f1": lambda g: g[0]},
            gene_init_func=lambda: np.random.randn(3),
        )

        nsga2.initialize()

        assert len(nsga2.population) == 20
        assert all(isinstance(ind, Individual) for ind in nsga2.population)

    def test_dominance(self):
        """Test dominance relations."""
        ind1 = Individual(
            genes=np.array([1.0]),
            objectives={
                "obj1": ObjectiveValue("obj1", 10.0, minimize=False),
                "obj2": ObjectiveValue("obj2", 5.0, minimize=True),
            },
        )

        ind2 = Individual(
            genes=np.array([2.0]),
            objectives={
                "obj1": ObjectiveValue("obj1", 5.0, minimize=False),  # Worse
                "obj2": ObjectiveValue("obj2", 10.0, minimize=True),  # Worse
            },
        )

        # ind1 dominates ind2 (better on both objectives)
        assert ind1.dominates(ind2)
        assert not ind2.dominates(ind1)

    def test_non_dominated_sorting(self):
        """Test non-dominated sorting."""
        config = NSGA2Config(population_size=10)

        nsga2 = NSGA2(
            config=config,
            objective_funcs={
                "f1": lambda g: g[0],
                "f2": lambda g: -g[0],  # Conflict with f1
            },
            gene_init_func=lambda: np.random.randn(2),
        )

        nsga2.initialize()
        fronts = nsga2._fast_non_dominated_sort()

        assert len(fronts) > 0
        assert len(fronts[0].individuals) > 0

        # All individuals should be in some front
        total = sum(len(f.individuals) for f in fronts)
        assert total == len(nsga2.population)

    def test_crowding_distance(self):
        """Test crowding distance calculation."""
        front = ParetoFront(
            individuals=[
                Individual(
                    genes=np.array([float(i)]),
                    objectives={
                        "f1": ObjectiveValue("f1", float(i), minimize=False),
                        "f2": ObjectiveValue("f2", float(10 - i), minimize=False),
                    },
                )
                for i in range(5)
            ]
        )

        config = NSGA2Config()
        nsga2 = NSGA2(config, {}, lambda: np.array([0.0]))
        nsga2._calculate_crowding_distance(front)

        # Boundary individuals should have infinite distance
        assert front.individuals[0].crowding_distance == float("inf")
        assert front.individuals[-1].crowding_distance == float("inf")

        # Interior individuals should have finite distances
        assert front.individuals[1].crowding_distance > 0
        assert front.individuals[2].crowding_distance > 0

    def test_nsga2_optimization(self):
        """Test NSGA2 on a simple multi-objective problem."""
        config = NSGA2Config(
            population_size=30,
            max_generations=50,
        )

        # Schaffer N.1 function: minimize f1 = x^2, f2 = (x-2)^2
        objectives = {
            "f1": lambda g: g[0] ** 2,
            "f2": lambda g: (g[0] - 2) ** 2,
        }

        nsga2 = NSGA2(
            config=config,
            objective_funcs=objectives,
            gene_init_func=lambda: np.random.uniform(-5, 5, 1),
            bounds=(np.array([-5]), np.array([5])),
        )

        fronts = nsga2.run()

        assert len(fronts) > 0
        assert len(fronts[0].individuals) > 0

        # First front should have diverse solutions
        pareto_solutions = nsga2.get_pareto_front_solutions()
        assert len(pareto_solutions) > 0

    def test_nsga2_statistics(self):
        """Test NSGA2 statistics."""
        config = NSGA2Config(population_size=20)

        nsga2 = NSGA2(
            config=config,
            objective_funcs={"f1": lambda g: g[0]},
            gene_init_func=lambda: np.random.randn(2),
        )

        nsga2.initialize()
        stats = nsga2.get_statistics()

        assert "generation" in stats
        assert "population_size" in stats
        assert "num_fronts" in stats


class TestFitnessEvaluator:
    """Test FitnessEvaluator class."""

    def test_fitness_metrics_creation(self):
        """Test FitnessMetrics creation."""
        metrics = FitnessMetrics(
            total_return=15.5,
            sharpe_ratio=1.2,
            max_drawdown=10.0,
            win_rate=60.0,
        )

        assert metrics.total_return == 15.5
        assert metrics.sharpe_ratio == 1.2
        assert metrics.max_drawdown == 10.0
        assert metrics.win_rate == 60.0

    def test_metrics_to_dict(self):
        """Test converting metrics to dictionary."""
        metrics = FitnessMetrics(total_return=10.0, sharpe_ratio=1.0)
        d = metrics.to_dict()

        assert d["total_return"] == 10.0
        assert d["sharpe_ratio"] == 1.0

    def test_sharpe_fitness(self):
        """Test Sharpe ratio fitness calculation."""
        evaluator = FitnessEvaluator()

        # Create mock backtest result with required attributes
        class MockResult:
            total_trades = 20
            sharpe_ratio = 1.5
            max_drawdown = 15.0
            total_return = 20.0
            volatility = 10.0
            win_rate = 60.0
            winning_trades = 12
            losing_trades = 8
            avg_win = 150.0
            avg_loss = -100.0
            profit_factor = 2.0
            calmar_ratio = 1.5

        fitness = evaluator.calculate_fitness_sharpe(MockResult())

        assert isinstance(fitness, float)
        # Should return some value (may be negative due to drawdown penalty)

    def test_profit_risk_fitness(self):
        """Test profit-risk balanced fitness."""
        evaluator = FitnessEvaluator()

        class MockResult:
            total_trades = 20
            total_return = 30.0
            max_drawdown = 20.0
            sharpe_ratio = 1.5
            volatility = 15.0
            win_rate = 60.0
            winning_trades = 12
            losing_trades = 8
            avg_win = 150.0
            avg_loss = -100.0
            profit_factor = 2.0
            calmar_ratio = 1.5

        fitness = evaluator.calculate_fitness_profit_risk(
            MockResult(), risk_weight=0.5
        )

        assert isinstance(fitness, (int, float))

    def test_multi_objective_fitness(self):
        """Test multi-objective fitness."""
        evaluator = FitnessEvaluator()

        metrics = FitnessMetrics(
            total_return=20.0,
            sharpe_ratio=1.5,
            win_rate=65.0,
            num_trades=30,
        )

        fitness = evaluator.calculate_fitness_multi_objective(
            metrics=metrics,
            objectives=["total_return", "sharpe_ratio", "win_rate"],
            weights=[0.4, 0.4, 0.2],
        )

        assert fitness is not None

    def test_fitness_with_insufficient_trades(self):
        """Test fitness with insufficient trades."""
        evaluator = FitnessEvaluator(min_trades=20)

        class MockResult:
            total_trades = 5
            sharpe_ratio = 2.0

        fitness = evaluator.calculate_fitness_sharpe(MockResult())

        assert fitness == -1.0

    def test_sortino_fitness(self):
        """Test Sortino ratio fitness."""
        evaluator = FitnessEvaluator()

        # Generate sample returns
        np.random.seed(42)
        returns = np.random.normal(0.001, 0.02, 100)

        fitness = evaluator.calculate_fitness_sortino(returns=returns)

        assert isinstance(fitness, float)

    def test_fitness_function_factory(self):
        """Test creating fitness functions."""
        evaluator = FitnessEvaluator()

        sharpe_func = evaluator.create_fitness_function("sharpe")
        assert callable(sharpe_func)

        profit_risk_func = evaluator.create_fitness_function(
            "profit_risk", risk_weight=0.6
        )
        assert callable(profit_risk_func)

    def test_convergence_metrics(self):
        """Test convergence metrics calculation."""
        evaluator = FitnessEvaluator()

        # Converged history (stable values around 1.0)
        converged_history = [1.0 + 0.001 * np.sin(i) for i in range(100)]
        metrics = evaluator.get_convergence_metrics(converged_history, window=20)

        assert "converged" in metrics
        assert "improvement_rate" in metrics
        assert "stability" in metrics

        # Stability should be high for flat curve
        assert metrics["stability"] > 0.5

    def test_population_evaluation(self):
        """Test evaluating a population."""
        evaluator = FitnessEvaluator()

        # Create mock results
        def create_mock(i):
            m = type("Mock", (), {})()
            m.total_trades = 20
            m.sharpe_ratio = 1.0 + i * 0.1
            m.max_drawdown = 10.0
            m.total_return = 15.0
            m.volatility = 12.0
            m.winning_trades = 12
            m.losing_trades = 8
            return m

        results = [create_mock(i) for i in range(5)]

        scores = evaluator.evaluate_population(results)

        assert len(scores) == 5
        assert all(isinstance(s, float) for s in scores)


class TestIntegration:
    """Integration tests for evolution module."""

    def test_ga_with_fitness_evaluator(self):
        """Test GA using FitnessEvaluator."""
        config = GAConfig(population_size=30, max_generations=30)
        evaluator = FitnessEvaluator()

        # Simple optimization: maximize x where x in [0, 10]
        def fitness_func(genes):
            x = genes[0]
            return x if 0 <= x <= 10 else -100

        def gene_init_func():
            return np.random.uniform(0, 10, 1)

        ga = GeneticAlgorithm(config, fitness_func, gene_init_func)
        best = ga.run()

        assert best.genes[0] > 8  # Should be close to 10

    def test_gp_complex_expression(self):
        """Test GP with complex expression evaluation."""
        config = GPConfig(population_size=20, max_depth=4)

        def fitness_func(chromosome):
            # Test if tree can compute x^2 + 2x + 1 for various x
            errors = []
            for x in np.linspace(-2, 2, 10):
                context = {"param_x": x, "price_close": x}
                try:
                    result = chromosome.evaluate(context)
                    expected = x ** 2 + 2 * x + 1
                    errors.append((result - expected) ** 2)
                except:
                    errors.append(1000)
            return -np.mean(errors)

        gp = GeneticProgramming(config, fitness_func)
        gp.initialize()

        # Run a few generations
        for _ in range(10):
            gp.step()

        assert gp.best_chromosome is not None

    def test_nsga2_with_multiple_objectives(self):
        """Test NSGA2 with realistic trading objectives."""
        config = NSGA2Config(population_size=20, max_generations=20)

        # Two objectives: profit (maximize), low risk (minimize)
        objectives = {
            "profit": lambda g: np.sum(g ** 2),  # Maximize
            "risk": lambda g: np.std(g),  # Minimize
        }

        nsga2 = NSGA2(
            config=config,
            objective_funcs=objectives,
            gene_init_func=lambda: np.random.randn(5),
        )

        fronts = nsga2.run()

        assert len(fronts) > 0
        assert len(fronts[0].individuals) > 0

    def test_evolution_callback(self):
        """Test evolution with callback tracking progress."""
        config = GAConfig(population_size=20, max_generations=10)

        progress = []

        def callback(gen, fitness):
            progress.append((gen, fitness))

        ga = GeneticAlgorithm(
            config,
            lambda g: -np.sum((g - 5) ** 2),
            lambda: np.random.randn(1),
        )

        ga.initialize()
        for _ in range(10):
            fitness = ga.step()

        assert len(progress) == 0  # No callback passed to step

        # Test with callback in run
        progress = []
        ga2 = GeneticAlgorithm(
            config,
            lambda g: -np.sum((g - 5) ** 2),
            lambda: np.random.randn(1),
        )
        ga2.run(callback=lambda gen, fit: progress.append((gen, fit)))

        assert len(progress) == 10
