import pandas as pd
import numpy as np
import re
from datetime import datetime

def classify(val,lower,upper):
    if val < lower:
        return 'low'
    elif val > upper:
        return 'high'
    else:
        return 'none'


def create_full_calendar_and_interpolate(
        master_data,
        group_columns,
        variable,
        date_column,
        freq,
        min_records,
        max_records
    ):
    master_data[date_column] = pd.to_datetime(master_data[date_column])
    
    full_group_data = []
    success_metrics = []
    dropped_metrics = []

    for group_key, group in master_data.groupby(group_columns):
        # Capture the raw record count before any processing
        raw_record_count = len(group)
        
        current_group_info = {
            col: group_key[i] if isinstance(group_key, (tuple, list)) else group_key 
            for i, col in enumerate(group_columns)
        }
        
        # 1. Calendar Generation
        min_date, max_date = group[date_column].min(), group[date_column].max()
        full_dates = pd.date_range(start=min_date, end=max_date, freq=freq)
        
        if max_records is not None and len(full_dates) > max_records:
            full_dates = full_dates[-max_records:]

        # 2. Expansion
        calendar_dict = current_group_info.copy()
        calendar_dict[date_column] = full_dates
        full_calendar = pd.DataFrame(calendar_dict)

        # 3. Merge
        merged = full_calendar.merge(group, on=group_columns + [date_column], how="left")
        
        total_len = len(merged)
        interpolated_count = merged[variable].isna().sum()
        interpolation_rate = interpolated_count / total_len if total_len > 0 else 0

        # --- Check 1: Min Records ---
        if raw_record_count < min_records:
            drop_entry = current_group_info.copy()
            drop_entry.update({
                "reason": "Below Min Records",
                "details": f"Total records {raw_record_count} < {min_records}",
                "record_count": raw_record_count,  # Added raw record count
                #"interpolated_records": total_len       # This is the 'interpolated' length
            })
            dropped_metrics.append(drop_entry)
            continue

        # --- Check 2: Max Interpolation Rate ---
        if interpolation_rate > 0.25:
            drop_entry = current_group_info.copy()
            drop_entry.update({
                "reason": "High Interpolation",
                "details": f"{interpolation_rate:.1%} > 25%",
                "record_count": raw_record_count,  # Added raw record count
                #"interpolated_records": total_len       # This is the 'interpolated' length
            })
            dropped_metrics.append(drop_entry)
            continue

        # --- Success: Interpolate ---
        merged["is_missing_record"] = merged[variable].isna()
        merged[variable] = merged[variable].interpolate(method="linear", limit_direction="both")

        success_entry = current_group_info.copy()
        success_entry.update({
            "initial_records": raw_record_count,
            "interpolated_records": interpolated_count,
            "final_records": total_len,
            "interpolation_pct": round(interpolation_rate * 100, 2)
        })
        success_metrics.append(success_entry)
        full_group_data.append(merged)

    # Convert lists of dicts to DataFrames
    final_df = pd.concat(full_group_data, ignore_index=True) if full_group_data else pd.DataFrame()
    success_report = pd.DataFrame(success_metrics)
    exclusion_report = pd.DataFrame(dropped_metrics)
    
    return final_df, success_report, exclusion_report


import re

class Colors:

    BLUE = '\033[94m'

    RED = '\033[91m'

    END = '\033[0m'

    BOLD = '\033[1m'

    BOLD_ITALIC = "\033[1;3m"

    RESET = "\033[0m"


