"""
models.py — Modello di Mercato e Simulazione degli Arrivi

Questo modulo implementa tutta la logica di simulazione del comportamento dei clienti
nel punto vendita. Definisce come i clienti scelgono i prodotti e come si comportano in caso di stock-out.

Struttura:

  MNLChoiceModel            : Modello Multinomial Logit per un singolo segmento di clienti.
  LatentChoiceModel         : Combina N segmenti MNL con pesi specifici (Latent Class model).
  MarketSimulation          : Prepara le feature dei prodotti e calcola probabilità / matrice di sostituzione.
  [sottoclassi]
     QualityStressTest      : Stress test filtrando per qualità.
     PriceStressTest        : Stress test filtrando per prezzo.
     MarcaDropStressTest    : Stress test rimuovendo una o più marche.
     TipoDropStressTest     : Stress test rimuovendo uno o più tipi di prodotto.
     ScaffaleDropStressTest : Stress test rimuovendo una o più posizioni di scaffale.
  ClientArrivals            : Genera arrivi Poisson, scenari di domanda e simula le vendite con sostituzione.

Come utilizzare:

  1. Caricare il catalogo prodotti (DataFrame con Marca, Prodotto, Scaffale, Qualità, Prezzo)
  2. Istanziare MarketSimulation - chiamare sim_prod_prepare() - ottiene la matrice feature X
  3. Istanziare LatentChoiceModel con use_model() - si ottengono le probabilità di scelta P
  4. Calcolare la matrice di sostituzione con substitution_Matrix()
  5. Istanziare ClientArrivals - generare scenari di domanda con reduced_scenarios()

"""

import numpy as np
import pandas as pd
from sklearn.cluster import KMeans

class MNLChoiceModel: 
  """
  classe che definisce le probabilità per il singolo MNL model choice
  """
  def __init__(self,params):
    self.params=np.array(params) #array con coefficienti beta del modello
  def utilities(self,X_list): #X_list è un array di 'liste' (per ogni prodotto ho una riga = vettore/lista), che deve avere stesse dimensioni di params
    """
    metodo che definisce le utilità per ogni prodotto
    aspetta come input un numpy array e restituisce un numpy array
    """
    X=np.asarray(X_list) #rendiamo X elemento np
    if X.shape[-1] !=len(self.params): #controlliamo le dimensioni
        raise ValueError(f"Controlla le dimensioni: X ha {X.shape[-1]} feature, ma params è {len(self.params)}")
    
    util=np.dot(X,self.params)
    if X[-1,:].any():
       util[-1]=-2.5  #tolgo importanza alla no_buy optio, in questo modo diciamo al modello che, se un cliente ha scelto di entrare è perchè è convinto di comprare   
    return util
  
  def probabilities(self,X,available_prod=None):
    """
    metodo che definisce le probabilità per ogni prodotto
    aspetta come input un numpy array e restituisce un numpy array applicando la funzione softmax
    considera anche la possibilità di usare un filtro booleano per gestire i prodotti non in catalogo
    """
    u=self.utilities(X) #usiamo il metodo utilità per ottenere le u
    if available_prod is not None: #se ci sono dei prodotti che mancano, a questi andiamo a mettere un'utilità irrisoria, così che non influenzino le probabilità
      u=np.where(available_prod,u,-1e200) 
    
    expU=np.exp(u-np.max(u)) #formula MNL
    return expU/expU.sum()
  

class LatentChoiceModel: 
    """
    classe per definire le probabilità combinate di più MNL, usando un latent model choice MNL
    """
    def __init__(self,segments,segment_prob):
        self.segments=segments
        self.segment_prob=np.array(segment_prob)

    def segment_pChoices(self,X,available_prod=None):
        """
        metodo che definisce le probabilità per ogni prodotto per ogni segmento
        aspetta come input un numpy array e restituisce un numpy array
        considera anche la possibilità di usare un filtro booleano per gestire i prodotti non in catalogo
        """
        if available_prod is not None and len(available_prod)!=X.shape[0]:
          available_prod.append(1)
        pChoices=np.array([seg.probabilities(X,available_prod) for seg in self.segments]) #costruiamo le P sui prodotti per ogni segmento
        return pChoices
    
    def weighted_Probabilities(self,X,available_prod=None):
        """
        metodo che definisce le probabilità congiunta per ogni prodotto
        aspetta come input un numpy array e restituisce un numpy array applicado una media pesata dei segmenti
        considera anche la possibilità di usare un filtro booleano per gestire i prodotti non in catalogo
        """
        seg_choice=self.segment_pChoices(X,available_prod)
        if len(seg_choice)!=len(self.segment_prob): #controlliamo le dimensioni
          raise ValueError(f"Controlla dimensioni: abbiamo {len(seg_choice)} segmenti, ma {len(self.segment_prob)} segmenti di probabilità")
        if self.segment_prob.sum()!=1: #controlliamo che le P date in input sommino ad 1
          raise ValueError(f"La somma delle probabilità dei segmenti somma a {self.segment_prob.sum()} e non a 1")
        return np.dot(self.segment_prob,seg_choice) #media pesata per ogni prodotto
        

