import pandas as pd
import numpy as np
import joblib
import json
from sklearn.model_selection import train_test_split
from datetime import datetime

from .config import Config, DataConfig, BinningConfig, SelectionConfig, ModelConfig
from .eda import FeatureAnalyzer
from .binning import BinningProcess
from .selection import FeatureSelector
from .modeling import ModelTrainer
from .evaluation import ModelEvaluator
from .scorecard import ScorecardTransformer
from .reporting import ReportGenerator
from .plot_utils import plot_binning
from .validation import DataValidator

class AutoScore:
    def __init__(self, random_state=42):
        self.random_state = random_state
        self.eda = FeatureAnalyzer()
        self.binning = None
        self.selector = FeatureSelector()
        self.trainer = ModelTrainer(random_state=random_state)
        self.evaluator = ModelEvaluator()
        self.sc_transformer = None
        self.reporter = ReportGenerator()
        self.validator = DataValidator()
        
        # Intermediate results
        self.data_summary = None
        self.iv_df = None
        self.selected_features = []
        self.model_metrics = {}
        self.scorecard_df = None
        
        # Store data splits for reporting
        self.X_train = None
        self.y_train = None
        self.X_test = None
        self.y_test = None
        self.binning_stats = {}
        self.score_stats = {}
        self.dataset_stats = {}

    @staticmethod
    def validate_date_format(date_str, expected_format='yyyy-mm-dd'):
        """
        Validate date format and convert to standard format.
        """
        if pd.isna(date_str):
            raise ValueError(f"Date value is None/NaN. Expected format: {expected_format}")
        
        if not isinstance(date_str, str):
            try:
                date_str = str(date_str)
            except:
                raise ValueError(f"Cannot convert date to string. Expected format: {expected_format}")
        
        # Try to parse the date using pandas
        try:
            parsed_date = pd.to_datetime(date_str, errors='coerce')
            if pd.isna(parsed_date):
                raise ValueError(f"Invalid date format: '{date_str}'. Expected format: {expected_format}")
            # Return in standard format
            return parsed_date.strftime('%Y-%m-%d')
        except Exception as e:
            raise ValueError(f"Date parsing error for '{date_str}': {str(e)}. Expected format: {expected_format}")
    
    @staticmethod
    def convert_date_column(date_series):
        """
        Convert date column to standard 'yyyy-mm-dd' format.
        """
        # Try to convert using pandas to_datetime
        try:
            converted = pd.to_datetime(date_series, errors='coerce')
            
            # Check conversion success rate
            success_rate = converted.notna().mean()
            
            if success_rate < 0.8:
                raise ValueError(f"Date conversion failed for {(1-success_rate)*100:.1f}% of values. Please check date format.")
            
            # Convert to standard string format
            return converted.dt.strftime('%Y-%m-%d')
            
        except Exception as e:
            raise ValueError(f"Failed to convert date column: {str(e)}")
        
    def fit(self, df, 
            target_col='target',
            exclude_cols=None,
            pipeline=None,
            data_config: DataConfig = None,
            binning_config: BinningConfig = None,
            selection_config: SelectionConfig = None,
            model_config: ModelConfig = None,
            **kwargs):
        """
        Execute full scorecard development pipeline with support for intermediate process inputs.
        
        Parameters:
        -----------
        df : pd.DataFrame
            Full dataset containing features and target column
        target_col : str, default='target'
            Name of the target column (0/1, 1 represents bad customer)
        exclude_cols : list, optional
            List of columns to exclude from features (e.g., ['id', 'date_col'])
        pipeline : tuple, optional
            Tuple containing custom transformers, e.g. (Bin_transformer,)
        data_config : DataConfig, optional
            Configuration for data processing and splitting
        binning_config : BinningConfig, optional
            Configuration for binning process
        selection_config : SelectionConfig, optional
            Configuration for feature selection
        model_config : ModelConfig, optional
            Configuration for model training and scorecard generation
        **kwargs : dict
            Legacy arguments for backward compatibility
            
        Returns:
        --------
        dict
            Dictionary containing intermediate results.
        """
        if exclude_cols is None:
            exclude_cols = []
            
        if data_config is None:
            data_config = DataConfig(
                test_size=kwargs.get('test_size', 0.3),
                date_col=kwargs.get('date_col', 'create_time'),
                oot_split_date=kwargs.get('oot_split_date', None),
                target_col=target_col,
                exclude_cols=exclude_cols,
                excluded_features=kwargs.get('excluded_features', []),
                special_values=kwargs.get('special_values', []),
                protected_features=kwargs.get('protected_features', [])
            )
        else:
            if not data_config.target_col:
                data_config.target_col = target_col
            if not data_config.exclude_cols:
                data_config.exclude_cols = exclude_cols
        
        target_col = data_config.target_col
        if target_col not in df.columns:
            raise ValueError(f"Target column '{target_col}' not found in dataframe. Available columns: {df.columns.tolist()}")
        
        cols_to_exclude = set(data_config.exclude_cols)
        cols_to_exclude.add(target_col)
        if data_config.date_col and data_config.date_col in df.columns:
            cols_to_exclude.add(data_config.date_col)
        if data_config.excluded_features:
            cols_to_exclude.update(data_config.excluded_features)
        
        feature_cols = [col for col in df.columns if col not in cols_to_exclude]
        
        X = df[feature_cols].copy()
        y = df[target_col].copy()
        
        print(f"Auto input parsing: target='{target_col}', features={len(feature_cols)} columns")
        print(f"Excluded columns: {list(cols_to_exclude)}")
            
        if binning_config is None:
            binning_config = BinningConfig(
                max_bins=kwargs.get('max_bins', 10),
                min_bin_pct=kwargs.get('min_bin_pct', 0.05),
                monotonicity_constraints=kwargs.get('monotonicity_constraints', None),
                custom_binning_rules=kwargs.get('custom_binning_rules', None),
                final_bins=kwargs.get('final_bins', None)
            )

        if selection_config is None:
            selection_config = SelectionConfig(
                iv_threshold=kwargs.get('iv_threshold', 0.02),
                missing_threshold=kwargs.get('missing_threshold', 0.95),
                single_value_threshold=kwargs.get('single_value_threshold', 0.99),
                vif_threshold=kwargs.get('vif_threshold', 10),
                corr_threshold=kwargs.get('corr_threshold', 0.8),
                selection_method=kwargs.get('selection_method', 'backward_lr'),
                auc_threshold_decrease=kwargs.get('auc_threshold_decrease', 0.005),
                final_features=kwargs.get('final_features', None)
            )

        if model_config is None:
            model_config = ModelConfig(
                pdo=kwargs.get('pdo', 20),
                base_score=kwargs.get('base_score', 600),
                base_odds=kwargs.get('base_odds', 50),
                random_state=kwargs.get('random_state', self.random_state)
            )
            
        self.validator.validate(X, y, date_col=data_config.date_col)

        if isinstance(y, pd.Series):
             y_name = y.name if y.name else 'target'
             temp_df = X.copy()
             temp_df[y_name] = y
             
             temp_df = self.eda.clean_data(temp_df, target_col=y_name, row_missing_threshold=0.9)
             
             y = temp_df[y_name]
             X = temp_df.drop(columns=[y_name])
        else:
             # Just clean X
             X = self.eda.clean_data(X, row_missing_threshold=0.9)

        X, converted_cols = self.eda.detect_and_convert_types(X)
        if converted_cols:
            print(f"Automatically converted to numeric: {converted_cols}")
            
        # 4. Split Data (Train/Test/OOT)
        X_oot, y_oot = None, None
        
        date_col = data_config.date_col
        if date_col in X.columns and data_config.oot_split_date:
            try:
                oot_split_date_std = self.validate_date_format(data_config.oot_split_date)
                print(f"OOT split date validated: {oot_split_date_std}")
                
                print(f"Converting date column '{date_col}' to standard format...")
                X[date_col] = self.convert_date_column(X[date_col])
                print(f"Date column conversion completed successfully")
                
                print(f"Splitting OOT data based on {date_col} > {oot_split_date_std}")
                mask_oot = X[date_col] > oot_split_date_std
                X_oot = X[mask_oot].copy()
                y_oot = y[mask_oot].copy()
                X = X[~mask_oot].copy()
                y = y[~mask_oot].copy()
                
                # Drop date column from modeling
                X_oot = X_oot.drop(columns=[date_col])
                X = X.drop(columns=[date_col])
            except ValueError as e:
                print(f"ERROR processing OOT split: {e}")
                raise
        elif date_col in X.columns:
            # Just convert date column format even without OOT split
            try:
                print(f"Converting date column '{date_col}' to standard format...")
                X[date_col] = self.convert_date_column(X[date_col])
                print(f"Date column conversion completed successfully")
            except ValueError as e:
                print(f"WARNING: {e}")
                print(f"Proceeding without date conversion...")
            X = X.drop(columns=[date_col])
        
        X_train, X_test, y_train, y_test = train_test_split(
            X, y, test_size=data_config.test_size, random_state=self.random_state, stratify=y
        )
        
        # Save datasets for reporting
        self.X_train = X_train
        self.y_train = y_train
        self.X_test = X_test
        self.y_test = y_test
        
        # Collect stats
        self.dataset_stats = {
            'original_total': len(X) + (len(X_oot) if X_oot is not None else 0), # Approx
            'filtered_total': len(X) + (len(X_oot) if X_oot is not None else 0),
            'train': {
                'total': len(X_train),
                'good': (y_train == 0).sum(),
                'bad': (y_train == 1).sum(),
                'bad_rate': (y_train == 1).mean()
            },
            'test': {
                'total': len(X_test),
                'good': (y_test == 0).sum(),
                'bad': (y_test == 1).sum(),
                'bad_rate': (y_test == 1).mean()
            }
        }
        
        if X_oot is not None:
            self.dataset_stats['oot'] = {
                'total': len(X_oot),
                'good': (y_oot == 0).sum(),
                'bad': (y_oot == 1).sum(),
                'bad_rate': (y_oot == 1).mean()
            }
        else:
            self.dataset_stats['oot'] = None
        
        # 5. EDA
        print("Running EDA...")
        self.data_summary = self.eda.analyze(X_train, target=y_train)
        
        # 6. Binning
        print("Running Binning...")
        if pipeline and len(pipeline) > 0:
            print("Using provided binning transformer from pipeline...")
            self.binning = pipeline[0]
        else:
            self.binning = BinningProcess(
                max_bins=binning_config.max_bins, 
                min_bin_pct=binning_config.min_bin_pct, 
                special_values=data_config.special_values
            )
        
        if binning_config.final_bins:
            print(f"Using provided final binning rules ({len(binning_config.final_bins)} features)...")
            print("Skipping automatic binning step.")
            self.binning.set_rules(binning_config.final_bins)
            self.binning.recalculate_stats(X_train, y_train)
        elif binning_config.custom_binning_rules:
            print("Using custom binning rules...")
            self.binning.set_rules(binning_config.custom_binning_rules)
            self.binning.recalculate_stats(X_train, y_train)
        elif self.binning.rules:
             print("Using existing rules in binning transformer...")
             self.binning.recalculate_stats(X_train, y_train)
        else:
            self.binning.fit(X_train, y_train, 
                             monotonicity_constraints=binning_config.monotonicity_constraints, 
                             fillna_value=-999)
        
        self.iv_df = self.binning.iv_df
        
        # Transform to WOE
        X_train_woe = self.binning.transform(X_train)
        X_test_woe = self.binning.transform(X_test)
        X_oot_woe = self.binning.transform(X_oot) if X_oot is not None else None
        
        # 7. Feature Selection
        print("Running Feature Selection...")
        if selection_config.final_features:
             print(f"Using provided final features ({len(selection_config.final_features)})...")
             print("Skipping feature selection step.")
             self.selected_features = selection_config.final_features
             missing_features = [f for f in self.selected_features if f not in X_train_woe.columns]
             if missing_features:
                 raise ValueError(f"Final features not found in data: {missing_features}")
        else:
            print("Using raw training data for feature selection (not WOE-transformed data)...")
            self.selected_features = self.selector.comprehensive_selection(
                X_train, y_train,
                iv_df=self.iv_df,
                iv_threshold=selection_config.iv_threshold,
                missing_threshold=selection_config.missing_threshold,
                single_value_threshold=selection_config.single_value_threshold,
                vif_threshold=selection_config.vif_threshold,
                corr_threshold=selection_config.corr_threshold,
                selection_method=selection_config.selection_method,
                auc_threshold_decrease=selection_config.auc_threshold_decrease,
                protected_features=data_config.protected_features,
                woe_transformer=self.binning
            )

        print(f"Final selected features: {len(self.selected_features)}")
        print(self.selected_features)

        if len(self.selected_features) == 0:
            print("Warning: No features selected after feature selection. Using all available features.")
            self.selected_features = X_train_woe.columns.tolist()
        
        # 8. Model Training
        print("Training Model...")
        self.trainer.train(X_train_woe[self.selected_features], y_train)
        
        # 9. Evaluation
        print("Evaluating Model...")
        self.evaluate_performance(X_train_woe, y_train, X_test_woe, y_test, X_oot_woe, y_oot)
        
        # 10. Scorecard Generation
        print("Generating Scorecard...")
        self.sc_transformer = ScorecardTransformer(
            pdo=model_config.pdo, 
            base_score=model_config.base_score, 
            base_odds=model_config.base_odds
        )
        self.scorecard_df = self.sc_transformer.fit(self.trainer.model, self.binning, self.selected_features)
        
        # Collect stats
        self.binning_stats = self._collect_binning_stats(X_train, y_train, X_test, y_test, X_oot, y_oot)
        self.score_stats = self._collect_score_stats(X_train_woe, y_train, X_test_woe, y_test, X_oot_woe, y_oot)
        
        # Prepare Return Object
        fit_result = {
            'numeric_features': [c for c in X.columns if pd.api.types.is_numeric_dtype(X[c])],
            'categorical_features': [c for c in X.columns if not pd.api.types.is_numeric_dtype(X[c])],
            'selected_features': self.selected_features,
            'monotonicity_constraints': binning_config.monotonicity_constraints,
            'binning_rules': self.binning.rules,
            'bin_transformer': self.binning,
            'woe_transformer': self.binning,
            'model': self.trainer.model,
            'scorecard_transformer': self.sc_transformer,
            'performance': self.model_metrics,
            'data_summary': self.data_summary
        }
        
        return fit_result

    def evaluate_performance(self, X_train_woe, y_train, X_test_woe, y_test, X_oot_woe=None, y_oot=None):
        """
        Evaluate model on Train/Test/OOT.
        """
        # Handle NaN values in WOE data
        X_train_clean = X_train_woe[self.selected_features].fillna(0)
        X_test_clean = X_test_woe[self.selected_features].fillna(0)
        
        # Train metrics
        pred_train = self.trainer.predict_proba(X_train_clean)
        metrics_train = self.evaluator.evaluate(y_train, pred_train)
        
        # Test metrics
        pred_test = self.trainer.predict_proba(X_test_clean)
        metrics_test = self.evaluator.evaluate(y_test, pred_test)
        
        # PSI (Train vs Test)
        psi_test = self.evaluator.calculate_psi(pred_train, pred_test)
        
        self.model_metrics = {
            'train': metrics_train,
            'test': metrics_test,
            'psi_train_test': psi_test
        }
        
        if X_oot_woe is not None:
            X_oot_clean = X_oot_woe[self.selected_features].fillna(0)
            pred_oot = self.trainer.predict_proba(X_oot_clean)
            metrics_oot = self.evaluator.evaluate(y_oot, pred_oot)
            psi_oot = self.evaluator.calculate_psi(pred_train, pred_oot)
            self.model_metrics['oot'] = metrics_oot
            self.model_metrics['psi_train_oot'] = psi_oot
    
    def _collect_binning_stats(self, X_train, y_train, X_test, y_test, X_oot=None, y_oot=None):
        """
        Collect binning statistics for each dataset.
        """
        binning_stats = {}
        
        for dataset_name, X, y in [('train', X_train, y_train), ('test', X_test, y_test)]:
            stats_list = []
            for feat in self.selected_features:
                if feat not in self.binning.rules:
                    continue
                
                rule = self.binning.rules[feat]
                
                if rule['type'] == 'numeric':
                    # Numeric feature binning
                    cutoffs = rule['cutoffs']
                    bins = [-np.inf] + cutoffs + [np.inf]
                    
                    # Assign bins
                    X_copy = X.copy()
                    x_col = pd.to_numeric(X_copy[feat], errors='coerce')
                    bin_indices = pd.cut(x_col, bins=bins, labels=False, include_lowest=True)
                    
                    # Calculate statistics for each bin
                    for bin_idx in range(len(bins) - 1):
                        mask = bin_indices == bin_idx
                        bin_good = (y[mask] == 0).sum()
                        bin_bad = (y[mask] == 1).sum()
                        bin_total = bin_good + bin_bad
                        bin_bad_rate = bin_bad / bin_total if bin_total > 0 else 0
                        
                        stats_list.append({
                            'feature': feat,
                            'dataset': dataset_name,
                            'bin': bin_idx,
                            'bin_range': f'[{bins[bin_idx]}, {bins[bin_idx+1]})',
                            'count': bin_total,
                            'good': bin_good,
                            'bad': bin_bad,
                            'bad_rate': bin_bad_rate
                        })
                else:
                    # Categorical feature binning
                    categories = rule['categories']
                    
                    for bin_idx, bin_vals in enumerate(categories):
                        mask = X[feat].isin(bin_vals)
                        bin_good = (y[mask] == 0).sum()
                        bin_bad = (y[mask] == 1).sum()
                        bin_total = bin_good + bin_bad
                        bin_bad_rate = bin_bad / bin_total if bin_total > 0 else 0
                        
                        stats_list.append({
                            'feature': feat,
                            'dataset': dataset_name,
                            'bin': bin_idx,
                            'bin_range': str(bin_vals),
                            'count': bin_total,
                            'good': bin_good,
                            'bad': bin_bad,
                            'bad_rate': bin_bad_rate
                        })
            
            binning_stats[dataset_name] = pd.DataFrame(stats_list)
        
        # OOT dataset
        if X_oot is not None:
            stats_list = []
            for feat in self.selected_features:
                if feat not in self.binning.rules:
                    continue
                
                rule = self.binning.rules[feat]
                
                if rule['type'] == 'numeric':
                    cutoffs = rule['cutoffs']
                    bins = [-np.inf] + cutoffs + [np.inf]
                    
                    X_copy = X_oot.copy()
                    x_col = pd.to_numeric(X_copy[feat], errors='coerce')
                    bin_indices = pd.cut(x_col, bins=bins, labels=False, include_lowest=True)
                    
                    for bin_idx in range(len(bins) - 1):
                        mask = bin_indices == bin_idx
                        bin_good = (y_oot[mask] == 0).sum()
                        bin_bad = (y_oot[mask] == 1).sum()
                        bin_total = bin_good + bin_bad
                        bin_bad_rate = bin_bad / bin_total if bin_total > 0 else 0
                        
                        stats_list.append({
                            'feature': feat,
                            'dataset': 'oot',
                            'bin': bin_idx,
                            'bin_range': f'[{bins[bin_idx]}, {bins[bin_idx+1]})',
                            'count': bin_total,
                            'good': bin_good,
                            'bad': bin_bad,
                            'bad_rate': bin_bad_rate
                        })
                else:
                    categories = rule['categories']
                    
                    for bin_idx, bin_vals in enumerate(categories):
                        mask = X_oot[feat].isin(bin_vals)
                        bin_good = (y_oot[mask] == 0).sum()
                        bin_bad = (y_oot[mask] == 1).sum()
                        bin_total = bin_good + bin_bad
                        bin_bad_rate = bin_bad / bin_total if bin_total > 0 else 0
                        
                        stats_list.append({
                            'feature': feat,
                            'dataset': 'oot',
                            'bin': bin_idx,
                            'bin_range': str(bin_vals),
                            'count': bin_total,
                            'good': bin_good,
                            'bad': bin_bad,
                            'bad_rate': bin_bad_rate
                        })
            
            binning_stats['oot'] = pd.DataFrame(stats_list)
        
        return binning_stats
    
    def _collect_score_stats(self, X_train_woe, y_train, X_test_woe, y_test, X_oot_woe=None, y_oot=None):
        """
        Collect score distribution statistics for each dataset.
        """
        score_stats = {}
        
        for dataset_name, X_woe, y in [('train', X_train_woe, y_train), ('test', X_test_woe, y_test)]:
            # Calculate scores
            X_sel = X_woe[self.selected_features]
            scores = self.sc_transformer.transform(X_sel, self.selected_features, self.trainer.model)
            
            # Equal frequency binning (20 bins)
            try:
                score_bins = pd.qcut(scores, q=20, labels=False, duplicates='drop')
            except ValueError:
                # If not enough unique values, use cut instead
                score_bins = pd.cut(scores, bins=20, labels=False, include_lowest=True)
            
            # Calculate statistics for each score bin
            stats_list = []
            unique_bins = sorted(np.unique(score_bins)) if isinstance(score_bins, np.ndarray) else sorted(score_bins.unique())
            
            # Overall statistics
            total_count = len(y)
            total_good = (y == 0).sum()
            total_bad = (y == 1).sum()
            overall_bad_rate = total_bad / total_count if total_count > 0 else 0
            
            cum_count = 0
            cum_good = 0
            cum_bad = 0
            
            for bin_idx in unique_bins:
                mask = score_bins == bin_idx
                bin_good = (y[mask] == 0).sum()
                bin_bad = (y[mask] == 1).sum()
                bin_total = bin_good + bin_bad
                bin_bad_rate = bin_bad / bin_total if bin_total > 0 else 0
                
                # Cumulative counts
                cum_count += bin_total
                cum_good += bin_good
                cum_bad += bin_bad
                
                # Cumulative rates (corrected)
                cum_bad_rate = cum_bad / cum_count if cum_count > 0 else 0
                cum_good_rate = cum_good / cum_count if cum_count > 0 else 0
                
                # Percentages
                count_pct = bin_total / total_count if total_count > 0 else 0
                bad_pct = bin_bad / total_bad if total_bad > 0 else 0
                good_pct = bin_good / total_good if total_good > 0 else 0
                cum_count_pct = cum_count / total_count if total_count > 0 else 0
                cum_bad_pct = cum_bad / total_bad if total_bad > 0 else 0
                cum_good_pct = cum_good / total_good if total_good > 0 else 0
                
                # Lift
                lift = bin_bad_rate / overall_bad_rate if overall_bad_rate > 0 else 0
                
                # KS
                ks = abs(cum_bad_pct - cum_good_pct)
                
                # Remaining bad rate
                remain_count = total_count - cum_count
                remain_bad = total_bad - cum_bad
                remain_bad_rate = remain_bad / remain_count if remain_count > 0 else 0
                
                # Score range
                bin_scores = scores[mask]
                min_score = bin_scores.min() if len(bin_scores) > 0 else 0
                max_score = bin_scores.max() if len(bin_scores) > 0 else 0
                score_range_str = f"[{min_score:.0f}-{max_score:.0f})"
                
                stats_list.append({
                    'dataset': dataset_name,
                    'bin': bin_idx,
                    'score_range': score_range_str,
                    'count': bin_total,
                    'good': bin_good,
                    'bad': bin_bad,
                    'bad_rate': bin_bad_rate,
                    'cum_bad_rate': cum_bad_rate,
                    'cum_good_rate': cum_good_rate,
                    'count_pct': count_pct,
                    'bad_pct': bad_pct,
                    'good_pct': good_pct,
                    'cum_count_pct': cum_count_pct,
                    'cum_bad_pct': cum_bad_pct,
                    'cum_good_pct': cum_good_pct,
                    'lift': lift,
                    'ks': ks,
                    'remain_bad_rate': remain_bad_rate
                })
            
            score_stats[dataset_name] = pd.DataFrame(stats_list)
        
        # OOT dataset
        if X_oot_woe is not None:
            X_sel = X_oot_woe[self.selected_features]
            scores = self.sc_transformer.transform(X_sel, self.selected_features, self.trainer.model)
            
            # Equal frequency binning (20 bins)
            try:
                score_bins = pd.qcut(scores, q=20, labels=False, duplicates='drop')
            except ValueError:
                score_bins = pd.cut(scores, bins=20, labels=False, include_lowest=True)
            
            # Calculate statistics for each score bin
            stats_list = []
            unique_bins = sorted(np.unique(score_bins)) if isinstance(score_bins, np.ndarray) else sorted(score_bins.unique())
            
            # Overall statistics
            total_count = len(y_oot)
            total_good = (y_oot == 0).sum()
            total_bad = (y_oot == 1).sum()
            overall_bad_rate = total_bad / total_count if total_count > 0 else 0
            
            cum_count = 0
            cum_good = 0
            cum_bad = 0
            
            for bin_idx in unique_bins:
                mask = score_bins == bin_idx
                bin_good = (y_oot[mask] == 0).sum()
                bin_bad = (y_oot[mask] == 1).sum()
                bin_total = bin_good + bin_bad
                bin_bad_rate = bin_bad / bin_total if bin_total > 0 else 0
                
                # Cumulative counts
                cum_count += bin_total
                cum_good += bin_good
                cum_bad += bin_bad
                
                # Cumulative rates (corrected)
                cum_bad_rate = cum_bad / cum_count if cum_count > 0 else 0
                cum_good_rate = cum_good / cum_count if cum_count > 0 else 0
                
                # Percentages
                count_pct = bin_total / total_count if total_count > 0 else 0
                bad_pct = bin_bad / total_bad if total_bad > 0 else 0
                good_pct = bin_good / total_good if total_good > 0 else 0
                cum_count_pct = cum_count / total_count if total_count > 0 else 0
                cum_bad_pct = cum_bad / total_bad if total_bad > 0 else 0
                cum_good_pct = cum_good / total_good if total_good > 0 else 0
                
                # Lift
                lift = bin_bad_rate / overall_bad_rate if overall_bad_rate > 0 else 0
                
                # KS
                ks = abs(cum_bad_pct - cum_good_pct)
                
                # Remaining bad rate
                remain_count = total_count - cum_count
                remain_bad = total_bad - cum_bad
                remain_bad_rate = remain_bad / remain_count if remain_count > 0 else 0
                
                # Score range
                bin_scores = scores[mask]
                min_score = bin_scores.min() if len(bin_scores) > 0 else 0
                max_score = bin_scores.max() if len(bin_scores) > 0 else 0
                score_range_str = f"{min_score:.0f}-{max_score:.0f}"
                
                stats_list.append({
                    'dataset': 'oot',
                    'bin': bin_idx,
                    'score_range': score_range_str,
                    'count': bin_total,
                    'good': bin_good,
                    'bad': bin_bad,
                    'bad_rate': bin_bad_rate,
                    'cum_bad_rate': cum_bad_rate,
                    'cum_good_rate': cum_good_rate,
                    'count_pct': count_pct,
                    'bad_pct': bad_pct,
                    'good_pct': good_pct,
                    'cum_count_pct': cum_count_pct,
                    'cum_bad_pct': cum_bad_pct,
                    'cum_good_pct': cum_good_pct,
                    'lift': lift,
                    'ks': ks,
                    'remain_bad_rate': remain_bad_rate
                })
            
            score_stats['oot'] = pd.DataFrame(stats_list)
        
        return score_stats
    
    def _calculate_feature_metrics(self, X_train, y_train, X_test, y_test, feature):
        """
        Calculate PSI, AUC, KS for a single feature.
        """
        from sklearn.metrics import roc_auc_score, roc_curve
        
        metrics = {'PSI': np.nan, 'AUC': np.nan, 'KS': np.nan}
        
        try:
            # Determine feature type
            is_numeric = pd.api.types.is_numeric_dtype(X_train[feature])
            
            # PSI calculation
            if is_numeric:
                # For numeric features, use binning approach
                bins = np.linspace(X_train[feature].min(), X_train[feature].max(), 11)
                bins[0] -= 0.001
                bins[-1] += 0.001
                
                train_dist = pd.cut(X_train[feature], bins=bins, include_lowest=True).value_counts(normalize=True)
                test_dist = pd.cut(X_test[feature], bins=bins, include_lowest=True).value_counts(normalize=True)
                
                # Align distributions
                all_bins = sorted(set(train_dist.index) | set(test_dist.index))
                train_aligned = [train_dist.get(b, 0.0001) for b in all_bins]
                test_aligned = [test_dist.get(b, 0.0001) for b in all_bins]
                
                # Calculate PSI (similar to IV calculation to avoid inf values)
                psi = 0
                for i in range(len(all_bins)):
                    if train_aligned[i] > 0 and test_aligned[i] > 0:
                        psi += (train_aligned[i] - test_aligned[i]) * np.log(train_aligned[i] / test_aligned[i])
                metrics['PSI'] = psi
            else:
                # For categorical features, use category distribution approach
                psi = self.evaluator.calculate_psi(
                    X_train[feature], X_test[feature], 
                    buckets=10, 
                    feature_type='categorical'
                )
                metrics['PSI'] = psi
            
            # AUC calculation
            try:
                auc = roc_auc_score(y_test, X_test[feature])
                metrics['AUC'] = auc
            except:
                pass
            
            # KS calculation
            try:
                fpr, tpr, thresholds = roc_curve(y_test, X_test[feature])
                ks = max(tpr - fpr)
                metrics['KS'] = ks
            except:
                pass
        except Exception as e:
            pass
        
        return metrics
    
    def _calculate_feature_correlation(self, X, selected_features):
        """
        Calculate feature correlation matrix.
        """
        # Filter numeric features only
        numeric_features = [f for f in selected_features if pd.api.types.is_numeric_dtype(X[f])]
        
        if len(numeric_features) == 0:
            return pd.DataFrame()
        
        # Calculate correlation matrix
        corr_matrix = X[numeric_features].corr()
        
        return corr_matrix

    def update_model_weights(self, coef_dict, intercept=None):
        """
        Manually update model weights and re-evaluate.
        coef_dict: {feature: weight}
        """
        if self.trainer.model is None:
            print("Model not trained yet.")
            return
            
        # Get estimator
        estimator = self.trainer.model.best_estimator_ if hasattr(self.trainer.model, 'best_estimator_') else self.trainer.model
        
        # Update coefficients
        # Assuming coef_dict contains all selected features
        new_coefs = []
        for feat in self.selected_features:
            if feat in coef_dict:
                new_coefs.append(coef_dict[feat])
            else:
                # Keep original? Or error?
                # Let's try to find index
                idx = self.selected_features.index(feat)
                new_coefs.append(estimator.coef_[0][idx])
                
        estimator.coef_ = np.array([new_coefs])
        
        if intercept is not None:
            estimator.intercept_ = np.array([intercept])
            
        print("Model weights updated.")
        # Note: Should re-run evaluation and scorecard generation ideally
        # But user needs to provide data for evaluation. 
        # So usually this is done before final save.
        
    def adjust_rules(self, new_rules, X_train=None, y_train=None):
        """
        Manually adjust binning rules and visualize.
        """
        if self.binning is None:
            print("Binning process not initialized. Run fit() first or provide custom rules.")
            return
            
        print("Adjusting rules...")
        # Update specific rules
        for col, rule in new_rules.items():
            if col in self.binning.rules:
                # Assuming user provides 'cutoffs' or 'categories' directly
                if 'cutoffs' in rule:
                    self.binning.rules[col]['cutoffs'] = rule['cutoffs']
                if 'categories' in rule:
                    self.binning.rules[col]['categories'] = rule['categories']
            else:
                print(f"Warning: {col} not found in existing rules.")
                
        # Recalculate stats if data provided (highly recommended)
        if X_train is not None and y_train is not None:
            self.binning.recalculate_stats(X_train, y_train)
            self.iv_df = self.binning.iv_df
            
    def visualize_binning(self, feature_names, data_dict=None, filename=None, show=True):
        """
        Visualize binning for one or more features.
        
        Parameters:
        -----------
        feature_names : str or list
            Single feature name or list of feature names to visualize
        data_dict : dict, optional
            Dictionary of datasets to compare, e.g. {'Train': (X_train, y_train)}
        filename : str, optional
            If provided, save plots to files. For multiple features, 
            filename should be a directory path or use {feature} placeholder
        show : bool
            Whether to display the plot
        """
        if isinstance(feature_names, str):
            feature_names = [feature_names]
            
        for feat in feature_names:
            if self.binning is None or feat not in self.binning.rules:
                print(f"No binning info for {feat}")
                continue
            
            feat_filename = None
            if filename:
                if '{feature}' in filename:
                    feat_filename = filename.format(feature=feat)
                else:
                    base, ext = filename.rsplit('.', 1) if '.' in filename else (filename, 'png')
                    feat_filename = f"{base}_{feat}.{ext}"
            
            if hasattr(self.binning, 'visualize_binning'):
                self.binning.visualize_binning(feat, data_dict, show, feat_filename)
            else:
                stats = self.binning.rules[feat]['stats']
                plot_binning(stats, feat, show=show, filename=feat_filename)

    def save_model(self, filepath):
        """Save the entire AutoScore object."""
        joblib.dump(self, filepath)
        
    def load_model(self, filepath):
        """Load AutoScore object."""
        return joblib.load(filepath)
    
    def export_lightweight(self, output_path):
        """
        Export lightweight model for deployment without autoscore dependency.
        """
        if self.trainer.model is None:
            raise ValueError("Model not trained yet. Please call fit() first.")
        
        if self.binning is None:
            raise ValueError("Binning not initialized. Please call fit() first.")
        
        if self.sc_transformer is None:
            raise ValueError("Scorecard not generated. Please call fit() first.")
        
        # 1. Extract model coefficients and intercept
        model = self.trainer.model
        if hasattr(model, 'best_estimator_'):
            estimator = model.best_estimator_
        else:
            estimator = model
        
        coefs = [float(c) for c in estimator.coef_[0]]
        intercept = float(estimator.intercept_[0])
        
        # 2. Extract binning rules
        binning_rules = {}
        for feat, rule in self.binning.rules.items():
            # Convert numpy types to Python native types
            woe_map_converted = {}
            for k, v in rule['woe_map'].items():
                woe_map_converted[str(k)] = float(v)
            
            stats_converted = []
            for stat in rule['stats']:
                # Handle both numeric and categorical features
                if rule['type'] == 'numeric':
                    min_val = float(stat.get('min', stat.get('lower', 0)))
                    max_val = float(stat.get('max', stat.get('upper', 0)))
                else:
                    # For categorical features, min/max are lists of categories
                    min_val = stat.get('min', stat.get('lower', []))
                    max_val = stat.get('max', stat.get('upper', []))
                
                stat_converted = {
                    'min': min_val,
                    'max': max_val,
                    'good': int(stat['good']),
                    'bad': int(stat['bad']),
                    'woe': float(stat['woe']),
                    'iv': float(stat.get('iv', 0))
                }
                stats_converted.append(stat_converted)
            
            binning_rules[feat] = {
                'type': rule['type'],
                'cutoffs': [float(c) for c in rule.get('cutoffs', [])],
                'categories': rule.get('categories', []),
                'woe_map': woe_map_converted,
                'stats': stats_converted
            }
        
        # 3. Extract scoring parameters
        scoring_params = {
            'pdo': int(self.sc_transformer.pdo),
            'base_score': int(self.sc_transformer.base_score),
            'base_odds': int(self.sc_transformer.base_odds),
            'factor': float(self.sc_transformer.factor),
            'offset': float(self.sc_transformer.offset),
            'selected_features': self.selected_features
        }
        
        # 4. Prepare model data
        model_data = {
            'metadata': {
                'version': '1.0.0',
                'created_at': pd.Timestamp.now().strftime('%Y-%m-%d %H:%M:%S'),
                'description': 'Lightweight model exported from AutoScore'
            },
            'model': {
                'coefs': coefs,
                'intercept': intercept,
                'n_features': len(self.selected_features)
            },
            'binning_rules': binning_rules,
            'scoring_params': scoring_params
        }
        
        # 5. Save to JSON file
        with open(output_path, 'w', encoding='utf-8') as f:
            json.dump(model_data, f, indent=2, ensure_ascii=False)
        
        print(f"Lightweight model exported to: {output_path}")
        print(f"Model info:")
        print(f"  - Version: {model_data['metadata']['version']}")
        print(f"  - Features: {len(self.selected_features)}")
        print(f"  - PDO: {scoring_params['pdo']}")
        print(f"  - Base Score: {scoring_params['base_score']}")
        print(f"  - Base Odds: {scoring_params['base_odds']}")
        
        return output_path
    
    def online_fit(self, X_new, y_new, retain_binning=True, retain_features=True):
        """
        Online learning: Update model with new data.
        """
        if self.trainer.model is None:
            raise ValueError("Model not trained yet. Please call fit() first.")
        
        if self.binning is None:
            raise ValueError("Binning not initialized. Please call fit() first.")
        
        print("Starting online learning...")
        
        if retain_binning:
            print("Retaining existing binning rules...")
            # Transform new data using existing binning rules
            X_new_woe = self.binning.transform(X_new)
            
            # Fill any remaining NaN values with 0 (for missing bins)
            X_new_woe = X_new_woe.fillna(0)
            
            if retain_features:
                print("Retaining existing feature selection...")
                # Use existing features
                X_new_sel = X_new_woe[self.selected_features]
            else:
                print("Re-running feature selection...")
                # Re-select features
                features_iv = self.selector.filter_by_iv(self.iv_df, threshold=0.02)
                self.selected_features = self.selector.stepwise_selection(
                    X_new_woe[features_iv], y_new, method='forward'
                )
                X_new_sel = X_new_woe[self.selected_features]
            
            # Incremental learning using partial_fit
            print("Updating model coefficients using partial_fit...")
            self.trainer.partial_fit(X_new_sel, y_new, classes=np.array([0, 1]))
            
            # Re-evaluate model
            print("Re-evaluating model...")
            pred_new = self.trainer.predict_proba(X_new_sel)
            metrics_new = self.evaluator.evaluate(y_new, pred_new)
            
            # Update performance metrics
            self.model_metrics['online'] = metrics_new
            
            # Regenerate scorecard with updated coefficients
            print("Regenerating scorecard...")
            self.scorecard_df = self.sc_transformer.fit(self.trainer.model, self.binning, self.selected_features)
            
        else:
            print("Re-running full pipeline with new data...")
            # Re-run full pipeline (this is slower but more comprehensive)
            # This requires access to original data, which we don't have
            # So we'll just update the model with new data
            print("Warning: Full pipeline re-run requires original data.")
            print("Falling back to incremental learning with new binning...")
            
            # Fit new binning on new data
            new_binning = BinningProcess(
                max_bins=self.binning.max_bins,
                min_bin_pct=self.binning.min_bin_pct,
                special_values=self.binning.special_values
            )
            new_binning.fit(X_new, y_new)
            
            # Transform and select features
            X_new_woe = new_binning.transform(X_new)
            features_iv = self.selector.filter_by_iv(new_binning.iv_df, threshold=0.02)
            self.selected_features = self.selector.stepwise_selection(
                X_new_woe[features_iv], y_new, method='forward'
            )
            X_new_sel = X_new_woe[self.selected_features]
            
            # Train new model
            print("Training new model...")
            self.trainer.train(X_new_sel, y_new)
            
            # Update binning and scorecard
            self.binning = new_binning
            self.iv_df = new_binning.iv_df
            
            # Re-evaluate
            pred_new = self.trainer.predict_proba(X_new_sel)
            metrics_new = self.evaluator.evaluate(y_new, pred_new)
            self.model_metrics['online'] = metrics_new
            
            # Regenerate scorecard
            print("Regenerating scorecard...")
            self.scorecard_df = self.sc_transformer.fit(self.trainer.model, self.binning, self.selected_features)
        
        print(f"Online learning completed.")
        print(f"New performance: AUC={metrics_new['auc']:.4f}, KS={metrics_new['ks']:.4f}")
        
        return self.model_metrics
          
    def predict_proba(self, X):
        X_woe = self.binning.transform(X)
        return self.trainer.predict_proba(X_woe[self.selected_features])
        
    def predict_score(self, X):
        X_woe = self.binning.transform(X)
        return self.sc_transformer.transform(X_woe, self.selected_features, self.trainer.model)
        
    def create_report(self, filename='scorecard_report.xlsx'):
        """
        Export all results to Excel.
        """
        # 1. 数据情况
        data_situation_list = [
            {'Dataset': '原始总样本数', 'Total': self.dataset_stats['original_total']},
            {'Dataset': '样本筛选后最终总样本数', 'Total': self.dataset_stats['filtered_total']},
            {'Dataset': '训练集', 'Total': self.dataset_stats['train']['total'], 
             'Good': self.dataset_stats['train']['good'], 'Bad': self.dataset_stats['train']['bad'], 
             'Bad Rate': f"{self.dataset_stats['train']['bad_rate']:.2%}"},
            {'Dataset': '测试集', 'Total': self.dataset_stats['test']['total'], 
             'Good': self.dataset_stats['test']['good'], 'Bad': self.dataset_stats['test']['bad'], 
             'Bad Rate': f"{self.dataset_stats['test']['bad_rate']:.2%}"}
        ]
        
        if self.dataset_stats['oot'] is not None:
            data_situation_list.append({
                'Dataset': 'OOT集', 'Total': self.dataset_stats['oot']['total'], 
                 'Good': self.dataset_stats['oot']['good'], 'Bad': self.dataset_stats['oot']['bad'], 
                 'Bad Rate': f"{self.dataset_stats['oot']['bad_rate']:.2%}"})
        
        data_situation_df = pd.DataFrame(data_situation_list)
        
        # 2. Summary - 数据集基本信息（合并IV Analysis）
        summary_df = self.data_summary.copy()
        
        # 合并IV值
        if 'iv' in self.iv_df.columns:
            iv_dict = dict(zip(self.iv_df['variable'], self.iv_df['iv']))
            summary_df['IV'] = summary_df['variable'].map(iv_dict)
        
        # 计算特征性能指标（PSI、AUC、KS）
        feature_psi = {}
        feature_auc = {}
        feature_ks = {}
        
        # 计算所有特征的指标
        all_features = self.data_summary['variable'].tolist()
        for feat in all_features:
            if feat in self.X_train.columns and feat in self.X_test.columns:
                try:
                    metrics = self._calculate_feature_metrics(
                        self.X_train, self.y_train, 
                        self.X_test, self.y_test, 
                        feat
                    )
                    feature_psi[feat] = metrics['PSI']
                    feature_auc[feat] = metrics['AUC']
                    feature_ks[feat] = metrics['KS']
                except:
                    pass
        
        # 合并特征性能指标到summary
        summary_df['PSI'] = summary_df['variable'].map(feature_psi)
        summary_df['AUC'] = summary_df['variable'].map(feature_auc)
        summary_df['KS'] = summary_df['variable'].map(feature_ks)
        
        # 3. Selected Features - 特征选择过程
        # 原始特征清单
        original_features = self.data_summary['variable'].tolist()
        
        # 特征选择过程详情
        selection_process_df = pd.DataFrame(self.selector.selection_process)
        
        # 最终选择的特征清单
        selected_features_df = pd.DataFrame({
            'Feature': self.selected_features,
            'Type': ['Selected'] * len(self.selected_features)
        })
        
        # 4. Model Performance - 更直观的展示
        metrics_list = [
            {'Dataset': '训练集', 'AUC': f"{self.model_metrics['train']['auc']:.4f}", 
             'KS': f"{self.model_metrics['train']['ks']:.4f}"},
            {'Dataset': '测试集', 'AUC': f"{self.model_metrics['test']['auc']:.4f}", 
             'KS': f"{self.model_metrics['test']['ks']:.4f}", 
             'PSI_vs_Train': f"{self.model_metrics['psi_train_test']:.4f}"}
        ]
        
        if 'oot' in self.model_metrics:
            metrics_list.append({
                'Dataset': 'OOT集', 'AUC': f"{self.model_metrics['oot']['auc']:.4f}", 
                 'KS': f"{self.model_metrics['oot']['ks']:.4f}", 
                 'PSI_vs_Train': f"{self.model_metrics['psi_train_oot']:.4f}"
            })
        
        model_performance_df = pd.DataFrame(metrics_list)
        
        # 5. Model Coefficients
        model = self.trainer.model.best_estimator_ if hasattr(self.trainer.model, 'best_estimator_') else self.trainer.model
        coef_df = pd.DataFrame({
            'Feature': ['Intercept'] + self.selected_features,
            'Coefficient': [model.intercept_[0]] + list(model.coef_[0])
        })
        
        # 6. Scorecard - 各数据集的分箱分布
        scorecard_detailed = []
        
        for dataset_name in ['train', 'test']:
            if dataset_name not in self.binning_stats:
                continue
            
            dataset_stats = self.binning_stats[dataset_name]
            
            for feat in self.selected_features:
                feat_stats = dataset_stats[dataset_stats['feature'] == feat]
                
                for _, row in feat_stats.iterrows():
                    scorecard_detailed.append({
                        'Feature': feat,
                        'Dataset': dataset_name,
                        'Bin': row['bin'],
                        'Bin_Range': row['bin_range'],
                        'Count': row['count'],
                        'Good': row['good'],
                        'Bad': row['bad'],
                        'Bad_Rate': f"{row['bad_rate']:.2%}"
                    })
        
        if 'oot' in self.binning_stats:
            dataset_stats = self.binning_stats['oot']
            
            for feat in self.selected_features:
                feat_stats = dataset_stats[dataset_stats['feature'] == feat]
                
                for _, row in feat_stats.iterrows():
                    scorecard_detailed.append({
                        'Feature': feat,
                        'Dataset': 'oot',
                        'Bin': row['bin'],
                        'Bin_Range': row['bin_range'],
                        'Count': row['count'],
                        'Good': row['good'],
                        'Bad': row['bad'],
                        'Bad_Rate': f"{row['bad_rate']:.2%}"
                    })
        
        scorecard_detailed_df = pd.DataFrame(scorecard_detailed)
        
        # 7. Score Bin - 评分等频20分箱分布
        score_bin_detailed = []
        
        for dataset_name in ['train', 'test']:
            if dataset_name not in self.score_stats:
                continue
            
            dataset_stats = self.score_stats[dataset_name]
            
            for _, row in dataset_stats.iterrows():
                score_bin_detailed.append({
                    'Dataset': dataset_name,
                    'Bin': row['bin'],
                    'Score_Range': row['score_range'],
                    'Count': row['count'],
                    'Good': row['good'],
                    'Bad': row['bad'],
                    'Bad_Rate': f"{row['bad_rate']:.2%}",
                    'Cum_Bad_Rate': f"{row['cum_bad_rate']:.2%}",
                    'Cum_Good_Rate': f"{row['cum_good_rate']:.2%}",
                    'Count_Pct': f"{row['count_pct']:.2%}",
                    'Bad_Pct': f"{row['bad_pct']:.2%}",
                    'Good_Pct': f"{row['good_pct']:.2%}",
                    'Cum_Count_Pct': f"{row['cum_count_pct']:.2%}",
                    'Cum_Bad_Pct': f"{row['cum_bad_pct']:.2%}",
                    'Cum_Good_Pct': f"{row['cum_good_pct']:.2%}",
                    'Lift': f"{row['lift']:.2f}",
                    'KS': f"{row['ks']:.4f}",
                    'Remain_Bad_Rate': f"{row['remain_bad_rate']:.2%}"
                })
        
        if 'oot' in self.score_stats:
            dataset_stats = self.score_stats['oot']
            
            for _, row in dataset_stats.iterrows():
                score_bin_detailed.append({
                    'Dataset': 'oot',
                    'Bin': row['bin'],
                    'Score_Range': row['score_range'],
                    'Count': row['count'],
                    'Good': row['good'],
                    'Bad': row['bad'],
                    'Bad_Rate': f"{row['bad_rate']:.2%}",
                    'Cum_Bad_Rate': f"{row['cum_bad_rate']:.2%}",
                    'Cum_Good_Rate': f"{row['cum_good_rate']:.2%}",
                    'Count_Pct': f"{row['count_pct']:.2%}",
                    'Bad_Pct': f"{row['bad_pct']:.2%}",
                    'Good_Pct': f"{row['good_pct']:.2%}",
                    'Cum_Count_Pct': f"{row['cum_count_pct']:.2%}",
                    'Cum_Bad_Pct': f"{row['cum_bad_pct']:.2%}",
                    'Cum_Good_Pct': f"{row['cum_good_pct']:.2%}",
                    'Lift': f"{row['lift']:.2f}",
                    'KS': f"{row['ks']:.4f}",
                    'Remain_Bad_Rate': f"{row['remain_bad_rate']:.2%}"
                })
        
        score_bin_detailed_df = pd.DataFrame(score_bin_detailed)
        
        # 8. 特征相关性矩阵
        feature_corr_df = self._calculate_feature_correlation(self.X_train, self.data_summary['variable'].tolist())
        
        # 9. 原始特征清单
        original_features_df = pd.DataFrame({
            'Feature': original_features,
            'Type': ['Original'] * len(original_features)
        })
        
        data_dict = {
            '数据情况': data_situation_df,
            'Summary': summary_df,
            'feature_corr': feature_corr_df,
            '原始特征清单': original_features_df,
            '特征选择过程': selection_process_df,
            'Selected Features': selected_features_df,
            'Model Coefficients': coef_df,
            'Model Performance': model_performance_df,
            'Scorecard': scorecard_detailed_df,
            'Score Bin': score_bin_detailed_df
        }
        
        
        return self.reporter.generate(filename, data_dict)
    
    def _get_rating(self, auc):
        """
        Get rating based on AUC value.
        """
        if auc >= 0.8:
            return '优秀'
        elif auc >= 0.75:
            return '良好'
        elif auc >= 0.7:
            return '中等'
        elif auc >= 0.65:
            return '一般'
        else:
            return '较差'
    
    def _get_psi_rating(self, psi):
        """
        Get PSI rating based on PSI value.
        """
        if psi < 0.1:
            return '稳定'
        elif psi < 0.2:
            return '轻微变化'
        elif psi < 0.25:
            return '中等变化'
        else:
            return '显著变化'