def print_anomaly_stats(df, final_results, success_report, exclusion_report, group_columns, min_records, interpolation_method="linear"):


    # --- 1. DATA PREPARATION & CALCULATIONS ---

    total_records = len(df)

    Evaluated_records = len(final_results)

    total_anomalies = final_results['is_Anomaly'].fillna(False).astype(bool).sum()

    anomaly_rate = (total_anomalies / Evaluated_records) * 100 if total_records > 0 else 0

    num_excluded = len(exclusion_report)

    total_groups = len(success_report) + num_excluded

    evaluated_groups = len(success_report)

    total_interpolated_records = success_report['interpolated_records'].sum() if not success_report.empty else 0

    groups_with_interpolation = success_report[success_report['interpolated_records'] > 0].shape[0] if not success_report.empty else 0
    
    Evaluated_records_rate = (Evaluated_records/total_records*100) if total_records > 0 else 0
    
    def format_interp_val(count, rate):

        return f"{int(count):,} ({rate:.1f}%)"



    if num_excluded > 0:

        high_interp_mask = exclusion_report['reason'] == "High Interpolation"

        min_history_mask = exclusion_report['reason'] == "Below Min Records"

        groups_not_interp = high_interp_mask.sum()

        records_not_interp = exclusion_report.loc[high_interp_mask, 'record_count'].sum()

        groups_less_min = min_history_mask.sum()

        records_less_min = exclusion_report.loc[min_history_mask, 'record_count'].sum()

        records_excluded = exclusion_report.record_count.sum()

    else:

        groups_not_interp = records_not_interp = groups_less_min = records_less_min = records_excluded = 0
        
    Evaluated_records_rate = (Evaluated_records/total_records*100) if total_records > 0 else 0
    
    records_excluded_rate = (records_excluded/total_records*100) if total_records > 0 else 0
    
    records_less_min_rate = (records_less_min/total_records*100) if total_records > 0 else 0
    
    # Calculate rates relative to total_records

    total_missing_records = total_interpolated_records+ records_not_interp

    total_missing_records_rate = (total_missing_records/total_records*100) if total_records > 0 else 0

    total_interpolated_records_rate = (total_interpolated_records / total_records * 100) if total_records > 0 else 0

    records_not_interp_rate = (records_not_interp / total_records * 100) if total_records > 0 else 0
    
    
    # --- 2. PRINTING UTILITIES ---

    def print_header(title):

        # Increased width to 70 to match the wider rows

        print(f"\n{Colors.BLUE}{Colors.BOLD}{'='*70}")

        print(f"{title:^70}")

        print(f"{'='*70}{Colors.END}")



    def print_stats_rows(stats):

        label_width = 45

        value_width = 22

        for label, val in stats:

            visible_val = re.sub(r'\033\[[0-9;]*m', '', str(val))

            padding = " " * (value_width - len(visible_val))

            print(f"{label:<{label_width}} : {padding}{val}")



    # --- 3. EXECUTION SUMMARY ---

    print_header("ANOMALY DETECTION EXECUTIVE SUMMARY")

    exec_stats = [

        ["Total Groups", f"{total_groups:,}"],

        ["Total Records", f"{total_records:,}"],

        ["Evaluated Groups", f"{evaluated_groups:,}"],

        ["Evaluated Records", format_interp_val(Evaluated_records, Evaluated_records_rate)],

        ["Detected Anomalies", f"{Colors.RED}{total_anomalies:,}{Colors.END}"],

        ["Anomaly Rate", f"{Colors.RED}{anomaly_rate:.1f}%{Colors.END}"]

    ]

    print_stats_rows(exec_stats)



    # --- 4. INTERPOLATION REPORT ---

    print_header("INTERPOLATION REPORT")





    # Clean formatting without color codes

    



    interp_stats = [

        ["Interpolation Method", interpolation_method.capitalize()],

        ["Groups with Missing Records", f"{groups_with_interpolation:,}"],

        ["Total Missing Records", format_interp_val(total_missing_records, total_missing_records_rate)],

        ["Successfully Interpolated", format_interp_val(total_interpolated_records, total_interpolated_records_rate)],

        ["Not Interpolated Records", format_interp_val(records_not_interp, records_not_interp_rate)]

    ]

    print_stats_rows(interp_stats)



    # --- 5. EXCLUSION SUMMARY ---

    print_header("EXCLUSION SUMMARY")

    excl_stats = [

        ["Groups Excluded Total", f"{num_excluded:,}"],

        ["Records Excluded Total", format_interp_val(records_excluded, records_excluded_rate)],

        ["Groups Excluded — >25% Records Missing", f"{groups_not_interp:,}"],

        ["Records Excluded — >25% Records Missing", format_interp_val(records_not_interp, records_not_interp_rate)],

        [f"Groups Excluded — <{min_records} Records Available", f"{groups_less_min:,}"],

        [f"Records Excluded — <{min_records} Records Available", format_interp_val(records_less_min, records_less_min_rate)]

    ]

    print_stats_rows(excl_stats)

    

    print(f"\nSee {Colors.BOLD_ITALIC}exclusion_report{Colors.RESET} for full list of {', '.join(group_columns)}s excluded")

    

    #Identify model columns (is_..._anomaly)

    model_cols = [col for col in final_results.columns if col.startswith('is_') and col.endswith('_anomaly') and col != 'is_Anomaly']

    

    #--- MODEL PERFORMANCE REPORT (Dynamic) ---

    print_header("MODEL PERFORMANCE REPORT")

    

    model_perf_data = []

    for col in model_cols:

        # Clean name: 'is_IsolationForest_anomaly' -> 'IsolationForest'

        model_name = col.replace('is_', '').replace('_anomaly', '')

        m_count = final_results[col].sum()

        m_rate = (m_count / Evaluated_records * 100) if Evaluated_records > 0 else 0

        

        # Adding RED color to the rate and the count in parenthesis

        formatted_val = (

            f"{Colors.RED}{m_rate:.1f}%{Colors.END} "

            f"({Colors.RED}{int(m_count):,}{Colors.END} anomalies)"

        )

    
        model_perf_data.append([

            f"{model_name}", 

            formatted_val

        ])

    

    print_stats_rows(model_perf_data)



    # --- 6. TOP ANOMALIES & SAMPLES ---

    print_header(f"TOP 5 GROUPS BY ANOMALY RATE ({' > '.join(group_columns)}):")

    group_stats = final_results.groupby(group_columns)['is_Anomaly'].agg(['mean', 'sum']).sort_values(by='mean', ascending=False).head(5)

    

    for label, row in group_stats.iterrows():

        group_label = label if isinstance(label, (str, int)) else " | ".join(map(str, label))

        rate_str = f"{Colors.RED}{row['mean']*100:>4.1f}%{Colors.END}"

        count_str = f"({Colors.RED}{int(row['sum'])}{Colors.END} anomalies)"

        print(f" - {group_label} : {rate_str} {count_str}")

    

    print(f"\n{Colors.BLUE}{'='*70}{Colors.END}\n")