#Andiamo a costruire il nostro esempio di segmenti
#params=[α (intercetta = pasta,Marca_A,Scaffale_Alto), ν_1 (quality 0-6.8escluso), ν_2 (quality 6.8-10), θ (price), β_riso, β_gnocchi, γ_B, γ_C, γ_D, δ_occhi, δ_basso]
#classe 'madre': classe generale legata ai test sulla probabilità, che poi si dirama negli stress test su qualità e drop prodotti
class MarketSimulation:
    """
    la classe definisce la simulazione del mercato
    come input riceve il dataset con le caratteristiche dei prodotti, i parametri del modello MNL (imponiamo un valore di default), l'ordine con cui vengono dummizzate le varibile categoriche (imponiamo un valore di defualt) 
    e il vettore con le probabilità dei segmenti (imponiamo valore di defualt)
    restituisce come output le probabilità di scelta per ogni prodotto e per ogni segmento
    """
    def __init__(self,df_prod,params=None,ordini=None,seg_probs=None):
        self.df_prod=df_prod.copy()
        #segmenti = [loyal_A, loyal_B, loyal_C, loyal_D, price_sensitive, quality_sensitive]
        self.params=params or [np.array([3.6,-0.1,0.2,-1,-0.4,-0.4,-6,-6,-5,0.6,-0.5]),
                       np.array([-2.4,-0.1,0.2,-1,-0.4,-0.4,6,-1,0,0.6,-0.5]),
                       np.array([-2.4,-0.1,0.2,-1,-0.4,-0.4,-1,6,0,0.6,-0.5]),
                       np.array([-3.4,-0.1,0.2,-1,-0.4,-0.4,0,0,7,0.6,-0.5]),
                       np.array([-0.9,-0.3,0.2,-1.3,-0.5,-0.7,0.6,-0.5,0,1,-0.9]),
                       np.array([-0.5,-1.5,0.6,-1,0.2,0.4,-0.4,0.6,0,0.2,-0.1])] #parametri per ogni segmento

        self.ordini=ordini or {'marche':['A','B','C','D'],'prodotti':['Pasta','Riso','Gnocchi'],'scaffali':['Alto','Occhi','Basso']} #utile per il dummies

        self.seg_probs=seg_probs or [0.06,0.06,0.06,0.06,0.4,0.36] #probabilità che il cliente arrivi da un certo segmento
        
        #self.available_prod=available_prod
        

    def sim_prod_prepare(self,file_market="market_model.txt",write_flag=False):
        """
        Il metodo prepara la matrice su cui facciamo la simulazione
        simula il mercato e restituisce la probabilità di scelta per ogni prodotto e segmento
        """
        df=self.df_prod.copy()

        if df.empty:
          print(f'Non ci sono prodotti da testare')
          return [None, None, None]
        else:
          if 'Marca' in df.columns:
            df_return=df.copy()

            df['Marca']=pd.Categorical(df['Marca'],categories=self.ordini['marche'])
            df['Prodotto']=pd.Categorical(df['Prodotto'],categories=self.ordini['prodotti'])
            df['Scaffale']=pd.Categorical(df['Scaffale'],categories=self.ordini['scaffali'])

            false_rows=pd.DataFrame({'Marca': pd.Categorical(['A','B','C','D'], categories=self.ordini['marche']),'Prodotto': pd.Categorical(['Pasta','Gnocchi','Riso','Pasta'], categories=self.ordini['prodotti']),'Scaffale': pd.Categorical(['Alto', 'Basso', 'Occhi', 'Alto'], categories=self.ordini['scaffali'])}) #aggiungo i prodotti 'fake' per l'encoding
            df=pd.concat([df,false_rows],ignore_index=True) # Tutte le altre colonne (Qualità, Prezzo) saranno NaN

            df=pd.get_dummies(df,columns=['Prodotto','Marca','Scaffale'], drop_first=True) #faccio encoding con get_dummies

            df=df.iloc[:-4] #togliamo prodotti fake dopo l'encoding

          else:
            df_return=pd.from_dummies(df, sep="_",default_category={"Marca":"A", "Prodotto":"Pasta", "Scaffale":"Alto"})


          if 'Intercetta' not in df.columns:
              df.insert(0,'Intercetta',1.0)

          #Controlliamo se c'è o aggiungiamo la riga no_buy
          if not np.any(np.all(df.values==0,axis=1)):
            no_buy_values=np.zeros(df.shape[1])
            df.loc[len(df)]=no_buy_values
          df=df.reset_index(drop=True) #df = dataframe con tutti i prodotti e le variabili dummy + intercetta e no_buy option
          cols_order=['Intercetta','Qualità_bassa', 'Qualità_alta', 'Prezzo €/kg', 'Prodotto_Riso', 'Prodotto_Gnocchi', 'Marca_B', 'Marca_C', 'Marca_D', 'Scaffale_Occhi', 'Scaffale_Basso']
          df=df[cols_order] #messi come i parametri segnati in params

          X_new=df.to_numpy(dtype=np.float64)

          if write_flag:
            with open(file_market,"w",encoding="utf-8") as f_m:
              colonne=["α (intercetta)", "ν_1 (qualità 0-7)", "ν_2 (qualità 7-10)", "θ (prezzo)", "β_riso", "β_gnocchi", "γ_B", "γ_C", "γ_D", "δ_occhi", "δ_basso"]

              indici=["loyal_A", "loyal_B", "loyal_C", "loyal_D", "price_sensitive", "quality_sensitive"]
              df_params=pd.DataFrame(self.params,columns=colonne,index=indici)

              f_m.write(f"\nI parametri del mercato sono: \n{df_params}\n")
              f_m.write(f"\nLe probabilità dei segmenti sono: \n{ {indici[i]: self.seg_probs[i] for i in range(len(self.seg_probs))} }\n")
              f_m.write(f"\nLe caratteristiche dei prodotti sono: \n{df_return}\n")

              f_m.write(f"\nLa simulazione viene fatta su {(X_new.shape[0]-1)/(len(df_return['Scaffale'].unique()))} prodotti unici\n")

              f_m.write(f"\nDummies: \n{df}")      

        return [df_return,X_new] #df_return = è il dataframe con tutti i prodotti e le variabili non dummizzate
        
    def use_model(self, modello_probabilità,modello_probabilità_cumulate) :
        segments=[modello_probabilità(param) for param in self.params]
        sim_model=modello_probabilità_cumulate(segments,self.seg_probs)
        return sim_model 

    def substitution_Matrix(self, matrix, sim_model, available_prod=None):
        """
        Questo metodo calcola la matrice di sostituzione di primo livello considerando un prodotto che va in stockout
        """
        X_new=matrix.copy()
        X_new[-1,0] =-1.0 
        total_options=X_new.shape[0] 
        prod_number=total_options-1 
                        
        latent_model_subs=sim_model
        SubMatrix=np.zeros((total_options,total_options)) 
        if available_prod is None:
          available_prod_now=np.ones(total_options,dtype=bool) #definiamo un vettore booleano di prodotti disponibili
        else: 
          available_prod_now=available_prod     
        for i in range(prod_number):
          if available_prod_now[i]==True:
            available_prod_now[i]=False #per ogni prodotto andiamo a vedere come cambiano le probabilità supponendo la non disponibilità di tale prodotto per modellare la sostituzione
            P_substitution=latent_model_subs.weighted_Probabilities(X_new,available_prod=available_prod_now)
            SubMatrix[i]=P_substitution
            available_prod_now[i]=True  
          else:
            SubMatrix[i]=np.zeros(total_options)
            SubMatrix[i,prod_number]=1.0      
        SubMatrix[prod_number,prod_number]=1.0        
    
        return SubMatrix
    def substitution_Matrix_bound(self,matrix):
        """
        Questo metodo rende sparsa la matrice di sostituzione andando ad annullare tutti quei valori piccoli 
        e ripartendo le probabilità tolte sugli altri prodotti
        """
        view=matrix[:,:-1]
        mask=view<0.01
        tots=np.sum(np.where(mask,view,0),axis=1)
        counts=np.sum(mask,axis=1)                  

        view[mask]=0
        remaining_counts=view.shape[1]-counts

        mask_has_valid=remaining_counts>0

        add=np.divide(tots,remaining_counts,out=np.zeros_like(tots),where=mask_has_valid)
        view+=np.where(~mask&mask_has_valid[:,np.newaxis],add[:,np.newaxis],0)

        matrix[~mask_has_valid,-1]+=tots[~mask_has_valid]

        return matrix       
      

