__author__ = "Simon Nilsson"


import glob
import itertools
import logging
import os
import shutil
from configparser import ConfigParser
from datetime import datetime
from typing import Any, Dict, List, Optional, Tuple, Union

import cv2
import pandas as pd

try:
    from typing import Literal
except:
    from typing_extensions import Literal

from simba.utils.checks import (check_file_exist_and_readable,
                                check_if_dir_exists,
                                check_if_filepath_list_is_empty)
from simba.utils.data import create_color_palettes
from simba.utils.enums import ConfigKey, Defaults, Dtypes, Keys, Paths
from simba.utils.errors import (DataHeaderError, DuplicationError,
                                InvalidInputError,
                                MissingProjectConfigEntryError,
                                NoFilesFoundError, NoROIDataError,
                                NotDirectoryError, ParametersFileError)
from simba.utils.lookups import get_color_dict, get_emojis
from simba.utils.printing import SimbaTimer, stdout_success
from simba.utils.read_write import (find_core_cnt, get_all_clf_names,
                                    get_fn_ext, read_config_file, read_df,
                                    read_project_path_and_file_type, write_df)
from simba.utils.warnings import (BodypartColumnNotFoundWarning,
                                  DataHeaderWarning, InvalidValueWarning,
                                  NoDataFoundWarning, NoFileFoundWarning)


