This is the code for a fusion optimizer for the fan game Pokemon Infinite Fusion. The code should provide a website that lets the user set weights for each base stat of the resulting fusion, and then also add filters based on abilities, types and moves. 
To calculate how fusions are created:
 - HP, Special Attack and Speed base stats get 2/3rd of the "HEAD" pokemon base stat and 1/3rd of the "BODY" pokemon base stat
 - Attack, Defense and Special Defense base stats get 1/3rd of the "HEAD" pokemon base stat and 2/3rd of the "BODY" pokemon base stat
 - The typing combination is TYPE 1 of the HEAD pokemon and TYPE 2 of the BODY pokemon. If the types of the fusion would be the same, it only gets one, there's no FIRE/FIRE pokemon, rather, just FIRE type.
 - The abilities of both pokemon are available to the fusion.
 - The movesets of both pokemon are available to the fusion.

The code itself mostly works, when it presents a fusion, it DOES fit the criteria, however there are many issues, whenever the users selects a strict criteria for the resulting fusion, it will not find it. I think it should do TWO things. First, it should enforce that whenever the user wants to force the fusion to have an ability or move, the optimizer should force when calculating the fusions that at least one of the pokemon on it should have the specified ability/move, this way we limit the resulting fusions that will NOT fit the criteria of the user and make sure that it will have the selected ability/move. This way, when calculating fusions, if the user has selected an ability/move, the optimizer will have two datasets, one with pokemon that have that ability/move and the second one with pokemon that earned its spot by having the correct typing and base stats SECOND IDEA. The optimizer should add a score for each pokemon of the filtered datasets and reward them with more chances of showing up in the fusions, based on their typing, base stats and the weights that the user has set for the fusion base stats.

import requests
import json
import os
import streamlit as st
import pandas as pd
import numpy as np

# Function to get the list of all Pokémon
@st.cache_data
def get_pokemon_list():
    url = 'https://pokeapi.co/api/v2/pokemon?limit=809'  # Adjust the limit if needed
    response = requests.get(url)
    data = response.json()
    return data['results']

# Function to get detailed data for a single Pokémon
def get_pokemon_data(pokemon_url):
    response = requests.get(pokemon_url)
    pokemon_data = response.json()
    return pokemon_data

# Function to fetch and store Pokémon data locally
def fetch_and_store_pokemon_data(json_filename):
    st.info("Fetching Pokémon data from the API...")
    pokemon_list = get_pokemon_list()
    all_pokemon_data = []

    progress_bar = st.progress(0)
    total_pokemon = len(pokemon_list)

    # Fetch data for each Pokémon
    for index, pokemon in enumerate(pokemon_list, start=1):
        pokemon_name = pokemon['name']
        pokemon_url = pokemon['url']
        pokemon_data = get_pokemon_data(pokemon_url)

        # Get types
        types = [t['type']['name'] for t in pokemon_data['types']]

        # Get base stats
        stats = {stat['stat']['name']: stat['base_stat'] for stat in pokemon_data['stats']}

        # Get abilities
        abilities = [a['ability']['name'] for a in pokemon_data['abilities']]

        # Get moves
        moves = [move['move']['name'] for move in pokemon_data['moves']]

        # Create a Pokémon entry
        pokemon_entry = {
            'name': pokemon_name,
            'types': types,
            'stats': stats,
            'abilities': abilities,
            'moves': moves
        }

        all_pokemon_data.append(pokemon_entry)

        # Update progress bar
        progress_bar.progress(index / total_pokemon)

    # Save data to JSON file
    with open(json_filename, 'w') as json_file:
        json.dump(all_pokemon_data, json_file, indent=4)

    st.success(f"Data saved to '{json_filename}'")
    return all_pokemon_data

# Function to load data from the local JSON file
@st.cache_data
def load_pokemon_data(json_filename):
    with open(json_filename, 'r') as json_file:
        all_pokemon_data = json.load(json_file)
    return all_pokemon_data