#Sotto-classe relativa ai test: filtriamo sulla qualità
class QualityStressTest(MarketSimulation):
  """
  la classe definisce i test sulla qualità
  si aspetta come input un dataframe con le caratteristiche dei prodotti e il livello di qualità desiderato
  """
  def __init__(self, df_prod, params=None, ordini=None, seg_probs=None, write_flag=False, quality_level=None):
    super().__init__(df_prod, params, ordini, seg_probs)
    self.quality_level = quality_level

  def sim_QualityStressTest(self, MNLChoiceModel, LatentChoiceModel):
    """
    Questo metodo simula il mercato e restituisce le probabilità di scelta per ogni prodotto e per ogni segmento dopo l'applicazione del filtro di qualità.
    Il funzionamento si basa sul livello di qualità: se i prodotti sono di bassa qualità, vengono selezionati tutti i prodotti con una qualità inferiore al livello di qualità, e viceversa.
    Se il livello di qualità è fuori dall'intervallo (0-10), viene restituito un dataframe vuoto.
    Il valore predefinito restituisce l'intero dataframe.
    """
    is_no_buy=(self.df_prod.iloc[:, 1:] == 0).all(axis=1)
    self.df_prod=self.df_prod[~is_no_buy].copy()

    if self.quality_level==None: #ovvero non ho messo filtro qualità
      prodotti_quality=self.df_prod
    elif self.quality_level < 6.8:
      min_quality_real=self.df_prod.loc[self.df_prod["Qualità_bassa"] > 0, "Qualità_bassa"].min()
      if self.quality_level > min_quality_real:
        prodotti_quality=self.df_prod[(self.df_prod["Qualità_bassa"] <= self.quality_level) & (self.df_prod["Qualità_bassa"] != 0)].copy()
      else:
        print(f"Qualità sotto il livello minimo, otteniamo un set vuoto.")
        self.df_prod=pd.DataFrame()
        prodotti_quality=pd.DataFrame()
    else:
      max_quality_real=self.df_prod["Qualità_alta"].max()
      if self.quality_level < max_quality_real:
        prodotti_quality=self.df_prod[self.df_prod["Qualità_alta"] >= self.quality_level].copy()
      else:
        print(f"Qualità sopra il livello massimo, otteniamo un set vuoto.")
        self.df_prod=pd.DataFrame()
        prodotti_quality=pd.DataFrame()

    if not self.df_prod.empty:
      self.df_prod=prodotti_quality.reset_index(drop=True)

    df_info, X=super().sim_prod_prepare() #richiamiamo il metodi della classe madre
    sim_model_test=super().use_model(MNLChoiceModel, LatentChoiceModel)
    P=sim_model_test.weighted_Probabilities(X, available_prod=None) 
    PS=sim_model_test.segment_pChoices(X, available_prod=None) 
    
    return [df_info, pd.DataFrame(P), pd.DataFrame(PS)]

