import random
import numpy as np
import streamlit as st

# import all functions from src.backend.chatbot
from src.backend.chatbot import *


def genetic_algorithm_plants(model, demo_lite):
    # Define the compatibility matrix
    compatibility_matrix = st.session_state.full_mat
    # Define the list of plants
    plant_list = st.session_state.plant_list

    # Define the user-selected plants, number of plant beds, and constraints
    user_plants = st.session_state.input_plants_raw
    num_plant_beds = st.session_state.n_plant_beds
    # 1 <= min_species_per_bed <= max_species_per_bed <= len(user_plants)
    min_species_per_bed = st.session_state.min_species
    # max_species_per_bed >= floor(length(user_plants)-(min_species_per_bed*num_plant_beds-1) & max_species_per_bed <= len(user_plants)
    max_species_per_bed = st.session_state.max_species

    # Genetic Algorithm parameters
    population_size = st.session_state.population_size
    num_generations = st.session_state.num_generations
    tournament_size = st.session_state.tournament_size
    crossover_rate = st.session_state.crossover_rate
    mutation_rate = st.session_state.mutation_rate
    seed_population_rate = st.session_state.seed_population_rate

    def generate_initial_population(model, demo_lite):
        population = []

        # Add seed groupings to the population, validated and replaced as necessary
        num_seeds = int(
            population_size * st.session_state.seed_population_rate
        )  # 10% of the population as seeds
        # we generate just one seed grouping for this beta language model suggestion feature
        seed_grouping = get_language_model_suggestions(model, demo_lite)
        if seed_grouping != "no response yet":
            valid_seed_grouping = validate_and_replace(seed_grouping)
            population.append(valid_seed_grouping)

        # Fill the rest of the population with random groupings, also validated and replaced
        while len(population) < population_size:
            random_grouping = generate_random_grouping()
            valid_random_grouping = validate_and_replace(random_grouping)
            population.append(valid_random_grouping)

        return population

    def generate_random_grouping():
        random.shuffle(user_plants)
        remaining_plants = user_plants.copy()
        grouping = []

        total_plants = len(user_plants)
        plants_per_bed = total_plants // num_plant_beds
        extra_plants = total_plants % num_plant_beds

        for bed_index in range(num_plant_beds):
            if bed_index < extra_plants:
                # Distribute extra plants among the first few beds
                num_species_in_bed = plants_per_bed + 1
            else:
                num_species_in_bed = plants_per_bed

            # Ensure the bed size is within the min and max constraints
            num_species_in_bed = max(
                min_species_per_bed, min(num_species_in_bed, max_species_per_bed)
            )

            bed = remaining_plants[:num_species_in_bed]
            remaining_plants = remaining_plants[num_species_in_bed:]
            grouping.append(bed)

        return grouping

    # Perform crossover between two parents, preserving at least one occurrence of each plant
    def crossover(parent1, parent2):
        if random.random() < crossover_rate:
            crossover_point = random.randint(1, num_plant_beds - 1)
            child1 = parent1[:crossover_point] + parent2[crossover_point:]
            child2 = parent2[:crossover_point] + parent1[crossover_point:]

            # Ensure each plant appears at least once in the offspring
            for plant in user_plants:
                if all(plant not in bed for bed in child1):
                    # Find a bed with fewer species and add the missing plant
                    min_bed_index = min(
                        range(len(child1)), key=lambda i: len(child1[i])
                    )
                    child1[min_bed_index].append(plant)
                if all(plant not in bed for bed in child2):
                    # Find a bed with fewer species and add the missing plant
                    min_bed_index = min(
                        range(len(child2)), key=lambda i: len(child2[i])
                    )
                    child2[min_bed_index].append(plant)

            return child1, child2
        else:
            return parent1, parent2

    # Perform mutation on an individual, ensuring no bed exceeds the maximum species constraint
    def mutate(individual):
        if random.random() < mutation_rate:
            mutated_bed = random.randint(0, num_plant_beds - 1)
            species_in_bed = individual[mutated_bed]

            # Remove excess species if there are more than the maximum constraint
            if len(species_in_bed) > max_species_per_bed:
                species_in_bed = random.sample(species_in_bed, max_species_per_bed)

            # Add missing plants by performing swaps between current species and missing plants
            missing_plants = [
                plant for plant in user_plants if plant not in species_in_bed
            ]
            num_missing_plants = min(
                len(missing_plants), max_species_per_bed - len(species_in_bed)
            )
            for _ in range(num_missing_plants):
                swap_species = random.choice(missing_plants)
                missing_plants.remove(swap_species)
                species_in_bed.append(swap_species)
                species_in_bed.remove(random.choice(species_in_bed))

            individual[mutated_bed] = species_in_bed

        return individual

    # calculate the fitness score of the grouping
    def calculate_fitness(grouping):
        positive_reward_factor = (
            1000  # this can be adjusted to increase the reward for compatible species
        )
        negative_penalty_factor = (
            2000  # this can be adjusted to increase the penalty for incompatible species
        )

        # define penalties for not meeting constraints
        penalty_for_exceeding_max = 500  # can adjust as needed
        penalty_for_not_meeting_min = 500  # can adjust as needed
        penalty_for_not_having_all_plants = 1000  # can adjust as needed

        score = 0
        # iterate over each plant bed
        for bed in grouping:
            for i in range(len(bed)):
                for j in range(i + 1, len(bed)):
                    # get the plant name
                    species1_name = bed[i]
                    species2_name = bed[j]
                    species1_index = plant_list.index(species1_name)
                    species2_index = plant_list.index(species2_name)

                    # compatibility score between two species in the same bed
                    compatibility_score = compatibility_matrix[species1_index][
                        species2_index
                    ]

                    if compatibility_score > 0:
                        # positive reward for compatible species
                        score += compatibility_score * positive_reward_factor
                    elif compatibility_score < 0:
                        # negative penalty for incompatible species
                        score += compatibility_score * negative_penalty_factor

        # apply penalties for not meeting constraints
        if len(bed) > max_species_per_bed:
            score -= penalty_for_exceeding_max
        if len(bed) < min_species_per_bed:
            score -= penalty_for_not_meeting_min
        if len(set(plant for bed in grouping for plant in bed)) < len(user_plants):
            score -= penalty_for_not_having_all_plants

        return score
    
    # Perform tournament selection
    def tournament_selection(population):
        selected = []
        for _ in range(population_size):
            participants = random.sample(population, tournament_size)
            winner = max(participants, key=calculate_fitness)
            selected.append(winner)
        return selected

    # Perform replacement of the population with the offspring, ensuring maximum species constraint is met
    def replacement(population, offspring):
        sorted_population = sorted(population, key=calculate_fitness, reverse=True)
        sorted_offspring = sorted(offspring, key=calculate_fitness, reverse=True)

        # Adjust the offspring to meet the maximum species constraint
        adjusted_offspring = []
        for individual in sorted_offspring:
            for bed_idx in range(num_plant_beds):
                species_in_bed = individual[bed_idx]
                if len(species_in_bed) > max_species_per_bed:
                    species_in_bed = random.sample(species_in_bed, max_species_per_bed)
                individual[bed_idx] = species_in_bed
            adjusted_offspring.append(individual)

        return (
            sorted_population[: population_size - len(adjusted_offspring)]
            + adjusted_offspring
        )

    # Genetic Algorithm main function
    def genetic_algorithm(model, demo_lite):
        population = generate_initial_population(model, demo_lite)

        for generation in range(num_generations):
            print(f"Generation {generation + 1}")

            selected_population = tournament_selection(population)
            offspring = []

            for _ in range(population_size // 2):
                parent1 = random.choice(selected_population)
                parent2 = random.choice(selected_population)
                child1, child2 = crossover(parent1, parent2)
                child1 = mutate(child1)
                child2 = mutate(child2)
                offspring.extend([child1, child2])

            population = replacement(population, offspring)
            # Validate and replace any missing plants in the new population
            population = [validate_and_replace(grouping) for grouping in population]

        best_grouping = max(population, key=calculate_fitness)
        best_grouping = validate_and_replace(best_grouping)
        best_fitness = calculate_fitness(best_grouping)
        print(f"Best Grouping: {best_grouping}")
        print(f"Fitness Score: {best_fitness}")
        st.session_state.best_grouping = best_grouping
        st.session_state.best_fitness = best_fitness
        # st.write(f"Best Grouping: {best_grouping}")
        # st.write(f"Fitness Score: {best_fitness}")
        return best_grouping

    # def validate_and_replace(grouping):
    #     print("Grouping structure before validation:", grouping)
    #     all_plants = set(user_plants)
    #     for bed in grouping:
    #         all_plants -= set(bed)

    #     # Replace missing plants
    #     for missing_plant in all_plants:
    #         replaced = False
    #         for bed in grouping:
    #             if len(set(bed)) != len(bed):  # Check for duplicates
    #                 for i, plant in enumerate(bed):
    #                     if bed.count(plant) > 1:  # Found a duplicate
    #                         bed[i] = missing_plant
    #                         replaced = True
    #                         break
    #             if replaced:
    #                 break

    #         # If no duplicates were found, replace a random plant
    #         if not replaced:
    #             random_bed = random.choice(grouping)
    #             random_bed[random.randint(0, len(random_bed) - 1)] = missing_plant

    #     return grouping

    ############
    ############ experimental

    def adjust_grouping(grouping):
        # Determine the plants that are missing in the grouping
        plants_in_grouping = set(plant for bed in grouping for plant in bed)
        missing_plants = set(user_plants) - plants_in_grouping

        for missing_plant in missing_plants:
            # Find a bed that can accommodate the missing plant without exceeding max_species_per_bed
            suitable_bed = next(
                (bed for bed in grouping if len(bed) < max_species_per_bed), None
            )
            if suitable_bed is not None:
                suitable_bed.append(missing_plant)
            else:
                # If no suitable bed is found, replace a random plant in a random bed
                random_bed = random.choice(grouping)
                random_bed[random.randint(0, len(random_bed) - 1)] = missing_plant

        # Ensure min_species_per_bed and max_species_per_bed constraints
        for bed in grouping:
            while len(bed) < min_species_per_bed:
                additional_plant = random.choice(
                    [plant for plant in user_plants if plant not in bed]
                )
                bed.append(additional_plant)
            while len(bed) > max_species_per_bed:
                bed.remove(random.choice(bed))

        return grouping

    def validate_and_replace(grouping):
        best_grouping = None
        best_fitness = float("-inf")

        for _ in range(5):  # Generate 5 different configurations
            temp_grouping = [bed.copy() for bed in grouping]
            temp_grouping = adjust_grouping(temp_grouping)
            current_fitness = calculate_fitness(temp_grouping)

            if current_fitness > best_fitness:
                best_fitness = current_fitness
                best_grouping = temp_grouping

        return best_grouping

    ############
    def get_language_model_suggestions(model, demo_lite):
        # This returns a list of seed groupings based on the compatibility matrix
        st.session_state.seed_groupings = get_seed_groupings_from_LLM(model, demo_lite)
        return st.session_state.seed_groupings

    # Run the genetic algorithm

    best_grouping = genetic_algorithm(model, demo_lite)
    return best_grouping
