""" Classified to SeaBASS Data Format Module

This module contains a class to convert CNN-classified and/or validated 
datasets to a SeaBASS-compatible format. 

- Class MakeSeaBASS():
  Functions: 
    - __init__: Initializes the class by loading metadata and classification 
      dataframes and setting up various values like experiment, trigger mode, 
      etc. Creates a dictionary of all the dataset-universal values needed to 
      create the SeaBASS header. 
    - preview_seabass: Builds a complete SeaBASS string to preview the final
      format.
    - make_seabass_files: Loops over all samples in the dataset, creates a 
      seabass file for each one, and saves it to a new local folder. 
    - compile_header: Builds the header section of the SeaBASS file. 
    - extract_sample_ID: Retrieves the unique sample identifier from the sample
      file name. 
    - extract_sample_info: Extracts date and time information from the
      name of the sample. Also identifies the IFCB number. 
    - extract_investigator_info(dictionary): Retrieves investigator information 
      from a dictionary.
    - extract_metadata_for_header: Extracts metadata values like lat, long, 
      temp, salinity, etc. to be added to the header. 
    - run_sample_checks: Cross-checks the row numbers in various metadata files.
    - structure_data: Transforms data from the sample df into a SeaBASS-format
      string. 
    - write_seabass: Combines the header and data into one string. 
"""

import pandas as pd
import numpy as np
import os
import utopia_pipeline_tools as upt
from utopia_pipeline_tools.azure_blob_tools import list_files_in_blob
from utopia_pipeline_tools.ifcb_data_tools import retrieve_filepaths_from_local