#Sotto-classe relativa ai test: filtriamo sul prezzo
class PriceStressTest(MarketSimulation):
  """
  la classe definisce i test sul prezzo
  si aspetta come input un dataframe con le caratteristiche dei prodotti e il livello di prezzo desiderato
 """
  def __init__(self, df_prod, params=None, ordini=None, seg_probs=None, price_level=0, greater=False):
    super().__init__(df_prod, params, ordini, seg_probs)
    self.price_level = price_level #valore soglia
    self.greater = greater #se è vero ---> vogliamo tenere i prodotti con prezzi maggiori della soglia

  def sim_PriceStressTest(self, MNLChoiceModel, LatentChoiceModel):
    """
    Questo metodo simula il mercato e restituisce le probabilità di scelta per ogni prodotto e per ogni segmento dopo aver applicato il filtro prezzo.
    Il suo funzionamento si basa sul flag "greater": se è impostato su True, vengono selezionati tutti i prodotti con un prezzo superiore al livello di prezzo, e viceversa.
    Se il livello di prezzo è fuori dall'intervallo, viene restituito un dataframe vuoto.
    """
    is_no_buy = (self.df_prod.iloc[:, 1:] == 0).all(axis=1)
    prodotti_reali = self.df_prod[~is_no_buy].copy()

    if self.greater:
        filtro = self.df_prod["Prezzo €/kg"] >= self.price_level
        if self.price_level > prodotti_reali["Prezzo €/kg"].max():
          print(f"Prezzo sopra il livello massimo, otteniamo un set vuoto.")
          self.df_prod=pd.DataFrame()
    else:
        filtro=self.df_prod["Prezzo €/kg"]<=self.price_level
        if self.price_level < prodotti_reali["Prezzo €/kg"].min():
          print(f"Prezzo sotto il livello minimo, otteniamo un set vuoto.")
          self.df_prod=pd.DataFrame()

    if not self.df_prod.empty:
      prodotti_price=self.df_prod[filtro].copy()
      self.df_prod=prodotti_price.reset_index(drop=True)

    df_info, X= super().sim_prod_prepare() #richiamiamo il metodi della classe madre
    sim_model_test=super().use_model(MNLChoiceModel, LatentChoiceModel) 
    P=sim_model_test.weighted_Probabilities(X, available_prod=None) 
    PS=sim_model_test.segment_pChoices(X, available_prod=None) 
    
    return [df_info, pd.DataFrame(P), pd.DataFrame(PS)]

