import numpy as np
import pandas as pd

class Soil(object):
    """
    A class to represent a soil sample with its characteristics.

    Attributes
    ----------
    temperature : array-like
        Soil bulk temperature [K]
    water : array-like
        Soil volumetric water content [m**3/m**3]
    salinity : array-like
        Soil salinity (NaCl) of the bulk pore fluid [mol/L]
    sand : array-like
        Soil sand content [g/g]*100
    silt : array-like
        Soil silt content [g/g]*100
    clay : array-like
        Soil clay content [g/g]*100
    porosity : array-like
        Soil porosity [m**3/m**3]
    bulk_density : array-like 
        Soil bulk density [kg/m**3]
    particle_density : array-like
        Soil particle density [kg/m**3]
    CEC : array-like
        Soil cation exchange capacity [meq/100g]
    orgm : array-like
        Soil organic matter [g/g]*100
    bulk_perm : array-like
        Soil bulk real relative dielectric permittivity [-]
    bulk_perm_inf : array-like
        Soil bulk real relative permittivity at infinite frequency [-]
    water_perm : array-like
        Soil water phase real dielectric permittivity [-]
    solid_perm : array-like
        Soil solid real relative dielectric permittivity phase [-]
    air_perm : array-like
        Soil air real relative dielectric permittivity phase [-]
    offset_perm : array-like
        Soil bulk real relative dielectric permittivity when soil bulk real electrical conductivity is zero [-]
    bulk_ec : array-like
        Soil bulk real electrical conductivity [S/m]
    bulk_ec_tc : array-like
        Soil bulk real electrical conductivity temperature corrected (298.15 K) [S/m]
    bulk_ec_dc : array-like
        Soil bulk real electrical conductivity direct current [S/m]
    bulk_ec_dc_tc : array-like
        Soil bulk real electrical conductivity direct current (0 Hz) temperature corrected (298.15 K) [S/m]
    water_ec : array-like
        Soil water real electrical conductivity [S/m]
    s_ec : array-like
        Soil bulk real surface electrical conductivity [S/m]
    solid_ec : array-like
        Soil solid real electrical conductivity [S/m]
    dry_ec : array-like
        Soil bulk real electrical conductivity at zero water content [S/m]
    sat_ec : array-like 
        Soil bulk real electrical conductivity at saturation water content [S/m]
    frequency_perm : array-like
        Frequency of dielectric permittivity measurement [Hz]
    frequency_ec : array-like
        Frequency of electric conductivity measurement [Hz]
    L : single-value
        Soil scalar depolarization factor of solid particles (effective medium theory) [-]
    Lw : single-value 
        Soil scalar depolarization factor of water aggregates (effective medium theory) [-]
    m : single-value
        Soil cementation factor as defined in Archie law [-]
    n : single-value
        Soil saturation factor as defined in Archie second law [-]
    alpha : single-value
        Soil alpha exponent as defined in volumetric mixing theory [-]
    texture : str
        Soil texture according to USDA convention: "Sand", "Loamy sand", "Sandy loam", "Loam", "Silt loam", "Silt", "Sandy clay loam", "Clay loam", "Silty clay loam", "Sandy clay", "Clay", "Silty clay"
    instrument : str
        Instrument utilized: 'HydraProbe', 'TDR', 'GPR', 'Miller 400D', 'Dualem'
    info : DataFrame
        Data Frame containing descriptive information about how each array-like attribute was determined or modified.
    df : DataFrame
        Data Frame containing the quantitative information of all soil array-like attributes for each state.
    E : single-value
        Empirical constant as in Rohades model [-]
    F : single-value
        Empirical constant as in Rohades model [-]
    roundn : int
        Number of decimal places to round results.
    range_ratio : single-value
        Factor for extending extrapolation domain during fitting modelling
    n_states : int
        Number of soil states

    Notes
    -----
    Attributes provided by the user that do not match the expected types or values 
    will raise a ValueError.
    """

    def __init__(self, **kwargs):
        # Define acceptable types for each argument
        array_like_types = [float, np.float64, int, list, np.ndarray]
        single_value = [float, np.float64, int]
        attributes = {
                'temperature': array_like_types,
                'water': array_like_types,
                'salinity': array_like_types,
                'sand': array_like_types,
                'silt': array_like_types,
                'clay': array_like_types,
                'porosity': array_like_types,
                'bulk_density': array_like_types,
                'particle_density': array_like_types,
                'CEC': array_like_types,
                'orgm': array_like_types,
                'bulk_perm': array_like_types,
                'bulk_perm_inf': array_like_types,
                'air_perm': array_like_types,
                'water_perm': array_like_types,
                'solid_perm': array_like_types,
                'offset_perm': array_like_types,
                'bulk_ec': array_like_types,
                'bulk_ec_tc': array_like_types,
                'bulk_ec_dc': array_like_types,
                'bulk_ec_dc_tc': array_like_types,
                'water_ec': array_like_types,
                'solid_ec': array_like_types,
                'dry_ec': array_like_types,
                'sat_ec': array_like_types,
                's_ec': array_like_types,
                'frequency_perm': array_like_types,
                'frequency_ec': array_like_types,
                'L': single_value,
                'Lw': single_value,
                'm': single_value,
                'n': single_value,
                'alpha': single_value,
                'texture': [str],
                'instrument': [str],
                'range_ratio': single_value,
                'n_states': single_value,
                'E': single_value,
                'F': single_value,
                'roundn': [int]
                }

        accepted_values = {
            'texture': ["Sand", "Loamy sand", "Sandy loam", "Loam", "Silt loam", "Silt", "Sandy clay loam", "Clay loam", "Sandy clay", "Clay", "Silty clay", np.nan],
            'instrument': ["TDR", "GPR", 'HydraProbe', 'EMI Dualem', 'EMI EM38-DD', np.nan]
        }

        # Convert all inputs to np.ndarray if they are of type list, int, or float
        def to_ndarray(arg, key=None):
            if key in ['texture', 'instrument']:
                return arg  # return the argument if it is 'texture' or 'instrument'
            if isinstance(arg, (list, int, np.float64, float)):
                return np.array([arg]) if isinstance(arg, (int, np.float64, float)) else np.array(arg)
            return arg

        # Check each input argument
        for key in attributes:

            if key in kwargs:
                value = kwargs[key]

                if type(value) in attributes[key]:
                    # if the key is 'texture' or 'instrument' verify if value is in the accepted_values
                    if key in ['texture', 'instrument'] and value not in accepted_values[key]:
                        raise ValueError(f"Invalid value for '{key}'. Must be one of {accepted_values[key]}")
                    setattr(self, key, to_ndarray(value, key=key))
                else:
                    raise ValueError(f"'{key}' must be one of {attributes[key]}")
                
            else:
                # If the key is not provided in the kwargs, set it as np.nan.
                setattr(self, key, to_ndarray(np.nan, key=key))
            
        self.roundn = 3 if np.isnan(self.roundn[0]) else self.roundn
        self.range_ratio = 2 if np.isnan(self.range_ratio[0]) else self.range_ratio
        
        ### Fill the state variables with nans when are shorter than n_states
        array_like_attributes = ['temperature', 'water', 'salinity', 'sand', 'silt', 'clay', 'porosity', 'bulk_density', 'particle_density', 'CEC',
                            'orgm', 'bulk_perm', 'bulk_perm_inf', 'air_perm', 'water_perm', 'solid_perm', 'offset_perm', 
                            'bulk_ec', 'bulk_ec_tc', 'bulk_ec_dc', 'bulk_ec_dc_tc', 'water_ec', 'solid_ec', 'dry_ec', 'sat_ec', 's_ec', 'frequency_perm', 'frequency_ec']

        # calculate the max length of the input arrays
        n_states = max([len(getattr(self, attr)) for attr in array_like_attributes])
        self.n_states = n_states                            # Number of states of the soil

        # Now loop over each attribute in the list
        for attribute in array_like_attributes:
            attr = getattr(self, attribute)
            
            if len(attr) != n_states:
                setattr(self, attribute, np.append(attr, [np.nan]*(n_states - len(attr))))        

            if ~np.isnan(attr[0]) and (np.isnan(attr[1:(n_states)])).all():
                setattr(self, attribute, np.append(attr[0], [attr[0]]*(n_states - 1)))     

        ### Defining special attributes ### 
        self.df = pd.DataFrame({attr: getattr(self, attr) for attr in array_like_attributes})

        # defining soil.info
        self.info = self.df.where(pd.notna(self.df), 'nan')
        self.info = self.info.where(pd.isna(self.df), 'Value given by the user')
        
    # Simplify the getter methods using __getattr__
    def __getattr__(self, name):
        """
        Custom attribute access mechanism.

        Parameters
        ----------
        name : str
            Name of the attribute to be accessed.

        Returns
        -------
        np.ndarray
            The value of the attribute.

        Raises
        ------
        AttributeError
            If the attribute does not exist.
        """
        if name in self.__dict__:
            return self.__dict__[name]
        else:
            raise AttributeError(f"No such attribute: {name}")        
        
    def __str__(self):                                                   
        """
        Return a string representation of the class.

        Returns
        -------
        str
            String representation of the class as Soil.df
        """
        return str(self.df)