# Function to load or fetch Pokémon data
def get_all_pokemon_data(json_filename):
    if os.path.exists(json_filename):
        all_pokemon_data = load_pokemon_data(json_filename)
        # Check if 'moves' is present in the data; if not, refetch data
        if 'moves' not in all_pokemon_data[0]:
            st.warning("Existing data does not include Pokémon moves. Refetching data...")
            all_pokemon_data = fetch_and_store_pokemon_data(json_filename)
    else:
        all_pokemon_data = fetch_and_store_pokemon_data(json_filename)
    return all_pokemon_data

def main():
    st.title("Pokémon Fusion Optimizer")

    # Load data
    json_filename = 'pokemon_data.json'
    all_pokemon_data = get_all_pokemon_data(json_filename)

    # Convert data to DataFrame
    data_rows = []
    for pkmn in all_pokemon_data:
        row = {
            'Name': pkmn['name'],
            'Types': pkmn['types'],
            'Abilities': pkmn['abilities'],
            'Moves': pkmn['moves'],
        }
        # Add stats
        for stat_name, stat_value in pkmn['stats'].items():
            row[stat_name] = stat_value
        # Calculate Base Stat Total (BST)
        row['BST'] = sum(pkmn['stats'].values())
        data_rows.append(row)
    df = pd.DataFrame(data_rows)
    df.set_index('Name', inplace=True)

    # User input for stat weights
    st.sidebar.header("Stat Weights")
    stat_columns = ['hp', 'attack', 'defense', 'special-attack', 'special-defense', 'speed']
    stat_weights = {}
    for stat in stat_columns:
        stat_weights[stat] = st.sidebar.slider(f"Weight for {stat.replace('-', ' ').title()}:", min_value=0.0, max_value=1.0, value=0.5, step=0.01)

    # Max BST filter for base Pokémon
    st.sidebar.header("Base Pokémon Filters")
    max_bst = st.sidebar.number_input("Maximum BST for Base Pokémon:", min_value=0, max_value=720, value=720, step=1)

    # Stat filters for base Pokémon
    st.sidebar.write("## Base Stat Filters")
    stat_filters = {}
    for stat in stat_columns:
        min_val = st.sidebar.number_input(f"Minimum {stat.replace('-', ' ').title()}:", min_value=0, max_value=255, value=0, step=1, key=f"min_{stat}")
        max_val = st.sidebar.number_input(f"Maximum {stat.replace('-', ' ').title()}:", min_value=0, max_value=255, value=255, step=1, key=f"max_{stat}")
        stat_filters[stat] = (min_val, max_val)

    # Type filters for base Pokémon
    st.sidebar.write("## Type Filters for Base Pokémon")
    types_list = sorted({t.title() for types in df['Types'] for t in types})
    selected_types = st.sidebar.multiselect("Select Base Pokémon Types:", types_list)

    # Number of top fusions to display
    st.sidebar.header("Fusion Options")
    top_k = st.sidebar.number_input("Number of Top Fusions to Display:", min_value=1, max_value=500, value=50, step=1)

    # Type filters for fusions (optional)
    st.sidebar.write("## Fusion Type Filters (Optional)")
    fusion_type_inputs = st.sidebar.multiselect("Select Desired Fusion Types (leave empty for no filter):", types_list)

    # Ability filters for fusions (optional)
    st.sidebar.write("## Fusion Ability Filters (Optional)")
    # Collect all abilities from the dataset
    abilities_list = sorted({ability.title() for abilities in df['Abilities'] for ability in abilities})
    fusion_ability_inputs = st.sidebar.multiselect("Select Desired Fusion Abilities (leave empty for no filter):", abilities_list)

    # Move filters for fusions (optional)
    st.sidebar.write("## Fusion Moveset Filters (Optional)")
    # Collect moves from the dataset
    moves_list = sorted({move.title() for moves in df['Moves'] for move in moves})
    fusion_move_inputs = st.sidebar.multiselect("Select Required Moves (leave empty for no filter):", moves_list)

    # Button to compute fusions
    if st.sidebar.button("Compute Best Fusions"):
        with st.spinner("Computing best fusions..."):
            # Filter base Pokémon based on BST
            filtered_df = df[df['BST'] <= max_bst]

            # Apply stat filters to base Pokémon
            for stat in stat_columns:
                min_val, max_val = stat_filters[stat]
                filtered_df = filtered_df[(filtered_df[stat] >= min_val) & (filtered_df[stat] <= max_val)]

            # Apply type filters to base Pokémon
            if selected_types:
                filtered_df = filtered_df[filtered_df['Types'].apply(lambda types: any(t.title() in selected_types for t in types))]

            # Add pokemon that have the desired abilities and moves to the filtered df
            if fusion_ability_inputs:
                filtered_df = filtered_df[filtered_df['Abilities'].apply(lambda abilities: any(ability.title() in fusion_ability_inputs for ability in abilities))]

            if fusion_move_inputs:
                filtered_df = filtered_df[filtered_df['Moves'].apply(lambda moves: any(move.title() in fusion_move_inputs for move in moves))]

            if filtered_df.empty:
                st.warning("No base Pokémon match the specified filters.")
                return

            # Precompute head and body contributions for all filtered Pokémon
            head_contributions = {}
            body_contributions = {}
            for name, row in filtered_df.iterrows():
                stats = row[stat_columns]
                types = row['Types']
                abilities = row['Abilities']
                moves = set(row['Moves'])

                # Head contributions
                head_stats = {
                    'hp': (2/3) * stats['hp'],
                    'attack': (1/3) * stats['attack'],
                    'defense': (1/3) * stats['defense'],
                    'special-attack': (2/3) * stats['special-attack'],
                    'special-defense': (1/3) * stats['special-defense'],
                    'speed': (2/3) * stats['speed']
                }
                # Compute weighted head score
                head_score = sum(head_stats[stat] * stat_weights[stat] for stat in stat_columns)
                head_contributions[name] = {'stats': head_stats, 'score': head_score, 'types': types, 'abilities': abilities, 'moves': moves}

                # Body contributions
                body_stats = {
                    'hp': (1/3) * stats['hp'],
                    'attack': (2/3) * stats['attack'],
                    'defense': (2/3) * stats['defense'],
                    'special-attack': (1/3) * stats['special-attack'],
                    'special-defense': (2/3) * stats['special-defense'],
                    'speed': (1/3) * stats['speed']
                }
                # Compute weighted body score
                body_score = sum(body_stats[stat] * stat_weights[stat] for stat in stat_columns)
                body_contributions[name] = {'stats': body_stats, 'score': body_score, 'types': types, 'abilities': abilities, 'moves': moves}

            # Generate all possible head-body pairs
            heads_list = list(head_contributions.keys())
            bodies_list = list(body_contributions.keys())

            # Limit the number of pairs to process to prevent overloading
            max_pairs = 500000  # Adjust as needed
            total_pairs = len(heads_list) * len(bodies_list)
            if total_pairs > max_pairs:
                st.info(f"Processing a sample of {max_pairs} out of {total_pairs} possible fusion pairs to maintain performance.")
                np.random.seed(42)  # For reproducibility
                sample_heads = np.random.choice(heads_list, size=min(len(heads_list), int(np.sqrt(max_pairs))), replace=False)
                sample_bodies = np.random.choice(bodies_list, size=min(len(bodies_list), int(np.sqrt(max_pairs))), replace=False)
            else:
                sample_heads = heads_list
                sample_bodies = bodies_list

            possible_pairs = []

            # Prepare desired fusion types (if any)
            desired_fusion_types = set([t.title() for t in fusion_type_inputs])

            # Prepare desired fusion abilities (if any)
            desired_fusion_abilities = set([a.lower() for a in fusion_ability_inputs])

            # Prepare desired fusion moves (if any)
            desired_fusion_moves = set([m.lower() for m in fusion_move_inputs])

            # Generate all possible head-body pairs
            for head_name in sample_heads:
                head_data = head_contributions[head_name]
                head_types = head_data['types']
                fusion_t1 = head_types[0].title() if head_types else ''
                for body_name in sample_bodies:
                    body_data = body_contributions[body_name]
                    body_types = body_data['types']
                    # Fusion T2 calculation
                    if len(body_types) > 1:
                        fusion_t2 = body_types[1].title()
                    else:
                        fusion_t2 = body_types[0].title() if body_types else ''
                    # Avoid duplicate types
                    if fusion_t1 == fusion_t2:
                        fusion_types_set = {fusion_t1}
                        fusion_t2 = ''
                    else:
                        fusion_types_set = {fusion_t1, fusion_t2}

                    # Apply fusion type filters (if any)
                    if desired_fusion_types:
                        if desired_fusion_types.issubset(fusion_types_set):
                            pass
                        else:
                            continue  # Skip if the desired types are not a subset of fusion types
                    # Else: No fusion type filters applied

                    # Fusion Ability (inherited from body Pokémon)
                    fusion_abilities = [a.lower() for a in body_data['abilities']] + [b.lower() for b in head_data['abilities']]

                    # Apply fusion ability filters (if any)
                    if desired_fusion_abilities:
                        if not desired_fusion_abilities.intersection(set(fusion_abilities)):
                            continue  # Skip if fusion abilities do not match desired abilities
                    # Else: No fusion ability filters applied

                    # Fusion Moveset (combined moves of head and body)
                    fusion_moves = head_data['moves'].union(body_data['moves'])

                    # Apply fusion move filters (if any)
                    if desired_fusion_moves:
                        if not desired_fusion_moves.issubset(set([m.lower() for m in fusion_moves])):
                            continue  # Skip if fusion moves do not include all desired moves
                    # Else: No fusion move filters applied

                    # Compute total fusion score
                    total_score = head_data['score'] + body_data['score']

                    # Collect the pair
                    possible_pairs.append((total_score, head_name, body_name, fusion_t1, fusion_t2, fusion_abilities))

            if not possible_pairs:
                st.warning("No fusions match the specified filters.")
                return

            # Sort possible pairs by total score
            possible_pairs.sort(reverse=True)

            # Get top K fusions
            top_fusions = []
            for idx, (fusion_score, head_name, body_name, fusion_t1, fusion_t2, fusion_abilities) in enumerate(possible_pairs):
                if idx >= top_k:
                    break

                # Compute fusion stats
                fusion_stats = {}
                for stat in stat_columns:
                    hs = head_contributions[head_name]['stats'][stat]
                    bs = body_contributions[body_name]['stats'][stat]
                    fusion_stats[stat] = hs + bs

                # Format abilities for display
                abilities_display = ', '.join([a.title() for a in fusion_abilities])

                fusion_entry = {
                    'Head': head_name.title(),
                    'Body': body_name.title(),
                    'Fusion Score': fusion_score,
                    'Type 1': fusion_t1,
                    'Type 2': fusion_t2,
                    'Abilities': abilities_display,
                }
                for stat in stat_columns:
                    fusion_entry[stat.replace('-', ' ').title()] = int(fusion_stats[stat])
                top_fusions.append(fusion_entry)

            if top_fusions:
                # Apply stat filters to fusions
                fusion_df = pd.DataFrame(top_fusions)
                for stat in stat_columns:
                    min_val, max_val = stat_filters[stat]
                    fusion_df = fusion_df[(fusion_df[stat.replace('-', ' ').title()] >= min_val) & (fusion_df[stat.replace('-', ' ').title()] <= max_val)]

                if fusion_df.empty:
                    st.warning("No fusions match the specified filters.")
                else:
                    fusion_df = fusion_df[['Head', 'Body', 'Type 1', 'Type 2', 'Abilities', 'Fusion Score'] + [stat.replace('-', ' ').title() for stat in stat_columns]]
                    st.write(f"### Top {len(fusion_df)} Fusions:")
                    st.dataframe(fusion_df.reset_index(drop=True))
            else:
                st.warning("No fusions match the specified filters.")
    else:
        st.write("Adjust the settings and click 'Compute Best Fusions' to find the optimal Pokémon fusions based on your preferences.")

if __name__ == "__main__":
    main()