#Sotto-classe relativa ai test: drop delle marche
class MarcaDropStressTest(MarketSimulation):
  """
  classe sul test del drop della marca 
  si aspetta il dataframe con i prodotti e la marca da droppare,o una lista o una stringa, o una lista con più stringhe
  """
  def __init__(self, df_prod, params=None, ordini=None, seg_probs=None, marca_drop=None):
    super().__init__(df_prod, params, ordini, seg_probs)
    self.marca_drop=[marca_drop] if isinstance(marca_drop, str) else marca_drop
    if marca_drop is None:
        self.marca_drop=[] #sovrascriviamo con lista vuota se in input avevamo None

  def _valid_marche(self, df=None): #metodo per controllare le marche da droppare
    """
    metodo che controlla che la marca da dropppare sia valida
    si aspetta come input il dataframe e restituisce la lista di marche che si possono droppare
    """
    target_df = df if df is not None else self.df_prod
    input_marche = self.marca_drop

    if input_marche!=[]:
      marche_valide=[m for m in input_marche if m in target_df['Marca'].unique()] #teniamo solo le marche che esistono effettivamente nel DF
      if len(marche_valide)!=len(input_marche) and len(marche_valide)!=0:
        print(f"Attenzione: alcune marche non esistono nel dataset. Considereremo solo le marche {marche_valide}")
      elif len(marche_valide)!=len(input_marche) and len(marche_valide)==0:
        print(f"Attenzione: non ci sono marche valide da eliminare")
      valide=marche_valide

    else:
        valide=[] #se non viene segnato il nome della marca, allora è come se non si volesse togliere nulla

    return valide

  def sim_MarcaDropStressTest(self, MNLChoiceModel, LatentChoiceModel):
    """
    questo metodo simula il mercato e restituisce le probabilità di scelta per ogni prodotto e per ogni segmento dopo l'applicazione del filtro della marca.
    """
    marche_effettive=self._valid_marche(self.df_prod) #richiamiamo il metodo interno di controllo marche valide
    is_no_buy=(self.df_prod.iloc[:, 1:] == 0).all(axis=1)
    self.df_prod=self.df_prod[~is_no_buy].copy()

    prodotti_drop=self.df_prod[~self.df_prod['Marca'].isin(marche_effettive)].copy() #creiamo un nuovo dataframe in cui eliminiamo tutte le righe che corrispondono ai prodotti della marca da eliminare

    if prodotti_drop.empty:
      self.df_prod=pd.DataFrame()
    else:
      prodotti_drop=prodotti_drop.reset_index(drop=True)
      self.df_prod=prodotti_drop
    
    df_info, X=super().sim_prod_prepare() #richiamiamo il metodi della classe madre
    sim_model_test=super().use_model(MNLChoiceModel, LatentChoiceModel) 
    P=sim_model_test.weighted_Probabilities(X, available_prod=None) 
    PS=sim_model_test.segment_pChoices(X, available_prod=None) 
    
    return [df_info, pd.DataFrame(P), pd.DataFrame(PS)]