def calculate_ensemble_scores(df, variable, group_columns, date_column):
    """
    Calculates the normalized consensus score across all anomaly models.
    """
    
    # Identify all columns that are model flags (is_..._anomaly)
    anomaly_flags = [col for col in df.columns if col.startswith('is_') and col.endswith('_anomaly') and col != 'is_Anomaly']
    
    # 1. Total Votes (Count of True)
    df['Anomaly_Votes'] = df[anomaly_flags].sum(axis=1).astype(int)

    # 2. Total Models active for that row (Count of non-NaN values)
    df['Vote_Cnt'] = df[anomaly_flags].notna().sum(axis=1).astype(int)
    
    # 3. Anomaly Votes Score Display (x out of N)
    df['Anomaly_Votes_Display'] = df['Anomaly_Votes'].astype(int).astype(str) + " out of " + df['Vote_Cnt'].astype(int).astype(str)
    
    # 5. Final Boolean Consensus (e.g., majority rule)
    df['is_Anomaly'] = df['Anomaly_Votes'] / df['Vote_Cnt'] >= 0.5
    
    groups = list(df.groupby(group_columns))
    
    
    # 6. Scale all the model scores to be between -1 and 1
    def get_scaled_score(group, score_scaled_col):
        group[score_scaled_col] = 100 * np.where(group[score_scaled_col] < 0,
                                                 -group[score_scaled_col]/group[score_scaled_col].min(),
                                                 group[score_scaled_col]/group[score_scaled_col].max())
        group[score_scaled_col] = np.where(group[score_scaled_col] < 0, group[score_scaled_col] - 1, group[score_scaled_col] + 1)
        group[score_scaled_col] = np.where(group[score_scaled_col] < 0,
                                           -np.log(-group[score_scaled_col]),
                                           np.log(group[score_scaled_col]))
        if len(group[group[score_scaled_col] > 0]) >= 1:
            is_min = group[group[score_scaled_col] > 0][score_scaled_col].min()
            is_max = group[group[score_scaled_col] > 0][score_scaled_col].max()
        else:
            is_min = 1
            is_max = 2
        if len(group[group[score_scaled_col] < 0]) >= 1:
            not_min = group[group[score_scaled_col] < 0][score_scaled_col].min()
            not_max = group[group[score_scaled_col] < 0][score_scaled_col].max()
        else:
            not_min = 1
            not_max = 2
        group[score_scaled_col] = 100 * np.where(group[score_scaled_col] > 0,
                                              0.51 + 0.49 * (group[score_scaled_col] - is_min)/(is_max - is_min),
                                              0.49 * (group[score_scaled_col] - not_min)/(not_max - not_min))
        return group
    
    score_scaled_cols = ['Percentile_score_scaled', 'SD_score_scaled', 'MAD_score_scaled', 'IQR_score_scaled',
                         'EWMA_score_scaled', 'FB_score_scaled', 'IsolationForest_score_scaled', 'DBSCAN_score_scaled']

    df_scores = []
    for group in groups:
        group = group[1]
        try:
            group['Percentile_score_scaled'] = np.where(group['is_Percentile_anomaly'].isna()==False,
                                                     abs(group[variable] - (group['Percentile_high'] + group['Percentile_low'])/2)/((group['Percentile_high'] - group['Percentile_low'])/2) - 1,
                                                     np.nan)
            group = get_scaled_score(group, 'Percentile_score_scaled')
        except:
            group['Percentile_score_scaled'] = np.nan

        try:
            group['SD_score_scaled'] = np.where(group['is_SD_anomaly'].isna()==False,
                                             abs(group[variable] - (group['SD2_high'] + group['SD2_low'])/2)/((group['SD2_high'] - group['SD2_low'])/2) - 1,
                                             np.nan)
            group = get_scaled_score(group, 'SD_score_scaled')
        except:
            group['SD_score_scaled'] = np.nan

        try:
            group['MAD_score_scaled'] = np.where(group['is_MAD_anomaly'].isna()==False,
                                              abs(group[variable] - (group['MAD_high'] + group['MAD_low'])/2)/((group['MAD_high'] - group['MAD_low'])/2) - 1,
                                              np.nan)
            group = get_scaled_score(group, 'MAD_score_scaled')
        except:
            group['MAD_score_scaled'] = np.nan

        try:
            group['IQR_score_scaled'] = np.where(group['is_IQR_anomaly'].isna()==False,
                                              abs(group[variable] - (group['IQR_high'] + group['IQR_low'])/2)/((group['IQR_high'] - group['IQR_low'])/2) - 1,
                                              np.nan)
            group = get_scaled_score(group, 'IQR_score_scaled')
        except:
            group['IQR_score_scaled'] = np.nan

        try:
            group['EWMA_score_scaled'] = np.where(group['is_EWMA_anomaly'].isna()==False,
                                               abs(group[variable] - (group['EWMA_high'] + group['EWMA_low'])/2)/((group['EWMA_high'] - group['EWMA_low'])/2) - 1,
                                               np.nan)
            group = get_scaled_score(group, 'EWMA_score_scaled')
        except:
            group['EWMA_score_scaled'] = np.nan

        try:
            group['FB_score_scaled'] = np.where(group['is_FB_anomaly'].isna()==False,
                                             abs(group[variable] - (group['FB_high'] + group['FB_low'])/2)/((group['FB_high'] - group['FB_low'])/2) - 1,
                                             np.nan)
            group = get_scaled_score(group, 'FB_score_scaled')
        except:
            group['FB_score_scaled'] = np.nan

        try:
            group['IsolationForest_score_scaled'] = np.where(group['is_IsolationForest_anomaly'] == True,
                                                          group['IsolationForest_score'] - group[group['is_IsolationForest_anomaly'] == True]['IsolationForest_score'].min(),
                                                          group['IsolationForest_score'] - group[group['is_IsolationForest_anomaly'] == False]['IsolationForest_score'].min())

            group['IsolationForest_score_scaled'] = 100 * np.where(group['is_IsolationForest_anomaly'] == True,
                                                                0.49*group['IsolationForest_score_scaled']/group[group['is_IsolationForest_anomaly'] == True]['IsolationForest_score_scaled'].max() + 0.51,
                                                                0.49*group['IsolationForest_score_scaled']/group[group['is_IsolationForest_anomaly'] == False]['IsolationForest_score_scaled'].max())
        except:
            group['IsolationForest_score_scaled'] = np.nan

        try:
            group['DBSCAN_score_scaled'] = np.where(group['is_DBSCAN_anomaly'] == True,
                                                 group['DBSCAN_score'] - group[group['is_DBSCAN_anomaly'] == True]['DBSCAN_score'].min(),
                                                 group['DBSCAN_score'] - group[group['is_DBSCAN_anomaly'] == False]['DBSCAN_score'].min())
            group['DBSCAN_score_scaled'] = 100 * np.where(group['is_DBSCAN_anomaly'] == True,
                                                       0.51 + 0.49*group['DBSCAN_score_scaled']/group[group['is_DBSCAN_anomaly'] == True]['DBSCAN_score_scaled'].max(),
                                                       0.49*group['DBSCAN_score_scaled']/group[group['is_DBSCAN_anomaly'] == False]['DBSCAN_score_scaled'].max())
        except:
            group['DBSCAN_score_scaled'] = np.nan
        
        try:
            group['Anomaly_Score'] = group[score_scaled_cols].mean(axis=1)
            
            # Rescale all non anomalies between 0 and 0.5 and anomalies between 0.5 and 1.0
            if len(group[group['is_Anomaly'] == True]) >= 1:
                is_min = group[group['is_Anomaly'] == True]['Anomaly_Score'].min()
                is_max = group[group['is_Anomaly'] == True]['Anomaly_Score'].max()
            else:
                is_min = 0
                is_max = 1
            if len(group[group['is_Anomaly'] == False]) >= 1:
                not_min = group[group['is_Anomaly'] == False]['Anomaly_Score'].min()
                not_max = group[group['is_Anomaly'] == False]['Anomaly_Score'].max()
            else:
                not_min = 0
                not_max = 1

            group['Anomaly_Score'] = (100 * np.where(group['is_Anomaly'] == True,
                                                     0.51 + 0.49 * (group['Anomaly_Score'] - is_min)/(is_max - is_min),
                                                     0.49 * (group['Anomaly_Score'] - not_min)/(not_max - not_min))).astype(int)
            
        except:
            group['Anomaly_Score'] = np.nan

        df_scores.append(group[group_columns + [date_column] + score_scaled_cols + ['Anomaly_Score']])
    
    df_scores = pd.concat(df_scores)
    df = df.merge(df_scores, on=group_columns + [date_column], how='left')
    
    
    # 7. Reposition is_Anomaly column to the end
    df['is_Anomaly'] = df.pop('is_Anomaly')

    return df


def min_records_extraction(freq,eval_period):
    freq_upper = freq.upper()
    
    if freq_upper.startswith('W'):
        annual_count = 52
    elif freq_upper.startswith('D') or freq_upper.startswith('B'):
        annual_count = 365
    elif freq_upper.startswith('M'):
        annual_count = 12
    else:
        # Fallback to weekly if custom/unknown
        annual_count = 52

    # Logic: 1 year for min, 2 years for max
    min_records = annual_count + eval_period
    #max_records = (2 * annual_count) + eval_period
    
    return min_records