class ConfigReader(object):
    """
    Methods for reading SimBA configparser.Configparser project config and associated project data

    :param configparser.Configparser config_path: path to SimBA project_config.ini
    :param bool read_video_info: if true, read the project_folder/logs/video_info.csv file.
    """

    def __init__(self, config_path: str, read_video_info: bool = True):
        self.timer = SimbaTimer(start=True)
        self.config_path = config_path
        self.config = read_config_file(config_path=config_path)
        self.datetime = datetime.now().strftime("%Y%m%d%H%M%S")
        self.project_path, self.file_type = read_project_path_and_file_type(
            config=self.config
        )
        self.input_csv_dir = os.path.join(self.project_path, Paths.INPUT_CSV.value)
        self.features_dir = os.path.join(
            self.project_path, Paths.FEATURES_EXTRACTED_DIR.value
        )
        self.targets_folder = os.path.join(
            self.project_path, Paths.TARGETS_INSERTED_DIR.value
        )
        self.machine_results_dir = os.path.join(
            self.project_path, Paths.MACHINE_RESULTS_DIR.value
        )
        self.directionality_df_dir = os.path.join(
            self.project_path, Paths.DIRECTIONALITY_DF_DIR.value
        )
        self.outlier_corrected_dir = os.path.join(
            self.project_path, Paths.OUTLIER_CORRECTED.value
        )
        self.outlier_corrected_movement_dir = os.path.join(
            self.project_path, Paths.OUTLIER_CORRECTED_MOVEMENT.value
        )
        self.heatmap_clf_location_dir = os.path.join(
            self.project_path, Paths.HEATMAP_CLF_LOCATION_DIR.value
        )
        self.heatmap_location_dir = os.path.join(
            self.project_path, Paths.HEATMAP_LOCATION_DIR.value
        )
        self.line_plot_dir = os.path.join(self.project_path, Paths.LINE_PLOT_DIR.value)
        self.probability_plot_dir = os.path.join(
            self.project_path, Paths.PROBABILITY_PLOTS_DIR.value
        )
        self.gantt_plot_dir = os.path.join(
            self.project_path, Paths.GANTT_PLOT_DIR.value
        )
        self.path_plot_dir = os.path.join(self.project_path, Paths.PATH_PLOT_DIR.value)
        self.shap_logs_path = os.path.join(self.project_path, Paths.SHAP_LOGS.value)
        self.video_info_path = os.path.join(self.project_path, Paths.VIDEO_INFO.value)
        self.font = cv2.FONT_HERSHEY_COMPLEX
        self.roi_features_save_dir = os.path.join(
            self.project_path, Paths.ROI_FEATURES.value
        )
        self.configs_meta_dir = os.path.join(self.project_path, "configs")
        self.sklearn_plot_dir = os.path.join(
            self.project_path, Paths.SKLEARN_RESULTS.value
        )
        self.detailed_roi_data_dir = os.path.join(
            self.project_path, Paths.DETAILED_ROI_DATA_DIR.value
        )
        self.directing_animals_video_output_path = os.path.join(
            self.project_path, Paths.DIRECTING_BETWEEN_ANIMALS_OUTPUT_PATH.value
        )
        self.animal_cnt = self.read_config_entry(
            config=self.config,
            section=ConfigKey.GENERAL_SETTINGS.value,
            option=ConfigKey.ANIMAL_CNT.value,
            data_type=Dtypes.INT.value,
        )
        self.clf_cnt = self.read_config_entry(
            self.config,
            ConfigKey.SML_SETTINGS.value,
            ConfigKey.TARGET_CNT.value,
            Dtypes.INT.value,
        )
        self.clf_names = get_all_clf_names(config=self.config, target_cnt=self.clf_cnt)
        self.feature_file_paths = glob.glob(self.features_dir + "/*." + self.file_type)
        self.target_file_paths = glob.glob(self.targets_folder + "/*." + self.file_type)
        self.input_csv_paths = glob.glob(self.input_csv_dir + "/*." + self.file_type)
        self.outlier_corrected_paths = glob.glob(
            self.outlier_corrected_dir + "/*." + self.file_type
        )
        self.outlier_corrected_movement_paths = glob.glob(
            self.outlier_corrected_movement_dir + "/*." + self.file_type
        )
        self.cpu_cnt, self.cpu_to_use = find_core_cnt()
        self.machine_results_paths = glob.glob(
            self.machine_results_dir + "/*." + self.file_type
        )
        self.logs_path = os.path.join(self.project_path, "logs")
        self.body_parts_path = os.path.join(self.project_path, Paths.BP_NAMES.value)
        check_file_exist_and_readable(file_path=self.body_parts_path)
        self.body_parts_lst = (
            pd.read_csv(self.body_parts_path, header=None).iloc[:, 0].to_list()
        )
        self.body_parts_lst = [x for x in self.body_parts_lst if str(x) != "nan"]
        self.get_body_part_names()
        self.get_bp_headers()
        self.bp_col_names = self.x_cols + self.y_cols + self.p_cols
        self.pose_setting = self.read_config_entry(
            config=self.config,
            section=ConfigKey.CREATE_ENSEMBLE_SETTINGS.value,
            option=ConfigKey.POSE_SETTING.value,
            data_type=Dtypes.STR.value,
        )
        self.roi_coordinates_path = os.path.join(
            self.logs_path, Paths.ROI_DEFINITIONS.value
        )
        self.video_dir = os.path.join(self.project_path, "videos")
        self.clf_validation_dir = os.path.join(
            self.project_path, Paths.CLF_VALIDATION_DIR.value
        )
        self.clf_data_validation_dir = os.path.join(
            self.project_path, "csv", "validation"
        )
        self.annotated_frm_dir = os.path.join(
            self.project_path, Paths.ANNOTATED_FRAMES_DIR.value
        )
        self.single_validation_video_save_dir = os.path.join(
            self.project_path, Paths.SINGLE_CLF_VALIDATION.value
        )
        self.data_table_path = os.path.join(self.project_path, Paths.DATA_TABLE.value)
        self.check_multi_animal_status()
        self.multiprocess_chunksize = Defaults.CHUNK_SIZE.value
        self.maxtasksperchild = Defaults.MAX_TASK_PER_CHILD.value
        self.clr_lst = create_color_palettes(
            self.animal_cnt, int(len(self.x_cols) / self.animal_cnt) + 1
        )
        self.animal_bp_dict = self.create_body_part_dictionary(
            self.multi_animal_status,
            self.multi_animal_id_list,
            self.animal_cnt,
            self.x_cols,
            self.y_cols,
            self.p_cols,
            self.clr_lst,
        )
        self.project_bps = list(set([x[:-2] for x in self.bp_headers]))
        self.color_dict = get_color_dict()
        self.emojis = get_emojis()
        if read_video_info:
            self.video_info_df = self.read_video_info_csv(
                file_path=self.video_info_path
            )

    def read_roi_data(self) -> None:
        """
        Method to read in ROI definitions from SimBA project
        """

        if not os.path.isfile(self.roi_coordinates_path):
            raise NoROIDataError(
                msg="SIMBA ERROR: No ROI definitions were found in your SimBA project. Please draw some ROIs before analyzing your ROI data"
            )
        else:
            self.rectangles_df = pd.read_hdf(
                self.roi_coordinates_path, key=Keys.ROI_RECTANGLES.value
            ).dropna(how="any")
            self.circles_df = pd.read_hdf(
                self.roi_coordinates_path, key=Keys.ROI_CIRCLES.value
            ).dropna(how="any")
            self.polygon_df = pd.read_hdf(
                self.roi_coordinates_path, key=Keys.ROI_POLYGONS.value
            )
            if "Center_XCenter_Y" in self.polygon_df.columns:
                self.polygon_df = self.polygon_df.drop(["Center_XCenter_Y"], axis=1)
            self.polygon_df = self.polygon_df.dropna(how="any")
            self.shape_names = list(
                itertools.chain(
                    self.rectangles_df["Name"].unique(),
                    self.circles_df["Name"].unique(),
                    self.polygon_df["Name"].unique(),
                )
            )
            self.roi_dict = {
                Keys.ROI_RECTANGLES.value: self.rectangles_df,
                Keys.ROI_CIRCLES.value: self.circles_df,
                Keys.ROI_POLYGONS.value: self.polygon_df,
            }
            self.roi_types_names_lst = set()
            for idx, r in self.roi_dict[Keys.ROI_RECTANGLES.value].iterrows():
                self.roi_types_names_lst.add(f'Rectangle: {r["Name"]}')
            for idx, r in self.roi_dict[Keys.ROI_CIRCLES.value].iterrows():
                self.roi_types_names_lst.add(f'Circle: {r["Name"]}')
            for idx, r in self.roi_dict[Keys.ROI_POLYGONS.value].iterrows():
                self.roi_types_names_lst.add(f'Polygon: {r["Name"]}')
            self.roi_types_names_lst = list(self.roi_types_names_lst)
            for shape_type, shape_data in self.roi_dict.items():
                if shape_type == Keys.ROI_CIRCLES.value:
                    self.roi_dict[Keys.ROI_CIRCLES.value]["Center_X"] = self.roi_dict[
                        Keys.ROI_CIRCLES.value
                    ]["centerX"]
                    self.roi_dict[Keys.ROI_CIRCLES.value]["Center_Y"] = self.roi_dict[
                        Keys.ROI_CIRCLES.value
                    ]["centerY"]
                elif shape_type == Keys.ROI_RECTANGLES.value:
                    self.roi_dict[Keys.ROI_RECTANGLES.value][
                        "Center_X"
                    ] = self.roi_dict[Keys.ROI_RECTANGLES.value]["Bottom_right_X"] - (
                        (
                            self.roi_dict[Keys.ROI_RECTANGLES.value]["Bottom_right_X"]
                            - self.roi_dict[Keys.ROI_RECTANGLES.value]["width"]
                        )
                        / 2
                    )
                    self.roi_dict[Keys.ROI_RECTANGLES.value][
                        "Center_Y"
                    ] = self.roi_dict[Keys.ROI_RECTANGLES.value]["Bottom_right_Y"] - (
                        (
                            self.roi_dict[Keys.ROI_RECTANGLES.value]["Bottom_right_Y"]
                            - self.roi_dict[Keys.ROI_RECTANGLES.value]["height"]
                        )
                        / 2
                    )
                elif shape_type == Keys.ROI_POLYGONS.value:
                    self.roi_dict[Keys.ROI_POLYGONS.value]["Center_X"] = self.roi_dict[
                        Keys.ROI_POLYGONS.value
                    ]["Center_X"]
                    self.roi_dict[Keys.ROI_POLYGONS.value]["Center_Y"] = self.roi_dict[
                        Keys.ROI_POLYGONS.value
                    ]["Center_Y"]

    def get_all_clf_names(self) -> List[str]:
        """
        Helper to return all classifier names in SimBA project

        :return List[str]:
        """

        model_names = []
        for i in range(self.clf_cnt):
            entry_name = "target_name_{}".format(str(i + 1))
            model_names.append(
                self.read_config_entry(
                    self.config,
                    ConfigKey.SML_SETTINGS.value,
                    entry_name,
                    data_type=Dtypes.STR.value,
                )
            )
        return model_names

    def insert_column_headers_for_outlier_correction(
        self, data_df: pd.DataFrame, new_headers: List[str], filepath: str
    ) -> pd.DataFrame:
        """
        Helper to insert new column headers onto a dataframe.

        :param pd.DataFrame data_df:  Dataframe where headers to to-bo replaced.
        :param List[str] new_headers:  Names of new headers.
        :param str filepath:  Path to where ``data_df`` is stored on disk
        :returns pd.DataFrame: Dataframe with new headers
        :raises DataHeaderWarning: If new headers are fewer/more than columns in dataframe

        :example:
        >>> df = pd.DataFrame(data=[[1, 2, 3], [1, 2, 3]], columns=['Feature_1', 'Feature_2', 'Feature_3'])
        >>> ConfigReader.insert_column_headers_for_outlier_correction(data_df=df, new_headers=['Feature_4', 'Feature_5', 'Feature_6'], filepath='test/my_test_file.csv')
        """

        if len(new_headers) != len(data_df.columns):
            difference = int(len(data_df.columns) - len(new_headers))
            bp_missing = int(abs(difference) / 3)
            if difference < 0:
                raise DataHeaderError(
                    msg=f"SIMBA ERROR: SimBA expects {len(new_headers)} columns of data inside the files within project_folder/csv/input_csv directory. However, within file {filepath} file, SimBA found {len(data_df.columns)} columns. Thus, there is {abs(difference)} missing data columns in the imported data, which may represent {int(bp_missing)} bodyparts if each body-part has an x, y and p value. Either revise the SimBA project pose-configuration with {bp_missing} less body-part, or include {bp_missing} more body-part in the imported data"
                )
            else:
                raise DataHeaderError(
                    msg=f"SIMBA ERROR: SimBA expects {len(new_headers)} columns of data inside the files within project_folder/csv/input_csv directory. However, within file {filepath} file, SimBA found {len(data_df.columns)} columns. Thus, there is {abs(difference)} more data columns in the imported data than anticipated, which may represent {int(bp_missing)} bodyparts if each body-part has an x, y and p value. Either revise the SimBA project pose-configuration with {bp_missing} more body-part, or include {bp_missing} less body-part in the imported data"
                )
        else:
            data_df.columns = new_headers
            return data_df

    def get_number_of_header_columns_in_df(self, df: pd.DataFrame) -> int:
        """
        Helper to find the count of non-numerical rows at the top of a dataframe.

        :param pd.DataFrame data_df: Dataframe to find the count non-numerical header rows in.
        :returns int
        :raises DataHeaderError: All rows are non-numerical.

        :example:
        >>> ConfigReader.get_number_of_header_columns_in_df(df=pd.DataFrame(data=[[1, 2, 3], [1, 2, 3]]))
        >>> 0
        >>> ConfigReader.get_number_of_header_columns_in_df(df=pd.DataFrame(data=[['Head_1', 'Body_2', 'Tail_3'], ['Some_nonsense', 'A_mistake', 'Maybe_multi_headers?'], [11, 99, 109], [122, 43, 2091]]))
        >>> 2
        """

        for i in range(len(df)):
            try:
                _ = df.iloc[i:].apply(pd.to_numeric).reset_index(drop=True)
                return i
            except ValueError:
                pass
        raise DataHeaderError(msg="Could find the count of header columns in dataframe")

    def find_video_of_file(
        self, video_dir: Union[str, os.PathLike], filename: str
    ) -> Union[str, os.PathLike]:
        """
        Helper to find the video file representing a known data file basename.

        :param Union[str, os.PathLike] video_dir: Directory holding putative video file.
        :param str filename: Data file name, e.g., ``Video_1``.
        :return Union[str, os.PathLike]: Path to video file.

        :example:
        >>> config_reader = ConfigReader(config_path='My_SimBA_Config')
        >>> config_reader.find_video_of_file(video_dir=config_reader.video_dir, filename='Video1')
        >>> '/project_folder/videos/Video1.mp4'
        """
        try:
            all_files_in_video_folder = [
                f for f in next(os.walk(video_dir))[2] if not f[0] == "."
            ]
        except StopIteration:
            raise NoFilesFoundError(msg=f"No files found in the {video_dir} directory")
        all_files_in_video_folder = [
            os.path.join(video_dir, x) for x in all_files_in_video_folder
        ]
        return_path = None
        for file_path in all_files_in_video_folder:
            _, video_filename, ext = get_fn_ext(file_path)
            if (video_filename == filename) and (
                (ext.lower() == ".mp4") or (ext.lower() == ".avi")
            ):
                return_path = file_path

        if return_path is None:
            NoFileFoundWarning(
                f"SimBA could not find a video file representing {filename} in the project video directory"
            )
        return return_path

    def add_missing_ROI_cols(self, shape_df: pd.DataFrame) -> pd.DataFrame:
        """
        Helper to add missing ROI definitions (``Color BGR``, ``Thickness``, ``Color name``) in ROI info
        dataframes created by the first version of the SimBA ROI user-interface but analyzed using newer versions of SimBA.


        :param pd.DataFrame shape_df: Dataframe holding ROI definitions.
        :return pd.DataFrame with ``Color BGR``, ``Thickness``, ``Color name`` fields
        """

        if not "Color BGR" in shape_df.columns:
            shape_df["Color BGR"] = [(255, 255, 255)] * len(shape_df)
        if not "Thickness" in shape_df.columns:
            shape_df["Thickness"] = [5] * len(shape_df)
        if not "Color name" in shape_df.columns:
            shape_df["Color name"] = "White"

        return shape_df

    def remove_a_folder(self, folder_dir: str) -> None:
        """Helper to remove a directory"""
        shutil.rmtree(folder_dir, ignore_errors=True)

    def remove_multiple_folders(self, folders: List[str]) -> None:
        """Helper to remove multiple directories"""
        for folder_path in folders:
            shutil.rmtree(folder_path, ignore_errors=True)

    def find_animal_name_from_body_part_name(self, bp_name: str, bp_dict: dict) -> str:
        """
        Given body-part name and animal body-part dict, returns the animal name

        :param str bp_name: Name of the body-part. E.g., ``Ear_1``.
        :param dict bp_dict: Nested dict holding animal names as keys and body-part names as and coordinates as values. Created by :meth:`simba.mixins.config_reader.ConfigReader.create_body_part_dictionary`
        :returns str

        :example:
        >>> config_reader = ConfigReader(config_path='tests/data/test_projects/two_c57/project_folder/project_config.ini')
        >>> ConfigReader.find_animal_name_from_body_part_name(bp_name='Ear_1', bp_dict=config_reader.animal_bp_dict)
        >>> 'simon'
        """

        for animal_name, animal_bps in bp_dict.items():
            if bp_name in [x[:-2] for x in animal_bps["X_bps"]]:
                return animal_name

    def create_logger(self, path: str) -> None:
        logger = logging.getLogger()
        logger.setLevel(logging.INFO)
        handler = logging.FileHandler(filename=path, encoding="utf-8")  # or whatever
        handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
        logger.addHandler(handler)

    def create_body_part_dictionary(
        self,
        multi_animal_status: bool,
        animal_id_lst: list,
        animal_cnt: int,
        x_cols: List[str],
        y_cols: List[str],
        p_cols: Optional[List[str]] = None,
        colors: Optional[List[List[Tuple[int, int, int]]]] = None,
    ) -> Dict[str, Union[List[str], List[Tuple]]]:
        """
        Helper to create dict of dict lookup of body-parts where the keys are animal names, and
        values are the body-part names.

        :param bool multi_animal_status: If True, it is a multi-animal SimBA project.
        :param List[str] multi_animal_id_lst: Animal names. Eg., ['Simon, 'JJ']. Note: If a single animal project, this will be overridden and set to `Animal_1`.
        :param int animal_cnt: Number of animals in the SimBA project.
        :param List[str] x_cols: column names for body-part coordinates on x-axis. Returned by  :meth:`simba.mixins.config_reader.ConfigReader.get_body_part_names`
        :param List[str] y_cols: column names for body-part coordinates on y-axis. Returned by  :meth:`simba.mixins.config_reader.ConfigReader.get_body_part_names`
        :param List[str] p_cols: column names for body-part pose-estimation probability values. Returned by  :meth:`simba.mixins.config_reader.ConfigReader.get_body_part_names`
        :param Optional[List[List[Tuple[int, int, int]]]] colors: Optional bgr colors to associate with the body-parts. Returned by :meth:`simba.utils.data.create_color_palettes`.
        :returns dict

        :example:
        >>> ConfigReader.create_body_part_dictionary(multi_animal_status=True, animal_id_lst=['simon',])
        >>> {'simon': {'X_bps': ['Nose_1_x', 'Ear_left_1_x', 'Ear_right_1_x', 'Center_1_x', 'Lat_left_1_x', 'Lat_right_1_x', 'Tail_base_1_x', 'Tail_end_1_x'], 'Y_bps': ['Nose_1_y', 'Ear_left_1_y', 'Ear_right_1_y', 'Center_1_y', 'Lat_left_1_y', 'Lat_right_1_y', 'Tail_base_1_y', 'Tail_end_1_y'], 'colors': [[255.0, 0.0, 255.0], [223.125, 31.875, 255.0], [191.25, 63.75, 255.0], [159.375, 95.625, 255.0], [127.5, 127.5, 255.0], [95.625, 159.375, 255.0], [63.75, 191.25, 255.0], [31.875, 223.125, 255.0], [0.0, 255.0, 255.0]], 'P_bps': ['Nose_1_p', 'Ear_left_1_p', 'Ear_right_1_p', 'Center_1_p', 'Lat_left_1_p', 'Lat_right_1_p', 'Tail_base_1_p', 'Tail_end_1_p']}, 'jj': {'X_bps': ['Nose_2_x', 'Ear_left_2_x', 'Ear_right_2_x', 'Center_2_x', 'Lat_left_2_x', 'Lat_right_2_x', 'Tail_base_2_x', 'Tail_end_2_x'], 'Y_bps': ['Nose_2_y', 'Ear_left_2_y', 'Ear_right_2_y', 'Center_2_y', 'Lat_left_2_y', 'Lat_right_2_y', 'Tail_base_2_y', 'Tail_end_2_y'], 'colors': [[102.0, 127.5, 0.0], [102.0, 143.4375, 31.875], [102.0, 159.375, 63.75], [102.0, 175.3125, 95.625], [102.0, 191.25, 127.5], [102.0, 207.1875, 159.375], [102.0, 223.125, 191.25], [102.0, 239.0625, 223.125], [102.0, 255.0, 255.0]], 'P_bps': ['Nose_2_p', 'Ear_left_2_p', 'Ear_right_2_p', 'Center_2_p', 'Lat_left_2_p', 'Lat_right_2_p', 'Tail_base_2_p', 'Tail_end_2_p']}}
        """

        animal_bp_dict = {}
        if multi_animal_status:
            for animal in range(animal_cnt):
                animal_bp_dict[animal_id_lst[animal]] = {}
                animal_bp_dict[animal_id_lst[animal]]["X_bps"] = [
                    i for i in x_cols if animal_id_lst[animal] in i
                ]
                animal_bp_dict[animal_id_lst[animal]]["Y_bps"] = [
                    i for i in y_cols if animal_id_lst[animal] in i
                ]
                if colors:
                    animal_bp_dict[animal_id_lst[animal]]["colors"] = colors[animal]
                if p_cols:
                    animal_bp_dict[animal_id_lst[animal]]["P_bps"] = [
                        i for i in p_cols if animal_id_lst[animal] in i
                    ]
                if not animal_bp_dict[animal_id_lst[animal]]["X_bps"]:
                    multi_animal_status = False
                    break

        if not multi_animal_status:
            if animal_cnt > 1:
                for animal in range(animal_cnt):
                    currAnimalName = f"Animal_{str(animal + 1)}"
                    search_string_x = f"_{str(animal + 1)}_x"
                    search_string_y = f"_{str(animal + 1)}_y"
                    search_string_p = f"_{str(animal + 1)}_p"
                    animal_bp_dict[currAnimalName] = {}
                    animal_bp_dict[currAnimalName]["X_bps"] = [
                        i for i in x_cols if i.endswith(search_string_x)
                    ]
                    animal_bp_dict[currAnimalName]["Y_bps"] = [
                        i for i in y_cols if i.endswith(search_string_y)
                    ]
                    if colors:
                        animal_bp_dict[currAnimalName]["colors"] = colors[animal]
                    if p_cols:
                        animal_bp_dict[currAnimalName]["P_bps"] = [
                            i for i in p_cols if i.endswith(search_string_p)
                        ]
                if animal_id_lst[0] != "":
                    for animal in range(len(animal_id_lst)):
                        currAnimalName = f"Animal_{str(animal + 1)}"
                        animal_bp_dict[animal_id_lst[animal]] = animal_bp_dict.pop(
                            currAnimalName
                        )

            else:
                animal_bp_dict["Animal_1"] = {}
                animal_bp_dict["Animal_1"]["X_bps"] = [i for i in x_cols]
                animal_bp_dict["Animal_1"]["Y_bps"] = [i for i in y_cols]
                if colors:
                    animal_bp_dict["Animal_1"]["colors"] = colors[0]
                if p_cols:
                    animal_bp_dict["Animal_1"]["P_bps"] = [i for i in p_cols]

        return animal_bp_dict

    def get_body_part_names(self):
        """
        Helper to extract pose-estimation data field names (x, y, p)

        :example:
        >>> config_reader = ConfigReader(config_path='test/project_config.csv')
        >>> config_reader.get_body_part_names()
        """
        self.x_cols, self.y_cols, self.p_cols = [], [], []
        for bp in self.body_parts_lst:
            self.x_cols.append(f"{bp}_x")
            self.y_cols.append(f"{bp}_y")
            self.p_cols.append(f"{bp}_p")

    def drop_bp_cords(self, df: pd.DataFrame) -> pd.DataFrame:
        """
        Helper to remove pose-estimation fields from dataframe.

        :param pd.DataFrame df: pandas dataframe containing pose-estimation fields (body-part x, y, p fields)
        :return pd.DataFrame: ``df`` without pose-estimation fields

        :example:
        >>> config_reader = ConfigReader(config_path='test/project_folder/project_config.csv')
        >>> df = read_df(config_reader.machine_results_paths[0], file_type='csv')
        >>> df = config_reader.drop_bp_cords(df=df)
        """
        missing_body_part_fields = list(set(self.bp_col_names) - set(list(df.columns)))
        if len(missing_body_part_fields) > 0:
            BodypartColumnNotFoundWarning(
                msg=f"SimBA could not drop body-part coordinates, some body-part names are missing in dataframe. SimBA expected the following body-parts, that could not be found inside the file: {missing_body_part_fields}"
            )
        else:
            return df.drop(self.bp_col_names, axis=1)

    def get_bp_headers(self) -> None:
        """
        Helper to create ordered list of all column header fields for SimBA project dataframes.

        >>> config_reader = ConfigReader(config_path='test/project_folder/project_config.csv')
        >>> config_reader.get_bp_headers()
        """
        self.bp_headers = []
        for bp in self.body_parts_lst:
            c1, c2, c3 = (f"{bp}_x", f"{bp}_y", f"{bp}_p")
            self.bp_headers.extend((c1, c2, c3))

    def read_config_entry(
        self,
        config: ConfigParser,
        section: str,
        option: str,
        data_type: Literal["str", "int", "float", "folder_path"],
        default_value: Optional[Any] = None,
        options: Optional[List[Any]] = None,
    ) -> Union[str, int, float]:
        """
        Helper to read entry from a configparser.ConfigParser object

        :param ConfigParser config: Parsed SimBA project config
        :param str section: Project config section name
        :param str option: Project config option name
        :param str data_type: Type of data. E.g., ``str``, ``int``, ``float``, ``folder_path``.
        :param Optional[Any] default_value: If entry cannot be found, then default to this value.
        :param Optional[List[Any]] options: If not ``None``, then list of viable entries.
        :raise InvalidInputError: If returned value is not in ``options``.
        :raise MissingProjectConfigEntryError: If no entry is found and no ``default_value`` is provided.

        :return Union[str, float, int, os.Pathlike]

        :example:
        >>> config = ConfigReader(config_path='tests/data/test_projects/two_c57/project_folder/project_config.ini')
        >>> config.read_config_entry(config=self.config, section='Multi animal IDs', option='id_list', data_type='str')
        >>> 'simon,jj'
        """
        try:
            if config.has_option(section, option):
                if data_type == Dtypes.FLOAT.value:
                    value = config.getfloat(section, option)
                elif data_type == Dtypes.INT.value:
                    value = config.getint(section, option)
                elif data_type == Dtypes.STR.value:
                    value = config.get(section, option).strip()
                elif data_type == Dtypes.FOLDER.value:
                    value = config.get(section, option).strip()
                    if not os.path.isdir(value):
                        raise NotDirectoryError(
                            msg=f"The SimBA config file includes paths to a folder ({value}) that does not exist."
                        )
                if options != None:
                    if value not in options:
                        raise InvalidInputError(
                            msg=f"{option} is set to {str(value)} in SimBA, but this is not among the valid options: ({options})"
                        )
                    else:
                        return value
                return value
            elif default_value != None:
                return default_value
            else:
                raise MissingProjectConfigEntryError(
                    msg=f"SimBA could not find an entry for option {option} under section {section} in the project_config.ini. Please specify the settings in the settings menu."
                )
        except ValueError:
            if default_value != None:
                return default_value
            else:
                raise MissingProjectConfigEntryError(
                    msg=f"SimBA could not find an entry for option {option} under section {section} in the project_config.ini. Please specify the settings in the settings menu."
                )

    def read_video_info_csv(self, file_path: str) -> pd.DataFrame:
        """
        Helper to read the project_folder/logs/video_info.csv of the SimBA project in as a pd.DataFrame
        Parameters
        ----------
        file_path: str

        Returns
        -------
        pd.DataFrame
        """

        check_file_exist_and_readable(file_path=file_path)
        info_df = pd.read_csv(file_path)
        for c in [
            "Video",
            "fps",
            "Resolution_width",
            "Resolution_height",
            "Distance_in_mm",
            "pixels/mm",
        ]:
            if c not in info_df.columns:
                raise ParametersFileError(
                    msg=f'The project "project_folder/logs/video_info.csv" does not not have an anticipated header ({c}). Please re-create the file and make sure each video has a {c} value'
                )
        info_df["Video"] = info_df["Video"].astype(str)
        for c in [
            "fps",
            "Resolution_width",
            "Resolution_height",
            "Distance_in_mm",
            "pixels/mm",
        ]:
            try:
                info_df[c] = info_df[c].astype(float)
            except:
                raise ParametersFileError(
                    msg=f'One or more values in the {c} column of the "project_folder/logs/video_info.csv" file could not be interpreted as a numeric value. Please re-create the file and make sure the entries in the {c} column are all numeric.'
                )
        if info_df["fps"].min() <= 1:
            InvalidValueWarning(
                msg="Videos in your SimBA project have an FPS of 1 or less. Please use videos with more than one frame per second, or correct the inaccurate fps inside the `project_folder/logs/videos_info.csv` file"
            )
        return info_df

    def read_video_info(
        self, video_name: str, raise_error: Optional[bool] = True
    ) -> (pd.DataFrame, float, float):
        """
        Helper to read the meta-data (pixels per mm, resolution, fps) from the video_info.csv for a single input file.

        :param str video_name: The name of the video without extension to get the metadata for
        :param Optional[bool] raise_error: If True, raise error if video info for the video name cannot be found. Default: True.
        :raise ParametersFileError: If ``raise_error`` and video metadata info is not found
        :raise DuplicationError: If file contains multiple entries for the same video.
        :return (pd.DataFrame, float, float) representing all video info, pixels per mm, and fps
        """

        video_settings = self.video_info_df.loc[
            self.video_info_df["Video"] == video_name
        ]
        if len(video_settings) > 1:
            raise DuplicationError(
                msg=f"SimBA found multiple rows in the project_folder/logs/video_info.csv named {str(video_name)}. Please make sure that each video name is represented ONCE in the video_info.csv"
            )
        elif len(video_settings) < 1:
            if raise_error:
                raise ParametersFileError(
                    msg=f"SimBA could not find {str(video_name)} in the video_info.csv file. Make sure all videos analyzed are represented in the project_folder/logs/video_info.csv file."
                )
            else:
                return (None, None, None)
        else:
            try:
                px_per_mm = float(video_settings["pixels/mm"])
                fps = float(video_settings["fps"])
                return video_settings, px_per_mm, fps
            except TypeError:
                raise ParametersFileError(
                    msg=f"Make sure the videos that are going to be analyzed are represented with APPROPRIATE VALUES inside the project_folder/logs/video_info.csv file in your SimBA project. Could not interpret the fps, pixels per millimeter and/or fps as numerical values for video {video_name}"
                )

    def check_multi_animal_status(self) -> None:
        """
        Helper to check if the project is a multi-animal SimBA project.

        """
        multi_animal_id_lst = []
        if not self.config.has_section("Multi animal IDs"):
            for animal in range(self.animal_cnt):
                multi_animal_id_lst.append("Animal_" + str(animal + 1))
            multi_animal_status = False

        else:
            multi_animal_id_str = self.read_config_entry(
                config=self.config,
                section="Multi animal IDs",
                option="id_list",
                data_type="str",
            )
            multi_animal_id_lst = [x.lstrip() for x in multi_animal_id_str.split(",")]
            multi_animal_id_lst = [x for x in multi_animal_id_lst if x != "None"]
            if (self.animal_cnt > 1) and (len(multi_animal_id_lst) > 1):
                multi_animal_status = True
            else:
                for animal in range(self.animal_cnt):
                    multi_animal_id_lst.append("Animal_{}".format(str(animal + 1)))
                multi_animal_status = False

        self.multi_animal_status = multi_animal_status
        self.multi_animal_id_list = multi_animal_id_lst[: self.animal_cnt]

    def remove_roi_features(self, data_dir: Union[str, os.PathLike]) -> None:
        """
        Helper to remove ROI-based features from datasets within a directory. The identified ROI-based fields are move to the
        ``project_folder/logs/ROI_data_{datetime}`` directory.

        .. note::
           ROI-based features are identified based on the combined criteria of (i) The prefix of the field is a named ROI in
           the ``project_folder/logs/ROI_definitions.h5`` file, and (ii) the suffix of the field is contained in the ['in zone',
           'n zone_cumulative_time', 'in zone_cumulative_percent', 'distance', 'facing']

        :param Union[str, os.PathLike] data_dir: directory with data to remove ROi features from.

        :example:
        >>> self.remove_roi_features('/project_folder/csv/features_extracted')

        """
        ROI_COL_SUFFIXES = [
            "in zone",
            "n zone_cumulative_time",
            "in zone_cumulative_percent",
            "distance",
            "facing",
        ]

        check_if_dir_exists(in_dir=data_dir)
        timer = SimbaTimer(start=True)
        filepaths = glob.glob(data_dir + f"/*.{self.file_type}")
        roi_dir = os.path.join(
            self.project_path,
            "logs",
            f'ROI_data_{datetime.now().strftime("%Y%m%d%H%M%S")}',
        )
        check_if_filepath_list_is_empty(
            filepaths=filepaths,
            error_msg=f"No .{self.file_type} files found in {data_dir}.",
        )
        for file_cnt, file_path in enumerate(filepaths):
            _, video_name, _ = get_fn_ext(filepath=file_path)
            roi_cols = set()
            df = read_df(file_path=file_path, file_type=self.file_type)
            for shape_name in self.shape_names:
                roi_cols.update([x for x in df.columns if shape_name in x])
            for suffix in ROI_COL_SUFFIXES:
                roi_cols.update([x for x in roi_cols if x.endswith(suffix)])
            if len(roi_cols) == 0:
                NoDataFoundWarning(msg=f"NO ROI data found in {file_path}")
            else:
                roi_df = df[list(roi_cols)]
                df = df.drop(list(roi_cols), axis=1)
                roi_save_path = os.path.join(roi_dir, video_name + "." + self.file_type)
                if not os.path.exists(roi_dir):
                    os.makedirs(roi_dir)
                write_df(df=roi_df, file_type=self.file_type, save_path=roi_save_path)
                write_df(df=df, file_type=self.file_type, save_path=file_path)
                print(f"ROI features (N={len(roi_cols)}) removed from {video_name}...")
        timer.stop_timer()
        stdout_success(
            msg=f"ROI features removed from {len(filepaths)} files in the {data_dir} directory. The ROI features has been moved to the {roi_dir} directory",
            elapsed_time=timer.elapsed_time_str,
        )

        # x = [x for x in self.shape_names if any(x in y or y in x for y in df.colum)]


# config = ConfigReader(config_path='/Users/simon/Desktop/envs/simba_dev/tests/data/test_projects/two_c57/project_folder/project_config.ini', read_video_info=False)
# config.read_roi_data()


# config = ConfigReader(config_path='/Users/simon/Desktop/envs/troubleshooting/Nastacia_unsupervised/project_folder/project_config.ini', read_video_info=False)
# config.read_roi_data()
# config.remove_roi_features('/Users/simon/Desktop/envs/troubleshooting/Nastacia_unsupervised/project_folder/csv/features_extracted')

# remove_roi_features(
#     config_path='/Users/simon/Desktop/envs/troubleshooting/Nastacia_unsupervised/project_folder/project_config.ini',
#     data_dir='/Users/simon/Desktop/envs/troubleshooting/Nastacia_unsupervised/project_folder/csv/features_extracted')

# test = ConfigReader(config_path='/Users/simon/Desktop/envs/troubleshooting/two_black_animals_14bp/project_folder/project_config.ini')