#Sotto-classe relativa ai test: drop delle tipologie
class TipoDropStressTest(MarketSimulation):
  """
  classe che definisce il test del drop delle tipologie di prodotto
  si aspetta il dataframe con i prodotti e il tipo di prodotto da droppare,o una lista o una stringa, o una lista con più stringhe
  """
  def __init__(self, df_prod, params=None, ordini=None, seg_probs=None, write_flag=False, type_drop=None):
    super().__init__(df_prod, params, ordini, seg_probs)
    self.type_drop = [type_drop] if isinstance(type_drop, str) else type_drop
    if type_drop is None:
        self.type_drop = [] #sovrascriviamo con lista vuota se in input avevamo None

  def _valid_type(self, df=None): #metodo per controllare le tipologie da droppare
    """
    metodo che controlla che la tiplogia da dropppare sia valida
    si aspetta come input il dataframe e restituisce la lista di tipologie che si possono droppare
    """
    target_df=df if df is not None else self.df_prod
    input_type=self.type_drop

    if input_type!=[]:
      type_valide = [m for m in input_type if m in target_df['Prodotto'].unique()] #teniamo solo le type che esistono effettivamente nel DF
      if len(type_valide)!=len(input_type) and len(type_valide)!=0:
        print(f"Attenzione: alcune tipologie non esistono nel dataset. Considereremo solo le tipologie {type_valide}")
      elif len(type_valide)!=len(input_type) and len(type_valide)==0:
        print(f"Attenzione: non ci sono tipologie valide da eliminare")
      valide=type_valide

    else:
        valide=[] #se non viene segnato il nome della marca, allora è come se non si volesse togliere nulla

    return valide

  def sim_TipoDropStressTest(self, MNLChoiceModel, LatentChoiceModel):
    """
    questo metodo simula il mercato e restituisce le probabilità di scelta per ogni prodotto e per ogni segmento dopo l'applicazione del filtro della tipologia.
    """
    type_effettive=self._valid_type(self.df_prod) #richiamiamo il metodo interno di controllo tipologie valide
    is_no_buy=(self.df_prod.iloc[:, 1:] == 0).all(axis=1)
    self.df_prod=self.df_prod[~is_no_buy].copy()

    prodotti_drop=self.df_prod[~self.df_prod['Prodotto'].isin(type_effettive)].copy() #creiamo un nuovo dataframe in cui eliminiamo tutte le righe che corrispondono ai prodotti del tipo da eliminare

    if prodotti_drop.empty:
      self.df_prod=pd.DataFrame()
    else:
      prodotti_drop=prodotti_drop.reset_index(drop=True)
      self.df_prod=prodotti_drop
    
    df_info, X=super().sim_prod_prepare() #richiamiamo il metodi della classe madre
    sim_model_test=super().use_model(MNLChoiceModel, LatentChoiceModel)
    P=sim_model_test.weighted_Probabilities(X, available_prod=None) 
    PS=sim_model_test.segment_pChoices(X, available_prod=None) 
    
    return [df_info, pd.DataFrame(P), pd.DataFrame(PS)]

#Sotto-classe relativa ai test: drop dello scaffale
class ScaffaleDropStressTest(MarketSimulation):
  """
  classe che definisce il test del drop dello scaffale
  si aspetta il dataframe con i prodotti e lo scaffale da droppare,o una lista o una stringa, o una lista con più stringhe
  """
  def __init__(self, df_prod, params=None, ordini=None, seg_probs=None, write_flag=False, shelf_drop=None):
    super().__init__(df_prod, params, ordini, seg_probs)
    self.shelf_drop = [shelf_drop] if isinstance(shelf_drop, str) else shelf_drop
    if shelf_drop is None:
        self.shelf_drop = [] #sovrascriviamo con lista vuota se in input avevamo None

  def _valid_shelf(self, df=None): #metodo per controllare le tipologie da droppare
    """
    metodo che controlla che lo scaffale da dropppare sia valido
    si aspetta come input il dataframe e restituisce la lista di scaffali che si possono droppare
    """
    target_df = df if df is not None else self.df_prod
    input_shelf=self.shelf_drop

    if input_shelf!=[]:
      shelf_valide=[m for m in input_shelf if m in target_df['Scaffale'].unique()] #teniamo solo gli scaffali che esistono effettivamente nel DF
      if len(shelf_valide)!=len(input_shelf) and len(shelf_valide)!=0:
        print(f"Attenzione: alcune tipologie di scaffale non esistono nel dataset. Considereremo solo le tipologie {shelf_valide}")
      elif len(shelf_valide)!=len(input_shelf) and len(shelf_valide)==0:
        print(f"Attenzione: non ci sono tipologie di scaffale valide da eliminare")
      valide=shelf_valide

    else:
        valide=[] #se non viene segnato il nome della marca, allora è come se non si volesse togliere nulla

    return valide

  def sim_ScaffaleDropStressTest(self, MNLChoiceModel, LatentChoiceModel):
    """
    questo metodo simula il mercato e restituisce le probabilità di scelta per ogni prodotto e per ogni segmento dopo l'applicazione del filtro dello scaffale.
    """
    shelf_effettive = self._valid_shelf(self.df_prod) #richiamiamo il metodo interno di controllo tipologie valide
    is_no_buy = (self.df_prod.iloc[:, 1:] == 0).all(axis=1)
    self.df_prod = self.df_prod[~is_no_buy].copy()

    prodotti_drop = self.df_prod[~self.df_prod['Scaffale'].isin(shelf_effettive)].copy() #creiamo un nuovo dataframe in cui eliminiamo tutte le righe che corrispondono allo scaffale da eliminare

    if prodotti_drop.empty:
      self.df_prod = pd.DataFrame()
    else:
      prodotti_drop = prodotti_drop.reset_index(drop=True)
      self.df_prod = prodotti_drop
    
    df_info, X= super().sim_prod_prepare() #richiamiamo il metodi della classe madre
    sim_model_test  = super().use_model(MNLChoiceModel, LatentChoiceModel)
    P = sim_model_test.weighted_Probabilities(X, available_prod=None) 
    PS = sim_model_test.segment_pChoices(X, available_prod=None) 
    
    return [df_info, pd.DataFrame(P), pd.DataFrame(PS)]
  