class MakeSeaBASS():
    def __init__(self, metadata_filepath, class_filepath, experiment, cruise, 
                 location='blob', container=None, folder_filepath=None,
                 investigator_info=None, stations=True, flags=False, 
                 doc_list=None, data_status='final', trigger_mode='both', 
                 notes=None, sample_filepath=None, filepaths=None):         
        """
        Loads the classification and metadata csv files and sets up the header 
        values that are consistent across all samples. Also retrieves the file-
        names of all sample csv files from the sample subfolders within the 'ml'
        folder.    

        :param metadata_filepath: Filepath to the metadata file used to convert 
            ifcb data from raw to processed. This file must be a csv and have 
            columns containing lat/long, temperature, salinity, and sample 
            volume, concentration, and flag information.  
        :type metadata_filepath: str  
        :param class_filepath: Filepath to the .csv file generated by the code 
            that applies the CNN to the ml folder of ifcb images. This data must
            include 'filepath', 'pred_label', and probability columns labelled 
            '0' to '9'.   
        :type class_filepath: str  
        :param experiment: The name of the overall experiment. This must match  
            the experiment name in SeaBASS records.     
        :type experiment: str  
        :param cruise: The name of the specific cruise where data was collected. 
            This needs to match the name of the cruise in SeaBASS records.   
        :type cruise: str  
        :param location: Indicates where the data is stored. Can be 'blob' or 
            'local'.  
        :type location: str, kwarg  
        :param folder_filepath: If location is set to be 'local', use this 
            input to specify the location of the 'ml' folder that contains the 
            ifcb data on your local machine. Default is 'blob'.  
        :type folder_filepath: str, conditional
        :param container: If location is set to be 'blob', this input indicates 
            which blob container the data is stored in. 
        :type container: str, conditional kwarg
        :param investigator_info: To be used if you want to enter investigator
            information that is not saved in the __init__ file of this library. 
            The dictionary must be of the form: {Firstname_Lastname: [Org, 
            email], Firstname_Lastname: [Org, email], ...}. Include no spaces in 
            the investigator or organization names.   
        :type investigator_info: dict, optional  
        :param stations: Indicates whether the metadata includes station 
            information. Input should be True or False. Default True.  
        :type stations: bool, optional  
        :param flags: Indicates whether the metadata inlcudes flag information. 
            Default False. 
        :type flags: bool, optional
        :param doc_list: A comma-separated string of documents associated with 
            the SeaBASS submission. Should include the names of the protocol, 
            checklist, and taxonomic ID documents at minimum.   
        :type doc_list: str  
        :param data_status: Indicates whether the data is preliminary or final. 
            This input should be either 'preliminary' or 'final'. Not case 
            sensitive.  
        :type data_status: str, kwarg  
        :param trigger_mode: Indicates whether the data was collected on the 
            'chlorophyll', 'scattering', or 'both' setting. Default 'both'.  
        :type trigger_mode: str, kwarg  
        :param notes: A location to insert any additional notes in the notes 
            section of the header.  
        :type notes: str, optional  
        :param sample_filepath: Filepath to the sample-specific metadata file. 
            This is an optional input if you want to use the tool for just one 
            SeaBASS file. 
        :type sample_filepath: str, optional 
        :param filepaths: Filepaths to the sample-specific metadata files. An 
            option if you already have these filepaths and don't want to spend 
            time retrieving them from the blob/folder. 
        :type filepaths: DataFrame
        """
        
        # Initializing class variables
        self.sample_filename = None
        self.sample_df = None 

        if location == 'blob':
            self.blob = True

            blob_location_url_prefix = f'''https://{upt.config_info[
                 'blob_storage_name']}.blob.core.windows.net/{container}/'''

            if sample_filepath is None:
                if filepaths is None:
                    # retrieve sample csv filenames (DataFrame format)
                    self.sample_filenames = list_files_in_blob(
                        container=container, selection='csv')
                else:
                    self.sample_filenames = pd.read_csv(filepaths)
            else:
                # for only creating a single SeaBASS file
                self.sample_filenames = None
                self.sample_filename = sample_filepath
                self.sample_df = pd.read_csv(sample_filepath)
            
        elif location == 'local':
            self.blob = False

            if sample_filepath is None:
                if filepaths is None:
                # retrieving sample csv filenames (list format)
                    local_filenames = retrieve_filepaths_from_local(
                        folder_filepath)
                    self.sample_filenames = pd.DataFrame({'filepath': [x for x 
                                                            in local_filenames 
                                                            if 'csv' in x]})
                else:
                    self.sample_filenames = pd.read_csv(filepaths)
            else:
                # for single SeaBASS file
                self.sample_filenames = None
                self.sample_filename = sample_filepath
                self.sample_df = pd.read_csv(sample_filepath)
        else:
            print("""ACTION REQUIRED: Specify a valid location kwarg ('blob' or 
                  'local').""")
        
        tgz = cruise.replace(" ", "") + '_raw_images_metadata_associated.tgz'
        
        # load input data
        self.metadata_df = pd.read_csv(metadata_filepath)
        self.classification_df = pd.read_csv(class_filepath)

        # boolean indicators of whether to look for station and flag info
        self.stations_bool = stations
        self.flags_bool = flags

        # format document list
        if doc_list is not None and doc_list is not '':
            docs_formatted = doc_list.replace(" ", "")
        else:
            docs_formatted = -9999
        
        # extract investigator information from dictionary format
        if investigator_info is not None:
            [investigators, affiliations, 
             emails] = self.extract_investigator_info(
                 dictionary=investigator_info)
        else:
            [investigators, affiliations, 
             emails] = self.extract_investigator_info() 

        # boolean indicator of notes inclusion + record notes
        if notes is not None and notes != '':
            self.notes_bool = True
            self.notes = notes
        else:
            self.notes_bool = False

        # full dataset-applicable header values (accessible in all class fns)
        self.header_values = {'investigators': investigators,
                              'affiliations': affiliations,
                              'emails': emails,
                              'experiment': experiment,
                              'cruise': cruise,
                              'documents': docs_formatted,
                              'calibration_file': 'no_cal_files',
                              'data_type': 'taxonomy',
                              'data_status': data_status.lower(),
                              'water_depth': 'NA',
                              'pixel_per_um': 2.7488,
                              'blob_location': blob_location_url_prefix,
                              'associated_archives': tgz,
                              'associated_archive_types': 'planktonic',
                              'length_representation_instrument_varname': 
                                'MajorAxisLength',
                              'width_representation_instrument_varname': 
                                'MinorAxisLength',
                              'missing': -9999,
                              'delimiter': 'comma',
                              'ifcb_trigger_mode': trigger_mode
                              }
        
    def preview_seabass(self):
        """ Generates the string of a single SeaBASS file. Uses the first csv in
        the full dataset or the single sample csv depending on how the class was
        initialized.

        Returns:
        :param sb_string: A SeaBASS-formatted string with all header information 
            and image data. 
        :type sb_string: str
        """
        if self.sample_df is None: 
            # using the first csv in the container
            if self.blob is True:
                # extract name from df
                sfn = self.sample_filenames['filepath'][0]
            else:
                # call first item in a list
                sfn = self.sample_filenames[0]
            
            # build full filepath name
            sample_df_filepath = f"{self.header_values['blob_location']}{sfn}"
            # load csv
            sample_df = pd.read_csv(sample_df_filepath)
        else:
            # using the specified sample csv
            sample_df_filepath = self.sample_filename
            sample_df = self.sample_df
        
        # build SeaBASS file
        sb_string = self.write_seabass(sample_df_filepath, sample_df)
        
        return sb_string
    
    def make_seabass_files(self):
        """Loops over all samples in the dataset, generating a SeaBASS file for 
        each sample and saving it to a local folder called:
        {cruise}_SeaBASS_{data_status}.
        """
        folder_name = f'''{self.header_values['cruise'].replace(' ', '')
                           }_SeaBASS_{self.header_values['data_status']}'''
        os.makedirs(folder_name, exist_ok=True)

        for fp in self.sample_filenames:
            sample_df_filepath = f"{self.header_values['blob_location']}{fp}"
            sample_df = pd.read_csv(sample_df_filepath)

            sb_string = self.write_seabass(sample_df_filepath, sample_df)

            with open(f'{folder_name}/{self.filename}', "w") as file:
                file.write(sb_string)
                
        print("INFO: SeaBASS file generation complete!")
    
    def compile_header(self, sample_filename, sample_df):
        """ Calls the values stored in the header_values dictionary, generates 
        some additional sample-specific values, and puts them in the header 
        format required for SeaBASS files. 

        :param sample_filename: Filename of the sample metadata csv.
        :type sample_filename: str
        :param sample_df: Dataframe that stores sample- and image-specific 
            data. 
        :type sample_df: DataFrame

        Returns:
        :param header: The header section of the SeaBASS file. 
        :type header: str
        """
        # choose trigger condition note based on indicated mode
        if self.header_values['ifcb_trigger_mode'] == 'both':
            trigger_note = '''chlorophyll fluorescence (PMTB) OR side scattering  
! (PMTA)  '''
        elif self.header_values['ifcb_trigger_mode'] == 'chlorophyll':
            trigger_note = 'chlorophyll fluorescence (PMTB)'
        elif self.header_values['ifcb_trigger_mode'] == 'scattering':
            trigger_note = 'side scattering (PMTA)'
        else: 
            print("ACTION REQUIRED: Enter a valid trigger mode kwarg.")

        # add additional notes to header
        if self.notes_bool is True:
            additional_notes = self.notes
        else: 
            additional_notes = 'None'

        # retrieve metadata from csv accompanying the images in the sample 
        # folder and from the filename
        sample_ID = self.extract_sample_ID(sample_filename)
        date, time, ifcb_number = self.extract_sample_info(
            sample_filename)

        [data_type, latitude, longitude, temperature, salinity, depth, 
         volume_sampled, volume_imaged, flag, station, 
         concentration] = self.extract_metadata_for_header(sample_ID, sample_df,
                                                           self.stations_bool,
                                                           self.flags_bool)
        
        # build filename based on header values
        self.filename = f"""{self.header_values['experiment'].replace(" ", "")
                             }-{self.header_values['cruise'].replace(" ", "")}_{
                                 data_type}_IFCB_plankton_and_particles_{date
                                                                         }.sb"""

        # construct header
        header = f"""/begin_header
/ 
/investigators={self.header_values['investigators']}\n
/affiliations={self.header_values['affiliations']}\n
/contact={self.header_values['emails']}\n
/experiment={self.header_values['experiment'].replace(" ", "")}\n
/cruise={self.header_values['cruise'].replace(" ", "")}\n
/station={station}\n
/data_file_name={self.filename}\n
/documents={self.header_values['documents']}\n
/calibration_files={self.header_values['calibration_file']}\n
/eventID={sample_ID}\n
/data_type={self.header_values['data_type']}\n
/instrument_model=Imaging_FlowCytobot_IFCB{ifcb_number}\n
/instrument_manufacturer=McLane_Research_Laboratories_Inc\n
/data_status={self.header_values['data_status']}\n
/start_date={date}\n
/end_date={date}\n
/start_time={time}\n
/end_time={time}\n
/north_latitude={latitude}[DEG]\n
/south_latitude={latitude}[DEG]\n
/east_longitude={longitude}[DEG]\n
/west_longitude={longitude}[DEG]\n
/wt={temperature}\n
/sal={salinity}\n
/water_depth={self.header_values['water_depth']}\n
/measurement_depth={depth}\n
/volume_sampled_ml={volume_sampled}\n
/volume_imaged_ml={volume_imaged}\n
/pixel_per_um={self.header_values['pixel_per_um']}\n
/associatedMedia_source={self.header_values['blob_location']}\n
/associated_archives={self.header_values['associated_archives']}\n
/associated_archive_types={self.header_values['associated_archive_types']}\n
/length_representation_instrument_varname={self.header_values[
    'length_representation_instrument_varname']}\n
/width_representation_instrument_varname={self.header_values[
    'width_representation_instrument_varname']}\n
/missing={self.header_values['missing']}\n
/delimiter={self.header_values['delimiter']}\n 
!\n
! {self.header_values['experiment']} cruise {
    self.header_values['cruise']}\n
!\n
! IFCB trigger mode: {trigger_note}\n
! Concentration: {concentration}\n
!\n
! Flags defined in the protocol document.\n
! Flag: {flag}\n
!\n
! The associated_archives file points to the sample folder, which contains a\n 
! metadata .csv and all plankton .png images in the sample.\n
!\n
! Stored in blob: {self.blob}\n
!\n
! If the 'stored in blob' variable above is true, access each image directly\n
! from the associatedMedia string by copy and pasting it into your browser.\n
!\n
! ifcbUTOPIA organization: https://github.com/ifcb-utopia\n
! Data pipeline and analysis resources in data-pipeline repository.\n
!\n
! Files include ML automated taxonomic classification. Any image categorized\n
! as 'Inoperative' is either non-living or unclassifiable due to technical\n    
! issues, so these images have no associated AphiaID.\n
!\n
! Additional notes: {additional_notes}\n
!\n
/fields=associatedMedia,data_provider_category_automated,scientificName_automated,scientificNameID_automated,prediction_score_automated_category,biovolume,area_cross_section,length_representation,width_representation,equivalent_spherical_diameter\n
/units=none,none,none,none,none,um^3,um^2,um,um,um\n
/end_header"""

        return header

    def extract_sample_ID(self, filepath):
        """ Extracts the sample ID (of the form DYYYYMMDDTHHMMSS_IFCB###). This 
        is the name of the sample folder and is included in the filenames of all 
        sample images. 

        :param filepath: Filepath to the sample folder or to the sample csv.
        :type filepath: str

        Returns:
        :param sampleID: Includes a string of letters and numbers representing 
            the date and time of when the sample was taken and the IFCB 
            instrument number of the IFCB used to collect the sample. A unique 
            sample identifier. 
        :type sampleID: str
        """
        # split the filename at the _ml to retrieve the sample ID
        sampleID = filepath.split('_ml')[0].split('/')[-1]

        return sampleID
    
    def extract_sample_info(self, filepath):
        """ Uses the filepath to extract information about the sample.

        :param filepath: Filepath to the sample folder or to the sample csv.
        :type filepath: str

        Returns:
        :param date: Date in the form YYYYMMDD.
        :type date: str
        :param time: Time of collection in the 24-hr form HH:MM:SS[TimeZone].
        :type time: str
        :param ifcb_number: IFCB number, assumes the instrument number has three
        digits. If it has more or less, adjust the n_digits value in the code.
        :type ifcb_number: str
        """
        # account for strange / behavior in filepaths
        if '/' in filepath:
            filepath = filepath.split('/')[-1]
        elif '\\' in filepath:
            filepath = filepath.split('\\')[-1]
        
        # split the filepath at the D and T 
        apres_D = filepath.split('D')[1]
        apres_T = filepath.split('T')[1]
        
        # Take the 8 characters after D to retrieve the date (YYYYMMDD)
        # and the 6 characters after the T for the time (HH:MM:SS[TimeZone])
        date = ''.join(list(apres_D)[0:8])
        hr = ''.join(list(apres_T)[0:2])
        mins = ''.join(list(apres_T)[2:4])
        sec = ''.join(list(apres_T)[4:6])
        time =  f'{hr}:{mins}:{sec}[GMT]'

        # Number of digits indicates how many digits are expected in the IFCB
        # number. Assumed to be 3. The filepaths splits at IFCB to retrieve 
        # the instrument number.
        n_digits = 3
        ifcb_number = ''.join(list(filepath.split('IFCB')[-1])[0:n_digits])

        return date, time, ifcb_number
    
    def extract_investigator_info(self, dictionary=upt.default_investigators):
        """ Retrieves investigator information from the dictionary in the 
        __init__.py file of utopia_pipeline_tools (or optionally from a manually
        specified dictionary). 

        :param dictionary: Dictionary containing investigator names, 
        affiliations, and emails.
        :type dictionary: dict, optional

        Returns:
        :param investigators: Comma-separated string of investigators with no 
        spaces.
        :type investigators: str
        :param affiliations_list: Comma-separated string of affiliated 
        organizations with no spaces.
        :type affiliations_list: str
        :param emails_list: Comma-separated string of the investigators' emails 
        with no spaces.
        :type emails_list: str
        """
        # convert the investigator names into a comma-separated string with no
        # spaces
        investigators = ",".join(list(dictionary.keys())).replace(" ", "_")
        affiliations_list = []
        emails_list = []
        for n in np.arange(len(dictionary.values())):
            # loop over all affiliations and emails in the dictionary and
            # convert to lists
            affiliations_list.append(list(
                dictionary.values())[n][0])
            emails_list.append(list(
                dictionary.values())[n][1])
        
        # convert lists to comma-separated strings with no spaces
        affiliations = ",".join(affiliations_list).replace(" ", "_")
        emails = ",".join(emails_list).replace(" ", "_")
        
        return investigators, affiliations, emails
    
    def extract_metadata_for_header(self, sample_ID, sample_df, 
                                    stations_bool, flags_bool):
        """ Extracts sample-specific metadata values from the sample csv and 
        the general metadata csv. 

        :param sample_ID: Unique identifier of a sample.
        :type sample_ID: str
        :param sample_df: The sample dataframe saved in the sample folder within
            the ml folder. Should contain 'Latitude', 'Longitude', 
            'Temperature', 'Salinity', 'Depth', and 'Concentration' columns.
        :type sample_df: DataFrame
        :param stations_bool: Indicates whether or not to look for a 'Station'
            column in the metadata file.
        :type stations_bool: bool
        :param flags_bool: Indicates whether or not to look for a 'Flag' column
            in the metadata file. 

        Returns:
        :param data_type: Describes how the data was taken, i.e. in-line.
        :type data_type: str
        :param lat: Latitude reading at time of sample. 
        :type lat: float
        :param long: Longitude reading at time of sample.
        :type long: float
        :param temp: Temperature measurement.
        :type temp: float
        :param salinity: Salinity measurement.
        :type salinity: float
        :param depth: Depth at which the sample was retrieved.
        :type depth: float
        :param vol_sampled: Volume of the sample taken (mL).
        :type vol_sampled: float/int
        :param vol_imaged: Volume of water from the sample imaged (mL)
        :type vol_imaged: float
        :param flag: Integer representing a flag describing the sample 
            conditions. For instance, if there was bad alignment on the IFCB for
            a specific sample, that would be flagged.
        :type flag: int
        :param station: Indicates which station the sample was taken at. 
        :type station: int/str
        :param concentration: The sample's concentration. 
        :type concentration: float/int
        """
        # find the 'bin' column in the metadata file then use that to isolate
        # the specific row in the table associated with the sample ID
        bin_col_name = [x for x in self.metadata_df.columns if 'bin' in 
                        x.lower()][0]
        row = self.metadata_df[self.metadata_df[bin_col_name]
                            ==sample_ID]
        
        # get data type from the sample df
        try:
            data_type = row['sample_type'][0]  # is this the right col name? 
        except:
            data_type = 'in-line'

        # get latitude and longitude from sample df or metadata df
        try:
            lat = sample_df['Latitude'][0]
            long = sample_df['Longitude'][0]
            
            if lat == np.nan or lat == None:
                try:
                    lat_col_name = [x for x in self.metadata_df.columns if 
                                    'lat' in x.lower()][0]
                    long_col_name = [x for x in self.metadata_df.columns if 
                                    'lon' in x.lower()][0]
                    lat = row[lat_col_name][0]
                    long = row[long_col_name][0]
                except:
                    lat = -9999
                    long = -9999
                    print("INFO: No coordinate information found.")
        except:
            try:
                lat_col_name = [x for x in self.metadata_df.columns if 'lat'
                                in x.lower()][0]
                long_col_name = [x for x in self.metadata_df.columns if 
                                 'lon' in x.lower()][0]
                lat = row[lat_col_name][0]
                long = row[long_col_name][0]
            except:
                lat = -9999
                long = -9999
                print("INFO: No coordinate information found.")
        
        # get temperature, salinity, and depth from the sample df
        try:
            temp = sample_df['Temperature'][0]
        except:
            temp = -9999
            print("INFO: No temperature information found.")
        try:
            salinity = sample_df['Salinity'][0]
        except:
            salinity = -9999
            print("INFO: No salinity information found.")
        try:
            depth = sample_df['Depth'][0]
        except:
            depth = -9999
            print("INFO: No depth information found.")
        
        # get volumes from the metadata df
        try:
            vol_sampled = row['VolumeSampleRequested'][0] 
        except:
            # default IFCB sample size is 5 mL
            vol_sampled = 5
            print("""INFO: No 'volume sampled' information found. Using default 
                  value (5mL).""")
        try:
            vol_imaged = row['VolumeSampled'][0] 
        except:
            vol_imaged = -9999
            print("INFO: No 'volume imaged' information found.")

        # look for flag and station information if applicable
        try: 
            if flags_bool is True:
                flag_col_name = [x for x in self.metadata_df.columns if 'flag' 
                                 in x.lower()][0]
                flag = row[flag_col_name][0]
            else:
                flag = -9999
        except:
            flag = -9999
            print("INFO: No flag information available.")

        if stations_bool is True:
            try:
                station_col_name = [x for x in self.metadata_df.columns if 
                                    'station' in x.lower()][0]
                station = row[station_col_name][0]
            except:
                station = 'NA'
        else:
            station = 'NA'
        
        # get concentration from the sample df or the metadata df
        try: 
            concentration = sample_df['Concentration'][0]

            if concentration == np.nan or concentration == None:
                concentration_col_name = [x for x in self.metadata_df.columns if 
                                          'concentration' in x.lower()][0]
                concentration = row[concentration_col_name][0]
        except:
            concentration = -9999
        
        # assemble the values
        values = [data_type, lat, long, temp, salinity, depth, vol_sampled, 
                  vol_imaged, flag, station, concentration]
        # if any are NaN or None, replace with -9999 for missing
        for x in values:
            if x == np.nan or x == None:
                x = -9999

        return values
    
    def run_sample_checks(self, sample_filename, sample_df):
        """ Checks that the number of rows in the associated data files are as 
        expected. The sample and classification dataframes should have the same
        number of rows pertaining to the given sampleID, and the metadata 
        dataframe should only have 1. 

        :param sample_filename: Name of the sample csv file.
        :type sample_filename: str
        :param sample_df: The sample-specific dataframe stored in the sample 
            folder.
        :type sample_df: DataFrame
        """ 
        # retrieve sample ID
        sample_ID = self.extract_sample_ID(sample_filename)
        # check length of sample df
        n_rows_sample_df = len(sample_df.index)
        # check how many rows in the metadata df are associated with the 
        # specific sample
        bin_col_name = [x for x in self.metadata_df.columns if 'bin' in 
                        x.lower()][0]
        n_rows_metadata_df = len([x for x in self.metadata_df[bin_col_name] if 
                                  sample_ID in x])
        # check how many rows in the classification df are associates with the 
        # specific sample
        n_rows_class_df = len([x for x in self.classification_df['filepath'] if 
                               sample_ID in x])
        
        # compare and print results
        if n_rows_class_df == n_rows_sample_df:
            print(f"""Sample and classification dataframes have 
                  {n_rows_class_df} rows, and the metadata dataframe has 
                  {n_rows_metadata_df}.""")
            print("INFO: Sample checks complete with no errors!")
        else:
            print(f"""Sample dataframe: {n_rows_sample_df} rows,  
                  Classification dataframe: {n_rows_class_df} rows,  
                  Metadata dataframe: {n_rows_metadata_df} rows.""")
            print("WARNING: Number of images not consistent across input files."
                  )
    
    def structure_data(self, sample_filename, sample_df):
        """ Compiles and converts sample metadata into the SeaBASS format. Each
        line of data represents a single image, with comma-separated attributes
        indicated by the /fields value in the header. 

        :param sample_filename: Name of the sample csv file.
        :type sample_filename: str
        :param sample_df: The sample-specific dataframe stored in the sample 
            folder.
        :type sample_df: DataFrame

        Returns:
        :param data_string: The string of SeaBASS-formatted data extracted from 
            the sample_df.
        :type data_string: str
        """
        # take the subset of the classification df that is associated with the 
        # specific sample
        sample_ID = self.extract_sample_ID(sample_filename)
        class_subset = self.classification_df[self.classification_df[
            'filepath'].str.contains(sample_ID)]
        
        # initialize data string
        data_string = ''

        # loop over all rows in the classification df subset
        for x in np.arange(len(sample_df.index)):
            # retrieve predicted label (int)
            key = class_subset['pred_label'][x]
            # build the full blob storage filepath
            filepath = f'''{self.header_values['blob_location']}{
                class_subset['filepath'][x]}'''
            # find the names associated with the key value
            category = upt.label_list[key]  # our label names
            sci_name = upt.aphiaID_dict[key][0]  # scientific name from WoRMS
            nameID = f'''urn:lsid:marinespecies.org:taxname:{
                upt.aphiaID_dict[key][1]}'''  # taxonomic identifier
            pred_score = class_subset[str(key)][x]  # probability score

            # size information (calibrated from pixels to um)
            biovolume = sample_df['Biovolume'][x]/(
                upt.calibration_ratio**3)
            area = sample_df['Area'][x]/(upt.calibration_ratio**2)
            length = sample_df['MajorAxisLength'][x]/upt.calibration_ratio
            width = sample_df['MinorAxisLength'][x]/upt.calibration_ratio
            esd = sample_df['EquivalentDiameter'][x]/upt.calibration_ratio

            # compiling the line
            line = f"{filepath},{category},{sci_name},{nameID},{pred_score},{biovolume},{area},{length},{width},{esd}\n"
            line = line.replace('nan,0.0,0.0,0.0,0.0', '-9999,-9999,-9999,-9999,-9999')


            data_string = data_string + line
            
        return data_string

    def write_seabass(self, sample_filename, sample_df):
        """ Combines the header and data into a single, correctly formatted 
        string.

        :param sample_filename: Name of the sample csv file.
        :type sample_filename: str
        :param sample_df: The sample-specific dataframe stored in the sample 
            folder.
        :type sample_df: DataFrame

        Returns:
        :param full_string: The full SeaBASS-structured string containing header
            and formatted data sections. 
        :type full_string: str
        """
        # add together the header and data string
        full_string = self.compile_header(sample_filename, sample_df
                                          ) + '\n' + self.structure_data(
                                              sample_filename, sample_df)

        return full_string