#Dinamica dell'arrivo dei clienti in negozio: abbiamo una probabilità per ogni segmento, quindi costruiamo un arrivo con poisson distribuito su una settimana di lavoro.
#Imponiamo 6 giorni lavorativi: una media di arrivi fissata e gli arrivi differenziati per giorni della settimana stabiliti con dei valori moltiplicativi per la media.
#Il rate di arrivo dei clienti lo mettiamo diverso per ogni giorno settimanale, imponendo delle logiche di arrivi tipici
class ClientArrivals:
  """
  questa classe definisce gli arrivi dei clienti e le loro scelte secondo il modello di scelta che vogliamo utilizzare.
  inoltre, presenta anche il flusso delle scorte tenendo conto della domanda primaria e anche della domanda sostitutiva di primo ordine (sempre calcolata tramite probabilità utilizzando il modello di scelta).
  """
  def __init__(self,rate_arrival,seed=None):
    self.rate_arrival=rate_arrival
    self.seed=seed 
    self.rng=np.random.default_rng(seed) #per ripetitività

  def make_arrivals(self,num_scenarios, probabilities):
    """
    questo metodo simula gli arrivi in negoozio e calcola la domanda primaria in uno scenario, considerando uno scenario come una settimana di vendita.
    prende in input il numero totale di scenari che vogliamo simulare (# in questo modo evita di ricalcolare mille volte la p_firstchoice nel main)

    Dove si usa: nel metodo reduced_scenarios, per generare gli arrivi di tutt i gli scenari simulati e nello script function_test_OOS.py per simulare gli arrivi degli scenari di test
    """

    num_days=len(self.rate_arrival)
    num_products=len(probabilities) #contanto anche no_buy option
    demand_scenarios=np.zeros((num_scenarios,num_days,num_products), dtype=int) #prealloco le dimensioni del risultato finale

    for s in range(num_scenarios):
        for d in range(num_days):
            n_arrivals=self.rng.poisson(self.rate_arrival[d])  #genero il numero di clienti giornalieri        
            demand_scenarios[s,d,:]=self.rng.multinomial(n_arrivals,probabilities) #estrae il numero di prodotti acquistati per ogni giorno (nd_array) e lo mette direttamente all'interno della struttura preallocata
    demand_scenarios_weekly=demand_scenarios.sum(axis=1)

    return demand_scenarios,demand_scenarios_weekly

  def _only_arrivals(self, num_scenarios): ##Per estensione futura con modello NN ibrido stocastico
    """
    metodo che definisce solo il numero di arrivi per scenario simulati
    """
    num_days=len(self.rate_arrival)
    n_arrivals=np.zeros((num_scenarios,num_days), dtype=int) #prealloco le dimensioni del risultato finale

    for s in range(num_scenarios):
        for d in range(num_days):
            n_arrivals[s,d]=self.rng.poisson(self.rate_arrival[d])  #genero il numero di clienti giornalieri        
    return n_arrivals
  
  def reduced_scenarios(self,probabilities,n_start,n_target):
    """
    Genera un set ampio di scenari e li riduce usando K-Means.
    Ritorna gli scenari ottimizzati e i relativi pesi (probabilità).

    Dove si usa: in prepare_data.py nella funzione prepare_input_data() per generare gli scenari ridotti da passare al modello SAA
    """
    _, demand_weekly=self.make_arrivals(n_start, probabilities)
      
    kmeans=KMeans(n_clusters=n_target, random_state=self.seed)
    kmeans.fit(demand_weekly)
        
    red_scenarios=kmeans.cluster_centers_.astype(int)

    labels=kmeans.labels_
    pesi=np.bincount(labels)/n_start
       
    return red_scenarios,pesi
  
  def reduced_arrival_scenarios(self,n_start,n_target): ##Per estensione futura con modello NN ibrido stocastico
    """
    Genera un set ampio di scenari di arrivo e li riduce usando K-Means.
    Ritorna gli scenari ottimizzati e i relativi pesi (probabilità).
    """

    arrivals_daily=self._only_arrivals(n_start)

    kmeans=KMeans(n_clusters=n_target, random_state=self.seed)
    kmeans.fit(arrivals_daily)
        
    red_scenarios=kmeans.cluster_centers_.astype(int)
    red_arr_scen=red_scenarios.sum(axis=1)

    labels=kmeans.labels_
    pesi=np.bincount(labels)/n_start
       
    return red_arr_scen,pesi
  

  def _total_demand_flow(self,probabilities, subs_matrix,actual_assortment): #passiamo l'assortimento presente
    """
    simula la domanda primaria e la domanda di sostituzione con diversi tipi di assortimento possibile

    Input: 
      1. probabilità di acquisto per prodotto, 
      2. matrice di sostituzione,
      3. l'assortimento proposto

    Output: probabilità di acquisto per prodotto considerando sostituzione a catalogo

    Dove viene usata: in prepare_data.py nella funzione prepare_arrivals_det

    """
    num_products=len(probabilities) #contanto anche no_buy option
    final_prob=np.zeros(num_products) #prealloco le dimensioni del risultato finale
    final_prob[-1]=probabilities[-1] #prealloco le dimensioni del risultato finale

    for i in range(num_products-1):
        if actual_assortment[i]==1:
            final_prob[i]+=probabilities[i]
        else:
            final_prob+=probabilities[i]*subs_matrix[i]

    return final_prob
   
    
  def make_sales_substitutions(self, demand_scenario, z_assortment, initial_stock, subs_matrix): #gli passo il singolo scenario e valuto vendite e sostituzione in tale 
      """
      metodo che simula come la domanda primaria interagisce con l'assortimento effettivo e come le scorte modificano la domanda in base alla matrice di sostituzione.
      considera uno scenario e prende in considerazione tutte le variabili rilevanti necessarie per esplorare l'ottimizzazione.

      Dove viene usata: nel fine function_test_OOS.py, per valutare come l'assortimento proposto risponde al mercato
      """
      current_stock=np.array(initial_stock, dtype=int)
    
      total_sales=np.zeros(len(z_assortment)) #inizializziamo il vettore di vendite, quindi per ogni prodotto quante vendite faremo
      lost_demand=np.zeros(len(z_assortment)+1) #manteniamo memoria della domanda persa 
      lost_service=0
      # Ciclo sui giorni dello scenario
      for daily_demand in demand_scenario: #ho la domanda giornaliera per ogni prodotto, considerando ogni scenario ---> un 2d.array 
        #andiamo a considerare il singolo prodotto j e la sua domanda associata
          for j, d_j in enumerate(daily_demand[:-1]): # Escludiamo l'indice No-Buy j: prodotto, d_j domanda associata a prodotto
              if d_j==0: continue #se la domanda del prodotto j è nulla, allora non abbiamo niente da valutare

              if z_assortment[j]==1 and current_stock[j] > 0: #se siamo con un prodotto in assortimento e il pezzo c'è in stock (quindi ne abbiamo a sufficienza)
                  sold_prim=np.minimum(d_j, current_stock[j]) #tutto quello che possiamo vendere lo vendiamo e teniamo tracci di cosa dobbiamo fare con la sostituzione
                  current_stock[j]-=sold_prim #aggiorniamo il totale corrente
                  total_sales[j]+=sold_prim #aggiorniamo le vendite totali
                  unmet_j=d_j-sold_prim #la domanda che non abbiamo ancora soddisfatto
              else:
                  unmet_j=d_j # Prodotto non in assortimento o già esaurito

              # 2. Sostituzione 
              if unmet_j>0:
                  # Distribuiamo la domanda insoddisfatta secondo la riga j della matrice
                  sub_dist=self.rng.multinomial(unmet_j, subs_matrix[j]) #guardo la matrice di sostituzione e vedo dove mi manda il modello di scelta probabilisticamente
                  
                  for k, qty_k in enumerate(sub_dist[:-1]): #non considero il no_buy
                  #rifacciamo lo stesso check della domanda primaria e aggiorniamo le variabili di stato
                      if z_assortment[k]==1 and current_stock[k] > 0:
                          sold_sub=np.minimum(qty_k, current_stock[k])
                          current_stock[k]-=sold_sub
                          total_sales[k]+=sold_sub
                          lost_demand[k]+=(qty_k-sold_sub) #Sostituto finito
                          lost_service+=(qty_k-sold_sub)
                      else:
                          lost_demand[k]+=qty_k #Sostituto non in assortimento o No-Buy
                          lost_service+=qty_k
          #Aggiungiamo la No-Buy originale dello scenario
          lost_demand[len(z_assortment)]+=daily_demand[-1]
          
      rest=current_stock-total_sales[-1]

                              
      return total_sales, lost_demand, rest, lost_service #=le vendite totali, la domanda persa dentro il negozio+no_buy a priori, le rimanenze a negozio (suddivisi per prodotto) + domanda persa cumulata all'interno del negozio senza no_buy a priori









